Les Undefined Behaviors

Les Undefined Behaviors (UB) sont des situations où aucun comportement n'est imposé au compilateur. Il s'agit donc d'erreurs difficiles à découvrir puisqu'elles peuvent faire crash le programme, renvoyer des valeurs incorrectes silencieusement ou faire fonctionner correctement le programme.

Elles existent pour permettent aux compilateurs d'optimiser le code (aucune contrainte n'existe pour la situation donnée) et augmenter leur comptabilité (n'importe quel comportement est accepté). Il en existe environ 200 en C (2011), et encore bien d'autres en C++.

Par exemple, l'overflow des entiers signés est un UB :

// On obtient ce résultat même sans optimisations.
bool is_max_int(int x) {
    return x + 1 < x;
}
int main() {
    std::cout << (MAX_INT + 1) < (MAX_INT) << '\n'; // Affiche 1
    std::cout << is_max_int(MAX_INT)       << '\n'; // Affiche 0
}

Des overflows des nombres signés peuvent être définis avec l'option -fwrapv, et ils peuvent faire crash le programme avec -ftrapv.

Le comportement d'une UB est fortement lié à la plateforme, le compilateur ou les optimisations activées. Des UB peuvent donc rester invisibles jusqu'au moment où une optimisation les exploite pour faire dysfonctionner le code.

Unspecified & implementation-defined behavior

Les Unspecified Behaviors désignent des situations correctes où plusieurs solutions sont disponibles, et aucune n'est requise. Par exemple, l'ordre d'évaluation des fonctions est un Unspecified Behavior :

int a();
int b();
int c();
int h(int, int, int);
// Peut appeler a, b et c dans n'importe quel ordre.
int main() {
    return h(a(), b(), c());
}

Les Implementation-defined Behaviors sont des comportements qui peuvent changer selon l'implémentation. Cependant, contrairement aux Unspecified Behaviors, chaque implémentation doit toujours adopter la même solution. Par exemple, la taille en mémoire d'un pointeur fait partie de cette catégorie :

// Résultat fixé selon l'implémentation (généralement 32 bits ou 64 bits).
int main() { return sizeof(void*); }

UB courants

Voici des exemples qui illustre certains Undefined Behaviors communs :

  • L'overflow des entiers signés.

  • Lire un objet non initialisé.

  • Utiliser des pointeurs de types différents pointant vers le même objet.

  • Ne pas retourner de valeur dans une fonction ne retournant pas void.

  • Déréférencer un pointeur nul, ou vers un objet non existant (ou déjà détruit).

  • Manipuler le membre inactif d'une union (comportement défini en C).

Cette réponse de Stack Overflow liste quelques UB qu'il faut garder en tête. Les UB nécessitent une attention particulière pour être évitées, mais des outils permettent de les mitiger (comme des analyseurs statiques, ou UBsan qui modifie le programme à la compilation pour détecter des UB en cours d'exécution).

Optimisations possibles

Une optimisation classique effectuée par le compilateur consiste à partir du principe que les UB ne sont jamais atteints. Ainsi, lorsqu'une UB est créé, le compilateur peut partir du principe que le chemin contenant l'UB n'est jamais atteint :

// Variable globale.
int table[4] = {};

// Code initialement écrit.
bool exists_in_table(int v) {
    // Erreur : à  'i <= 4', au lieu de 'i < 4'.
    for (int i = 0; i <= 4; i++) {
        if (table[i] == v) return true;
    }
    return false;
}

Dans l'exemple ci-dessus, la boucle dépasse table et finit par accéder à de la mémoire qui ne nous appartient pas : c'est un UB. Donc, le compilateur peut partir du principe qu'on atteint jamais ce moment. En effet, si la fonction retourne 'true' avant d'atteindre l'UB, elle est correcte. LE compilateur peut donc remplacer toute la fonction par return true.

On peut utiliser ceci à notre avantage :

// Calcule x^n.
int power_nth(int x, int n) {
     int value = 1;
     for (int i = 0; i < n; ++i) {
         value *= x;
     }
     return value;
}

// Si n > 5, le compilateur atteint un UB.
// Il va donc partir du principe qu'on ne l'atteint pas, et que
// n est toujours inférieur ou égal à 5.
int power_nth_small(int x, int n) {
    if (n > 5) {
        int* ptr = nullptr;
        return *ptr;
        // On peut remplacer l'UB par __builtin_unreachable().
    }
    int value = 1;
    // La boucle peut être déroulée (5 fois) par le compilateur.
    for (int i = 0; i < n; ++i) {
        value *= x;
    }
    return value;
}

Pour aller plus loin

Des effets assez spectaculaires peuvent se produire selon le contexte et l'UB. Par exemple, une fonction jamais appelée peut être appelée dans le code généré. On dit parfois que les UB peuvent remonter le temps, ou faire sortir des démons volants de vos narines (nasal demons).

Des fonctions peuvent avoir un UB selon leurs paramètres, comme sqrt(-1).

Last updated