
En C ++ 20, la programmation par contrat est apparue. À ce jour, aucun compilateur n'a encore implémenté la prise en charge de cette fonctionnalité.
Mais il existe maintenant un moyen d'essayer d'utiliser des contrats à partir de C ++ 20, comme cela est décrit dans la norme.
TL; DR
Il y a un bruit de fourche qui prend en charge les contrats. À l'aide de son exemple, je vous explique comment utiliser les contrats de sorte que dès qu'une fonctionnalité apparaît dans votre compilateur préféré, vous pouvez immédiatement commencer à l'utiliser.
Beaucoup a déjà été écrit sur la programmation sous contrat, mais en un mot, je vais vous dire ce que c'est et à quoi ça sert.
Logique de Hoar
Le paradigme des contrats est basé sur la logique Hoar ( 1 , 2 ).
La logique des gémissements est un moyen de prouver formellement l'exactitude d'un algorithme.
Il fonctionne avec des concepts tels que précondition, postcondition et invariant.
D'un point de vue pratique, l'utilisation de la logique de Hoar est, tout d'abord, un moyen de prouver formellement l'exactitude d'un programme dans les cas où des erreurs peuvent conduire à un désastre ou à la mort. Deuxièmement, un moyen d'augmenter la fiabilité du programme, ainsi qu'une analyse et des tests statiques.
Programmation contractuelle
( 1 , 2 )
L'idée principale des contrats est que, par analogie avec les contrats commerciaux, des accords sont décrits pour chaque fonction ou méthode. Ces dispositions doivent être respectées tant par l'appelant que par l'appelant.
Une partie intégrante des contrats comprend au moins deux modes d'assemblage: le débogage et l'épicerie. Les contrats doivent se comporter différemment selon le mode de génération. La pratique la plus courante consiste à vérifier les contrats dans l'assemblage de débogage et à les ignorer dans l'épicerie.
Parfois, les contrats sont également vérifiés dans l'assemblage du produit et leur non-exécution peut, par exemple, conduire à la génération d'une exception.
La principale différence entre l'utilisation des contrats de l'approche «classique» est que l'appelant doit se conformer aux conditions préalables de l'appelé, qui sont décrites dans le contrat, et l'appelant doit se conformer à ses postconditions et invariants.
En conséquence, l'appelé n'est pas tenu de vérifier l'exactitude de ses paramètres. Cette obligation est attribuée à l'appelant par le contrat.
La non-conformité aux contrats doit être détectée au stade des tests et complète tous les types de tests: intégration modulaire, etc.
À première vue, l'utilisation de contrats rend le développement plus difficile et dégrade la lisibilité du code. En fait, l'exact opposé est vrai. Les adeptes du typage statique trouveront plus facile d'évaluer les avantages des contrats, car leur option la plus simple est de décrire les types dans la signature des méthodes et des fonctions.
Alors, quels sont les avantages des contrats:
- Améliorez la lisibilité du code grâce à une documentation explicite.
- Améliorez la fiabilité du code en complétant les tests.
- Permettez aux compilateurs d'utiliser des optimisations de bas niveau et de générer un code plus rapide en fonction de la conformité du contrat. Dans ce dernier cas, le non-respect du contrat dans le déblocage peut conduire à UB.
Programmation sous contrat en C ++
La programmation des contrats est mise en œuvre dans de nombreuses langues. Les exemples les plus frappants sont Eiffel , où le paradigme a été mis en œuvre pour la première fois, et les contrats D , in D font partie du langage.
En C ++, avant la norme C ++ 20, les contrats pouvaient être utilisés comme des bibliothèques distinctes.
Cette approche présente plusieurs inconvénients:
- Syntaxe très maladroite à l'aide de macros.
- L'absence d'un style unique.
- Incapacité à utiliser les contrats du compilateur pour optimiser le code.
Les implémentations de bibliothèque sont généralement basées sur l'utilisation des bonnes vieilles directives d'assertion et de préprocesseur qui vérifient l'indicateur de compilation.
L'utilisation de contrats sous cette forme rend le code laid et illisible. C'est l'une des raisons pour lesquelles l'utilisation des contrats en C ++ est peu pratiquée.
À l'avenir, je montrerai à quoi ressemblera l'utilisation des contrats en C ++ 20.
Et puis, nous analyserons tout cela plus en détail:
int f(int x, int y) [[ expects: x > 0 ]]
Essayez
Malheureusement, à l'heure actuelle, aucun des compilateurs largement utilisés n'a encore mis en œuvre le support contractuel.
Mais il y a une issue.
Le groupe de recherche ARCOS de l' Université Carlos III de Madrid a mis en œuvre un support expérimental pour les contrats dans la fourchette clang ++.
Afin de ne pas «écrire du code sur une feuille de papier», mais pour pouvoir immédiatement essayer de nouvelles opportunités en affaires, nous pouvons collecter cette fourchette et l'utiliser pour essayer les exemples ci-dessous.
Les instructions d'assemblage sont décrites dans le fichier Lisez-moi du référentiel 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
Je n'ai eu aucun problème lors du montage, mais la compilation des sources prend très longtemps.
Pour compiler les exemples, vous devrez spécifier explicitement le chemin vers le binaire clang ++.
Par exemple, ça ressemble à ça pour moi
/home/valmat/work/git/clang-contracts/build/bin/clang++ -std=c++2a -build-level=audit -g test.cpp -o test.bin
J'ai préparé des exemples pour vous aider à examiner les contrats à l'aide d'exemples de code réel. Je suggère, avant de commencer à lire la section suivante, de cloner et de compiler des exemples.
git clone https://github.com/valmat/cpp20-contracts-examples/ cd cpp20-contracts-examples make CPP=/path/to/clang++
Ici /path/to/clang++
chemin vers le binaire clang++
de votre assemblage de compilateur expérimental.
En plus du compilateur lui-même, le groupe de recherche ARCOS a préparé sa version de Compiler Explorer pour son fork.
Programmation sous contrat en C ++ 20
Maintenant, rien ne nous empêche de commencer à rechercher les possibilités offertes par la programmation contractuelle et d'essayer immédiatement ces possibilités dans la pratique.
Comme mentionné ci-dessus, les contrats sont construits à partir de conditions préalables, de postconditions et d'invariants (déclarations).
En C ++ 20, les attributs avec la syntaxe suivante sont utilisés pour cela
[[contract-attribute modifier identifier: conditional-expression]]
Où contract-attribute
peut prendre l'une des valeurs suivantes:
attend , assure ou affirme .
expects
utilisé pour les conditions préalables, ensures
pour les postconditions et assert
pour les déclarations.
conditional-expression
est une expression booléenne qui est validée dans un prédicat de contrat.
modifier
et l' identifier
peuvent être omis.
Pourquoi ai-je besoin d'un modifier
que j'écrirai un peu plus bas.
identifier
utilisé uniquement avec ensures
et est utilisé pour représenter la valeur de retour.
Les conditions préalables ont accès aux arguments.
Les post-conditions ont accès à la valeur retournée par la fonction. La syntaxe est utilisée pour cela.
[[ensures return_variable: expr(return_variable)]]
Où return_variable
une expression valide pour la variable.
En d'autres termes, les conditions préalables sont destinées à déclarer les restrictions imposées sur les arguments acceptés par la fonction, et les postconditions à déclarer les restrictions imposées sur la valeur renvoyée par la fonction.
On pense que les conditions préalables et postconditions font partie de l'interface de fonction, tandis que les instructions font partie de son implémentation.
Les prédicats de précondition sont toujours évalués immédiatement avant l'exécution de la fonction. Les postconditions sont satisfaites immédiatement après le passage de la fonction de contrôle au code appelant.
Si une exception est levée dans une fonction, la postcondition ne sera pas vérifiée.
Les postconditions ne sont vérifiées que si la fonction se termine normalement.
Si une exception s'est produite lors de la vérification de l'expression dans le contrat, std::terminate()
sera appelée.
Les conditions préalables et postconditions sont toujours décrites en dehors du corps de la fonction et ne peuvent pas avoir accès aux variables locales.
Si les conditions préalables et postconditions décrivent un contrat pour une méthode de classe publique, ils ne peuvent pas avoir accès aux champs de classe privés et protégés. Si la méthode de classe est protégée, il y a accès aux données protégées et publiques de la classe, mais pas aux données privées.
La dernière limitation est tout à fait logique, étant donné que le contrat fait partie de l'interface de méthode.
Les instructions (invariants) sont toujours décrites dans le corps d'une fonction ou d'une méthode. De par leur conception, ils font partie de la mise en œuvre. Et, en conséquence, ils peuvent avoir accès à toutes les données disponibles. Y compris les variables de fonction locales et les champs de classe privés et protégés.
exemple 1
Nous définissons deux conditions préalables, une postcondition et une invariante:
int foo(int x, int y) [[ expects: x > y ]]
exemple 2
Une condition préalable à une méthode publique ne peut faire référence à un domaine protégé ou privé:
struct X {
La modification des variables dans les expressions décrites par les attributs du contrat n'est pas autorisée. S'il est cassé, il y aura UB.
Les expressions décrites dans les contrats ne devraient pas avoir d'effets secondaires. Bien que les compilateurs puissent vérifier cela, ils ne sont pas tenus de le faire. La violation de cette exigence est considérée comme un comportement non défini.
struct X { int m = 5; int foo(int n) [[ expects: n < m++ ]]
L'obligation de ne pas changer l'état du programme dans les expressions de contrat deviendra évidente un peu plus bas lorsque je parlerai des niveaux des modificateurs de contrat et des modes de construction.
Maintenant, je note simplement que le bon programme devrait fonctionner comme s'il n'y avait aucun contrat.
Comme je l'ai noté ci-dessus, dans le contrat, vous pouvez spécifier autant de conditions préalables et postconditions que vous le souhaitez.
Tous seront vérifiés dans l'ordre. Mais les conditions préalables sont toujours vérifiées avant l'exécution de la fonction, et les conditions postérieures immédiatement après sa sortie.
Cela signifie que les conditions préalables sont toujours vérifiées en premier, comme illustré dans l'exemple suivant:
int foo(int n) [[ expects: expr(n) ]]
Les expressions en postconditions peuvent faire référence non seulement à la valeur renvoyée par la fonction, mais aussi aux arguments de la fonction.
int foo(int &n) [[ ensures: expr(n) ]];
Dans ce cas, vous pouvez omettre l'identificateur de valeur de retour.
Si la postcondition fait référence à l'argument de la fonction, alors cet argument est considéré au point de sortie de la fonction , et non au point d'entrée, comme c'est le cas avec les conditions préalables.
Il n'y a aucun moyen de faire référence à la valeur d'origine (au point d'entrée de la fonction) dans la postcondition.
exemple :
void incr(int &n) [[ expects: 3 == n ]] [[ ensures: 4 == n ]] {++n;}
Les prédicats dans les contrats ne peuvent faire référence à des variables locales que si la durée de vie de ces variables correspond au temps de calcul du prédicat.
Par exemple, pour les fonctions constexpr
, les variables locales ne peuvent être référencées que si elles sont connues au moment de la compilation.
exemple :
int a = 1; constexpr int b = 100; constexpr int foo(int n) [[ expects: a <= n ]]
Contrats pour les pointeurs de fonction
Vous ne pouvez pas définir de contrats pour un pointeur de fonction, mais vous pouvez attribuer l'adresse d'une fonction pour laquelle un contrat est défini à un pointeur de fonction.
exemple :
int foo(int n) [[expects: n < 10]] { return n*n; } int (*pfoo)(int n) = &foo;
Appeler pfoo(100)
violera le contrat.
Contrats de succession
La mise en œuvre classique du concept de contrat suggère que les conditions préalables peuvent être affaiblies dans les sous-classes, les postconditions et les invariants peuvent être renforcés dans les sous-classes.
Dans une implémentation C ++ 20, ce n'est pas le cas.
Premièrement, les invariants en C ++ 20 font partie d'une implémentation, pas d'une interface. Pour cette raison, ils peuvent être à la fois renforcés et affaiblis. S'il n'y a aucune assert
dans l'implémentation de la fonction virtuelle, elle ne sera pas héritée.
Deuxièmement, il est nécessaire que lors de l'héritage, les fonctions soient identiques ODR .
Et, comme les conditions préalables et les postconditions font partie de l'interface, elles doivent donc correspondre exactement à l'héritier.
De plus, la description des conditions préalables et postconditions pendant l'héritage peut être omise. Mais s'ils sont déclarés, ils doivent correspondre exactement à la définition de la classe de base.
exemple :
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 {
RemarqueMalheureusement, l'exemple ci - dessus ne fonctionne pas comme prévu dans le compilateur expérimental.
Si foo
de Derived2
contrat, il ne sera pas hérité de la classe de base. De plus, le compilateur vous permet de déterminer pour une sous-classe un contrat qui ne correspond pas au contrat de base.
Une autre erreur du compilateur expérimental:
l'enregistrement doit être syntaxiquement correct
virtual int foo(int n) override [[expects: n < 10]] {...}
Cependant, sous cette forme, j'ai reçu une erreur de compilation
inheritance1.cpp:20:36: error: expected ';' at end of declaration list virtual int foo(int n) override ^ ;
et a dû être remplacé par
virtual int foo(int n) [[expects: n < 10]] override {...}
Je pense que cela est dû à la particularité du compilateur expérimental, et le code à syntaxe correcte fonctionnera dans les versions de sortie des compilateurs.
Modificateurs de contrat
Les vérifications de prédicat de contrat peuvent entraîner des coûts de traitement supplémentaires.
Par conséquent, une pratique courante consiste à vérifier les contrats dans les versions de développement et de test et à les ignorer dans la version.
À ces fins, la norme propose trois niveaux de modificateurs de contrat. À l'aide de modificateurs et de clés de compilation, le programmeur peut contrôler quels contacts sont vérifiés dans l'assemblage et lesquels sont ignorés.
default
- ce modificateur est utilisé par défaut. On suppose que le coût de calcul de la vérification de l'exécution d'une expression avec ce modificateur est faible par rapport au coût de calcul de la fonction elle-même.audit
- ce modificateur suppose que le coût de calcul de la vérification de l'exécution d'une expression est significatif par rapport au coût de calcul de la fonction elle-même.axiom
- ce modificateur est utilisé si l'expression est déclarative. Non vérifié à l'exécution. Sert à documenter l'interface d'une fonction, à utiliser par des analyseurs statiques et un optimiseur de compilateur. Les expressions avec le modificateur axiom
ne axiom
jamais évaluées lors de l'exécution.
Exemple
[[expects: expr]]
À l'aide de modificateurs, vous pouvez déterminer quelles vérifications dans quelles versions de vos assemblys seront utilisées et lesquelles seront désactivées.
Il convient de noter que même si la vérification n'est pas effectuée, le compilateur a le droit d'utiliser le contrat pour des optimisations de bas niveau. Bien que la vérification du contrat puisse être désactivée par l'indicateur de compilation, la violation du contrat entraîne un comportement de programme non défini.
À la discrétion du compilateur, des fonctionnalités peuvent être fournies pour permettre la axiom
expressions marquées comme axiom
.
Dans notre cas, c'est une option de compilation
-axiom-mode=<mode>
-axiom-mode=on
active le mode axiome et, par conséquent, désactive la vérification des revendications avec l'identifiant axiom
,
-axiom-mode=off
le mode axiome et, en conséquence, permet la vérification des instructions avec l'identifiant axiom
.
exemple :
int foo(int n) [[expects axiom: n < 10]] { return n*n; }
Un programme peut être compilé avec trois niveaux de vérification différents:
off
désactive toutes les vérifications d'expression dans les contratsdefault
seules les expressions avec le modificateur default
sont cochéesaudit
mode avancé lorsque toutes les vérifications sont effectuées avec le modificateur default
et audit
La manière exacte d'implémenter l'installation du niveau de vérification est à la discrétion des développeurs du compilateur.
Dans notre cas, l'option de compilation est utilisée pour cela
-build-level=<off|default|audit>
La valeur par défaut est -build-level=default
Comme déjà mentionné, le compilateur peut utiliser des contrats pour des optimisations de bas niveau. Pour cette raison, malgré le fait qu'au moment de l'exécution, certains prédicats dans les contrats (selon le niveau de vérification) peuvent ne pas être calculés, leur non-exécution conduit à un comportement indéfini.
Je vais reporter les exemples d'application des niveaux d'assemblage à la section suivante, où ils peuvent être rendus visuels.
Interception de rupture de contrat
Selon les options du programme, en cas de rupture de contrat, il peut y avoir différents scénarios de comportement.
Par défaut, une rupture du contrat entraîne le plantage du programme, un appel à std::terminate()
. Mais le programmeur peut outrepasser ce comportement en fournissant son propre gestionnaire et en indiquant au compilateur qu'il est nécessaire de poursuivre le programme après la rupture du contrat.
Lors de la compilation, vous pouvez installer le gestionnaire de violation , qui est appelé lorsque le contrat est violé.
La manière d'implémenter l'installation du gestionnaire est à la discrétion des créateurs du compilateur.
Dans notre cas, cela
-contract-violation-handler=<violation_handler>
La signature du processeur doit être
void(const std::contract_violation& info)
ou
void(const std::contract_violation& info) noexcept
std::contract_violation
équivalent à la définition suivante:
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; };
Ainsi, le gestionnaire vous permet d'obtenir des informations assez complètes sur exactement où et dans quelles conditions une violation de contrat s'est produite.
Si le gestionnaire de violation est spécifié, alors en cas de violation de contrat, par défaut, std::abort()
sera appelé immédiatement après son exécution (sans spécifier le gestionnaire, std::terminate()
sera appelé).
La norme suppose que les compilateurs fournissent des outils qui permettent aux programmeurs de continuer à exécuter un programme après une rupture de contrat.
La manière d'implémenter ces outils est laissée à la discrétion des développeurs du compilateur.
Dans notre cas, c'est une option de compilation
-fcontinue-after-violation
Les -fcontinue-after-violation
et -contract-violation-handler
peuvent être définies indépendamment l'une de l'autre. Par exemple, vous pouvez définir -fcontinue-after-violation
, mais pas définir -contract-violation-handler
. Dans ce dernier cas, après la rupture du contrat, le programme continuera simplement à fonctionner.
La possibilité de poursuivre le programme après une violation du contrat est spécifiée par la norme, mais cette fonctionnalité doit être prise en compte.
Techniquement, le comportement d'un programme après rupture de contrat n'est pas défini, même si le programmeur a explicitement indiqué que le programme devait continuer à fonctionner.
Cela est dû au fait que le compilateur peut effectuer des optimisations de bas niveau basées sur l'exécution du contrat.
Idéalement, en cas de rupture de contrat, vous devez enregistrer les informations de diagnostic dès que possible et mettre fin au programme. Vous devez comprendre exactement ce que vous faites en permettant au programme de fonctionner après la violation.
Définissez votre gestionnaire et utilisez-le pour intercepter une rupture de contrat
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; }
Et considérons un exemple de rupture de contrat:
#include "violation_handler.h" int foo(int n) [[expects: n < 10]] { return n*n; } int main() { foo(100);
Nous compilons le programme avec les options -contract-violation-handler=violation_handler
et -fcontinue-after-violation
et -fcontinue-after-violation
$ bin/example8-handling.bin line_number : 4 file_name : example8-handling.cpp function_name : foo comment : n < 10 assertion_level : default
Nous pouvons maintenant donner des exemples démontrant le comportement du programme en cas de rupture du contrat à différents niveaux d'assemblage et modes de contrat.
Prenons l' exemple suivant:
#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 vous le -build-level=off
avec l'option -build-level=off
, comme prévu, les contrats ne seront pas vérifiés.
En rassemblant le niveau default
(avec l'option -build-level=default
), nous obtenons la sortie suivante:
$ 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
Et l'assemblage avec le niveau d' audit
donnera:
$ 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
Remarques
violation_handler
peut lever des exceptions. Dans ce cas, vous pouvez configurer le programme de sorte que la violation du contrat entraîne le déclenchement d'une exception.
Si la fonction pour laquelle les contrats sont décrits est marquée comme noexcept
et lors de la vérification du contrat violation_handler
appelé, ce qui lève une exception, std::terminate()
sera appelé.
Exemple
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 l'indicateur est passé au compilateur: ne continuez pas à exécuter le programme après avoir rompu le contrat ( continuation mode=off
), mais le gestionnaire de violation lève une exception, puis std::terminate()
sera forcé.
Conclusion
Les contrats concernent des contrôles d'exécution non intrusifs. Ils jouent un rôle très important pour garantir la qualité des logiciels publiés.
C ++ est utilisé très largement. Et pour sûr, il y aura un nombre suffisant de réclamations à la spécification des contrats. À mon avis subjectif, la mise en œuvre s'est avérée assez pratique et visuelle.
Les contrats C ++ 20 rendront nos programmes encore plus fiables, rapides et compréhensibles. J'attends avec impatience leur implémentation dans les compilateurs.
PS
Dans PM, ils me disent que probablement dans la version finale de la norme expects
et ensures
sera remplacé par pre
et post
, respectivement.