Aller au contenu

Variables, portée et durée de vie

Apprenez les quatre durées de stockage, comment la portée et la durée de vie sont des concepts distincts, les pièges d'initialisation, et le fiasco de l'ordre d'initialisation statique.

La portée et la durée de vie ne sont pas la même chose

L'une des idées fausses les plus courantes en C++ est de confondre portée et durée de vie. Elles sont liées mais distinctes :

La portée est une propriété de compilation — elle détermine où un nom est visible dans le code source. Une variable déclarée à l'intérieur d'un bloc {} n'est visible que dans ce bloc et dans tout bloc imbriqué.

La durée de vie est une propriété d'exécution — elle détermine quand un objet existe en mémoire. La durée de vie d'un objet commence quand son constructeur se termine et se termine quand son destructeur commence.

Dans de nombreux cas, la portée et la durée de vie coïncident : une variable locale est visible dans son bloc et vit jusqu'à la fin du bloc. Mais elles peuvent diverger. Une variable locale static n'est visible qu'à l'intérieur de sa fonction (portée limitée) mais vit pendant toute la durée du programme (durée de vie illimitée). Un objet alloué dynamiquement a une durée de vie qui s'étend au-delà de la portée du pointeur qui l'a créé.

Les quatre durées de stockage

Le C++ définit quatre durées de stockage qui contrôlent quand les objets sont créés et détruits :

storage.cppc++23
#include <iostream>
#include <string>

// Static storage duration — lives for the entire program
int global_count = 0;                    // Initialized before main()
static int file_local = 42;              // Same, but only visible in this file

void demonstrate_storage() {
    // Automatic storage duration — created on entry, destroyed on exit
    int local = 10;                      // Born here, dies at closing brace

    // Static storage duration (local scope)
    static int call_count = 0;           // Initialized ONCE, persists across calls
    ++call_count;
    std::cout << "Call #" << call_count << '\n';

    // Thread-local storage duration (C++11)
    // thread_local int per_thread = 0;  // One copy per thread, lives until thread ends
}

int main() {
    demonstrate_storage();  // Call #1
    demonstrate_storage();  // Call #2 — call_count is still alive!
    demonstrate_storage();  // Call #3

    // Dynamic storage duration — you control the lifetime
    int* heap_int = new int{99};         // Born here
    std::cout << *heap_int << '\n';
    delete heap_int;                     // Dies here — you must not forget!
    heap_int = nullptr;                  // Good practice: nullify after delete

    // In modern C++, prefer smart pointers over raw new/delete
    // auto ptr = std::make_unique<int>(99);  // Covered in later modules
}
Sortie

Cliquez sur Exécuter pour compiler et lancer cet exemple.

Formes d'initialisation et initialisation à zéro

Le C++ a trois concepts fondamentaux d'initialisation :

Initialisation par défaut — la variable est construite sans initialiseur. Pour les types intégrés (int, double, etc.), cela signifie que la valeur est indéterminée (la lire est un comportement indéfini !). Pour les types classe, le constructeur par défaut est appelé.

Initialisation par valeur — déclenchée par des parenthèses vides T() ou des accolades vides T{}. Pour les types intégrés, cela initialise à zéro : les entiers deviennent 0, les pointeurs deviennent nullptr, les booléens deviennent false. Pour les types classe, le constructeur par défaut est appelé (et les membres sont initialisés par valeur si aucun constructeur ne les initialise).

Initialisation à zéro — une phase spécifique qui se produit pour les variables statiques/thread-local avant toute autre initialisation. Toutes les variables statiques sont initialisées à zéro avant que le programme ne démarre.

C'est pourquoi static int x; est garanti à 0, mais un int x; local ne l'est pas — la variable locale est initialisée par défaut (indéterminée), tandis que la variable statique passe d'abord par l'initialisation à zéro.

L'initialisation en pratique

Comprendre quand une variable est à zéro ou indéterminée est crucial pour éviter les bogues :

init_forms.cppc++23
#include <iostream>

struct Point {
    int x;
    int y;
};

static int global_int;          // Zero-initialized → 0
static Point global_point;      // Zero-initialized → {0, 0}

int main() {
    int a;                      // Default-initialized → INDETERMINATE (UB to read!)
    int b{};                    // Value-initialized → 0
    int c = int();              // Value-initialized → 0
    int d = {};                 // Value-initialized → 0

    Point p1;                   // Default-initialized → members are INDETERMINATE
    Point p2{};                 // Value-initialized → {0, 0}
    Point p3{1, 2};             // Aggregate-initialized → {1, 2}

    // Array initialization
    int arr1[5];                // Default-initialized → INDETERMINATE values!
    int arr2[5]{};              // Value-initialized → {0, 0, 0, 0, 0}
    int arr3[5]{1, 2};          // Partially initialized → {1, 2, 0, 0, 0}

    std::cout << "global_int: " << global_int << '\n';  // Safe: 0
    std::cout << "b: " << b << '\n';                    // Safe: 0
    // std::cout << a;  // DANGER: UB — reading uninitialized variable!

    std::cout << "p2: (" << p2.x << ", " << p2.y << ")\n";  // Safe: (0, 0)
    std::cout << "p3: (" << p3.x << ", " << p3.y << ")\n";  // Safe: (1, 2)
}
Sortie

Cliquez sur Exécuter pour compiler et lancer cet exemple.

Ordre de destruction : LIFO

Les objets avec une durée de stockage automatique (variables locales) sont détruits dans l'ordre inverse de leur construction — Dernier Entré, Premier Sorti (LIFO). Ce n'est pas juste une convention ; le standard le garantit.

Cela a une importance considérable quand les objets dépendent les uns des autres. Si l'objet b référence l'objet a, vous devez déclarer a avant b. Alors b est détruit en premier (pendant que a est encore vivant), et a est détruit en second. Si l'ordre était inversé, le destructeur de b pourrait tenter d'utiliser un a mort — comportement indéfini.

Pour les variables statiques, la destruction se produit dans l'ordre inverse de la construction après le retour de main(). Les variables globales définies dans la même unité de traduction sont construites de haut en bas et détruites de bas en haut. Mais entre les unités de traduction, l'ordre de construction des globales est non spécifié — c'est le fiasco de l'ordre d'initialisation statique.

Piège

Le fiasco de l'ordre d'initialisation statique est l'un des pièges les plus notoires en C++. Lorsque vous avez des variables globales ou statiques dans différentes unités de traduction (différents fichiers .cpp), le standard C++ ne spécifie pas l'ordre dans lequel elles sont initialisées. Si une globale dépend d'une autre d'une unité de traduction différente, elle peut être utilisée avant d'être construite.

La solution classique est l'idiome Construct on First Use : remplacez la variable globale par une fonction qui retourne une référence vers une variable locale statique. Les statiques locales sont garanties d'être initialisées la première fois que la fonction est appelée (et c'est thread-safe depuis C++11).

  • L'ordre d'initialisation des globales entre les unités de traduction est non spécifié
  • Si la globale A (dans file1.cpp) dépend de la globale B (dans file2.cpp), B pourrait ne pas encore exister quand A est construite
  • Solution : utilisez l'idiome Construct on First Use — encapsulez les globales dans des fonctions avec des statiques locales
  • L'initialisation des statiques locales est thread-safe depuis C++11 (connue sous le nom de « magic statics »)

L'idiome Construct on First Use

Ce patron résout le fiasco de l'ordre d'initialisation statique en différant la construction jusqu'à la première utilisation :

construct_on_first_use.cppc++23
#include <string>
#include <iostream>

// BAD: global depends on another global — order is unspecified across files
// std::string bad_prefix = "[LOG] ";  // What if this is used before construction?

// GOOD: Construct on First Use idiom
std::string& log_prefix() {
    static std::string prefix = "[LOG] ";  // Constructed on first call, thread-safe
    return prefix;
}

std::string& app_name() {
    static std::string name = "MyApp";
    return name;
}

void log(const std::string& message) {
    // Safe: log_prefix() and app_name() are guaranteed to be initialized
    std::cout << log_prefix() << app_name() << ": " << message << '\n';
}

int main() {
    log("Starting up");
    log("Ready");
}
Sortie

Cliquez sur Exécuter pour compiler et lancer cet exemple.

Points clés
  • La portée (où un nom est visible) et la durée de vie (quand un objet existe) sont des concepts distincts qui peuvent diverger
  • Le C++ a quatre durées de stockage : automatique (stack), statique (durée du programme), thread-local, et dynamique (heap)
  • Les types intégrés initialisés par défaut ont des valeurs indéterminées — les lire est un comportement indéfini
  • L'initialisation par valeur avec {} garantit l'initialisation à zéro pour les types intégrés
  • Les variables locales sont détruites dans l'ordre LIFO — déclarez les dépendances avant leurs dépendants
  • Utilisez l'idiome Construct on First Use pour éviter le fiasco de l'ordre d'initialisation statique entre unités de traduction

Quiz — Testez vos connaissances

(15 XP)

1. Quelle est la valeur d'un `int x;` local (sans initialiseur) en C++ ?

2. Qu'est-ce que le fiasco de l'ordre d'initialisation statique ?

3. En quoi `static int count = 0;` à l'intérieur d'une fonction diffère-t-il de `int count = 0;` ?