Cómo escribir estructuras de datos type-safe (genéricas) en C
(danielchasehooper.com)- Este artículo explica un nuevo método para crear estructuras de datos type-safe (genéricas) en C
- Describe, usando como ejemplo una implementación de lista enlazada, una técnica que asocia información de tipo a la estructura de datos mediante
union - Compara las diferencias y desventajas de cada enfoque frente a los patrones genéricos tradicionales de C (macros, punteros
void, Flexible Array Member) - Permite verificación de tipos en tiempo de compilación, lo que ayuda a prevenir de antemano el uso de tipos incorrectos
- La nueva técnica ofrece una interfaz de funciones/estructuras de datos clara y consistente, como
foo_list
Introducción
- Presenta una forma de crear estructuras de datos genéricas en C con seguridad de tipos
- Esta técnica conecta la información de tipo a la estructura de datos en tiempo de compilación usando
union - Puede aplicarse a cualquier estructura de datos, como mapas, arreglos o árboles binarios, y se explica con el ejemplo de una lista enlazada básica
- Como muchos desarrolladores creen que los genéricos no son posibles en C, la explicación avanza paso a paso de forma sencilla
Genéricos tradicionales basados en macros
- La forma tradicional de implementar estructuras de datos genéricas en C consiste en usar macros para generar nombres de estructuras, funciones y tipos
- Se amplía incluyendo varias veces el encabezado de la estructura de datos para distintos tipos
Ejemplos:
- Uso de macros (por ejemplo,
CONCAT,NODE_TYPE,PREPEND_FUNC) para generar nombres de estructuras y funciones adecuados para cada tipo - Como se genera una función y una estructura separadas para cada tipo, se obtienen definiciones distintas para tipos como
intyFoo
Desventajas:
- Es difícil identificar dónde están definidas los tipos y las funciones (se generan con macros y cuesta rastrearlas)
- Es difícil aprovechar el autocompletado de código
- Se generan múltiples versiones de la misma función, lo que incrementa el tamaño del binario y el tiempo de compilación
- Los nombres de función requieren un prefijo de tipo (por ejemplo,
Foo_list_prepend)
Etapa 1 de los genéricos: enfoque con punteros void
- El tipo de dato de la estructura se hace independiente del tipo usando
void * - El campo
datade la lista enlazada se declara comovoid * - Como no es posible verificar tipos, pueden producirse errores de tipo en tiempo de ejecución y la seguridad en compilación es baja
- Uso ineficiente de memoria y caché: el nodo y los datos se asignan por separado, lo que añade sobrecarga innecesaria y aumenta los fallos de caché
Etapa 2 de los genéricos: almacenamiento inline (Flexible Array Member)
- Se utiliza Flexible Array Member para guardar el propio dato dentro del nodo en lugar de almacenar un puntero
- Basta con una sola asignación por nodo, y en caché los datos quedan ubicados junto al puntero
next - Este enfoque requiere pasar información de tamaño y usar algo como
memcpy, pero mejora el rendimiento gracias a una disposición de memoria consistente - Con la función
list_alloc_frontes posible inicializar directamente la estructura sinmemcpy
Etapa 3 de los genéricos: implementación de verificación de tipos
- Se declara un puntero del tipo parametrizado en el miembro
payloadde ununionpara añadir información de tipo a la estructura de datos en tiempo de compilación - Ejemplo:
#define List(type) union { ListNode *head; type *payload; } - De esta forma se puede obtener el tipo de la lista con
__typeof__(foo_list.payload) - En la macro
list_prepend, mediante un cast del tipo de función, la compilación solo es válida si se usa el tipo correcto - Si se usa un tipo incorrecto, se produce un error en tiempo de compilación
Ejemplo de error:
- Si se agrega un
intafoo_list, aparece el mensaje de error de compilación'incompatible integer to pointer conversion'
Compatibilidad con compiladores sin typeof
- Hasta antes de C23,
__typeof__no era estándar, por lo que algunos compiladores (por ejemplo, versiones antiguas de MSVC) no lo soportan - Se puede lograr un efecto similar con métodos alternativos, como aprovechar un miembro
payloaddentro de unastruct
Paso de parámetros y typedef
- Incluso si tienen la misma forma, el compilador considera que distintos
List(Foo)son tipos diferentes - Al usar
typedef, el paso de parámetros y la asignación funcionan de manera más fluida
Ejemplos:
typedef List(Foo) ListFoo;- Se puede declarar una variable
ListFooy usarla como parámetro de función
Cierre y ampliación a distintas estructuras de datos
- Esta técnica también puede usarse en estructuras de datos que requieren varios parámetros de tipo (por ejemplo, un hash map)
- Mediante
union, es posible garantizar la seguridad de tipos tanto parakeycomo paravalue - Para una práctica más detallada y la implementación de las macros, consulta el gist de código relacionado
Conclusión
- El nuevo enfoque supera las desventajas de los métodos existentes (legibilidad, eficiencia de compilación y mantenibilidad) y ofrece un esquema de nombres consistente para funciones junto con seguridad de tipos
- Facilita el soporte para múltiples estructuras de datos y varios parámetros de tipo
- Gracias a la verificación de tipos en tiempo de compilación, asegura al mismo tiempo la seguridad y la eficiencia al usar estructuras de datos genéricas
Agradecimientos
- Este artículo fue completado con la retroalimentación y el apoyo de Martin Fouilleul
2 comentarios
¿No sería más simple usar Zig?
Opiniones de Hacker News
Señalan que, en el código del paso 2, la forma de usar
uint64_t data[];no sirve para tipos cuyos requisitos de alineación son mayores queuint64_t, y en cambio desperdicia espacio para tipos más pequeños; por ejemplo, esto es aún más problemático en un ABI ilp32 sobre una arquitectura de 64 bits. También comentan que en el código del paso 3 debería serint main() { List(Foo) foo_list = {NULL};así. Sintypeofno se puede devolver el valor de retorno, y en el código alternativo pueden aparecer errores relacionados conconst; este problema se hace más visible por la simetría del operador==. Si se quitapayload, deja de haber información de tamaño y ya no es seguro; por ejemplo, podría parecer que agregar unint32_taList(int64_t)está bien, pero en realidad no se puede determinar el tamaño real deint32_t. Dicen que haría falta reforzarlo para que sea más seguro. Consideran que hay dos grandes limitaciones al usar genéricos en C: primero, que el enfoque de delegación con vtable está limitado porque los structs no pueden incluir macros; segundo, que al delegar a una vtable externa hay que declarar de antemano todos los tipos que se van a usar. Añaden que la mejor forma es declarar solo funciones estáticas en un header que incluya las declaracionestypedef, pero que GCC y Clang difieren en el momento en que emiten advertencias por static indefinidos. Al final ponen como ejemplo el diseño de funciones que reciben distintos structs de buffer, subrayando que también hay que mantener todas las versionesconstSobre el problema de delegar a una vtable externa, comparten que en un proyecto anterior incluso llegaron a crear un compilador para resolverlo directamente. Cuentan que, al iniciar el proyecto Apache Clownfish, empezaron parseando archivos
.h, pero terminaron creando su propio formato, los headers de Clownfish (.cfh). Muestran como ejemplo código que llama al método "Clone" de un obj real, y describen la experiencia de haber tenido que generar en masa este tipo de código forzado para soportar bindings de lenguajes dinámicos que necesitaban características orientadas a objetos. Explican que el objetivo de Clownfish era ofrecer el modelo de objetos mínimo común, y que los tipos de los lenguajes enlazados también se generaban desde.cfh. Añaden que, por esta complejidad, la mayoría termina renunciando a la seguridad de tipos y usando casts avoid*. https://github.com/apache/lucy-clownfishSobre
int main(), recuerdan que en Cint main()significa que la cantidad de argumentos es indeterminada. Para indicar que no hay argumentos debe declararse comoint main(void). Enfatizan que es algo que muchos programadores de C++ suelen olvidarOpinan que les gustaría que un union pudiera realmente unirse con otras estructuras, es decir, que un tipo pudiera declararse a sí mismo como parte del union de otro tipo
Señalan que al hacer
malloc, por el padding interno el tamaño calculado puede ser menor que el real; por ejemplo, plantean el riesgo de algo comomalloc(sizeof(*node) + data_size);Refutan el contenido del truco #0 y dicen que usaron ese truco al crear un dialecto completo de C. Comparten como ejemplo una implementación de binary heap genérico: https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h. Dicen que la sintaxis es algo pesada, pero que al final se convierte en un struct normal de C, lo que aporta grandes ventajas en optimización y previsibilidad. Consideran que en otras implementaciones son inevitables
void*, el dimensionamiento de memoria en tiempo de ejecución y las definiciones por macroComo autor, explica que un binary heap y una linked list tienen propósitos distintos. Un binary heap necesita leer los datos al almacenarlos, así que el enfoque es diferente, y al escribir un binary heap genérico las decisiones pueden ser otras. Aclara que eso también lo mencionó en una nota al pie del texto
Presentan varias razones para preferir implementaciones en headers. Al depurar, es más fácil seguir el código y aprovechar la información de tipos que con funciones hechas con macros. El compilador puede aplicar optimización monomorfizada a cada instancia, sin costo en tiempo de ejecución ni carga por tamaños variables. También se pueden poner structs genéricos en la pila. Dicen que los dos problemas mencionados por el autor se pueden esquivar: los nombres se pueden cambiar fácilmente con macros de nombres de función, y usando
weak symbolel enlazador puede fusionar automáticamente definiciones duplicadas. Añaden que los contenedores genéricos de tipos puntero tienen otro problema, pero que puede resolverse contypedefy similares. Comentan que en C las estructuras de datos intrusive siguen siendo cómodas, aunque difíciles de depurarLa frase de que "el compilador se los come como donas" les causó mucha risa
Al convertir tipos de función, por ejemplo, se asume que la representación interna de
Foo*yvoid*es la misma, pero el estándar de C no lo garantiza. Cuando no hay compatibilidad de tipos ("compatible") entre ellos, ese cast puede llevar a comportamiento indefinido. También mencionan que esto podría afectar al análisis de aliasing del compilador, y adjuntan un enlace de referencia: https://news.ycombinator.com/item?id=44421185Preguntan: "Si para usar genéricos en C hay que hacer estas maromas, ¿no sería mejor simplemente usar C++?"
Comparten la experiencia de que, por criterios de seguridad y otros requisitos, en muchos proyectos heredados migrar a C++ no es algo viable de inmediato. En proyectos nuevos sí establecen estándares e introducen C++, pero creen que los proyectos existentes tendrán que seguir en C por un tiempo. Opinan que la postura de "simplemente usa C++" agradecería un poco más de contexto y empatía
Añaden que, en la práctica, en entornos donde se usa C, cambiar a C++ puede ser aún más complejo y causar más problemas
En cambio, otro comenta que si con un poco de esfuerzo se puede lograr lo mismo en C, entonces no hace falta ir hasta C++
Presentan un patrón que realmente se usa en el kernel de Linux: incluir un
struct list_headcon la información de la lista dentro del struct específico de cada tipo. Dejan un enlace de referencia relacionado: https://kernelnewbies.org/FAQ/LinkedListsSeñalan que, en la sección "typeof on old compilers",
(list)->payload = (item);en realidad no es un no-op, sino que sobrescribe la cabecera de la lista conitem. Proponen que, si ese era el comportamiento esperado, debería envolverse enif(0)if(0)Muestran que en el lenguaje D este tipo de lista genérica es mucho más simple, y recalcan la incomodidad de las macros de C con la metáfora de que el preprocesador de C se siente como martillarse las uñas, mientras que para clavar un Nail gun es mucho más rápido y limpio
Les parece interesante la idea de aprovechar
unionytypeof(). En su caso, sienten que con estructuras de datos intrusive al final hace falta un wrapper envuelto en macros grandes, y se preguntan siunionytypeoftambién permitirían una implementación así. Comparten como ejemplo código de un wrapper de hash table y un enlace a la documentación: https://github.com/FRRouting/frr/blob/master/lib/typesafe.h#L823-L971 https://docs.frrouting.org/projects/dev-guide/en/latest/lists.htmlComparten que personalmente ya usan esta técnica en una biblioteca experimental: https://github.com/uecker/noplate/blob/main/src/list.h
Parece que la idea central es asegurar la seguridad de tipos aprovechando el tipo de los punteros a función, en vez del típico tipo handle. Comentan que el estándar C23 mejoró los problemas de compatibilidad de tipos, y comparten el documento del estándar y el estado de soporte en GCC/Clang recientes: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3037.pdf
unionpara vincular la información de tipo al tipo de datos genérico; no se trata solo de casts de funciones, y menciona que también discutió varias alternativas en las notas al pie y en "typeof on old compilers" con bastante detalle