- En C también se pueden crear estructuras de datos genéricas con seguridad de tipos combinando macros,
void *, flexible array member y union; el ejemplo muestra una implementación paso a paso con una lista enlazada
- Incluir varias veces headers específicos por tipo es seguro, pero el código generado por macros dificulta rastrear definiciones y autocompletar, y puede aumentar el tamaño del binario y el tiempo de compilación
- Una lista basada en
void * es versátil, pero no evita errores de tipo; si el nodo y los datos se asignan por separado, puede haber 2 asignaciones por nodo y fallos de caché
- Si se almacenan los datos dentro del nodo con flexible array member y se envuelve
List(type) en un union, se puede añadir información de tipo en tiempo de compilación sin costo en tiempo de ejecución
- La macro
list_prepend hace coincidir, mediante el operador ternario, el valor pasado con el tipo de payload para provocar errores de compilación; para el tipo del puntero de retorno se puede usar __typeof__()
Punto de partida de la implementación de genéricos en C
- El objetivo es declarar listas por tipo en C, como
List(int) o List(Foo), y hacer que insertar un tipo incorrecto no compile
- En el ejemplo se puede insertar un valor
Foo en List(Foo), pero código como list_prepend(&foo_list, 7), que inserta otro tipo, no compila
- Dentro de
list_for(item, &foo_list), item puede tratarse como tipo Foo *
Nivel 0: enfoque de header genérico
- Una forma es escribir la estructura de datos en un header y hacer varios
#include cambiando la macro de tipo T
list.h genera mediante macros tipos y funciones como FooListNode y Foo_list_prepend, basándose en T
- Este enfoque es genérico y seguro en tipos, pero su uso resulta tosco
- Como los tipos y funciones se componen con macros, es difícil encontrar dónde están definidos
- El autocompletado de código puede no funcionar bien
- Se crean copias de la misma función por cada tipo, lo que aumenta el tamaño del binario y el tiempo de compilación
- En vez de un único
list_prepend(), hay que usar funciones con prefijo de tipo como Foo_list_prepend() o int_list_prepend()
- Este enfoque puede ser más adecuado para funciones genéricas que requieren generación de código por tipo
Nivel 1: lista basada en void *
- Si
ListNode tiene void *data, puede contener datos de varios tipos
list_prepend(ListNode **head, void *data) guarda el puntero a los datos tal cual, por lo que la implementación es simple
- El problema es que esta estructura no es segura en tipos
- Si el nodo y los datos se asignan por separado, también aumentan los costos de memoria y rendimiento
- Se requieren dos asignaciones por nodo
- El propio puntero
data usa memoria adicional
- Al recorrer la lista, puede haber fallos de caché tanto al acceder al siguiente nodo como al acceder a los datos
- El código de ejemplo usa
malloc por familiaridad, pero en la práctica se recomienda usar un Arena; como material relacionado se pueden consultar este video y este artículo
Nivel 2: almacenar los datos dentro del nodo
- En lugar de
void *data, usar Flexible Array Member permite colocar los datos dentro del nodo
struct ListNode tiene ListNode *next y char data[], y al asignar se reserva de una vez sizeof(* node) + data_size
list_prepend recibe los datos y su tamaño, y los copia a node->data con memcpy
- Este enfoque ubica
next y los datos reales cerca en memoria, reduciendo los problemas de asignación y caché del enfoque con void *
- A cambio, el llamador debe pasar
data_size
- Si se quiere evitar
memcpy, list_alloc_front puede devolver un puntero al área de datos del nodo para que el llamador inicialice esa memoria directamente
- La alineación, el padding y el cálculo de tamaño del miembro
data son otro tema, por lo que el ejemplo no los trata en detalle
Nivel 3: añadir información de tipo con union
- La técnica clave es definir
List(type) como un union, poniendo juntos el head real de la lista y un puntero para información de tipo
#define List(type) union { \
ListNode *head; \
type *payload; \
}
payload no se usa en tiempo de ejecución y proporciona información de tipo en tiempo de compilación
- Como se usa
union, payload no consume memoria adicional
- Se pueden crear listas por tipo como
List(Foo) foo_list o List(int) int_list
Verificación de tipos con el operador ternario
- La macro
list_prepend llama a la función interna _list_prepend y, mediante el operador ternario, hace coincidir los tipos de item y (list)->payload
#define list_prepend(list, item) \
_list_prepend(&((list)->head), \
(1 ? (item) : (list)->payload), \
sizeof(*(list)->payload))
- Si los dos tipos candidatos del operador ternario no coinciden, el compilador emite un error de incompatibilidad de tipos
- Por ejemplo, si se pasa un
Bar * a List(Foo), Clang muestra un error de incompatibilidad entre los tipos de puntero Foo * y Bar *
- La misma macro también pasa automáticamente el tamaño del tipo almacenado con
sizeof(*(list)->payload)
- El trabajo real lo realiza una función interna genérica como
_list_prepend(ListNode **head, void *data, size_t data_size)
Usar __typeof__() para el tipo de retorno
- Cuando una función genérica debe devolver un puntero a los datos internos, se puede usar
__typeof__() para convertir el valor de retorno void * al tipo de payload
#define list_alloc_front(list) \
(__typeof__((list)->payload))_list_alloc_front(&(list)->head, sizeof(*(list)->payload))
__typeof__() es compatible con Clang, GCC y MSVC 19.39 o superior
__typeof__() era una extensión opcional hasta su inclusión en el estándar C23
- En compiladores sin
__typeof__(), como MSVC anterior a 19.39, se puede usar verificación de tipos basada en el operador ternario
- También es posible lograr retornos seguros en tipos mediante el método de asignación con
payload, pero se omiten los detalles de implementación
Enfoque anterior y advertencias sobre definición
- El enfoque anterior hacía una conversión de
_list_prepend a un tipo de puntero a función que incluía __typeof__((list)->payload) antes de llamarla
- Llamar a un puntero a función convertido de tipo es técnicamente comportamiento indefinido, aunque en compiladores y plataformas modernos se trata como algo que en la práctica no causa problemas
- El enfoque actual induce el error mediante coincidencia de tipos con el operador ternario, en vez de convertir punteros a función
Problema al pasar List(Foo) como argumento
- El compilador de C puede no considerar del mismo tipo dos definiciones de
List(Foo) con la misma estructura
List(Foo) a;
List(Foo) b = a; // error
- Si se define
void my_function(List(Foo) list) como argumento de función y se llama my_function(a), también puede aparecer un error de tipos incompatibles
- La solución es asignarle un nombre al tipo con
typedef
typedef List(Foo) ListFoo;
ListFoo a;
ListFoo b = a; // ok
void my_function(ListFoo list);
my_function(a); // ok
- Para variables locales se puede seguir usando la forma
List(Foo) local_foo_list
- En GCC 15 y en Clang de fines de 2025, por un cambio de reglas, los tipos estructuralmente idénticos con el mismo nombre de etiqueta se tratarán como el mismo tipo
Aplicable también a estructuras de datos más allá de listas
- La misma técnica puede aplicarse no solo a listas, sino también a varias estructuras de datos como maps, arrays o árboles binarios
- También puede extenderse a estructuras de datos que requieren varios tipos relacionados
- Por ejemplo, un hash map puede poner dentro del
union la estructura interna, el tipo de clave y el tipo de valor
#define Map(key_type, value_type) union { \
MapInternal map; \
key_type *key; \
value_type *value; \
}
- stb_ds.h también es un ejemplo de estructuras de datos genéricas con seguridad de tipos, pero como sus arrays y maps usan arrays de C, algunos errores de tipo se detectan en el momento de asignar el array, no cuando se pasa el valor
2 comentarios
¿No sería más simple usar Zig?
Opiniones de Hacker News
En el código de nivel 2,
uint64_t data[];es incorrecto para tipos cuyos requisitos de alineación son mayores que los deuint64_t, y desperdicia espacio para tipos con requisitos menores. Por ejemplo, eso ocurre con el ABI ilp32 en arquitecturas de 64 bits.El código de nivel 3 debería ser
int main() { List(Foo) foo_list = {NULL};.Como no hay
typeof, si se recurre a un rodeo no se puede devolver nada, y como==es simétrico, ese rodeo también permite errores relacionados conconst.payloadtampoco se puede omitir de forma segura, porque es necesario para conocer el tamaño correcto. Debería ser posible agregar unint32_taList(int64_t), pero no se puede saber elsizeofde eseint32_t. A este código todavía le faltan bastantes piezas para funcionar correctamente.Actualmente, los genéricos en C tienen dos grandes limitaciones. Primero, el enfoque de delegar en una vtable tiene funcionalidad limitada porque una estructura no puede contener macros, solo funciones. Segundo, para evitar overhead hay que delegar en una vtable externa, y para eso hay que declarar por adelantado todos los tipos que vayan a usar la vtable.
Lo mejor que he encontrado hasta ahora fue dejar solo declaradas, pero no definidas, funciones
staticen un header de avance que declara los typedef. De hecho, GCC y Clang difieren en la etapa en la que emiten la advertencia “undefined static” cuando no se incluye el header de un tipo específico en alguna unidad de traducción.Por ejemplo, basta pensar en una función que reciba
struct SizedBuffer {void *p; size_t len;};ostruct BoundedBuffer {void *begin; void *end;};provenientes de headers distintos, y también sus respectivas versionesconst.Debido al problema de que, para delegar en una vtable externa, hay que declarar por adelantado todos los tipos que usarán esa vtable, en el proyecto Apache Clownfish en el que participé hace tiempo incluso construimos un compilador para eso.
Al principio parseábamos archivos
.h, pero al final nos pareció mejor crear un pequeño lenguaje de headers llamado.cfh, “Clownfish Header”.Generábamos código como este para llamar a la versión
CharBufdel métodoClonedefinido en la clase padreObj:typedef cfish_CharBuf*(*CFISH_CharBuf_Clone_t)(cfish_CharBuf* self);extern uint32_t CFISH_CharBuf_Clone_OFFSET;static inline cfish_CharBuf*CFISH_CharBuf_Clone(cfish_CharBuf* self) {const CFISH_CharBuf_Clone_t method= (CFISH_CharBuf_Clone_t)cfish_obj_method(self,CFISH_CharBuf_Clone_OFFSET);return method(self);}Se usaba así:
cfish_CharBuf *charbuf = cfish_CharBuf_new();cfish_CharBuf *clone = CFISH_CharBuf_Clone(charbuf);El objetivo de Clownfish era ofrecer un modelo de objetos de mínimo común denominador para bindings de varios lenguajes dinámicos, y los archivos
.cfhtambién se usaban para derivar tipos para los lenguajes de binding. Aun así, la cantidad de código repetitivo generado para evitar el problema señalado era realmente absurda.Por eso, casi todo el mundo prefiere renunciar a la seguridad de tipos y simplemente usar casts a
void*en el destino de la llamada.https://github.com/apache/lucy-clownfish
En C,
int main()no significa que no reciba argumentos, sino que recibe un número desconocido de argumentos. Para indicar que no recibe argumentos hay que escribirint main(void). Es algo que quienes usan C++ suelen olvidar.Sería bueno que
unionpudiera extenderse de forma aditiva. Es decir, que un tipo pudiera declararse a sí mismo como parte de la misma union que otros tipos, sin tener que declarar de antemano en un solo lugar todos los tipos posibles.malloc(sizeof(*node) + data_size);también puede ser problemático por el padding. El tamaño calculado puede quedar demasiado pequeño.No estoy de acuerdo.
Con el trick#0 mencionado en el artículo llegué a crear todo un dialecto de C. Por ejemplo, un heap binario genérico está en https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h.
La sintaxis es algo pesada, pero tiene una gran ventaja: lo que obtienes al final es una estructura C normal, simple, predecible y fácil de optimizar. Es código que el compilador se devora como si nada.
Cualquier otro enfoque termina necesitando
void*y cálculos de tamaño de memoria en runtime, y de todos modos también hay que definir macros.Si hubiera usado un heap binario genérico, quizá habría ponderado las opciones de otra manera. También mencioné este punto en una nota al pie.
Como cada instancia se monomorfiza, el compilador tiene más margen de optimización, y no hace falta pagar costos en runtime por tamaños variables. Al ser de tamaño fijo, también se puede poner una estructura genérica en el stack.
Al menos dos de los problemas que menciona el autor se pueden esquivar. Los nombres pueden resolverse con una macro simple de name mangling, cambiando de
Bar_func(args…)afunc(Bar)(args…). La expansión del binario puede reducirse en parte usando símbolos débiles, para que el linker elimine duplicados de funciones compartidas entre unidades de traducción.Los contenedores genéricos de tipos puntero tienen otros problemas, pero se pueden esquivar con typedefs o alias de tipo.
En C, las estructuras de datos intrusivas siguen siendo más cómodas, pero son dolorosas de manejar en el debugger.
El casteo de tipos de función asume que un tipo de puntero a elemento, por ejemplo
Foo*, tiene la misma representación quevoid*, pero el estándar de C no garantiza eso. En términos del estándar, los dos tipos no son “compatibles”.Por lo tanto, llamar a una función con el tipo convertido es comportamiento indefinido. Incluso si las representaciones de los punteros coinciden por casualidad, también afecta el análisis de aliasing del compilador. Sobre esto, también vale la pena ver [0].
Pareciera que castear funciones con distintos tipos de argumentos es el núcleo de la seguridad de tipos en las llamadas genéricas, pero no sé si esto es algo que se pueda corregir.
https://news.ycombinator.com/item?id=44421185
Si uno quiere “C con genéricos”, ¿no sería mejor no dar tantas vueltas y simplemente usar C++?
Dicho eso, para proyectos nuevos sí se pueden fijar estándares y expectativas para usar C++, y de hecho lo hacemos, definiendo un
stdespecífico como objetivo.Veo bastante seguido esta actitud en Hacker News, y se siente cercana a un “mejora tus habilidades”. Creo que acá hace falta mucho más contexto.
Fue realmente decepcionante que Microsoft se apartara de la postura de que “C++ es el futuro”, incluso después de su nuevo entusiasmo por Linux y el software libre y de código abierto.
https://herbsutter.com/2012/05/03/reader-qa-what-about-vc-an...
https://devblogs.microsoft.com/cppblog/c11-and-c17-standard-...
Hoy eso ya no importa tanto, porque por las regulaciones gubernamentales y de ciberseguridad Microsoft tiene una nueva política para C y C++.
https://azure.microsoft.com/en-us/blog/microsoft-azure-secur...
https://blogs.windows.com/windowsexperience/2024/11/19/windo...
Es un truco genial. Ya lo estoy usando en mi propia biblioteca experimental: https://github.com/uecker/noplate/blob/main/src/list.h
Es el enfoque en el que, en vez de meter los datos dentro del nodo como ahora, se mete la estructura del nodo dentro de los datos y, como efecto adicional, un objeto puede pertenecer a varios contenedores.
Hay que tener cuidado con la parte que dice que “los tipos estructuralmente idénticos se consideran el mismo tipo gracias a un cambio de reglas en GCC 15 y Clang de fines de 2025”.
En las nuevas reglas, lo único que se considera el mismo tipo son las uniones con etiqueta, y deben tener la misma estructura y la misma etiqueta.
El macro
List(T)tendría que cambiar para generar una etiqueta distinta para cadaT. Para tipos simples de una sola palabra es fácil con##, pero con algo apenas más complejo, como un puntero achar, es decir, un string, se vuelve imposible.Claro que se podría obligar a hacer typedef de todos los tipos antes de usarlos en
List, pero eso reduce mucho la generalidad.typedef char *str;List(str) my_list_of_str;List(str) tokenize(str input) {...}Creo que el término habitual para un “miembro que no hace nada y solo conserva el tipo” es type witness. Pero hay mucha menos literatura sobre type witness de la que esperaba.
Lo vi sobre todo en Haskell, y también lo usé en Scala para imitar jerarquías de tipos que no existen en el sistema de tipos real.
En cierto sentido, este truco con union también se parece a un phantom type, porque el tipo auxiliar en realidad no se usa para nada.
También está el enfoque que se usa en el kernel de Linux: embeber
struct list_head, que contiene la información de la lista, dentro de la estructura específica de cada tipo.https://kernelnewbies.org/FAQ/LinkedLists
LIST_HEAD_INITeINIT_LIST_HEADson confusos.Si tengo que hacer esto, preferiría usar directamente templates de C++.
En D se hace así:
struct ListNode(T) {ListNode* next;T data;}T!int node;¿Por qué sufrir con el preprocesador de C? Usar macros del preprocesador es como usar un martillo en vez de una pistola de clavos para carpintería de acabado. La pistola de clavos es 10 veces más rápida, clava los clavos con precisión siempre y no deja marcas en forma de media luna en la pieza