miércoles, 29 de enero de 2014

Comportamientos en C/C++: indefinido, sin especificar, etc.

C++ se estandariza a través de un comité internacional. Eso tiene muchas ventajas... y algún inconveniente.
Una de las cosas que a mi, en su día, me parecieron muy inconvenientes es la esquisitez con la que parecen hablar. Adjetivos que me parecen sinónimos identifican cosas completamente diferentes. Pero supongo que esa esquisitez es necesaria si estás en un comité internacional donde te van a leer y aprobar o rechazar un montón de gente.
El caso es que una de las primeras cosas a las que te enfrentas en C++ es a entender a qué se refieren cuando hablan de “comportamiento indefinido”, “Comportamiento definido por la implementación”, “comportamiento sin especificar” y “programa mal-formado”.

En el estándar C++, traducido libremente por mi, ponen:
§1.3.26 [defns.well.formed] Programa bien formado: Programa C++ construido de acuerdo a las reglas de sintaxis, a las reglas de semántica diagnosticable y a la regla de definición única.
§1.3.25 [defns.unespecified] Comportamiento sin especificar: Comportamiento de una construcción bien formada y con datos correctos, pero que depende de la implementación.
§1.3.10 [defns.impl.defined] Comportamiento definido por la implementación: Comportamiento de una construcción bien formada y con datos correctos, pero que depende de la implementación y que cada implementación debe documentar.
§1.3.24 [defns.undefined] Comportamiento indefinido: Comportamiento para el cual el Estandar Internacional no impone requisitos algunos.
- A mi, francamente, esas frases me dejan un poco igual. Es que no veo la diferencia, la verdad.
Bueno, lo que puedo es intentar transcribir lo que creo que significan.
Un programa bien formado es un programa para el cual el compilador debe generar código. Y ese código debe hacer algo, debe tener un comportamiento. Para poder generar código, deben cumplirse las reglas de sintaxis (no dejarte olvidado un ’;’, por ejemplo); las reglas de semántica (no puedes, por ejemplo, invocar a una función no existente); pero también debes cumplir la One Definition Rule... que no es el momento adecuado ahora explicar.
El caso es que por mucho que haya generado código, el compilador sólo está obligado a generar código de comportamiento predecible para ciertos casos (para ciertos datos de entrada, por así decir) y no todos.
Creo que C++ es un lenguaje de “bajo nivel”. Ojo, permite crear abstracciones de alto nivel, así que ese no es el motivo por el cual digo que C++ es de bajo nivel. El motivo es que su filosofía siempre ha sido la de generar código eficiente. Código que sería dificil de superar aún a pesar de que el programador programara en ensamblador.
Y, entre otros motivos, una de las formas en que C++ posibilita esto es mediante el comportamiento indefinido, el comportamiento sin especificar y el comportamiento dependiente de la implementación.
- Por ahora te explicas peor que tres libros cerrados en su estantería.
Vaaale. Un ejemplo. Dividir entre 0:
int x = ...;
int y = 32 / x;
Imaginemos que x se obtiene de alguna forma no determinista. Por ejemplo, es un dato pedido al usuario en tiempo de ejecución. Aún así, estaremos de acuerdo en que el compilador debe generar código para el 32/x, ¿no?
Pero entonces, si x acaba almacenando 0... ¿qué debe hacer el codigo generado por el compilador para la expresión 32/x?
Imaginemos que el estándar hubiera dicho “en las divisiones, el compilador debe generar código que haga XXX cuando el divisor sea 0”. Sustituye XXX por lo que quieras.
El problema es que para llevar a cabo esa tarea, en muchas plataformas el código generado debería ser algo así:
if(x==0)
   XXX();
int y = 32 / x;
Ese if cuesta ciclos de procesador. Y puede ser muy significativo. Y quizás yo ya sepa, por como está construido mi programa, que x siempre será distinto a 0 así que no estoy dispuesto a pagar ese if.
Da igual las vueltas que le des o lo poquito que creas que ese if cuesta. Sí: quizás en tu plataforma lo que tú decidas que es el if+XXX no te cueste ciclos. Por ejemplo, en una CPU Intel pudiera ser que dividir entre 0 lanzara una excepción, así que no habría necesidad de poner if. Pero C++ es multiplataforma, así que tu caso particular vale de bastante poco.
La filosofía en C++ es: “no pagues por aquello que no quieres”. Así que si algo cuesta ciclos de reloj, el programador debe indicarlo explícitamente.
- ¿Y cómo resuelve entonces el estandar este tipo de casos?
Lo que el estándar dice es “esto provoca comportamiento indefinido”.
Que viene a significar que los compiladores pueden hacer lo que les de la gana. Pueden abortar el código; hacer que siga funcionando pero con valores raros en las variables; hacer que sigan funcionando durante media hora y luego revienten; hacer que tu programa evolucione hasta adquirir conciencia de sí mismo y comenzar una guerra contra la humanidad, lo que sea... [GCC 1.17, por ejemplo, lanzaba un conocido videojuego cuando se encontraba con ciertos comportamientos indefinidos]
Es importante recalcar que los compiladores C++ no están siquiera obligados a emitir un warning cuando descubren comportamiento indefinido. Imagina que por cada división que pusieras en tu código el compilador te dijera: 
Warning f.cc:12:34: Estás haciendo una división. Como dividas por cero mi comportamiento es indefinido. ¿Lo sabes, no?
No valdría para nada y, francamente, sería inacabable la salida por pantalla.
Pero es que además, es muy difícil detectar comportamientos indefinidos. Ver esta entrada del blog de LLVM por si quieres una explicación completa.
- Bueno, venga vale, deja de dar el plastazo con lo de indefinido. Creo que ya lo pillo. Pero... ¿qué es entonces “sin especificar”? ¿Y “dependiente de la implementación”?
Pues es que hay situaciones en las que no hace falta ponerse tan bruto porque todos los compiladores de todas las plataformas están dispuestos a generar un código predecible... aunque diferente en cada compilador+plataforma.
Por ejemplo, en este codigo:
f( g(b), h(b) );
C++ deja sin especificar el órden al que se llaman las funciones g() y h(). Eso quiere decir que un compilador puede llamar antes a g() que a h(), mientras que otro puede hacer justo lo contrario. Pero ningún compilador C++ puede hacer que ese trozo de código evolucione para convertirse en una amenaza para la humanidad.
Es código válido... pero de comportamiento sin especificar. El estándar lista una serie de posibilidades, y obliga a que cada compilador se decante por uno, pero no obliga a uno en particular. Así que el estándar dice que antes esa construcción, el compilador puede o llamar primero a g() y luego a h() o viceversa... pero no admite que se lance una excepción o cualquier otro comportamiento indefinido.
- ¿Porqué no obligan a un orden? ¿Tan dificil sería?
No, para nada. De hecho, lenguajes como Java obligan a un orden. O sea, que técnicamente es posible hacerlo. El problema es que cierto orden favorece a ciertas plataformas y el opuesto a otras plataformas. En Java es sencillo entonces decidir el orden: ya que Java lo impone la antigua Sun (ahora Oracle), pues el que mas favorezca a las plataformas Sun.
Pero C++ se decide por consenso. Y a veces el único consenso es: venga, cada uno hacerlo como os de la gana, pero aseguraros de que el programa no se cae, hombre. Y eso, en lenguaje mas currado, es lo que llaman “comportamiento sin especificar”.
- Pues empieza a no gustarme eso del comité, ¿eh?
Pues no te he contado cosas raras que pueden pasar debido a esto. Por ejemplo, date cuenta de que un comportamiento sin especificar puede derivar en un comportamiento indefinido en ciertas plataformas pero en otras no.
Por ejemplo:
void g(int &b) { b = 0; }
void h(int &b) { b = 32/b; }
...
int b= 10;
f( g(b), h(b) );
Si se ejecuta h() antes que g(), todo bien.
Pero si se ejecuta g() antes que h() acabamos ejecutando 32/0... que es comportamiento indefinido. Y, como es comportamiento indefinido, sería hasta legal que el compilador generara código que se retrotajera en el tiempo y no ejecutara g(b), lo que seguramente provocaría una singularidad espacio-temporal que terminaría con este universo... ¡así que ten cuidado con lo que haces!
El comportamiento indefinido que mas quebraderos de cabeza te va a dar en la vida real es cuando el compilador decide no generar código. Desde fuera es raro: el compilador parece darse cuenta de que el código que has puesto es indefinido así que decide no generar código. ¡Será @#~%...!, ¿no podia haberme advertido?
Evidentemente, las cosas para el compilador nunca son tan sencillas. Y es que el compilador no se da cuenta de que el código es indefinido. Lo que en verdad ocurre es que el código intermedio que genera no tiene sentido, y cuando se alimenta al optimizador con ese código intermedio, el optimizador determina que puede eliminar el código. Por ejemplo, si en el código intermedio acabas teniendo algo así:
if(0)
   XXXX();
El optimizador determina que a XXXX() no se puede llamar nunca y por tanto no genera código para ello.
Si ese 0 está ahí como artefacto raro resultante en el código por un comportamiento indefinido... pues el optimizador elimina todo. Desde fuera pareciera como que el compilador se está dando cuenta del comportamiento indefinido y en vez de avisar omite todo el código. Está siendo malo a posta, ¿no? Pues si tan sencillo te parece haz tú el compilador de C++. Y ya me cuentas. Suerte, ¿eh?
- Venga, me has convencido, puedo entender que se quiera diferenciar comportamiento definido de indefinido y de sin especificar. Pero es que al principio has nombrado otro mas: dependiente de la implementación. ¿Eso de qué va?
Bueno, pues en la explicación anterior he simplificado. Entre código predecible y código de comportamiento indefinido no hay un sólo tipo de caso, hay dos.
Comportamiento definido por la implementación es cuando el estándar lista una o más cosas que está permitido que ocurran en un caso particular; obliga a que no sucedan cosas no listadas pero también obliga a que la alternativa que tomen sea predecible, siempre haga lo mismo... y además debe documentarse qué es lo que hace.
Por contra, en el comportamiento sin especificar el estándar lista una o más cosas que está permitido que ocurran en un caso particular; obliga a que no sucedan cosas no listadas pero no obliga a que el comportamiento sea predecible. En una compilación podría suceder una cosas y en otra la contraria, por ejemplo. O en una parte de código sucede una cosa y en otra otra.
Si quieres una regla nemotécnica para todo esto podrías decir que:
  • lo más peligroso es el comportamiento indefinido
  • algo menos peligroso es el comportamiento sin especificar. El rango de las cosas que puede pasar está fijado en el estándar... por mucho que no sepas qué sucederá exáctamente.
  • Menos peligroso aún es el comportamiento definido por la implementación. Basta con que te leas la documentación del compilador que uses para saber qué va a pasar. Tendrás problemas haciendo código portable, pero nada mas.
  • Y lo más seguro es, claro está, el comportamiento definido
- Pues, oyes, ahora entiendo porqué hay gente que dice que Java es mejor que C++. Sólo por el hecho de que Java no admite comportamientos indefinidos, sin especificar o definidos por la implementación, ya lo hace muy superior a C++.
Bueno, te lo puedes creer, si. Pero no seas tan inocente.
En primer lugar, hasta Java contiene comportamiento indefinido [Ver "J. Bloch y N. Gafter. Java Puzzlers: Traps, Pitfalls, and Corner Cases. Addison-Wesley Professional, Julio 2005"]
Pero es que para mi, el primer comportamiento indefinido es el creado por los programadores, que son seres humanos. Y las máquinas virtuales Java son creadas por programadores... y lo que es peor ¡en lenguages que no son Java!
Así que mira, no me creo que lo hagan tan bien.
De hecho, si te paras a pensarlo, tampoco gano tanto haciendo que el comportamiento sea definido. Vale, en Java cuando divido por 0 se lanza una excepción que para el programa. En C++ pasa algo malo que suele terminar en que el programa se para. ¿Pues tampoco hay tanta diferencia, no?
- Estás siendo un poco hipócrita, ¿no? Que el 95% del código tenga un comportamiento predecible es mejor que si sólo lo tiene el 80%, ¿no?
Si. Pero teniendo en cuenta que estoy manteniendo una conversación conmigo mismo... esta es la última de mis preocupaciones, ¿verdad?

No hay comentarios:

Publicar un comentario