Comptime em Zig: Metaprogramação Sem Macros

Se você já programou em C, provavelmente já sofreu com macros do preprocessador. Se veio do C++, sabe que templates podem se tornar um pesadelo de mensagens de erro. E se usou Rust, conhece o poder — e a complexidade — de proc macros. A linguagem Zig resolve esses problemas com uma abordagem única: o comptime.

O comptime (abreviação de compile-time) é o mecanismo que permite executar código Zig arbitrário durante a compilação. Não é uma linguagem separada, não é um preprocessador, não é um sistema de templates. É o mesmo Zig que você já conhece, rodando antes do programa estar pronto.

O que é Comptime?

Em Zig, a palavra-chave comptime marca expressões, variáveis e parâmetros que devem ser resolvidos em tempo de compilação. Diferente de constexpr em C++ ou const fn em Rust, o comptime de Zig suporta loops, condicionais, alocação de memória estática e até manipulação de tipos — tudo no mesmo código que você escreveria para runtime.

const std = @import("std");

fn fibonacci(comptime n: u32) u32 {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// O resultado é calculado na compilação, não na execução
const fib_10 = fibonacci(10); // = 55

Nesse exemplo, fibonacci(10) é computado inteiramente pelo compilador. O binário final contém apenas o valor 55 — sem nenhum cálculo em runtime.

Comptime vs Runtime: Quando Usar Cada Um

A distinção entre comptime e runtime é fundamental em Zig. Variáveis comptime não existem no binário final — elas são resolvidas e substituídas durante a compilação.

fn processarDados(comptime T: type, dados: []const T) T {
    // T é resolvido em comptime — o compilador gera código
    // específico para cada tipo usado
    var soma: T = 0;
    for (dados) |valor| {
        soma += valor;
    }
    return soma;
}

test "soma genérica" {
    const inteiros = [_]i32{ 1, 2, 3, 4, 5 };
    const floats = [_]f64{ 1.5, 2.5, 3.5 };

    try std.testing.expectEqual(@as(i32, 15), processarDados(i32, &inteiros));
    try std.testing.expectEqual(@as(f64, 7.5), processarDados(f64, &floats));
}

O compilador gera duas versões de processarDados — uma para i32 e outra para f64. Isso é semelhante à monomorphization de Rust, mas com uma sintaxe muito mais direta. Para entender melhor os tipos em Zig, confira o glossário sobre anytype.

Funções Comptime: O Coração da Metaprogramação

Funções em Zig podem receber parâmetros comptime e retornar tipos. Isso permite criar abstrações poderosas sem sacrificar performance ou legibilidade.

Geração de Tipos em Tempo de Compilação

Um dos usos mais poderosos do comptime é criar novos tipos dinamicamente:

fn VetorFixo(comptime T: type, comptime tamanho: usize) type {
    return struct {
        dados: [tamanho]T,
        len: usize,

        const Self = @This();

        pub fn init() Self {
            return .{
                .dados = undefined,
                .len = 0,
            };
        }

        pub fn adicionar(self: *Self, valor: T) !void {
            if (self.len >= tamanho) return error.Cheio;
            self.dados[self.len] = valor;
            self.len += 1;
        }

        pub fn obter(self: *const Self, indice: usize) ?T {
            if (indice >= self.len) return null;
            return self.dados[indice];
        }
    };
}

test "vetor fixo" {
    var vec = VetorFixo(u32, 10).init();
    try vec.adicionar(42);
    try vec.adicionar(99);
    try std.testing.expectEqual(@as(?u32, 42), vec.obter(0));
}

A função VetorFixo retorna um tipo. Ela é executada em tempo de compilação e gera uma struct completa com métodos. VetorFixo(u32, 10) e VetorFixo(f64, 100) são tipos completamente diferentes, otimizados individualmente pelo compilador.

Validação em Tempo de Compilação

O comptime permite validar restrições antes do programa sequer rodar:

fn MatrizQuadrada(comptime N: usize) type {
    if (N == 0) @compileError("Matriz não pode ter dimensão zero");
    if (N > 1024) @compileError("Dimensão máxima é 1024");

    return struct {
        dados: [N][N]f64,

        pub fn identidade() @This() {
            var m: @This() = .{ .dados = std.mem.zeroes([N][N]f64) };
            for (0..N) |i| {
                m.dados[i][i] = 1.0;
            }
            return m;
        }
    };
}

// Isso compila normalmente:
const Mat4 = MatrizQuadrada(4);

// Isso gera erro de compilação com mensagem clara:
// const MatInvalida = MatrizQuadrada(0);
// error: Matriz não pode ter dimensão zero

Compare isso com um erro de template C++ — em Zig, a mensagem de erro é exatamente o que você escreveu com @compileError. Sem pilhas de instanciação incompreensíveis.

Tabelas de Lookup em Tempo de Compilação

Construir tabelas pré-computadas é uma técnica clássica de otimização. Com comptime, isso é trivial:

fn gerarTabelaSeno(comptime tamanho: usize) [tamanho]f64 {
    var tabela: [tamanho]f64 = undefined;
    for (0..tamanho) |i| {
        const angulo = @as(f64, @floatFromInt(i)) * (2.0 * std.math.pi) / @as(f64, @floatFromInt(tamanho));
        tabela[i] = @sin(angulo);
    }
    return tabela;
}

// Tabela de 256 valores de seno, computada em compilação
const tabela_seno = comptime gerarTabelaSeno(256);

pub fn senoRapido(indice: u8) f64 {
    return tabela_seno[indice];
}

A tabela_seno é calculada inteiramente pelo compilador e embutida no binário como dados estáticos. Em runtime, consultar um valor é apenas um acesso a array — O(1), sem nenhum cálculo trigonométrico.

Estruturas de Dados Genéricas com Comptime

O comptime é a base para generics em Zig. A biblioteca padrão usa esse padrão extensivamente. Veja como criar um hashmap genérico simplificado:

fn MapaSimples(comptime K: type, comptime V: type, comptime capacidade: usize) type {
    return struct {
        chaves: [capacidade]?K,
        valores: [capacidade]V,

        const Self = @This();

        pub fn init() Self {
            return .{
                .chaves = [_]?K{null} ** capacidade,
                .valores = undefined,
            };
        }

        pub fn inserir(self: *Self, chave: K, valor: V) !void {
            const indice = @as(usize, @intCast(chave)) % capacidade;
            self.chaves[indice] = chave;
            self.valores[indice] = valor;
        }

        pub fn buscar(self: *const Self, chave: K) ?V {
            const indice = @as(usize, @intCast(chave)) % capacidade;
            if (self.chaves[indice]) |k| {
                if (k == chave) return self.valores[indice];
            }
            return null;
        }
    };
}

Para exemplos mais detalhados de uso de inline com loops em comptime, confira os cheatsheets de comptime.

Comptime vs C Macros: Por que Zig É Superior

O preprocessador C opera em texto — ele faz substituição textual sem entender a semântica do código. Isso causa problemas clássicos:

AspectoMacros CComptime Zig
Verificação de tiposNenhumaCompleta
DebuggabilidadeTerrívelNormal
Mensagens de erroConfusasClaras com @compileError
EscopoGlobalLexical normal
ComposiçãoFrágilNatural
Geração de tiposImpossívelNativa

Em C, #define MAX(a, b) ((a) > (b) ? (a) : (b)) avalia argumentos duas vezes e não tem verificação de tipos. Em Zig:

fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

Simples, seguro e com tipos verificados. Para uma comparação mais ampla entre Zig e C++, veja nosso artigo Zig vs C++.

Comptime vs Rust Proc Macros

Rust possui um sistema de macros poderoso, mas em camadas: macro_rules! para macros declarativas e proc macros para transformações de AST. Proc macros são essencialmente programas Rust separados que manipulam tokens.

O comptime de Zig é fundamentalmente diferente:

  • Rust: proc macros são compiladores dentro de compiladores — uma crate separada, API diferente, debugging complexo
  • Zig: comptime é o mesmo código, a mesma linguagem, as mesmas ferramentas

Quando um derive(Serialize) em Rust falha, o rastreamento de erros passa por camadas de geração de código. Em Zig, a mesma funcionalidade é implementada com reflexão em comptime usando funções como @typeInfo — e qualquer erro aponta diretamente para a linha do seu código.

Para mais comparações entre Zig e Rust, confira Zig vs Rust.

Comptime vs C++ Templates

Templates C++ são Turing-complete, mas formam uma sublinguagem funcional acidentalmente complexa. Em C++20, conceitos (concepts) melhoraram a situação, mas a complexidade fundamental permanece.

O comptime de Zig oferece o mesmo poder com uma fração da complexidade:

  • C++ Templates: linguagem funcional implícita com regras de SFINAE
  • Zig Comptime: código imperativo normal executado em compilação

A diferença na prática é brutal: o código comptime de Zig é legível para qualquer programador que conheça a linguagem, enquanto metaprogramação com templates C++ exige conhecimento especializado.

Reflexão em Tempo de Compilação

Zig oferece reflexão completa via builtins como @typeInfo, @typeName e @hasField. Isso permite introspecção de tipos sem nenhum custo em runtime:

fn imprimirCampos(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .@"struct" => |s| {
            inline for (s.fields) |campo| {
                std.debug.print("Campo: {s}, Tipo: {s}\n", .{
                    campo.name,
                    @typeName(campo.type),
                });
            }
        },
        else => @compileError("Esperado uma struct"),
    }
}

Essa função imprime todos os campos de qualquer struct em tempo de compilação. O inline for desenrola o loop, gerando código específico para cada campo — zero overhead em runtime.

Boas Práticas com Comptime

  1. Use comptime para eliminar branches em runtime — se uma decisão pode ser tomada na compilação, marque o parâmetro como comptime
  2. Prefira @compileError a erros em runtime — falhe cedo, falhe claro
  3. Documente funções que retornam tipos — o leitor precisa saber que MeuTipo(u32, 10) gera um tipo novo
  4. Cuidado com tempos de compilação — loops comptime muito grandes podem aumentar o tempo de build
  5. Use inline for com moderação — desenrolar loops grandes pode inflar o binário

Conclusão

O comptime é o que torna Zig único no ecossistema de linguagens de sistemas. Enquanto C depende de um preprocessador primitivo, C++ de templates acidentalmente complexos e Rust de macros procedurais separadas, Zig oferece metaprogramação usando a mesma linguagem que você já conhece.

Se você está começando com Zig, o comptime pode parecer exótico. Mas à medida que você ganha experiência, percebe que ele é a ferramenta mais natural e poderosa para escrever código genérico, validar invariantes e otimizar performance — tudo em tempo de compilação.

Quer saber mais sobre por que aprender Zig? Confira nossos outros artigos e o glossário completo para se aprofundar na linguagem.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.