wandres.dev
TÁCTICO · El preprocesador a fondo

Compilación condicional: guardas, plataformas y disciplina

Las guardas de cabecera y pragma once resuelven el mismo problema con garantías distintas; la detección de plataforma y compilador tiene reglas que casi nadie respeta; y llenar el código de ifdef multiplica en secreto el número de programas que crees estar escribiendo. Esta lección fija las reglas y define dónde deben vivir las condicionales.

⏱ 18 min

La compilación condicional es la herramienta con la que C sobrevive a cuarenta años de sistemas operativos, compiladores y arquitecturas incompatibles entre sí. También es la que convierte un fichero legible en un laberinto donde nadie sabe qué código llega realmente al compilador. Las reglas técnicas son pocas y precisas; lo difícil no es aprenderlas, es aceptar la disciplina arquitectónica que impide que las condicionales se extiendan por todo el proyecto.

🎯 Al terminar esta lección sabrás
  • Escribir guardas de cabecera correctas y valorar #pragma once con sus garantías reales.
  • Distinguir #ifdef, defined y #if con expresión, y evitar el fallo silencioso de los identificadores no definidos.
  • Detectar plataforma y compilador con las macros correctas y en el orden correcto.
  • Confinar las condicionales a los bordes del proyecto en lugar de esparcirlas por la lógica.

Guardas de cabecera frente a pragma once

Si una cabecera se incluye dos veces en la misma unidad de traducción —lo habitual en cuanto hay dependencias cruzadas— sus declaraciones de tipos se repiten y el programa no compila. La solución clásica es una guarda: un identificador que la primera inclusión define y que las siguientes usan para saltarse todo el contenido.

#ifndef PROYECTO_MODULO_RED_H
#define PROYECTO_MODULO_RED_H

/* declaraciones */

#endif /* PROYECTO_MODULO_RED_H */

Dos detalles importan. El nombre debe llevar el proyecto y la ruta, porque el preprocesador no tiene espacios de nombres y dos cabeceras con la guarda UTILS_H en proyectos distintos hacen que la segunda desaparezca en silencio, sin ningún diagnóstico. Y el identificador no debe empezar por guion bajo seguido de mayúscula ni contener doble guion bajo: esos nombres están reservados a la implementación, y aunque en la práctica funcionen, colisionar con una macro interna de la biblioteca estándar produce averías muy difíciles de diagnosticar.

La alternativa #pragma once cabe en una línea, no puede colisionar y no exige elegir nombre. No está en el estándar, pero la soportan GCC, Clang, MSVC y prácticamente cualquier compilador vivo. Su diferencia real con la guarda es de identidad: la guarda identifica el contenido por un nombre lógico, mientras que #pragma once identifica el fichero por su ubicación en el sistema de archivos. Si el mismo fichero llega por dos rutas distintas —enlaces simbólicos, copias en árboles de dependencias, montajes de red— el compilador puede considerarlo dos ficheros y procesarlo dos veces.

💡
Qué usar en la práctica

Para código propio de aplicación, #pragma once es la elección razonable: menos ruido, cero colisiones, soportado en todas partes. Para cabeceras públicas de una biblioteca que se distribuye y se instala en árboles ajenos, usa guardas con nombre cualificado por proyecto, que son estándar y no dependen de cómo el usuario reorganice los ficheros. Poner las dos cosas a la vez es una práctica extendida y perfectamente defendible: cuesta una línea y cubre los dos modos de fallo. Lo que nunca debes hacer es escribir la guarda y olvidar el #endif, o cerrar con #endif un #if distinto del que creías.

Las tres formas de preguntar, y la que falla en silencio

#ifdef X pregunta si X está definida, sin mirar su valor. #if defined(X) hace lo mismo pero puede combinarse con operadores lógicos. Y #if EXPRESION evalúa una expresión constante entera con la aritmética más ancha disponible.

La trampa está en la tercera forma. En una expresión de #if, cualquier identificador que no sea una macro definida se sustituye por cero y la compilación continúa sin protestar. Un error tipográfico en el nombre de una opción de configuración no produce ningún mensaje: produce silenciosamente la rama contraria.

#define HABILITAR_CACHE 1

#if HABILITAR_CACH        /* erratra: vale 0, se toma la rama falsa */
    usar_cache();
#endif

La defensa es compilar con -Wundef, que convierte ese uso en advertencia, y preferir #if defined(X) cuando lo que quieres saber es si algo existe. Recuerda además que el operador defined solo tiene sentido dentro de #if y #elif, y que C23 añade #elifdef y #elifndef para escribir cadenas de condiciones sin anidar.

Detectar plataforma y compilador sin adivinar

Ninguna macro dice el nombre del sistema operativo: hay que consultar las que cada implementación define. El orden importa, porque varias familias de compiladores se declaran compatibles entre sí definiendo las macros de otras.

🐧

Sistema operativo

__linux__ en Linux, _WIN32 en Windows —también en 64 bits—, __APPLE__ junto con TARGET_OS_MAC, y __unix__ como paraguas amplio y poco preciso.

🛠️

Compilador

Comprueba __clang__ antes que __GNUC__, porque Clang define ambas; y _MSC_VER para MSVC, que no define ninguna de las anteriores.

📐

Versión del estándar

__STDC_VERSION__ vale al menos 202311L en C23. Es la forma correcta de activar novedades del lenguaje sin preguntar por el compilador.

🔍

Capacidades

__has_include es estándar desde C23 y pregunta por una cabecera concreta; __has_c_attribute hace lo propio con los atributos.

Ese último punto es el cambio de mentalidad que trae C23. Preguntar por el compilador o por el sistema es una aproximación indirecta: lo que de verdad te interesa saber es si una cabecera, un atributo o una función existen. __has_include permite preguntarlo directamente, y el código deja de romperse cuando aparece una versión nueva o un compilador que no estaba en tu lista.

#if defined(__clang__)
    #define FORZAR_INLINE __attribute__((always_inline)) inline
#elif defined(__GNUC__)
    #define FORZAR_INLINE __attribute__((always_inline)) inline
#elif defined(_MSC_VER)
    #define FORZAR_INLINE __forceinline
#else
    #define FORZAR_INLINE inline
#endif

#if __has_include(<threads.h>)
    #include <threads.h>
    #define TIENE_HILOS_C11 1
#endif

Fíjate en la rama final sin condición: toda cadena de detección debe terminar en un caso por defecto que compile, aunque sea con una versión menos eficiente. Un #error también es una salida legítima, y siempre preferible a que la macro quede sin definir y el fallo aparezca doscientas líneas después.

📝
C23 incorpora #warning al estándar

#error detiene la traducción con un mensaje propio y existe desde ANSI C. Su hermano #warning, que avisa sin detener, llevaba décadas implementado en GCC y Clang como extensión y solo en C23 ha entrado en el estándar. La pareja permite algo valioso en una cadena de detección: fallar de forma ruidosa cuando la plataforma es realmente inviable, y advertir con claridad cuando has caído en la rama genérica y el programa funcionará, pero más despacio o con menos garantías. Un #warning que dice qué ruta se ha tomado ahorra horas de investigación cuando alguien compila el proyecto en un sistema que tú nunca probaste.

flowchart TB
A[necesidad de variar el comportamiento] --> B{la diferencia afecta a un tipo o a una llamada del sistema}
B -->|Si| C[ficheros separados por plataforma]
B -->|No| D{ambas ramas compilan en todas partes}
D -->|Si| E[usar if normal con constante de configuracion]
D -->|No| F[condicional del preprocesador confinada en la cabecera]
C --> G[el sistema de construccion elige el fichero]
G --> H[la logica queda limpia de condicionales]
E --> H
F --> H
style H fill:#a6e3a1,color:#11111b

No llenar el código de condicionales

El principio arquitectónico es simple de enunciar y exige constancia: las condicionales deben vivir en los bordes del proyecto, nunca en su lógica. Hay tres técnicas que lo consiguen, en orden de preferencia.

La primera es separar por ficheros. Declara una interfaz portable en una cabecera sin una sola condicional, escribe una implementación por plataforma en ficheros distintos y deja que el sistema de construcción elija cuál compila. Meson y CMake seleccionan fuentes por plataforma en una línea. El resultado es que cada fichero de implementación se lee de arriba abajo como un programa normal.

La segunda es concentrar las condicionales en una única cabecera de configuración que traduzca las diferencias a macros propias con nombre semántico. El resto del proyecto usa PROYECTO_TIENE_MMAP y nunca vuelve a mencionar __linux__. Cuando aparezca una plataforma nueva, hay un solo fichero que tocar.

La tercera, y la más infravalorada, es usar un if normal en lugar de un #if cuando ambas ramas compilan en todas las plataformas. Con una constante conocida en tiempo de compilación, el optimizador elimina la rama muerta y el código generado es idéntico al de la condicional del preprocesador. La diferencia es decisiva: con if, las dos ramas pasan por el analizador sintáctico y por la comprobación de tipos en todas las compilaciones, así que un error en la rama que hoy no se usa aparece hoy y no dentro de seis meses en la máquina de otro.

/* cabecera de configuracion, el unico sitio con condicionales */
#if defined(__linux__) || defined(__APPLE__)
    #define PROYECTO_TIENE_MMAP 1
#else
    #define PROYECTO_TIENE_MMAP 0
#endif

/* codigo de aplicacion: sin una sola directiva */
if (PROYECTO_TIENE_MMAP && tam >= UMBRAL_MMAP)
    return cargar_mapeado(ruta, tam);
return cargar_leyendo(ruta, tam);
Cada condicional duplica el numero de programas que nadie ha compilado

La consecuencia que casi nunca se enuncia es combinatoria. Un fichero con diez condicionales independientes no describe un programa: describe hasta mil veinticuatro programas distintos, de los cuales tu integración continua compila dos o tres y tú lees ninguno, porque lo que tienes delante es la superposición de todos ellos. De ahí salen los defectos más caros de C y los más difíciles de atribuir: la rama que solo se activa en un sistema minoritario y que lleva dos años sin compilar, la macro que en cierta combinación queda sin definir y se evalúa como cero, la variable declarada dentro de un #ifdef y usada fuera. Nada de eso lo detecta el compilador, porque el compilador solo ve la variante concreta que le tocó traducir; el preprocesador ya eliminó las demás antes de que existiera un programa que analizar. Esta es exactamente la razón por la que los lenguajes posteriores rechazaron el modelo: Rust exige que el código bajo cfg sea sintácticamente válido siempre y ofrece comprobación cruzada de características, y Zig sustituye la condicional textual por comptime, que opera sobre código ya analizado y tipado. La lección práctica para C no es evitar la compilación condicional, que es imprescindible, sino tratarla como lo que es: un multiplicador del espacio de configuraciones que hay que mantener deliberadamente pequeño. Menos condicionales, más arriba en la arquitectura, con menos interacciones entre ellas, y siempre que se pueda un if corriente en vez de un #if, para que el compilador siga viendo el programa entero.

⚔️ Audita las condicionales de un proyecto
  1. Escribe dos cabeceras con la misma guarda UTILS_H en directorios distintos, inclúyelas ambas y observa que la segunda desaparece sin diagnóstico.
  2. Introduce una errata en el nombre de una macro dentro de un #if y comprueba que solo -Wundef la delata.
  3. Escribe una cadena de detección para tres compiladores y verifica con -dM -E qué macros define realmente el tuyo.
  4. Usa __has_include para incluir threads.h solo si existe y define una macro de capacidad a partir de ello.
  5. Toma una función con dos #ifdef internos, reescríbela con una constante de configuración y un if normal, y compara el ensamblador con -O2 -S.