
En C ++ 20, apareció la programación por contrato. Hasta la fecha, ningún compilador ha implementado soporte para esta característica.
Pero ahora hay una manera de tratar de usar contratos de C ++ 20, como se describe en el estándar.
TL; DR
Hay un sonido de bifurcación que respalda los contratos. Usando su ejemplo, le digo cómo usar contratos para que tan pronto como aparezca una característica en su compilador favorito, pueda comenzar a usarla de inmediato.
Ya se ha escrito mucho sobre la programación por contrato, pero en pocas palabras le diré qué es y para qué sirve.
Lógica de Hoar
El paradigma de los contratos se basa en la lógica de Hoar ( 1 , 2 ).
La lógica de Hoar es una forma de demostrar formalmente la corrección de un algoritmo.
Opera con conceptos como precondición, poscondición e invariante.
Desde un punto de vista práctico, el uso de la lógica de Hoar es, en primer lugar, una forma de demostrar formalmente la corrección de un programa en los casos en que los errores pueden conducir al desastre o la pérdida de vidas. En segundo lugar, una forma de aumentar la confiabilidad del programa, junto con análisis y pruebas estáticos.
Programación de contratos
( 1 , 2 )
La idea principal de los contratos es que, por analogía con los contratos en los negocios, se describen acuerdos para cada función o método. Estos arreglos deben ser observados tanto por la persona que llama como por la persona que llama.
Una parte integral de los contratos es al menos dos modos de ensamblaje: depuración y almacenamiento. Los contratos deben comportarse de manera diferente según el modo de compilación. La práctica más común es verificar los contratos en el ensamblaje de depuración e ignorarlos en el supermercado.
A veces, los contratos también se verifican en el ensamblaje del producto y su incumplimiento puede, por ejemplo, generar una excepción.
La principal diferencia entre el uso de contratos desde el enfoque "clásico" es que la persona que llama debe cumplir con las condiciones previas de la persona que llama, que se describen en el contrato, y la persona que llama debe cumplir con sus condiciones posteriores e invariantes.
En consecuencia, la parte llamada no está obligada a verificar la exactitud de sus parámetros. Esta obligación es asignada a la persona que llama por el contrato.
El incumplimiento de los contratos debe detectarse en la etapa de prueba y complementa todo tipo de pruebas: integración modular, etc.
A primera vista, el uso de contratos dificulta el desarrollo y degrada la legibilidad del código. De hecho, todo lo contrario es cierto. Los adherentes a la tipificación estática encontrarán más fácil evaluar los beneficios de los contratos, porque su opción más simple es describir los tipos en la firma de métodos y funciones.
Entonces, ¿cuáles son los beneficios de los contratos?
- Mejore la legibilidad del código a través de documentación explícita.
- Mejore la confiabilidad del código al complementar las pruebas.
- Permita que los compiladores usen optimizaciones de bajo nivel y generen un código más rápido basado en el cumplimiento del contrato. En este último caso, el incumplimiento del contrato en la asamblea de liberación puede conducir a UB.
Programación de contratos en C ++
La programación por contrato se implementa en muchos idiomas. Los ejemplos más llamativos son Eiffel , donde el paradigma se implementó por primera vez, y D , en D, los contratos son parte del lenguaje.
En C ++, antes del estándar C ++ 20, los contratos podían usarse como bibliotecas separadas.
Este enfoque tiene varias desventajas:
- Sintaxis muy torpe con macros.
- La falta de un solo estilo.
- Incapacidad de usar contratos por parte del compilador para optimizar el código.
Las implementaciones de la biblioteca generalmente se basan en el uso de las antiguas directivas de afirmación y preprocesador que verifican el indicador de compilación.
El uso de contratos de esta forma realmente hace que el código sea feo e ilegible. Esta es una de las razones por las cuales el uso de contratos en C ++ es poco practicado.
Mirando hacia el futuro, mostraré cómo se verá el uso de contratos en C ++ 20.
Y luego, analizaremos todo esto con más detalle:
int f(int x, int y) [[ expects: x > 0 ]]
Prueba
Desafortunadamente, por el momento, ninguno de los compiladores ampliamente utilizados ha implementado soporte de contrato.
Pero hay una salida.
El grupo de investigación ARCOS de la Universidad Carlos III de Madrid implementó el soporte experimental para contratos en la bifurcación clang ++.
Para no "escribir código en una hoja de papel", sino para poder probar inmediatamente nuevas oportunidades en los negocios, podemos recopilar este tenedor y usarlo para probar los ejemplos a continuación.
Las instrucciones de ensamblaje se describen en el archivo Léame del repositorio de github
https://github.com/arcosuc3m/clang-contracts
git clone https://github.com/arcosuc3m/clang-contracts/ mkdir -p clang-contracts/build/ && cd clang-contracts/build/ cmake -G "Unix Makefiles" -DLLVM_USE_LINKER=gold -DBUILD_SHARED_LIBS=ON -DLLVM_USE_SPLIT_DWARF=ON -DLLVM_OPTIMIZED_TABLEGEN=ON ../ make -j8
No tuve problemas durante el ensamblaje, pero recopilar las fuentes lleva mucho tiempo.
Para compilar los ejemplos, deberá especificar explícitamente la ruta al binario clang ++.
Por ejemplo, se ve algo así para mí.
/home/valmat/work/git/clang-contracts/build/bin/clang++ -std=c++2a -build-level=audit -g test.cpp -o test.bin
He preparado ejemplos para que le resulte conveniente examinar los contratos utilizando ejemplos de código real. Sugiero, antes de comenzar a leer la siguiente sección, clonar y compilar ejemplos.
git clone https://github.com/valmat/cpp20-contracts-examples/ cd cpp20-contracts-examples make CPP=/path/to/clang++
Aquí /path/to/clang++
ruta al binario clang++
de su ensamblador de compilador experimental.
Además del compilador en sí, el grupo de investigación ARCOS preparó su versión de Compiler Explorer para su tenedor.
Programación de contratos en C ++ 20
Ahora nada nos impide comenzar a investigar las posibilidades que ofrece la programación por contrato, e inmediatamente probar estas oportunidades en la práctica.
Como se mencionó anteriormente, los contratos se construyen a partir de precondiciones, postcondiciones e invariantes (declaraciones).
En C ++ 20, los atributos con la siguiente sintaxis se utilizan para esto
[[contract-attribute modifier identifier: conditional-expression]]
Donde contract-attribute
puede tomar uno de los siguientes valores:
espera , asegura o afirma .
expects
utiliza para las condiciones previas, ensures
condiciones posteriores y assert
para las declaraciones.
conditional-expression
es una expresión booleana que se valida en un predicado de contrato.
modifier
e identifier
pueden omitirse.
¿Por qué necesito un modifier
? Escribiré un poco más abajo.
identifier
usa solo con ensures
y se usa para representar el valor de retorno.
Las condiciones previas tienen acceso a los argumentos.
Las condiciones posteriores tienen acceso al valor devuelto por la función. La sintaxis se usa para esto.
[[ensures return_variable: expr(return_variable)]]
Donde return_variable
cualquier expresión válida para la variable.
En otras palabras, las condiciones previas están destinadas a declarar restricciones impuestas a los argumentos aceptados por la función, y las condiciones posteriores para declarar restricciones impuestas sobre el valor devuelto por la función.
Se cree que las condiciones previas y posteriores son parte de la interfaz de la función, mientras que las declaraciones son parte de su implementación.
Los predicados de condición previa siempre se evalúan inmediatamente antes de que se ejecute la función. Las condiciones posteriores se satisfacen inmediatamente después de que la función de control pasa al código de llamada.
Si se lanza una excepción en una función, no se comprobará la condición posterior.
Las condiciones posteriores se comprueban solo si la función se completa normalmente.
Si se produjo una excepción al verificar la expresión en el contrato, se llamará a std::terminate()
.
Las condiciones previas y posteriores siempre se describen fuera del cuerpo de la función y no pueden tener acceso a las variables locales.
Si las condiciones previas y posteriores describen un contrato para un método de clase pública, no pueden tener acceso a los campos de clase privados y protegidos. Si el método de la clase está protegido, entonces hay acceso a los datos públicos y protegidos de la clase, pero no a los privados.
La última limitación es completamente lógica, dado que el contrato es parte de la interfaz del método.
Las declaraciones (invariantes) siempre se describen en el cuerpo de una función o método. Por diseño, son parte de la implementación. Y, en consecuencia, pueden tener acceso a todos los datos disponibles. Incluyendo variables de función local y campos de clase privados y protegidos.
ejemplo 1
Definimos dos precondiciones, una poscondición y una invariante:
int foo(int x, int y) [[ expects: x > y ]]
ejemplo 2
Una condición previa de un método público no puede referirse a un campo privado o protegido:
struct X {
No se permite la modificación de variables dentro de las expresiones descritas por los atributos del contrato. Si está roto, habrá UB.
Las expresiones descritas en los contratos no deberían tener efectos secundarios. Aunque los compiladores pueden verificar esto, no están obligados a hacerlo. La violación de este requisito se considera comportamiento indefinido.
struct X { int m = 5; int foo(int n) [[ expects: n < m++ ]]
El requisito de no cambiar el estado del programa en las expresiones contractuales se volverá obvio un poco más bajo cuando hablo de los niveles de modificadores de contrato y modos de construcción.
Ahora solo noto que el programa correcto debería funcionar como si no hubiera ningún contrato.
Como señalé anteriormente, en el contrato puede especificar tantas condiciones previas y posteriores como desee.
Todos ellos serán revisados en orden. Pero las condiciones previas siempre se verifican antes de ejecutar la función, y las condiciones posteriores inmediatamente después de salir de ella.
Esto significa que las condiciones previas siempre se verifican primero, como se ilustra en el siguiente ejemplo:
int foo(int n) [[ expects: expr(n) ]]
Las expresiones en las condiciones posteriores pueden referirse no solo al valor devuelto por la función, sino también a los argumentos de la función.
int foo(int &n) [[ ensures: expr(n) ]];
En este caso, puede omitir el identificador del valor de retorno.
Si la condición posterior se refiere al argumento de la función, este argumento se considera en el punto de salida de la función , y no en el punto de entrada, como es el caso de las condiciones previas.
No hay forma de referirse al valor original (en el punto de entrada de la función) en la condición posterior.
ejemplo :
void incr(int &n) [[ expects: 3 == n ]] [[ ensures: 4 == n ]] {++n;}
Los predicados en los contratos pueden referirse a variables locales solo si la vida útil de estas variables corresponde al tiempo de cálculo del predicado.
Por ejemplo, para constexpr
funciones constexpr
, no se puede hacer referencia a las variables locales a menos que se conozcan en tiempo de compilación.
ejemplo :
int a = 1; constexpr int b = 100; constexpr int foo(int n) [[ expects: a <= n ]]
Contratos para punteros de función
No puede definir contratos para un puntero de función, pero puede asignar la dirección de una función para la cual se define un contrato a un puntero de función.
ejemplo :
int foo(int n) [[expects: n < 10]] { return n*n; } int (*pfoo)(int n) = &foo;
Llamar a pfoo(100)
violará el contrato.
Contratos de herencia
La implementación clásica del concepto de contratos sugiere que las condiciones previas pueden debilitarse en subclases, las condiciones posteriores y los invariantes pueden fortalecerse en subclases.
En una implementación de C ++ 20, este no es el caso.
Primero, los invariantes en C ++ 20 son parte de una implementación, no una interfaz. Por esta razón, pueden fortalecerse y debilitarse. Si no hay assert
en la implementación de la función virtual, no se heredará.
En segundo lugar, se requiere que al heredar las funciones sean idénticas a ODR .
Y, dado que las condiciones previas y posteriores son parte de la interfaz, en el heredero deben coincidir exactamente.
Además, se puede omitir la descripción de precondiciones y postcondiciones durante la herencia. Pero si se declaran, entonces deben coincidir exactamente con la definición en la clase base.
ejemplo :
struct Base { virtual int foo(int n) [[ expects: n < 10 ]] [[ ensures r: r > 100 ]] { return n*n; } }; struct Derived1 : Base { virtual int foo(int n) override [[ expects: n < 10 ]] [[ ensures r: r > 100 ]] { return n*n*2; } }; struct Derived2 : Base {
ObservaciónDesafortunadamente, el ejemplo anterior no funciona en el compilador experimental como se esperaba.
Si foo
de Derived2
contrato, no se heredará de la clase base. Además, el compilador le permite determinar para una subclase un contrato que no coincide con el contrato base.
Otro error experimental del compilador:
el registro debe ser sintácticamente correcto
virtual int foo(int n) override [[expects: n < 10]] {...}
Sin embargo, en este formulario, recibí un error de compilación
inheritance1.cpp:20:36: error: expected ';' at end of declaration list virtual int foo(int n) override ^ ;
y tuvo que ser reemplazado por
virtual int foo(int n) [[expects: n < 10]] override {...}
Creo que esto se debe a la peculiaridad del compilador experimental, y el código de corrección de sintaxis funcionará en las versiones de lanzamiento de los compiladores.
Modificadores de contrato
Las verificaciones de predicados contractuales pueden incurrir en costos de procesamiento adicionales.
Por lo tanto, una práctica común es verificar los contratos en las versiones de desarrollo y prueba e ignorarlos en la versión de lanzamiento.
Para estos fines, el estándar ofrece tres niveles de modificadores de contrato. Usando modificadores y claves de compilación, el programador puede controlar qué contactos se verifican en el ensamblaje y cuáles se ignoran.
default
: este modificador se usa por defecto. Se supone que el costo computacional de verificar la ejecución de una expresión con este modificador es pequeño en comparación con el costo de calcular la función en sí.audit
: este modificador supone que el costo computacional de verificar la ejecución de una expresión es significativo en comparación con el costo de calcular la función en sí.axiom
: este modificador se usa si la expresión es declarativa. No verificado en tiempo de ejecución. Sirve para documentar la interfaz de una función, para su uso por analizadores estáticos y un optimizador de compilador. Las expresiones con el modificador axiom
nunca se evalúan en tiempo de ejecución.
Ejemplo
[[expects: expr]]
Con los modificadores, puede determinar qué comprobaciones se utilizarán en las versiones de sus ensamblados y cuáles se desactivarán.
Vale la pena señalar que incluso si no se realiza la verificación, el compilador tiene el derecho de usar el contrato para optimizaciones de bajo nivel. Aunque la marca de compilación puede deshabilitar la verificación del contrato, el incumplimiento del contrato conduce a un comportamiento indefinido del programa.
A discreción del compilador, se pueden proporcionar facilidades para permitir la axiom
expresiones marcadas como axiom
.
En nuestro caso, esta es una opción de compilación
-axiom-mode=<mode>
-axiom-mode=on
activa el modo axiom y, en consecuencia, desactiva la verificación de reclamaciones con el identificador axiom
,
-axiom-mode=off
el modo axiom y, en consecuencia, habilita la verificación de sentencias con el identificador axiom
.
ejemplo :
int foo(int n) [[expects axiom: n < 10]] { return n*n; }
Un programa se puede compilar con tres niveles diferentes de verificación:
off
apaga todas las verificaciones de expresión en los contratos- solo se verifican las expresiones
default
con el modificador default
audit
el modo avanzado cuando todas las verificaciones se realicen con el modificador default
y de audit
La forma exacta de implementar la instalación del nivel de verificación queda a criterio de los desarrolladores del compilador.
En nuestro caso, la opción del compilador se utiliza para esto
-build-level=<off|default|audit>
El valor predeterminado es -build-level=default
Como ya se mencionó, el compilador puede usar contratos para optimizaciones de bajo nivel. Por esta razón, a pesar del hecho de que en el momento de la ejecución algunos de los predicados en los contratos (dependiendo del nivel de verificación) pueden no calcularse, su incumplimiento conduce a un comportamiento indefinido.
Voy a posponer ejemplos de la aplicación de niveles de ensamblaje hasta la siguiente sección, donde pueden hacerse visuales.
Intercepción de incumplimiento de contrato
Dependiendo de las opciones que vaya a utilizar el programa, en caso de incumplimiento de contrato puede haber diferentes escenarios de comportamiento.
Por defecto, un incumplimiento del contrato conduce a la caída del programa, una llamada a std::terminate()
. Pero el programador puede anular este comportamiento al proporcionar su propio controlador e indicarle al compilador que es necesario continuar el programa después del incumplimiento del contrato.
En la compilación, puede instalar el controlador de violación , que se llama cuando se viola el contrato.
La forma de implementar la instalación del controlador es a discreción de los creadores del compilador.
En nuestro caso, esto
-contract-violation-handler=<violation_handler>
La firma del procesador debe ser
void(const std::contract_violation& info)
o
void(const std::contract_violation& info) noexcept
std::contract_violation
equivalente a la siguiente definición:
struct contract_violation { uint_least32_t line_number() const noexcept; std::string_view file_name() const noexcept; std::string_view function_name() const noexcept; std::string_view comment() const noexcept; std::string_view assertion_level() const noexcept; };
Por lo tanto, el controlador le permite obtener información bastante completa sobre exactamente dónde y en qué condiciones se produjo una violación del contrato.
Si se especifica el controlador del controlador de infracción , en caso de incumplimiento del contrato, de manera predeterminada, se llamará a std::abort()
inmediatamente después de su ejecución (sin especificar el controlador, se llamará a std::terminate()
).
El estándar asume que los compiladores proporcionan herramientas que permiten a los programadores continuar ejecutando un programa después del incumplimiento del contrato.
La forma de implementar estas herramientas queda a discreción de los desarrolladores del compilador.
En nuestro caso, esta es una opción de compilación
-fcontinue-after-violation
Las -fcontinue-after-violation
y -contract-violation-handler
se pueden configurar de forma independiente entre sí. Por ejemplo, puede establecer -fcontinue-after-violation
, pero no establecer -contract-violation-handler
. En el último caso, después de un incumplimiento de contrato, el programa simplemente continuará funcionando.
El estándar especifica la capacidad de continuar el programa después de un incumplimiento del contrato, pero se debe tener cuidado con esta función.
Técnicamente, el comportamiento de un programa después del incumplimiento del contrato no está definido, incluso si el programador indicó explícitamente que el programa debería continuar funcionando.
Esto se debe a que el compilador puede realizar optimizaciones de bajo nivel basadas en la ejecución del contrato.
Idealmente, si se produce un incumplimiento de contrato, debe registrar la información de diagnóstico lo antes posible y finalizar el programa. Debe comprender exactamente lo que está haciendo al permitir que el programa funcione después de una infracción.
Defina su controlador y úselo para interceptar un incumplimiento de contrato
void violation_handler(const std::contract_violation& info) { std::cerr << "line_number : " << info.line_number() << std::endl; std::cerr << "file_name : " << info.file_name() << std::endl; std::cerr << "function_name : " << info.function_name() << std::endl; std::cerr << "comment : " << info.comment() << std::endl; std::cerr << "assertion_level : " << info.assertion_level() << std::endl; }
Y considere un ejemplo de incumplimiento de contrato:
#include "violation_handler.h" int foo(int n) [[expects: n < 10]] { return n*n; } int main() { foo(100);
-contract-violation-handler=violation_handler
el programa con las opciones -contract-violation-handler=violation_handler
y -fcontinue-after-violation
y ejecutamos
$ bin/example8-handling.bin line_number : 4 file_name : example8-handling.cpp function_name : foo comment : n < 10 assertion_level : default
Ahora podemos dar ejemplos que demuestran el comportamiento del programa en caso de incumplimiento del contrato en diferentes niveles de ensamblaje y modos de contrato.
Considere el siguiente ejemplo :
#include "violation_handler.h" int foo(int n) [[ expects axiom : n < 100 ]] [[ expects default : n < 200 ]] [[ expects audit : n < 300 ]] { return 2 * n; } int main() { foo(350);
Si lo -build-level=off
con la -build-level=off
, como se esperaba, los contratos no se verificarán.
Al reunirnos con el nivel default
(con la opción -build-level=default
), obtenemos el siguiente resultado:
$ bin/example9-default.bin line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default
Y la asamblea con el nivel de audit
dará:
$ bin/example9-audit.bin line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default line_number : 6 file_name : example9.cpp function_name : foo comment : n < 300 assertion_level : audit line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default
Observaciones
violation_handler
puede lanzar excepciones. En este caso, puede configurar el programa para que la violación del contrato conduzca al lanzamiento de una excepción.
Si la función para la que se describen los contratos se marca como noexcept
y cuando se verifica el contrato violation_handler
llama a violation_handler
, que arroja una excepción, violation_handler
llamará a std::terminate()
.
Ejemplo
void violation_handler(const std::contract_violation&) { throw std::exception(); } int foo(int n) noexcept [[ expects: n > 0 ]] { return n*n; } int main() { foo(0);
Si el indicador se pasa al compilador: no continúe ejecutando el programa después de romper el contrato ( continuation mode=off
), pero el controlador de violación arroja una excepción, entonces se std::terminate()
.
Conclusión
Los contratos se relacionan con verificaciones de tiempo de ejecución no intrusivas. Desempeñan un papel muy importante para garantizar la calidad del software lanzado.
C ++ se usa muy ampliamente. Y seguro que habrá un número suficiente de reclamaciones a la especificación de los contratos. En mi opinión subjetiva, la implementación resultó ser bastante conveniente y visual.
Los contratos de C ++ 20 harán que nuestros programas sean aún más confiables, rápidos y comprensibles. Espero su implementación en compiladores.
PS
En PM, me dicen que probablemente en la versión final del estándar expects
y ensures
que ensures
reemplazado por pre
y post
, respectivamente.