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
.
- O original
goku
, vinculado à função. - A cópia em
lookup
, de propriedade dolookup
. - 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.