A avaliação de que o comptime do Zig é extraordinário
(scottredig.com)- A metaprogramação em tempo de compilação (comptime) do Zig pode ser lida como código de runtime, mas ainda assim gerar código executável tão eficiente quanto uma função em C escrita manualmente para tarefas repetitivas, como somar campos de uma struct
- Em vez de uma sintaxe separada para genéricos, o uso de
comptime T: typeeanytypepara lidar com programação genérica é um dos principais casos de uso do comptime no Zig - Runtime, comptime e o sistema de build compartilham a mesma linguagem Zig, então até uma função como Fizz Buzz pode ser executada durante a compilação para virar um array pré-calculado
- O compilador avalia primeiro o que for possível e deixa apenas as partes que precisam de valores de runtime como bytecode de saída que depois vira código de máquina
- O comptime do Zig pode resolver problemas parecidos com geração de código em texto, mas sem substituir palavras-chave arbitrariamente como macros em C, o que reduz a carga de leitura e depuração do código
Uma forma de ler o comptime do Zig
- O comptime do Zig é um recurso de metaprogramação, mas ao ler o comportamento final não é preciso se preocupar demais com a distinção entre tempo de compilação e runtime
- O terceiro item do
zig zen, “Favor reading code over writing code”, é um critério importante- Metaprogramação baseada em macros ou geração de código pode criar duas camadas: o código original e o código expandido
- Esse tipo de camada indireta dificulta a leitura do código, a depuração e a mudança de comportamento
- O texto compara lado a lado o código de runtime que soma o array
[3]i64 = .{1,2,3}e o código de comptime que soma os camposa,b,cda structMyStructinline for (comptime std.meta.fieldNames(MyStruct))percorre os nomes dos campos@field(my_struct, field_name)acessa o campo usando um nome conhecido em tempo de compilação
- O exemplo de soma dos campos da struct parece reflexão em runtime, mas no executável fica código tão eficiente quanto uma função de soma escrita manualmente para aquele tipo de struct
- O processo de transformação mostrado no exemplo não reproduz exatamente a implementação do Zig, sendo mais uma explicação conceitual
- A equipe principal do Zig está trabalhando em um depurador capaz de executar passo a passo código misturando comptime e runtime como no exemplo
Genéricos são tratados com comptime
- O Zig não tem um recurso separado de genéricos
- Para tornar um tipo genérico, escreve-se uma função que recebe um tipo e retorna um tipo
- Ex.:
pub fn GenericMyStruct(comptime T: type) type - Os campos
a,b,cdastructretornada usam todos o tipoT
- Ex.:
- Funções genéricas também podem ser escritas da mesma forma
- Ex.:
fn quadratic(comptime T: type, a: T, b: T, c: T, x: T) T - É possível explicitar o argumento de tipo como em
quadratic(f32, ...)equadratic(i64, ...)
- Ex.:
- Se o tipo do argumento não for importante para outras partes da assinatura da função, ele pode ser inferido com o tipo especial
anytype - Só isso não explica todo o comptime, mas já serve como ponto de partida para entender trabalhos genéricos comuns no Zig
Executando o mesmo código Zig em tempo de compilação
- O Zig usa a mesma linguagem em runtime, comptime e no sistema de build
- No exemplo de Fizz Buzz, a função
fizzBuzz(writer)é escrita primeiro como uma função comum de saída em runtime- Ela percorre de
1até100 - Para múltiplos de
3e5, imprimefizzbuzz; para múltiplos de3,fizz; para múltiplos de5,buzz; caso contrário, imprime o número
- Ela percorre de
- Ao executar a mesma função
fizzBuzzdentro de um blococomptime, toda a saída pode ser pré-calculadastd.io.countingWriter(std.io.null_writer)calcula primeiro a quantidade de bytes necessária- Com o tamanho calculado, cria-se o array
buffer: [cw.bytes_written]u8 - A saída é escrita de novo em
std.io.fixedBufferStream(&buffer)para produzir o valorfull_fizzbuzz
- Medindo apenas o trecho principal, a versão pré-calculada roda cerca de 9 vezes mais rápido
- Como o exemplo é pequeno, o tempo total de execução ainda depende mais de outros fatores
- Existem diferenças entre comptime e runtime quanto aos valores e recursos acessíveis
- Valores
comptime_int,comptime_floatetypesó podem ser acessados em comptime - Algumas funções aceitam apenas argumentos de comptime e, na prática, ficam restritas ao comptime
- Chamadas de sistema e recursos que dependem delas só podem ser acessados em runtime
- Valores
- Código que não usa esses recursos exclusivos funciona da mesma forma em comptime e runtime
Entendendo comptime como avaliação parcial
- O comptime pode ser visto como avaliação parcial (partial evaluation) durante o processo de compilação
- Avaliação parcial é a técnica de, com apenas alguns argumentos de uma função disponíveis, substituir e calcular primeiro as expressões que usam valores já conhecidos, criando depois uma nova função que recebe só os argumentos ainda desconhecidos
- O exemplo de soma do array mostra esse processo passo a passo
- O loop
foré desdobrado em instruções separadas para cada iteração array[0],array[1],array[2]são substituídos por1,2,3sumé atualizado de0 → 1 → 3 → 6- No fim, tudo é simplificado até uma forma como
std.debug.print(..., .{6})
- O loop
- O exemplo de soma dos campos da struct pode ser lido do mesmo jeito
inline foré executado em comptime, então é expandido em instruções separadas para os campos"a","b","c"@field(my_struct, "a")viramy_struct.a- A função final passa a conter diretamente
sum += my_struct.a,sum += my_struct.b,sum += my_struct.c
- Essa abordagem permite expressar a intenção diretamente no código e ainda reduz a necessidade de deixar comentários pedindo atualização manual da função de soma quando os campos mudarem
Avaliação em tempo de compilação e emissão de código de runtime
- O comptime do Zig também pode ser visto como a combinação de avaliação em tempo de compilação e emissão de código para runtime
- O código de entrada é executado pelo compilador
- Instruções que podem ser conhecidas em tempo de compilação são avaliadas imediatamente
- Instruções que exigem valores de runtime são adicionadas ao código de saída
- Essa visão é a mais próxima da forma real como o compilador do Zig trabalha
- Primeiro o Zig faz o parse da sintaxe
- Depois transforma o código em bytecode para uma máquina virtual
- A VM avalia as partes possíveis e emite novo bytecode para as partes que precisam ser tratadas em runtime
- Esse novo bytecode é convertido depois em código de máquina
- Condicionais como
ifque dependem de entrada de runtime fazem com que os dois caminhos sejam emitidos - Código morto não passa por análise semântica
- Mesmo que uma função esteja errada, talvez não apareça erro de compilação até ela ser realmente usada
- Essa característica torna a compilação mais eficiente e permite compilação condicional natural sem
#ifdef
- No Zig, nomes de tipos também são expressões avaliadas em comptime com valor
type- Mesmo nomes de tipo que parecem simples, como tipos de argumentos de função, são resultados de avaliação em comptime
- Essa característica torna possíveis os exemplos de genéricos mostrados antes
- Se necessário, tipos podem ser calculados com expressões mais complexas
- A análise estática do Zig é mais complexa do que em muitas outras linguagens de tipagem estática
- Para descobrir todos os tipos, o compilador precisa executar uma parte considerável de si mesmo
- Até as ferramentas acompanharem isso, recursos de editor como autocomplete nem sempre funcionam tão bem
Relação com geração de código em texto
- O comptime do Zig tem uma forma diferente da geração de código em texto, mas pode resolver problemas parecidos
- A função de geração
writeSumFnusawriter.printpara emitir o código-fonte da funçãosumFields- O código executado no gerador corresponde à parte de comptime no Zig
- O código emitido pelo gerador corresponde ao código de runtime
- A versão com comptime no Zig expressa a mesma lógica de forma mais direta
std.meta.fieldNames(MyStruct)obtém os nomes dos camposinline fore@fieldconstroem o acesso aos campos
- Geração de código que usa informação de tipo como entrada fica mais simples no Zig
- Na abordagem de geração textual, é preciso resolver separadamente de onde vêm o nome do tipo e os nomes dos campos
- No Zig, o próprio tipo e as informações dos campos podem ser manipulados diretamente em comptime
- Quando a entrada vem de algo como uma especificação externa, é possível incluir o conteúdo do arquivo com
@embedFilee depois fazer o parse normalmente - No exemplo da struct genérica, o comptime trata referências a nomes de tipos de forma mais direta do que a geração de código em texto
- Na geração textual, seria preciso concatenar strings como
MyStruct_{s}e manter tudo consistente - Com comptime, basta usar a função diretamente como em
GenericMyStruct(T)
- Na geração textual, seria preciso concatenar strings como
- Também há exceções
- É possível criar tipos cujos nomes de campos são decididos em comptime
- Nesse caso, é preciso chamar uma função embutida com uma especificação que contenha a lista de definições dos campos
- Esses tipos não podem definir declarações como métodos
- Isso não limita a expressividade do código em si, mas limita a forma da API que pode ser exposta a outros códigos
- A maior parte do que faz sentido fazer com macros textuais em C também pode ser feita com comptime, embora a forma do código possa ser bem diferente
- O Zig não oferece um recurso que substitua palavras-chave por outros nomes como fazem macros textuais
Leituras adicionais e exemplos da biblioteca padrão
- O Zig em si não é uma linguagem que depende apenas de comptime, e é possível aprender mais no site oficial
- A referência da linguagem trata dos recursos específicos de comptime
- A biblioteca padrão do Zig tem exemplos de uso de comptime
- A função de formatação usada por
std.debug.printé uma função genérica poderosa- Em muitas linguagens, a string de formatação é interpretada em runtime, e validadores separados podem ser adicionados para detectar erros mais cedo
- No Zig, a string de formatação é analisada em comptime para gerar código de saída eficiente, e toda a validação também é feita em tempo de compilação
ArrayListé um contêiner genérico relativamente simples, mas completo em recursos- A função
maindo Zig pode ter vários tipos de retorno, e isso não é mágica do compilador, mas sim tratado com código comum de comptime
- A função de formatação usada por
1 comentários
Opiniões no Hacker News
Em vez de elogiar sem crítica problemas já conhecidos em programação em tempo de compilação, seria bom que o texto se aprofundasse mais
Programação em estágios não é novidade, e famílias de linguagens Lisp fazem coisas semelhantes há décadas, mas nesse processo muitos trade-offs e problemas de projeto vieram à tona. Por exemplo, genéricos desse tipo quebram a parametricidade (parametricity), tornando difícil inferir algo apenas pela assinatura de tipo de uma função. Também não está claro como Zig lida com tipos genéricos recursivos, e a ordem e o momento em que verificação de tipos e computação em tempo de compilação interagem também são escolhas importantes. O texto diz que código em tempo de compilação pode gerar não só valores, mas também código, porém não trata de higiene (hygiene). Uma boa discussão relacionada é https://typesanitizer.com/blog/zig-generics.html
Dito isso, em outras situações, quando vejo
comptimeem código Zig, às vezes penso “ah…”. Como macros de Lisp, é fácil usá-lo para contornar problemas que, na verdade, não existiriam ou desapareceriam com uma estrutura de código melhor. O exemplo do artigo original, que percorre campos de uma struct para somar valores, é típico de como as pessoas usamcomptimena prática, mas em muitos casos é melhor usar uma estrutura de dados que seja realmente iterável, comostd.enums.EnumArrayEm teoria, a preocupação é clara, mas o quanto isso importa em uma linguagem prática é outra questão. Templates de C++ também quebram a parametricidade por meio de especialização de templates, mas na prática isso tende a não ser um grande problema e, pelo contrário, permite otimizações. Por exemplo, uma implementação como
std::vector<bool>, que armazena cada elemento efetivamente em 1 bit, só é possível com esse tipo de flexibilidadeVejo o
comptimeao estilo Zig como um trade-off direto entre inferibilidade e expressividade em comparação com assinaturas de função definidas de forma mais estática. Ele afeta apenas código que faz esse tipo de inferência por tipos, e é uma parte que pode ser escolhida conforme a necessidade. Mesmo quando a assinatura de tipo não foi suficiente, ler algumas dezenas de linhas de fonte geralmente resolveu como usar, e nunca fiquei confuso por muito tempo. No exemplo de tipo genérico recursivo, usar o nome que está sendo declarado gera “dependency loop detected”, mas há formas de contornar isso, e o exemplo genérico do texto também referencia a si mesmo. A verificação de tipos acontece durantecomptime; por exemplo, o primeiro@compileLog("Hi")é executado, mas se depois houver um erro de tipo emconst a: u32 = "42";, o segundo log não é alcançado. Podem surgir problemas sutis na verificação de tipos entrecomptimee runtime, mas eles aparecem só em códigos bastante difíceis, são fáceis de resolver, e a equipe principal também sabe disso, então espero que sejam tratados antes da 1.0. Seria preciso explicar melhor o que exatamente se quer dizer por higieneSe a função tem um propósito lógico claro e bons nomes para ela e seus parâmetros, dá para inferir bastante apenas pela declaração. Partes que o programador pode especificar, como rótulos de parâmetros e nomes de tipos, também podem ser vistas como parte do nome. Concordo com a conclusão do título do texto linkado, mas ele parece mais um texto de equipe de debate tentando marcar pontos do que uma argumentação significativa. Um enquadramento melhor é flexibilidade versus complexidade. Um sistema genérico fixo, se bem projetado, é mais simples do que um programável, mas menos flexível. A vantagem de Zig é que a linguagem de metaprogramação é quase igual à linguagem comum, e a própria linguagem comum também é simples, reduzindo o custo de complexidade adicional. Ainda assim, como código em tempo de compilação e código em runtime se misturam e há poucos indícios, surge a complexidade de dificultar para o programador distinguir o que é executado quando. Fico pensando se um language server ou plugin de editor poderia oferecer um “shader de comptime” que desse uma cor de fundo diferente ao código
comptimePela experiência com Rust, muitas coisas que as pessoas tentam fazer com genéricos constantes parecem que seriam mais fáceis com um recurso como
comptime. Permitir aritmética sobre genéricos constantes preservando a parametricidade é difícil de implementar e, se o que se quer de fato é algo como “uma trait de função hash cujo tamanho de saída é N”, pode ser aceitável abrir mão da parametricidade para esse objetivo e transformar N em uma trait em uma etapa anterior de geração de código. Mas macros de Rust são flexíveis e trabalhosas demais para serem usadas desse jeito. Ainda assim, no momento em que se substitui polimorfismo paramétrico por um recurso ingênuo de geração de código, começa o caminho difícilD já tinha isso há 17 anos. Recursos de D vêm sendo constantemente levados para outras linguagens
Zig marca, com a palavra-chave
comptime, os blocos que serão executados durante a compilação, mas em D quem decide a execução não é uma palavra-chave, e sim se é uma expressão constante. Por exemplo,int s = sum(3, 4);é executado em tempo de execução, eenum e = sum(3, 4);é executado em tempo de compilação. Se chamadas a funções do sistema como globais não constantes, entrada/saída emalloc()forem evitadas, um bom número de funções pode ser executado em tempo de compilação sem alterações. Graças ao gerenciamento automático de memória de D, alocação de memória também é possívelAntes era preciso escrever um programa separado para criar tabelas estáticas, mas com execução de funções em tempo de compilação isso deixou de ser necessário. Os valores de inicialização do array
tytabvêm de uma lambda que calcula e retorna um array. O exemplo completo está em https://github.com/dlang/dmd/blob/master/compiler/src/dmd/ba.... Outro uso comum de CTFE é criar DSLsSe a máquina em que o compilador roda e a máquina em que o programa será executado forem diferentes, como o compilador D garante a correção? Por exemplo, fico pensando como o compilador sabe que
int s = sum(100000, 1000000)terá o mesmo valor em todas as máquinas x86. Pode haver diferenças sutis entre gerações de CPU, então como se garante que um cálculo feito de fato no host terá o mesmo resultado na máquina-alvo? Ou presume-se que, se a arquitetura for a mesma, host e alvo são suficientemente parecidos?Dá para escrever
int sum(int a, int b) { return a + b; }e usar algo como_Static_assert(sum(3, 4) == 7, "look ma, check at compile time!");. Não sei por que o padrão C não adiciona isso, e funciona bemcomptimesquare(2)é uma chamada em tempo de execução, ecomptime square(3)é uma chamada em tempo de compilação. Uma chamadacomptimegera erro de compilação se algo não for compatível com tempo de compilação, e vejo isso como um recurso importante. Isso porque serve como sinal de alerta quando um código que se esperava executar em tempo de compilação acaba, por acaso, sendo empurrado para avaliação em tempo de execução por causa de mudanças nos argumentos de entradaZig parece interessante, mas eu gostaria que tivesse sobrecarga de operadores
A maioria dos argumentos contra sobrecarga de operadores não me convence. A alegação de que não se sabe o que realmente está acontecendo por baixo não é suficiente, já que também é possível criar uma função chamada
addque faça multiplicação.iostreamsouboost::spiritem C++ aparecem com frequência como exemplos de abuso, mas em outras linguagens com sobrecarga de operadores isso não parece acontecer muito, e parece mais um problema específico de C++Em OCaml, é possível redefinir operadores, mas apenas dentro do contexto de outro módulo. Por exemplo, se o módulo
Vec3redefiniu+, dá para usarVec3.(a + b + c + d)oulet open Vec3 in a + b + c + d. Ao ler o código, não surge a dúvida “de onde veio este+?”, e isso é muito melhor do quea.add(b).add(c).add(d). Mas Zig já está se consolidando, então parece difícil que entre uma mudança grande para resolver esse tipo de problema__de Python e PHPA abordagem de Zig é revigorante, e a capacidade de acompanhar o código é mais importante do que os poucos segundos gastos digitando alguns caracteres a mais
A solução ideal seria a linguagem oferecer operadores definidos pelo usuário que deixem claro que se trata de uma sobrecarga. Por exemplo, com uma forma de prefixo/sufixo como
let c = a |+| b, quem lê o código sabe que a operação|+|na prática é uma chamada de função. Ainda poderia ser abusado, mas uma das principais preocupações seria reduzidaSe a linguagem oferecesse diretamente uma sintaxe de matemática vetorial, como linguagens de shading, acho que seria bom. Zig tem ao menos o tipo
@Vector(), um pouco parecido com a Vector Extension do Clang, mas infelizmente não é a Extended Vector Extension. Veja https://ziglang.org/documentation/master/#Vector e https://clang.llvm.org/docs/LanguageExtensions.html#vectors-...Não sei bem se já existe alguma proposta assim
fieldNamesparece muito parecido com ofieldPairsde Nim, e é uma estrutura extremamente convenienteTorna muito fácil criar serialização eficiente, e recentemente implementei, em cerca de 20 linhas, uma verificação em tempo de compilação de segurança de threads para tipos usando
fieldPairs. Acho que recursos assim deveriam se tornar padrão em linguagens de programação. Uma das coisas de que mais sinto falta em Rust está aqui; da última vez que tentei, estava limitado a macros sem tipo. É restritivo demais. Só resta torcer para que uma struct de um crate tenhaserdeimplementado, e também não dá para criar programaticamente uma struct com os mesmos camposÉ uma pena. Coisas como
serdeseriam muito mais fáceis de implementar com reflexão em tempo de compilaçãoO único compromisso é que essas bibliotecas teriam de ser incluídas em forma de código-fonte
Depois de escrever uma biblioteca de parser de C relativamente completa, não sei se concordo com a ideia de que a própria linguagem precisa de metaprogramação
Se você quer gerar structs, serialização, propriedades, instrumentação etc., basta escrever um programa C comum que processe arquivos-fonte e produza arquivos-fonte, e executá-lo antes no script de build. Fico curioso para saber como esses metaprogramas são depurados e testados. O meu é um programa C comum, então uso o mesmo depurador e as mesmas ferramentas que uso para o restante do código
Como o próprio compilador é escrito na mesma linguagem, dá para inserir um plugin que tenha acesso somente leitura à AST e emita código-fonte C#. Para depuração, é preciso um pouco mais de trabalho para criar um bom framework de testes, mas depois disso dá para executar o compilador com o plugin dentro do framework padrão de testes unitários e inspecionar tudo com um depurador interativo
“Execução arbitrária em tempo de compilação em C:
cl /nologo /Zi metaprogram.c && metaprogram.exe,cl /nologo /Zi program.c. O código em tempo de compilação roda em velocidade nativa, é depurável, totalmente procedural e arbitrário. O compilador não precisa executar o código para você.” https://x.com/ryanjfleury/status/1875824288487571873Para fazer isso em C, seria preciso escrever um parser completo de C para o programa C que processa os arquivos-fonte
Escrevia código Python que gerava código Python e mandava compilar de novo. Era uma bagunça frágil e horrível, e ter de fazer esse tipo de coisa foi uma das grandes razões para eu sair de Python. É parecido com perguntar por que não passar todos os argumentos de função como strings. Há quem use código baseado em strings, mas isso deveria ser raro, e a linguagem deveria oferecer meios de evitar isso
Um padrão interessante é poder gerar structs em tempo de compilação
Fiz um experimento em que eu criava um arquivo JSON no PyTorch, lia com
@embedFileno Zig e gerava structs com um métodorunespecífico para implementar uma rede neural. Em teoria, isso permite que o compilador otimize diretamente a rede neural. Ainda não consegui demonstrar uma grande vantagem, mas também há o fato de a rede inteira ficar na stack, sem alocação dinâmica. Não tenho certeza se isso é bomcomptimeNão tenho certeza de que
comptimeem Zig seria especialmente rápido, e também não quero rodar toda vez um parser de JSON que gera structsÉ interessante, mas, em casos assim, acabo concordando com comentários do tipo “D fez isso primeiro”
Se o
comptimedo Zig impressionou você, vale muito a pena olhar também Nim, que tem avaliação de código em tempo de compilação e um sistema completo de macros sobre a ASTEla tem os mesmos problemas de ecossistema de outras linguagens de nicho, além de fatores como um mantenedor que divide opiniões, contribuidores centrais que parecem não permanecer por muito tempo e financiamento principal vindo de uma empresa de criptomoedas. Dez anos atrás essas coisas talvez não me incomodassem, mas hoje é diferente
Nim também parece estar tentando se afastar de exceções, mas exceções contaminam funções e obrigam você a se preocupar com elas mesmo quando não usa funções que lançam exceções[1]. A vida é curta demais para lidar com fluxo de controle invisível. [1]: https://github.com/status-im/nim-stew
Zig, no geral, é uma linguagem bem boa e faz o que precisa fazer
É muito importante não se desviar do propósito, e por isso não gosto muito quando algumas linguagens são usadas em qualquer lugar só porque simplesmente são usadas
Eu gostaria que existisse algo que combinasse as capacidades de metaprogramação do Zig com o enorme ecossistema, a comunidade e a segurança do Rust
Olhando apenas para o design da linguagem, gosto muito mais de Zig do que de Rust, mas, como programador amador pouco habilidoso, pelo menos por enquanto não tenho confiança para escrever em Zig algo realmente útil ou confiável
Porque o compilador não me ajudava encontrando esse tipo de problema em tempo de compilação. Sei que Zig não promete isso, mas para mim é uma desvantagem decisiva, então acho que Zig não é a linguagem certa para mim. Por outro lado, gosto mais do conceito de
comptimedo que das macros de Rust