2 puntos por GN⁺ 2025-01-13 | 1 comentarios | Compartir por WhatsApp
  • Aunque el C estándar siguió cambiando hasta C23, Walter Bright considera que viejas incomodidades como la evaluación de expresiones constantes, las referencias adelantadas y la dependencia de headers todavía entorpecen el flujo de desarrollo
  • ImportC, el compilador de C integrado en el compilador de D, aprovecha la ventaja de ser una implementación nueva para ejecutar en tiempo de compilación funciones que cumplan las condiciones en posiciones de constant-expression
  • Si CTFE es posible, pruebas unitarias como _Static_assert(sum(3, 4) == 7,...) pueden ejecutarse en cada compilación sin un ejecutable separado
  • Las restricciones del C estándar sobre el orden de las declaraciones generan repeticiones de declaraciones adelantadas y una disposición de código en orden inverso, pero ImportC adopta un enfoque menos atado al orden global de las declaraciones
  • Si se importan directamente declaraciones de un archivo .c con algo como __import dex;, se puede reducir la necesidad de archivos .h separados y también bajar la carga de depuración causada por desajustes entre header e implementación

Incomodidades que siguen presentes en el C estándar

  • El C estándar se ha mejorado de forma regular hasta C23, pero Walter Bright cree que todavía quedan partes que no se han corregido
  • La comunidad de Dlang integró un compilador de C dentro del compilador del lenguaje de programación D, y a este compilador lo llaman ImportC
  • ImportC fue creado desde cero, así que tenía margen para abordar carencias de C con tecnología moderna de compiladores
  • El artículo trata cuatro puntos
    • evaluación de constant-expression
    • pruebas unitarias en tiempo de compilación
    • referencias adelantadas de declaraciones
    • importación de declaraciones

Ejecución de funciones en tiempo de compilación y evaluación de expresiones constantes

  • C puede calcular expresiones simples en tiempo de compilación mediante constant folding, pero en el C estándar no se pueden ejecutar funciones en tiempo de compilación
  • Si se compila con gcc el código de ejemplo enum E { A = 3, B = 4, C = sum(5, 6) };, aparece un error indicando que el valor del enumerador C no es una constante entera
  • ImportC sí puede compilar el mismo código
  • La idea es que el compilador pueda ejecutar funciones en tiempo de compilación en cualquier lugar donde aparezca constant-expression en la sintaxis de C
  • Eso sí, la función no debe realizar operaciones como I/O, acceso a variables globales mutables o llamadas al sistema

Pruebas unitarias que se ejecutan en cada compilación

  • Si la CTFE, es decir, la evaluación de funciones en tiempo de compilación, es posible, también puede cambiar la forma de hacer pruebas unitarias
  • Una razón por la que las pruebas unitarias son poco comunes en código C es la molestia de tener que crear y ejecutar un objetivo de build y un ejecutable separados
  • Si se compila con gcc el código de ejemplo _Static_assert(sum(3, 4) == 7, "test #1");, se produce un error porque la expresión dentro del static assertion no es una constante
  • ImportC sí puede compilar este código
  • Este enfoque simplifica las pruebas unitarias de funciones que pueden ejecutarse en tiempo de compilación
    • no requiere un build separado
    • no requiere trabajo adicional
    • las pruebas se ejecutan cada vez que se compila el código
  • En la suite de pruebas de ImportC también se usa ampliamente este método

Repetición de declaraciones causada por las referencias adelantadas

  • En el C estándar no se permiten las referencias adelantadas porque el compilador solo conoce las declaraciones que aparecen antes en el texto
  • En el código de ejemplo, si floo() llama primero a dex() que está definido después, gcc da un error porque la declaración implícita de dex entra en conflicto de tipos con la definición real
  • Si se cambia el orden de floo y dex, el mismo código compila sin problemas
  • ImportC puede aceptar declaraciones globales sin importar el orden en que aparezcan
  • Si no hay referencias adelantadas, hay que agregar una declaración separada para cada definición adelantada
    • primero se escribe una declaración como char dex(char *s, int i);
    • y después se vuelve a escribir la definición de la función
  • Este método implica repetición innecesaria y hace que el programador coloque las funciones leaf arriba y las funciones de interfaz global abajo, en una disposición inversa
  • Se critica que esa disposición no tiene sentido, como si se leyera un artículo periodístico de abajo hacia arriba

Importar declaraciones sin archivos header

  • En C, para usar un módulo externo normalmente hay que poner sus declaraciones en un archivo .h y hacer include de este en el archivo .c
  • La estructura de ejemplo es la siguiente
    • floo.c hace include de dex.h y llama a dex()
    • dex.h declara char dex(char *s, int i);
    • dex.c hace include de dex.h y define dex()
  • Crear un archivo .h para cada módulo externo aumenta el trabajo repetitivo
  • Si el archivo .h no coincide exactamente con el archivo .c, puede tomar mucho tiempo encontrar qué salió mal
  • El enfoque propuesto es importar dex.c directamente
    • en floo.c se usa __import dex;
    • en dex.c solo se deja una definición como char dexx(char *s, int i) { return s[i]; }
  • Con este enfoque ya no hace falta escribir un archivo .h
  • ImportC también soporta esta forma

Documentos relacionados

1 comentarios

 
GN⁺ 2025-01-13
Opiniones de Hacker News
  • Una de las cosas que más extraño cuando uso un lenguaje que no es C son los archivos de encabezado.
    Me gusta especialmente en el código C lo claramente que se separan lo público/privado y la interfaz/implementación. Me parece bueno que con solo revisar el archivo .h de una biblioteca puedas entender cómo usarla; normalmente el .h incluye la documentación de uso y no duplica lo que está en el .c. También se podría poner la documentación en el .c, pero para quien lo usa, leer la interfaz se vuelve mucho menos cómodo.

    • Ese argumento siempre me ha parecido raro, porque otros lenguajes resuelven este problema con herramientas, y creo que eso es mejor.
      Por ejemplo, en Rust basta con cargo doc --open para ver la interfaz y el uso de una biblioteca. Toda la API pública se genera automáticamente en un formato buscable, sin necesidad de duplicar código entre encabezado y fuente ni mantenerlo manualmente.
    • Creo que los archivos de encabezado son más bien un hack débil para lidiar con plataformas con restricciones de recursos de los años 70.
      Solo funcionan bien cuando se respetan las convenciones, y se ven pobres comparados con lenguajes como Ada, donde las especificaciones de interfaz e implementación están bien diseñadas y no requieren parseos repetidos. Me gusta usar C, pero esta parte debería haberse diseñado mejor.
    • Estoy de acuerdo y a la vez no lo estoy.
      Los archivos de encabezado de C funcionan insertando texto tal cual, así que están al nivel de “parece que más o menos funciona”. En cambio, Ada tenía los conceptos de paquete y cuerpo de paquete; el paquete correspondía al archivo de encabezado y el cuerpo de paquete a la implementación. Cuando usé Ada hace mucho tiempo, aunque la implementación del cuerpo del paquete todavía no estuviera lista, todos podían compilar tomando el paquete como base, así que se podía ir ajustando la interfaz incluso antes de implementar. En otra dirección, me gusta el import de Python como una especie de “archivo de encabezado”, porque se mapea de forma natural al sistema de archivos y no hace falta lidiar con la semántica de include de C.
    • Creo que detrás de esto hay una diferencia de mentalidad.
      Cuando hay archivos de encabezado, uno empieza a pensar la interfaz como algo distinto de la implementación. Por eso, a quienes están acostumbrados a este enfoque les incomoda la idea de que la interfaz sea generada por herramientas. La interfaz no es un subproducto de la implementación, sino algo que se diseña por separado y de forma intencional, y para algunas personas es incluso más importante que la implementación. En cambio, quienes están acostumbrados a documentación generada automáticamente se sienten incómodos cuando la interfaz no se genera a partir de la única fuente de verdad que es el código de implementación. Después de usar durante mucho tiempo lenguajes con archivos de interfaz separados y lenguajes sin ellos, cada quien se afianza en su bando y olvida cómo se sentía pensar de la manera opuesta.
    • En la mayoría de los lenguajes compilados con módulos esto ya es posible.
      Ya sea separándolo, como en Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F# y D, o generándolo con herramientas de texto o gráficas, como en Object Pascal, D, Haskell, Java, C#, F#, Swift, Go y Rust. Todos tienen tipos más fuertes, compilación más rápida y espacios de nombres adecuados. Las toolchains de Rust y Swift todavía tienen cosas por pulir, pero las herramientas de C siempre fueron primitivas en comparación con lo que ocurría fuera de Bell Labs. Si AT&T hubiera podido explotarlo comercialmente, la historia habría sido distinta; al final, recibimos limones gratis en lugar de naranjas maduras. Aun así, se creó algo parecido a TypeScript para C, y hoy también soporta tipos de colección con verificación de límites y módulos adecuados.
  • Como el autor tiene mucho más conocimiento y experiencia en este campo, me da curiosidad cómo resolvería los siguientes problemas.
    La evaluación de expresiones constantes se vuelve simple si se hace solo dentro de la unidad de traducción, pero eso limita mucho lo que se puede hacer sin repetir código. Las pruebas unitarias en tiempo de compilación son posibles hasta cierto punto si las pruebas pueden expresarse como macros, y se vuelven más fáciles si se suma el punto anterior. Las referencias adelantadas de declaraciones pueden generar mucha resistencia, porque el compilador pasaría de una pasada a dos, con impacto en el rendimiento. Para quienes compilan bases de código enormes y además paralelizan por unidad de traducción, podría ser difícil de aceptar. La importación de declaraciones es un cambio que rompe compatibilidad. En C he implementado algo parecido a plantillas definiendo una variable, importando un archivo .c, cambiando la variable y luego importando de nuevo el mismo .c. También he puesto varias definiciones antes de SQLite C Amalgamation y agregado funciones para exponer funciones internas; todos esos usos parecen que se romperían. Me pregunto si hay soluciones para estos problemas.

    • Es cierto que el compilador debe poder ver el código fuente de la función que se evalúa.
      En C se puede hacer con #include, y en D se maneja importando el módulo que contiene el código necesario. Expresar las pruebas como macros no sirve si lo que se quiere probar son funciones. El ejemplo se dio de forma simple para facilitar la comprensión, pero el uso real puede volverse mucho más complejo. En términos de rendimiento, D compila más rápido que los compiladores de C; la razón principal es que el preprocesador de C es una estructura fosilizada que requiere varias pasadas, mientras que D usa import en lugar de #include y por eso no recompila repetidamente archivos .h. La estrategia de D es separar el parseo del análisis semántico; puede ser un poco más lenta, pero no tiene el costo de recompilar declaraciones duplicadas y plegarlas en una sola. La ejecución de funciones en tiempo de compilación puede convertirse en un cuello de botella si se usa mucho, pero si se usa con moderación el rendimiento está bien. Si estás implementando plantillas en C mediante hacks, ya sobrepasaste el lenguaje y necesitas uno más potente. D tiene metaprogramación de primer nivel, y otros lenguajes con plantillas suelen seguir el camino de D.
    • Personalmente, no me gustan las referencias adelantadas porque hacen que el código sea más difícil de leer.
      Ya no se puede confiar en que el grafo de dependencias esté en orden topológico.
    • Otros lenguajes parecen funcionar bien sin archivos de encabezado ni declaraciones adelantadas, así que me cuesta entender por qué habría resistencia.
      También me pregunto si los compiladores modernos de C realmente siguen siendo de una sola pasada.
  • El ejemplo de evaluación de expresiones constantes del artículo es bastante simple, pero en casos más complejos probablemente empeoraría mucho la velocidad del compilador y el uso de memoria, y para aprovecharlo haría falta una máquina virtual
    Por eso entiendo que quizá se haya considerado “demasiado complejo” para incluirlo en el estándar. Me habría gustado que C++ o C avanzaran hacia importación de declaraciones, en lugar de la mezcla rara definida en C++20. Por ejemplo, algo como #import "string.c" as str, donde se importa un módulo bajo algún símbolo y se puede acceder a todos los símbolos no estáticos del archivo como str.trim(" Hello World ");. Aparte, no me gusta algo como __import dex;, donde la ruta del archivo no es explícita. En ese caso no se sabe si importa dex.d o dex.c

    • Otros lenguajes populares también pueden hacerlo, y en D la ejecución de funciones en tiempo de compilación es una función muy popular y útil
      Si se usa mucho, obviamente consume tiempo de compilación y memoria. Sobre la máquina virtual: el plegado de constantes en sí ya es una máquina virtual, y esto sería extenderla para manejar llamadas a funciones. La semántica de C es simple, así que no sería tan malo. El método de import que propones es casi lo mismo que hace el import de D: https://dlang.org/spec/module.html#import-declaration. El problema de si es dex.d o dex.c sí ocurre en la práctica, y la respuesta es la configuración de las rutas de importación. Es parecido a las rutas de include de un compilador de C
    • La mayoría de los compiladores de C reales probablemente ya hacen cierta evaluación en tiempo de compilación para optimizar
      C ya tiene expresiones constantes. El obstáculo más grande es que el compilador necesita acceso al código fuente de la función, así que probablemente quedaría limitado a funciones de la misma unidad de traducción. Y también puede ser mucho mayor el problema humano de que un comité con representantes de varios compiladores tenga que ponerse de acuerdo sobre la semántica de esa evaluación constante
    • En realidad, esto parece casi un 1:1 con la función constexpr de C++
      Como casi todos los compiladores de C también son compiladores de C++, me pregunto si soportar funciones y evaluación constexpr en C realmente podría ser tan malo
  • Siempre escribo pruebas unitarias para el código en C
    Con un buen sistema de build y aceptando un poco de boilerplate, no es difícil. Las pruebas de la biblioteca npy llaman a npy_load("tests/npy/uint8.npy") en test_load_uint8(), hacen assert de las dimensiones, el tamaño y el tipo, luego llaman a npy_free, y se ejecutan desde main con algo como PRINT_RUN(test_load_uint8);. Se podrían generar algunas partes de las pruebas con el preprocesador, pero prefiero mantenerlo simple

    • Esa función parece hacer I/O, así que no funcionaría como prueba en tiempo de compilación
      Un ejemplo de pruebas unitarias del compilador ImportC sería algo como _Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok"); y _Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Como verifica la semántica en tiempo de compilación, no hace falta enlazar ni ejecutar. Cuantas más pruebas haya, este enfoque se vuelve bastante más rápido, y cuanto más rápido corre el conjunto de pruebas, más sube la productividad
    • Yo hago casi lo mismo
      En su momento investigué a fondo frameworks de pruebas unitarias más vistosos, pero me di cuenta de que no aportaban mucho y terminé adoptando casi la misma forma. Algo que otros podrían ver mal es que estoy dispuesto a hacer #include del archivo .c bajo prueba para llamar funciones estáticas. Incluyo solo un único archivo .c. Además, antes del #include hago un pequeño procesamiento con el preprocesador para asegurarme de que NDEBUG no quede definido, por si alguien compila en “modo release” y desactiva los assert
    • Creo que hacer que las pruebas sean lo más fáciles posible de ejecutar ayuda mucho a llegar a una base de código que sí tenga pruebas
      Uso algo parecido: https://github.com/ensisoft/detonator/blob/master/base/test_.... Tomé mucho de boost.test.minimal y originalmente era de un solo header, pero con los años fue necesario agregar una unidad de traducción separada. Mi conclusión es que, si mantienes la base de código en un estado donde las pruebas siempre pasan, se necesita mucha menos complejidad en las herramientas de prueba, como reportes de errores o tolerancia a fallas
  • Las pruebas unitarias en tiempo de compilación son una idea tan mala como convertir en errores, y no en advertencias, los “imports/variables/resultados sin usar”
    Es una “función niñera” que le quita control al desarrollador y al final lo obliga a pasar por trámites burocráticos para poder terminar el trabajo. Estas pruebas que hacen fallar el build sirven para builds de “creo que ya terminé”, pero no encajan con los builds de “todavía estoy trabajando”, que son el 99% de los builds. Es parecido a decir “no puedes usar la sierra de mesa antes de guardar el taladro”

    • En esta parte me cuesta estar de acuerdo
      Si intentabas expresar una idea y una prueba en tiempo de compilación te dice que está mal, quizá esa idea todavía está incompleta o no pensaste lo suficiente en todas las consecuencias de esa expresión. Es parecido a la verificación de tipos de Haskell. Un programa que no pasa la verificación de tipos no puede compilarse, y obliga al programador a expresar siempre ideas completas. En teoría, eso podría llevar a programas mejor pensados. Aunque también vuelve más difícil escribirlos, porque obliga al programador a encontrar incluso los rincones donde “sé que no es válido, pero no me importa”
    • Estas pruebas en tiempo de compilación pueden estar más cerca de static_assert, y tienen mucho valor para detectar usos incompatibles de funciones de biblioteca
      Me parece una idea bastante buena
  • Que “las funciones leaf vienen primero y las funciones de interfaz global al final” para mí está al revés.
    Por varias razones prefiero escribir el código en orden topológico. Se parece a la forma de escribir código dentro de una función, deja claro dónde debería ir cada función dentro de un módulo y, lo más importante, hace muy evidentes las dependencias circulares entre fragmentos de código internos del módulo. No me gustan mucho las dependencias circulares porque vuelven más enredada la base de código y dificultan entender un módulo como una unidad independiente. En Python también pueden crear problemas que no aparecen hasta la ejecución[0], y como los imports circulares son tan comunes, parece que los verificadores de tipos actuales tienen desactivado por defecto su diagnóstico[1]. Lenguajes como C, OCaml y SML, que no admiten referencias hacia adelante, permiten aplicar el “principio de mínima sorpresa” a las dependencias circulares. OCaml incluso prohíbe las dependencias recursivas entre funciones si no se declaran como let rec fn1 = .. and fn2 = ..; aunque al escribir puede ser un poco molesto, al leer se convierte en información importante.
    [0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
    [1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (ver reportImportCycles)

  • Hay una explicación de que, como el compilador solo conoce lo que aparece léxicamente antes, se termina con un orden “de abajo hacia arriba”, donde las funciones leaf vienen primero y las funciones de interfaz global al final; pero incluso en lenguajes que permiten referencias hacia adelante, como Python, ese orden es común[0].
    Me pregunto si es un vestigio de lenguajes que no permiten referencias hacia adelante, o si realmente tiene más sentido para ciertos tipos de código.
    [0] https://stackoverflow.com/a/73131538

    • Python no tiene referencias hacia adelante.
      Simplemente, los identificadores dentro del cuerpo de una función no se resuelven hasta que se ejecuta la propia función, y para entonces ya está definido todo lo del scope del módulo. Se puede comprobar directamente poniendo cualquier nombre dentro del cuerpo de una función y cargando el módulo.
    • En mi código, la interfaz pública casi siempre va arriba y los detalles de implementación tienden a ir al final del archivo.
      No es que tenga una regla estricta, pero me parece más limpio y sensato leerlo así. Especialmente cuando la implementación es grande, no quiero tener que desplazarme por todo eso para recién entonces ver qué hace básicamente. Me da curiosidad saber cómo lo hacen otros.
  • Entre las “cosas obvias” que me gustaría que C hiciera están el soporte para un tipo slice que contenga un puntero y una longitud, versiones reentrantes y, de ser posible, thread-safe de las API que usan estado global, la estandarización de algo como defer de Go o Zig, o el atributo cleanup de GCC, y algún soporte portable para Unicode y UTF-8.

    • ¿La mayoría de eso no son funcionalidades que uno querría en la biblioteca estándar, más que algo que deba hacer el lenguaje en sí?
  • La idea de que, en todos los lugares de la sintaxis de C donde aparece una expresión constante, el compilador debería poder ejecutar funciones en tiempo de compilación siempre que no hagan I/O, no accedan a variables globales mutables, no hagan llamadas al sistema, etc., puede romperse fácilmente.
    Basta con elegir una función pura que tarde muchísimo en ejecutarse. Por ejemplo, si int busybeaver(int n) {...} fuera una función pura que devuelve la vida máxima de una máquina de castor ocupado de n estados, código como int x = busybeaver(99); sería un problema.

  • C23 tiene constexpr, pero todavía no se puede aplicar a funciones.
    Sin embargo, hay una propuesta: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf