X-macros: metaprogramación desde una sola lista
La técnica clásica para que una única declaración genere el enum, la tabla de nombres, el serializador y el validador, sin que puedan desincronizarse jamás. Esta lección construye el patrón desde cero, lo extiende a descriptores de campos y delimita con precisión su coste en legibilidad y diagnósticos.
Todo proyecto en C acumula listas que deben mantenerse en paralelo: un enum de códigos de error y su tabla de mensajes, un conjunto de opciones y su analizador de línea de órdenes, unos campos y su serializador. La sincronización manual funciona hasta el día en que alguien añade un elemento en un sitio y no en los otros, y entonces el programa imprime el mensaje equivocado o escribe basura en un fichero. Las X-macros eliminan esa clase entera de defectos declarando la lista una vez y proyectándola tantas veces como haga falta.
- Construir el patrón de X-macro y entender por qué la definición y el uso deben ir separados.
- Generar un
enum, su tabla de nombres y su analizador desde una única declaración. - Extender la técnica a descriptores de campos para serialización y validación genéricas.
- Valorar su coste real en diagnósticos y depuración frente a la generación de código externa.
La lista única como fuente de verdad
La idea cabe en dos movimientos. Primero se declara una macro que enumera los elementos invocando sobre cada uno una macro auxiliar que todavía no existe. Después, en cada punto donde se necesita una proyección distinta de la lista, se define esa macro auxiliar, se expande la lista y se retira la definición.
#define LISTA_ERRORES \
X(OK, 0, "sin error") \
X(NO_EXISTE, 2, "el recurso no existe") \
X(PERMISO, 13, "permiso denegado") \
X(MEMORIA, 12, "memoria insuficiente")
La lista no genera nada por sí sola: es un patrón inerte a la espera de que alguien decida qué significa X. Ese es el mecanismo entero de la técnica y explica su nombre. El paso de retirar la definición con #undef no es opcional: si la macro auxiliar sobrevive, la siguiente proyección chocará con la anterior y el diagnóstico será desconcertante.
flowchart TB A[una sola lista declarada] --> B[proyeccion uno define X como constante de enum] A --> C[proyeccion dos define X como entrada de tabla] A --> D[proyeccion tres define X como rama de switch] B --> E[enum generado] C --> F[tabla de nombres generada] D --> G[funcion de conversion generada] E --> H[imposible que se desincronicen] F --> H G --> H style A fill:#89b4fa,color:#11111b style H fill:#a6e3a1,color:#11111b
Enum, tablas y conversiones desde una declaración
Con la lista anterior, las tres proyecciones habituales se escriben así. Cada una define X con una forma distinta, expande la lista y limpia.
typedef enum {
#define X(nombre, cod, msg) ERR_##nombre = cod,
LISTA_ERRORES
#undef X
} Error;
static const char *const mensajes[] = {
#define X(nombre, cod, msg) [ERR_##nombre] = msg,
LISTA_ERRORES
#undef X
};
const char *error_texto(Error e) {
switch (e) {
#define X(nombre, cod, msg) case ERR_##nombre: return msg;
LISTA_ERRORES
#undef X
}
return "codigo desconocido";
}
Error error_desde_texto(const char *s) {
#define X(nombre, cod, msg) if (strcmp(s, #nombre) == 0) return ERR_##nombre;
LISTA_ERRORES
#undef X
return ERR_MEMORIA;
}
Fíjate en cuánto trabajo hacen aquí los operadores de la lección anterior: ## construye el identificador ERR_PERMISO a partir del fragmento PERMISO, y # produce la cadena "PERMISO" sin que nadie la escriba. Añadir un error nuevo consiste en una sola línea en la lista, y las cuatro proyecciones se actualizan a la vez. Olvidar uno de los sitios deja de ser posible, porque ya no hay varios sitios.
Con inicializadores designados en la tabla, el índice de cada mensaje lo fija la propia constante del enum, así que la tabla sigue siendo correcta aunque los códigos no sean consecutivos ni estén ordenados. Y el switch sin rama por defecto sobre un enum completo permite que -Wswitch avise si algún día alguien añade una constante fuera de la lista.
Declara la lista en la cabecera del módulo, no en el fichero de implementación: así cualquier consumidor puede crear sus propias proyecciones —una tabla de traducciones, un mapa a códigos HTTP— sin tocar tu código. Y aunque la tradición llama X a la macro auxiliar, un nombre cualificado como ERR_X es preferible en cabeceras públicas: X es un identificador brevísimo y sin prefijo, exactamente el tipo de nombre que colisiona con el de otra biblioteca. Cuando una lista deba proyectarse con dos macros auxiliares distintas a la vez —por ejemplo, una para el primer elemento y otra para el resto—, dale a cada una su propio nombre en vez de anidar condicionales.
Descriptores de campos: serialización y validación
El salto cualitativo llega cuando la lista describe los campos de una estructura en lugar de constantes sueltas. Con el nombre del campo, su tipo y un descriptor de formato, la misma declaración genera la estructura, una tabla de metadatos y todos los recorridos genéricos que quieras.
#define CAMPOS_CONFIG \
C(int, puerto, "%d") \
C(unsigned, reintentos,"%u") \
C(const char *, host, "%s")
typedef struct {
#define C(tipo, campo, fmt) tipo campo;
CAMPOS_CONFIG
#undef C
} Config;
typedef struct { const char *nombre, *fmt; size_t desp; } CampoInfo;
static const CampoInfo campos[] = {
#define C(tipo, campo, fmt) { #campo, fmt, offsetof(Config, campo) },
CAMPOS_CONFIG
#undef C
};
void config_volcar(const Config *c, FILE *f) {
for (size_t i = 0; i < sizeof campos / sizeof campos[0]; i++)
fprintf(f, "%s=", campos[i].nombre); /* + despacho por fmt */
}
Lo que acabas de construir es una forma rudimentaria de reflexión: una tabla que en tiempo de ejecución describe la estructura campo a campo, con su nombre, su desplazamiento y su formato. Sobre ella se escribe una sola vez el volcado, el analizador del fichero de configuración, la comparación y el diagnóstico, en lugar de una versión por campo. Añadir una opción de configuración pasa a ser una línea.
La combinación con _Generic cierra el círculo cuando los tipos son heterogéneos: la lista aporta los nombres y los desplazamientos, y _Generic elige la función de lectura o escritura correcta para cada tipo sin que haya que anotar el formato a mano.
Una variante clásica evita incluso la macro de lista: se pone cada elemento en su propio fichero, y cada proyección lo incluye tras definir la macro auxiliar. Es más pesado, pero permite listas de miles de entradas sin líneas de continuación y con diagnósticos que apuntan a la línea correcta del fichero de datos.
Coste real, higiene y alternativas
La técnica no es gratis, y quien la vende como una victoria pura no ha mantenido código que la use.
Diagnósticos opacos
Un error dentro de una proyección se comunica sobre el texto expandido. El compilador señala la línea de la lista, no la del error real.
Depuración incómoda
El depurador no conoce el código generado; para leerlo hay que pasar por -E y formatear la salida a mano.
Navegación rota
Los índices del editor no encuentran ERR_PERMISO porque ese identificador no existe en ningún fichero: lo fabrica ##.
Fragilidad al crecer
Una lista con muchos parámetros por elemento y proyecciones que ignoran la mitad se vuelve ilegible con sorprendente rapidez.
De ahí salen tres reglas de higiene. Mantén cada proyección trivial: una línea por elemento, sin lógica; si necesitas condiciones, llama a una función normal desde la proyección. Retira siempre la macro auxiliar con #undef justo después de expandir. Y no metas en la lista más columnas de las que use al menos una proyección, porque cada parámetro extra se paga en todas.
La alternativa honesta es la generación de código externa: un script que lea un fichero de datos y escriba un .c de verdad, integrado en el sistema de construcción. Produce código legible, depurable y navegable, con diagnósticos que apuntan a líneas reales, a cambio de una dependencia de construcción y un paso más. La regla práctica que funciona es el tamaño: para listas de decenas de elementos y tres o cuatro proyecciones, las X-macros ganan porque no añaden nada al proceso de construcción; para cientos de elementos, esquemas anidados o proyecciones que necesiten lógica de verdad, el generador externo gana con claridad.
Lo que las X-macros resuelven no es un problema de C: es el problema de todo lenguaje que no puede hablar de sus propias declaraciones. Cuando escribes un enum, el compilador conoce perfectamente el nombre de cada constante —los tiene en su tabla de símbolos, los usa para diagnosticar— pero no existe ninguna construcción del lenguaje que te permita pedírselos. Esa información se destruye al final de la traducción, y por eso te ves obligado a escribirla otra vez a mano en forma de tabla de cadenas, aceptando desde el primer día que las dos copias se separarán. Las X-macros no añaden reflexión: invierten la dirección del problema. En vez de derivar la tabla del enum, que es imposible, derivan ambos de una tercera declaración anterior a los dos, y con ello convierten una obligación de disciplina humana en una propiedad estructural del programa. Ese es el patrón profundo que conviene llevarse, porque aparece muy por encima del preprocesador: siempre que dos artefactos deban coincidir y el lenguaje no pueda comprobarlo, la respuesta correcta no es más cuidado ni más revisiones, sino encontrar la declaración común de la que ambos puedan derivarse mecánicamente. Los derive de Rust, la reflexión estática de C++26 y los generadores de esquemas hacen exactamente eso con más comodidad y mejores mensajes; C lo hace con sustitución de texto y sin red. Pero la propiedad que se obtiene es idéntica y es la que importa: los dos artefactos ya no pueden desincronizarse porque han dejado de ser dos.
- Declara
LISTA_ERRORESy genera con ella elenum, la tabla de mensajes, la función de texto y la inversa desde cadena. - Añade un error nuevo tocando solo la lista y comprueba que las cuatro proyecciones lo reflejan sin más cambios.
- Compila con
-Euna de las proyecciones y lee el código realmente generado; mide cuánto tardas en localizar un error introducido a propósito. - Construye
CAMPOS_CONFIGcon tres campos, genera la estructura y la tabla de descriptores, y escribe un volcado genérico usandooffsetofy_Generic. - Quita un
#undefa propósito y estudia el diagnóstico resultante; luego mueve la lista a una cabecera y crea una proyección nueva desde otro fichero.