Comportamiento indefinido en C++ (Undefined Behavior)

El estándar ISO del lenguaje C++ establece reglas sintácticas y semánticas que conducen, bajo un estricto cumplimiento de las mismas, a programas bien definidos. Ciertos aspectos y operaciones de la máquina abstracta del lenguaje son descritos como dependientes de la implementación (implementation-defined), como por ejemplo el tamaño de un entero sizeof(int). Hablamos aquí de los parámetros de la máquina abstracta, los cuales deben ser adecuadamente documentados por la implementación (véase el punto 4.1.1.2 de [1]).

Otros aspectos y operaciones, por su parte, se describen sin especificar (unspecified), como por ejemplo el orden de evaluación de los argumentos en una llamada a función. Ello introduce características no-deterministas en la máquina abstracta: una instancia de la máquina abstracta puede tener más de una ejecución posible para un programa e input dados. En tales casos, y siempre que sea posible, el estándar trata de definir el conjunto de comportamientos permitidos (punto 4.1.1.3 de [1]), si bien la implementación no está obligada a documentar el comportamiento seguido en cada caso.

En este artículo centraremos nuestra atención en las operaciones descritas bajo la etiqueta de comportamiento indefinido (undefined behavior o simplemente UB), las cuales conducen a programas incorrectos (punto 4.1.1.4 de [1]). Entre tales operaciones, podríamos destacar [2, 3]:

  • Lectura de una variable sin inicializar.
  • Desreferencia de un puntero nulo.
  • Acceso a un objeto a través de un puntero de tipo distinto a aquél.
  • Acceso a memoria más allá del límite de un array.
  • Desbordamiento (overflow) de un entero con signo (no así de un entero con signo).
  • División de un entero por cero.
  • Uso de un objeto tras su destrucción (incluyendo la invocación del destructor por segunda vez).
  • Modificación de un objeto constante (típicamente mediante una conversión const_cast).
  • Ausencia de sentencia de retorno en una función que devuelva un valor.
  • Bucles infinitos sin efectos visibles/secundarios.
  • Data races.
Una implementación 100% conforme con el estándar que ejecute un programa bien definido producirá el mismo comportamiento observable que una de las posibles ejecuciones de la máquina abstracta con el mismo programa y conjunto de datos de entrada. Sin embargo, si una de tales ejecuciones contiene una operación UB, el estándar no impone ningún requisito sobre la implementación al ejecutar dicho programa con dicho input, ni siquiera en relación a las operaciones correctas previas a aquélla que presente UB por vez primera (véase el punto 4.1.1.5 de [1]). En otras palabras, un programa con UB no se considera código válido en C++, quedando fuera de la definición formal del lenguaje.

Si bien un compilador podrá emitir warnings ante varias de las situaciones de UB antes enumeradas, no está obligado a diagnosticarlas. Así, ante la existencia de UB, pueden producirse varios escenarios posibles. Por orden creciente de gravedad en sus consecuencias, enumeraríamos (punto 3.27 de [1]):
  1. La compilación o ejecución del programa se interrumpe con la emisión de mensajes de diagnóstico.
  2. El proceso de compilación o de ejecución responde, de forma documentada, según las características de la plataforma de trabajo (con o sin la emisión de mensajes de diagnótisco). La portabilidad del código, sin embargo, queda comprometida.
  3. El programa compila y se ejecuta de forma impredecible, pudiendo producir resultados incorrectos (proporcionaremos un ejemplo de este caso al final del artículo), un crash del proceso, corrupción de la memoria, vulnerabilidades de seguridad, etcétera.

El lector puede preguntarse la razón por la que se permite la existencia de UB en primer lugar. El primer caso señalado en este artículo (empleo de variables sin inicializar) podría por ejemplo ser evitado si el lenguaje impusiera que todas las variables no-estáticas debieran ser inicializadas al introducirlas en ámbito por vez primera. Sin embargo, ello podría afectar gravemente al rendimiento de nuestros programas. Al admitir la existencia de variables sin inicializar, el lenguaje permite introducir buffers dentro de un ámbito sin necesidad de iniciarlos por defecto a cero (como sí hace, por ejemplo, Java). En caso contrario, incurriríamos en un importante sobrecoste en tiempo de ejecución, toda vez que dichos buffers son típicamente rellenados en operaciones inmediatamente posteriores:

   auto gen = std::mt19937{std::random_device{}()};    auto distr = std::uniform_int_distribution{1, 6};    auto dice = std::bind(distr, gen);    std::array<int, 2'000> buffer; // 2000 enteros sin inicializar    std::ranges::generate(buffer, dice); // el buffer se rellena con enteros aleatorios // entre 1 y 6

Esta misma misma preocupación por el rendimiento justifica el que, por defecto, el acceso a arrays se realice sin control de límite a través de la indexación con corchetes. Por supuesto, contenedores como std::array y std::vector proporcionan también funciones públicas de acceso at() con bounds-checking, las cuales lanzan excepciones al tratar de acceder elementos más allá del tamaño límite permitido. Sin embargo, ello supone un gasto no despreciable de ciclos de la CPU en comparación con las versiones sin bounds-checking. De primar la eficiencia, la comprobación de precondiciones en código correcto puede suponer un coste inaceptable.

   auto nums = std::array{1, 2, 3, 4};    nums[4] = 5;    // UB: acceso más allá del límite permitido para nums    nums.at(4) = 5; // ok, lanzamiento de excepción std::out_of_range en runtime

Como se explica en el seminario [4], la reducción del número de casos de UB en el lenguaje es un aspecto en continuo debate difícil de implementar: establecer una definición determinada puede resultar adecuado en una plataforma, pero conducir a pesimización o resultar imposible de implentar en otras. Tal es el caso de la desreferencia de un puntero nulo: mientras que ciertas plataformas pueden detectarla en runtime y lanzar una excepción, otras carecen de mecanismos para ello.

Al permitir la existencia de UB, en suma, el lenguaje y sus bibliotecas pueden ser implementados en una enorme variedad de arquitecturas de la forma más eficiente posible [5]. Al codificar en C++, el programador se compromete expresamente a evitar cualquier fuente de UB en sus programas. Bajo este contrato, el compilador puede realizar importantes optimizaciones una vez asume por defecto que las operaciones que conducen a UB nunca se producirán. A modo de ejemplo, la función

   auto silly_predicate(int value) -> bool { return value + 1 > value; }

será optimizada por el compilador GCC 11.2 bajo los flags -DNDEBUG -O3 -std=c++20 a simplemente:

   auto silly_predicate(int value) -> bool { return true; }

El conjunto de instrucciones generado así lo atestigua:

silly_predicate(int):         mov     eax1         ret

Dada la versión original de la función silly_predicate, el compilador es capaz de discernir que, si value tomase como valor INT_MAX, la función incurriría en UB al tratar de calcular INT_MAX+1 (desbordamiento de entero con signo, puntos 6.8.1.1 y 7.1.4 de [1]). En cualquier otro caso, la función devolverá true sin experimentar UB. Dado que toda ejecución legal (es decir, correcta) de la función devuelve true, el compilador decide ignorar el caso de UB y optimizar la función en la forma señalada. Por supuesto, sería conveniente documentar dicha precondición (de forma que recaiga sobre el programador la verificación de su cumplimiento) e introducir una asersión que la compruebe en procesos de depuración (véase este post anterior sobre programación basada en contratos):

   auto silly_predicate(int value) -> bool // pre: value != INT_MAX { assert(value != std::numeric_limits<int>::max()); return value + 1 > value; }


Procesos de optimización como los señalados pueden producir resultados inesperados al compilar un programa que exhiba UB. Así, consideremos el código de ejemplo siguiente, analizado en detalle en la referencia [6]:

   #include <array>   std::array<int, 4> nums;    auto nums_contains(int value) -> bool    {       for (auto i = 0; i <= 4; ++i) {          if (nums[i] == value) return true;       }       return false;    }

Este código presenta obviamente UB, pues la función nums_contains trata de acceder memoria más allá del final del array (existe un bug en la condición de control i<=4). De forma notable, el compilador GCC 11.2 generará, nuevamente bajo los flags -DNDEBUG -O3 -std=c++20, el siguiente conjunto de instrucciones:

nums_contains(int):         mov    eax1         ret nums:         .zero   16

reduciendo pues la función nums_contains a simplemente:

   auto nums_contains(int value) -> bool { return true; }

En efecto, según la lógica del compilador, una de las cuatro primeras iteraciones del bucle for podría llegar a evaluarse como true. Si no fuese ése el caso, se ejecutaría la quinta iteración, donde incurriríamos en UB al tratar de acceder al valor nums[4], que queda fuera de los límites del array nums. Toda ejecución legal (es decir, correcta) del programa retornaría true antes de que ello sucediera. El compilador decide entonces ignorar la posibilidad de que se alcance UB y retornar true en cualquier caso, aun cuando value no se encuentre realmente en el array nums (caso de nivel 3 de gravedad de UB según la clasificación proporcionada anteriormente):

   nums = {1, 2, 3, 4};    fmt::print("{}\n", nums_contains(7)); // output: true (aunque 7 no está en nums)

Como mencionábamos anteriormente, recae en el programador la responsabilidad de garantizar que los códigos estén libres de UB. Para ello, cuenta con herramientas de diagnóstico tanto estático como en runtime, tales como Clang Static Analyzer [7], Valgrind [8] o warnings específicos de los compiladores, que proporcionan una ayuda inestimable en dicha tarea [9].


Referencias bibliográficas:
  1. ISO C++20 draft - N4835 - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/n4835.pdf
  2. cppreference - Undefined behavior - https://en.cppreference.com/w/cpp/language/ub
  3. CppCon 2021 - Back To Basics: Undefined Behavior - Ansel Sermersheim & Barbara Geller
  4. CppCon 2016 - Garbage In, Garbage Out: Arguing about Undefined Behavior... - Chandler Carruth - https://youtu.be/yG1OZ69H_-o
  5. Stack Overflow - https://stackoverflow.com/questions/51557895/why-does-undefined-behaviour-exist
  6. Raymond Chen - Undefined behavior can result in time travel - https://devblogs.microsoft.com/oldnewthing/20140627-00/?p=633
  7. Clang Static Analyzer - https://clang-analyzer.llvm.org/
  8. Valgrind - https://valgrind.org/
  9. The LLVM Project Blog - Chris Lattner - What Every C Programmer Should Know About Undefined Behavior (parts 1, 2, and 3) - https://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

No hay comentarios:

Publicar un comentario