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âmetroscomptime. - 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:
comptimecom 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.