wandres.dev
ESPECIALISTA · Enums y typedef

Enums en C23: el tipo subyacente fijo

Durante cuarenta años el compilador eligió en secreto el entero que respaldaba cada enum. C23 te deja fijarlo, y con él el tamaño, el rango y el ABI. Además, cómo usar enums para modelar estados con exhaustividad verificada.

⏱ 16 min

El enum fue siempre el tipo más honesto y más ambiguo de C: honesto porque pone nombres donde si no habría números sueltos; ambiguo porque el entero que lo respalda lo elegía el compilador en privado, y ese secreto se filtraba al sizeof, al layout de tus structs y al ABI de tus bibliotecas. C23 cierra el agujero con una anotación de dos caracteres —los dos puntos y un tipo— y, de paso, asciende al enum a herramienta legítima para modelar estados.

🎯 Al terminar esta lección sabrás
  • Entender qué quedaba sin especificar en el enum anterior a C23 y por qué dolía en sistemas.
  • Fijar el tipo subyacente y saber qué garantiza exactamente esa fijación.
  • Usar enums de tamaño conocido en protocolos, structs empaquetadas y fronteras de ABI.
  • Modelar máquinas de estados con exhaustividad verificada por el compilador.

El entero que el compilador elegía por ti

Hasta C17 la norma decía dos cosas que casi nadie leía junto. Primera: cada enumerador —cada nombre de la lista— tiene tipo int, sin excepción. Segunda: el tipo enumerado en sí es compatible con char, con algún tipo entero con signo o con algún tipo entero sin signo, y la elección es definida por la implementación, con la única condición de que pueda representar todos los valores declarados.

Lee otra vez esa segunda frase. Significa que sizeof(enum Color) podía valer 1, 2, 4 u 8 según el compilador, la plataforma y las banderas, y que tu código no tenía forma portable de saberlo ni de exigirlo. La consecuencia práctica es que un enum era un tipo perfectamente seguro dentro de una unidad de traducción y una fuente de sorpresas en cuanto cruzaba una frontera.

/* C17 y anteriores */
enum Color { ROJO, VERDE, AZUL };

/* sizeof(enum Color) puede ser 1 con -fshort-enums (el defecto en ARM EABI)
   y 4 en x86-64 con GCC. El mismo header, dos layouts distintos. */
struct Pixel { enum Color c; unsigned char alfa; };

Y había un segundo límite, más rígido: como los enumeradores eran int, declarar un valor fuera del rango de int era una violación de restricción. No podías escribir MASCARA_TOTAL = 0xFFFFFFFF en un enum portable, aunque fuese exactamente la constante que tu registro de hardware necesitaba.

⚠️
-fshort-enums es un ABI, no una optimización

GCC y Clang ofrecen -fshort-enums, que reduce cada enum al entero más pequeño que lo represente. En ARM EABI está activo por defecto. Compilar dos objetos del mismo proyecto con y sin esa bandera produce dos interpretaciones incompatibles del mismo header: una struct con distinto tamaño y distintos desplazamientos, enlazada sin una sola advertencia. Es la clase de fallo que se manifiesta como corrupción de memoria a kilómetros del origen.

Fijar el tipo subyacente

La novedad más esperada de C23 en este terreno es una sintaxis mínima: dos puntos y un tipo entero tras el nombre del enum.

#include <stdint.h>

enum Color : uint8_t { ROJO, VERDE, AZUL };          /* 1 byte, garantizado */
enum Opcode : uint16_t { NOP = 0x0000, LOAD = 0x0100 };
enum Mascara : uint32_t { TODO = 0xFFFFFFFFu };      /* ya cabe: no es int */

Fijar el tipo subyacente cambia tres cosas a la vez, y conviene separarlas porque se confunden con facilidad. Primera: el tipo enumerado pasa a ser compatible con el tipo fijado, así que su tamaño, su alineación y su rango son los de ese tipo, en todo compilador y toda plataforma. Segunda: los enumeradores dejan de ser int y adoptan el tipo fijado, lo que altera la promoción de enteros en las expresiones donde aparecen. Tercera: el tipo queda completo desde la propia declaración, así que puedes declararlo sin cuerpo y definir la lista más adelante, algo imposible antes.

/* declaracion adelantada: legal en C23 porque el tipo ya se conoce */
enum Estado : uint8_t;

struct Sesion { enum Estado s; };   /* usable aunque la lista aun no exista */

enum Estado : uint8_t { INACTIVO, CARGANDO, LISTO, CERRADO };

C23 también relaja el enum sin tipo fijo: ahora un enumerador puede tomar valores que no caben en int, y el compilador escoge un tipo capaz de representarlos. Es una mejora real, pero es la solución perezosa: te quita el error de compilación sin devolverte el control. Si el enum va a vivir dentro de una struct, de un fichero o de un paquete de red, fija el tipo.

🔒

Con tipo fijo

Tamaño, alineación y rango garantizados. Declaración adelantada posible. Los enumeradores tienen el tipo fijado.

🎲

Sin tipo fijo

El compilador elige. Portable solo si nunca observas el tamaño ni el layout. Los enumeradores son int salvo que no quepan.

Fijar el tipo es fijar el contrato, no el tamaño

La lectura superficial de esta novedad es que ahora puedes controlar el sizeof. La lectura correcta es que C23 convierte una propiedad definida por la implementación en una propiedad definida por ti, y esa transferencia de autoridad es el patrón que recorre toda la revisión. Un enum sin tipo fijo no es un tipo con tamaño desconocido: es un tipo cuyo contrato depende de quién lo compile, lo cual significa que no es un contrato en absoluto. En cuanto ese enum aparece en una struct pública, en la firma de una función exportada o en un formato serializado, has publicado una API cuyo significado varía con las banderas del que la consume. Fijar : uint8_t no ahorra tres bytes: convierte una interfaz ambigua en una interfaz verificable, y con ello vuelve legítimo usar enums donde antes la disciplina obligaba a bajar a uint8_t desnudo y perder todos los nombres. El resultado es el mejor de los dos mundos —representación exacta y vocabulario del dominio— y es la razón de que esta sea, para quien escribe bibliotecas o toca hardware, la novedad de C23 que más código permite reescribir mejor.

Enums en protocolos, registros y fronteras de ABI

Con el tipo fijado, el enum se vuelve seguro precisamente donde antes estaba prohibido: en cualquier layout observable desde fuera.

/* Cabecera de un protocolo binario: cada campo con tamano exacto */
enum TipoMensaje : uint8_t { MSG_HOLA = 1, MSG_DATOS = 2, MSG_ADIOS = 3 };
enum Bandera     : uint8_t { F_NINGUNA = 0, F_URGENTE = 1u << 0, F_CIFRADO = 1u << 1 };

struct Cabecera {
    enum TipoMensaje tipo;    /* 1 byte, garantizado */
    enum Bandera     banderas;/* 1 byte, garantizado */
    uint16_t         longitud;
};
static_assert(sizeof(struct Cabecera) == 4, "layout del protocolo");

Ese static_assert es el complemento natural: fijas el tipo y después verificas la consecuencia. Un cambio futuro que rompa el layout ya no llega a producción, se detiene en la compilación.

Un aviso importante: fijar el tipo subyacente no convierte al enum en un tipo validado. C sigue permitiendo asignar cualquier entero del rango subyacente a una variable de tipo enumerado, así que un tipo que valga 200 es perfectamente legal aunque no corresponda a ningún enumerador. El enum documenta la intención y da nombres; la validación de los datos que entran de fuera sigue siendo tuya, y toda entrada externa debe pasar por una función que compruebe pertenencia antes de tratarla como estado.

static bool tipo_valido(uint8_t crudo, enum TipoMensaje *fuera) {
    switch ((enum TipoMensaje)crudo) {
    case MSG_HOLA:
    case MSG_DATOS:
    case MSG_ADIOS:
        *fuera = (enum TipoMensaje)crudo;
        return true;
    }
    return false;    /* valor ajeno al conjunto: se rechaza aqui, no mas adentro */
}

Fíjate en el reparto de responsabilidades: el tipo fijo garantiza que el byte llega intacto, y esta función garantiza que su valor pertenece al dominio. Ninguna de las dos cosas sustituye a la otra, y confundirlas es el origen de la mayoría de los fallos de análisis de protocolos.

Modelar estados con exhaustividad

El segundo uso serio del enum es representar el conjunto cerrado de estados de una máquina. Aquí la ganancia no es el tamaño sino el trabajo que le puedes delegar al compilador.

enum Estado : uint8_t { INACTIVO, CONECTANDO, ACTIVO, CERRANDO, CERRADO };

static enum Estado avanzar(enum Estado s, bool ok) {
    switch (s) {                       /* sin 'default': eso es intencional */
    case INACTIVO:   return ok ? CONECTANDO : INACTIVO;
    case CONECTANDO: return ok ? ACTIVO     : CERRADO;
    case ACTIVO:     return ok ? ACTIVO     : CERRANDO;
    case CERRANDO:   return CERRADO;
    case CERRADO:    return CERRADO;
    }
    return CERRADO;                    /* inalcanzable si el valor es valido */
}

La omisión deliberada del default es la técnica clave. Con -Wswitch —incluido en -Wall— el compilador avisa de cualquier enumerador no cubierto. El día que añadas REINTENTANDO a la lista, cada switch incompleto del proyecto se convierte en una advertencia que te señala exactamente dónde falta lógica. Poner default: apaga ese aviso para siempre y regala la única forma de exhaustividad que C ofrece; por eso el return de seguridad va después del switch, no dentro de él.

stateDiagram-v2
[*] --> INACTIVO
INACTIVO --> CONECTANDO : solicitud aceptada
CONECTANDO --> ACTIVO : handshake correcto
CONECTANDO --> CERRADO : fallo
ACTIVO --> CERRANDO : peticion de cierre
CERRANDO --> CERRADO : drenaje completo
CERRADO --> [*]
💡
Un enum por eje, no un enum para todo

Cuando un estado combina dimensiones independientes —conectado o no, cifrado o no, con datos pendientes o no— la tentación es enumerar el producto cartesiano y acabar con quince nombres. Es una trampa: los estados imposibles se cuelan y la exhaustividad deja de significar nada. Modela cada eje con su propio enum o con un conjunto de banderas de tipo fijo, y reserva el enum de estados para el eje que de verdad es una secuencia. Un switch de cinco casos que el compilador verifica vale más que uno de quince que nadie lee.

📝
Lo esencial de los enums en C23

Antes de C23 el tipo entero que respalda un enum lo elegía la implementación, y con él el tamaño y el layout; los enumeradores eran siempre int, lo que impedía constantes fuera de ese rango. C23 permite fijar el tipo subyacente con enum E : uint8_t, lo que garantiza tamaño, alineación y rango, cambia el tipo de los enumeradores y hace posible la declaración adelantada. Eso vuelve seguro usar enums en protocolos, structs públicas y fronteras de ABI, siempre acompañados de static_assert sobre el layout. C no valida el rango: los datos externos siguen necesitando comprobación. Y para máquinas de estados, un switch sin default con -Wall es la única exhaustividad que el lenguaje te regala.

⚔️ Fija el tipo y cierra los estados
  1. Declara el mismo enum con y sin tipo subyacente fijo y compara sizeof con -fshort-enums y sin él. Explica qué cambia y qué no.
  2. Escribe un enum con un enumerador de valor 0xFFFFFFFF y comprueba qué dice el compilador en modo C17 frente a C23.
  3. Construye una cabecera de protocolo con dos enums de tipo fijo y valídala con static_assert sobre sizeof y sobre los desplazamientos de cada campo.
  4. Implementa una máquina de estados sin default en el switch, compila con -Wall, añade después un estado nuevo y observa cuántas advertencias aparecen.
  5. Adapta tipo_valido a un enum de banderas cuyos valores se combinan con OR, y explica por qué ahí la validación no puede escribirse como un switch.