🔝 Retour au Sommaire
Un contrat est un accord entre une fonction et ses appelants. La fonction s'engage à fournir un résultat correct si — et seulement si — l'appelant respecte certaines conditions d'entrée. C'est un concept de génie logiciel formalisé par Bertrand Meyer dans les années 1980 avec le langage Eiffel, et adopté depuis par Ada, D, Kotlin et d'autres. C++ est resté sans mécanisme de contrat standard pendant plus de trois décennies, se contentant de la macro assert — un outil rudimentaire, limité et basé sur le préprocesseur.
C++26 introduit enfin un système de contrats de premier plan dans le langage, basé sur la proposition P2900. Trois mots-clés permettent d'exprimer des assertions contractuelles : pre (précondition), post (postcondition), et contract_assert (assertion dans le corps). Le design est volontairement pragmatique — décrit par ses auteurs comme un « MVP » (produit minimum viable) — avec une courbe d'apprentissage bien plus douce que les coroutines ou les modules.
📎 Cette section est la couverture approfondie des contrats C++26. Pour leur positionnement dans la stratégie de gestion d'erreurs, voir section 17.6.
Une précondition exprime ce qui doit être vrai avant l'exécution de la fonction. C'est une obligation de l'appelant :
double square_root(double x)
pre (x >= 0.0)
{
// L'implémentation peut supposer que x >= 0
// ...
}La précondition pre (x >= 0.0) dit : « cette fonction ne doit être appelée qu'avec une valeur positive ou nulle ». Si l'appelant passe une valeur négative, c'est un bug chez l'appelant, pas dans la fonction. La distinction est fondamentale — une précondition violée n'est pas une erreur à traiter (comme un fichier manquant), c'est un défaut de programmation à corriger.
Une postcondition exprime ce qui doit être vrai après l'exécution de la fonction. C'est une obligation de la fonction elle-même :
double square_root(double x)
pre (x >= 0.0)
post (r: r >= 0.0) // r est le nom donné à la valeur de retour
post (r: r * r - x < 0.001) // La racine est approximativement correcte
{
// ...
}Le nom r dans post (r: ...) est un identifiant qui lie la valeur de retour de la fonction. Il est visible uniquement dans le prédicat de la postcondition. Plusieurs postconditions peuvent être spécifiées — chacune est vérifiée indépendamment.
contract_assert remplace la macro assert pour les vérifications au sein du corps d'une fonction :
void process_batch(std::span<const int> data)
pre (!data.empty())
{
auto midpoint = data.size() / 2;
contract_assert(midpoint > 0);
auto first_half = data.first(midpoint);
auto second_half = data.subspan(midpoint);
contract_assert(!first_half.empty());
contract_assert(!second_half.empty());
// ...
}Le mot-clé contract_assert a été choisi plutôt que assert pour éviter un conflit avec la macro existante de <cassert>. Contrairement à la macro, contract_assert est un véritable mot-clé du langage — il ne souffre pas des problèmes classiques des macros (expansion inattendue, virgules dans les arguments, interaction avec le préprocesseur).
Les spécificateurs pre et post se placent après la liste des paramètres (et après les qualificateurs const, noexcept, etc.), mais avant le corps de la fonction :
class Container {
public:
int& at(size_t index)
pre (index < size_) // Après les qualificateurs
{
return data_[index];
}
void resize(size_t new_capacity)
pre (new_capacity > 0)
post (: size() == new_capacity) // post sans nom si on n'utilise pas r
{
// ...
}
size_t size() const noexcept
post (r: r <= capacity_) // Après noexcept, avant le corps
{
return size_;
}
};Une fonction peut avoir un nombre quelconque de préconditions et de postconditions, dans n'importe quel ordre. Elles sont évaluées dans l'ordre de déclaration :
void transfer(Account& from, Account& to, double amount)
pre (amount > 0)
pre (from.balance() >= amount)
pre (&from != &to)
post (: from.balance() >= 0)
{
from.withdraw(amount);
to.deposit(amount);
}Chaque pre et post contient un prédicat — une expression convertible en bool. Quand le prédicat s'évalue à true, la vérification passe. Quand il s'évalue à false, il y a violation de contrat.
Les spécificateurs pre et post peuvent être appliqués à :
- Les fonctions libres
- Les fonctions membres (y compris
const,static) - Les fonctions templates
- Les lambdas
- Les coroutines
- La fonction
main
Ils ne peuvent pas être appliqués aux fonctions = default, = delete, ou aux fonctions virtuelles (ce dernier cas a été retiré lors de la réunion de Hagenberg en février 2025, le design n'étant pas encore pleinement abouti pour l'héritage de contrats).
auto safe_divide = [](double a, double b)
pre (b != 0.0)
-> double
{
return a / b;
};Le point le plus innovant du design est la séparation entre la spécification du contrat (dans le code source) et son évaluation (déterminée au moment du build). Le même contrat peut être évalué différemment selon le mode de compilation, sans modifier le code source.
Le prédicat est évalué. En cas de violation, un diagnostic est produit (via le violation handler) et le programme se termine :
enforce : évaluer → violation ? → diagnostic → std::terminate()
C'est le comportement le plus proche de la macro assert en mode debug. C'est le mode par défaut — si aucune option de compilation n'est spécifiée, les contrats sont en mode enforce.
Le prédicat est évalué. En cas de violation, un diagnostic est produit mais l'exécution continue :
observe : évaluer → violation ? → diagnostic → continuer l'exécution
Ce mode est précieux pour le déploiement progressif de contrats dans une base de code existante. On ajoute des contrats, on les déploie en observe, et on surveille les logs pour détecter les violations sans risquer de faire planter le programme en production. Une fois qu'on a confiance dans l'absence de faux positifs, on passe en enforce.
Le prédicat est évalué. En cas de violation, le programme se termine immédiatement, sans diagnostic, sans appeler le violation handler, sans aucun effet de bord :
quick_enforce : évaluer → violation ? → terminaison immédiate
Ce mode est conçu pour les systèmes temps réel et safety-critical où la latence du diagnostic est inacceptable. La terminaison est aussi rapide qu'un __builtin_trap() — quelques instructions machine tout au plus.
Le prédicat n'est pas évalué du tout. Le contrat n'a aucun effet à l'exécution — il est purement documentaire :
ignore : ne rien faire (zéro coût runtime)
Ce mode permet d'avoir des contrats dans le code sans aucun impact sur les performances en production. Les contrats restent visibles dans le code source (documentation, analyse statique), mais ne génèrent aucune instruction.
La sémantique d'évaluation est choisie au moment de la compilation, via des flags du compilateur — pas dans le code source. Cela signifie que le même code source peut être compilé en mode enforce pour le développement et en mode ignore (ou observe) pour la production, sans aucune modification :
# Développement : vérification complète
g++ -std=c++26 -fcontracts=enforce main.cpp
# Production : contrats comme documentation uniquement
g++ -std=c++26 -O2 -fcontracts=ignore main.cpp
# Déploiement progressif : log des violations sans arrêter
g++ -std=c++26 -O2 -fcontracts=observe main.cpp
# Safety-critical : terminaison immédiate sans overhead de diagnostic
g++ -std=c++26 -O2 -fcontracts=quick-enforce main.cpp
⚠️ Les flags exacts (-fcontracts=...) sont illustratifs. La syntaxe réelle dépendra de l'implémentation de chaque compilateur. Le standard laisse le choix du mécanisme de sélection de la sémantique à l'implémentation.
Pour les sémantiques enforce et observe, le comportement par défaut en cas de violation est d'afficher un diagnostic puis de terminer (enforce) ou de continuer (observe). Ce comportement est personnalisable via une fonction globale handle_contract_violation :
#include <contracts>
void handle_contract_violation(const std::contracts::contract_violation& v) {
std::print(stderr,
"CONTRACT VIOLATION\n"
" Type: {}\n"
" Location: {}:{}\n"
" Comment: {}\n",
v.semantic() == std::contracts::evaluation_semantic::enforce
? "enforce" : "observe",
v.source_location().file_name(),
v.source_location().line(),
v.comment()
);
// Logging personnalisé, envoi de métriques, stacktrace...
auto trace = std::stacktrace::current(1);
std::print(stderr, " Stack:\n{}\n", trace);
}L'objet contract_violation fournit des informations sur la violation : la sémantique d'évaluation, la localisation dans le source, un commentaire optionnel, et si une exception a été lancée pendant l'évaluation du prédicat.
Le violation handler est installé simplement en le définissant et en le liant au programme — pas besoin d'enregistrement explicite. Si aucun handler n'est défini, l'implémentation fournit un handler par défaut.
Après le retour du handler, le comportement dépend de la sémantique : pour observe, l'exécution continue normalement. Pour enforce, le programme se termine (via std::terminate()). Le handler ne peut pas empêcher la terminaison en mode enforce — c'est une garantie de sécurité.
La macro assert de <cassert> souffre de limitations bien connues :
// Problème 1 : les virgules dans les expressions template cassent la macro
assert(std::is_same_v<int, int>); // Erreur de préprocesseur !
// La virgule dans le template est interprétée comme séparateur d'argument
// Problème 2 : pas de postconditions possibles
// assert ne peut pas exprimer "la valeur de retour satisfait X"
// Problème 3 : binaire — soit actif (debug) soit retiré (NDEBUG)
// Pas de mode "vérifier mais continuer" (observe)
// Pas de mode "terminaison rapide sans diagnostic" (quick_enforce)
// Problème 4 : pas de violation handler personnalisable
// assert appelle abort() — point final
// Problème 5 : effets de bord en mode NDEBUG
int x = 0;
assert(++x > 0); // En debug : x == 1, en release : x == 0 ! // Pas de problème de virgule — contract_assert est un keyword, pas une macro
contract_assert(std::is_same_v<int, int>); // OK
// Postconditions possibles et naturelles
int compute(int x) post (r: r > 0) { ... }
// Quatre sémantiques au lieu de deux
// enforce, observe, quick_enforce, ignore
// Violation handler personnalisable
// Logging, stacktrace, métriques — au choix
// Pas de surprise sur les effets de bord en mode ignore
// Le standard spécifie que les effets de bord du prédicat
// peuvent être élimés en mode ignoreLes contrats documentent et vérifient les préconditions des API de manière standard — plus besoin de documentation textuelle que personne ne lit :
template <std::ranges::random_access_range R>
void sort_range(R& range, size_t from, size_t to)
pre (from <= to)
pre (to <= std::ranges::size(range))
{
std::ranges::sort(std::ranges::subrange(
std::ranges::begin(range) + from,
std::ranges::begin(range) + to));
}Les préconditions sont visibles dans la déclaration — pas besoin de lire le corps ni la documentation pour savoir ce qui est attendu.
Un pattern courant consiste à définir une méthode privée invariant() appelée via contract_assert dans les méthodes publiques :
class BankAccount {
double balance_;
double overdraft_limit_;
bool invariant() const {
return balance_ >= -overdraft_limit_;
}
public:
void withdraw(double amount)
pre (amount > 0)
pre (amount <= balance_ + overdraft_limit_)
post (: invariant())
{
balance_ -= amount;
contract_assert(invariant());
}
void deposit(double amount)
pre (amount > 0)
post (: balance_ >= 0 || invariant())
{
balance_ += amount;
}
};Le mode observe est la clé du déploiement progressif dans une base de code existante :
Phase 1 : Ajouter des contrats aux fonctions critiques
Compiler en mode "observe"
Déployer en production
Surveiller les logs → détecter les violations sans crash
Phase 2 : Corriger les violations détectées
Ajouter des contrats à d'autres fonctions
Toujours en mode "observe"
Phase 3 : Une fois stable (pas de violations observées pendant X semaines)
Passer en mode "enforce" pour les composants critiques
Garder "observe" pour les composants récemment annotés
Phase 4 : Tout en mode "enforce" (ou "quick_enforce" en safety-critical)
Ce processus est impossible avec la macro assert, qui offre uniquement le choix binaire « actif en debug / retiré en release ».
La proposition P2900 est explicite sur ce point : les contrats sont des assertions de correction, pas un mécanisme de gestion d'erreurs ni de contrôle de flux.
Les contrats ne sont pas de la validation d'entrée utilisateur. Si un utilisateur saisit un âge négatif, c'est une erreur d'entrée attendue — à traiter avec std::expected (section 12.8), des exceptions, ou un code d'erreur. Si une fonction interne reçoit un âge négatif alors que l'appelant était censé l'avoir validé, c'est une violation de précondition — un bug.
Les contrats ne sont pas des branches de contrôle. Le code ne doit pas dépendre du résultat de l'évaluation d'un contrat. Un contrat peut être ignoré, et le programme doit fonctionner correctement même sans vérification — c'est le principe de redondance. Les contrats vérifient ce qui devrait déjà être vrai.
Les contrats ne remplacent pas les exceptions. Les exceptions gèrent les erreurs récupérables à l'exécution. Les contrats détectent les bugs de programmation. Un fichier manquant est une erreur (exception ou expected). Un index hors bornes passé par le code appelant est un bug (contrat).
Commencer par les préconditions. Les préconditions sont le cas d'usage le plus immédiat et le plus bénéfique. Elles documentent l'interface, détectent les bugs tôt, et sont simples à écrire. Ajouter des préconditions aux fonctions publiques est le premier pas.
Garder les prédicats simples et sans effets de bord. Un prédicat de contrat doit être une expression pure — pas d'allocation, pas de modification d'état, pas d'I/O. C'est une bonne pratique et une exigence pratique (le prédicat peut ne pas être évalué en mode ignore).
Utiliser le mode observe pour le déploiement initial. Ne pas activer enforce directement dans une base de code existante sans une phase d'observation — il y aura inévitablement des violations inattendues.
Documenter les postconditions pour les fonctions complexes. Les postconditions sont plus difficiles à écrire mais extrêmement utiles — elles capturent l'intention de la fonction et détectent les régressions.
Définir un violation handler adapté au projet. Logging structuré, stack trace (section 12.12), métriques Prometheus — le handler est l'endroit où intégrer les contrats dans le système d'observabilité du projet (section 40).
Ne pas utiliser les contrats pour la validation de données externes. Les entrées utilisateur, les données réseau, les fichiers de configuration — tout ce qui vient de l'extérieur du programme doit être validé avec la gestion d'erreurs classique, pas avec des contrats.
📎 17.6 Contrats C++26 dans le contexte de la gestion d'erreurs