Aprendendo Zig

Bem-vindo(a) ao livro Aprendendo Zig, uma introdução à linguagem de programação Zig. Este guia tem como objetivo familiarizá-lo(a) com Zig. Ele presume experiência prévia em programação, embora não em uma linguagem específica.

Zig está em desenvolvimento intenso, e tanto a linguagem quanto a sua biblioteca padrão estão em constante evolução. Este guia se destina à versão de desenvolvimento mais recente do Zig. No entanto, é possível que parte do código esteja desatualizada. Se você baixou a versão mais recente da linguagem Zig e encontrou problemas ao executar algum código, por favor, relate o problema1.

Traduções

Índice

  1. Instalação
  2. Visão Geral da Linguagem - Parte 1
  3. Visão Geral da Linguagem - Parte 2
  4. Convenções de Estilização
  5. Ponteiros
  6. Memória de Pilha
  7. Memória Dinâmica & Alocadores
  8. Genéricos (parametrização polimórfica)
  9. Codificando em Zig
  10. Conclusão

Instalação

A página de download do Zig inclui binários pré-compilados para plataformas comuns. Nesta página, você encontrará binários para a versão de desenvolvimento mais recente, bem como para as principais versões. A versão mais recente, seguida por este guia, pode ser encontrada no topo da página.

Para o meu computador, vou baixar zig-macos-aarch64-0.12.0-dev.161+6a5463951.tar.xz. Você pode estar em uma plataforma diferente ou em uma versão mais recente. Após extrair o arquivo, você deve ter um binário zig (além de outras coisas) que desejará criar um alias ou adicionar ao seu caminho; de acordo com o fluxo ao qual está acostumado.

Agora, você deve conseguir executar no terminal os comandos zig zen e zig version para testar sua configuração.

1

Relatar o problema em inglês.

Visão Geral da Linguagem - Parte 1

Zig é uma linguagem compilada fortemente tipada. Ela suporta genéricos (parametrização polimórfica), possui poderosas capacidades de metaprogramação em tempo de compilação e não inclui um coletor de lixo. Muitas pessoas consideram o Zig uma alternativa moderna ao C. Como tal, a sintaxe da linguagem é semelhante à do C. Estamos falando de declarações terminadas por ponto e vírgula e blocos delimitados por chaves.

Aqui está como se parece o código Zig:

const std = @import("std");

// Este código não irá compilar caso a função `main` não seja `pub` (tenha visibilidade pública)
pub fn main() void {
	const user = User{
		.power = 9001,
		.name = "Goku",
	};

	std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});
}

pub const User = struct {
	power: u64,
	name: []const u8,
};

Se você salvar o código acima como learning.zig e executar zig run learning.zig, você deverá ver: Goku's power is 9001.

Este é um exemplo simples, algo que você pode conseguir acompanhar mesmo que seja a primeira vez que você está vendo um codigo em Zig. Ainda assim, vamos analisar linha por linha.

Consulte a seção de instalação do Zig para configurar rapidamente e começar a usar.

Importação

Muito poucos programas são escritos como um único arquivo sem uma biblioteca padrão ou bibliotecas externas. Nosso primeiro programa não é exceção e utiliza a biblioteca padrão do Zig para imprimir nossa saída. O sistema de importação do Zig é direto e depende da função @import e da palavra-chave pub (para tornar o código acessível fora do arquivo atual).

Funções que começam com @ são funções integradas (nativas em nível de compilador). Estas são fornecidas pelo compilador, constrastando com aquelas fornecidas pela biblioteca padrão.

Importamos um módulo especificando o nome do módulo. A biblioteca padrão do Zig está disponível usando o nome "std". Para importar um arquivo específico, utilizamos o seu caminho relativo ao arquivo que está fazendo a importação. Por exemplo, se movermos a estrutura User para seu próprio arquivo, digamos models/user.zig:

// models/user.zig
pub const User = struct {
	power: u64,
	name: []const u8,
};

Então, o importaríamos da seguinte forma:

// main.zig
const User = @import("models/user.zig").User;

Se nosso struct User não estiver marcada como pub, receberemos o seguinte erro: 'User' não está marcada como 'pub'.

models/user.zig pode exportar mais de uma coisa. Por exemplo, também poderíamos exportar uma constante:

// models/user.zig
pub const MAX_POWER = 100_000;

pub const User = struct {
	power: u64,
	name: []const u8,
};

Neste caso, poderíamos importar ambos:

const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER

Neste ponto, você pode ter mais perguntas do que respostas. No trecho acima, o que é user? Ainda não o vimos, mas e se usarmos var em vez de const? Ou talvez você esteja se perguntando como usar bibliotecas de terceiros. São todas boas perguntas, mas para respondê-las, primeiro precisamos aprender mais sobre Zig. Por enquanto, teremos que ficar satisfeitos com o que aprendemos: como importar a biblioteca padrão do Zig, como importar outros arquivos e como exportar definições.

Comentários

A próxima linha no nosso exemplo Zig é um comentário:

// Este código não irá compilar caso a função `main` não seja `pub` (tenha visibilidade pública)

O Zig não tem comentários de várias linhas, como os /* ... */ em C.

Existe suporte experimental para geração automatizada de documentação com base em comentários. Se você já viu a documentação da biblioteca padrão do Zig, então você já viu isso em ação. //! é conhecido como um comentário de documento de nível superior e pode ser colocado no início do arquivo. Um comentário de três barras (///), conhecido como comentário de documento, pode ser colocado em lugares específicos, como antes de uma declaração. Você receberá um erro do compilador se tentar usar qualquer tipo de comentário de documento no lugar errado.

Funções

Nossa próxima linha de código é o início da nossa função principal (main):

pub fn main() void

Todo executável precisa de uma função chamada main: é o ponto de entrada do programa. Se renomeássemos main para algo diferente, como doIt, e tentássemos executar zig run learning.zig, receberíamos um erro: 'learning' has no member named 'main' (dizendo que 'learning' não tem um membro chamado 'main').

Ignorando o papel especial de main como o ponto de entrada do nosso programa, é uma função bastante básica: não recebe parâmetros e não retorna nada, ou seja, void. O seguinte é um pouco mais interessante:

const std = @import("std");

pub fn main() void {
	const sum = add(8999, 2);
	std.debug.print("8999 + 2 = {d}\n", .{sum});
}

fn add(a: i64, b: i64) i64 {
	return a + b;
}

Programadores de C e C++ perceberão que em Zig não se exige declarações pré-definidas, ou seja, a função add é chamada antes de ser definida.

A próxima coisa a notar é o tipo i64: um inteiro de 64 bits com marcação. Alguns outros tipos numéricos são: u8, i8, u16, i16, u32, i32, u47, i47, u64, i64, f32 e f64. A inclusão de u47 e i47 não é um teste para garantir que você ainda está acordado; Zig suporta inteiros de tamanho arbitrário em bits. Embora você provavelmente não os use com frequência, eles podem ser úteis. Um tipo que você usará com frequência é usize, que é um inteiro sem marcação do tamanho de um ponteiro e geralmente o tipo que representa o comprimento/tamanho de algo.

Além de f32 e f64, Zig também suporta os tipos de ponto flutuante f16, f80 e f128.

Embora não haja uma boa razão para fazer isso, se mudarmos a implementação de add para:

fn add(a: i64, b: i64) i64 {
	a += b;
	return a;
}

Vamos obter um erro em a += b;: cannot assign to constant (não é possível atribuir a uma constante). Esta é uma lição importante que revisaremos com mais detalhes posteriormente: os parâmetros de função são constantes.

Para melhorar a legibilidade, não há sobrecarga de funções (a mesma função nomeada definida com tipos de parâmetros e/ou número de parâmetros diferentes). Por enquanto, isso é tudo o que precisamos saber sobre funções.

Estruturas (struct)

A próxima linha de código é a criação de um User, um tipo que é definido no final do nosso trecho. A definição de User é:

pub const User = struct {
	power: u64,
	name: []const u8,
};

Como nosso programa é um único arquivo e, portanto, User é usado apenas no arquivo onde é definido, não precisávamos torná-lo pub. Mas, então, não teríamos visto como expor uma declaração para outros arquivos.

Os campos de um struct são terminados com uma vírgula e podem ser atribuídos um valor padrão:

pub const User = struct {
	power: u64 = 0,
	name: []const u8,
};

Quando criamos um struct, cada campo deve ser definido. Por exemplo, na definição original, onde power não tinha um valor padrão, o seguinte geraria um erro: missing struct field: power.

const user = User{.name = "Goku"};

No entanto, com o nosso valor padrão, o código acima compila normalmente.

Structs podem ter métodos, podem conter declarações (incluindo outros structs) e até mesmo podem não ter nenhum campo, nesse caso agindo mais como um namespace.

pub const User = struct {
	power: u64 = 0,
	name: []const u8,

	pub const SUPER_POWER = 9000;

	fn diagnose(user: User) void {
		if (user.power >= SUPER_POWER) {
			std.debug.print("it's over {d}!!!", .{SUPER_POWER});
		}
	}
};

Métodos são apenas funções normais que podem ser chamadas com uma sintaxe de ponto. Ambas funções funcionam:

// Chamar a função através da variável
user.diagnose();

// É o mesmo que chamar a função através do tipo, passando a variável como argumento
User.diagnose(user);

Na maioria das vezes, você usará a sintaxe de ponto, mas os métodos como uma espécie simplificação na sintaxe sobre funções normais podem ser úteis.

A instrução if é o primeiro controle de fluxo que vimos. É bastante direta, não é? Vamos explorar isso com mais detalhes na próxima parte.

A função diagnose é definida dentro do nosso tipo User e aceita um User como seu primeiro parâmetro. Como tal, podemos chamá-la com a sintaxe de ponto. Mas as funções dentro de uma estrutura não precisam seguir esse padrão. Um exemplo comum é ter uma função init para iniciar nossa estrutura:

pub const User = struct {
	power: u64 = 0,
	name: []const u8,

	pub fn init(name: []const u8, power: u64) User {
		return User{
			.name = name,
			.power = power,
		};
	}
}

O uso de init é apenas uma convenção e, em alguns casos, open ou algum outro nome pode fazer mais sentido. Se você, como eu, não é um(a) programador(a) de C++, a sintaxe para inicializar campos, .$campo = $valor, pode parecer um pouco estranha, mas você se acostumará rapidamente.

Quando criamos "Goku", declaramos a variável user como const:

const user = User{
	.power = 9001,
	.name = "Goku",
};

Isso significa que não podemos modificar user. Para modificar uma variável, ela deve ser declarada usando var. Além disso, você pode ter notado que o tipo de user é inferido com base no que é atribuído a ele. Poderíamos ser explícitos:

const user: User = User{
	.power = 9001,
	.name = "Goku",
};

Veremos casos em que precisamos ser explícitos sobre o tipo de uma variável, mas na maioria das vezes, o código é mais legível sem o tipo explícito. A inferência de tipo funciona da mesma forma. Isso é equivalente a ambos os trechos acima:

const user: User = .{
	.power = 9001,
	.name = "Goku",
};

No entanto, essa forma de uso é bastante incomum. Um lugar onde é mais comum é ao retornar uma estrutura de uma função. Aqui, o tipo pode ser inferido a partir do tipo de retorno da função. Nossa função init provavelmente seria escrita assim:

pub fn init(name: []const u8, power: u64) User {
	// Ao invés de retornar "User{...}"
	return .{
		.name = name,
		.power = power,
	};
}

Como a maioria das coisas que exploramos até agora, revisaremos structs no futuro quando falarmos sobre outras partes da linguagem. Mas, na maior parte, são bem simples.

Vetores (arrays) e Segmentos (slices)

Podemos pular a última linha do nosso código, mas dado que nosso pequeno trecho contém duas strings (cadeia de caraceteres), "Goku" e "{s}'s power is {d}\n", você provavelmente está curioso sobre como funcionam as strings em Zig. Para entender melhor as strings, vamos primeiro explorar arrays e slices.

Arrays possuem tamanho fixo com um comprimento conhecido em tempo de compilação. O comprimento faz parte do tipo, portanto, um array de 4 inteiros assinados, [4]i32, é um tipo diferente de um array de 5 inteiros assinados, [5]i32.

O comprimento do array pode ser inferido a partir da inicialização. No código a seguir, todas as três variáveis são do tipo [5]i32:

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

// nós já vimos esta .{...} sintaxe com structs
// ela também funciona com arrays
const b: [5]i32 = .{1, 2, 3, 4, 5};

// use a notação _ para deixar o compilador inferir o comprimento do array
const c = [_]i32{1, 2, 3, 4, 5};

Um slice (segmento), por outro lado, é um ponteiro para um array com um comprimento. O comprimento é conhecido em tempo de execução. Abordaremos ponteiros em uma parte posterior, mas você pode pensar em um slice como uma visão (segmento) de parte do array.

Se você está familiarizado com Go, pode ter notado que as slices em Zig são um pouco diferentes: elas não têm uma capacidade, apenas um ponteiro e um comprimento.

Dado o seguinte bloco de código,

const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];

Eu adoraria poder te dizer que b é um slice com um comprimento de 3 e um ponteiro para a. No entanto, porque "fatiamos" nosso array usando valores conhecidos em tempo de compilação, ou seja, 1 e 4, nosso comprimento, 3, também é conhecido em tempo de compilação. O compilador do Zig analisa tudo isso e, portanto, b não é realmente um slice, mas sim um ponteiro para um array de inteiros com um comprimento de 3. Especificamente, seu tipo é *const [3]i32. Portanto, esta demonstração de um slice é frustrada pela engenhosidade e capacidade de análise do compilador do Zig.

Em uma bse de código real, você provavelmente usará mais slices do que arrays. Para o bem ou para o mal, os programas tendem a ter mais informações em tempo de execução do que em tempo de compilação. Em um exemplo pequeno, no entanto, precisamos enganar o compilador para obter o que queremos:

const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 4;
const b = a[1..end];

Agora, b é, de fato, um slice. Mais especificamente, seu tipo é []const i32. Você pode perceber que o comprimento do slice não faz parte do tipo, porque o comprimento é uma propriedade em tempo de execução, e os tipos são sempre totalmente conhecidos em tempo de compilação. Ao criar um slice, podemos omitir o limite superior para criar um slice até o final do que estamos fatiando (seja um array ou um slice), por exemplo, const c = b[2..];.

Se tivéssemos declarado end como const, ela teria se tornado um valor conhecido em tempo de compilação, o que teria resultado em um comprimento conhecido em tempo de compilação para b e, portanto, criado um ponteiro para um array, e não um slice. Eu acho isso um pouco confuso, mas não é algo que surge com muita frequência e não é muito difícil de dominar. Eu adoraria pular isso neste momento, mas não consegui encontrar uma maneira legítima de evitar esse detalhe.

Aprender a linguagem Zig me ensinou que os tipos são muito descritivos. Não é apenas um número inteiro ou um booleano, ou mesmo um array de inteiros de 32 bits com sinal. Os tipos também contêm outras informações importantes. Já falamos sobre o comprimento fazer parte do tipo de um array, e muitos dos exemplos mostraram como a constância também faz parte dele. Por exemplo, em nosso último exemplo, o tipo de b é []const i32. Você pode verificar isso por si mesmo com o seguinte código:

const std = @import("std");

pub fn main() void {
	const a = [_]i32{1, 2, 3, 4, 5};
	var end: usize = 4;
	const b = a[1..end];
	std.debug.print("{any}", .{@TypeOf(b)});
}

Se tentássemos escrever em b, como por exemplo b[2] = 5;, obteríamos um erro de compilação: "cannot assign to constant" (não é possível atribuir a uma constante). Isso ocorre devido ao tipo de b.

Para resolver isso, você pode ser tentado a fazer a seguinte alteração:

// substituição de const por var
var b = a[1..end];

Mas você obterá o mesmo erro, por quê? Como dica, fica a questão: qual é o tipo de b, ou mais genericamente, o que é b? Um slice é um comprimento e um ponteiro para parte de um array. O tipo de um slice é sempre derivado do array subjacente. Se b for declarado como const ou não, o array subjacente é do tipo [5]const i32, e assim b deve ser do tipo []const i32. Se quisermos poder escrever em b, precisamos alterar a de const para var.

const std = @import("std");

pub fn main() void {
	var a = [_]i32{1, 2, 3, 4, 5};
	var end: usize = 4;
	const b = a[1..end];
	b[2] = 99;
}

Isso funciona porque nosso slice não é mais []const i32, mas sim []i32. Você pode estar se perguntando por que isso funciona quando b ainda é const (constante). Mas a constância de b está relacionada a b em si, não aos dados que b aponta. Bem, não tenho certeza se essa é uma ótima explicação, mas para mim, este código destaca a diferença:

const std = @import("std");

pub fn main() void {
	var a = [_]i32{1, 2, 3, 4, 5};
	var end: usize = 4;
	const b = a[1..end];
	b = b[1..];
}

Este código não irá compilar; como o compilador nos informa: "cannot assign to constant" (não podemos atribuir a uma constante). Mas se tivéssemos feito var b = a[1..end];, então o código teria funcionado porque b em si já não é uma constante.

Vamos descobrir mais sobre arrays e slices enquanto exploramos outros aspectos da linguagem, incluindo strings, que não são menos importantes.

Cadeia de caracteres (strings)

Eu gostaria de poder dizer que Zig possui um tipo de string e que é incrível. Infelizmente, não é o caso. Em sua forma mais simples, as strings em Zig são sequências (ou seja, arrays ou slices) de bytes (u8). Vimos isso na definição do campo name: name: []const u8.

Por convenção, e apenas por convenção, essas strings devem conter apenas valores UTF-8, já que o código-fonte Zig é ele mesmo codificado em UTF-8. No entanto, isso não é imposto pelo compilador, e não há realmente nenhuma diferença entre um []const u8 que representa uma string ASCII ou UTF-8 e um []const u8 que representa dados binários arbitrários. Como poderia haver, eles são do mesmo tipo.

Com base no que aprendemos sobre arrays e slices, você estaria correto ao supor que []const u8 é uma slice de um array constante de bytes (onde um byte é um inteiro sem sinal de 8 bits). Mas em nenhum lugar do nosso código fatiamos um array ou mesmo tivemos um array, certo? Tudo o que fizemos foi atribuir "Goku" a user.name. Como isso funcionou?

As literais de string, aquelas que você vê no código-fonte, têm um comprimento conhecido em tempo de compilação. O compilador sabe que "Goku" tem um comprimento de 4. Então, você estaria próximo ao pensar que "Goku" é melhor representado por um array, algo como [4]const u8. Mas as literais de string têm algumas propriedades especiais. Elas são armazenadas em um local especial dentro do binário e são deduplicadas. Portanto, uma variável para uma literal de string será um ponteiro para este local especial. Isso significa que o tipo de "Goku" está mais próximo de *const [4]u8, um ponteiro para um array constante de 4 bytes.

Tem mais. As literais de string são terminadas por um caractere nulo. Ou seja, elas sempre têm um \0 no final. Strings terminadas por nulo são importantes ao interagir com C. Na memória, "Goku" realmente se pareceria com: {'G', 'o', 'k', 'u', 0}, então você poderia pensar que o tipo é *const [5]u8. Mas isso seria ambíguo na melhor das hipóteses e perigoso na pior das hipóteses (você poderia sobrescrever o terminador nulo). Em vez disso, Zig tem uma sintaxe distinta para representar arrays terminados por nulo. "Goku" tem o tipo: *const [4:0]u8, um ponteiro para um array de 4 bytes terminado por nulo. Embora estejamos falando sobre strings, estamos nos concentrando em arrays de bytes terminados por nulo (já que é assim que as strings são tipicamente representadas em C), a sintaxe é mais genérica: [COMPRIMENTO:MARCADOR], onde "MARCADOR" é o valor especial encontrado no final do array. Então, embora eu não consiga pensar em um motivo para isso, o seguinte é completamente válido:

const std = @import("std");

pub fn main() void {
    // um array de 3 booleanos com false sendo o marcador
	const a = [3:false]bool{false, true, false};

	// Esta linha de código é mais avançada e não será explicada!
	std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}

Cuja saída será: { 0, 1, 0, 0}.

Eu hesitei em incluir este exemplo, já que a última linha é bastante avançada e eu não pretendo explicá-la. Por outro lado, é um exemplo funcional que você pode executar e experimentar para examinar melhor alguns dos conceitos que discutimos até agora, se você estiver interessado.

Se eu consegui explicar isso de forma aceitável, provavelmente ainda há uma coisa da qual você está incerto. Se "Goku" é um *const [4:0]u8, como conseguimos atribuí-lo a name, um []const u8? A resposta é simples: o Zig fará a coerção de tipo para você. Ele fará isso entre alguns tipos diferentes, mas é mais óbvio com strings. Isso significa que se uma função tiver um parâmetro []const u8, ou uma estrutura tiver um campo []const u8, literais de string podem ser usadas. Como as strings terminadas por nulo são arrays, e os arrays têm um comprimento conhecido, essa coerção é barata, ou seja, não requer a iteração pela string para encontrar o terminador nulo.

Portanto, ao falar sobre strings, geralmente nos referimos a um []const u8. Quando necessário, explicitamente mencionamos uma string terminada por nulo, que pode ser automaticamente coercida para um []const u8. Mas lembre-se de que um []const u8 também é usado para representar dados binários arbitrários, e, como tal, o Zig não tem a noção de uma string como as linguagens de programação de nível mais alto têm. Além disso, a biblioteca padrão do Zig possui apenas um módulo unicode muito básico.

Claro, em um programa real, a maioria das strings (e de forma mais genérica, arrays) não são conhecidas em tempo de compilação. O exemplo clássico é a entrada do usuário, que não é conhecida quando o programa está sendo compilado. Isso é algo que teremos que revisitar ao falar sobre memória. Mas a resposta curta é que, para esses dados, que têm um valor desconhecido em tempo de compilação e, portanto, um comprimento desconhecido, alocaremos dinamicamente memória em tempo de execução. Nossas variáveis de string, ainda do tipo []const u8, serão slices que apontam para essa memória alocada dinamicamente.

Palavras-chave comptime e anytype

Há muito mais acontecendo na última linha de código não explorada:

std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});

Vamos apenas dar uma olhada superficial, mas isso oferece a oportunidade de destacar alguns dos recursos mais avançados do Zig. Essas são coisas das quais você pelo menos deve estar ciente, mesmo que não as tenha dominado.

O primeiro é o conceito em Zig de execução em tempo de compilação, ou comptime. Isso é fundamental para as capacidades de metaprogramação do Zig e, como o nome sugere, envolve a execução de código em tempo de compilação, em vez de tempo de execução. Ao longo deste guia, apenas arranharemos a superfície do que é possível com comptime, mas é algo que está sempre presente.

Você pode estar se perguntando o que há na linha acima que exige a execução em tempo de compilação. A definição da função print exige que nosso primeiro parâmetro, o formato da string, seja conhecido em tempo de compilação:

// perceba "comptime" antes da variável "fmt"
pub fn print(comptime fmt: []const u8, args: anytype) void {

E a razão para isso é que a função print realiza verificações extras em tempo de compilação que você não teria na maioria das outras linguagens. Que tipo de verificações? Bem, digamos que você altere o formato para "it's over {d}\n", mas mantenha os dois argumentos. Você receberá um erro de compilação: unused argument in 'it's over {d}' (argumento não utilizado em 'it's over {d}'). Ela também faz verificações de tipo: altere a string de formato para "{s}'s power is {s}\n" e você receberá: invalid format string 's' for type 'u64' (string de formato inválida 's' para o tipo 'u64'). Essas verificações não seriam possíveis de serem feitas em tempo de compilação se o formato da string não fosse conhecido em tempo de compilação. Daí a necessidade de um valor conhecido em tempo de compilação.

O único lugar onde o comptime impactará imediatamente o seu código são os tipos padrão para literais de inteiros e ponto flutuante, os tipos especiais comptime_int e comptime_float. Esta linha de código não é válida: var i = 0;. Você receberá um erro de compilação: variable of type 'comptime_int' must be const or comptime (a variável do tipo 'comptime_int' deve ser const ou comptime). O código comptime só pode trabalhar com dados que são conhecidos em tempo de compilação e, para inteiros e pontos-flutuantes, esses dados são identificados pelos tipos especiais comptime_int e comptime_float. Um valor desse tipo pode ser usado na execução em tempo de compilação. Mas provavelmente você não passará a maior parte do seu tempo escrevendo código para a execução em tempo de compilação, então isso não é particularmente útil por padrão. O que você precisará fazer é dar um tipo explícito às suas variáveis:

var i: usize = 0;
var j: f64 = 0;

Observe que esse erro ocorreu apenas porque usamos var. Se tivéssemos usado const, não teríamos recebido o erro, já que o ponto central do erro é que um comptime_int deve ser constante.

Em uma parte futura, examinaremos o comptime um pouco mais ao explorar os genéricos.

A outra coisa especial sobre nossa linha de código é o estranho . {user.name, user.power}, que, a partir da definição de print acima, sabemos que mapeia para uma variável do tipo anytype. Esse tipo não deve ser confundido com algo como o Object em Java ou o any (também conhecido como interface{}) em Go. Em vez disso, em tempo de compilação, o Zig criará uma versão da função print especificamente para todos os tipos que foram passados para ela.

Isso nos leva à pergunta: o que estamos passando para ela? Já vimos a notação .{ ... } antes, ao permitir que o compilador infira o tipo da nossa estrutura. Isso é semelhante: cria um literal de estrutura anônima. Considere este código:

pub fn main() void {
	std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
}

cuja saída no terminal será:

struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}

Aqui, demos nomes aos campos de nossa estrutura anônima, year e month. Em nosso código original, não o fizemos. Nesse caso, os nomes dos campos são gerados automaticamente como "0", "1", "2", etc. A função print espera uma estrutura com esses campos e usa a posição ordinal na string de formato para obter o argumento apropriado.

Zig não possui sobrecarga de funções, e não possui funções variádicas (funções com um número arbitrário de argumentos). Mas ele tem um compilador capaz de criar funções especializadas com base nos tipos passados, incluindo tipos inferidos e criados pelo próprio compilador.

Visão Geral da Linguagem - Parte 2

Esta parte continua de onde a anterior parou: familiarizando-nos com a linguagem. Vamos explorar o fluxo de controle e tipos do Zig além das estruturas. Juntamente com a primeira parte, teremos coberto a maior parte da sintaxe da linguagem, permitindo-nos abordar mais aspectos da linguagem e da biblioteca padrão.

Fluxo de controle

O fluxo de controle em Zig é provavelmente familiar, mas com sinergias adicionais em relação a aspectos da linguagem que ainda não exploramos. Vamos começar com uma visão geral rápida do fluxo de controle e voltaremos a isso quando discutirmos recursos que geram comportamentos especiais de fluxo de controle.

Você notará que, em vez dos operadores lógicos && e ||, usamos and e or. Como na maioria das linguagens, and e or controlam o fluxo de execução: eles têm "curto-circuito" (interrompe e modificam o fluxo de execução do programa). O lado direito de um and não é avaliado se o lado esquerdo for false, e o lado direito de um or não é avaliado se o lado esquerdo for true. Em Zig, o fluxo de controle é realizado com palavras-chave, e, portanto, and e or são usados.

Além disso, o operador de comparação, ==, não funciona entre slices, como []const u8, ou seja, strings. Na maioria dos casos, você usará std.mem.eql(u8, str1, str2), que comparará o comprimento e, em seguida, os bytes das duas slices.

O if, else if e else em Zig são comuns:

// std.mem.eql faz uma comparação byte-a-byte
// no caso de uma string, é uma comparação sensitiva
if (std.mem.eql(u8, method, "GET") or std.mem.eql(u8, method, "HEAD")) {
	// lidar com a requisição GET
} else if (std.mem.eql(u8, method, "POST")) {
	// lidar com a requisição POST
} else {
	// ...
}

O primeiro argumento para std.mem.eql é um tipo, neste caso, u8. Este é o primeiro exemplo de uma função genérica que vimos. Vamos explorar isso mais detalhadamente em uma parte posterior.

O exemplo acima está comparando strings ASCII e provavelmente deveria ser insensível a maiúsculas e minúsculas. std.ascii.eqlIgnoreCase(str1, str2) é provavelmente uma opção melhor.

Não há operador ternário, mas você pode usar um if/else da seguinte forma:

const super = if (power > 9000) true else false;

switch é semelhante a um if/else if/else, mas tem a vantagem de ser exaustivo. Ou seja, é um erro de compilação se nem todos os casos forem tratados. Este código não será compilado:

fn anniversaryName(years_married: u16) []const u8 {
	switch (years_married) {
		1 => return "paper",
		2 => return "cotton",
		3 => return "leather",
		4 => return "flower",
		5 => return "wood",
		6 => return "sugar",
	}
}

Nos é dito: o switch deve lidar com todas as possibilidades. Como nosso years_married é um inteiro de 16 bits, isso significa que precisamos lidar com todos os 64 mil casos? Sim, mas felizmente há um else:

// ...
6 => return "sugar",
else => return "no more gifts for you",

Podemos combinar vários casos ou usar intervalos, e usar blocos para casos complexos:

fn arrivalTimeDesc(minutes: u16, is_late: bool) []const u8 {
	switch (minutes) {
		0 => return "arrived",
		1, 2 => return "soon",
		3...5 => return "no more than 5 minutes",
		else => {
			if (!is_late) {
				return "sorry, it'll be a while";
			}
			// a fazer, algo está muito errado
			return "never";
		},
	}
}

Embora um switch seja útil em vários casos, sua natureza exaustiva realmente se destaca ao lidar com enums, sobre as quais falaremos em breve.

O loop for do Zig é usado para iterar sobre arrays, slices e intervalos. Por exemplo, para verificar se um array contém um valor, poderíamos escrever:

fn contains(haystack: []const u32, needle: u32) bool {
	for (haystack) |value| {
		if (needle == value) {
			return true;
		}
	}
	return false;
}

Os loops for podem funcionar em várias sequências ao mesmo tempo, contanto que essas sequências tenham o mesmo comprimento. Acima, usamos a função std.mem.eql. Veja como ela (quase) se parece:

pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
	// se não tiverem o mesmo comprimento, não pode ser iguais
	if (a.len != b.len) return false;

	for (a, b) |a_elem, b_elem| {
		if (a_elem != b_elem) return false;
	}

	return true;
}

A verificação inicial do if não é apenas uma otimização de desempenho agradável, é uma proteção necessária. Se a retirarmos e passarmos argumentos de comprimentos diferentes, teremos um pânico em tempo de execução: loop for sobre objetos com comprimentos não iguais.

Os loops for também podem iterar sobre intervalos, como:

for (0..10) |i| {
	std.debug.print("{d}\n", .{i});
}

Nosso switch usou três pontos, 3...6, enquanto este intervalo usa dois, 0..10. Isso ocorre porque os casos do switch são inclusivos de ambos os números, enquanto o for é exclusivo do limite superior.

Isso realmente se destaca em combinação com uma (ou mais!) sequência:

fn indexOf(haystack: []const u32, needle: u32) ?usize {
	for (haystack, 0..) |value, i| {
		if (needle == value) {
			return i;
		}
	}
	return null;
}

Isso é uma prévia de tipos nulos.

O final do intervalo é inferido a partir do comprimento de haystack, embora pudéssemos nos punir e escrever: 0..haystack.len. Loops for não suportam a forma mais genérica do idioma init; compare; step. Para isso, contamos com o while.

Como o while é mais simples, tomando a forma de while (condição) { }, temos um maior controle sobre a iteração. Por exemplo, ao contar o número de sequências de escape em uma string, precisamos incrementar nosso iterador em 2 para evitar contar duas vezes uma \\:

var i: usize = 0;
var escape_count: usize = 0;
while (i < src.len) {
	if (src[i] == '\\') {
		i += 2;
		escape_count += 1;
	} else {
		i += 1;
	}
}

Um while pode ter uma cláusula else, que é executada quando a condição é falsa. Ele também aceita uma declaração para ser executada após cada iteração. Esse recurso era comumente usado antes do for suportar várias sequências. O exemplo acima pode ser escrito como:

var i: usize = 0;
var escape_count: usize = 0;

//                  esta parte
while (i < src.len) : (i += 1) {
	if (src[i] == '\\') {
		// +1 aqui, e +1 acima == +2
		i += 1;
		escape_count += 1;
	}
}

break e continue são suportados para interromper o loop mais interno ou pular para a próxima iteração.

Blocos podem ser rotulados e break e continue podem direcionar um rótulo específico. Um exemplo artificial:

outer: for (1..10) |i| {
	for (i..10) |j| {
		if (i * j > (i+i + j+j)) continue :outer;
		std.debug.print("{d} + {d} >= {d} * {d}\n", .{i+i, j+j, i, j});
	}
}

break tem outro comportamento interessante, que é o de retornar um valor de um bloco:

const personality_analysis = blk: {
	if (tea_vote > coffee_vote) break :blk "sane";
	if (tea_vote == coffee_vote) break :blk "whatever";
	if (tea_vote < coffee_vote) break :blk "dangerous";
};

Blocos de código como este devem ser terminados com ponto e vírgula.

Mais tarde, ao explorarmos uniões marcadas, uniões de erros e tipos opcionais, veremos o que mais essas estruturas de fluxo de controle têm a oferecer.

Enumerações (enums)

Enumerações são constantes inteiras que recebem um rótulo. Eles são definidos de maneira semelhante a um struct:

// poderia ser "pub"
const Status = enum {
	ok,
	bad,
	unknown,
};

E, assim como um struct, pode conter outras definições, incluindo funções que podem ou não receber o enum como parâmetro:

const Stage = enum {
	validate,
	awaiting_confirmation,
	confirmed,
	completed,
	err,

	fn isComplete(self: Stage) bool {
		return self == .confirmed or self == .err;
	}
};

Se você deseja a representação de string de um enum, pode usar a função embutida @tagName(enum).

Lembre-se de que os tipos de struct podem ser inferidos com base no tipo atribuído ou no tipo de retorno usando a notação .{...}. Acima, vemos o tipo de enum sendo inferido com base em sua comparação com self, que é do tipo Stage. Poderíamos ter sido explícitos e escrito: return self == Stage.confirmed or self == Stage.err;. Mas, ao lidar com enums, você frequentemente verá o tipo de enum omitido via a notação .$value.

A natureza exaustiva do switch combina bem com enums, pois garante que você tratou todos os casos possíveis. Tenha cuidado ao usar a cláusula else de um switch, pois ela corresponderá a qualquer valor de enum recém-adicionado, o que pode ou não ser o comportamento desejado.

Uniões marcadas (tagged unions)

Uma união define um conjunto de tipos que um valor pode ter. Por exemplo, esta união Number pode ser um integer (número inteiro), um número float (ponto flutuante) ou um NaN (não é um número):

const std = @import("std");

pub fn main() void {
	const n = Number{.int = 32};
	std.debug.print("{d}\n", .{n.int});
}

const Number = union {
	int: i64,
	float: f64,
	nan: void,
};

Uma união pode ter apenas um campo definido por vez; é um erro tentar acessar um campo não definido. Como definimos o campo int, se tentássemos acessar n.float em seguida, receberíamos um erro. Um de nossos campos, nan, tem um tipo void. Como definiríamos o seu valor? Utilize {}:

const n = Number{.nan = {}};

Um desafio com uniões é saber qual campo está definido. É aí que as uniões marcadas entram em jogo. Uma união marcada combina um enum com uma união, que pode ser usada em uma instrução switch. Considere este exemplo:

pub fn main() void {
	const ts = Timestamp{.unix = 1693278411};
	std.debug.print("{d}\n", .{ts.seconds()});
}

const TimestampType = enum {
	unix,
	datetime,
};

const Timestamp = union(TimestampType) {
	unix: i32,
	datetime: DateTime,

	const DateTime = struct {
		year: u16,
		month: u8,
		day: u8,
		hour: u8,
		minute: u8,
		second: u8,
	};

	fn seconds(self: Timestamp) u16 {
		switch (self) {
			.datetime => |dt| return dt.second,
			.unix => |ts| {
				const seconds_since_midnight: i32 = @rem(ts, 86400);
				return @intCast(@rem(seconds_since_midnight, 60));
			},
		}
	}
};

Observe que cada caso em nosso switch captura o valor tipado do campo. Ou seja, dt é um Timestamp.DateTime e ts é um i32. Esta é também a primeira vez que vemos uma estrutura aninhada dentro de outro tipo. DateTime poderia ter sido definido fora da união. Também estamos vendo duas novas funções embutidas: @rem para obter o resto e @intCast para converter o resultado para um u16 (@intCast infere que queremos um u16 a partir do nosso tipo de retorno, uma vez que o valor está sendo retornado).

Como podemos ver no exemplo acima, uniões marcadas podem ser usadas de alguma forma como interfaces, desde que todas as implementações possíveis sejam conhecidas antecipadamente e possam ser incorporadas na união marcada.

Finalmente, o tipo de enum de uma união marcada pode ser inferido. Em vez de definir um TimestampType, poderíamos ter feito:

const Timestamp = union(enum) {
	unix: i32,
	datetime: DateTime,

	...

e o Zig teria criado um enum implícito com base nos campos da nossa união.

Opcionais

Qualquer valor pode ser declarado como opcional adicionando um ponto de interrogação, ?, ao tipo. Tipos opcionais podem ser null ou um valor do tipo definido:

var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";

A necessidade de ter um tipo explícito deve estar clara: se tivéssemos apenas feito const name = "Leto";, então o tipo inferido seria o não opcional []const u8.

.? é usado para acessar o valor por trás do tipo opcional:

std.debug.print("{s}\n", .{name.?});

Mas teremos um pânico em tempo de execução se usarmos .? em um valor nulo. Uma instrução if pode desempacotar com segurança um valor opcional:

if (home) |h| {
	// h é um []const u8
	// temos um valor para "home"
} else {
	// não temos um valor para "home"
}

orelse pode ser usado para desempacotar o valor opcional ou executar código. Isso é comumente usado para especificar um valor padrão ou retornar da função:

const h = home orelse "unknown"
// ou talvez

// retornar nossa função
const h = home orelse return;

No entanto, orelse também pode receber um bloco e executar lógica mais complexa. Tipos opcionais também se integram com while e são frequentemente usados para criar iteradores. Não implementaremos um iterador aqui, mas espero que este código fictício faça sentido:

while (rows.next()) |row| {
	// realizar alguma operação com "row"
}

Tipo indefinido (undefined)

Até agora, cada variável que vimos foi inicializada com um valor sensato. Mas às vezes não conhecemos o valor de uma variável quando ela é declarada. Tipos opcionais são uma opção, mas nem sempre fazem sentido. Nestes casos, podemos definir variáveis como undefined para deixá-las não inicializadas.

Um lugar comum para fazer isso é ao criar uma array que será preenchida por alguma função:

var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);

O código acima ainda cria uma array de 16 bytes, mas deixa a memória não inicializada.

Erros

Zig possui capacidades simples e pragmáticas para tratamento de erros. Tudo começa com conjuntos de erros, que se parecem e se comportam como enums:

// Assim como nosso struct na Parte 1, "OpenError" pode ser marcado como "pub"
// para torná-lo acessível do lado de fora do arquivo em que está definido
const OpenError = error {
	AccessDenied,
	NotFound,
};

A função, incluindo o main, pode agora retornar este erro:

pub fn main() void {
	return OpenError.AccessDenied;
}

const OpenError = error {
	AccessDenied,
	NotFound,
};

Se você tentar executar isso, receberá um erro: "expected type 'void', found 'error{AccessDenied,NotFound}'". Isso faz sentido: definimos o main com um tipo de retorno void, mas estamos retornando algo (um erro, claro, mas isso ainda não é void). Para resolver isso, precisamos alterar o tipo de retorno de nossa função.

pub fn main() OpenError!void {
	return OpenError.AccessDenied;
}

Isso é chamado de tipo de união de erros e indica que nossa função pode retornar ou um erro OpenError ou um void (ou seja, nada). Até agora, fomos bastante explícitos: criamos um conjunto de erros para os possíveis erros que nossa função pode retornar e usamos esse conjunto de erros no tipo de retorno da união de erros de nossa função. No entanto, quando se trata de erros, o Zig tem alguns truques interessantes na manga. Primeiro, em vez de especificar uma união de erros como error set!return type, podemos deixar o Zig inferir o conjunto de erros usando: !return type. Portanto, poderíamos, e provavelmente iríamos, definir nosso main como:

pub fn main() !void

Ainda, Zig é capaz de criar conjuntos de erros implicitamente para nós. Em vez de criar nosso conjunto de erros, poderíamos ter feito:

pub fn main() !void {
	return error.AccessDenied;
}

Nossas abordagens completamente explícitas e implícitas não são exatamente equivalentes. Por exemplo, referências a funções com conjuntos de erros implícitos exigem o uso do tipo especial anyerror. Desenvolvedores de bibliotecas podem ver vantagens em serem mais explícitos, como código auto-documentado. Ainda assim, acredito que tanto os conjuntos de erros implícitos quanto a união de erros inferida são pragmáticos; eu faço amplo uso de ambos.

O verdadeiro valor das uniões de erros é o suporte embutido na linguagem na forma de catch e try. Uma chamada de função que retorna uma união de erros pode incluir uma cláusula catch. Por exemplo, uma biblioteca de servidor HTTP pode ter um código que se parece com:

action(req, res) catch |err| {
	if (err == error.BrokenPipe or err == error.ConnectionResetByPeer) {
		return;
	} else if (err == error.BodyTooBig) {
		res.status = 431;
		res.body = "Request body is too big";
	} else {
		res.status = 500;
		res.body = "Internal Server Error";
		// todo: log err
	}
};

A versão usando switch é mais idiomática:

action(req, res) catch |err| switch (err) {
	error.BrokenPipe, error.ConnectionResetByPeer) => return,
	error.BodyTooBig => {
		res.status = 431;
		res.body = "Request body is too big";
	},
	else => {
		res.status = 500;
		res.body = "Internal Server Error";
	}
};

Isso é tudo muito elegante, mas sejamos honestos, o mais provável é que você vai fazer em catch é propagar o erro para quem chamou:

action(req, res) catch |err| return err;

Isso é tão comum que é o que try faz. Em vez do exemplo acima, fazemos:

try action(req, res);

Isso é especialmente útil, dado que os erros devem ser tratados. Muito provavelmente, você fará isso com um try ou catch.

Programadores de Go perceberão que try requer menos teclas do que if err != nil { return err }.

Na maioria das vezes, você usará try e catch, mas as uniões de erros também são suportadas por if e while, assim como os tipos opcionais. No caso de while, se a condição retornar um erro, a cláusula else será executada.

Existe um tipo especial chamado anyerror, que pode conter qualquer erro. Embora pudéssemos definir uma função como retornando anyerror!TYPE em vez de !TYPE, os dois não são equivalentes. O conjunto de erros inferido é criado com base no que a função pode retornar. anyerror é o conjunto de erros global, um superset de todos os conjuntos de erros no programa. Portanto, usar anyerror em uma assinatura de função provavelmente sinaliza que sua função pode retornar erros que, na realidade, ela não pode. anyerror é usado para parâmetros de função ou campos de estrutura que podem lidar com qualquer erro (imagine uma biblioteca de auditoria, ou "logging").

Não é incomum uma função retornar uma união de erros com tipo opcional. Com um conjunto de erros inferido, isso se parece com:

// carregue o último jogo salvo
pub fn loadLast() !?Save {
	// A fazer
	return null;
}

Existem diferentes maneiras de consumir essas funções, mas a mais compacta é usar try para desembrulhar nosso erro e, em seguida, orelse para desembrulhar o opcional. Aqui está um esqueleto funcional:

const std = @import("std");

pub fn main() void {
	// Esta é a linha que você quer se concentrar
	const save = (try Save.loadLast()) orelse Save.blank();
	std.debug.print("{any}\n", .{save});
}

pub const Save = struct {
	lives: u8,
	level: u16,

	pub fn loadLast() !?Save {
		//a fazer
		return null;
	}

	pub fn blank() Save {
		return .{
			.lives = 3,
			.level = 1,
		};
	}
};

Embora o Zig tenha mais profundidade, e algumas das características da linguagem tenham capacidades mais avançadas, o que vimos nestas duas primeiras partes é uma parte significativa da linguagem. Isso servirá como uma base, permitindo-nos explorar tópicos mais complexos sem nos distrairmos muito com a sintaxe.

Estilização

Nesta breve parte, abordaremos duas regras de codificação impostas pelo compilador, bem como a convenção de nomenclatura da biblioteca padrão.

Variáveis não utilizadas

Zig não permite que variáveis fiquem sem uso. O seguinte código resultará em dois erros de compilação:

const std = @import("std");

pub fn main() void {
	const sum = add(8999, 2);
}

fn add(a: i64, b: i64) i64 {
	// atenção ao fato que isto é a + a, não a + b
	return a + a;
}

O primeiro erro ocorre porque sum é uma constante local não utilizada (unused local constant). O segundo erro ocorre porque b é um parâmetro de função não utilizado (unused function parameter). Para este código, esses são bugs óbvios. No entanto, você pode ter razões legítimas para ter variáveis e parâmetros de função não utilizados. Nestes casos, você pode atribuir as variáveis ao sublinhado (_):

const std = @import("std");

pub fn main() void {
	_ = add(8999, 2);

	// ou

	sum = add(8999, 2);
	_ = sum;
}

fn add(a: i64, b: i64) i64 {
	_ = b;
	return a + a;
}

Como alternativa a fazer _ = b;, poderíamos ter nomeado o parâmetro da função como _, embora, na minha opinião, isso deixe o leitor adivinhando qual é o parâmetro não utilizado:

fn add(a: i64, _: i64) i64 {

Observe que std também está sem uso, mas não gera um erro. Em algum momento no futuro, espera-se que o Zig também trate isso como um erro de compilação.

Sombreamento

Zig não permite que um identificador "oculte" outro usando o mesmo nome. Este código, para ler de um socket, não é válido:

fn read(stream: std.net.Stream) ![]const u8 {
	var buf: [512]u8 = undefined;
	const read = try stream.read(&buf);
	if (read == 0) {
		return error.Closed;
	}
	return buf[0..read];
}

A nossa variável read oculta o nome da nossa função. Eu não sou fã dessa regra, pois geralmente leva os desenvolvedores a usarem nomes curtos e sem significado. Por exemplo, para fazer com que este código compile, eu mudaria read para n. Esta é uma situação em que, na minha opinião, os desenvolvedores estão em uma posição muito melhor para escolher a opção mais legível.

Convenções de Nomenclatura

Além das regras impostas pelo compilador, você é, obviamente, livre para seguir qualquer convenção de nomenclatura que preferir. Mas ajuda a entender a convenção de nomenclatura do próprio Zig, já que grande parte do código com o qual você irá interagir, desde a biblioteca padrão até bibliotecas de terceiros, nos torna parte dela.

O código-fonte Zig é recuado com 4 espaços. Eu pessoalmente uso um tab porque é melhor para acessibilidade.

Os nomes das funções são camelCase e as variáveis são snake_case. Os tipos são PascalCase. Há uma intersecção interessante entre essas três regras. Variáveis que fazem referência a um tipo, ou funções que retornam um tipo, seguem a regra de tipo e são PascalCase. Já vimos isso, embora você possa não ter percebido.

std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});

Vimos outras funções integradas: @import, @rem e @intCast. Como essas são funções, elas são camelCase. @TypeOf também é uma função interna, mas é PascalCase, por quê? Porque retorna um tipo e, portanto, a convenção de nomenclatura de tipo é usada. Se atribuíssemos o resultado de @TypeOf a uma variável, usando a convenção de nomenclatura do Zig, essa variável também deveria ser PascalCase:

const T = @TypeOf(3)
std.debug.print("{any}\n", .{T});

O executável zig possui um comando fmt que, dado um arquivo ou diretório, formatará o arquivo com base no guia de estilo do próprio Zig. Porém, ele não cobre tudo, por exemplo, ajustará o recuo e as posições dos colchetes, mas não irá alternar entre identificados maiúsculos e minúsculos.

Ponteiros

Zig não inclui um coletor de lixo. A responsabilidade de gerenciar a memória recai sobre você, o programador. É uma grande responsabilidade, pois tem impacto direto no desempenho, estabilidade e segurança da sua aplicação.

Começaremos falando sobre ponteiros, que é um tópico importante para discutir por si só, mas também para começar a nos treinar para ver os dados do nosso programa de um ponto de vista orientado à memória. Se você já está familiarizado com ponteiros, alocações de memória heap e ponteiros pendentes (dangling pointers), sinta-se à vontade para pular para os próximos capítulos.


O código a seguir cria um usuário com um power de 1 e, em seguida, chama a função levelUp que incrementa o poder do usuário em 1. Você consegue adivinhar a saída?

const std = @import("std");

pub fn main() void {
	var user = User{
		.id = 1,
		.power = 100,
	};

	// esta foi a linha adicionada
	levelUp(user);
	std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
	user.power += 1;
}

pub const User = struct {
	id: u64,
	power: i32,
};

Isso foi uma pegadinha; o código não será compilado: "cannot assign to constant" (não é possível atribuir a uma constante). Vimos na parte 1 que os parâmetros de função são constantes, portanto, user.power += 1; não é válido. Para corrigir o erro de compilação, poderíamos modificar a função levelUp para:

fn levelUp(user: User) void {
	var u = user;
	u.power += 1;
}

O código será compilado, mas a saída será "User 1 has power of 100", embora a intenção do nosso código seja aumentar o poder do usuário (função levelUp) para 101. O que está acontecendo?

Para entender, ajuda pensar sobre dados em relação à memória e variáveis como rótulos que associam um tipo a um local específico na memória. Por exemplo, em main, criamos um User. Uma visualização simples desses dados na memória seria:

user -> ------------ (id)
        |    1     |
        ------------ (power)
        |   100    |
        ------------

Existem duas coisas importantes a serem observadas. A primeira é que nossa variável user aponta para o início de nossa estrutura. A segunda é que os campos são dispostos sequencialmente. Lembre-se de que nosso user também tem um tipo. Esse tipo nos diz que id é um inteiro de 64 bits e power é um inteiro de 32 bits. Armado com uma referência ao início de nossos dados e o tipo, o compilador pode traduzir user.power para: acessar um inteiro de 32 bits localizado 64 bits a partir do início. Isso é o poder das variáveis, elas referenciam a memória e incluem as informações de tipo necessárias para entender e manipular a memória de maneira significativa.

Por padrão, Zig não faz garantias sobre o layout de memória das estruturas. Pode armazenar os campos em ordem alfabética, por tamanho ascendente ou com lacunas. Pode fazer o que quiser, desde que seja capaz de traduzir nosso código corretamente. Essa liberdade pode permitir certas otimizações. Somente se declararmos uma struct compacta (packed struct) obteremos garantias firmes sobre o layout de memória. Ainda assim, nossa visualização de user é razoável e útil.

Aqui está uma visualização um pouco diferente que inclui endereços de memória. O endereço de memória do início desses dados é um endereço aleatório que inventei. Este é o endereço de memória referenciado pela variável user, que também é o valor do nosso primeiro campo, id. No entanto, dado este endereço inicial, todos os endereços subsequentes têm um endereço relativo conhecido. Como id é um inteiro de 64 bits, ele ocupa 8 bytes de memória. Portanto, power deve estar em $start_address + 8:

user ->   ------------  (id: 1043368d0)
          |    1     |
          ------------  (power: 1043368d8)
          |   100    |
          ------------

Para verificar isso por si mesmo, gostaria de apresentar o operador addressof (endereço de memória da variável): &. Como o nome indica, o operador & retorna o endereço de uma variável (também pode retornar o endereço de uma função, não é incrível?!). Mantendo a definição existente de User, experimente este código no main:

pub fn main() void {
	var user = User{
		.id = 1,
		.power = 100,
	};
	std.debug.print("{*}\n{*}\n{*}\n", .{&user, &user.id, &user.power});
}

Este código imprime o endereço de user, user.id e user.power. Você pode obter resultados diferentes com base em sua plataforma e outros fatores, mas espero que você veja que o endereço de user e user.id é o mesmo, enquanto user.power está em um deslocamento de 8 bytes. Eu obtive:

learning.User@1043368d0
u64@1043368d0
i32@1043368d8

O operador & retorna um ponteiro para um valor. Um ponteiro para um valor é um tipo distinto. O endereço de um valor do tipo T é um *T. Pronunciamos isso como um ponteiro para T. Portanto, se pegarmos o endereço de user, obteremos um *User, ou seja, um ponteiro para User:

pub fn main() void {
	var user = User{
		.id = 1,
		.power = 100,
	};

	const user_p = &user;
	std.debug.print("{any}\n", .{@TypeOf(user_p)});
}

Nosso objetivo original era aumentar o atributo power do nosso user em 1, por meio da função levelUp. Conseguimos fazer o código compilar, mas quando imprimimos o valor de power, ele ainda era o valor original. É um pouco avançado, mas vamos mudar o código para imprimir o endereço de user em main e em levelUp:

pub fn main() void {
	const user = User{
		.id = 1,
		.power = 100,
	};

	// linha acrescentada
	std.debug.print("main: {*}\n", .{&user});

	levelUp(user);
	std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: User) void {
	// acrescente esta linha
	std.debug.print("levelUp: {*}\n", .{&user});
	var u = user;
	u.power += 1;
}

Se você executar isso, obterá dois endereços diferentes. Isso significa que user que está sendo modificado em levelUp é diferente de user em main. Isso acontece porque o Zig passa uma cópia do valor (pass-by-value). Isso pode parecer uma escolha padrão estranha, mas um dos benefícios é que o chamador de uma função pode ter certeza de que a função não modificará o parâmetro (porque não pode). Em muitos casos, isso é uma garantia útil. Claro, às vezes, como com levelUp, queremos que a função modifique um parâmetro. Para conseguir isso, precisamos que levelUp atue no user real em main, não em uma cópia. Podemos fazer isso passando o endereço do nosso usuário para a função:

const std = @import("std");

pub fn main() void {
	var user = User{
		.id = 1,
		.power = 100,
	};

	// user -> &user
	levelUp(&user);
	std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

// User -> *User
fn levelUp(user: *User) void {
	user.power += 1;
}

pub const User = struct {
	id: u64,
	power: i32,
};

Tivemos que fazer duas alterações. A primeira foi chamar levelUp com o endereço de user, ou seja, &user, em vez de user. Isso significa que nossa função não recebe mais um User. Em vez disso, ela recebe um *User, que foi a nossa segunda alteração.

O código agora funciona conforme pretendido. Ainda há muitas sutilezas com os parâmetros de função e nosso modelo de memória em geral, mas estamos progredindo. Agora pode ser um bom momento para mencionar que, além da sintaxe específica, nada disso é exclusivo do Zig. O modelo que estamos explorando aqui é o mais comum; algumas linguagens podem apenas esconder muitos dos detalhes, e, assim, a flexibilidade, dos desenvolvedores.

Métodos

Muito provavelmente, você teria escrito levelUp como um método da estrutura User:

pub const User = struct {
	id: u64,
	power: i32,

	fn levelUp(user: *User) void {
		user.power += 1;
	}
};

Isso levanta a pergunta: como chamamos um método com um receptor de ponteiro? Talvez tenhamos que fazer algo como: &user.levelUp()? Na verdade, você apenas o chama normalmente, ou seja, user.levelUp(). O Zig sabe que o método espera um ponteiro e passa o valor corretamente (por referência).

Inicialmente, escolhi uma função porque é explícita e, portanto, mais fácil de entender.

Parâmetros constantes de função

Eu mais do que insinuei que, por padrão, o Zig passará uma cópia de um valor (chamado de "passagem por valor"). Em breve, veremos que a realidade é um pouco mais sutil (dica: e quanto a valores complexos com objetos aninhados?)

Mesmo ficando com tipos simples, a verdade é que o Zig pode passar parâmetros da maneira que quiser, contanto que possa garantir que a intenção do código seja preservada. Em nosso levelUp original, onde o parâmetro era um User, o Zig poderia ter passado uma cópia do usuário ou uma referência a main.user, contanto que pudesse garantir que a função não o modificasse. (Eu sei que, no final, queríamos que fosse modificado, mas ao fazer o tipo User, estávamos dizendo ao compilador que não queríamos).

Essa liberdade permite que o Zig use a estratégia mais otimizada com base no tipo do parâmetro. Tipos pequenos, como User, podem ser facilmente passados por valor (ou seja, copiados). Tipos maiores podem ser mais baratos de passar por referência. O Zig pode usar qualquer abordagem, contanto que a intenção do código seja preservada. Em certa medida, isso é possibilitado pela presença de parâmetros de função constantes.

Agora você sabe uma das razões pelas quais os parâmetros de função são constantes.

Talvez você esteja se perguntando como passar por referência poderia ser mais lento, mesmo em comparação com a cópia de uma estrutura realmente pequena. Veremos isso de forma mais clara a seguir, mas a ideia principal é que acessar user.power quando user é um ponteiro adiciona um pequeno overhead. O compilador precisa avaliar o custo da cópia em comparação com o custo de acessar campos indiretamente por meio de um ponteiro.

Ponteiros de ponteiros

Nós vimos anteriormente como era a memória de user dentro da nossa função main. Agora que alteramos o levelUp, como seria a memória dele?

main:
user -> ------------  (id: 1043368d0)  <---
        |    1     |                      |
        ------------  (power: 1043368d8)  |
        |   100    |                      |
        ------------                      |
                                          |
        .............  espaço vazio       |
        .............  ou outros dados    |
                                          |
levelUp:                                  |
user -> -------------  (*User)            |
        | 1043368d0 |----------------------
        -------------

Dentro de levelUp, user é um ponteiro para um User. Seu valor é um endereço. Não é apenas qualquer endereço, é claro, mas o endereço de main.user. Vale a pena ser explícito que a variável user em levelUp representa um valor concreto. Esse valor acontece de ser um endereço. E não é apenas um endereço, é também um tipo, um *User. É tudo muito consistente, não importa se estamos falando de ponteiros ou não: variáveis associam informações de tipo a um endereço. A única coisa especial sobre ponteiros é que, quando usamos a sintaxe de ponto, por exemplo, user.power, o Zig, sabendo que user é um ponteiro, seguirá automaticamente o endereço.

Algumas outras linguagens requerem um símbolo diferente ao acessar um campo por meio de um ponteiro.

O importante de entender é que a variável user em levelUp em si existe na memória em algum endereço. Assim como fizemos antes, podemos verificar isso por nós mesmos:

fn levelUp(user: *User) void {
	std.debug.print("{*}\n{*}\n", .{&user, user});
	user.power += 1;
}

O código acima imprime o endereço que a variável user referencia, bem como o seu valor, que é o endereço de user em main.

Se user é um *User, então o que é &user? É um **User, ou seja, um ponteiro para um ponteiro para um User. Eu posso continuar fazendo isso até que um de nós fique sem memória!

Existem casos de uso para múltiplos níveis de indireção (pointer indirection), mas não é algo que precisamos agora. O propósito desta seção é mostrar que ponteiros não são algo especial; eles são apenas um valor, que é um endereço, e um tipo.

Ponteiros aninhados

Até agora, nosso User foi simples, contendo dois inteiros. É fácil visualizar sua memória e, quando falamos sobre "copiar", não há ambiguidade. Mas o que acontece quando User se torna mais complexo e contém um ponteiro?

pub const User = struct {
	id: u64,
	power: i32,
	name: []const u8,
};

Adicionamos um campo chamado name, que é um slice. Lembre-se de que um slice é composto por um comprimento e um ponteiro. Se inicializarmos nosso user com o nome "Goku", como isso seria representado na memória?

user -> -------------  (id: 1043368d0)
        |     1     |
        -------------  (power: 1043368d8)
        |    100    |
        -------------  (name.len: 1043368dc)
        |     4     |
        -------------  (name.ptr: 1043368e4)
  ------| 1182145c0 |
  |     -------------
  |
  |     .............  espaço vazio
  |     .............  ou outros dados
  |
  --->  -------------  (1182145c0)
        |    'G'    |
        -------------
        |    'o'    |
        -------------
        |    'k'    |
        -------------
        |    'u'    |
        -------------

O novo campo name é um slice composto por campos len e ptr. Eles são dispostos em sequência junto com todos os outros campos. Em uma plataforma de 64 bits, tanto len quanto ptr serão de 64 bits, ou seja, 8 bytes. A parte interessante é o valor de name.ptr: é um endereço para algum outro lugar na memória.

Já que usamos uma string literal, user.name.ptr apontará para uma localização específica dentro da área onde todas as constantes são armazenadas dentro do nosso binário.

Os tipos podem se tornar muito mais complexos do que isso com aninhamento profundo. Mas simples ou complexos, todos se comportam da mesma forma. Especificamente, se voltarmos ao nosso código original onde levelUp aceitava um simples User e Zig fornecia uma cópia, como isso seria agora que temos um ponteiro aninhado?

A resposta é que apenas uma cópia rasa do valor é feita. Ou, como alguns dizem, apenas a memória imediatamente acessível pela variável é copiada. Pode parecer que levelUp obteria uma cópia incompleta de user, possivelmente com um name inválido. Mas lembre-se de que um ponteiro, como nosso user.name.ptr, é um valor, e esse valor é um endereço. Uma cópia de um endereço ainda é o mesmo endereço:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       |
                 |     1     |                        |
                 -------------  (power: 1043368f4)    |
                 |    100    |                        |
                 -------------  (name.len: 1043368f8) |
                 |     4     |                        |
                 -------------  (name.ptr: 104336900) |
                 | 1182145c0 |-------------------------
                 -------------                        |
                                                      |
                 .............  espaço vazio          |
                 .............  ou outros dados       |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

A partir do que foi apresentado, podemos ver que a cópia rasa funcionará. Como o valor de um ponteiro é um endereço, copiar o valor significa que obtemos o mesmo endereço. Isso tem implicações importantes em relação à mutabilidade. Nossa função não pode alterar os campos diretamente acessíveis por main.user, já que ela obteve uma cópia, mas ela tem acesso ao mesmo name, então pode modificá-lo? Neste caso específico, não, name é uma constante (const). Além disso, nosso valor "Goku" é uma string literal, que é sempre imutável. No entanto, com um pouco de trabalho, podemos ver a implicação da cópia rasa:

const std = @import("std");

pub fn main() void {
	var name = [4]u8{'G', 'o', 'k', 'u'};
	var user = User{
		.id = 1,
		.power = 100,
		// transformando num slice, [4]u8 -> []u8
		.name = name[0..],
	};
	levelUp(user);
	std.debug.print("{s}\n", .{user.name});
}

fn levelUp(user: User) void {
	user.name[2] = '!';
}

pub const User = struct {
	id: u64,
	power: i32,
	// []const u8 -> []u8
	name: []u8
};

O código acima imprime "Go!u". Tivemos que mudar o tipo de name de []const u8 para []u8 e, em vez de uma string literal, que é sempre imutável, criar uma matriz e fatiá-la. Alguns podem ver inconsistência aqui. Passar por valor impede que uma função mute campos imediatos, mas não campos com um valor por trás de um ponteiro. Se quiséssemos que name fosse imutável, deveríamos tê-lo declarado como []const u8 em vez de []u8.

Algumas linguagens têm uma implementação diferente, mas muitas linguagens funcionam exatamente assim (ou muito parecido). Embora tudo isso possa parecer esotérico, é fundamental para a programação diária. A boa notícia é que você pode dominar isso usando exemplos e trechos simples; não fica mais complicado à medida que outras partes do sistema crescem em complexidade.

Estruturas recursivas

Às vezes, você precisa que uma estrutura seja recursiva. Mantendo nosso código existente, vamos adicionar uma varíavel manager opcional do tipo ?User ao nosso User. Enquanto estamos nisso, criaremos dois Users e atribuiremos um como gerente de outro:

const std = @import("std");

pub fn main() void {
	const leto = User{
		.id = 1,
		.power = 9001,
		.manager = null,
	};

	const duncan = User{
		.id = 1,
		.power = 9001,
		.manager = leto,
	};

	std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
	id: u64,
	power: i32,
	manager: ?User,
};

Este código não vai compilar: struct 'learning.User' depends on itself (dependência de si própria). Isso falha porque todo tipo precisa ter um tamanho conhecido em tempo de compilação.

Não tivemos esse problema ao adicionar name, mesmo que os nomes possam ter comprimentos diferentes. O problema não é com o tamanho dos valores, mas com o tamanho dos tipos em si. Zig precisa dessa informação para fazer tudo o que discutimos acima, como acessar um campo com base na posição do seu deslocamento. name era uma fatia, um []const u8, e isso tem um tamanho conhecido: 16 bytes - 8 bytes para len e 8 bytes para ptr.

Você pode pensar que isso será um problema com qualquer opcional ou união. Mas para ambos os opcionais e uniões, o tamanho máximo possível é conhecido e Zig pode usá-lo. Uma estrutura recursiva não tem tal limite superior, a estrutura pode se repetir uma vez, duas vezes ou milhões de vezes. Esse número variaria de User para User e não seria conhecido em tempo de compilação.

Vimos a resposta com name: use um ponteiro. Ponteiros sempre ocupam usize bytes. Em uma plataforma de 64 bits, isso são 8 bytes. Assim como o nome real "Goku" não foi armazenado com/ao lado do nosso user, usar um ponteiro significa que nosso gerente não está mais vinculado ao layout de memória de user.

const std = @import("std");

pub fn main() void {
	const leto = User{
		.id = 1,
		.power = 9001,
		.manager = null,
	};

	const duncan = User{
		.id = 1,
		.power = 9001,
		// mudança: leto -> &leto
		.manager = &leto,
	};

	std.debug.print("{any}\n{any}", .{leto, duncan});
}

pub const User = struct {
	id: u64,
	power: i32,
	// mudança: ?const User -> ?*const User
	manager: ?*const User,
};

Você pode nunca precisar de uma estrutura recursiva, mas isso não se trata de modelagem de dados. Trata-se de entender ponteiros e modelos de memória e compreender melhor o que o compilador está fazendo.


Muitos desenvolvedores têm dificuldade com ponteiros, pode haver algo evasivo sobre eles. Eles não parecem concretos como um número inteiro, ou uma string ou um User. Nada disso precisa estar totalmente claro para você avançar. Mas vale a pena dominar, e não apenas para o Zig. Esses detalhes podem estar ocultos em linguagens como Ruby, Python e JavaScript, e em menor medida em C#, Java e Go, mas ainda estão lá, impactando como você escreve código e como esse código é executado. Então, vá com calma, experimente exemplos, adicione declarações de impressão de depuração para examinar variáveis e seus endereços. Quanto mais você explorar, mais claro ficará.

Memória de Pilha

Aprofundar nos ponteiros forneceu uma visão sobre a relação entre variáveis, dados e memória. Então, estamos começando a ter uma ideia de como a memória se parece, mas ainda não falamos sobre como os dados e, por extensão, a memória são gerenciados. Para scripts curtos e simples, isso provavelmente não importa. Em uma era de laptops com 32GB de RAM, você pode iniciar seu programa, usar alguns megabytes de RAM para ler um arquivo e analisar uma resposta HTTP, fazer algo incrível e sair. Na saída do programa, o sistema operacional sabe que a memória que deu ao seu programa pode agora ser usada para outra coisa.

Mas para programas que rodam por dias, meses ou até anos, a memória se torna um recurso limitado e precioso, provavelmente disputado por outros processos em execução na mesma máquina. Simplesmente não há como esperar até que o programa saia para liberar memória. Isso é o trabalho principal de um coletor de lixo: saber quais dados não estão mais em uso e liberar sua memória. No Zig, você é o coletor de lixo.

A maioria dos programas que você escreverá fará uso de três "áreas" de memória. A primeira é o espaço global, onde são armazenadas as constantes do programa, incluindo literais de string. Todos os dados globais são incorporados no binário, totalmente conhecidos em tempo de compilação (e, portanto, em tempo de execução) e imutáveis. Esses dados existem ao longo da vida do programa, nunca precisando de mais ou menos memória. Além do impacto que tem no tamanho do nosso binário, isso não é algo com que precisamos nos preocupar.

A segunda área de memória é a pilha de chamadas, o tópico desta parte. A terceira área é o heap, o tópico da nossa próxima parte.

Não há uma diferença física real entre as áreas de memória, é um conceito criado pelo sistema operacional e pelo executável.

Seções (quadros) da memória de pilha (stack frames)

Todos os dados que vimos até agora foram constantes armazenadas na seção de dados globais de nosso binário ou variáveis locais. "Local" indica que a variável é válida apenas no escopo em que é declarada. Em Zig, os escopos começam e terminam com chaves, { ... }. A maioria das variáveis está limitada a uma função, incluindo os parâmetros da função, ou a um bloco de controle de fluxo, como um if. Mas, como vimos, você pode criar blocos arbitrários e, portanto, escopos arbitrários.

Na parte anterior, visualizamos a memória de nossas funções main e levelUp, cada uma com um User:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
levelUp: user -> -------------  (id: 1043368ec)       |
                 |     1     |                        |
                 -------------  (power: 1043368f4)    |
                 |    100    |                        |
                 -------------  (name.len: 1043368f8) |
                 |     4     |                        |
                 -------------  (name.ptr: 104336900) |
                 | 1182145c0 |-------------------------
                 -------------                        |
                                                      |
                 .............  espaço vazio          |
                 .............  ou outros dados       |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Há uma razão para levelUp estar imediatamente após main: esta é a nossa pilha de chamadas (simplificada). Quando nosso programa começa, main, junto com suas variáveis locais, é empurrado para a pilha de chamadas. Quando levelUp é chamado, seus parâmetros e quaisquer variáveis locais são empurrados para a pilha de chamadas. Importante, quando levelUp retorna, ele é retirado da pilha. Após o retorno de levelUp e o controle voltar para main, nossa pilha de chamadas parece:

main: user ->    -------------  (id: 1043368d0)
                 |     1     |
                 -------------  (power: 1043368d8)
                 |    100    |
                 -------------  (name.len: 1043368dc)
                 |     4     |
                 -------------  (name.ptr: 1043368e4)
                 | 1182145c0 |-------------------------
                 -------------
                                                      |
                 .............  espaço vazio          |
                 .............  ou outros dados       |
                                                      |
                 -------------  (1182145c0)        <---
                 |    'G'    |
                 -------------
                 |    'o'    |
                 -------------
                 |    'k'    |
                 -------------
                 |    'u'    |
                 -------------

Quando uma função é chamada, todo o seu quadro de pilha é acrescentado para a pilha de chamadas. Esta é uma das razões pelas quais precisamos saber o tamanho de cada tipo. Embora possamos não saber o comprimento do nome do nosso usuário até que aquela linha específica de código seja executada (supondo que não seja uma string literal constante), sabemos que nossa função tem um User e, além dos outros campos, precisaremos de 8 bytes para name.len e 8 bytes para name.ptr.

Quando a função retorna, seu quadro de pilha, que foi o último empurrado para a pilha de chamadas, é retirado. Algo incrível acabou de acontecer: a memória usada por levelUp foi automaticamente liberada! Embora tecnicamente essa memória pudesse ser devolvida ao sistema operacional, até onde sei, nenhuma implementação realmente reduz a pilha de chamadas (elas a aumentarão dinamicamente quando necessário). Ainda assim, a memória usada para armazenar o quadro de pilha do levelUp está agora livre para ser usada em nosso processo para outro quadro de pilha.

Em um programa normal, a pilha de chamadas pode ficar bastante grande. Entre todo o código de framework e bibliotecas que um programa típico utiliza, você acaba com funções profundamente aninhadas. Normalmente, isso não é um problema, mas de vez em quando, você pode encontrar algum tipo de erro de estouro de pilha, o famoso Stack Overflow! Isso ocorre quando nossa pilha de chamadas fica sem espaço. Mais frequentemente do que não, isso acontece com funções recursivas - uma função que chama a si mesma.

Assim como nossos dados globais, a pilha de chamadas é gerenciada pelo sistema operacional e pelo executável. No início do programa e para cada thread que iniciamos posteriormente, uma pilha de chamadas é criada (cujo tamanho geralmente pode ser configurado no sistema operacional). A pilha de chamadas existe durante toda a vida do programa ou, no caso de uma thread, durante toda a vida da thread. No encerramento do programa ou da thread, a pilha de chamadas é liberada. Mas, ao contrário dos nossos dados globais, a pilha de chamadas contém apenas os quadros de pilha para a hierarquia de funções atualmente em execução. Isso é eficiente tanto em termos de uso de memória quanto na simplicidade de empilhar e desempilhar os quadros de pilha na pilha.

Ponteiros pendentes (dangling pointers)

A pilha de chamadas é incrível tanto por sua simplicidade quanto por sua eficiência. Mas também é assustadora: quando uma função retorna, qualquer um de seus dados locais se torna inacessível. Isso pode parecer razoável, afinal, são dados locais, mas pode introduzir problemas sérios. Considere este código:

const std = @import("std");

pub fn main() void {
	var user1 = User.init(1, 10);
	var user2 = User.init(2, 20);

	std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
	std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
}

pub const User = struct {
	id: u64,
	power: i32,

	fn init(id: u64, power: i32) *User{
		var user = User{
			.id = id,
			.power = power,
		};
		return &user;
	}
};

À primeira vista, seria razoável esperar a seguinte saída:

User 1 has power of 10
User 2 has power of 20

Eu obtive:

User 2 has power of 20
User 9114745905793990681 has power of 0

Você pode obter resultados diferentes, mas com base na minha saída, user1 herdou os valores de user2, e os valores de user2 não fazem sentido. O problema-chave com este código é que User.init retorna o endereço do user local, &user. Isso é chamado de ponteiro pendurado (dangling pointer), um ponteiro que referencia memória inválida. Isso é a origem de muitos erros de segmentação de memória (segfault).

Quando um quadro de pilha é removido da pilha de chamadas, quaisquer referências que temos a essa memória são inválidas. O resultado ao tentar acessar essa memória é indefinido. Você provavelmente obterá dados sem sentido ou um erro de segmentação. Poderíamos tentar dar algum sentido à minha saída, mas não é um comportamento no qual gostaríamos ou mesmo poderíamos confiar.

Um desafio com esse tipo de bug é que, em linguagens com coletores de lixo, o código acima é perfeitamente aceitável. Em Go, por exemplo, seria detectado que o user local sobrevive ao seu escopo, a função init, e garantiria sua validade pelo tempo que fosse necessário (como Go faz isso é um detalhe de implementação, mas tem algumas opções, incluindo mover os dados para o heap, que é sobre o que trata a próxima parte).

A outra questão, lamento dizer, é que pode ser um bug difícil de detectar. Em nosso exemplo acima, estamos claramente retornando o endereço de uma variável local. Mas tal comportamento pode se esconder dentro de funções aninhadas e tipos de dados complexos. Você vê algum possível problema com o seguinte código incompleto:

fn read() !void {
	const input = try readUserInput();
	return Parser.parse(input);
}

O que quer que Parser.parse retorne sobrevive a input. Se o Parser mantiver uma referência a input, isso será um ponteiro pendurado apenas esperando para causar problemas em nosso aplicativo. Idealmente, se o Parser precisa que input sobreviva tanto quanto ele, ele fará uma cópia e essa cópia estará vinculada à sua própria vida (mais sobre isso na próxima parte). Mas não há nada aqui para impor esse contrato. A documentação do Parser pode esclarecer o que espera de input ou o que faz com ele. Na falta disso, talvez seja necessário examinar o código para descobrir.


A maneira simples de resolver nosso bug inicial é modificar init para que retorne um User em vez de um *User (ponteiro para User). Então, seria possível simplesmente utilizar return user; em vez de return &user;. Mas nem sempre será possível fazer isso. Frequentemente, os dados precisam existir além dos limites rígidos dos escopos de funções. Para isso, temos a terceira área de memória, o heap, que será abordada na próxima parte.

Antes de entrar no heap, saiba que veremos um exemplo final de ponteiros pendurados antes do final deste guia. Nesse ponto, teremos coberto o suficiente da linguagem para apresentar um exemplo um pouco menos confuso. Quero revisitar esse tópico porque, para os desenvolvedores que vêm de linguagens com coleta de lixo, isso provavelmente causará bugs e frustração. No entanto, é algo que você vai dominar. Tudo se resume a estar ciente de onde e quando os dados existem na memória.

Memória Dinâmica & Alocadores

Tudo o que vimos até agora foi restrito pela necessidade de um tamanho definido antecipadamente. Arrays sempre têm um comprimento conhecido em tempo de compilação (na verdade, o comprimento faz parte do tipo). Todas as nossas strings foram literais de string, que têm um comprimento conhecido em tempo de compilação.

Além disso, as duas estratégias de gerenciamento de memória que vimos, dados globais e a pilha de chamadas, embora simples e eficientes, são limitadas. Nenhuma delas pode lidar com dados de tamanho dinâmico e ambas são rígidas em relação aos períodos de vida dos dados.

Esta parte está dividida em dois temas. O primeiro é uma visão geral do nosso terceiro espaço de memória, o heap. O outro é a abordagem direta, mas única, do Zig para o gerenciamento de memória no heap. Mesmo que você esteja familiarizado com a memória do heap, digamos, ao usar o malloc do C, você vai querer ler a primeira parte, pois ela é bastante específica para o Zig.

Memória dinâmica (heap)

O heap é a terceira e última área de memória à nossa disposição. Em comparação com os dados globais e a pilha de chamadas, o heap é um pouco como o "oeste selvagem": tudo é permitido. Especificamente, dentro do heap, podemos criar memória em tempo de execução com um tamanho conhecido em tempo de execução e ter controle total sobre o tempo de vida dessa memória.

A pilha de chamadas é incrível devido à maneira simples e previsível como ela gerencia dados (empilhando e desempilhando frames de pilha). Esse benefício também é uma desvantagem: os dados têm uma vida útil vinculada ao seu lugar na pilha de chamadas. O heap é exatamente o oposto. Ele não possui um ciclo de vida embutido, então nossos dados podem viver pelo tempo que for necessário. E esse benefício é sua desvantagem: ele não possui um ciclo de vida embutido, então se não liberarmos a memória, ninguém o fará.

Vamos ver um exemplo:

const std = @import("std");

pub fn main() !void {
    // falaremos sobre alocadores na sequência
	var gpa = std.heap.GeneralPurposeAllocator(.{}){};
	const allocator = gpa.allocator();

	// ** as próximas duas linhas são as importantes **
	var arr = try allocator.alloc(usize, try getRandomCount());
	defer allocator.free(arr);

	for (0..arr.len) |i| {
		arr[i] = i;
	}
	std.debug.print("{any}\n", .{arr});
}

fn getRandomCount() !u8 {
	var seed: u64 = undefined;
	try std.os.getrandom(std.mem.asBytes(&seed));
	var random = std.rand.DefaultPrng.init(seed);
	return random.random().uintAtMost(u8, 5) + 5;
}

Em breve abordaremos os alocadores do Zig; por enquanto, saiba que o allocator é um std.mem.Allocator. Estamos utilizando dois de seus métodos: alloc e free. Porque estamos chamando allocator.alloc com um try, sabemos que pode falhar. Atualmente, o único erro possível é OutOfMemory. Seus parâmetros em sua maioria nos dizem como ele funciona: ele quer um tipo (T) e um contador e, em caso de sucesso, retorna uma fatia de []T. Essa alocação acontece em tempo de execução - precisa ser assim, nosso contador só é conhecido em tempo de execução.

Como regra geral, cada alloc terá um free correspondente. Enquanto alloc aloca memória, free a libera. Não deixe este código simples limitar sua imaginação. Este padrão comum de try alloc + defer free é utilizado com frequência, e por uma boa razão: liberar próximo ao local da alocação é relativamente à prova de falhas. Mas igualmente comum é alocar em um lugar enquanto libera em outro. Como mencionamos anteriormente, o heap não possui gerenciamento de ciclo de vida embutido. Você pode alocar memória em um manipulador de HTTP e liberá-la em uma thread de segundo plano, duas partes completamente separadas do código.

defer & errdefer

Em uma pequena digressão, o código acima introduziu um novo recurso da linguagem: defer, que executa o código ou bloco fornecido na saída do escopo. "Saída do escopo" inclui alcançar o final do escopo ou retornar do escopo. defer não está estritamente relacionado a alocadores ou gerenciamento de memória; você pode usá-lo para executar qualquer código. Mas o uso acima é comum.

O defer do Zig é semelhante ao do Go, com uma diferença importante. No Zig, o defer será executado no final de seu escopo contêiner. No Go, o defer é executado no final da função contêiner. A abordagem do Zig provavelmente é menos surpreendente, a menos que você seja um desenvolvedor Go.

Um parente do defer é o errdefer, que de forma semelhante executa o código ou bloco fornecido na saída do escopo, mas apenas quando um erro é retornado. Isso é útil ao fazer configurações mais complexas e ter que desfazer uma alocação anterior por causa de um erro.

O exemplo a seguir é um salto em complexidade. Ele apresenta tanto o errdefer quanto um padrão comum que envolve alocar em init e liberar em deinit:

const std = @import("std");
const Allocator = std.mem.Allocator;

pub const Game = struct {
	players: []Player,
	history: []Move,
	allocator: Allocator,

	fn init(allocator: Allocator, player_count: usize) !Game {
		var players = try allocator.alloc(Player, player_count);
		errdefer allocator.free(players);

		// armazena as 10 movimentações mais recentes por jogador
		var history = try allocator.alloc(Move, player_count * 10);

		return .{
			.players = players,
			.history = history,
			.allocator = allocator,
		};
	}

	fn deinit(game: Game) void {
		const allocator = game.allocator;
		allocator.free(game.players);
		allocator.free(game.history);
	}
};

Espero que isso destaque duas coisas. Primeiro, a utilidade do errdefer. Sob condições normais, players é alocado em init e liberado em deinit. Mas há um caso limite quando a inicialização de history falha. Neste caso e apenas neste caso, precisamos desfazer a alocação de players.

O segundo aspecto digno de nota neste código é que o ciclo de vida de nossas duas fatias alocadas dinamicamente, players e history, é baseado na lógica de nossa aplicação. Não há uma regra que dite quando deinit deve ser chamado ou quem deve chamá-lo. Isso é bom, porque nos dá tempos de vida arbitrários, mas é ruim porque podemos estragar as coisas ao nunca chamar deinit ou chamá-lo mais de uma vez.

Os nomes init e deinit não são especiais. Eles são apenas o que a biblioteca padrão do Zig usa e o que a comunidade adotou. Em alguns casos, incluindo na biblioteca padrão, são usados open e close, ou outros nomes mais apropriados.

Liberação dupla (double free) & Vazamento de memória (memory leak)

Logo acima, mencionei que não há regras que determinam quando algo deve ser liberado. Mas isso não é totalmente verdade; há algumas regras importantes, apenas não são impostas, exceto pela sua própria meticulosidade.

A primeira regra é que você não pode liberar a mesma memória duas vezes.

const std = @import("std");

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

	var arr = try allocator.alloc(usize, 4);
	allocator.free(arr);
	allocator.free(arr);

	std.debug.print("Isto não será impresso\n", .{});
}

A última linha deste código é profética, ela não será impressa. Isso ocorre porque liberamos a mesma memória duas vezes. Isso é conhecido como double-free e não é válido. Isso pode parecer simples o suficiente para evitar, mas em projetos grandes com ciclos de vida complexos, pode ser difícil rastrear.

A segunda regra é que você não pode liberar a memória da qual você não tem uma referência. Isso pode parecer óbvio, mas nem sempre fica claro quem é responsável por liberá-lo. O seguinte cria uma nova string em minúsculas:

const std = @import("std");
const Allocator = std.mem.Allocator;

fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {
	var dest = try allocator.alloc(u8, str.len);

	for (str, 0..) |c, i| {
		dest[i] = switch (c) {
			'A'...'Z' => c + 32,
			else => c,
		};
	}

	return dest;
}

O código acima é permitido. Mas o seguinte não é:

// Neste caso, especificamente, nós deveríamos ter usado "std.ascii.eqlIgnoreCase"
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
	const lower = try allocLower(allocator, name);
	return std.mem.eql(u8, lower, "admin");
}

Isso é um vazamento de memória. A memória criada em allocLower nunca é liberada. Além disso, uma vez que isSpecial retorna, ela nunca pode ser liberada. Em linguagens com coletores de lixo, quando os dados se tornam inacessíveis, eventualmente serão liberados pelo coletor de lixo. Mas no código acima, uma vez que isSpecial retorna, perdemos nossa única referência à memória alocada, a variável lower. A memória está perdida até que nosso processo seja encerrado. Nossa função pode vazar apenas alguns bytes, mas se for um processo de longa execução e essa função for chamada repetidamente, isso se acumulará e eventualmente ficaremos sem memória.

Pelo menos no caso de dupla liberação, teremos uma falha grave. Vazamentos de memória podem ser insidiosos. Não é apenas que a causa raiz pode ser difícil de identificar. Vazamentos muito pequenos ou vazamentos em código executado raramente podem ser ainda mais difíceis de detectar. Esse é um problema tão comum que o Zig fornece ajuda, como veremos ao falar sobre alocadores.

Criar (create) & destruir (destroy)

O método alloc do std.mem.Allocator retorna uma fatia com o comprimento que foi passado como o segundo parâmetro. Se você deseja um único valor, usará create e destroy em vez de alloc e free. Algumas partes atrás, ao aprender sobre ponteiros, criamos um User e tentamos incrementar seu power. Aqui está a versão funcional baseada no heap desse código usando create:

const std = @import("std");

pub fn main() !void {
	// novamente, iremos falar sobre alocadores em breve!
	var gpa = std.heap.GeneralPurposeAllocator(.{}){};
	const allocator = gpa.allocator();

	// criar um usuário (User) na memória dinâmica (heap)
	var user = try allocator.create(User);

	// liberar a memória alocada para o usuário ao final deste escopo
	defer allocator.destroy(user);

	user.id = 1;
	user.power = 100;

	// linha adicionada
	levelUp(user);
	std.debug.print("User {d} has power of {d}\n", .{user.id, user.power});
}

fn levelUp(user: *User) void {
	user.power += 1;
}

pub const User = struct {
	id: u64,
	power: i32,
};

O método create recebe um único parâmetro, o tipo (T). Ele retorna um ponteiro para esse tipo ou um erro, ou seja, !*T. Talvez você esteja se perguntando o que aconteceria se criássemos nosso usuário (user), mas não definíssemos o id e/ou power. Isso é como definir esses campos como indefinidos e o comportamento é, bom, indefinido.

Quando exploramos ponteiros pendurados, tivemos uma função que retornava incorretamente o endereço do usuário local:

pub const User = struct {
	fn init(id: u64, power: i32) *User{
		var user = User{
			.id = id,
			.power = power,
		};
		// isto é um ponteiro pendurado/solto (dangling pointer)
		return &user;
	}
};

Neste caso, teria feito mais sentido retornar um User. Mas, às vezes, você desejará que uma função retorne um ponteiro para algo que ela cria. Você fará isso quando quiser que um tempo de vida seja livre da rigidez da pilha de chamadas. Para resolver nosso ponteiro pendurado acima, poderíamos ter usado o create:

// nosso tipo de retorno mudou, agora que "init" pode falhar nós mudamos: *User -> !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
	var user = try allocator.create(User);
	user.* = .{
		.id = id,
		.power = power,
	};
	return user;
}

Eu introduzi uma nova sintaxe, user.* = .{...}. É um pouco estranho, e eu não adoro, mas você verá isso. O lado direito é algo que você já viu: é um inicializador de estrutura com um tipo inferido. Poderíamos ter sido explícitos e usado: user.* = User{...}. O lado esquerdo, user.*, é como desreferenciamos um ponteiro. & pega um T e nos dá *T. .* é o oposto, aplicado a um valor do tipo *T, nos dá T. Lembre-se de que create retorna um !*User, então nosso usuário é do tipo *User.

Alocadores

Um dos princípios fundamentais do Zig é a ausência de alocações de memória ocultas ("No hidden memory allocations"). Dependendo da sua experiência, isso pode não parecer tão especial. Mas é um contraste acentuado com o que você encontrará em C, onde a memória é alocada com a função malloc da biblioteca padrão. Em C, se você quiser saber se uma função aloca memória ou não, precisa ler o código-fonte e procurar chamadas para malloc.

Zig não possui um alocador padrão. Em todos os exemplos acima, as funções que alocavam memória recebiam um parâmetro std.mem.Allocator. Por convenção, este é geralmente o primeiro parâmetro. Toda a biblioteca padrão do Zig, e a maioria das bibliotecas de terceiros, exigem que o chamador forneça um alocador se pretender alocar memória.

Essa explicitação pode se apresentar de duas formas. Em casos simples, o alocador é fornecido em cada chamada de função. Existem muitos exemplos disso, mas std.fmt.allocPrint é um que você provavelmente precisará mais cedo ou mais tarde. É semelhante ao std.debug.print que usamos, mas aloca e retorna uma string em vez de escrevê-la em stderr:

const say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power});
defer allocator.free(say);

A outra forma é quando um alocador é passado para init e, em seguida, é usado internamente pelo objeto. Vimos isso acima com nossa estrutura Game. Isso é menos explícito, já que você forneceu ao objeto um alocador para usar, mas você não sabe quais chamadas de método realmente vão alocar. Essa abordagem é mais prática para objetos de longa duração.

A vantagem de injetar o alocador não é apenas a explicitação, mas também a flexibilidade. std.mem.Allocator é uma interface que fornece as funções alloc, free, create e destroy, junto com algumas outras. Até agora, só vimos o std.heap.GeneralPurposeAllocator, mas outras implementações estão disponíveis na biblioteca padrão ou como bibliotecas de terceiros.

Zig não tem uma sintaxe elegante para criar interfaces. Um padrão para comportamento de vida de interface são uniões marcadas, embora isso seja relativamente restrito em comparação com interfaces verdadeiras. Outros padrões surgiram e são usados em toda a biblioteca padrão, como no caso de std.mem.Allocator. Se você estiver interessado, eu publiquei uma postagem à parte no blog explicando interfaces.

Se você estiver construindo uma biblioteca, é melhor aceitar um std.mem.Allocator e permitir que os usuários da sua biblioteca decidam qual implementação de alocador usar. Caso contrário, você precisará escolher o alocador correto e, como veremos, essas escolhas não são mutuamente exclusivas. Pode haver boas razões para criar diferentes alocadores dentro do seu programa.

Alocador de uso geral

Como o nome indica, o std.heap.GeneralPurposeAllocator é um alocador versátil "de uso geral", seguro para threads, que pode servir como o alocador principal de sua aplicação. Para muitos programas, este será o único alocador necessário. No início do programa, um alocador é criado e passado para as funções que o precisam. O código de exemplo do meu servidor HTTP é um bom exemplo:

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

pub fn main() !void {
    // criar nosso alocador para uso geral
	var gpa = std.heap.GeneralPurposeAllocator(.{}){};

	// retornar um "std.mem.Allocator" do alocador criado
	const allocator = gpa.allocator();

	// passar nosso alocador para funções e bibliotecas que necessitem dele
	var server = try httpz.Server().init(allocator, .{.port = 5882});

	var router = server.router();
	router.get("/api/user/:id", getUser);

	// bloquear o fluxo de execução (thread) atual
	try server.listen();
}

Criamos o GeneralPurposeAllocator, obtemos um std.mem.Allocator dele e o passamos para a função init do servidor HTTP. Em um projeto mais complexo, o alocador seria passado para várias partes do código, cada uma delas possivelmente o passando para suas próprias funções, objetos e dependências.

Você pode notar que a sintaxe em torno da criação de gpa é um pouco estranha. O que é isso: GeneralPurposeAllocator(.{}){}? São todas coisas que já vimos antes, apenas juntas. std.heap.GeneralPurposeAllocator é uma função e, como está usando PascalCase, sabemos que ela retorna um tipo. (Vamos falar mais sobre genéricos na próxima parte). Sabendo que ela retorna um tipo, talvez esta versão mais explícita seja mais fácil de entender:

const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};

// é o mesmo que:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};

Talvez você ainda tenha dúvidas sobre o significado de .{} . Isso também é algo que já vimos antes: é um inicializador de estrutura com um tipo implícito. Qual é o tipo e onde estão os campos? O tipo é std.heap.general_purpose_allocator.Config, embora ele não seja diretamente exposto assim, que é uma razão pela qual não somos explícitos. Nenhum campo é definido porque a estrutura Config define padrões, que estaremos usando. Este é um padrão comum com configurações/opções. Na verdade, vemos isso novamente algumas linhas abaixo quando passamos . {.port = 5882} para o init. Neste caso, estamos usando o valor padrão para todos os campos, exceto um, a porta.

std.testing.allocator

Espero que você tenha ficado suficientemente preocupado quando falamos sobre vazamentos de memória e, em seguida, ansioso para aprender mais quando mencionei que o Zig poderia ajudar. Essa ajuda vem do std.testing.allocator, que é um std.mem.Allocator. Atualmente, ele é implementado usando o GeneralPurposeAllocator com integração adicional no test runner do Zig, mas isso é um detalhe de implementação. O importante é que se usarmos std.testing.allocator em nossos testes, podemos detectar a maioria dos vazamentos de memória.

Você provavelmente já está familiarizado com arrays dinâmicos, frequentemente chamados de ArrayLists. Em muitas linguagens de programação dinâmicas, todos os arrays são arrays dinâmicos. Os arrays dinâmicos suportam um número variável de elementos. Zig tem um ArrayList genérico adequado, mas criaremos um especificamente para armazenar inteiros e demonstrar a detecção de vazamentos:

pub const IntList = struct {
	pos: usize,
	items: []i64,
	allocator: Allocator,

	fn init(allocator: Allocator) !IntList {
		return .{
			.pos = 0,
			.allocator = allocator,
			.items = try allocator.alloc(i64, 4),
		};
	}

	fn deinit(self: IntList) void {
		self.allocator.free(self.items);
	}

	fn add(self: *IntList, value: i64) !void {
		const pos = self.pos;
		const len = self.items.len;

		if (pos == len) {
		    // ficamos sem espaço
		    // cria-se um novo "slice" duas vezes maior
			var larger = try self.allocator.alloc(i64, len * 2);

            // copiamos os items que adicionamos previamente para o nosso novo espaço
            @memcpy(larger[0..len], self.items);

			self.items = larger;
		}

		self.items[pos] = value;
		self.pos = pos + 1;
	}
};

A parte interessante ocorre em add quando pos == len, indicando que preenchemos nosso array atual e precisamos criar um maior. Podemos usar IntList da seguinte forma:

const std = @import("std");
const Allocator = std.mem.Allocator;

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

	var list = try IntList.init(allocator);
	defer list.deinit();

	for (0..10) |i| {
		try list.add(@intCast(i));
	}

	std.debug.print("{any}\n", .{list.items[0..list.pos]});
}

O código é executado e imprime o resultado correto. No entanto, mesmo que tenhamos chamado deinit em list, há um vazamento de memória. Não se preocupe se você não percebeu, porque vamos escrever um teste e usar std.testing.allocator:

const testing = std.testing;
test "IntList: add" {
    // aqui estamos usando "testing.allocator"!
	var list = try IntList.init(testing.allocator);
	defer list.deinit();

	for (0..5) |i| {
		try list.add(@intCast(i+10));
	}

	try testing.expectEqual(@as(usize, 5), list.pos);
	try testing.expectEqual(@as(i64, 10), list.items[0]);
	try testing.expectEqual(@as(i64, 11), list.items[1]);
	try testing.expectEqual(@as(i64, 12), list.items[2]);
	try testing.expectEqual(@as(i64, 13), list.items[3]);
	try testing.expectEqual(@as(i64, 14), list.items[4]);
}

O @as é uma função nativa do Zig que realiza coerção de tipo. Se você está se perguntando por que nosso teste teve que usar tantos deles, você não está sozinho. Tecnicamente, é porque o segundo parâmetro, o "atual", é convertido para o primeiro, o "esperado". No código acima, nossos "esperados" são todos do tipo comptime_int, o que causa problemas. Muitos, eu incluso, consideram esse comportamento estranho e infeliz.

Se você está acompanhando, coloque o teste no mesmo arquivo que IntList e main. Os testes em Zig geralmente são escritos no mesmo arquivo, frequentemente próximo ao código que estão testando. Quando usamos zig test learning.zig para executar nosso teste, obtemos uma falha incrível:

Test [1/1] test.IntList: add... [gpa] (err): memory address 0x101154000 leaked:
/code/zig/learning.zig:26:32: 0x100f707b7 in init (test)
   .items = try allocator.alloc(i64, 2),
                               ^
/code/zig/learning.zig:55:29: 0x100f711df in test.IntList: add (test)
 var list = try IntList.init(testing.allocator);

... MORE STACK INFO ...

[gpa] (err): memory address 0x101184000 leaked:
/code/test/learning.zig:40:41: 0x100f70c73 in add (test)
   var larger = try self.allocator.alloc(i64, len * 2);
                                        ^
/code/test/learning.zig:59:15: 0x100f7130f in test.IntList: add (test)
  try list.add(@intCast(i+10));

Temos múltiplos vazamentos de memória. Felizmente, o alocador de teste nos diz exatamente onde a memória com vazamento foi alocada. Você consegue identificar o vazamento agora? Se não, lembre-se de que, em geral, cada alloc deve ter um free correspondente. Nosso código chama free uma vez, em deinit. No entanto, alloc é chamado uma vez em init e, em seguida, toda vez que add é chamado e precisamos de mais espaço. Toda vez que alocamos mais espaço, precisamos liberar o self.items anterior:

// código existente
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);

// código adicionado
// liberação das alocações feitas
self.allocator.free(self.items);

Adicionando esta última linha, após copiar os elementos para nosso slice maior, resolve o problema. Se você executar zig test learning.zig, não deverá haver erros.

ArenaAllocator

O GeneralPurposeAllocator é uma escolha padrão razoável porque funciona bem em todos os casos possíveis. No entanto, dentro de um programa, você pode se deparar com padrões de alocação que podem se beneficiar de alocadores mais especializados. Um exemplo é a necessidade de um estado de curta duração que pode ser descartado quando o processamento for concluído. "Parseadores" frequentemente têm tal requisito. Uma função de análise básica pode se parecer com:

fn parse(allocator: Allocator, input: []const u8) !Something {
	var state = State{
		.buf = try allocator.alloc(u8, 512),
		.nesting = try allocator.alloc(NestType, 10),
	};
	defer allocator.free(state.buf);
	defer allocator.free(state.nesting);

	return parseInternal(allocator, state, input);
}

Embora isso não seja muito difícil de gerenciar, parseInternal pode precisar de outras alocações de curta duração que precisarão ser liberadas. Como alternativa, poderíamos criar um ArenaAllocator que nos permite liberar todas as alocações de uma só vez:

fn parse(allocator: Allocator, input: []const u8) !Something {
	// criar um "ArenaAllocator" do alocador providenciado
	var arena = std.heap.ArenaAllocator.init(allocator);

	// isto irá liberar qualquer coisa criada desta arena de memória
	defer arena.deinit();

    // criar um "std.mem.Allocator" da arena, este será o alocador que iremos utilizados internamente
	const aa = arena.allocator();

	var state = State{
		// aqui usamos "aa"!
		.buf = try aa.alloc(u8, 512),

		// aqui usamos "aa"!
		.nesting = try aa.alloc(NestType, 10),
	};

	// estamos passando "aa" aqui, então garantimos que quaisquer outras alocações ocorrerão dentro da arena de memória criada
	return parseInternal(aa, state, input);
}

O ArenaAllocator recebe um alocador filho, neste caso o alocador que foi passado para init, e cria um novo std.mem.Allocator. Quando este novo alocador é usado para alocar ou criar memória, não precisamos chamar free ou destroy. Tudo será liberado quando chamarmos deinit na arena. Na verdade, o free e destroy de um ArenaAllocator não fazem nada.

O ArenaAllocator deve ser usado com cuidado. Como não há maneira de liberar alocações individuais, é necessário ter certeza de que o deinit da arena será chamado dentro de um crescimento de memória razoável. Curiosamente, esse conhecimento pode ser interno ou externo. Por exemplo, em nosso esqueleto acima, fazer uso de um ArenaAllocator faz sentido dentro do Parser, já que os detalhes do tempo de vida do estado são uma questão interna.

Alocadores como o ArenaAllocator, que têm um mecanismo para liberar todas as alocações anteriores, podem quebrar a regra de que cada alocação (alloc) deve ter uma liberação (free) correspondente. No entanto, se você receber um std.mem.Allocator, não deve fazer suposições sobre a implementação por de baixo.

O mesmo não pode ser dito para a nossa IntList. Ela pode ser usada para armazenar 10 ou 10 milhões de valores. Pode ter uma vida útil medida em milissegundos ou semanas. Ela não está em posição de decidir o tipo de alocador a ser usado. É o código que faz uso de IntList que tem esse conhecimento. Originalmente, gerenciamos nossa IntList assim:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

var list = try IntList.init(allocator);
defer list.deinit();

Poderíamos ter optado por fornecer um ArenaAllocator:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();

var list = try IntList.init(aa);

// Honestamente, estou em dúvida se devemos ou não chamar "list.deinit"
// Tecnicamente, não precisamos visto que utilizamos "defer" na chamada acima para "arena.deinit()" arena.deinit() above.
defer list.deinit();

...

Não precisamos alterar a IntList, pois ela lida apenas com um std.mem.Allocator. E se a IntList internamente criasse sua própria arena, isso também funcionaria. Não há motivo para você não criar uma arena dentro de uma arena.

Como último exemplo rápido, o servidor HTTP que mencionei acima expõe um alocador de arena na Response. Assim que a resposta é enviada, a arena é limpa. A vida útil previsível da arena (do início ao fim da solicitação) a torna uma opção eficiente. Eficiente em termos de desempenho e facilidade de uso.

FixedBufferAllocator

O último alocador que vamos examinar é o std.heap.FixedBufferAllocator, que aloca memória a partir de um buffer (ou seja, []u8) que fornecemos. Este alocador tem dois benefícios principais. Primeiro, uma vez que toda a memória que poderia ser usada é criada antecipadamente, ele é rápido. Segundo, ele limita naturalmente quanto de memória pode ser alocado. Esse limite rígido também pode ser visto como uma desvantagem. Outra desvantagem é que free e destroy só funcionarão no último item alocado/criado (pense em uma pilha). Chamar free em uma alocação que não é a última é seguro, mas não fará nada.

const std = @import("std");

pub fn main() !void {
	var buf: [150]u8 = undefined;
	var fa = std.heap.FixedBufferAllocator.init(&buf);
	defer fa.reset();

	const allocator = fa.allocator();

	const json = try std.json.stringifyAlloc(allocator, .{
		.this_is = "an anonymous struct",
		.above = true,
		.last_param = "are options",
	}, .{.whitespace = .indent_2});

	std.debug.print("{s}\n", .{json});
}

O código acima imprime:

{
  "this_is": "an anonymous struct",
  "above": true,
  "last_param": "are options"
}

Mas mude nosso buf para ser um [120]u8 e você obterá um erro de OutOfMemory.

Um padrão comum com FixedBufferAllocators e, em menor grau, com ArenaAllocators, é usar reset neles e reutilizá-los. Isso libera todas as alocações anteriores e permite que o alocador seja reutilizado.


Ao não ter um alocador padrão, Zig é tanto transparente quanto flexível em relação às alocações. A interface std.mem.Allocator é poderosa, permitindo que alocadores especializados envolvam os mais gerais, como vimos com o ArenaAllocator.

De maneira mais geral, o poder e as responsabilidades associadas às alocações de heap são esperançosamente evidentes. A capacidade de alocar memória de tamanho arbitrário com uma vida útil arbitrária é essencial para a maioria dos programas.

No entanto, devido à complexidade que vem com a memória dinâmica, é bom ficar de olho em alternativas. Por exemplo, acima usamos std.fmt.allocPrint, mas a biblioteca padrão também possui um std.fmt.bufPrint. Este último recebe um buffer em vez de um alocador:

const std = @import("std");

pub fn main() !void {
	const name = "Leto";

	var buf: [100]u8 = undefined;
	const greeting = try std.fmt.bufPrint(&buf, "Hello {s}", .{name});

	std.debug.print("{s}\n", .{greeting});
}

Essa API transfere a responsabilidade do gerenciamento de memória para o chamador. Se tivéssemos um name mais longo ou um buf menor, nosso bufPrint poderia retornar um erro NoSpaceLeft. Mas existem muitos cenários em que um aplicativo possui limites conhecidos, como um comprimento máximo de nome. Nesses casos, bufPrint é mais seguro e rápido.

Outra possível alternativa para alocações dinâmicas é transmitir dados para um std.io.Writer. Assim como nosso Allocator, Writer é uma interface implementada por muitos tipos, como arquivos. Acima, usamos stringifyAlloc para serializar JSON em uma string alocada dinamicamente. Poderíamos ter usado stringify e fornecido um Writer:

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

	try std.json.stringify(.{
		.this_is = "an anonymous struct",
		.above = true,
		.last_param = "are options",
	}, .{.whitespace = .indent_2}, out.writer());
}

Enquanto alocadores são frequentemente fornecidos como o primeiro parâmetro de uma função, os writers geralmente são os últimos. ಠ_ಠ

Em muitos casos, envolver nosso writer em um std.io.BufferedWriter proporcionaria um bom impulso de desempenho.

O objetivo não é eliminar todas as alocações dinâmicas. Isso não funcionaria, já que essas alternativas só fazem sentido em casos específicos. Mas agora você tem muitas opções à sua disposição. Desde frames de pilha até um alocador de propósito geral, e todas as coisas intermediárias, como buffers estáticos, writers de streaming e alocadores especializados.

Genéricos (parametrização polimórfica)

No final da parte anterior, construímos uma matriz dinâmica básica chamada IntList. O objetivo dessa estrutura de dados era armazenar um número dinâmico de valores. Embora o algoritmo que usamos funcionasse para qualquer tipo de dado, nossa implementação estava vinculada a valores i64. Aí entram os genéricos, cujo objetivo é abstrair algoritmos e estruturas de dados de tipos específicos.

Muitas linguagens implementam genéricos com sintaxe especial e regras específicas para genéricos. No caso do Zig, os genéricos são menos uma característica específica e mais uma expressão do que a linguagem é capaz. Especificamente, os genéricos aproveitam a poderosa metaprogramação em tempo de compilação do Zig.

Vamos começar olhando para um exemplo bobo, apenas para nos situarmos:

const std = @import("std");

pub fn main() !void {
	var arr: IntArray(3) = undefined;
	arr[0] = 1;
	arr[1] = 10;
	arr[2] = 100;
	std.debug.print("{any}\n", .{arr});
}

fn IntArray(comptime length: usize) type {
	return [length]i64;
}

O código acima imprime { 1, 10, 100 }. A parte interessante é que temos uma função que retorna um type (portanto, a função tem PascalCase). E não é qualquer tipo, mas um tipo com base em um parâmetro de função. Esse código só funcionou porque declaramos length como comptime. Ou seja, exigimos que quem chama IntArray forneça um parâmetro de comprimento conhecido em tempo de compilação. Isso é necessário porque nossa função retorna um tipo (type) e os tipos (types) devem sempre ser conhecidos em tempo de compilação.

Uma função pode retornar qualquer tipo, não apenas primitivos e arrays. Por exemplo, com uma pequena alteração, podemos fazê-la retornar uma estrutura:

const std = @import("std");

pub fn main() !void {
	var arr: IntArray(3) = undefined;
	arr.items[0] = 1;
	arr.items[1] = 10;
	arr.items[2] = 100;
	std.debug.print("{any}\n", .{arr.items});
}

fn IntArray(comptime length: usize) type {
	return struct {
		items: [length]i64,
	};
}

Pode parecer estranho, mas o tipo de arr realmente é IntArray(3). É um tipo como qualquer outro tipo, e arr é um valor como qualquer outro valor. Se chamássemos IntArray(7), seria um tipo diferente. Talvez possamos organizar melhor as coisas:

const std = @import("std");

pub fn main() !void {
	var arr = IntArray(3).init();
	arr.items[0] = 1;
	arr.items[1] = 10;
	arr.items[2] = 100;
	std.debug.print("{any}\n", .{arr.items});
}

fn IntArray(comptime length: usize) type {
	return struct {
		items: [length]i64,

		fn init() IntArray(length) {
			return .{
				.items = undefined,
			};
		}
	};
}

À primeira vista, pode não parecer mais organizado. Mas além de não ter nome e estar aninhada em uma função, nossa estrutura está se parecendo com qualquer outra estrutura que vimos até agora. Ela tem campos, tem funções. Você sabe o que dizem: se parece com um pato.... Bem, isso se parece, nada e grasna como uma estrutura normal, porque é.

Tomamos esse caminho para nos familiarizarmos com uma função que retorna um tipo e a sintaxe correspondente. Para obter um genérico mais típico, precisamos fazer uma última alteração: nossa função precisa receber um tipo. Na realidade, esta é uma pequena mudança, mas type pode parecer mais abstrato do que usize, então fizemos isso lentamente. Vamos dar um salto e modificar nossa IntList anterior para funcionar com qualquer tipo. Começaremos com um esqueleto:

fn List(comptime T: type) type {
	return struct {
		pos: usize,
		items: []T,
		allocator: Allocator,

		fn init(allocator: Allocator) !List(T) {
			return .{
				.pos = 0,
				.allocator = allocator,
				.items = try allocator.alloc(T, 4),
			};
		}
	}
};

A estrutura (struct) acima é quase idêntica à nossa IntList anterior, exceto que i64 foi substituído por T. Esse T pode parecer especial, mas é apenas um nome de variável. Poderíamos tê-lo chamado de item_type. No entanto, seguindo a convenção de nomenclatura do Zig, variáveis do tipo type são escritas em PascalCase.

Para o bem ou para o mal, usar uma única letra para representar um parâmetro de tipo é muito mais antigo que o Zig. T é um padrão comum na maioria das linguagens, mas você verá variações específicas do contexto, como mapas de hash usando K e V para seus tipos de parâmetros de chave e valor.

Se você não tem certeza sobre nosso esqueleto, considere os dois lugares onde usamos T: items: []T e allocator.alloc(T, 4). Quando queremos usar esse tipo genérico, criaremos uma instância usando:

var list = try List(u32).init(allocator);

Quando o código é compilado, o compilador cria um novo tipo substituindo cada T por u32. Se usarmos List(u32) novamente, o compilador reutilizará o tipo que foi criado anteriormente. Se especificarmos um novo valor para T, como List(bool) ou List(User), novos tipos serão criados.

Para completar nossa Lista genérica, podemos literalmente copiar e colar o restante do código do IntList e substituir i64 por T. Aqui está um exemplo completo funcional:

const std = @import("std");
const Allocator = std.mem.Allocator;

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

	var list = try List(u32).init(allocator);
	defer list.deinit();

	for (0..10) |i| {
		try list.add(@intCast(i));
	}

	std.debug.print("{any}\n", .{list.items[0..list.pos]});
}

fn List(comptime T: type) type {
	return struct {
		pos: usize,
		items: []T,
		allocator: Allocator,

		fn init(allocator: Allocator) !List(T) {
			return .{
				.pos = 0,
				.allocator = allocator,
				.items = try allocator.alloc(T, 4),
			};
		}

		fn deinit(self: List(T)) void {
			self.allocator.free(self.items);
		}

		fn add(self: *List(T), value: T) !void {
			const pos = self.pos;
			const len = self.items.len;

			if (pos == len) {
			    // ficamos sem espaço
			    // cria-se um "slice" duas vezes maior
				var larger = try self.allocator.alloc(T, len * 2);

                // copia-se os itens que adicionamos previamente ao nosso novo espaço criado
				@memcpy(larger[0..len], self.items);

				self.allocator.free(self.items);

				self.items = larger;
			}

			self.items[pos] = value;
			self.pos = pos + 1;
		}
	};
}

Nossa função init retorna uma List(T), e nossas funções deinit e add recebem uma List(T) e *List(T). Para nossa classe simples, isso está bem, mas para estruturas de dados grandes, escrever o nome genérico completo pode se tornar um pouco tedioso, especialmente se tivermos múltiplos parâmetros de tipo (por exemplo, um mapa hash que aceita um tipo separado para sua chave e valor). A função embutida @This() retorna o tipo mais interno de onde é chamada. Muito provavelmente, nosso List(T) seria escrito como:

fn List(comptime T: type) type {
	return struct {
		pos: usize,
		items: []T,
		allocator: Allocator,

		// Adicionado
		const Self = @This();

		fn init(allocator: Allocator) !Self {
			// mesmo código
		}

		fn deinit(self: Self) void {
			// mesmo código
		}

		fn add(self: *Self, value: T) !void {
			// mesmo código
		}
	};
}

Self não é um nome especial, é apenas uma variável, e está em PascalCase porque seu valor é um tipo (type). Podemos usar Self onde anteriormente usávamos List(T).


Poderíamos criar exemplos mais complexos, com vários parâmetros de tipo e algoritmos mais avançados. No entanto, no final, o código genérico essencial não seria diferente dos exemplos simples acima. Na próxima parte, voltaremos a abordar os genéricos ao analisar a ArrayList(T) e StringHashMap(V) da biblioteca padrão.

Programando em Zig

Com grande parte da linguagem agora abordada, vamos concluir revisando alguns tópicos e explorando alguns aspectos mais práticos do uso do Zig. Ao fazer isso, vamos introduzir mais da biblioteca padrão e apresentar trechos de código menos triviais.

Ponteiros pendurados/soltos (dangling pointers)

Vamos começar examinando mais exemplos de ponteiros pendurados. Isso pode parecer algo estranho para focar, mas se você estiver vindo de uma linguagem com coleta de lixo, isso provavelmente será o maior desafio que você enfrentará.

Você consegue descobrir qual será a saída do seguinte código?

const std = @import("std");

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

	var lookup = std.StringHashMap(User).init(allocator);
	defer lookup.deinit();

	const goku = User{.power = 9001};

	try lookup.put("Goku", goku);

    // retorna um opcional, .? iria levantar um erro caso "Goku"
    // não estivesse em nosso dicionário (hashmap)
	const entry = lookup.getPtr("Goku").?;

	std.debug.print("Goku's power is: {d}\n", .{entry.power});

    // retorna verdadeiro/falso dependendo se o item foi removido ou não
	_ = lookup.remove("Goku");

	std.debug.print("Goku's power is: {d}\n", .{entry.power});
}

const User = struct {
	power: i32,
};

Ao executar isto, recebi como resultado:

Goku's power is: 9001
Goku's power is: -1431655766

Este código introduz o std.StringHashMap genérico do Zig, que é uma versão especializada do std.AutoHashMap com o tipo de chave definido como []const u8. Mesmo que você não tenha certeza do que está acontecendo, é uma boa suposição que a minha saída está relacionada ao fato de que nosso segundo print ocorre após o remove da entrada de lookup. Comente a chamada para remove, e a saída será normal.

A chave para entender o código acima é estar ciente de onde os dados/memória existem, ou, em outras palavras, quem é o proprietário. Lembre-se de que os argumentos do Zig são passados por valor, ou seja, passamos uma cópia [superficial] do valor. O User em nosso lookup não é a mesma memória referenciada por goku. Nosso código acima tem dois usuários, cada um com seu próprio proprietário. goku é de propriedade do main, e sua cópia é de propriedade de lookup.

O método getPtr retorna um ponteiro para o valor no mapa, em nosso caso, ele retorna um *User. Aqui está o problema, remove torna nosso ponteiro de entry inválido. Neste exemplo, a proximidade de getPtr e remove torna o problema um pouco óbvio. Mas não é difícil imaginar código chamando remove sem saber que uma referência à entrada está sendo mantida em outro lugar.

Quando escrevi este exemplo, não tinha certeza do que aconteceria. Era possível que remove fosse implementado definindo uma marcação interna, adiando a remoção real até um evento posterior. Se esse fosse o caso, o código acima poderia ter "funcionado" em nossos casos simples, mas teria falhado com um uso mais complicado. Isso soa aterrorizantemente difícil de depurar.

Além de não chamar remove, podemos corrigir isso de algumas maneiras diferentes. A primeira é que poderíamos usar get em vez de getPtr. Isso retornaria um User em vez de um *User e, portanto, retornaria uma cópia do valor em lookup. Teríamos então três Users.

  1. O original goku, vinculado à função.
  2. A cópia em lookup, de propriedade do lookup.
  3. E uma cópia de nossa cópia, entry, também vinculada à função.

Como entry seria agora sua própria cópia independente do usuário, removê-lo de lookup não o invalidaria.

Outra opção é alterar o tipo de lookup de StringHashMap(User) para StringHashMap(*const User). Este código funciona:

const std = @import("std");

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

	// User -> *const User
	var lookup = std.StringHashMap(*const User).init(allocator);
	defer lookup.deinit();

	const goku = User{.power = 9001};

	// goku -> &goku
	try lookup.put("Goku", &goku);

	// getPtr -> get
	const entry = lookup.get("Goku").?;

	std.debug.print("Goku's power is: {d}\n", .{entry.power});
	_ = lookup.remove("Goku");
	std.debug.print("Goku's power is: {d}\n", .{entry.power});
}

const User = struct {
	power: i32,
};

Existem várias sutilezas no código acima. Em primeiro lugar, agora temos um único User, o goku. O valor em lookup e entry são ambas referências ao goku. Nossa chamada para remove ainda remove o valor de nosso lookup, mas esse valor é apenas o endereço de user, não é o user em si. Se tivéssemos mantido getPtr, obteríamos um **User inválido, inválido por causa do remove. Em ambas as soluções, tivemos que usar get em vez de getPtr, mas neste caso, estamos apenas copiando o endereço, não o User completo. Para objetos grandes, isso pode fazer uma diferença significativa.

Com tudo em uma única função e um valor pequeno como User, isso ainda parece um problema criado artificialmente. Precisamos de um exemplo que torne a propriedade dos dados uma preocupação imediata.

Propriedade (ownership)

Eu adoro mapas de hash porque são algo que todos conhecem e usam. Eles também têm muitos casos de uso diferentes, a maioria dos quais você provavelmente experimentou em primeira mão. Embora possam ser usados para pesquisas de curta duração, muitas vezes são de longa duração e, portanto, exigem valores igualmente duradouros.

Este código preenche nosso mapa (lookup) com nomes que você insere no terminal. Um nome vazio interrompe o loop do prompt. Finalmente, ele detecta se "Leto" foi um dos nomes fornecidos.

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

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

	var lookup = std.StringHashMap(User).init(allocator);
	defer lookup.deinit();

	// stdin é um std.io.Reader
	// o oposto de um std.io.Writer, que já vimos
	const stdin = std.io.getStdIn().reader();

	// stdout é um std.io.Writer
	const stdout = std.io.getStdOut().writer();

	var i: i32 = 0;
	while (true) : (i += 1) {
		var buf: [30]u8 = undefined;
		try stdout.print("Please enter a name: ", .{});
		if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
			var name = line;
			if (builtin.os.tag == .windows) {
				// No Windows as linhas são terminadas com \r\n.
				// Então temos que remover o \r
				name = std.mem.trimRight(u8, name, "\r");
			}
			if (name.len == 0) {
				break;
			}
			try lookup.put(name, .{.power = i});
		}
	}

	const has_leto = lookup.contains("Leto");
	std.debug.print("{any}\n", .{has_leto});
}

const User = struct {
	power: i32,
};

O código é sensível a maiúsculas e minúsculas, mas não importa o quão perfeitamente digitamos "Leto", contains sempre retorna false. Vamos depurar isso iterando através de lookup e exibindo as chaves e valores:

// Acrescente este código após a iteração do "while"

var it = lookup.iterator();
while (it.next()) |kv| {
	std.debug.print("{s} == {any}\n", .{kv.key_ptr.*, kv.value_ptr.*});
}

Esse padrão de iterador é comum em Zig e depende da sinergia entre while e tipos opcionais. Nosso item do iterador retorna ponteiros para a chave e o valor, por isso os desreferenciamos com .* para acessar o valor real em vez do endereço. A saída dependerá do que você inserir, mas eu obtive:

Please enter a name: Paul
Please enter a name: Teg
Please enter a name: Leto
Please enter a name:

�� == learning.User{ .power = 1 }

��� == learning.User{ .power = 0 }

��� == learning.User{ .power = 2 }
false

Os valores parecem estar corretos, mas não as chaves. Se você não tem certeza do que está acontecendo, provavelmente é minha culpa. Antes, intencionalmente, desviei sua atenção. Eu disse que os mapas de hash são frequentemente de longa duração e, portanto, exigem valores de longa duração. A verdade é que eles exigem valores e chaves de longa duração! Observe que buf é definido dentro do nosso loop while. Quando chamamos o put, estamos dando ao nosso mapa de hash uma chave que tem uma vida útil muito mais curta do que o próprio mapa de hash. Mover buf para fora do loop while resolve nosso problema de tempo de vida, mas esse buffer é reutilizado em cada iteração. Ainda não funcionará porque estamos mutando os dados da chave subjacente.

Para o código acima, há realmente apenas uma solução: nosso lookup deve assumir a propriedade das chaves. Precisamos adicionar uma linha e alterar outra:

// substitua o "lookup.put" existente por estas duas linhas
const owned_name = try allocator.dupe(u8, name);

// name -> owned_name
try lookup.put(owned_name, .{.power = i});

dupe é um método de std.mem.Allocator que ainda não vimos antes. Ele aloca uma duplicata do valor fornecido. O código agora funciona porque nossas chaves, agora no heap, têm uma vida útil mais longa que lookup. Na verdade, fizemos um trabalho bom demais em estender a vida dessas strings: introduzimos vazamentos de memória.

Você pode ter pensado que, quando chamamos lookup.deinit, nossas chaves e valores seriam liberados para nós. Mas não há uma solução única que StringHashMap poderia usar. Primeiro, as chaves podem ser literais de string, que não podem ser liberadas. Segundo, elas podem ter sido criadas com um alocador diferente. Finalmente, embora mais avançado, há casos legítimos em que as chaves podem não ser de propriedade do mapa de hash.

A única solução é liberar as chaves por nós mesmos. Neste ponto, provavelmente faria sentido criar nosso próprio tipo UserLookup e encapsular essa lógica de limpeza em nossa função deinit. Vamos manter as coisas bagunçadas:

// substitua:
//   defer lookup.deinit();
// por:
defer {
	var it = lookup.keyIterator();
	while (it.next()) |key| {
		allocator.free(key.*);
	}
	lookup.deinit();
}

Nossa lógica de defer, a primeira que vimos com um bloco, libera cada chave e, em seguida, desinicializa lookup. Estamos usando keyIterator para iterar apenas sobre as chaves. O valor do iterador é um ponteiro para a entrada da chave no mapa de hash, um *[]const u8. Queremos liberar o valor real, já que é isso que alocamos via dupe, então desreferenciamos o valor usando .*.

Eu prometo, terminamos de falar sobre ponteiros pendentes e gerenciamento de memória. O que discutimos ainda pode estar pouco claro ou muito abstrato. Está tudo bem revisitar isso quando você tiver um problema mais prático para resolver. Dito isso, se você planeja escrever algo não trivial, é quase certo que precisará dominar esse tópico. Quando se sentir preparado, sugiro que pegue o exemplo do loop de prompt e brinque com ele por conta própria. Introduza um tipo UserLookup que encapsule toda a gestão de memória que tivemos que fazer. Tente ter valores *User em vez de User, criando os usuários no heap e liberando-os como fizemos com as chaves. Escreva testes que cubram sua nova estrutura, usando o std.testing.allocator para garantir que não está vazando memória.

ArrayList

Você ficará feliz em saber que pode esquecer sobre nosso IntList e a alternativa genérica que criamos. Zig possui uma implementação adequada de array dinâmico: std.ArrayList(T).

É algo bastante padrão, mas é uma estrutura de dados tão comumente necessária e utilizada que vale a pena vê-la em ação:

const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;

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

	var arr = std.ArrayList(User).init(allocator);
	defer {
		for (arr.items) |user| {
			user.deinit(allocator);
		}
		arr.deinit();
	}

	// stdin é um std.io.Reader
	// o oposto de um std.io.Writer, que já vimos
	const stdin = std.io.getStdIn().reader();

	// stdout é um std.io.Writer
	const stdout = std.io.getStdOut().writer();

	var i: i32 = 0;
	while (true) : (i += 1) {
		var buf: [30]u8 = undefined;
		try stdout.print("Please enter a name: ", .{});
		if (try stdin.readUntilDelimiterOrEof(&buf, '\n')) |line| {
			var name = line;
			if (builtin.os.tag == .windows) {
				// No Windows as linhas são terminadas com \r\n.
				// Temos que remover o \r
				name = std.mem.trimRight(u8, name, "\r");
			}
			if (name.len == 0) {
				break;
			}
			const owned_name = try allocator.dupe(u8, name);
			try arr.append(.{.name = owned_name, .power = i});
		}
	}

	var has_leto = false;
	for (arr.items) |user| {
		if (std.mem.eql(u8, "Leto", user.name)) {
			has_leto = true;
			break;
		}
	}

	std.debug.print("{any}\n", .{has_leto});
}

const User = struct {
	name: []const u8,
	power: i32,

	fn deinit(self: User, allocator: Allocator) void {
		allocator.free(self.name);
	}
};

Acima está uma reprodução do nosso código de hash map, mas usando um ArrayList(User). Todas as mesmas regras de tempo de vida e gerenciamento de memória se aplicam. Observe que ainda estamos criando um dupe do nome e ainda estamos liberando cada nome antes de desinicializar (deinit) o ArrayList.

Este é um bom momento para destacar que Zig não possui propriedades ou campos privados. Você pode ver isso quando acessamos arr.items para iterar pelos valores. A razão para não ter propriedades é eliminar uma fonte de surpresas. Em Zig, se parece com um acesso a campo, é um acesso a campo. Pessoalmente, acho que a falta de campos privados é um erro, mas certamente é algo com o qual podemos lidar. Tenho usado o prefixo de sublinhado nos campos para sinalizar "uso interno apenas".

Porque a string "type" é uma []u8 ou []const u8, um ArrayList(u8) é o tipo apropriado para um construtor de strings, como o StringBuilder do .NET ou o strings.Builder do Go. Na verdade, você frequentemente usará isso quando uma função receber um Writer e você quiser uma string. Anteriormente, vimos um exemplo que usava std.json.stringify para imprimir JSON no stdout. Aqui está como você usaria um ArrayList(u8) para armazená-lo em uma variável:

const std = @import("std");

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

	var out = std.ArrayList(u8).init(allocator);
	defer out.deinit();

	try std.json.stringify(.{
		.this_is = "an anonymous struct",
		.above = true,
		.last_param = "are options",
	}, .{.whitespace = .indent_2}, out.writer());

	std.debug.print("{s}\n", .{out.items});
}

Anytype

Na parte 1, falamos brevemente sobre anytype. É uma forma bastante útil de duck-typing em tempo de compilação. Aqui está um logger simples:

pub const Logger = struct {
	level: Level,

	// "error" é reservado, nomes dentro de @"..." sempre serão
	// tratados como identificadores
	const Level = enum {
		debug,
		info,
		@"error",
		fatal,
	};

	fn info(logger: Logger, msg: []const u8, out: anytype) !void {
		if (@intFromEnum(logger.level) <= @intFromEnum(Level.info)) {
			try out.writeAll(msg);
		}
	}
};

O parâmetro de saída (out) de nossa função info tem o tipo anytype. Isso significa que nosso Logger pode registrar mensagens em qualquer estrutura que tenha um método writeAll aceitando um []const u8 e retornando um !void. Isso não é uma característica em tempo de execução. A verificação de tipo ocorre em tempo de compilação e, para cada tipo usado, uma função corretamente tipada é criada. Se tentarmos chamar info com um tipo que não tenha todas as funções necessárias (neste caso, apenas writeAll), receberemos um erro de compilação:

var l = Logger{.level = .info};
try l.info("sever started", true);

Dando-nos: nenhum campo ou função de membro chamado "writeAll" em "bool" (no field or member function named 'writeAll' in 'bool'). Usar o writer de um ArrayList(u8) funciona:

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

	var l = Logger{.level = .info};

	var arr = std.ArrayList(u8).init(allocator);
	defer arr.deinit();

	try l.info("sever started", arr.writer());
	std.debug.print("{s}\n", .{arr.items});
}

Uma grande desvantagem do anytype é a documentação. Aqui está a assinatura da função std.json.stringify que usamos algumas vezes:

// Eu ODEIO definições de função em várias linhas
// Mas farei uma exceção para o guia
// visto que você pode estar lendo em uma tela pequena.

fn stringify(
	value: anytype,
	options: StringifyOptions,
	out_stream: anytype
) @TypeOf(out_stream).Error!void

O primeiro parâmetro, value: anytype, é meio óbvio. É o valor a ser serializado e pode ser qualquer coisa (na verdade, há algumas coisas que o serializador JSON do Zig não pode serializar). Podemos supor que out_stream é onde escrever o JSON, mas sua suposição é tão boa quanto a minha sobre quais métodos ela precisa implementar. A única maneira de descobrir é ler o código-fonte ou, alternativamente, passar um valor fictício e usar os erros do compilador como nossa documentação. Isso é algo que pode ser aprimorado com geradores automáticos de documentação melhores. Mas, não pela primeira vez, eu gostaria que o Zig tivesse interfaces.

@TypeOf

Nas partes anteriores, usamos @TypeOf para nos ajudar a examinar o tipo de várias variáveis. A partir do nosso uso, você poderia pensar que ele retorna o nome do tipo como uma string. No entanto, dado que é uma função PascalCase, você deve saber melhor: ela retorna um tipo (type).

Uma das minhas utilizações favoritas de anytype é combiná-la com as funções internas @TypeOf e @hasField para escrever ajudantes de teste. Embora todo tipo User que vimos tenha sido muito simples, peço que você imagine uma estrutura mais complexa com muitos campos. Em muitos de nossos testes, precisamos de um User, mas queremos especificar apenas os campos relevantes para o teste. Vamos criar uma userFactory:

fn userFactory(data: anytype) User {
	const T = @TypeOf(data);
	return .{
		.id = if (@hasField(T, "id")) data.id else 0,
		.power = if (@hasField(T, "power")) data.power else 0,
		.active  = if (@hasField(T, "active")) data.active else true,
		.name  = if (@hasField(T, "name")) data.name else "",
	};
}

pub const User = struct {
	id: u64,
	power: u64,
	active: bool,
	name: [] const u8,
};

Um usuário padrão pode ser criado chamando userFactory(.{}), ou podemos substituir campos específicos com userFactory(.{.id = 100, .active = false}). É um pequeno padrão, mas eu realmente gosto dele. Também é um bom passo inicial para o mundo da metaprogramação.

Mais comumente, @TypeOf é combinado com @typeInfo, que retorna um std.builtin.Type. Este é um poderoso sindicato rotulado que descreve totalmente um tipo. A função std.json.stringify usa recursivamente isso no valor (value) fornecido para descobrir como serializá-lo.

Zig Build

Se você leu este guia inteiro esperando obter insights sobre a configuração de projetos mais complexos, com várias dependências e vários destinos, você está prestes a ficar desapontado. O Zig possui um sistema de construção poderoso, tanto que um número crescente de projetos não relacionados ao Zig está fazendo uso dele, como o libsodium. Infelizmente, todo esse poder significa que, para necessidades mais simples, ele não é o mais fácil de usar ou entender.

A verdade é que eu não entendo bem o sistema de construção do Zig o suficiente para explicá-lo.

Ainda assim, podemos pelo menos ter uma visão geral breve. Para executar nosso código Zig, usamos zig run learning.zig. Uma vez, também usamos zig test learning.zig para executar um teste. Os comandos run e test são bons para brincar, mas é o comando build que você precisará para algo mais complexo. O comando build depende de um arquivo build.zig com o ponto de entrada build especial. Aqui está um esqueleto:

// build.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
	_ = b;
}

Cada compilação possui uma etapa padrão de "instalação", que você pode agora executar com zig build install, mas como nosso arquivo está principalmente vazio, você não obterá artefatos significativos. Precisamos informar à nossa compilação sobre o ponto de entrada do nosso programa, que está em learning.zig:

const std = @import("std");

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

	// executável de configuração
	const exe = b.addExecutable(.{
		.name = "learning",
		.target = target,
		.optimize = optimize,
		.root_source_file = .{ .path = "learning.zig" },
	});
	b.installArtifact(exe);
}

Agora, se você executar zig build install, obterá um binário em ./zig-out/bin/learning. Usar os destinos e otimizações padrão nos permite substituir o padrão usando argumentos de linha de comando. Por exemplo, para construir uma versão otimizada para o tamanho do nosso programa para Windows, faríamos:

zig build install -Doptimize=ReleaseSmall -Dtarget=x86_64-windows-gnu

Um executável geralmente terá dois passos adicionais, além do "install" padrão: "run" e "test". Uma biblioteca pode ter um único passo "test". Para uma execução básica sem argumentos, precisamos adicionar quatro linhas ao final de nosso arquivo build.zig:

// adicionar depois: b.installArtifact(exe);

const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());

const run_step = b.step("run", "Start learning!");
run_step.dependOn(&run_cmd.step);

Isso cria duas dependências por meio das duas chamadas para dependOn. A primeira associa nosso novo comando "run" ao passo de instalação incorporado. A segunda associa o passo "run" ao nosso recém-criado comando "run". Você pode estar se perguntando por que precisa de um comando run além de um passo run. Eu acredito que essa separação existe para dar suporte a configurações mais complicadas: passos que dependem de vários comandos ou comandos que são usados em vários passos. Se você executar zig build --help e rolar para o topo, verá nosso novo passo "run". Agora você pode executar o programa executando zig build run.

Para adicionar um passo "test", você duplicará a maior parte do código run que acabamos de adicionar, mas em vez de b.addExecutable, você iniciará as coisas com b.addTest:

const tests = b.addTest(.{
	.target = target,
	.optimize = optimize,
	.root_source_file = .{ .path = "learning.zig" },
});

const test_cmd = b.addRunArtifact(tests);
test_cmd.step.dependOn(b.getInstallStep());
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&test_cmd.step);

Nomeamos esse passo como "test". Executar zig build --help agora deve mostrar outro passo disponível, "test". Como não temos nenhum teste, é difícil dizer se isso está funcionando ou não. No arquivo learning.zig, adicione o seguinte:

test "dummy build test" {
	try std.testing.expectEqual(false, true);
}

Agora, ao executar zig build test, você deve obter uma falha no teste. Se corrigir o teste e executar zig build test novamente, você não obterá nenhuma saída. Por padrão, o executor de testes do Zig só produz saída em caso de falha. Use zig build test --summary all se, assim como eu, você sempre quiser um resumo, seja para indicar sucesso ou falha.

Essa é a configuração mínima necessária para começar. Mas fique tranquilo sabendo que, se precisar construir algo, Zig provavelmente pode lidar com isso. Por fim, você pode, e provavelmente deve, usar zig init-exe ou zig init-lib dentro do diretório do seu projeto para que o Zig crie um arquivo build.zig bem documentado para você.

Dependências de bibliotecas de terceiros

O sistema de gerenciamento de pacotes embutido no Zig é relativamente novo e, como consequência, possui algumas arestas ásperas. Embora haja espaço para melhorias, ele é utilizável como está. Existem duas partes que precisamos examinar: criar um pacote e usar pacotes. Vamos passar por isso em detalhes.

Primeiro, crie uma nova pasta chamada calc e crie três arquivos. O primeiro é add.zig, com o seguinte conteúdo:

// Ah, uma lição oculta, veja o tipo de b
// e o tipo de retorno!!

pub fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
	return a + b;
}

const testing = @import("std").testing;
test "add" {
	try testing.expectEqual(@as(i32, 32), add(30, 2));
}

É um pouco bobo, um pacote inteiro apenas para somar dois valores, mas isso nos permitirá focar no aspecto do empacotamento. Em seguida, adicionaremos outro igualmente bobo: calc.zig:

pub const add = @import("add.zig").add;

test {
    // Por padrão, apenas testes em um determinado arquivo
    // são incluídos. Esta linha de código mágica irá
    // causar uma referência para todos os contêineres
    // que serão testados.
	@import("std").testing.refAllDecls(@This());
}

Estamos dividindo isso entre calc.zig e add.zig para mostrar que o comando zig build automaticamente compilará e empacotará todos os arquivos do nosso projeto. Finalmente, podemos adicionar um build.zig:

const std = @import("std");

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

	const tests = b.addTest(.{
		.target = target,
		.optimize = optimize,
		.root_source_file = .{ .path = "calc.zig" },
	});

	const test_cmd = b.addRunArtifact(tests);
	test_cmd.step.dependOn(b.getInstallStep());
	const test_step = b.step("test", "Run the tests");
	test_step.dependOn(&test_cmd.step);
}

Isso é tudo uma repetição do que vimos na seção anterior. Com isso, você pode executar zig build test --summary all.

De volta ao nosso projeto de aprendizado (learning) e ao nosso arquivo build.zig anteriormente criado. Vamos começar adicionando nosso calc local como uma dependência. Precisamos fazer três adições. Primeiro, vamos criar um módulo apontando para o nosso calc.zig:

// Você pode colocar isto próximo ao tipo da
// função, antes da chamada para "addExecutable".

const calc_module = b.addModule("calc", .{
	.source_file = .{ .path = "CAMINHO_PARA_O_PROJETO_CALC/calc.zig" },
});

Você precisará ajustar o caminho para calc.zig. Agora, precisamos adicionar este módulo tanto para a variável exe quanto para tests:

const exe = b.addExecutable(.{
	.name = "learning",
	.target = target,
	.optimize = optimize,
	.root_source_file = .{ .path = "learning.zig" },
});
// adicione isto
exe.addModule("calc", calc_module);
b.installArtifact(exe);

....

const tests = b.addTest(.{
	.target = target,
	.optimize = optimize,
	.root_source_file = .{ .path = "learning.zig" },
});
// adicione isto
tests.addModule("calc", calc_module);

De dentro do projeto, agora você é capaz de usar @import("calc"):

const calc = @import("calc");
...
calc.add(1, 2);

Adicionar uma dependência remota exige um pouco mais de esforço. Primeiro, precisamos voltar ao projeto calc e definir um módulo. Você pode pensar que o projeto em si é um módulo, mas um projeto pode expor vários módulos, então precisamos criá-lo explicitamente. Usamos o mesmo addModule, mas descartamos o valor de retorno. Simplesmente chamar o addModule é suficiente para definir o módulo que outros projetos poderão importar.

_ = b.addModule("calc", .{
	.source_file = .{ .path = "calc.zig" },
});

Esta é a única alteração que precisamos fazer em nossa biblioteca. Por ser um exercício de ter uma dependência remota, eu enviei este projeto calc para o GitHub para que possamos importá-lo para o nosso projeto de aprendizado. Ele está disponível em https://github.com/karlseguin/calc.zig.

De volta ao nosso projeto de aprendizado, precisamos de um novo arquivo, build.zig.zon. "ZON" significa Zig Object Notation e permite que dados Zig sejam expressos em um formato legível por humanos e que esse formato legível por humanos seja transformado em código Zig. O conteúdo do build.zig.zon será:

.{
  .name = "learning",
  .paths = .{""},
  .version = "0.0.0",
  .dependencies = .{
    .calc = .{
      .url = "https://github.com/karlseguin/calc.zig/archive/e43c576da88474f6fc6d971876ea27effe5f7572.tar.gz",
      .hash = "12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
    },
  },
}

Há dois valores questionáveis neste arquivo, o primeiro é e43c576da88474f6fc6d971876ea27effe5f7572 dentro da url. Este é simplesmente o hash do commit do git. O segundo é o valor de hash. Até onde eu sei, atualmente não há uma ótima maneira de determinar qual deve ser esse valor, então usamos um valor fictício por enquanto.

Para usar essa dependência, precisamos fazer uma alteração em nosso build.zig:

// substitua isto:
const calc_module = b.addModule("calc", .{
	.source_file = .{ .path = "calc/calc.zig" },
});

// por isso:
const calc_dep = b.dependency("calc", .{.target = target,.optimize = optimize});
const calc_module = calc_dep.module("calc");

Em build.zig.zon, nomeamos a dependência como calc, e é essa dependência que estamos carregando aqui. De dentro dessa dependência, estamos pegando o módulo chamado calc, que é o que nomeamos no build.zig do calc.

Se você tentar executar zig build test, deve ver um erro:

error: hash mismatch:
expected:
12ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff,

found:
122053da05e0c9348d91218ef015c8307749ef39f8e90c208a186e5f444e818672d4

Copie e cole o hash correto de volta no build.zig.zon e tente executar zig build test novamente. Agora, tudo deve estar funcionando.

Pode parecer um pouco complicado, e espero que as coisas se tornem mais simples. Mas, na maior parte, é algo que você pode copiar e colar de outros projetos e, uma vez configurado, você pode prosseguir.

Um aviso, descobri que o cache de dependências do Zig é bastante agressivo. Se você tentar atualizar uma dependência, mas Zig não parece detectar a mudança... bom, eu costumo apagar a pasta zig-cache do projeto, bem como ~/.cache/zig.


Cobrimos muitos tópicos, explorando algumas estruturas de dados fundamentais e reunindo grandes partes das seções anteriores. Nosso código ficou um pouco mais complexo, concentrando-se menos na sintaxe específica e parecendo mais com código real. Estou animado com a possibilidade de que, apesar dessa complexidade, o código tenha feito sentido na maior parte. Se não fez, não desista. Escolha um exemplo, introduza erros, adicione instruções de impressão, escreva alguns testes para ele. Mexa diretamente com o código, criando o seu, e depois volte para ler as partes que não fizeram sentido inicialmente.

Conclusão

Alguns leitores podem me reconhecer como o autor de vários "The Little $TECH Book" e se perguntar por que isso não é chamado de "The Little Zig Book". A verdade é que não tenho certeza se o Zig se encaixa no formato "The Little". Parte do desafio é que a complexidade e a curva de aprendizado do Zig variarão muito dependendo de sua própria formação e experiência. Se você é um programador experiente em C ou C++, então um resumo conciso da linguagem provavelmente é suficiente, mas, nesse caso, você provavelmente dependerá da Referência de Linguagem do Zig.

Embora tenhamos coberto muita coisa neste guia, ainda há uma grande quantidade de conteúdo que não abordamos. Eu não quero que isso desanime ou sobrecarregue você. Todas as linguagens são multicamadas, e agora você tem uma base e uma referência para começar e iniciar sua maestria. Francamente, as partes que não cobri simplesmente não entendi o suficiente para explicar. Isso não me impediu de usar e construir coisas significativas em Zig, como uma biblioteca popular de servidor HTTP.

Quero destacar uma coisa que foi completamente deixada de lado. Provavelmente é algo que você já sabe, mas o Zig funciona especialmente bem com código C. Como o ecossistema ainda é jovem e a biblioteca padrão é pequena, você pode se deparar com casos em que usar uma biblioteca C é a melhor opção. Por exemplo, não há um módulo de expressão regular na biblioteca padrão do Zig, e uma opção razoável seria usar uma biblioteca C. Eu escrevi bibliotecas Zig para SQLite e DuckDB, e foi direto. Se você seguiu a maior parte do que está neste guia, não deverá ter problemas.

Espero que este recurso ajude e que você se divirta programando.

Agradecimentos

Agradeço à todas as pessoas que contribuíram e fizeram sugestões nesta série de publicações. Particularmente, à Gonzalo Diethelm por realizar uma edição detalhada.