Comprendre et éviter le comportement indéfini
Apprenez ce qu'est le comportement indéfini, pourquoi il existe, comment les compilateurs l'exploitent pour l'optimisation, et comment le détecter avec les sanitizers et les avertissements du compilateur.
Qu'est-ce que le comportement indéfini et pourquoi existe-t-il ?
Le comportement indéfini (UB) signifie que le standard C++ n'impose aucune exigence sur le comportement du programme. Le programme pourrait planter, produire de mauvais résultats, sembler fonctionner correctement, ou formater votre disque dur — tous sont des résultats « valides ».
Le UB existe parce qu'il permet une optimisation agressive. Quand le compilateur sait que certaines situations « ne peuvent pas arriver » (parce qu'elles seraient du UB), il peut générer du code plus rapide en omettant les vérifications pour ces situations. Par exemple :
- Le débordement d'entier signé est du UB permet au compilateur de supposer que x + 1 > x est toujours vrai, activant les optimisations de boucle.
- Le déréférencement d'un pointeur nul est du UB permet au compilateur d'éliminer les vérifications de null après un déréférencement.
- L'accès mémoire hors limites est du UB permet au compilateur d'omettre les vérifications de bornes, rendant l'accès aux tableaux aussi rapide que la mémoire brute.
Le contrat fondamental : C++ fait confiance au programmeur pour éviter le UB en échange de performances maximales. Quand cette confiance est violée, tout est permis.
Sources courantes de comportement indéfini
Voici les sources les plus fréquentes de UB dans le code C++ réel. Chaque programmeur C++ les rencontre.
#include <vector>
#include <iostream>
#include <cstdint>
// 1. SIGNED INTEGER OVERFLOW
void signed_overflow() {
int x = INT32_MAX;
int y = x + 1; // UB! Signed overflow is undefined
// unsigned overflow is well-defined (wraps around)
unsigned u = UINT32_MAX;
unsigned v = u + 1; // OK: v == 0 (well-defined wrap)
}
// 2. NULL POINTER DEREFERENCE
void null_deref(int* ptr) {
int val = *ptr; // UB if ptr is null
if (ptr == nullptr) { // Compiler may remove this check!
// The compiler reasons: "ptr was already dereferenced above,
// which would be UB if null. Since UB cannot happen in a
// correct program, ptr must be non-null. This check is dead code."
std::cout << "null!\n";
}
}
// 3. OUT-OF-BOUNDS ACCESS
void out_of_bounds() {
int arr[5] = {1, 2, 3, 4, 5};
int bad = arr[10]; // UB! No bounds checking on raw arrays
// std::vector::operator[] is also UB if out of bounds
// Use .at() for bounds-checked access (throws std::out_of_range)
}
// 4. USE-AFTER-FREE / DANGLING REFERENCES
int& dangling_reference() {
int local = 42;
return local; // UB! Reference to destroyed local
}
// 5. DATA RACES
// int shared = 0;
// Thread 1: shared++; // UB if concurrent with Thread 2
// Thread 2: shared++; // UB! Data race on non-atomic variableLe problème du « voyage dans le temps »
L'un des aspects les plus contre-intuitifs du UB est qu'il peut affecter le code avant le point de UB. Les compilateurs optimisent en supposant que le UB ne se produit jamais dans un programme correct, et ces optimisations peuvent réordonner ou éliminer du code qui apparaît avant le UB.
Considérez :
bool checked = false;
void process(int* p) {
int value = *p; // UB si p est nul
checked = true; // Cette ligne est AVANT l'utilisation de 'value'
if (p == nullptr) { // Compilateur : "p a été déréférencé, donc il n'est pas nul"
abort(); // Compilateur : "C'est du code mort, supprimons-le"
}
use(value);
}Le compilateur peut définir checked = true avant le déréférencement (réordonnancement pour le pipeline), puis supprimer entièrement la vérification de null. Le résultat : checked est mis à true même quand p est nul, et le déréférencement nul se produit sans l'abort. Le UB a « voyagé dans le temps » pour corrompre le drapeau checked.
Ce n'est pas un bug du compilateur — c'est une conséquence directe des libertés d'optimisation que le UB accorde.
Détecter le UB avec les sanitizers
Les sanitizers sont des outils fournis par le compilateur qui instrumentent votre code pour détecter le UB à l'exécution. Ils ajoutent des vérifications à l'exécution qui attrapent le UB avec des messages d'erreur clairs. Exécutez toujours votre suite de tests avec les sanitizers activés.
# AddressSanitizer (ASan) — detects:
# - Buffer overflow (stack, heap, global)
# - Use-after-free, use-after-return
# - Double-free, memory leaks
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 program.cpp -o program
# UndefinedBehaviorSanitizer (UBSan) — detects:
# - Signed integer overflow
# - Null pointer dereference
# - Out-of-bounds array access
# - Misaligned memory access
# - Type punning violations
g++ -fsanitize=undefined -fno-omit-frame-pointer -g program.cpp -o program
# ThreadSanitizer (TSan) — detects:
# - Data races between threads
# - Lock order inversions (potential deadlocks)
g++ -fsanitize=thread -g program.cpp -o program
# MemorySanitizer (MSan) — detects:
# - Reads of uninitialized memory
# (Clang-only, not available in GCC)
clang++ -fsanitize=memory -fno-omit-frame-pointer -g program.cpp -o program
# Combine ASan + UBSan (common in CI pipelines)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
-Wall -Wextra -Werror program.cpp -o program
# CMake integration
# In CMakeLists.txt:
# target_compile_options(myapp PRIVATE -fsanitize=address,undefined)
# target_link_options(myapp PRIVATE -fsanitize=address,undefined)Les avertissements du compilateur comme filet de sécurité
Les compilateurs modernes peuvent détecter de nombreuses situations potentielles de UB à la compilation. Traitez les avertissements comme des erreurs (-Werror) dans la CI pour vous assurer qu'aucun avertissement n'est ignoré.
# Recommended warning flags for all C++ projects
g++ -std=c++23 \
-Wall # Enable most warnings \
-Wextra # Enable additional warnings \
-Wpedantic # Strict ISO C++ compliance \
-Werror # Treat warnings as errors \
-Wconversion # Warn on implicit narrowing conversions \
-Wshadow # Warn when a variable shadows another \
-Wnon-virtual-dtor # Warn on classes with virtual functions but non-virtual dtor \
-Wold-style-cast # Warn on C-style casts \
-Wcast-align # Warn on potentially misaligned casts \
-Wunused # Warn on unused variables/functions \
-Woverloaded-virtual # Warn when a derived class hides a base virtual function \
-Wnull-dereference # Warn on potential null dereferences (GCC) \
-Wdouble-promotion # Warn when float is implicitly promoted to double \
-Wformat=2 # Warn on printf/scanf format string issues \
program.cpp -o program
# For Clang, add:
# -Weverything # Enable ALL warnings (then selectively disable)
# -Wno-c++98-compat # Disable C++98 compatibility warningsSuivez ces directives pour minimiser le UB dans votre base de code.
- Activez tous les avertissements et traitez-les comme des erreurs :
-Wall -Wextra -Wpedantic -Werror - Exécutez les sanitizers dans la CI : combinez ASan + UBSan pour chaque exécution de test ; TSan pour le code multithreadé
- Utilisez
.at()au lieu de[]pour un accès aux conteneurs vérifié pendant le développement - Utilisez des pointeurs intelligents (
unique_ptr,shared_ptr) au lieu dunew/deletebrut pour éliminer le use-after-free - Utilisez
std::atomicpour les variables partagées entre threads — n'utilisez jamais de types simples pour les données inter-threads - Préférez les types non signés pour la manipulation de bits mais les types signés pour l'arithmétique (l'arithmétique non signée boucle, ce qui peut causer des bugs subtils dans les bornes de boucle)
Le UB le plus dangereux est le code qui semble fonctionner parfaitement en test mais échoue en production. Voici pourquoi.
- Le UB peut « fonctionner » pendant des années puis casser quand vous mettez à jour le compilateur, changez les niveaux d'optimisation ou exécutez sur un CPU différent
- Débordement signé dans les boucles :
for (int i = 0; i < n; i++)— sinestINT_MAX,i++déborde. Le compilateur peut supposer que la boucle se termine et optimiser en conséquence - Lecture de variables non initialisées : la valeur n'est pas « aléatoire » — le compilateur peut optimiser la variable entièrement ou propager les bits qui se trouvent dans le registre
- Violations du strict aliasing : caster
int*enfloat*et déréférencer est du UB (sauf viachar*oustd::byte*). Utilisezstd::bit_cast(C++20) pour le type punning std::string_viewsurvivant à la chaîne : retourne une vue pendante qui semble fonctionner jusqu'à ce que la mémoire soit réutilisée
- Le comportement indéfini existe pour permettre des optimisations agressives du compilateur — C++ échange la sécurité contre la performance
- Le UB peut « voyager dans le temps » — le compilateur peut réordonner ou éliminer du code qui précède le point de UB
- Les sources les plus courantes de UB sont le débordement signé, le déréférencement nul, l'accès hors limites, le use-after-free et les courses de données
- Les sanitizers (ASan, UBSan, TSan, MSan) détectent le UB à l'exécution — exécutez-les dans chaque pipeline CI
- Les avertissements du compilateur (
-Wall -Wextra -Werror) attrapent de nombreuses situations potentielles de UB à la compilation - Le UB qui « fonctionne » aujourd'hui peut casser silencieusement avec un nouveau compilateur, niveau d'optimisation ou plateforme
Quiz — Testez vos connaissances
(15 XP)1. Pourquoi le débordement d'entier signé est-il un comportement indéfini en C++ ?
2. Que détecte AddressSanitizer (ASan) ?
3. Qu'est-ce que le problème du « voyage dans le temps » avec le comportement indéfini ?