wandres.dev
SEMIDIÓS · Undefined Behavior

Los tres grises de la norma

Undefined, unspecified e implementation-defined no son grados de una misma escala: son contratos con estructura logica distinta. La prueba de las tres preguntas para clasificar cualquier construccion y saber exactamente que puedes asumir.

⏱ 17 min

La norma de C no tiene una sola categoría de incertidumbre: tiene tres, y confundirlas alimenta la mitad de las discusiones estériles sobre portabilidad. undefined, unspecified e implementation-defined no son tres intensidades de un mismo peligro. Son contratos con estructuras lógicas distintas, y solo uno de ellos le concede al compilador licencia para reescribir tu programa entero.

🎯 Al terminar esta lección sabrás
  • Distinguir las tres categorías por su definición normativa, no por intuición.
  • Saber qué puedes asumir, documentar y probar en cada caso.
  • Aplicar la prueba de las tres preguntas a cualquier construcción dudosa.
  • Reconocer las categorías menores que casi nadie nombra: locale-specific y el comportamiento no obligatorio.

Las tres definiciones, tal como están escritas

La norma se define a sí misma en su cláusula de términos, y las tres definiciones son cortas y quirúrgicas. Merecen leerse literalmente, porque cada palabra hace trabajo.

Comportamiento indefinido: el que corresponde al uso de una construcción no portable o errónea, o de datos erróneos, para el cual esta norma no impone requisitos. Esa última cláusula es la bomba. No dice que el resultado sea impredecible: dice que no hay requisitos, ni sobre el resultado, ni sobre la operación, ni sobre el resto del programa, ni sobre lo que ocurrió antes.

Comportamiento no especificado: aquel en el que la norma ofrece dos o más posibilidades y no impone ningún requisito sobre cuál se elige en cada instancia. El conjunto está acotado por escrito. El programa sigue teniendo significado; lo que no tienes es el derecho a saber cuál de los significados posibles obtendrás.

Comportamiento definido por la implementación: comportamiento no especificado en el que la implementación documenta la elección. Es exactamente la categoría anterior más una obligación de documentación. La norma obliga a que exista un documento; no obliga a que la elección te guste.

💥

Undefined

Conjunto de resultados no acotado. Sin requisitos de ningún tipo. El optimizador puede razonar a partir de la premisa de que jamás ocurre.

🎲

Unspecified

Conjunto acotado por la norma. Sin documentación obligatoria y sin obligación de consistencia entre dos apariciones.

🔧

Implementation-defined

Conjunto acotado y documentado. Puedes leer el manual, decidir si te sirve y escribir código que dependa de ello, pagando en portabilidad.

La estructura lógica, no la gravedad

La forma correcta de recordarlas no es una escala de peligro sino un árbol de decisión sobre tres propiedades: acotación, documentación y estabilidad.

flowchart TD
A[La norma acota el conjunto de resultados posibles] -->|No| B[Undefined behavior]
A -->|Si| C[La implementacion debe documentar su eleccion]
C -->|No| D[Unspecified]
C -->|Si| E[Implementation defined]
B --> F[Todo el programa pierde significado]
D --> G[Programa valido, resultado no portable ni estable]
E --> H[Programa valido, resultado consultable en el manual]
style B fill:#f38ba8,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Tres ejemplos canónicos, uno de cada categoría, para fijar la diferencia:

#include <limits.h>
#include <stdio.h>

int leer(const char *quien) { puts(quien); return 0; }

int main(void) {
    /* 1. INDEFINIDO: desbordamiento en aritmetica con signo.
          La norma no impone ningun requisito sobre el resultado
          ni sobre el resto del programa. */
    int a = INT_MAX;
    int b = a + 1;

    /* 2. NO ESPECIFICADO: orden de evaluacion de los argumentos.
          Solo hay dos ordenes posibles, ambos legales, y el
          compilador puede elegir uno distinto en cada llamada. */
    int c = leer("izquierda") + leer("derecha");

    /* 3. DEFINIDO POR LA IMPLEMENTACION: si char tiene signo,
          y cuantos bits ocupa un int. Documentado por cada
          compilador y estable dentro de esa plataforma. */
    printf("%zu %d\n", sizeof(int), (int)(char)-1);
    return b + c;
}

Fíjate en la asimetría real. El caso 2 produce un programa correcto cuya salida no puedes predecir: verás izquierda antes que derecha o al revés, pero verás las dos, y c valdrá cero en ambos casos. El caso 3 produce un programa correcto cuya salida puedes averiguar leyendo el manual de tu compilador. El caso 1 no produce un programa: produce un texto que el compilador tiene derecho a traducir a cualquier cosa.

⚠️
No especificado no significa estable

El error más común es tratar lo no especificado como si fuera definido por la implementación descubierto empíricamente. No lo es. La norma dice en cada instancia: el compilador puede evaluar los argumentos de izquierda a derecha en una llamada y de derecha a izquierda en la de la línea siguiente, según le convenga a la asignación de registros. Medir el comportamiento una vez y grabarlo en la cabeza es exactamente el hábito que produce código que falla al cambiar de nivel de optimización.

La prueba de las tres preguntas

Ante cualquier construcción sospechosa, tres preguntas la clasifican sin necesidad de buscar en el índice de la norma.

  1. ¿Puedo enumerar los resultados posibles? Si la respuesta honesta es que no —porque la memoria podría estar corrupta, porque el objeto ya no existe, porque el valor no pertenece al dominio del tipo—, es comportamiento indefinido. La acotación es la frontera.
  2. ¿Está obligado el compilador a decirme cuál eligió? Si el conjunto está acotado pero nadie tiene que documentar la elección, es no especificado. Si hay un apéndice del manual donde figura, es definido por la implementación.
  3. ¿La elección debe repetirse? Lo definido por la implementación es estable dentro de esa implementación y esa configuración, porque está documentado. Lo no especificado puede cambiar entre dos apariciones del mismo programa.

Hay además dos categorías menores que casi nadie nombra y conviene tener en el vocabulario. El comportamiento específico de la localización es una variante de lo definido por la implementación cuya elección depende de la localización activa: qué caracteres son alfabéticos según isalpha, cómo ordena strcoll. Y el comportamiento no obligatorio designa las funcionalidades que la norma describe pero no exige, típicamente marcadas por macros de prueba de características que tu código debe consultar antes de usarlas.

/* la norma describe estas funcionalidades, pero no obliga a
   proveerlas: hay que preguntar antes de depender de ellas */
#ifdef __STDC_IEC_60559_BFP__
    /* coma flotante binaria conforme a IEC 60559 */
#endif

#ifndef __STDC_NO_ATOMICS__
#  include <stdatomic.h>
#endif

#ifndef __STDC_NO_THREADS__
#  include <threads.h>
#endif

Qué hacer con cada uno en un proyecto real

La clasificación no es un ejercicio taxonómico: dicta tres políticas de ingeniería distintas.

Lo indefinido se erradica. No se documenta, no se mide, no se justifica con la frase de que en tu máquina funciona. Es la única de las tres categorías que puede envenenar código lejano, y la única contra la que existen herramientas dedicadas —UBSan, análisis estático, fuzzing— porque es la única que constituye un defecto en sí misma.

Lo no especificado se elude por diseño. No preguntes qué orden elige tu compilador: escribe código cuyo resultado sea el mismo en todos los órdenes posibles. Un efecto colateral por expresión, una llamada con efectos por sentencia, y la categoría deja de importarte.

Lo definido por la implementación se documenta y se aísla. Sí puedes depender de que int tiene treinta y dos bits o de que el desplazamiento a la derecha de un negativo es aritmético, siempre que lo escribas en un sitio, lo concentres en una cabecera de configuración y lo verifiques con static_assert para que la compilación falle el día que alguien cruce a una plataforma donde no se cumpla.

#include <assert.h>
#include <limits.h>

/* dependencias documentadas y verificadas en tiempo de compilacion */
static_assert(CHAR_BIT == 8, "esta base de codigo asume octetos");
static_assert(sizeof(int) == 4, "asume int de 32 bits");
static_assert(-1 >> 1 == -1, "asume desplazamiento aritmetico");
Tres categorías, dos mundos

La lección que reordena todo lo demás es que estas tres categorías no forman un espectro: forman dos mundos. En un lado están lo no especificado y lo definido por la implementación, donde tu programa conserva significado y lo único que pierdes es portabilidad o predictibilidad. Puedes medirlos, documentarlos, encapsularlos y seguir adelante; la peor consecuencia es que el código haya que tocarlo al cambiar de plataforma. En el otro lado está el comportamiento indefinido, que no es una variación de resultado sino la retirada del significado. Cuando un programa contiene comportamiento indefinido alcanzable, la norma no dice que esa línea dé un valor raro: dice que no hay requisitos sobre la ejecución, y de ahí se sigue que ni siquiera las líneas anteriores tienen garantías, porque una traducción conforme puede haberlas reordenado o eliminado apoyándose en la premisa de que lo indefinido nunca ocurriría. Por eso la pregunta correcta ante una construcción dudosa no es qué tan malo es esto, sino a cuál de los dos mundos pertenece. Del primero se sale leyendo un manual. Del segundo no se sale: se evita.

⚔️ Clasifica antes de opinar
  1. Escribe el programa de los tres ejemplos y compílalo con gcc y con clang a -O0 y -O2. Compara la salida del caso no especificado en las cuatro combinaciones.
  2. Busca en la documentación de tu compilador la sección de comportamiento definido por la implementación y localiza tres decisiones concretas: signo de char, resultado de la conversión de un entero fuera de rango y desplazamiento a la derecha de negativos.
  3. Añade los tres static_assert de la última sección a un proyecto tuyo y compílalo cruzado a una plataforma de dieciséis bits para ver cuál salta primero.
  4. Clasifica sin ejecutar nada: i = i++, el valor de un puntero tras free, el resultado de comparar dos punteros a objetos distintos con el operador de menor que, y el número de bits de size_t.
  5. Justifica por escrito por qué leer un miembro de una unión distinto del último escrito es legal en C y no lo es en C++, y a qué categoría pertenece en cada lenguaje.