Zig para Programadores Rust: Comparação e Guia Prático

Se você é um programador Rust experiente e está curioso sobre Zig, este guia é para você. Vamos explorar, lado a lado, como os conceitos que você já domina em Rust se traduzem para Zig — e entender por que tantos desenvolvedores de sistemas estão adotando essa linguagem como complemento ou alternativa ao Rust.

Introdução: Por que Programadores Rust se Interessam por Zig?

Rust revolucionou a programação de sistemas com seu borrow checker e garantias de segurança em tempo de compilação. Então por que um programador Rust deveria conhecer Zig?

A resposta está na filosofia de simplicidade. Zig resolve muitos dos mesmos problemas que Rust, mas com uma abordagem radicalmente diferente:

  • Sem borrow checker, sem lifetimes: Zig confia no programador e fornece ferramentas de detecção em runtime (em modo debug) em vez de um sistema de tipos complexo.
  • Sem macros procedurais: comptime substitui todo o sistema de macros de Rust com código Zig real, depurável.
  • Sem traits nem generics tradicionais: Polimorfismo é feito via comptime e interfaces baseadas em convenção.
  • Interop com C nativa: Importe headers C diretamente, sem bindgen ou unsafe blocks.
  • Build system integrado: Sem Cargo.toml, sem TOML, sem dependências externas. O build.zig é escrito na própria linguagem.
  • Binários pequenos e previsíveis: Sem runtime, sem unwinding de exceções, controle total sobre alocações.

Aqui está uma visão geral das diferenças fundamentais:

CaracterísticaRustZig
Segurança de memóriaBorrow checker (compilação)Allocators explícitos + detecção em debug
Tratamento de errosResult<T, E> e ?Error unions (!T) e try/catch
GenericsTraits + type parameterscomptime + duck typing
Macrosmacro_rules! e proc macroscomptime (código real)
Build systemCargobuild.zig integrado
Asyncasync/.await + runtime (Tokio)io_uring nativo (sem runtime)
StringsString / &str[]const u8 (slices)
Null safetyOption<T>Tipos opcionais (?T)
FFI com Cextern "C" + unsafe@cImport nativo, sem unsafe
Gerenciamento de pacotescrates.ioZig Package Manager (build.zig.zon)

Modelo de Memória: Ownership/Borrow Checker vs Allocators Explícitos

Esta talvez seja a maior diferença conceitual entre as duas linguagens. Em Rust, o compilador rastreia a propriedade (ownership) de cada valor e garante que referências sejam sempre válidas. Em Zig, o programador gerencia a memória explicitamente usando allocators.

Ownership e Move Semantics

Rust — O valor é movido e a variável original se torna inválida:

fn main() {
    let nome = String::from("Zig Brasil");
    let outro = nome; // move ocorre aqui
    // println!("{}", nome); // ERRO: nome foi movido
    println!("{}", outro);
}

Zig — Não existe conceito de “move”. O programador controla a alocação:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const nome = try std.fmt.allocPrint(allocator, "Zig Brasil", .{});
    defer allocator.free(nome);

    // Copiar o slice é apenas copiar o ponteiro e o tamanho (não há "move")
    const outro = nome;

    std.debug.print("{s}\n", .{outro});
    std.debug.print("{s}\n", .{nome}); // Perfeitamente válido
}

Borrowing vs Slices

Rust — O borrow checker garante que referências mutáveis sejam exclusivas:

fn processar(dados: &mut Vec<i32>) {
    dados.push(42);
}

fn visualizar(dados: &[i32]) {
    for item in dados {
        println!("{}", item);
    }
}

fn main() {
    let mut lista = vec![1, 2, 3];
    processar(&mut lista);
    visualizar(&lista);
}

Zig — Slices são passados explicitamente, sem borrow checker:

const std = @import("std");

fn processar(lista: *std.ArrayList(i32)) !void {
    try lista.append(42);
}

fn visualizar(dados: []const i32) void {
    for (dados) |item| {
        std.debug.print("{d}\n", .{item});
    }
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var lista = std.ArrayList(i32).init(allocator);
    defer lista.deinit();

    try lista.appendSlice(&.{ 1, 2, 3 });
    try processar(&lista);
    visualizar(lista.items);
}

Lifetimes vs defer/errdefer

Rust — Lifetimes anotam quanto tempo uma referência vive:

struct Parser<'a> {
    input: &'a str,
    posicao: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Parser { input, posicao: 0 }
    }

    fn proximo_char(&mut self) -> Option<char> {
        let ch = self.input[self.posicao..].chars().next()?;
        self.posicao += ch.len_utf8();
        Some(ch)
    }
}

Zigdefer e errdefer garantem limpeza determinística:

const std = @import("std");

const Parser = struct {
    input: []const u8,
    posicao: usize,

    pub fn init(input: []const u8) Parser {
        return .{
            .input = input,
            .posicao = 0,
        };
    }

    pub fn proximoChar(self: *Parser) ?u8 {
        if (self.posicao >= self.input.len) return null;
        const ch = self.input[self.posicao];
        self.posicao += 1;
        return ch;
    }
};

pub fn main() void {
    var parser = Parser.init("Olá Zig");
    while (parser.proximoChar()) |ch| {
        std.debug.print("{c}", .{ch});
    }
    std.debug.print("\n", .{});
}

Arena Allocator: Padrão Comum em Zig

Em Rust, o borrow checker lida com a maioria dos casos de vida útil de memória. Em Zig, o padrão ArenaAllocator é extremamente comum para agrupar alocações e liberá-las de uma só vez:

const std = @import("std");

fn processarDocumento(allocator: std.mem.Allocator, conteudo: []const u8) ![]const u8 {
    // Todas as alocações temporárias usam o arena
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit(); // Libera TUDO de uma vez

    const arena_alloc = arena.allocator();

    // Alocações temporárias — não precisam de free individual
    const linhas = try splitLinhas(arena_alloc, conteudo);
    const processadas = try processarLinhas(arena_alloc, linhas);

    // Apenas o resultado final é alocado no allocator externo
    return try std.fmt.allocPrint(allocator, "{s}", .{processadas});
}

fn splitLinhas(allocator: std.mem.Allocator, texto: []const u8) ![][]const u8 {
    var lista = std.ArrayList([]const u8).init(allocator);
    var iter = std.mem.splitScalar(u8, texto, '\n');
    while (iter.next()) |linha| {
        try lista.append(linha);
    }
    return lista.toOwnedSlice();
}

fn processarLinhas(allocator: std.mem.Allocator, linhas: [][]const u8) ![]const u8 {
    var resultado = std.ArrayList(u8).init(allocator);
    for (linhas) |linha| {
        try resultado.appendSlice(linha);
        try resultado.append('\n');
    }
    return resultado.toOwnedSlice();
}

Tratamento de Erros: Result<T,E> vs Error Unions

Ambas as linguagens tratam erros como valores (sem exceções), mas a sintaxe e os mecanismos são distintos.

Padrão Básico

Rust:

use std::fs;
use std::io;

fn ler_config(caminho: &str) -> Result<String, io::Error> {
    let conteudo = fs::read_to_string(caminho)?;
    Ok(conteudo.trim().to_string())
}

fn main() {
    match ler_config("config.toml") {
        Ok(config) => println!("Config: {}", config),
        Err(e) => eprintln!("Erro: {}", e),
    }
}

Zig:

const std = @import("std");

fn lerConfig(allocator: std.mem.Allocator, caminho: []const u8) ![]const u8 {
    const arquivo = std.fs.cwd().openFile(caminho, .{}) catch |err| {
        std.log.err("Não foi possível abrir {s}: {}", .{ caminho, err });
        return err;
    };
    defer arquivo.close();

    return try arquivo.readToEndAlloc(allocator, 1024 * 1024);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const config = lerConfig(allocator, "config.toml") catch |err| {
        std.debug.print("Erro: {}\n", .{err});
        return;
    };
    defer allocator.free(config);

    std.debug.print("Config: {s}\n", .{config});
}

Definindo Erros Personalizados

Rust:

#[derive(Debug)]
enum MeuErro {
    ArquivoNaoEncontrado,
    FormatoInvalido(String),
    SemPermissao,
}

impl std::fmt::Display for MeuErro {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            MeuErro::ArquivoNaoEncontrado => write!(f, "Arquivo não encontrado"),
            MeuErro::FormatoInvalido(msg) => write!(f, "Formato inválido: {}", msg),
            MeuErro::SemPermissao => write!(f, "Sem permissão"),
        }
    }
}

impl std::error::Error for MeuErro {}

Zig:

const MeuErro = error{
    ArquivoNaoEncontrado,
    FormatoInvalido,
    SemPermissao,
};

fn operacao() MeuErro!u32 {
    return MeuErro.FormatoInvalido;
}

fn operacaoComDetalhes() MeuErro!u32 {
    // Em Zig, erros são valores simples. Para detalhes extras,
    // use logging ou retorne informação adicional via parâmetro.
    std.log.err("Formato inválido: campo 'nome' ausente", .{});
    return MeuErro.FormatoInvalido;
}

errdefer: O Padrão que Rust Não Tem

Um dos recursos mais elegantes de Zig é errdefer, que executa código de limpeza apenas quando a função retorna um erro:

const std = @import("std");

const Conexao = struct {
    socket: std.posix.socket_t,
    buffer: []u8,

    pub fn init(allocator: std.mem.Allocator, endereco: []const u8) !Conexao {
        const socket = try std.posix.socket(
            std.posix.AF.INET,
            std.posix.SOCK.STREAM,
            0,
        );
        errdefer std.posix.close(socket); // Fecha socket se algo falhar abaixo

        const buffer = try allocator.alloc(u8, 4096);
        errdefer allocator.free(buffer); // Libera buffer se algo falhar abaixo

        // Se a conexão falhar, socket e buffer são liberados automaticamente
        try conectar(socket, endereco);

        return .{
            .socket = socket,
            .buffer = buffer,
        };
    }

    pub fn deinit(self: *Conexao, allocator: std.mem.Allocator) void {
        std.posix.close(self.socket);
        allocator.free(self.buffer);
    }
};

fn conectar(socket: std.posix.socket_t, endereco: []const u8) !void {
    _ = socket;
    _ = endereco;
    // Implementação da conexão...
}

Em Rust, esse padrão requer Drop traits ou uso manual de guard patterns. O errdefer de Zig é mais explícito e direto.

Generics: Traits vs Comptime

Esta é uma diferença filosófica profunda. Rust usa traits para polimorfismo parametrizado com verificação em tempo de compilação. Zig usa comptime com duck typing em tempo de compilação.

Função Genérica

Rust:

use std::ops::Add;

fn somar<T: Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

fn main() {
    println!("{}", somar(10i32, 20i32));
    println!("{}", somar(1.5f64, 2.5f64));
}

Zig:

const std = @import("std");

fn somar(comptime T: type, a: T, b: T) T {
    return a + b;
}

pub fn main() void {
    std.debug.print("{d}\n", .{somar(i32, 10, 20)});
    std.debug.print("{d}\n", .{somar(f64, 1.5, 2.5)});
}

Traits vs Interfaces por Convenção

Rust — Traits definem interfaces formais:

trait Serializavel {
    fn serializar(&self) -> String;
    fn tamanho(&self) -> usize;
}

struct Usuario {
    nome: String,
    idade: u32,
}

impl Serializavel for Usuario {
    fn serializar(&self) -> String {
        format!("{}:{}", self.nome, self.idade)
    }

    fn tamanho(&self) -> usize {
        self.nome.len() + 4 // 4 bytes para idade
    }
}

fn processar<T: Serializavel>(item: &T) {
    println!("Dados: {}", item.serializar());
    println!("Tamanho: {} bytes", item.tamanho());
}

Zig — Duck typing em comptime (se o tipo tem os métodos necessários, funciona):

const std = @import("std");

const Usuario = struct {
    nome: []const u8,
    idade: u32,

    pub fn serializar(self: Usuario, buffer: []u8) []u8 {
        return std.fmt.bufPrint(buffer, "{s}:{d}", .{ self.nome, self.idade }) catch buffer[0..0];
    }

    pub fn tamanho(self: Usuario) usize {
        return self.nome.len + 4;
    }
};

const Produto = struct {
    titulo: []const u8,
    preco: f64,

    pub fn serializar(self: Produto, buffer: []u8) []u8 {
        return std.fmt.bufPrint(buffer, "{s}:{d:.2}", .{ self.titulo, self.preco }) catch buffer[0..0];
    }

    pub fn tamanho(self: Produto) usize {
        return self.titulo.len + 8;
    }
};

fn processar(comptime T: type, item: T) void {
    var buffer: [256]u8 = undefined;
    const dados = item.serializar(&buffer);
    std.debug.print("Dados: {s}\n", .{dados});
    std.debug.print("Tamanho: {d} bytes\n", .{item.tamanho()});
}

pub fn main() void {
    const usuario = Usuario{ .nome = "Ana", .idade = 30 };
    const produto = Produto{ .titulo = "Teclado", .preco = 199.90 };

    processar(Usuario, usuario);
    processar(Produto, produto);
}

Trait Objects vs Interfaces Dinâmicas (vtable)

Rustdyn Trait para dispatch dinâmico:

trait Animal {
    fn som(&self) -> &str;
    fn nome(&self) -> &str;
}

struct Cachorro { nome: String }
struct Gato { nome: String }

impl Animal for Cachorro {
    fn som(&self) -> &str { "Au au!" }
    fn nome(&self) -> &str { &self.nome }
}

impl Animal for Gato {
    fn som(&self) -> &str { "Miau!" }
    fn nome(&self) -> &str { &self.nome }
}

fn apresentar(animal: &dyn Animal) {
    println!("{}: {}", animal.nome(), animal.som());
}

Zig — Usa ponteiros opacos e vtables manuais ou std.mem.Allocator-style interfaces:

const std = @import("std");

const Animal = struct {
    ptr: *anyopaque,
    somFn: *const fn (*anyopaque) []const u8,
    nomeFn: *const fn (*anyopaque) []const u8,

    pub fn som(self: Animal) []const u8 {
        return self.somFn(self.ptr);
    }

    pub fn nome(self: Animal) []const u8 {
        return self.nomeFn(self.ptr);
    }

    pub fn init(comptime T: type, ptr: *T) Animal {
        return .{
            .ptr = @ptrCast(ptr),
            .somFn = @ptrCast(&T.som),
            .nomeFn = @ptrCast(&T.nome),
        };
    }
};

const Cachorro = struct {
    nome_animal: []const u8,

    pub fn som(_: *Cachorro) []const u8 {
        return "Au au!";
    }
    pub fn nome(self: *Cachorro) []const u8 {
        return self.nome_animal;
    }
};

const Gato = struct {
    nome_animal: []const u8,

    pub fn som(_: *Gato) []const u8 {
        return "Miau!";
    }
    pub fn nome(self: *Gato) []const u8 {
        return self.nome_animal;
    }
};

fn apresentar(animal: Animal) void {
    std.debug.print("{s}: {s}\n", .{ animal.nome(), animal.som() });
}

pub fn main() void {
    var cachorro = Cachorro{ .nome_animal = "Rex" };
    var gato = Gato{ .nome_animal = "Mimi" };

    apresentar(Animal.init(Cachorro, &cachorro));
    apresentar(Animal.init(Gato, &gato));
}

Build System: Cargo vs Zig Build

Programadores Rust estão acostumados com o excelente Cargo. O sistema de build do Zig é diferente, mas igualmente poderoso.

Estrutura de Projeto

Rust (Cargo):

meu-projeto/
├── Cargo.toml
├── Cargo.lock
├── src/
│   ├── main.rs
│   ├── lib.rs
│   └── utils/
│       └── mod.rs
└── tests/
    └── integration_test.rs

Zig:

meu-projeto/
├── build.zig
├── build.zig.zon
├── src/
│   ├── main.zig
│   └── utils.zig
└── test/
    └── integration_test.zig

Arquivo de Configuração

Rust — Cargo.toml:

[package]
name = "meu-projeto"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

[dev-dependencies]
criterion = "0.5"

Zig — build.zig:

const std = @import("std");

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

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

    // Testes unitários
    const testes = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const rodar_testes = b.addRunArtifact(testes);
    const passo_teste = b.step("test", "Executar testes unitários");
    passo_teste.dependOn(&rodar_testes.step);

    // Passo para rodar o executável
    const rodar = b.addRunArtifact(exe);
    rodar.step.dependOn(b.getInstallStep());
    const passo_rodar = b.step("run", "Executar o programa");
    passo_rodar.dependOn(&rodar.step);
}

Dependências Externas

Zig — build.zig.zon:

.{
    .name = "meu-projeto",
    .version = "0.1.0",
    .dependencies = .{
        .zap = .{
            .url = "https://github.com/zigzap/zap/archive/v0.1.0.tar.gz",
            .hash = "122013fd26b5cf2608da3905cf04e2b8eff9e74310ff8cfb15e41c1e83847e01e5",
        },
    },
    .paths = .{"."},
}

Comandos Comparados

AçãoRust (Cargo)Zig
Compilarcargo buildzig build
Executarcargo runzig build run
Testarcargo testzig build test
Releasecargo build --releasezig build -Doptimize=ReleaseFast
Cross-compilePrecisa de toolchainzig build -Dtarget=aarch64-linux
Formatarcargo fmtzig fmt src/

Async: Tokio vs io_uring

Esta é uma das maiores diferenças entre as duas linguagens. Rust depende de runtimes de terceiros (como Tokio) para async. Zig integra suporte nativo a io_uring diretamente.

Servidor HTTP Básico

Rust com Tokio:

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Servidor rodando em :8080");

    loop {
        let (mut socket, addr) = listener.accept().await?;
        println!("Conexão de: {}", addr);

        tokio::spawn(async move {
            let mut buf = [0u8; 1024];
            match socket.read(&mut buf).await {
                Ok(n) => {
                    let resposta = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nOlá";
                    let _ = socket.write_all(resposta.as_bytes()).await;
                    let _ = socket.shutdown().await;
                }
                Err(e) => eprintln!("Erro: {}", e),
            }
        });
    }
}

Zig com std.http (síncrono, mas leve):

const std = @import("std");
const net = std.net;

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var server = try std.http.Server.init(allocator, .{});
    defer server.deinit();

    const endereco = try net.Address.parseIp("127.0.0.1", 8080);
    try server.listen(endereco);
    std.debug.print("Servidor rodando em :8080\n", .{});

    while (true) {
        var response = try server.accept(.{ .allocator = allocator });
        defer response.deinit();

        try response.headers.append("Content-Type", "text/plain");
        try response.do();

        try response.writeAll("Olá");
        try response.finish();
    }
}

Async sem Runtime

A diferença fundamental entre Rust e Zig em relação a async:

AspectoRustZig
RuntimeNecessário (Tokio, async-std)Nenhum (usa io_uring ou epoll)
ModeloFutures + executorEvent loop no kernel
OverheadAlocações para tasksZero-copy, sem alocações extras
EcossistemaDivisão sync/asyncUma única API para tudo
Coloração de funçõesSim (async fn vs fn)Não existe diferenciação

Em Zig, operações de I/O podem ser feitas de forma eficiente usando threads do sistema operacional ou integrações diretas com io_uring no Linux, sem necessidade de um runtime separado como Tokio.

Macros vs Comptime

Se você já escreveu uma proc macro em Rust, sabe a dor: crate separado, syn, quote, TokenStream, compilação lenta. Em Zig, comptime faz tudo isso com código Zig normal.

Derive Macro vs Comptime Reflection

Rust — Derive macro para serialização (simplificado):

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Config {
    host: String,
    porta: u16,
    debug: bool,
}

fn main() {
    let config = Config {
        host: "localhost".to_string(),
        porta: 8080,
        debug: true,
    };

    let json = serde_json::to_string_pretty(&config).unwrap();
    println!("{}", json);
}

Zig — Reflexão com comptime para gerar JSON:

const std = @import("std");

const Config = struct {
    host: []const u8,
    porta: u16,
    debug: bool,
};

fn paraJson(comptime T: type, valor: T, writer: anytype) !void {
    const info = @typeInfo(T);
    switch (info) {
        .@"struct" => |s| {
            try writer.writeAll("{");
            inline for (s.fields, 0..) |field, i| {
                if (i > 0) try writer.writeAll(",");
                try writer.print("\"{s}\":", .{field.name});
                try paraJson(field.type, @field(valor, field.name), writer);
            }
            try writer.writeAll("}");
        },
        .pointer => |p| {
            if (p.size == .Slice and p.child == u8) {
                try writer.print("\"{s}\"", .{valor});
            }
        },
        .int => try writer.print("{d}", .{valor}),
        .bool => try writer.writeAll(if (valor) "true" else "false"),
        else => try writer.writeAll("null"),
    }
}

pub fn main() !void {
    const config = Config{
        .host = "localhost",
        .porta = 8080,
        .debug = true,
    };

    const stdout = std.io.getStdOut().writer();
    try paraJson(Config, config, stdout);
    try stdout.writeAll("\n");
}

O comptime de Zig itera sobre os campos da struct em tempo de compilação, gerando código especializado para cada tipo. Sem macros, sem crate separado, sem TokenStream.

Macro para Logging vs Comptime

Rust:

macro_rules! log_debug {
    ($($arg:tt)*) => {
        if cfg!(debug_assertions) {
            eprintln!("[DEBUG {}:{}] {}", file!(), line!(), format!($($arg)*));
        }
    };
}

fn main() {
    log_debug!("Valor: {}", 42);
}

Zig:

const std = @import("std");
const builtin = @import("builtin");

fn logDebug(comptime fmt: []const u8, args: anytype) void {
    if (builtin.mode == .Debug) {
        std.debug.print("[DEBUG {s}:{d}] " ++ fmt ++ "\n", .{@src().file, @src().line} ++ args);
    }
}

pub fn main() void {
    logDebug("Valor: {d}", .{42});
}

Geração de Código em Comptime

Um dos poderes mais impressionantes do comptime é gerar tipos e funções inteiramente em tempo de compilação:

const std = @import("std");

fn CriarVetor(comptime T: type, comptime N: usize) type {
    return struct {
        dados: [N]T,

        const Self = @This();

        pub fn init(valor_padrao: T) Self {
            return .{ .dados = [_]T{valor_padrao} ** N };
        }

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

        pub fn set(self: *Self, indice: usize, valor: T) void {
            if (indice < N) {
                self.dados[indice] = valor;
            }
        }

        pub fn soma(self: Self) T {
            var total: T = 0;
            for (self.dados) |v| {
                total += v;
            }
            return total;
        }

        pub fn tamanho() usize {
            return N;
        }
    };
}

pub fn main() void {
    const Vec3f = CriarVetor(f32, 3);
    var v = Vec3f.init(0.0);
    v.set(0, 1.0);
    v.set(1, 2.0);
    v.set(2, 3.0);

    std.debug.print("Soma: {d}\n", .{v.soma()});
    std.debug.print("Tamanho: {d}\n", .{Vec3f.tamanho()});
}

Em Rust, esse tipo de geração de código exigiria macros procedurais complexas ou genéricos com bounds extensos.

FFI e Interoperabilidade com C

Tanto Rust quanto Zig são excelentes para FFI com C, mas a abordagem é drasticamente diferente.

Importando Funções C

Rust:

// Precisa de bindings manuais ou bindgen
extern "C" {
    fn strlen(s: *const std::os::raw::c_char) -> usize;
    fn printf(format: *const std::os::raw::c_char, ...) -> i32;
}

fn main() {
    let msg = std::ffi::CString::new("Olá do Rust!\n").unwrap();
    unsafe {
        printf(msg.as_ptr());
        let tamanho = strlen(msg.as_ptr());
        println!("Tamanho: {}", tamanho);
    }
}

Zig:

const std = @import("std");
const c = @cImport({
    @cInclude("string.h");
    @cInclude("stdio.h");
});

pub fn main() void {
    const msg = "Olá do Zig!\n";
    _ = c.printf(msg.ptr);

    const tamanho = c.strlen(msg.ptr);
    std.debug.print("Tamanho: {d}\n", .{tamanho});
}

Em Zig, @cImport lê headers C diretamente e gera bindings automaticamente. Não há unsafe, não há CString, não há bindgen.

Usando uma Biblioteca C Completa

Rust — Usando SQLite via rusqlite (crate com bindings):

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    let conn = Connection::open_in_memory()?;

    conn.execute(
        "CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nome TEXT NOT NULL)",
        [],
    )?;

    conn.execute("INSERT INTO usuarios (nome) VALUES (?1)", ["Ana"])?;

    let mut stmt = conn.prepare("SELECT id, nome FROM usuarios")?;
    let rows = stmt.query_map([], |row| {
        Ok((row.get::<_, i32>(0)?, row.get::<_, String>(1)?))
    })?;

    for row in rows {
        let (id, nome) = row?;
        println!("{}: {}", id, nome);
    }
    Ok(())
}

Zig — Usando SQLite diretamente via @cImport:

const std = @import("std");
const c = @cImport({
    @cInclude("sqlite3.h");
});

pub fn main() !void {
    var db: ?*c.sqlite3 = null;

    if (c.sqlite3_open(":memory:", &db) != c.SQLITE_OK) {
        std.debug.print("Erro ao abrir banco\n", .{});
        return error.DatabaseError;
    }
    defer _ = c.sqlite3_close(db);

    // Criar tabela
    var err_msg: [*c]u8 = null;
    _ = c.sqlite3_exec(
        db,
        "CREATE TABLE usuarios (id INTEGER PRIMARY KEY, nome TEXT NOT NULL)",
        null,
        null,
        &err_msg,
    );

    // Inserir dados
    _ = c.sqlite3_exec(
        db,
        "INSERT INTO usuarios (nome) VALUES ('Ana')",
        null,
        null,
        &err_msg,
    );

    // Consultar
    var stmt: ?*c.sqlite3_stmt = null;
    _ = c.sqlite3_prepare_v2(db, "SELECT id, nome FROM usuarios", -1, &stmt, null);

    while (c.sqlite3_step(stmt) == c.SQLITE_ROW) {
        const id = c.sqlite3_column_int(stmt, 0);
        const nome_ptr = c.sqlite3_column_text(stmt, 1);
        std.debug.print("{d}: {s}\n", .{ id, nome_ptr });
    }
    _ = c.sqlite3_finalize(stmt);
}

A vantagem de Zig aqui: você usa a API C diretamente, sem camada de abstração intermediária. Para o build, basta adicionar ao build.zig:

exe.linkSystemLibrary("sqlite3");
exe.linkLibC();

Comparação de Padrões Comuns

Iteradores

Rust:

fn main() {
    let numeros = vec![1, 2, 3, 4, 5];

    let resultado: Vec<i32> = numeros
        .iter()
        .filter(|&&x| x > 2)
        .map(|&x| x * x)
        .collect();

    println!("{:?}", resultado); // [9, 16, 25]
}

Zig:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const numeros = [_]i32{ 1, 2, 3, 4, 5 };

    var resultado = std.ArrayList(i32).init(allocator);
    defer resultado.deinit();

    for (numeros) |x| {
        if (x > 2) {
            try resultado.append(x * x);
        }
    }

    for (resultado.items) |v| {
        std.debug.print("{d} ", .{v});
    }
    std.debug.print("\n", .{});
}

Zig não tem iteradores encadeáveis estilo funcional. Os loops for são explícitos e diretos.

Enums com Dados

Rust:

enum Mensagem {
    Texto(String),
    Imagem { url: String, largura: u32, altura: u32 },
    Ping,
}

fn processar(msg: &Mensagem) {
    match msg {
        Mensagem::Texto(t) => println!("Texto: {}", t),
        Mensagem::Imagem { url, largura, altura } => {
            println!("Imagem: {} ({}x{})", url, largura, altura)
        }
        Mensagem::Ping => println!("Ping!"),
    }
}

Zig — Tagged unions:

const std = @import("std");

const Mensagem = union(enum) {
    texto: []const u8,
    imagem: struct {
        url: []const u8,
        largura: u32,
        altura: u32,
    },
    ping: void,
};

fn processar(msg: Mensagem) void {
    switch (msg) {
        .texto => |t| std.debug.print("Texto: {s}\n", .{t}),
        .imagem => |img| std.debug.print(
            "Imagem: {s} ({d}x{d})\n",
            .{ img.url, img.largura, img.altura },
        ),
        .ping => std.debug.print("Ping!\n", .{}),
    }
}

pub fn main() void {
    const msg1 = Mensagem{ .texto = "Olá!" };
    const msg2 = Mensagem{ .imagem = .{
        .url = "foto.png",
        .largura = 800,
        .altura = 600,
    } };
    const msg3 = Mensagem{ .ping = {} };

    processar(msg1);
    processar(msg2);
    processar(msg3);
}

Testes

Rust:

fn soma(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_soma() {
        assert_eq!(soma(2, 3), 5);
    }

    #[test]
    #[should_panic]
    fn test_overflow() {
        let _ = soma(i32::MAX, 1);
    }
}

Zig:

const std = @import("std");
const expect = std.testing.expect;

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

test "soma basica" {
    try expect(soma(2, 3) == 5);
}

test "soma com valores negativos" {
    try expect(soma(-1, 1) == 0);
    try expect(soma(-5, -3) == -8);
}

test "soma com zero" {
    try expect(soma(0, 42) == 42);
}

Em Zig, blocos test ficam no mesmo arquivo que o código, sem necessidade de módulos de teste separados. Execute com zig build test ou zig test src/arquivo.zig.

Quando Escolher Zig vs Rust

Nem toda ferramenta é ideal para todo trabalho. Aqui está uma comparação honesta de quando cada linguagem brilha:

Escolha Rust Quando:

  • Segurança de memória em tempo de compilação é crítica: Sistemas financeiros, infraestrutura de segurança.
  • O ecossistema crates.io importa: Rust tem um ecossistema massivo de bibliotecas maduras.
  • Async complexo é necessário: Tokio é extremamente maduro para servidores de alta concorrência.
  • A equipe valoriza garantias do compilador: O borrow checker previne classes inteiras de bugs antes do deploy.
  • WebAssembly no frontend: Rust tem o melhor suporte WASM com wasm-bindgen e Yew.

Escolha Zig Quando:

  • Interop com C é prioridade: Zig importa headers C nativamente, perfeito para usar bibliotecas C existentes.
  • Binários mínimos são necessários: Sem runtime, Zig gera executáveis extremamente pequenos. Ideal para sistemas embarcados.
  • Cross-compilation é frequente: Zig compila para qualquer plataforma com um único comando, sem configurar toolchains extras.
  • Simplicidade importa mais que garantias: Para equipes que preferem explicitação a abstrações complexas.
  • Substituir C em projetos legados: Zig pode ser adotado incrementalmente em projetos C existentes.
  • Kernels e drivers: Zig não tem runtime, não tem exceções, não tem hidden control flow.

Tabela de Decisão por Domínio

DomínioRustZigNotas
Web backendExcelenteBomRust tem Actix/Axum maduros
Sistemas embarcadosBomExcelenteZig tem binários menores
Game enginesBomExcelenteZig tem controle total, sem GC
CLI toolsExcelenteBomRust tem clap, serde, ecossistema
Kernels/driversBomExcelenteLinux adota Zig oficialmente
NetworkingExcelenteBomTokio é muito maduro
Interop com CBomExcelenteZig importa headers direto
WASMExcelenteBomRust tem melhor tooling WASM
DevOps/InfraExcelenteBomRust tem ecossistema maior

Podem Coexistir?

Sim. Zig e Rust podem ser usados juntos no mesmo projeto. Ambos geram código compatível com a ABI C, então é possível:

  • Escrever uma biblioteca em Zig e usá-la em Rust via FFI.
  • Usar o compilador C do Zig (zig cc) para compilar dependências C de projetos Rust.
  • Substituir gradualmente componentes C em um projeto misto Rust/C usando Zig.
// lib_zig.zig — exporta função com ABI C
export fn calcular_hash(dados: [*]const u8, tamanho: usize) u64 {
    var hash: u64 = 5381;
    for (dados[0..tamanho]) |byte| {
        hash = ((hash << 5) +% hash) +% byte;
    }
    return hash;
}
// main.rs — importa a função Zig
extern "C" {
    fn calcular_hash(dados: *const u8, tamanho: usize) -> u64;
}

fn main() {
    let dados = b"Zig + Rust";
    let hash = unsafe { calcular_hash(dados.as_ptr(), dados.len()) };
    println!("Hash: {}", hash);
}

Próximos Passos

Agora que você entende como os conceitos de Rust se traduzem para Zig, aqui estão os próximos recursos recomendados:

  1. Como Instalar Zig — Configure seu ambiente de desenvolvimento Zig.
  2. Zig Build System — Domine o build.zig e substitua o Cargo na sua rotina.
  3. Zig para Desenvolvedores — Guia geral com exemplos práticos para programadores experientes.
  4. Zig Async e io_uring — Aprofunde-se no modelo async de Zig sem runtime.
  5. FFI e Interoperabilidade com C — Como integrar bibliotecas C sem bindings manuais.
  6. Comptime e Reflexão — Domine o comptime como substituto de macros.
  7. Tratamento de Erros em Zig — Guia completo sobre error unions e padrões de erro.
  8. Testes em Zig — Como testar código Zig de forma eficiente.

Recursos Externos

Se você vem de Rust, vai encontrar em Zig uma linguagem que respeita sua experiência com sistemas, mas oferece um caminho diferente: menos abstração, mais controle explícito, e uma simplicidade que pode ser surpreendentemente produtiva.

Continue aprendendo Zig

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