1 puntos por GN⁺ 2024-05-10 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2024-05-10
Opiniones en Hacker News
  • Cada vez que uso un lenguaje imperativo, casi siempre extraño los tipos de datos algebraicos.
    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 no haya usado tipos de datos algebraicos y pattern matching no entiende qué tienen de espectacular, y quien ya está acostumbrado tampoco lo entiende del todo hasta que trabaja en un lenguaje que no los tiene.
      Quien acaba de descubrirlos no puede dejar de decir que son el mejor invento desde el pan de molde :)
    • Con las sealed interfaces de Java 21 es posible hacer pattern matching.
    • Kotlin es compatible con la JVM y tiene tipos de datos algebraicos.
      Para Java está https://github.com/functionaljava/functionaljava; el soporte está discontinuado, pero es estable.
    • Los tipos de datos algebraicos y el pattern matching en realidad también funcionan muy bien en lenguajes imperativos. Rust es un ejemplo.
  • Si tuviera que implementar el producto de nuevo desde cero, pondría como requisito indispensable las uniones discriminadas con pattern matching exhaustivo impuesto por el compilador.
    Son tan potentes que no se puede estar sin ellas.
    • Lo que más quiero en Dart son los tipos unión, pero lamentablemente no parece que vayan a agregarlos pronto.
      Como recientemente llegó soporte de pattern matching impuesto por el compilador sobre sealed classes, parece que ya estamos a mitad de camino.
    • ¿Qué lenguajes encajan hoy con esa descripción? No estoy seguro de entender exactamente a qué se refiere, pero creo que lo entendería mejor viendo ejemplos en lenguajes que lo soporten.
  • Sin duda se ve mejor y probablemente funcione mejor que mi intento anterior [1], pero tiene 8 veces más código y depende del excelente, aunque un poco intimidante, conjunto de herramientas de macros Metalang9.
    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
    • Veo que ya le había puesto estrella a ese repositorio; parece que lo revisé cuando estaba diseñando Datatype99 :)
  • Esto es obra de un mago.
    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.
    • El autor tiene apenas 19 años. De pronto me siento como un tonto.
    • metalang99, del mismo autor, también puede resultar interesante.
    • x-macro, es decir, este estilo de uso de macros, es bastante vistoso.
      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.
    • Los tipos de datos algebraicos son, en general, sustitución de cadenas sobre structs y unions genéricos, con una etiqueta de unión agregada.
      Como uso de macros, no es tan complejo.
  • En Wikipedia hay algo interesante relacionado con esto: la forma de implementar las uniones como “jerarquías de clases de la programación orientada a objetos”.
    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.
  • También está https://melt.cs.umn.edu/. Tiene una extensión que agrega templates y tipos de datos algebraicos a C.
    https://github.com/melt-umn/ableC-template-algebraic-data-ty...
  • Que “dentro de las sentencias que se pasan a of e ifLet no se use break/continue de nivel superior, sino goto label” parece un riesgo de pegarse un tiro en el pie bastante grande.
    Aun así, todo lo demás es muy bueno.
    • Usar goto en sí no es un problema, pero hay que saber que dentro de esos bloques no se debe usar break/continue.
      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".
    • Lo bueno del terreno de las macros en Rust es que no solo es posible escribir código que verifique estas condiciones, sino que además es razonable e imaginable hacerlo, y también puedes apoyarte en bibliotecas open source fáciles de instalar.
      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

  • goto solo 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áctica
    Esa 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/continue
  • Supongamos que tienes que escribir un programa en C y que de verdad necesitas pattern matching exhaustivo sobre una etiqueta de unión. Lo que ofrece Datatype99 es, “en pocas palabras, azúcar sintáctico sobre uniones etiquetadas”
    Y 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 else de comptime, genera todas las ramas de una sentencia switch sobre 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 del switch contiene 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
  • ¿No se podrían obtener la mayoría de los beneficios de los tipos de datos algebraicos con struct + union + enum? He usado el patrón de tener una union de varios tipos y agregarle un enum que distingue cuál elegir
    std::variant también parece comportarse en cierta medida como un tipo suma
    El único problema es que no se puede hacer una sentencia switch limpia según un valor específico de un campo, pero los switch anidados tampoco son tan desordenados
    • Sí. Solo con convenciones y disciplina también se pueden obtener muchos de los beneficios de la orientación a objetos, pero para hacerlo hay que, por ejemplo, manejar la vtable directamente, así que a menudo hay que bajar a los detalles de implementación
      El 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 aspecto de modelado se puede imitar. Pero eso no llega ni a la mitad de los beneficios de los tipos de datos algebraicos
      El pattern matching es la gran ventaja de usabilidad
    • Al manejar valores de tipo suma, es absolutamente importante asegurarse de haber revisado todas las alternativas
      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
    • Usar C++ en la mayoría del código cuando realmente hace falta en general está bien, pero std::variant no me gusta mucho
      La forma de hacer matching exhaustivo de todos los casos con std::visit se siente hacky. Sería una gran ventaja si de verdad fuera una funcionalidad de primera clase del lenguaje
      Podría tener un impacto mayor en el código cotidiano que otras funciones en las que se ha venido trabajando, como las corrutinas
  • Es realmente impresionante que hayan implementado esto. Mis respetos