Design Patterns em Zig: Padrões de Projeto na Prática

Quando programadores vindos de linguagens orientadas a objetos como Java, C# ou C++ começam a trabalhar com Zig lang, uma das primeiras perguntas que surge é: “como faço design patterns aqui?”. A linguagem Zig não tem classes, herança nem polimorfismo baseado em vtables — e isso é proposital. Zig adota uma filosofia de simplicidade radical, onde os padrões de projeto ganham formas diferentes, muitas vezes mais claras e eficientes do que seus equivalentes em OOP.

Neste tutorial, vamos explorar como adaptar e implementar os padrões de projeto mais úteis em Zig, aproveitando os recursos únicos da linguagem: comptime, ponteiros de função, defer, structs com valores padrão e o poderoso sistema de tipos. Você vai perceber que muitos patterns clássicos se tornam mais simples — ou até desnecessários — quando temos as ferramentas certas.

Como Design Patterns Diferem em Zig

Em linguagens OOP tradicionais, design patterns surgem para contornar limitações do paradigma: a falta de funções de primeira classe leva ao Strategy Pattern com interfaces, a ausência de destruição determinística leva ao RAII com classes, e assim por diante. Zig elimina muitas dessas limitações diretamente na linguagem.

Princípios fundamentais que mudam a abordagem:

  • Sem herança: composição é o caminho natural.
  • Funções são valores: ponteiros de função substituem interfaces de um único método.
  • comptime: metaprogramação substitui reflexão e fábricas complexas.
  • defer/errdefer: gerenciamento de recursos é nativo.
  • Structs com defaults: eliminam a necessidade de builders complexos.

Vamos ver cada padrão na prática.

Builder Pattern com Structs e Valores Padrão

O Builder Pattern em linguagens OOP normalmente envolve uma classe separada com métodos encadeados. Em Zig, structs com valores padrão oferecem uma solução natural e muito mais enxuta.

const std = @import("std");

const HttpRequest = struct {
    method: Method = .GET,
    url: []const u8,
    headers: ?[]const Header = null,
    body: ?[]const u8 = null,
    timeout_ms: u32 = 30_000,
    follow_redirects: bool = true,
    max_redirects: u8 = 10,

    const Method = enum { GET, POST, PUT, DELETE, PATCH };

    const Header = struct {
        name: []const u8,
        value: []const u8,
    };

    pub fn execute(self: HttpRequest) !Response {
        std.debug.print("Executando {s} para {s} (timeout: {}ms)\n", .{
            @tagName(self.method),
            self.url,
            self.timeout_ms,
        });
        // Lógica de execução aqui...
        return Response{ .status = 200 };
    }
};

const Response = struct {
    status: u16,
};

pub fn main() !void {
    // Sem builder — apenas inicialização direta com defaults
    const req = HttpRequest{
        .url = "https://api.exemplo.com/dados",
        .method = .POST,
        .body = "{\"chave\": \"valor\"}",
        .timeout_ms = 5_000,
    };

    _ = try req.execute();

    // Request mínimo — todos os defaults aplicados
    const simple = HttpRequest{
        .url = "https://api.exemplo.com/status",
    };

    _ = try simple.execute();
}

A vantagem é clara: não há uma classe Builder separada, não há métodos encadeados que retornam self, e a inicialização é verificada em tempo de compilação. Se você esquecer um campo obrigatório (como url), o compilador reclama imediatamente.

Strategy Pattern com Ponteiros de Função e Comptime

O Strategy Pattern permite trocar algoritmos em tempo de execução. Em Zig, ponteiros de função são cidadãos de primeira classe, tornando a implementação direta e sem cerimônia.

const std = @import("std");

const SortStrategy = *const fn ([]i32) void;

fn bubbleSort(data: []i32) void {
    for (0..data.len) |i| {
        for (0..data.len - 1 - i) |j| {
            if (data[j] > data[j + 1]) {
                const tmp = data[j];
                data[j] = data[j + 1];
                data[j + 1] = tmp;
            }
        }
    }
}

fn insertionSort(data: []i32) void {
    for (1..data.len) |i| {
        const key = data[i];
        var j: usize = i;
        while (j > 0 and data[j - 1] > key) {
            data[j] = data[j - 1];
            j -= 1;
        }
        data[j] = key;
    }
}

const Sorter = struct {
    strategy: SortStrategy,

    pub fn sort(self: Sorter, data: []i32) void {
        self.strategy(data);
    }
};

pub fn main() void {
    var dados = [_]i32{ 64, 34, 25, 12, 22, 11, 90 };

    // Troca de estratégia em tempo de execução
    var sorter = Sorter{ .strategy = bubbleSort };
    sorter.sort(&dados);

    // Mudar a estratégia é simples
    sorter.strategy = insertionSort;
    var dados2 = [_]i32{ 5, 3, 8, 1, 9, 2 };
    sorter.sort(&dados2);
}

Para estratégias conhecidas em tempo de compilação, comptime permite especialização com zero custo em runtime:

fn createSorter(comptime strategy: SortStrategy) type {
    return struct {
        pub fn sort(data: []i32) void {
            strategy(data);
        }
    };
}

// O compilador gera código especializado — sem indireção
const FastSorter = createSorter(insertionSort);

Iterator Pattern: O Protocolo Nativo de Zig

Zig tem um protocolo de iterador idiomático baseado em uma convenção simples: qualquer struct com um método next() que retorna ?T (um optional) é um iterador. Não há interface formal — é puramente convencional, e funciona perfeitamente com while.

const std = @import("std");

const Range = struct {
    current: usize,
    end: usize,
    step: usize = 1,

    pub fn init(start: usize, end: usize) Range {
        return .{ .current = start, .end = end };
    }

    pub fn initWithStep(start: usize, end: usize, step: usize) Range {
        return .{ .current = start, .end = end, .step = step };
    }

    pub fn next(self: *Range) ?usize {
        if (self.current >= self.end) return null;
        const value = self.current;
        self.current += self.step;
        return value;
    }

    pub fn reset(self: *Range) void {
        self.current = 0;
    }
};

// Iterador que filtra valores pares de outro iterador
fn FilterEven(comptime T: type) type {
    return struct {
        source: *T,

        const Self = @This();

        pub fn next(self: *Self) ?usize {
            while (self.source.next()) |value| {
                if (value % 2 == 0) return value;
            }
            return null;
        }
    };
}

pub fn main() void {
    var range = Range.initWithStep(0, 20, 1);

    // Uso idiomático com while
    while (range.next()) |value| {
        std.debug.print("{} ", .{value});
    }
    std.debug.print("\n", .{});

    // Composição de iteradores
    range.reset();
    var filter = FilterEven(Range){ .source = &range };
    while (filter.next()) |value| {
        std.debug.print("par: {} ", .{value});
    }
    std.debug.print("\n", .{});
}

A beleza desse padrão é que ele é composível: você pode empilhar iteradores de filtragem, mapeamento e transformação sem alocações de heap.

Observer Pattern com Callbacks

O Observer Pattern notifica múltiplos “ouvintes” quando um evento ocorre. Em Zig, usamos um slice de ponteiros de função como lista de observers.

const std = @import("std");

const EventCallback = *const fn (event: Event) void;

const Event = struct {
    kind: EventKind,
    data: []const u8,

    const EventKind = enum {
        file_created,
        file_modified,
        file_deleted,
    };
};

const EventEmitter = struct {
    listeners: std.ArrayList(EventCallback),

    pub fn init(allocator: std.mem.Allocator) EventEmitter {
        return .{
            .listeners = std.ArrayList(EventCallback).init(allocator),
        };
    }

    pub fn deinit(self: *EventEmitter) void {
        self.listeners.deinit();
    }

    pub fn on(self: *EventEmitter, callback: EventCallback) !void {
        try self.listeners.append(callback);
    }

    pub fn emit(self: *EventEmitter, event: Event) void {
        for (self.listeners.items) |listener| {
            listener(event);
        }
    }
};

fn logEvent(event: Event) void {
    std.debug.print("[LOG] Evento: {s} - {s}\n", .{
        @tagName(event.kind),
        event.data,
    });
}

fn notifyUser(event: Event) void {
    std.debug.print("[NOTIFICACAO] {s}\n", .{event.data});
}

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

    var emitter = EventEmitter.init(allocator);
    defer emitter.deinit();

    // Registrar observers
    try emitter.on(logEvent);
    try emitter.on(notifyUser);

    // Emitir evento — todos os observers são notificados
    emitter.emit(.{
        .kind = .file_created,
        .data = "documento.txt criado com sucesso",
    });
}

RAII via defer e errdefer

Em C++, RAII (Resource Acquisition Is Initialization) depende de destrutores. Em Zig, defer e errdefer oferecem o mesmo comportamento de forma explícita e previsível, sem a complexidade de construtores e destrutores implícitos.

const std = @import("std");

const Database = struct {
    connection: []const u8,
    is_open: bool = false,

    pub fn open(addr: []const u8) !Database {
        std.debug.print("Conectando a {s}...\n", .{addr});
        return Database{
            .connection = addr,
            .is_open = true,
        };
    }

    pub fn close(self: *Database) void {
        if (self.is_open) {
            std.debug.print("Fechando conexão com {s}\n", .{self.connection});
            self.is_open = false;
        }
    }

    pub fn query(self: *Database, sql: []const u8) !void {
        if (!self.is_open) return error.ConnectionClosed;
        std.debug.print("Executando: {s}\n", .{sql});
    }
};

pub fn processarDados() !void {
    // Abre recurso
    var db = try Database.open("postgres://localhost/app");
    // defer garante que close() é chamado ao sair do escopo
    // mesmo em caso de erro — equivalente a RAII
    defer db.close();

    // Se esta query falhar, db.close() ainda é chamado
    try db.query("SELECT * FROM usuarios");
    try db.query("UPDATE config SET versao = 2");
}

pub fn criarComRollback(allocator: std.mem.Allocator) ![]u8 {
    const buffer = try allocator.alloc(u8, 1024);
    // errdefer: só executa se a função retornar erro
    // Perfeito para rollback de operações parciais
    errdefer allocator.free(buffer);

    // Se esta operação falhar, buffer é liberado automaticamente
    try preencherBuffer(buffer);

    // Se chegamos aqui, o chamador é responsável pelo buffer
    return buffer;
}

fn preencherBuffer(buffer: []u8) !void {
    @memset(buffer, 0);
}

A vantagem de defer sobre destrutores é a visibilidade: você vê exatamente o que acontece e em qual ordem. Não há surpresas com ordem de destruição ou chamadas implícitas. Para mais detalhes sobre defer e errdefer no contexto de erros, veja Tratamento de Erros em Zig.

Singleton com Variáveis de Container

Em Zig, variáveis no nível de container (no escopo do arquivo ou da struct) servem naturalmente como singletons, sem precisar de classes ou métodos estáticos complexos.

const std = @import("std");

const Config = struct {
    var instance: ?Config = null;

    db_host: []const u8,
    db_port: u16,
    log_level: LogLevel,

    const LogLevel = enum { debug, info, warn, err };

    pub fn get() Config {
        if (instance) |config| {
            return config;
        }
        // Inicialização padrão na primeira chamada
        instance = Config{
            .db_host = "localhost",
            .db_port = 5432,
            .log_level = .info,
        };
        return instance.?;
    }

    pub fn set(config: Config) void {
        instance = config;
    }
};

pub fn main() void {
    const config = Config.get();
    std.debug.print("DB: {s}:{}\n", .{ config.db_host, config.db_port });
}

Importante: variáveis var de container em Zig não são thread-safe por padrão. Para ambientes multi-threaded, considere usar std.Thread.Mutex ou @atomicStore/@atomicLoad.

Type Erasure para Interfaces com anytype e comptime

Zig não tem interfaces no sentido de Java ou Go, mas oferece duas abordagens poderosas para polimorfismo: anytype em comptime e type erasure manual para runtime.

const std = @import("std");

// Abordagem 1: Polimorfismo em comptime com anytype
// Zero custo em runtime — o compilador gera código especializado
fn serialize(writer: anytype, value: anytype) !void {
    const T = @TypeOf(value);

    switch (@typeInfo(T)) {
        .int => try writer.print("{}", .{value}),
        .float => try writer.print("{d:.2}", .{value}),
        .pointer => |ptr| {
            if (ptr.size == .Slice and ptr.child == u8) {
                try writer.print("\"{s}\"", .{value});
            }
        },
        .@"struct" => |info| {
            try writer.writeAll("{ ");
            inline for (info.fields, 0..) |field, i| {
                if (i > 0) try writer.writeAll(", ");
                try writer.print("{s}: ", .{field.name});
                try serialize(writer, @field(value, field.name));
            }
            try writer.writeAll(" }");
        },
        else => try writer.writeAll("(tipo desconhecido)"),
    }
}

// Abordagem 2: Type erasure para polimorfismo em runtime
// Quando você precisa de uma coleção heterogênea
const Writer = struct {
    ptr: *anyopaque,
    writeFn: *const fn (ptr: *anyopaque, data: []const u8) anyerror!void,

    pub fn write(self: Writer, data: []const u8) !void {
        return self.writeFn(self.ptr, data);
    }

    pub fn init(pointer: anytype) Writer {
        const Ptr = @TypeOf(pointer);
        const impl = struct {
            fn write(ptr: *anyopaque, data: []const u8) anyerror!void {
                const self: Ptr = @ptrCast(@alignCast(ptr));
                return self.write(data);
            }
        };
        return .{
            .ptr = pointer,
            .writeFn = impl.write,
        };
    }
};

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    const ponto = .{ .x = @as(i32, 10), .y = @as(i32, 20) };
    try serialize(stdout, ponto);
    try stdout.writeAll("\n");

    try serialize(stdout, @as(f64, 3.14159));
    try stdout.writeAll("\n");
}

A abordagem com anytype é preferida quando os tipos são conhecidos em compilação. Type erasure é reservado para cenários onde você realmente precisa de coleções heterogêneas ou plugins dinâmicos.

Quando NÃO Usar Patterns: A Filosofia de Simplicidade

Zig tem uma filosofia explícita: simplicidade sobre sofisticação. Muitos patterns de OOP existem para contornar problemas que Zig simplesmente não tem.

Padrões que geralmente são desnecessários em Zig:

  • Factory Method: use funções init() simples com parâmetros comptime.
  • Proxy Pattern: ponteiros e slices já fornecem indireção suficiente.
  • Template Method: composição com ponteiros de função resolve.
  • Decorator: funções wrapper são simples e claras.
  • Abstract Factory: comptime com tipos genéricos substitui naturalmente.
// Em vez de uma factory complexa...
fn createAnimal(comptime species: enum { dog, cat }) type {
    return struct {
        name: []const u8,

        pub fn speak(self: @This()) void {
            const sound = switch (species) {
                .dog => "Au au!",
                .cat => "Miau!",
            };
            std.debug.print("{s} diz: {s}\n", .{ self.name, sound });
        }
    };
}

// Uso: simples, direto, sem abstração desnecessária
const Dog = createAnimal(.dog);
const rex = Dog{ .name = "Rex" };

A regra de ouro em Zig: comece com a solução mais simples. Adicione abstração somente quando a complexidade real do problema exigir. Se você está criando uma interface com apenas uma implementação, provavelmente não precisa dela. Se um ponteiro de função resolve, não crie uma struct de type erasure. Zig recompensa a clareza e a simplicidade acima de tudo.

Conclusão

Design patterns em Zig não desaparecem — eles se transformam. A combinação de comptime, ponteiros de função, defer e structs com valores padrão permite expressar os mesmos conceitos de forma mais direta e com menos overhead conceitual. O Iterator Pattern se torna uma simples convenção de next(). O Builder Pattern vira inicialização com defaults. RAII se torna defer. E muitos patterns simplesmente deixam de ser necessários quando a linguagem oferece as primitivas corretas.

A chave é entender o problema que cada pattern resolve e depois encontrar a solução mais idiomática em Zig — que quase sempre será mais simples do que o equivalente em OOP.

Leia Também

Continue aprendendo Zig

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