Programación basada en contratos (II)

Introducción


Diagrama basado en commons.wikimedia.org/wiki/File:Design_by_contract.svg (CC0 1.0)
Un contrato es un conjunto de precondiciones, postcondiciones y aserciones (todas ellas predicados) asociadas a una función. En concreto [1]:
  • Las precondiciones establecen los requisitos a verificar por los argumentos de una función (y/o el estado de otros objetos de la que dependa) en el punto de entrada de su ejecución. La responsabilidad de su cumplimiento recae en el cliente de la función (caller).
  • Las postcondiciones fijan los requisitos a cumplir por los valores retornados por la función (y/o el estado de otros objetos) al finalizar su ejecución. Son una garantía ofrecida por la función (callee) al cliente.
  • Las aserciones, por su parte, establecen condiciones que deban satisfacerse en puntos específicos del cuerpo de la función. Suelen emplearse para verificar el mantenimiento de los invariantes de una clase.

El lenguaje C++ carece en la actualidad de construcciones que permitan especificar las precondiciones y postcondiciones de una función en su interfaz, aunque existe un grupo de estudio dedicado a tal efecto, el SG21, que podría disponer de una propuesta operativa de cara al estándar C++23. Hasta ese momento, los tres tipos señalados de predicados deberían expresarse en el cuerpo de la función a través de expresiones assert o soluciones específicas proporcionadas por bibliotecas como GSL [2], considerándose todo incumplimiento de contrato como un bug del programa.

Por supuesto, las postcondiciones que busquen evitar la fuga de recursos (memoria dinámica, socketsmutexes, etcétera) pueden gestionarse directamente a través de la técnica RAII (puedes consultar este post anterior para más detalles):

   auto mtx = std::mutex{};    void thread_safe_op(std::vector<int>& v)    {       auto _ = std::lock_guard{mtx}; // adquirimos el mutex       // modificamos de forma segura el vector compartido...    } // liberamos el mutex automáticamente en el cierre del ámbito

En este artículo diseñaremos dos macros, PRE y POST, destinadas a la comprobación de precondiciones y postcondiciones, respectivamente. A modo de ejemplo, consideremos la siguiente función para el cálculo de áreas de rectángulos:

   [[nodiscard]] auto rectangle_area(int width, int height) -> int    {       PRE(width > 0 and heigth > 0);       auto const res = width * heigth;       POST(res > 0);       return res;    }

La función anterior establece como precondiciones que la base y la altura del rectángulo deban ser ambas positivas. Asimismo, garantiza el retorno de un área positiva a modo de postcondición, en previsión de que el producto de longitudes pueda causar un desbordamiento aritmético de enteros.


Biblioteca minimalista para la programación con contratos


Nos proponemos diseñar una biblioteca sencilla, constituida por un único fichero de cabecera, que proporcione la funcionalidad básica del diseño basado en contratos.

En particular, deseamos que el programador pueda configurar sus aserciones. Por defecto, éstas mostrarán la información de diagnóstico relevante a través de la salida de error estándar (stderr) y abortarán el proceso, imitando así el funcionamiento de la macro clásica assert. Esta política recibirá el nombre de fail_by_abort [3]. Sin embargo, podremos también especificar políticas de diagnóstico diferentes que involucren, por ejemplo, la emisión de excepciones, la realización de un stack trace o el registro en un fichero log, entre otras.

Como ficheros de cabecera estándar, emplearemos <cstdio> y <cstdlib>. Los tipos y funciones de nuestra implementación estarán contenidos en el espacio de nombres contracts. Un objeto de tipo Assert_record guardará registro de la expresión evaluada como false,  el nombre del fichero, el nombre de la función y la línea de código donde se produzca el fallo de la aserción:

namespace contracts {     struct Assert_record {        char const* expression,                  * file_name,                  * function_name;        unsigned long long line_number;     };     // ...

A continuación, a modo de funciones auxiliares, proporcionaremos dos políticas de diagnóstico típicas: fail_by_abort, adoptada por defecto y explicada anteriormente, y fail_by_exception, basada en el lanzamiento de un objeto de tipo Assert_record:

    inline [[noreturn]] void fail_by_abort(Assert_record const& info) noexcept     {        std::fprintf(stderr,                     "abort: assertion (%s) failed in function %s, line %llu, file %s()\n",                     info.comment, info.file_name, info.line_number, info.function_name);        std::abort();      }     inline [[noreturn]] void fail_by_exception(Assert_record const& info)     {        throw info;     }     // ...

Como podemos observar, todas las políticas de diagnóstico compartirán una signatura común, tomando una referencia a un objeto constante Assert_record como único argumento y void como tipo de retorno. En el caso de las dos funciones anteriores, el atributo [[noreturn]] indica al compilador que éstas no llegarán en realidad a retornar, sino que verán interrumpidos sus flujos de ejecución necesariamente; ello da pie a posibles optimizaciones en compilación.

Definiremos un alias Handler_type para punteros a función con la mencionada signatura, así como una variable inline de este tipo --con vinculación externa (external linkage) y duración de almacenamiento estática--, de nombre handler, que por defecto almacenará la dirección en memoria de la función fail_by_abort:

    using Handler_type = void (*)(Assert_record const&);     namespace detail {        inline auto handler = Handler_type{&fail_by_abort};     }     // ...

Llegados a este punto, proporcionaremos tres funciones básicas para obtener (get_handler) o establecer (set_handler) la política de diagnóstico, así como para invocarla (handle) una vez recibida la información relevante desde el punto de fallo de una aserción:

    inline [[nodiscard]] auto get_handler() noexcept -> Handler_type  {   return detail::handler;  }     inline void set_handler(Handler_type new_handler) noexcept {   detail::handler = new_handler;  }     inline void handle(Assert_record const& info) { detail::handler(info); } } // final del espacio de nombres 'contracts'

Finalmente, concluiremos nuestro fichero de cabecera con la definición de las macros configurables ASSERT, PRE y POST:

   #ifdef CONTRACTS_ON       #define ASSERT(expression) \          do { \             if (!static_cast<double>(expression)) { \                auto const info = contracts::Assert_record{#expression, \                                                       __FILE__, __func__, __LINE__}; \                contracts::handle(info); \             } \          } while (false); \          static_assert(true, "force trailing semicolon"    #else       #define ASSERT(expression) \          static_assert(true, "force trailing semicolon")    #endif    #define PRE(expression) ASSERT(expression)    #define POST(expression) ASSERT(expression)

La definición de la macro CONTRACTS_ON sirve para activar las aserciones, empleadas comúnmente en modo DEBUG. En tal caso, podemos observar que una aserción creará un objeto de tipo Assert_record conteniendo la expresión #expression evaluada como falsa, el nombre del fichero __FILE__, el nombre de la función __func__ y el número de la línea de código __LINE__ donde se haya producido la incidencia. Dicho objeto será entonces remitido a la función configurable handle().


Lanzamiento de excepciones desde aserciones


Las aserciones definidas en los códigos anteriores podrían lanzar excepciones si así decidiéramos configurarlas (puedes revisar la definición de la función rectangle_area en la introducción de este artículo):

   contracts::set_handler(&contracts::fail_by_exception);    try {       auto const a = rectangle_area(-52);       // ...    }    catch (contracts::Assert_record const& info) {       std::fprintf(stderr,                    "assertion threw from function %s, line %llu, file %s()\n",                    info.function_name, info.line_number, info.file_name);    }

El lanzamiento de excepciones por parte de aserciones es una situación más común de lo que en un principio pudiese parecer. En efecto, con el fin de comprobar aserciones defensivas sin provocar el crash de un test driver, se requiere generalmente [4]:
  • La posibilidad de registrar diferentes assertion handlers para la macro de aserción.
  • La habilidad de devolver el control desde el handler hasta el test driver.
  • La capacidad para manejar excepciones inesperadas desde el test driver.
El mecanismo más simple para lograr esto, empleado por muchos test drivers, consiste en el lanzamiento de una excepción propia del test framework que contenga la información acerca de la aserción. Ello puede interferir, sin embargo, con la declaración de funciones noexcept, como analizaremos en el futuro.

Existe un debate en curso acerca de la conveniencia de permitir que las aserciones emitan excepciones. GSL ha eliminado tal posibilidad en su versión 3.0.0 [5], un decisión que sin embargo ha encontrado la oposición de algunos usuarios, al requerir la migración a entornos de prueba que dispongan de death tests como Google Test [6].



En el próximo post de esta serie analizaremos en más detalle la relación entre unit testing, aserciones y el especificador noexcept.


Referencias bibliográficas:
  1. Prof. J. Daniel García - Contracts programming after C++17 - https://www.arcos.inf.uc3m.es/jdgarcia/2017/05/04/contracts-programming-after-c17/
  2. GSL: Guidelines Support Library - https://github.com/microsoft/GSL
  3. CppCon 2019: Joshua Berne 'Contract use: Past, Present, and Future' - https://youtu.be/mmyIZzqh5ls
  4. Alisdair Meredith, John Lakos - N3248 - noexcept Prevents Library Validation - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2011/n3248.pdf
  5. GSL 3.0.0 Release - https://devblogs.microsoft.com/cppblog/gsl-3-0-0-release/
  6. Google Testing and Mocking Framework - https://github.com/google/googletest

No hay comentarios:

Publicar un comentario