Ce que « comportement indéfini » signifie vraiment
La page précédente a montré comment try/catch gère les erreurs que votre programme définit et lance à dessein. Le comportement indéfini est l'inverse : c'est l'ensemble des opérations auxquelles la norme C++ refuse de donner le moindre sens. Il n'y a aucune exception à attraper, aucun code d'erreur, aucune garantie de plantage. Le compilateur est libre de supposer que l'UB ne se produit jamais et de faire ce qu'il veut lorsqu'il se produit.
C'est cette liberté qui rend l'UB si dangereux. La même ligne boguée peut afficher la « bonne » réponse sur votre ordinateur portable, renvoyer n'importe quoi sur un serveur et être entièrement supprimée par l'optimiseur en -O2. L'UB n'est pas « un comportement que nous ne documentons pas », mais « un comportement sur lequel le langage ne promet rien ». Votre travail consiste à ne jamais l'écrire dès le départ.
int arr[3] = {1, 2, 3};
int x = arr[5]; // comportement indefini : lire au-dela de la fin du tableau
Il n'y a pas d'erreur de compilation ici, et lors de nombreuses exécutions, il vous remettra discrètement un entier perdu. Ce succès apparent est le piège.
Lire ou écrire hors limites
La forme la plus courante d'UB consiste à toucher une mémoire qui ne vous appartient pas. Les tableaux intégrés et std::vector::operator[] ne font aucune vérification de limites : un indice au-delà de la fin (ou négatif) est un UB immédiat, que vous lisiez ou écriviez.
Le bug à surveiller est d'utiliser <= là où vous vouliez < : lorsque i == v.size(), vous indexez un élément au-delà du dernier, ce qui est de l'UB. Préférez un for basé sur une plage (vu précédemment) quand vous n'avez pas besoin de l'indice, car il ne peut pas dépasser la fin. Lorsque vous indexez vraiment à la main et que vous voulez un filet de sécurité, v.at(i) lance std::out_of_range au lieu de corrompre silencieusement la mémoire :
Utilisez at() pendant que vous traquez un bug ; revenez à [] dans les boucles critiques une fois que vous avez prouvé que les indices sont valides.
Pointeurs pendants et use-after-free
Un pointeur ou une référence qui survit à l'objet qu'il désigne est pendant (dangling). L'utiliser est de l'UB : la mémoire a pu être réutilisée, libérée ou n'avoir jamais existé. C'est le piège que les pointeurs intelligents (du chapitre précédent) vous aident à éviter, mais les pointeurs bruts vous laissent encore y tomber.
La version la plus tranchante consiste à renvoyer l'adresse d'une variable locale. La locale meurt lorsque la fonction se termine, si bien que l'appelant se retrouve avec un pointeur vers rien :
int* makeNumber() {
int n = 42;
return &n; // renvoie l'adresse d'une locale - elle disparait apres le return
}
// Dereferencer le resultat est un comportement indefini.
La même chose se produit après un delete ou lorsqu'un vector se réalloue et invalide les itérateurs ou pointeurs qui le visent :
int* p = new int(5);
delete p;
cout << *p; // use-after-free : comportement indefini
vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4); // peut se reallouer - 'first' est maintenant pendant
cout << *first; // comportement indefini
Les défenses sont celles que vous connaissez déjà : maintenez les objets en vie tant qu'un pointeur en a besoin, préférez les références et les pointeurs intelligents aux pointeurs bruts propriétaires, et récupérez à nouveau les pointeurs/itérateurs après toute opération susceptible de redimensionner un conteneur.
Variables non initialisées et dépassement signé
Lire une variable avant de lui avoir donné une valeur est de l'UB pour les types intégrés : il n'y a pas de 0 par défaut. La variable contient les bits qui se trouvaient déjà dans cette mémoire, et l'optimiseur peut supposer que vous ne la lisez jamais non initialisée.
Si sum avait été déclaré comme un simple int sum;, chaque sum += i lirait d'abord une valeur indéterminée : de l'UB, et un bug notoirement difficile car il semble souvent fonctionner. Faites de l'initialisation une habitude : int x = 0; ou int x{};.
Un autre coupable silencieux est le dépassement d'entier signé. Pousser un int signé au-delà de son maximum est de l'UB (les types non signés bouclent de façon prévisible ; les types signés non) :
int big = 2147483647; // INT_MAX sur un int 32 bits
int oops = big + 1; // depassement signe : comportement indefini
Ne comptez pas sur le fait qu'il « boucle vers un nombre négatif » : le compilateur est autorisé à supposer que le dépassement ne peut pas se produire et à optimiser sur cette base. Si vous avez besoin d'un bouclage défini, utilisez un type non signé ou vérifiez les bornes avant d'additionner.
Détecter l'UB avec les sanitizers et les avertissements
Vous ne pouvez pas atteindre la confiance concernant l'UB à force de tests, car une exécution réussie ne garantit rien. Ce qui fonctionne, c'est de rendre l'UB bruyant à l'exécution avec les sanitizers du compilateur (disponibles dans GCC et Clang).
// AddressSanitizer : hors limites, use-after-free, fuites
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app
// UndefinedBehaviorSanitizer : depassement signe, deref nul, conversions invalides
g++ -fsanitize=undefined -g main.cpp -o app && ./app
Exécutez vos tests existants avec ces options et la lecture hors limites, le use-after-free ou le dépassement signé qui « marchait » se transforme en un rapport précis nommant le fichier et la ligne. Combinez-les avec -Wall -Wextra pour que le compilateur signale aussi le code suspect (comme une lecture probablement non initialisée) avant même que vous l'exécutiez.
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 main.cpp:7 in main
Traitez tout rapport d'un sanitizer comme un bug à corriger impérativement, pas comme un avertissement à ignorer : il vous dit que la norme ne fait aucune promesse sur cette ligne.
Conclusion
Le comportement indéfini est la partie de C++ où les garde-fous sautent : l'accès hors limites, les pointeurs pendants, le use-after-free, les lectures non initialisées et le dépassement signé produisent du code sans aucun sens défini, et « ça a marché » n'est jamais une preuve de correction. Pour rester en sécurité, écrivez de façon défensive (initialisez chaque variable, respectez les bornes des conteneurs, laissez les pointeurs intelligents posséder votre mémoire du tas), puis vérifiez avec -fsanitize=address, -fsanitize=undefined et -Wall -Wextra afin que l'UB silencieux devienne un rapport bruyant et corrigeable.
Cela clôt le chapitre Erreurs et Débogage. Entre les exceptions, try/catch et une saine crainte de l'UB, vous disposez maintenant des outils pour écrire du C++ qui échoue bruyamment et à dessein plutôt que silencieusement et par accident.
Questions fréquentes
Qu'est-ce que le comportement indéfini en C++ ?
Le comportement indéfini (UB) est toute opération à laquelle la norme C++ ne laisse explicitement aucun résultat défini ; par exemple lire au-delà de la fin d'un tableau ou déréférencer un pointeur pendant. Le compilateur est autorisé à faire n'importe quoi : planter, renvoyer n'importe quoi, supprimer le code par optimisation, ou sembler fonctionner aujourd'hui et casser après une recompilation. C'est un bug dans votre programme, pas une fonctionnalité du langage.
Pourquoi mon programme C++ fonctionne-t-il alors qu'il a un comportement indéfini ?
« Ça a marché » ne prouve rien au sujet de l'UB. La norme ne donne aucune garantie dans un sens ou dans l'autre, donc un bug d'UB peut produire le résultat attendu sur votre machine avec votre compilateur aujourd'hui, puis planter avec un autre niveau d'optimisation, une autre plateforme ou une autre version du compilateur. Ne considérez jamais une exécution réussie comme une preuve que l'UB est inoffensif : utilisez un sanitizer pour le détecter réellement.
Comment détecter un comportement indéfini en C++ ?
Compilez avec des sanitizers : -fsanitize=address (AddressSanitizer) trouve les lectures/écritures hors limites et le use-after-free, et -fsanitize=undefined (UndefinedBehaviorSanitizer) signale le dépassement signé, les déréférencements nuls et les conversions invalides. Activez les avertissements (-Wall -Wextra) et exécutez vos tests avec ces options : ils transforment l'UB silencieux en un rapport clair à l'exécution.