La opinión de que Zig comptime es extraordinario
(scottredig.com)- La metaprogramación en tiempo de compilación (comptime) de Zig puede leerse como código de runtime, pero aun así generar código ejecutable tan eficiente como una función en C escrita a mano para tareas repetitivas como sumar campos de una estructura
- Un caso de uso clave de Zig comptime es que maneja la programación genérica usando
comptime T: typeyanytypeen lugar de una sintaxis separada para genéricos - Runtime, comptime y el sistema de build comparten el mismo lenguaje Zig, por lo que incluso una función como Fizz Buzz puede ejecutarse durante la compilación y convertirse en un arreglo precalculado
- El compilador evalúa primero todo lo posible y deja solo las partes que requieren valores de runtime como bytecode de salida que más tarde se convertirá en código máquina
- Zig comptime puede resolver problemas similares a la generación de código por texto, pero no reemplaza palabras clave arbitrariamente como los macros de C, lo que reduce la carga de lectura y depuración del código
Una forma de leer Zig comptime
- El comptime de Zig es una función de metaprogramación, pero al leer el comportamiento final no hace falta ser demasiado consciente de la diferencia entre tiempo de compilación y runtime
- El tercer punto de
zig zen, “Favor reading code over writing code”, se vuelve un criterio importante- La metaprogramación basada en macros o generación de código puede crear dos capas: el código original y el código expandido
- Ese tipo de capa indirecta dificulta leer el código, depurarlo y cambiar su comportamiento
- Se compara lado a lado el código de runtime que suma un arreglo
[3]i64 = .{1,2,3}y el código comptime que suma los camposa,b,cde la estructuraMyStruct- Se recorren los nombres de campo con
inline for (comptime std.meta.fieldNames(MyStruct)) - Se accede a los campos usando nombres conocidos en tiempo de compilación con
@field(my_struct, field_name)
- Se recorren los nombres de campo con
- El ejemplo de sumar campos de una estructura puede parecer reflexión en runtime, pero en el ejecutable queda código eficiente para ese tipo de estructura, como si la función de suma se hubiera escrito a mano
- El proceso de transformación del ejemplo no es una reproducción exacta de la implementación de Zig, sino una explicación conceptual
- El equipo central de Zig está trabajando en un depurador que permita ejecutar paso a paso código mezclado entre comptime y runtime como en el ejemplo
Los genéricos se resuelven con comptime
- Zig no tiene una función separada de genéricos
- Para hacer genérico un tipo, se escribe una función que recibe un tipo y devuelve un tipo
- Ejemplo:
pub fn GenericMyStruct(comptime T: type) type - Los campos
a,b,cdelstructdevuelto usan todos el tipoT
- Ejemplo:
- Las funciones genéricas también pueden escribirse del mismo modo
- Ejemplo:
fn quadratic(comptime T: type, a: T, b: T, c: T, x: T) T - Se pueden indicar argumentos de tipo explícitos como
quadratic(f32, ...)yquadratic(i64, ...)
- Ejemplo:
- Si el tipo de un argumento no es importante para otras partes de la firma de la función, puede inferirse con el tipo especial
anytype - Esto por sí solo no explica todo comptime, pero sí es un buen punto de partida para entender tareas genéricas comunes en Zig
Ejecutar el mismo código Zig en tiempo de compilación
- Zig usa el mismo lenguaje en runtime, comptime y el sistema de build
- En el ejemplo de Fizz Buzz, primero se escribe la función
fizzBuzz(writer)como una función normal de salida en runtime- Recorre del
1al100 - Imprime
fizzbuzzen múltiplos de3y5,fizzen múltiplos de3,buzzen múltiplos de5, y el número en los demás casos
- Recorre del
- Al ejecutar la misma función
fizzBuzzdentro de un bloquecomptime, se puede precalcular toda la salida- Primero se calcula cuántos bytes se necesitan con
std.io.countingWriter(std.io.null_writer) - Con esa longitud calculada se crea el arreglo
buffer: [cw.bytes_written]u8 - Luego se vuelve a escribir sobre
std.io.fixedBufferStream(&buffer)para producir el valorfull_fizzbuzz
- Primero se calcula cuántos bytes se necesitan con
- Si se mide solo la parte clave, la versión precalculada corre alrededor de 9 veces más rápido
- Como el ejemplo es pequeño, el tiempo total de ejecución depende más de otros factores
- Entre comptime y runtime hay diferencias en los valores y funciones accesibles
- Los valores
comptime_int,comptime_floatytypesolo están disponibles en comptime - Algunas funciones aceptan solo argumentos comptime, así que en la práctica son exclusivas de comptime
- Las llamadas al sistema o funciones que dependen de ellas solo están disponibles en runtime
- Los valores
- El código que no usa estas capacidades exclusivas se comporta igual en comptime y en runtime
Entender comptime como evaluación parcial
- Comptime puede verse como evaluación parcial (partial evaluation) que ocurre durante la compilación
- La evaluación parcial consiste en tomar una función con solo algunos argumentos dados, sustituir y calcular primero las expresiones que usan valores ya conocidos, y producir una nueva función que recibe únicamente los argumentos que siguen siendo desconocidos
- El ejemplo de suma de arreglo muestra el proceso de evaluación paso a paso
- El bucle
forse descompone en sentencias individuales por cada iteración array[0],array[1],array[2]se sustituyen respectivamente por1,2,3sumse actualiza como0 → 1 → 3 → 6- Al final, todo se simplifica hasta una forma como
std.debug.print(..., .{6})
- El bucle
- El ejemplo de suma de campos de estructura puede leerse del mismo modo
- Como
inline forse ejecuta en comptime, se expande en sentencias individuales para los campos"a","b","c" @field(my_struct, "a")cambia amy_struct.a- La función final queda con una forma que contiene directamente
sum += my_struct.a,sum += my_struct.b,sum += my_struct.c
- Como
- Este enfoque permite expresar la intención directamente en el código y reduce la necesidad de dejar comentarios que digan que la función de suma debe actualizarse manualmente cuando cambien los campos
Evaluación en tiempo de compilación y emisión de código de runtime
- Zig comptime también puede verse como una combinación de evaluación en tiempo de compilación y emisión de código de runtime
- El código de entrada es ejecutado por el compilador
- Las sentencias que pueden conocerse en tiempo de compilación se evalúan de inmediato
- Las sentencias que necesitan valores de runtime se agregan al código de salida
- Esta perspectiva es la más cercana a cómo realmente procesa las cosas el compilador de Zig
- Zig primero analiza la sintaxis
- Convierte el código en bytecode para una máquina virtual
- La VM evalúa las partes posibles y emite nuevo bytecode para las partes que requieren procesamiento en runtime
- Ese nuevo bytecode luego se convierte en código máquina
- Las condicionales como
ifcon entradas de runtime emiten ambos caminos - El código muerto no pasa por análisis semántico
- Incluso si se escribe una función incorrecta, puede que no aparezca un error de compilación hasta que realmente se use
- Esta característica hace la compilación más eficiente y permite compilación condicional natural sin
#ifdef
- Los nombres de tipo en Zig también son expresiones que evalúan valores
typeen comptime- Incluso un nombre de tipo que parece simple, como el tipo de un argumento de función, es el resultado de una evaluación en comptime
- Esta propiedad hace posible el ejemplo de genéricos anterior
- Si hace falta, el tipo puede calcularse con expresiones más complejas
- El análisis estático de Zig es más complejo que el de muchos otros lenguajes estáticos tipados
- Para averiguar todos los tipos, el compilador debe ejecutar una parte considerable de sí mismo
- Hasta que las herramientas se pongan al día, funciones del editor como el autocompletado no siempre funcionarán bien
Relación con la generación de código por texto
- Zig comptime no tiene la misma forma que la generación de código por texto, pero puede resolver problemas parecidos
- La función generadora
writeSumFnproduce el código fuente de la funciónsumFieldsconwriter.print- El código que se ejecuta dentro del generador corresponde a la parte comptime de Zig
- El código que produce el generador corresponde al código de runtime
- La versión con comptime de Zig expresa la misma lógica de forma más directa
- Obtiene los nombres de campo con
std.meta.fieldNames(MyStruct) - Construye el acceso a campos con
inline fory@field
- Obtiene los nombres de campo con
- La generación de código que usa información de tipos como entrada se vuelve más simple en Zig
- En el enfoque de generación de texto, hay que resolver por separado de dónde salen los nombres de tipos y de campos
- En Zig, el propio tipo y la información de sus campos pueden manejarse directamente en comptime
- Cuando se usan entradas como una especificación externa, se puede incluir el contenido del archivo con
@embedFiley luego parsearlo como de costumbre - En el ejemplo de estructura genérica, comptime maneja las referencias a nombres de tipo de manera más directa que la generación de código por texto
- La generación de texto tiene que concatenar cadenas como
MyStruct_{s}y mantener la consistencia - Comptime usa directamente una función como
GenericMyStruct(T)
- La generación de texto tiene que concatenar cadenas como
- También hay excepciones
- Es posible crear un tipo cuyos nombres de campo se decidan en comptime
- En ese caso hay que llamar una función builtin con una especificación que contenga la lista de definiciones de campos
- En esos tipos no se pueden definir declaraciones como métodos
- No limita la expresividad del código en sí, pero sí restringe la forma de la API que puede exponerse a otro código
- La mayoría de las tareas razonables que pueden hacerse con macros de texto como los de C también pueden hacerse con comptime, aunque la forma del código puede ser muy distinta
- Zig no ofrece una función para reemplazar palabras clave por otros nombres como hacen los macros de texto
Lecturas adicionales y ejemplos de la biblioteca estándar
- Zig en sí no es un lenguaje que dependa solo de comptime, y se puede aprender más en el sitio web oficial
- La referencia del lenguaje cubre las funciones concretas de comptime
- La biblioteca estándar de Zig tiene ejemplos de uso de comptime
- La función de formato que usa
std.debug.printes una potente función genérica- En muchos lenguajes, la cadena de formato se analiza en runtime y se puede agregar un validador aparte para detectar errores antes
- Zig analiza la cadena de formato en comptime para generar código de salida eficiente y hacer toda la validación en tiempo de compilación
ArrayListes un contenedor genérico relativamente simple pero con funcionalidad completa- La función
mainde Zig puede tener varios tipos de retorno, y esto no se resuelve con magia del compilador sino con código comptime común
- La función de formato que usa
1 comentarios
Opiniones de Hacker News
Me gustaría que se abordaran con más profundidad los problemas ya conocidos en la programación en tiempo de compilación, en vez de elogiarla sin espíritu crítico.
La programación por etapas no es algo nuevo, y las familias de Lisp han hecho cosas similares durante décadas, pero en ese proceso salieron a la luz muchos compromisos de diseño y problemas. Por ejemplo, los genéricos de este estilo rompen la parametricidad (parametricity), lo que vuelve difícil razonar solo a partir de la firma de tipo de una función. Tampoco queda claro cómo maneja Zig los tipos genéricos recursivos, y también es una decisión importante cuándo y en qué orden interactúan la verificación de tipos y el cálculo en tiempo de compilación. El artículo dice que el código en tiempo de compilación puede generar no solo valores, sino también código, pero no aborda la higiene (hygiene). Una buena discusión relacionada es https://typesanitizer.com/blog/zig-generics.html
Dicho eso, en otras situaciones, cuando veo
comptimeen código Zig a veces pienso: “ah…”. Como con las macros de Lisp, es fácil usarlo para esquivar problemas que en realidad no existirían, o desaparecerían, si el código estuviera mejor estructurado. El ejemplo del artículo original, que recorre los campos de una estructura para sumar valores, es típico de cómo la gente usacomptime, pero en muchos casos es mejor usar una estructura de datos que realmente se pueda recorrer, comostd.enums.EnumArrayEn teoría, la preocupación es clara, pero otra cosa es qué tan importante resulta en un lenguaje práctico. Las plantillas de C++ también rompen la parametricidad mediante la especialización de plantillas, pero en la práctica no suele ser un gran problema y más bien permite optimizaciones. Por ejemplo, una implementación como
std::vector<bool>, que almacena cada elemento realmente en 1 bit, solo es posible con ese tipo de flexibilidadVeo el
comptimeal estilo Zig como un compromiso directo entre capacidad de inferencia y expresividad frente a firmas de función definidas de forma más estática. Solo afecta al código que hace ese tipo de inferencias a partir de los tipos, y es algo que se puede elegir según la necesidad. Incluso cuando la firma de tipo no era suficiente, por lo general bastaba leer unas decenas de líneas de código fuente para entender cómo usarlo, y nunca me quedé confundido por mucho tiempo. En el ejemplo de tipos genéricos recursivos, usar el nombre que se está declarando produce “dependency loop detected”, pero hay formas de evitarlo, y el ejemplo genérico del artículo también se referencia a sí mismo. La verificación de tipos ocurre durantecomptime; por ejemplo, el primer@compileLog("Hi")se ejecuta, pero si despuésconst a: u32 = "42";produce un error de tipo, no se llega al segundo log. Pueden aparecer problemas sutiles en la verificación de tipos entrecomptimey runtime, pero se ven solo en código bastante rebuscado, se resuelven fácilmente, y el equipo principal los conoce, así que espero que se aborden antes de la 1.0. Necesitaría una explicación más precisa de qué significa exactamente higieneSi una función tiene un propósito lógico claro y buenos nombres para la función y sus parámetros, se puede razonar a partir de la declaración. Las partes que el programador puede especificar, como las etiquetas de los parámetros y los nombres de tipos, también pueden verse como parte del nombre. Estoy de acuerdo con la conclusión del título del artículo enlazado, pero el texto se parece más a un ensayo de equipo de debate que busca ganar puntos que a un argumento significativo. Un mejor encuadre sería flexibilidad frente a complejidad. Un sistema fijo de genéricos, si está bien diseñado, es más simple que uno programable, pero menos flexible. La ventaja de Zig es que el lenguaje de metaprogramación es casi el mismo que el lenguaje normal, y ese lenguaje normal también es simple, lo que reduce el costo de complejidad adicional. Sin embargo, sí aparece una complejidad en la que se mezclan el código en tiempo de compilación y el código en runtime, con pocas pistas, lo que puede dificultar que el programador distinga qué se ejecuta y cuándo. Me pregunto si un servidor de lenguaje o un plugin de editor podría ofrecer un “sombreador de comptime” que dé otro color de fondo al código
comptimePor mi experiencia con Rust, muchas de las cosas que la gente intenta hacer con genéricos constantes serían más fáciles con una función como
comptime. Permitir aritmética sobre genéricos constantes manteniendo la parametricidad es difícil de implementar, y si lo que realmente se quiere es algo como “un trait de función hash cuyo tamaño de salida sea N”, puede estar bien renunciar a la parametricidad para ese propósito y convertir el trait en N en una etapa previa de generación de código. Pero las macros de Rust son demasiado flexibles y molestas para usarse de esa manera. Aun así, en cuanto reemplazas el polimorfismo paramétrico por una función ingenua de generación de código, te estás metiendo en un camino difícilD lo tenía desde hace 17 años. Las funciones de D se han ido trasladando de forma constante a otros lenguajes
Zig marca con la palabra clave
comptimelos bloques que se ejecutarán durante la compilación, pero en D lo que decide la ejecución no es una palabra clave, sino si es una expresión constante. Por ejemplo,int s = sum(3, 4);se ejecuta en tiempo de ejecución, yenum e = sum(3, 4);se ejecuta en tiempo de compilación. Si se evitan variables globales no constantes, entrada/salida y llamadas a funciones del sistema comomalloc(), una gran cantidad de funciones puede ejecutarse en tiempo de compilación sin cambios. Gracias a la gestión automática de memoria de D, también es posible asignar memoriaAntes había que escribir un programa aparte para crear tablas estáticas, pero con la ejecución de funciones en tiempo de compilación eso ya no es necesario. Los valores de inicialización del arreglo
tytabsalen de una lambda que calcula y devuelve el arreglo. El ejemplo completo está en https://github.com/dlang/dmd/blob/master/compiler/src/dmd/ba.... Otro uso común de CTFE es crear DSLSi la máquina donde corre el compilador y la máquina donde se ejecutará el programa son distintas, ¿cómo garantiza el compilador de D que el resultado sea correcto? Por ejemplo, me pregunto cómo sabe el compilador que
int s = sum(100000, 1000000)tendrá el mismo valor en todas las máquinas x86. Podría haber diferencias sutiles entre generaciones de CPU; entonces, ¿cómo se garantiza que un cálculo hecho realmente en el host dé el mismo resultado en la máquina objetivo? ¿O se asume que, si la arquitectura es la misma, host y objetivo son lo suficientemente parecidos?Con
int sum(int a, int b) { return a + b; }, se puede usar algo como_Static_assert(sum(3, 4) == 7, "look ma, check at compile time!");. No sé por qué el estándar de C no agrega esto, y funciona biencomptimesquare(2)es una llamada en runtime, ycomptime square(3)es una llamada en tiempo de compilación. Una llamadacomptimeproduce un error de compilación si algo no es compatible con tiempo de compilación, y me parece una función importante. Sirve como señal de alerta cuando código que esperabas que se ejecutara en tiempo de compilación pasa accidentalmente a evaluarse en runtime por un cambio en los argumentos de entradaZig se ve interesante, pero me gustaría que tuviera sobrecarga de operadores
La mayoría de los argumentos contra la sobrecarga de operadores no me convencen. El argumento de que no sabes qué está pasando realmente por dentro no basta, porque también podrías hacer que una función llamada
addhaga una multiplicación.iostreamsde C++ oboost::spiritsuelen citarse como casos de abuso, pero en otros lenguajes con sobrecarga de operadores no se ve tanto eso, y parece más bien un problema propio de C++En OCaml se pueden redefinir operadores, pero solo dentro del contexto de otro módulo. Por ejemplo, si en el módulo
Vec3se redefine+, se puede escribirVec3.(a + b + c + d)olet open Vec3 in a + b + c + d. Al leer el código no queda la duda de “¿de dónde salió este+?”, y es mucho mejor quea.add(b).add(c).add(d). De todos modos, Zig ya se está consolidando, así que parece difícil que entren cambios grandes para resolver un problema así__de Python y PHPEl enfoque de Zig es refrescante, y la capacidad de seguir el código es más importante que los pocos segundos que toma escribir algunos caracteres extra
La solución ideal sería que el lenguaje ofreciera operadores definidos por el usuario que dejaran claro que son sobrecargas. Por ejemplo, con una forma de prefijo/sufijo como
let c = a |+| b, quien lee el código puede saber que la operación|+|en realidad es una llamada a función. Aún podría abusarse, pero se reduciría una de las principales preocupacionesEstaría bien si el lenguaje ofreciera directamente sintaxis para matemática vectorial, como los lenguajes de shading. Zig al menos tiene el tipo
@Vector(), que se parece un poco a la Vector Extension de Clang, pero lamentablemente no es la Extended Vector Extension. Ver https://ziglang.org/documentation/master/#Vector y https://clang.llvm.org/docs/LanguageExtensions.html#vectors-...No sé bien si ya existe una propuesta así
fieldNamesse ve muy parecido afieldPairsde Nim, y es una estructura tremendamente prácticaHace muy fácil crear serialización eficiente, y hace poco implementé con
fieldPairsuna verificación de seguridad de hilos en tiempo de compilación para tipos en unas 20 líneas. Creo que este tipo de función debería ser una capacidad estándar de los lenguajes de programación. Uno de los puntos que más extraño en Rust está justo aquí, y la última vez que lo intenté estaba limitado a macros sin tipos. Es demasiado restrictivo. No queda más que esperar que las estructuras de un crate tengan implementadoserde, y tampoco se puede crear programáticamente una estructura con los mismos camposEs una pena. Cosas como
serdeserían mucho más fáciles de implementar si existiera reflexión en tiempo de compilaciónEl único compromiso es que esas bibliotecas tendrían que incluirse en forma de código fuente
Después de haber escrito una biblioteca de parser de C más o menos completa, no estoy tan seguro de que el lenguaje en sí realmente necesite metaprogramación
Si quieres generar estructuras, serialización, propiedades, instrumentación, etc., basta con escribir un programa normal en C que procese archivos fuente y genere archivos fuente, y ejecutarlo primero desde el script de build. Me pregunto cómo se depuran y prueban esos metaprogramas. El mío es un programa normal en C, así que usa el mismo depurador y las mismas herramientas que el resto del código
Como el compilador mismo está escrito en el mismo lenguaje, puedes agregar un plugin que acceda al AST en modo solo lectura y emita código fuente C#. Para depurarlo hay que trabajar un poco más para crear un buen framework de pruebas, pero después puedes ejecutar el compilador con el plugin dentro de un framework estándar de pruebas unitarias y verlo con un depurador interactivo
“Ejecución arbitraria en tiempo de compilación en C:
cl /nologo /Zi metaprogram.c && metaprogram.exe,cl /nologo /Zi program.c. El código en tiempo de compilación corre a velocidad nativa, es depurable, completamente procedural y arbitrario. No hace falta que el compilador ejecute el código.” https://x.com/ryanjfleury/status/1875824288487571873Para hacer eso en C, tendrías que escribir un parser completo de C para el programa en C que procesa los archivos fuente
Escribía código Python que generaba código Python, y luego hacía que se compilara de nuevo. Era un desastre frágil y espantoso, y haber tenido que hacer ese tipo de cosas fue una de las grandes razones por las que dejé Python. Es parecido a preguntar si no bastaría con pasar todos los argumentos de una función como strings. Hay gente que escribe código basado en strings, pero debería ser raro, y el lenguaje debería ofrecer una forma de evitarlo
Un patrón interesante es que en tiempo de compilación se pueden generar estructuras
Hice un experimento en el que creaba un archivo JSON en PyTorch, lo leía con
@embedFilede Zig y luego generaba una estructura con un métodorunespecífico para implementar una red neuronal. En teoría, esto permite que el compilador optimice directamente la red neuronal. Todavía no he demostrado una gran ventaja, pero también implica que toda la red está en el stack, sin asignación dinámica. No tengo claro si eso es buenocomptimeNo estoy seguro de que
comptimede Zig sea particularmente rápido, y tampoco quiero ejecutar cada vez un parser de JSON que genera estructurasEs interesante, pero en casos como este tiendo a estar de acuerdo con los comentarios del tipo “D lo hizo primero”
Si te sorprendió el
comptimede Zig, definitivamente vale la pena mirar Nim, que tiene evaluación de código en tiempo de compilación y un sistema completo de macros sobre el ASTTiene los mismos problemas de ecosistema que otros lenguajes de nicho, más factores como un mantenedor que genera opiniones divididas, colaboradores clave que parecen no quedarse mucho tiempo y financiamiento principal de una empresa de criptomonedas. Hace 10 años esas cosas quizá no me habrían molestado, pero ahora es distinto
Nim también parece estar intentando alejarse de las excepciones, pero las excepciones contaminan las funciones y te obligan a preocuparte por ellas aunque no uses funciones que lancen excepciones[1]. La vida es demasiado corta para lidiar con flujo de control invisible. [1]: https://github.com/status-im/nim-stew
Zig en general es un lenguaje bastante bueno y hace lo que necesita hacer
Es muy importante no salirse de su propósito, y por eso no me gusta mucho que algunos lenguajes se usen en cualquier cosa solo porque se usan
Ojalá hubiera algo que combinara las capacidades de metaprogramación de Zig con el enorme ecosistema, la comunidad y la seguridad de Rust
Si miro solo el diseño del lenguaje, Zig me gusta mucho más que Rust, pero como programador aficionado torpe no me siento capaz, al menos por ahora, de escribir en Zig algo realmente útil o confiable
Porque el compilador no me ayudaba a detectar esos problemas en tiempo de compilación. Sé que Zig no promete eso, pero para mí es una desventaja decisiva, así que creo que Zig no es el lenguaje adecuado para mí. En cambio, el concepto de
comptimeme gusta más que las macros de Rust