Ir al contenido

Comprender y Evitar el Comportamiento Indefinido

Aprende que es el comportamiento indefinido, por que existe, como los compiladores lo explotan para optimizacion y como detectarlo con sanitizers y advertencias del compilador.

Que Es el Comportamiento Indefinido y Por Que Existe?

Comportamiento indefinido (UB) significa que el estandar C++ no impone ningun requisito sobre el comportamiento del programa. El programa podria fallar, producir resultados erroneos, parecer funcionar correctamente o formatear tu disco duro — todos son resultados "validos".

El UB existe porque permite optimizacion agresiva. Cuando el compilador sabe que ciertas situaciones "no pueden ocurrir" (porque serian UB), puede generar codigo mas rapido omitiendo verificaciones para esas situaciones. Por ejemplo:

- El desbordamiento de enteros con signo es UB permite al compilador asumir que x + 1 > x siempre es verdadero, habilitando optimizaciones de bucle.
- Desreferenciar un puntero nulo es UB permite al compilador eliminar verificaciones de nulo despues de una desreferencia.
- Acceder a memoria fuera de limites es UB permite al compilador omitir verificaciones de limites, haciendo el acceso a arreglos tan rapido como la memoria cruda.

El trato fundamental: C++ confia en el programador para evitar UB a cambio de maximo rendimiento. Cuando esa confianza se viola, todas las apuestas estan canceladas.

Fuentes Comunes de Comportamiento Indefinido

Aqui estan las fuentes mas frecuentes de UB en codigo C++ del mundo real. Todo programador de C++ se encuentra con estas.

common_ub.cpp
#include <vector>
#include <iostream>
#include <cstdint>

// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
    int x = INT32_MAX;
    int y = x + 1;        // UB! Signed overflow is undefined
    // unsigned overflow is well-defined (wraps around)
    unsigned u = UINT32_MAX;
    unsigned v = u + 1;   // OK: v == 0 (well-defined wrap)
}

// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
    int val = *ptr;       // UB if ptr is null
    if (ptr == nullptr) { // Compiler may remove this check!
        // The compiler reasons: "ptr was already dereferenced above,
        // which would be UB if null. Since UB cannot happen in a
        // correct program, ptr must be non-null. This check is dead code."
        std::cout << "null!\n";
    }
}

// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
    int arr[5] = {1, 2, 3, 4, 5};
    int bad = arr[10];    // UB! No bounds checking on raw arrays
    // std::vector::operator[] is also UB if out of bounds
    // Use .at() for bounds-checked access (throws std::out_of_range)
}

// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
    int local = 42;
    return local;         // UB! Reference to destroyed local
}

// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++;    // UB if concurrent with Thread 2
// Thread 2: shared++;    // UB! Data race on non-atomic variable

El Problema del "Viaje en el Tiempo"

Uno de los aspectos mas contraintuitivos del UB es que puede afectar al codigo antes del punto de UB. Los compiladores optimizan basandose en la suposicion de que el UB nunca ocurre en un programa correcto, y estas optimizaciones pueden reordenar o eliminar codigo que aparece antes del UB.

Considera:

bool checked = false;
void process(int* p) {
    int value = *p;        // UB si p es nulo
    checked = true;        // Esta linea esta ANTES del uso de 'value'
    if (p == nullptr) {    // Compilador: "p fue desreferenciado, asi que no es nulo"
        abort();           // Compilador: "Esto es codigo muerto, eliminarlo"
    }
    use(value);
}

El compilador puede establecer checked = true antes de la desreferencia (reordenamiento para el pipeline), y luego eliminar la verificacion de nulo por completo. El resultado: checked se establece a true incluso cuando p es nulo, y la desreferencia nula procede sin el abort. El UB "viajo hacia atras en el tiempo" para corromper la flag checked.

Esto no es un error del compilador — es una consecuencia directa de las libertades de optimizacion que el UB otorga.

Detectando UB con Sanitizers

Los sanitizers son herramientas proporcionadas por el compilador que instrumentan tu codigo para detectar UB en tiempo de ejecucion. Agregan verificaciones en tiempo de ejecucion que capturan UB con mensajes de error claros. Siempre ejecuta tu suite de pruebas con sanitizers habilitados.

sanitizers.sh
# AddressSanitizer (ASan) — detects:
#   - Buffer overflow (stack, heap, global)
#   - Use-after-free, use-after-return
#   - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program

# UndefinedBehaviorSanitizer (UBSan) — detects:
#   - Signed integer overflow
#   - Null pointer dereference
#   - Out-of-bounds array access
#   - Misaligned memory access
#   - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program

# ThreadSanitizer (TSan) — detects:
#   - Data races between threads
#   - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program

# MemorySanitizer (MSan) — detects:
#   - Reads of uninitialized memory
#   (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program

# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
    -Wall -Wextra -Werror program.cpp -o program

# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)

Advertencias del Compilador como Red de Seguridad

Los compiladores modernos pueden detectar muchas situaciones potenciales de UB en tiempo de compilacion. Trata las advertencias como errores (-Werror) en CI para asegurar que ninguna advertencia sea ignorada.

warnings.sh
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
    -Wall           # Enable most warnings \
    -Wextra         # Enable additional warnings \
    -Wpedantic      # Strict ISO C++ compliance \
    -Werror         # Treat warnings as errors \
    -Wconversion    # Warn on implicit narrowing conversions \
    -Wshadow        # Warn when a variable shadows another \
    -Wnon-virtual-dtor  # Warn on classes with virtual functions but non-virtual dtor \
    -Wold-style-cast    # Warn on C-style casts \
    -Wcast-align    # Warn on potentially misaligned casts \
    -Wunused        # Warn on unused variables/functions \
    -Woverloaded-virtual  # Warn when a derived class hides a base virtual function \
    -Wnull-dereference   # Warn on potential null dereferences (GCC) \
    -Wdouble-promotion   # Warn when float is implicitly promoted to double \
    -Wformat=2      # Warn on printf/scanf format string issues \
    program.cpp -o program

# For Clang, add:
#   -Weverything    # Enable ALL warnings (then selectively disable)
#   -Wno-c++98-compat  # Disable C++98 compatibility warnings
Buena práctica

Sigue estas directrices para minimizar el UB en tu base de codigo.

  • Habilita todas las advertencias y tratalas como errores: -Wall -Wextra -Wpedantic -Werror
  • Ejecuta sanitizers en CI: Combina ASan + UBSan para cada ejecucion de pruebas; TSan para codigo multihilo
  • Usa .at() en lugar de [] para acceso a contenedores con verificacion de limites durante el desarrollo
  • Usa smart pointers (unique_ptr, shared_ptr) en lugar de new/delete crudos para eliminar uso-despues-de-liberar
  • Usa std::atomic para variables compartidas entre hilos — nunca uses tipos simples para datos entre hilos
  • Prefiere tipos sin signo para manipulacion de bits pero tipos con signo para aritmetica (la aritmetica sin signo envuelve, lo que puede causar errores sutiles en limites de bucles)
Error común

El UB mas peligroso es el codigo que parece funcionar perfectamente en pruebas pero falla en produccion. Aqui esta el por que.

  • El UB puede "funcionar" durante anos y luego romperse cuando actualizas el compilador, cambias los niveles de optimizacion o ejecutas en una CPU diferente
  • Desbordamiento con signo en bucles: for (int i = 0; i < n; i++) — si n es INT_MAX, i++ desborda. El compilador puede asumir que el bucle termina y optimizar en consecuencia
  • Leer variables no inicializadas: El valor no es "aleatorio" — el compilador puede optimizar eliminando la variable por completo o propagar los bits que esten en el registro
  • Violaciones de aliasing estricto: Convertir int* a float* y desreferenciar es UB (excepto a traves de char* o std::byte*). Usa std::bit_cast (C++20) para type punning
  • std::string_view que sobrevive al string: Devuelve una vista colgante que parece funcionar hasta que la memoria se reutiliza
Puntos clave
  • El comportamiento indefinido existe para habilitar optimizaciones agresivas del compilador — C++ intercambia seguridad por rendimiento
  • El UB puede "viajar en el tiempo" — el compilador puede reordenar o eliminar codigo que precede al punto de UB
  • Las fuentes mas comunes de UB son desbordamiento con signo, desreferencia de nulo, acceso fuera de limites, uso-despues-de-liberar y carreras de datos
  • Los sanitizers (ASan, UBSan, TSan, MSan) detectan UB en tiempo de ejecucion — ejecutalos en cada pipeline de CI
  • Las advertencias del compilador (-Wall -Wextra -Werror) capturan muchas situaciones potenciales de UB en tiempo de compilacion
  • El UB que "funciona" hoy puede romperse silenciosamente con un nuevo compilador, nivel de optimizacion o plataforma

Quiz — Pon a prueba tus conocimientos

(15 XP)

1. Por que el desbordamiento de enteros con signo es comportamiento indefinido en C++?

2. Que detecta AddressSanitizer (ASan)?

3. Que es el problema de 'viaje en el tiempo' con el comportamiento indefinido?