Programación basada en contratos (I)

Introducción


Un programador de C++ debe lidiar continuamente con múltiples desafíos: garantizar una correcta recuperación de los programas ante excepciones, prevenir bugs tales como accesos fuera de rango o referencias colgantes, adoptar estrategias ante un posible agotamiento de la memoria y un largo etcétera.  En este post trataremos de arrojar algo de luz sobre cómo manejar tales situaciones de forma correcta. No en vano, una de las características definitorias de cualquier lenguaje de programación es, precisamente, el conjunto de herramientas que pone a nuestra disposición para realizar esta importante labor.

Centraremos nuestra atención, en particular, en las llamadas a funciones. Distinguiremos dos categorías principales de incidencias por las que una función puede no alcanzar sus objetivos: recuperables e irrecuperables. Un lenguaje más reciente, Rust, maneja dichas categorías de forma claramente diferenciada, proporcionando un tipo de retorno Result<T,E> para errores recuperables (el cual contiene un valor de tipo esperado T o bien un elemento de error E) y una macro panic! para detener la ejecución del proceso en caso de producirse una incidencia irrecuperable [1]. Por defecto, C++ trata la primera categoría mediante el uso de excepciones, cuya utilidad pondremos en valor en este post, aunque el empleo de tipos variantes sea sin duda factible gracias a bibliotecas como Boost.Outcome [2]. Como discutiremos también, el tratamiento de errores irrecuperables debería descansar en el uso de contratos.


Taxonomía para el manejo de incidencias


Tal y como señala Herb Sutter (Microsoft) en su propuesta P0709 de extensión del lenguaje C++ [3]:
Una función debería informar al código que la invoque acerca de la imposibilidad de alcanzar sus objetivos si y sólo si:
  1. Se cumplieron sus precondiciones, pero...
  2. ...no pudo lograrse la consecución de sus postcondiciones de 'retorno con éxito', si bien el código invocador podría intentar recuperarse. En el caso del constructor de una clase, dichas postcondiciones consistirían en el correcto establecimiento de sus invariantes.

Hablaríamos en este caso (y sólo en este caso) de un error recuperable. La función recurriría a mecanismos tales como el lanzamiento de excepciones o el retorno de un tipo variante result<T,E> para comunicar el error al código invocador, de forma que éste pueda realizar las acciones oportunas ante dicha contingencia. Observemos que la transmisión de información relevante acerca de la causa del fallo es una característica clave del tratamiento de errores.

Conviene aclarar, en este punto, que hay situaciones en las que existe una razón clara por la que puede no existir un valor definido para un tipo retornado T, siendo la ausencia de valor tan natural como la generación de un valor regular. Pensemos por ejemplo en un parámetro de configuración que, de dejarse sin especificar, permita a la aplicación decidir por sí misma su valor. Tales situaciones no deberían considerarse errores en ningún caso --ciertamente, no sería necesario explicar los motivos de la ausencia de valor, al ser una posibilidad que se da por supuesta--, pudiendo ser gestionados de forma natural con tipos como std::optional<T> o boost::optional<T> [4].

Frente a los mencionados errores recuperables, podemos enfrentarnos a situaciones más graves como la corrupción de la máquina abstracta (debida, por ejemplo, a un stack overflow) o un bug del programa (como el acceso fuera de rango en un array o la desreferencia de un puntero nulo). Queda claro que incidencias de este tipo corromperán el estado del proceso, resultando inviable la recuperación posterior de forma programada y haciendo inútil cualquier notificación al código.

A este respecto, observemos que la biblioteca estándar de C++ ha lidiado tradicionalmente con los errores lógicos derivados del incumplimiento de precondiciones mediante la emisión de excepciones de tipo std::logic_error y sus derivados (como std::domain_error y std::out_of_range). Esto se ha demostrado un error de concepto, pues tales incumplimientos de precondiciones no constituyen errores recuperables, sino bugs del programa que conducen a comportamiento indefinido. En efecto, una función que opere bajo condiciones distintas a las originalmente asumidas producirá seguramente resultados inesperados. A poco que reflexionemos sobre ello concluiremos que carece de sentido informar de tales problemas al código invocador (independientemente del canal de comunicación que pudiéramos escoger, ya fuesen excepciones u otros). Sencillamente, no podemos esperar que dicho código esté preparado para solventar una violación de precondiciones que él mismo ha ignorado [5]. La forma más adecuada de lidiar con estas incidencias es a través de la programación basada en contratos y la política a seguir por defecto ante su incumplimiento debería consistir en abortar/terminar el proceso de forma rápida.

Todo ello conduce, de forma natural, a la siguiente taxonomía para el manejo de incidencias durante la ejecución de una función (basada en la tabla presentada en [6]):

Tipo de incidencia¿Cómo proceder?¿Qué agente debe gestionar la incidencia?
Error recuperable de forma programada
Excepciones, retorno de tipos variantes (Boost.Outcome), uso de errno, etcEl código que invoque a la función
Bug del programa (incluyendo cualquier incumplimiento de precondiciones, aserciones  y postcondiciones)
Contratos (terminate por defecto; posible emisión de excepciones en unit testing)El programador
Corrupción de la máquina abstracta (por ejemplo, por un stack overflow)
Directamente terminateEl usuario

La afirmación tan manida acerca de que "las excepciones deben emplearse únicamente en circunstancias excepcionales" constituye sin duda una de las tautologías más extendidas en el ámbito de la programación. La tabla anterior, sin embargo, nos ayuda a clarificar su uso y desechar viejos hábitos indebidos. En esencia, todo error recuperable ocurrido en una función debería notificarse por defecto mediante una excepción, aunque ciertamente existan otras posibilidades como retornar un valor de tipo variante boost::outcome::result [2] en analogía con el procedimiento empleado por Rust citado en la introducción.


Errores recuperables vs incumplimiento de precondiciones


Toda función debería especificar claramente sus precondiciones. Actualmente, el lenguaje C++ carece de herramientas nativas para proporcionar dicha información en la declaración de una operación (existe un grupo de trabajo específico a tal fin de cara al estándar C++23), por lo que recurriremos aquí a la introducción de meros comentarios en el código.

Imaginemos una función que inicialice un subsistema mediante la lectura de un archivo config.ini:

   void init_subsystem()     // precondiciones: ninguna    {       auto ifs = std::ifstream{"config.ini"};       if (!ifs)          throw std::ios_base::failure{"Unable to open config.ini"};       // otras posibles emisiones de excepciones...    }

La función carece de precondiciones, particularmente en lo que respecta a dicho archivo de configuración, de forma que la responsabilidad de establecer un flujo de lectura con éste recae en la función, no en el agente invocador. Cualquier fallo en dicha acción (así como cualquier otro error que impida a la función alcanzar sus postcondiciones) deberá ser notificado convenientemente como error potencialmente recuperable, típicamente con una excepción.

Por contra, consideremos el constructor de una clase Rectangle que tome dos doubles como parámetros para indicar la base y la altura de un rectángulo. Asumamos que la documentación de la función establece como precondición que ambas longitudes sean positivas. Supongamos entonces que uno de los valores proporcionado como argumento en la invocación de dicho constructor fuese negativo. Podríamos vernos tentados a manejar esta incidencia mediante la emisión de una excepción desde el constructor. Sin embargo, insistamos: esta incidencia constituye una violación de las precondiciones del constructor y, como tal, debería ser tratada como un bug del programa. La responsabilidad en la adecuación de los datos recae ahora en el agente invocador, no en el constructor. Si dicho agente no ha tenido la precaución de respetar las precondiciones, ¿acaso podemos esperar que esté preparado para manejar una excepción que avise de su incumplimiento? La respuesta es obviamente negativa. Como primera aproximación, manejaríamos la incidencia mediante una aserción clásica en el cuerpo de la función, la cual podría abortar el proceso en modo DEBUG y forzar a que el programador corrija y proteja adecuadamente su punto de invocación:

   class Rectangle {       double width_, height_;    public:       Rectangle(double width, double height) noexcept        // precondiciones: width>0.0 y height>0.0          : width_{width}, height_{height}       {          assert(width_ > 0.0 and height_ > 0.0);       }       // ...    };

Esta solución evita comprobaciones dobles innecesarias por parte del código invocador y el constructor una vez definida la macro NDEBUG. Asimismo, observemos que la ausencia de lanzamiento de excepciones nos ha permitido etiquetar la función como noexcept, dando así pie a optimizaciones por parte del compilador. La relación entre aserciones, noexcept y unit testing, más compleja que el ejemplo considerado, será discutida en próximos artículos de esta serie.


A vueltas con las excepciones...


Multitud de desarrolladores han cuestionado históricamente la conveniencia de emplear excepciones en entornos embebidos o sistemas de tiempo real críticos, entre otros, debido a varias razones [3]:
  • Su carácter no-determinista (las cláusulas catch requieren el uso de RTTI), con un coste estadístico temporal y espacial generalmente impredecible.
  • Su coste no-nulo, al incrementar el tamaño de los ejecutables típicamente en más de un 10%.
  • La penalización en runtime, pues el tiempo de ejecución de un bloque try-catch puede llegar a ser, en el peor de los casos, cientos de veces superior a una sentencia de retorno habitual.
Adicionalmente, algunos programadores señalan que:
  • Las excepciones dificultan el razonamiento acerca del flujo normal de un programa, al ser su propagación automática y no explícita.
Es por ello que los principales compiladores proporcionan flags para deshabilitar las excepciones en situaciones en las que su uso se considere desaconsejable. Sin embargo, esto conduce típicamente a la implementación de código no conforme con el estándar ISO del lenguaje e imposibilita en gran medida el empleo de la biblioteca estándar. En efecto, tengamos presente que la forma de comunicar que se ha producido un error durante la invocación de un constructor o un operador sobrecargado es, precisamente, a través de la emisión de excepciones.

Dichos problemas son abordados en la propuesta P0709 [3], que busca la introducción en C++ de un nuevo mecanismo de excepciones deterministas sin sobrecoste asociado.

Muchos expertos --entre ellos, de forma notable, el creador del lenguaje Bjarne Stroustrup-- sostienen sin embargo que dichas objeciones al empleo de excepciones no están en muchos casos bien fundamentadas [7]. En efecto, no se dispone de mediciones precisas en bases de código reales acerca del coste de las excepciones frente a otros tratamientos de errores como, por ejemplo, los basados en un return típico de valores de error. Asimismo, es lamentablemente frecuente un empleo abusivo de las excepciones en código real: éstas deberían ocurrir raramente, en una proporción típica de 1:100 ó 1:1000. Por último, se ha hecho notar la existencia de un gran margen de mejora en las implementaciones actuales de las excepciones en los compiladores [7, 8].

En este caso, como en todos los demás, no debemos actuar guiados por prejuicios. Las excepciones constituyen el mecanismo idiomático de comunicación de errores en C++ y, como tal, deberíamos emplearlo sin temor. Ante todo, no conviene llegar a conclusiones precipitadas acerca del rendimiento de nuestros códigos sin antes haber realizado las mediciones oportunas.



En el segundo post de esta serie analizaremos en detalle el diseño por contrato de funciones, así como la implementación de una macro de aserción avanzada, configurable por el usuario.


Referencias bibliográficas:
  1. The Rust Programming Language - Error handling - https://doc.rust-lang.org/book/ch09-00-error-handling.html
  2. Boost.Outcome 2.1 Library - https://www.boost.org/doc/libs/1_72_0/libs/outcome/doc/html/index.html
  3. Herb Sutter - P0709 R4 - Zero-overhead deterministic exceptions: Throwing values - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0709r4.pdf
  4. Boost.Optional Library - When to use optional - https://www.boost.org/doc/libs/1_72_0/libs/optional/doc/html/boost_optional/tutorial/when_to_use_optional.html
  5. Andrzej's C++ blog - Preconditions - https://akrzemi1.wordpress.com/2013/01/04/preconditions-part-i/
  6. CppCon 2019 - Herb Sutter - De-fragmenting C++: Making Exceptions and RTTI More Affordable and Usable - https://youtu.be/ARYP83yNAWk
  7. Bjarne Stroustrup - P1947 R0 - C++ exceptions and alternatives - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1947r0.pdf
  8. Gor Nishanov - P1676 R0 - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1676r0.pdf

1 comentario:

  1. Muy buen Blog, se agradece la variedad de contenido que ofreces,y mas por que demuestras C++ hasta sus ultimas versiones, saludos

    ResponderEliminar