Qué significa realmente el "comportamiento indefinido"
La página anterior mostró cómo try/catch maneja los errores que tu programa define y lanza a propósito. El comportamiento indefinido es lo contrario: es el conjunto de operaciones a las que el estándar de C++ se niega a darles cualquier significado. No hay excepción que capturar, ni código de error, ni garantía de que falle. El compilador es libre de asumir que el UB nunca ocurre y de hacer lo que quiera cuando ocurre.
Esa libertad es lo que hace al UB tan peligroso. La misma línea defectuosa podría imprimir la respuesta "correcta" en tu portátil, devolver basura en un servidor y ser eliminada por completo por el optimizador con -O2. El UB no es "comportamiento que no documentamos", sino "comportamiento sobre el que el lenguaje no promete nada". Tu trabajo es no escribirlo nunca.
int arr[3] = {1, 2, 3};
int x = arr[5]; // comportamiento indefinido: leer más allá del final del arreglo
Aquí no hay error de compilación, y en muchas ejecuciones simplemente te entregará un entero perdido. Ese aparente éxito es la trampa.
Leer o escribir fuera de límites
La forma más común de UB es tocar memoria que no te pertenece. Los arreglos integrados y std::vector::operator[] no comprueban los límites: un índice más allá del final (o uno negativo) es UB instantáneo, ya sea que leas o escribas.
El error que hay que vigilar es usar <= donde querías <: cuando i == v.size() indexas un elemento más allá del último, lo cual es UB. Prefiere un for basado en rango (visto antes) cuando no necesites el índice, ya que no puede salirse del final. Cuando sí indexas a mano y quieres una red de seguridad, v.at(i) lanza std::out_of_range en lugar de corromper la memoria silenciosamente:
Usa at() mientras buscas un error; vuelve a [] en los bucles críticos una vez que hayas demostrado que los índices son válidos.
Punteros colgantes y use-after-free
Un puntero o referencia que sobrevive al objeto al que apunta está colgante (dangling). Usarlo es UB: la memoria puede haber sido reutilizada, liberada o nunca haber existido. Esta es la trampa que los punteros inteligentes (del capítulo anterior) te ayudan a evitar, pero los punteros crudos todavía te dejan caer en ella.
La versión más aguda es devolver la dirección de una variable local. La local muere cuando la función retorna, así que quien llama se queda con un puntero a nada:
int* makeNumber() {
int n = 42;
return &n; // devuelve la direccion de una local: ya no existe tras el return
}
// Desreferenciar el resultado es comportamiento indefinido.
Lo mismo ocurre tras un delete o cuando un vector se reasigna e invalida los iteradores o punteros que apuntan a él:
int* p = new int(5);
delete p;
cout << *p; // use-after-free: comportamiento indefinido
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // puede reasignar: 'first' ahora esta colgante
cout << *first; // comportamiento indefinido
Las defensas son las que ya conoces: mantén los objetos vivos mientras algún puntero los necesite, prefiere las referencias y los punteros inteligentes sobre los punteros crudos con propiedad, y vuelve a obtener los punteros/iteradores tras cualquier operación que pueda redimensionar un contenedor.
Variables sin inicializar y desbordamiento con signo
Leer una variable antes de haberle dado un valor es UB para los tipos integrados: no hay un 0 por defecto. La variable contiene los bits que ya estuvieran en esa memoria, y el optimizador puede asumir que nunca la lees sin inicializar.
Si sum se hubiera declarado como un simple int sum;, cada sum += i leería primero un valor indeterminado: UB, y un error notoriamente difícil porque a menudo parece que funciona. Haz de la inicialización un hábito: int x = 0; o int x{};.
Otro infractor silencioso es el desbordamiento de enteros con signo. Empujar un int con signo más allá de su máximo es UB (los tipos sin signo dan la vuelta de forma predecible; los tipos con signo no):
int big = 2147483647; // INT_MAX en un int de 32 bits
int oops = big + 1; // desbordamiento con signo: comportamiento indefinido
No confíes en que "dará la vuelta a un número negativo": el compilador puede asumir que el desbordamiento no puede ocurrir y optimizar basándose en ello. Si necesitas un giro definido, usa un tipo sin signo o comprueba los límites antes de sumar.
Detectar el UB con sanitizers y advertencias
No puedes alcanzar confianza sobre el UB a base de pruebas, porque una ejecución exitosa no garantiza nada. Lo que sí funciona es hacer ruidoso el UB en tiempo de ejecución con los sanitizers del compilador (disponibles en GCC y Clang).
// AddressSanitizer: fuera de limites, use-after-free, fugas de memoria
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer: desbordamiento con signo, deref nulo, conversiones invalidas
g++ -fsanitize=undefined -g main.cpp -o app && ./app
Ejecuta tus pruebas existentes con estos flags y la lectura fuera de límites, el use-after-free o el desbordamiento con signo que "funcionaba bien" se convierte en un informe preciso que nombra el archivo y la línea. Combínalos con -Wall -Wextra para que el compilador también señale el código sospechoso (como una probable lectura sin inicializar) antes incluso de ejecutarlo.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
Trata cualquier informe del sanitizer como un error de obligada corrección, no como una advertencia que ignorar: te está diciendo que el estándar no hace ninguna promesa sobre esa línea.
Cierre
El comportamiento indefinido es la parte de C++ donde se quitan las barreras de seguridad: el acceso fuera de límites, los punteros colgantes, el use-after-free, las lecturas sin inicializar y el desbordamiento con signo producen código sin ningún significado definido, y "funcionó bien" nunca es prueba de que sea correcto. La forma de mantenerse a salvo es programar de forma defensiva (inicializa cada variable, respeta los límites de los contenedores, deja que los punteros inteligentes posean tu memoria del heap) y luego verificar con -fsanitize=address, -fsanitize=undefined y -Wall -Wextra para que el UB silencioso se convierta en un informe ruidoso y corregible.
Con esto se cierra el capítulo de Errores y Depuración. Entre las excepciones, try/catch y un sano temor al UB, ya tienes las herramientas para escribir C++ que falla de forma ruidosa y a propósito, en lugar de silenciosa y por accidente.
Preguntas frecuentes
¿Qué es el comportamiento indefinido en C++?
El comportamiento indefinido (UB) es cualquier operación a la que el estándar de C++ no le asigna ningún resultado definido; por ejemplo, leer más allá del final de un arreglo o desreferenciar un puntero colgante. El compilador puede hacer cualquier cosa: fallar, devolver basura, eliminar el código por optimización o parecer que funciona hoy y romperse tras recompilar. Es un error en tu programa, no una característica del lenguaje.
¿Por qué mi programa en C++ funciona aunque tenga comportamiento indefinido?
"Funcionó bien" no demuestra nada sobre el UB. El estándar no da ninguna garantía en ningún sentido, así que un error de UB puede producir el resultado que esperabas en tu máquina con tu compilador hoy y luego fallar con otro nivel de optimización, otra plataforma u otra versión del compilador. Nunca tomes una ejecución exitosa como prueba de que el UB es inofensivo: usa un sanitizer para detectarlo de verdad.
¿Cómo se detecta el comportamiento indefinido en C++?
Compila con sanitizers: -fsanitize=address (AddressSanitizer) encuentra lecturas/escrituras fuera de límites y use-after-free, y -fsanitize=undefined (UndefinedBehaviorSanitizer) señala el desbordamiento con signo, las desreferencias de punteros nulos y las conversiones inválidas. Activa las advertencias (-Wall -Wextra) y ejecuta tus pruebas con estos flags: convierten el UB silencioso en un informe claro en tiempo de ejecución.