Zig vs C: Uma Comparação Prática e Detalhada

Se você programa em C, vai se sentir em casa com Zig — a sintaxe é familiar, a performance é equivalente e ambas usam o backend LLVM para otimização. Mas Zig resolve dezenas de problemas clássicos que todo programador C conhece: memory leaks, buffer overflows, undefined behavior e toolchains de compilação cruzada impossíveis de configurar.

Neste artigo, vamos comparar as duas linguagens lado a lado com exemplos práticos. Se você quer um guia completo de migração, confira também o Zig para programadores C.

Alocação de memória

A gestão de memória é onde o Zig realmente brilha em relação ao C.

Em C — problemas clássicos:

#include <stdlib.h>
#include <string.h>

char* criar_mensagem(const char* nome) {
    // Fácil de calcular errado o tamanho
    char* msg = malloc(strlen(nome) + 20);
    if (msg == NULL) return NULL;  // Fácil de esquecer essa verificação

    sprintf(msg, "Olá, %s!", nome);
    return msg;
    // Quem vai chamar free()? O chamador precisa lembrar!
}

void funcao_com_leak() {
    int *dados = malloc(sizeof(int) * 100);
    if (alguma_condicao) return;  // LEAK! malloc sem free
    // ... processamento
    free(dados);
}

Em Zig — segurança por design:

const std = @import("std");

fn criarMensagem(allocator: std.mem.Allocator, nome: []const u8) ![]u8 {
    // Allocator explícito — sempre claro quem gerencia a memória
    return std.fmt.allocPrint(allocator, "Olá, {s}!", .{nome});
}

fn funcaoSemLeak(allocator: std.mem.Allocator) !void {
    const dados = try allocator.alloc(i32, 100);
    defer allocator.free(dados); // GARANTIDO — executa ao sair do escopo

    if (alguma_condicao) return; // defer executa mesmo aqui!
    // ... processamento
    // free é automático graças ao defer
}

Diferenças chave:

  • Allocators explícitos — em Zig, toda alocação de memória recebe um allocator como parâmetro, tornando claro quem é responsável pela memória
  • defer — garante que a limpeza ocorre mesmo em retornos antecipados, eliminando memory leaks
  • Sem NULL — Zig usa optionals e error unions ao invés de retornar NULL

Para entender allocators em profundidade, veja nosso tutorial de gerenciamento de memória em Zig.

Tratamento de erros

Em C, erros são tratados de formas inconsistentes: códigos de retorno, errno, ponteiros nulos. O Zig tem um sistema unificado.

Em C — caos de convenções:

// Convenção 1: retornar código de erro
int abrir_arquivo(const char* caminho, FILE** out) {
    *out = fopen(caminho, "r");
    if (*out == NULL) return -1;
    return 0;
}

// Convenção 2: retornar NULL
char* ler_config(const char* path) {
    FILE* f = fopen(path, "r");
    if (!f) return NULL;  // O que deu errado? Permissão? Arquivo não existe?
    // ... leitura
    return buffer;
}

// Convenção 3: errno global
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0) {
    perror("read failed");  // Qual erro? Race condition com errno?
}

Em Zig — erros são cidadãos de primeira classe:

const std = @import("std");

fn lerArquivo(caminho: []const u8) ![]u8 {
    const file = std.fs.cwd().openFile(caminho, .{}) catch |err| {
        // err é tipado: error.FileNotFound, error.AccessDenied, etc.
        std.debug.print("Erro ao abrir: {}\n", .{err});
        return err;
    };
    defer file.close();

    return file.readToEndAlloc(std.heap.page_allocator, 1024 * 1024);
}

pub fn main() !void {
    const conteudo = lerArquivo("config.txt") catch |err| switch (err) {
        error.FileNotFound => {
            std.debug.print("Arquivo não encontrado\n", .{});
            return;
        },
        error.AccessDenied => {
            std.debug.print("Sem permissão\n", .{});
            return;
        },
        else => return err,
    };
    defer std.heap.page_allocator.free(conteudo);

    // Usar conteúdo...
}

Vantagens do Zig:

  • Erros são tipados e documentados na assinatura da função
  • try propaga erros automaticamente (sem boilerplate)
  • catch permite tratamento granular com switch
  • Sem variáveis globais como errno
  • O compilador garante que todos os erros são tratados

Strings e buffers

Em C — território de buffer overflows:

#include <string.h>

void exemplo_perigoso() {
    char buffer[10];
    strcpy(buffer, "String muito longa para o buffer!");  // BUFFER OVERFLOW
    // Comportamento indefinido! Pode crashar, pode parecer funcionar...

    char nome[50];
    scanf("%s", nome);  // Sem limite de tamanho — outra vulnerabilidade
}

Em Zig — segurança em tempo de compilação e runtime:

fn exemploSeguro() void {
    var buffer: [10]u8 = undefined;
    const texto = "String muito longa";

    // Cópia segura com tamanho verificado
    if (texto.len > buffer.len) {
        std.debug.print("String não cabe no buffer!\n", .{});
        return;
    }

    @memcpy(buffer[0..texto.len], texto);

    // Slices sempre carregam o tamanho
    const slice: []const u8 = buffer[0..texto.len];
    std.debug.print("Tamanho: {}\n", .{slice.len});
}

Diferenças:

  • Strings em Zig são slices ([]const u8) com tamanho conhecido
  • Acesso fora dos limites é detectado em runtime (safety checks)
  • @memcpy verifica tamanhos em tempo de compilação quando possível
  • Sem terminador nulo obrigatório — interop com C usa [:0]const u8

Undefined Behavior

C é famoso pelo undefined behavior — código que parece correto mas pode fazer qualquer coisa:

Em C — armadilhas silenciosas:

int overflow() {
    int x = INT_MAX;
    x += 1;          // UNDEFINED BEHAVIOR! Pode retornar qualquer coisa
    return x;
}

int null_deref(int* ptr) {
    return *ptr;      // Se ptr é NULL: UNDEFINED BEHAVIOR
}

int array_oob() {
    int arr[5] = {0};
    return arr[10];   // UNDEFINED BEHAVIOR — acesso fora dos limites
}

Em Zig — comportamento sempre definido:

fn overflow() u32 {
    var x: u32 = std.math.maxInt(u32);
    x +%= 1;  // Operador de wrap explícito — resultado: 0
    // x += 1; // Isso geraria panic em modo debug (overflow detectado)
    return x;
}

fn acessoSeguro(ptr: ?*i32) i32 {
    // Optionals forçam verificação de null
    if (ptr) |valor| {
        return valor.*;
    }
    return 0; // Caso null tratado explicitamente
}

fn arraySeguro() i32 {
    const arr = [5]i32{ 1, 2, 3, 4, 5 };
    // arr[10]; // ERRO DE COMPILAÇÃO — índice fora dos limites
    return arr[4]; // OK
}

O Zig elimina undefined behavior com:

  • Overflow aritmético detectado em modo debug
  • Operadores explícitos para wrap (+%, -%, *%)
  • Optionals ao invés de ponteiros nulos
  • Verificação de limites em arrays e slices

Compilação cruzada

Uma das maiores dores de C é configurar toolchains para compilação cruzada. Em Zig, é trivial.

Em C — complexidade extrema:

# Compilar para ARM Linux a partir de x86_64:
# 1. Instalar cross-compiler
sudo apt install gcc-aarch64-linux-gnu

# 2. Encontrar as sysroot headers corretas
# 3. Configurar variáveis de ambiente
export CC=aarch64-linux-gnu-gcc
export CROSS_COMPILE=aarch64-linux-gnu-

# 4. Rezar para que as dependências compilem
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
# (geralmente falha na primeira tentativa)

Em Zig — um comando:

# Compilar para ARM Linux
zig build-exe main.zig -target aarch64-linux-gnu

# Compilar para Windows a partir de Linux
zig build-exe main.zig -target x86_64-windows-msvc

# Compilar para WebAssembly
zig build-exe main.zig -target wasm32-wasi

# Compilar para macOS a partir de Linux
zig build-exe main.zig -target x86_64-macos

O Zig suporta mais de 30 targets diferentes sem instalar nenhuma ferramenta adicional. Para mais detalhes, veja nosso tutorial de cross-compilation com Zig.

Sistema de build

Em C — múltiplas ferramentas:

# Makefile (ou CMakeLists.txt, ou Meson, ou Autotools...)
CC = gcc
CFLAGS = -Wall -O2
LDFLAGS = -lm -lpthread

all: programa
programa: main.o utils.o
    $(CC) $(LDFLAGS) -o $@ $^
%.o: %.c
    $(CC) $(CFLAGS) -c $<
clean:
    rm -f *.o programa

Em Zig — build system integrado:

// build.zig — tudo em Zig, com type safety
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "programa",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Adicionar biblioteca C? Simples:
    exe.linkSystemLibrary("pthread");
    exe.linkLibC();

    b.installArtifact(exe);
}

Para dominar o build system, confira nosso guia completo do build.zig.

Testes integrados

Em C — frameworks externos necessários:

// Precisa de um framework: CUnit, Check, Unity, cmocka...
#include <CUnit/CUnit.h>

void test_soma() {
    CU_ASSERT_EQUAL(soma(2, 3), 5);
}
// + dezenas de linhas de boilerplate para registrar testes

Em Zig — testes embutidos na linguagem:

fn somar(a: i32, b: i32) i32 {
    return a + b;
}

test "somar dois números" {
    const resultado = somar(2, 3);
    try std.testing.expectEqual(@as(i32, 5), resultado);
}

test "somar números negativos" {
    const resultado = somar(-1, -1);
    try std.testing.expectEqual(@as(i32, -2), resultado);
}
zig test arquivo.zig   # Executa todos os testes

Para mais sobre testes, veja nosso tutorial de testes em Zig.

Interoperabilidade: Zig fala C nativamente

Uma das maiores vantagens do Zig para quem vem de C é a interoperabilidade direta:

// Importar headers C diretamente — sem bindings manuais!
const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("math.h");
});

pub fn main() void {
    _ = c.printf("Raiz de 2: %f\n", c.sqrt(2.0));
}

O Zig pode compilar código C diretamente e linkar com bibliotecas C existentes. Isso permite migração gradual — você pode começar a usar Zig em um projeto C existente arquivo por arquivo.

Confira nosso guia completo de interoperabilidade Zig-C.

Tabela comparativa

AspectoCZig
PerformanceExcelenteExcelente (mesmo backend LLVM)
Segurança de memóriaManual, propenso a errosAllocators explícitos + defer
Undefined behaviorComum e silenciosoDetectado em runtime
Tratamento de errosInconsistenteSistema unificado com error unions
StringsPonteiros nulos terminadosSlices com tamanho
Compilação cruzadaComplexaNativa (30+ targets)
Sistema de buildExterno (Make/CMake)Integrado (build.zig)
TestesFramework externoEmbutido na linguagem
Interop com CImportação direta de headers
Curva de aprendizadoLonga (armadilhas ocultas)Mais curta (explícito e previsível)

Quando usar cada linguagem?

Continue com C se:

  • Você tem um codebase C maduro e estável
  • Precisa de compatibilidade com compiladores específicos (não-LLVM)
  • O ecossistema de bibliotecas do seu domínio é exclusivamente C

Escolha Zig se:

  • Está começando um projeto novo de sistemas
  • Precisa de compilação cruzada
  • Quer a performance de C com mais segurança
  • Quer migrar gradualmente de C (interop nativa)
  • Precisa de um build system moderno e integrado

Conclusão

Zig não é apenas uma “alternativa ao C” — é uma evolução pensada para resolver os problemas reais que desenvolvedores C enfrentam diariamente: memory leaks, buffer overflows, undefined behavior, toolchains de compilação cruzada impossíveis de configurar. E faz tudo isso sem sacrificar a performance que torna C indispensável.

A capacidade de importar headers C diretamente e interoperar com código C existente significa que você não precisa reescrever tudo — pode migrar gradualmente, arquivo por arquivo.

Próximos passos

Continue aprendendo Zig

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