- Datatype99 es una biblioteca basada en macros que ofrece tipos de datos algebraicos, pattern matching exhaustivo e introspección en tiempo de compilación en C99 puro
- Su objetivo es detectar en tiempo de compilación variants mal tipadas, pattern matching incompleto y acceso incorrecto a campos, y solo requiere un compilador C99 conforme al estándar
- Internamente,
datatype se expande a una tagged union y constructores de valor, match se expande a una sentencia switch, y la disposición de datos generada sigue una semántica formal de generación de código
- La instalación consiste en agregar
datatype99.h y la dependencia Metalang99 a la ruta de includes; en GCC/Clang se recomienda usar opciones de compilación para reducir la salida de errores por expansión de macros
- Puede integrarse en una base de código C con
#include <datatype99.h>, se sabe que funciona en GCC, Clang, MSVC y TCC, y también soporta C++11 o superior
Qué ofrece Datatype99
- Datatype99 proporciona tipos de datos algebraicos seguros e intuitivos en C99
- Su alcance funcional incluye pattern matching exhaustivo e introspección en tiempo de compilación
- Funciona sin herramientas externas de generación de código, y su implementación está basada en C99 puro
- Características principales
- Seguridad de tipos: detecta en tiempo de compilación variants mal tipadas, pattern matching no exhaustivo y acceso incorrecto a campos
- Portabilidad: requiere un compilador C99 conforme al estándar y no necesita biblioteca estándar, funciones específicas de compilador/plataforma ni VLA
- Previsibilidad: la semántica de generación de código está definida, lo que garantiza que la disposición de datos generada sea siempre la misma
- Errores comprensibles: la propia biblioteca detecta algunos errores de sintaxis en código incorrecto
- Casos de uso reales: se usa en OpenIPC para desarrollar software de streaming en tiempo real para cámaras IP, incluyendo una implementación de RTSP 1.0 y unas 50 mil líneas de código privado
Instalación y configuración de compilación
- Datatype99 consta de un único archivo de cabecera
datatype99.h y la dependencia Metalang99
- Para usarlo en un proyecto, hay que agregar
datatype99 y metalang99/include a los directorios de include
- En GCC se recomienda usar
-ftrack-macro-expansion=0, y en Clang -fmacro-backtrace-limit=1, para reducir la salida innecesaria de errores por expansión de macros
- Si se usa CMake, se recomienda
FetchContent
- El
datatype99/CMakeLists.txt predeterminado descarga Metalang99 v1.13.5 desde GitHub Releases
- Ese comportamiento puede redefinirse llamando antes a
FetchContent_Declare
- Las cabeceras que dependen de Datatype99 pueden convertirse en precompiled header, reduciendo el tiempo de compilación al no recompilarse cada vez que se incluyen
Forma de uso: usar tagged unions de manera más segura
- Datatype99 es básicamente azúcar sintáctica para tagged unions, y ofrece una forma más segura y concisa de usarlas
- En C normal, para representar un árbol binario hay que escribir manualmente un enum
tag y un union
- En Datatype99, la misma estructura puede declararse así
datatype(
BinaryTree,
(Leaf, int),
(Node, BinaryTree *, int, BinaryTree *)
);
- En el enfoque normal con
switch, incluso si por error se accede a tree->data.node después de case Leaf:, el compilador puede no advertirlo
- Si se usan
match y of, los bindings de cada variant solo son visibles dentro de esa rama, y un acceso inapropiado hace que falle la compilación
int sum(const BinaryTree *tree) {
match(*tree) {
of(Leaf, x) return *x;
of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
}
return -1;
}
- Los bindings que proporciona
of son variables como x, lhs y rhs, y tienen tipo puntero, por lo que se pueden modificar los valores
- Para crear variants se usan constructores de valor generados internamente
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);
Sintaxis y semántica de generación
- Datatype99 ofrece sintaxis basada en macros como
datatype, record, match, of, otherwise, MATCHES e ifLet
- Existen tanto nombres abreviados de macros como versiones con sufijo
- Ejemplo:
match99, of99, derive99
- Para evitar conflictos de nombres, se puede definir
DATATYPE99_NO_ALIASES antes de incluir datatype99.h
- En las cabeceras de la biblioteca se recomienda usar las macros con sufijo
datatype genera los siguientes elementos
typedef adelantado
- structs para cada variant no vacía
typedef para los tipos de campo de las variants
typedef para el sum type
- tagged union que incluye un enum
tag y un union
- constructores de valor
inline static por variant
- llamadas a los derivers indicados en
derive(...)
- Aunque todas las variants estén vacías, el estándar de C exige al menos un miembro en un union, por lo que se inserta
char dummy;
record es un struct con proceso de derivación definido, y cuando no hay campos también genera char dummy;
match compara secuencialmente una instancia de sum type con cada variant, ejecuta las sentencias de la rama que coincide y luego continúa con la siguiente instrucción
- Las formas completas de
match e ifLet se expanden cada una a una sola sentencia de C
MATCHES comprueba como verdadero o falso si una instancia de sum type corresponde a una variant específica
matches está deprecado y se recomienda usar MATCHES
derive y atributos auxiliares
derive(...) se usa para generar código global para un sum type o un record
- El deriver de un sum type se invoca en forma de macro compatible con Metalang99, y la lista de variants se pasa como una lista de tuplas
- El deriver de un record también se invoca como una macro compatible con Metalang99, y la lista de campos se pasa como una lista de tuplas con la forma
(<type>, <field-name>)
- Un helper attribute de derive es un argumento con nombre que se pasa al deriver
- El helper attribute usa la forma de macro tipo objeto
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
- Macros provistas para manipular atributos
DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: comprobar si un atributo existe
DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: extraer el valor de un atributo existente
DATATYPE99_assertAttrIsPresent: lanza un error fatal si falta un atributo requerido
Patrones de uso a tener en cuenta
- No se debe usar
break/continue de nivel superior dentro de las sentencias pasadas a of e ifLet
continue dentro de bucles internos for/while sí es válido
- El control de flujo de nivel superior debe sustituirse con una etiqueta
goto
- Si se quiere especificar un arreglo como parámetro de una variant, hay que colocarlo dentro de un
struct separado
- Los bindings que introduce
of siempre son mutables, así que si el valor pasado a match es const, hay que tener cuidado de no modificarlo
- Para mantener legibles las definiciones de
datatype, se puede usar // clang-format off y // clang-format on con Clang-Format
- Los helper attributes de derive deben ir siempre seguidos de
#undef después de la definición correspondiente de datatype, para evitar contaminar el namespace
- Si el significado de los parámetros de una variant no queda claro solo por el contexto, se pueden usar alias de tipo o structs separados con nombres más descriptivos
Errores, IDE y compatibilidad
- La propia biblioteca detecta algunos errores de sintaxis
- Formatos que no son tuplas, como
Bar(int)
- Comas faltantes
- Trailing commas no permitidas
- Otros errores aparecen mediante los diagnósticos normales del compilador
- Nombres de tipo inexistentes
match no exhaustivo
- Exceso de binders en
of
- Argumentos de variant mal tipados
- Retorno sin desreferenciar un binding de puntero
- Según la experiencia reportada, casi el 95% de los errores se muestran de forma comprensible
- Si un error no se entiende, se puede revisar el código generado con
-E; como la semántica de generación está formalmente definida, normalmente no aparece código inesperado
- VS Code activa automáticamente sugerencias para los tipos generados, pero no soporta el resaltado de sintaxis de macros
- Se sabe que Datatype99 funciona en GCC, Clang, MSVC y TCC
- También soporta C++11 o superior
Por qué apuntar a C y cuáles son los límites
- El software existente escrito en C puro puede beneficiarse de Datatype99
- Puede integrarse en una base de código C existente solo con
#include <datatype99.h>
- En algunos entornos, por razones históricas, se mantiene C puro, como en dispositivos embebidos, Linux y otros sistemas operativos
- El ABI estable de C es importante para proyectos de sistemas de plugins como MetaCall
- C se presenta como un lenguaje maduro, con especificación completa y muchas bibliotecas
- Si se puede usar un lenguaje más moderno o de mayor nivel, se recomienda hacerlo en lugar de C antiguo, pero para muchas personas esa opción no existe o tiene un costo alto
- La diferencia entre Datatype99 y Metalang99 está en sus roles
- Metalang99 es un lenguaje funcional para metaprogramación
- Datatype99 es una implementación de tipos de datos algebraicos escrita con Metalang99
1 comentarios
Opiniones en Hacker News
En el trabajo tengo que usar Java, y llegué a ver que Java no es tan malo como lo criticaban antes, pero hubo decenas de momentos en los que pensé: “ojalá Java tuviera las uniones discriminadas (discriminated unions) de F#”.
Se pueden imitar con varias técnicas, y muchas veces un enum alcanza, pero en la mayoría de los casos falta la flexibilidad y concisión de unos verdaderos tipos de datos algebraicos. Sobre todo, no está el genial pattern matching que se obtiene en los lenguajes funcionales.
Esta extensión de C parece tener el pattern matching que quiero, así que se ve bastante bien; tengo que ver si puedo usarla en proyectos de Arduino.
Quien acaba de descubrirlos no puede dejar de decir que son el mejor invento desde el pan de molde :)
Para Java está https://github.com/functionaljava/functionaljava; el soporte está discontinuado, pero es estable.
Son tan potentes que no se puede estar sin ellas.
Como recientemente llegó soporte de pattern matching impuesto por el compilador sobre sealed classes, parece que ya estamos a mitad de camino.
Si quieres ver cómo funcionan por dentro los tipos de datos algebraicos, creo que libsum es un buen material de introducción.
[1] https://github.com/naasking/libsum
Conozco C desde hace casi 20 años, pero nunca imaginé que su sistema de macros fuera lo bastante potente como para permitir semejante magia negra.
De verdad es genial.
Tradicionalmente se usaba para crear y acceder a genéricos con seguridad de tipos, o para reducir código repetitivo en definiciones de registros de hardware e interrupciones.
Tiene un aire algo maldito, pero en esencia es realmente muy simple, y en proyectos basados en C es una herramienta confiable para reducir complejidad cognitiva y código repetitivo.
Como uso de macros, no es tan complejo.
https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
También hay una entrada de blog larga sobre lo mismo, aunque el autor parece no haber visto todavía esa sección de Wikipedia.
https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
Es digno de elogio que el desarrollador de datatype99 muestre directamente en el README los problemas de este tipo de soluciones improvisadas.
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
Aun así, todo lo demás es muy bueno.
Me recordó a una vez que hice una UI de modo inmediato con macros. En mi caso no fue un problema, pero en algunos bloques se puede usar "break".
Por ejemplo, en un bloque win_form se puede salir con "break" y "goto" también funciona. En cambio, un bloque win_command no captura "break", así que si usas break o goto dentro de win_command, sales del bloque externo que envuelve a ese win_command, probablemente el bloque win_form. Normalmente se usa para cosas como un botón "Cancel".
No es solo que sea un poco mejor en algunos aspectos, sino que en todo el ecosistema va eliminando con suavidad un montón de pequeñas molestias acumuladas con el tiempo. Como el problema de que la familia de CPython tenga demasiados tipos léxicos propios, o lo que pasa con las bibliotecas de cómputo de alto rendimiento: el efecto acumulado es grande.
Por ejemplo, la parte de esta macro que causa este problema no habría sido un problema desde el principio en una macro de Rust bien escrita. Es un subproducto de gente inteligente intentando rodear las limitaciones de C.
Sin embargo, esta macro en sí originalmente portaba una funcionalidad nativa de Rust, así que en Rust no habría hecho falta escribirla desde el principio, y por eso se usa de forma natural en software de la comunidad
gotosolo es un tiro en el pie cuando se usa para saltar de una función a otra; de hecho, “goto considered harmful” también apuntaba a esa prácticaEsa práctica desapareció, y hoy el goto usado dentro de una función es bastante inocuo; en la práctica es casi lo mismo que
break/continueY supongamos que ya sabes que existe Rust, pero que por las razones que todos los que escriben programas en C conocen no vas a usar Rust
Aun así, al menos vale la pena considerar Zig. Hace dos días escribí código así en Zig
Usando el
inline elsedecomptime, genera todas las ramas de una sentenciaswitchsobre una unión etiquetada y suma un desplazamiento a los miembros de la unión que tienen el campo"l". Si es información de tipos conocida en tiempo de compilación, puedes cambiar de muchas maneras la naturaleza de las ramas, y hay bastante de esa información. Como todas las condiciones se resuelven en tiempo de compilación, cada rama delswitchcontiene solo la lógica necesaria para manejar esa variante“Pero mi programa ya está en C y solo lo necesito en un archivo”; aun así, conviene probar Zig. Tal vez te guste
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
struct+union+enum? He usado el patrón de tener unaunionde varios tipos y agregarle unenumque distingue cuál elegirstd::varianttambién parece comportarse en cierta medida como un tipo sumaEl único problema es que no se puede hacer una sentencia
switchlimpia según un valor específico de un campo, pero losswitchanidados tampoco son tan desordenadosvtabledirectamente, así que a menudo hay que bajar a los detalles de implementaciónEl problema de este enfoque es la gran carga mental de tener que ocuparse de todos los detalles sin omitir nada. Como cansa, uno empieza a meter funciones a la fuerza en clases existentes en vez de crear clases nuevas
Al final, las abstracciones empiezan a sentirse caras y, como resultado, se usan menos abstracciones aunque el problema podría resolverse de forma más elegante
En resumen, la capacidad de decir “Darmok and Jalad at Tanagra” en vez de volver a contar una larga historia cada vez que se hace referencia a una idea compleja es transformadora
El pattern matching es la gran ventaja de usabilidad
Hacerlo con macros es difícil, pero no imposible. Básicamente, se necesita una macro que inicie el contexto de matching, introducir una variable para rastrear todas las alternativas revisadas y configurarlo para que, al terminar el contexto de matching, se compruebe si se revisaron todas las alternativas
std::variantno me gusta muchoLa forma de hacer matching exhaustivo de todos los casos con
std::visitse siente hacky. Sería una gran ventaja si de verdad fuera una funcionalidad de primera clase del lenguajePodría tener un impacto mayor en el código cotidiano que otras funciones en las que se ha venido trabajando, como las corrutinas