Skip to content

Latest commit

 

History

History
388 lines (252 loc) · 17.6 KB

File metadata and controls

388 lines (252 loc) · 17.6 KB

🔝 Retour au Sommaire

3.5.1 — const : Immutabilité à l'exécution

Chapitre 3 — Types, Variables et Opérateurs · Section 3.5 · Sous-section 1 sur 3
Prérequis : 3.5 — const, constexpr et consteval


Introduction

const est le qualificateur d'immutabilité le plus ancien et le plus fondamental du C++. Son rôle est simple à énoncer : une variable déclarée const ne peut pas être modifiée après son initialisation. Toute tentative de modification provoque une erreur de compilation.

Derrière cette simplicité apparente se cache une richesse d'usage considérable. const s'applique aux variables locales, aux paramètres de fonctions, aux pointeurs (avec deux significations très différentes selon sa position), aux références, aux méthodes de classes et aux valeurs de retour. Maîtriser toutes ces facettes — ce que la communauté C++ appelle la const-correctness — est un pilier du développement professionnel en C++.


const sur les variables

Variables locales

La forme la plus élémentaire : une variable locale dont la valeur est fixée à l'initialisation.

const int max_attempts = 5;  
max_attempts = 10; // ❌ Erreur de compilation : assignment of read-only variable  

const double pi = 3.14159265358979;  
const auto threshold = compute_threshold(data); // La valeur peut venir du runtime  

Le point clé est que const n'exige pas que la valeur soit connue à la compilation. L'initialiseur peut être un appel de fonction, une entrée utilisateur, une lecture de fichier — la seule garantie est que la valeur ne changera plus après l'initialisation.

L'initialisation est obligatoire

Une variable const doit être initialisée au moment de sa déclaration. Sans initialiseur, le compilateur refuse le code — une variable immuable sans valeur n'aurait aucun sens :

const int x;       // ❌ Erreur : default initialization of 'const int'  
const int y = 42;  // ✅ Initialisée  
const int z{};     // ✅ Value-initialization : z vaut 0  

Cette exigence est en réalité un avantage : elle élimine la possibilité d'utiliser une variable const non initialisée, un bug impossible par construction.


const et les pointeurs

C'est ici que const révèle sa subtilité. Avec les pointeurs, il y a deux choses qui peuvent être const : la donnée pointée et le pointeur lui-même. La position du mot-clé const détermine laquelle.

Pointeur vers une donnée constante (const T* ou T const*)

Le pointeur peut être modifié (réassigné vers une autre adresse), mais la donnée pointée ne peut pas être modifiée via ce pointeur :

int x = 10;  
int y = 20;  

const int* ptr = &x;  // Pointeur vers un int constant
// Équivalent : int const* ptr = &x;

std::print("{}\n", *ptr); // ✅ Lecture autorisée → 10
// *ptr = 99;              // ❌ Erreur : donnée pointée est const
ptr = &y;                  // ✅ Le pointeur lui-même peut changer  
std::print("{}\n", *ptr); // ✅ → 20  

L'astuce mnémotechnique est de lire la déclaration de droite à gauche : const int* ptr se lit « ptr est un pointeur (*) vers un int qui est const ».

Pointeur constant vers une donnée mutable (T* const)

Le pointeur est fixe (il pointe toujours vers la même adresse), mais la donnée pointée peut être modifiée :

int x = 10;  
int y = 20;  

int* const ptr = &x;  // Pointeur constant vers un int

*ptr = 99;             // ✅ La donnée pointée peut être modifiée
// ptr = &y;           // ❌ Erreur : le pointeur lui-même est const

Lecture de droite à gauche : int* const ptr → « ptr est const, c'est un pointeur (*) vers un int ».

Pointeur constant vers une donnée constante (const T* const)

Ni le pointeur ni la donnée pointée ne peuvent être modifiés :

int x = 10;

const int* const ptr = &x;

// *ptr = 99;  // ❌ Donnée const
// ptr = &y;   // ❌ Pointeur const

Tableau récapitulatif

Déclaration Donnée modifiable ? Pointeur modifiable ?
int* ptr Oui Oui
const int* ptr Non Oui
int* const ptr Oui Non
const int* const ptr Non Non

La règle de lecture de droite à gauche

Pour décrypter toute déclaration impliquant const et des pointeurs, lisez la déclaration de droite à gauche en partant du nom de la variable :

const int* const ptr
      ←────────────
ptr est const, pointeur vers int const

Cette technique fonctionne dans tous les cas, y compris les plus complexes avec plusieurs niveaux d'indirection. En pratique, les pointeurs à double const ou à double indirection sont rares dans le code moderne grâce aux références et aux smart pointers.


const et les références

Les références const (aussi appelées references to const) sont omniprésentes en C++ moderne. Elles permettent de passer des objets sans copie tout en garantissant qu'ils ne seront pas modifiés :

void display(const std::string& text) {
    std::print("{}\n", text);
    // text += " modified"; // ❌ Erreur : text est const
}

std::string message = "Bonjour";  
display(message); // Aucune copie — passage par référence const  

Références const et temporaires

Une propriété fondamentale des références const est qu'elles peuvent se lier à des temporaires (rvalues), ce qu'une référence non-const ne peut pas faire :

const std::string& ref = "temporaire"s; // ✅ La référence const prolonge la durée de vie
// std::string& bad = "temporaire"s;    // ❌ Erreur : référence non-const ne se lie pas à un temporaire

Ce mécanisme, combiné avec la prolongation de durée de vie vue en section 3.4, fait des références const le choix par défaut pour les paramètres de fonctions qui ne modifient pas leur argument.

Pas de « référence const mutable »

Contrairement aux pointeurs, il n'existe pas de distinction entre « référence const » et « référence constante ». Une référence est intrinsèquement un alias fixe — elle ne peut jamais être réassignée pour désigner un autre objet. L'écriture int& const ref est illégale (et inutile, puisque la référence est déjà implicitement « const » dans le sens où elle ne peut pas être rebindée).

Il n'y a donc que deux formes :

int& ref = x;         // Référence mutable vers x  
const int& cref = x;  // Référence vers un int constant (ou : int const& cref = x)  

const dans les paramètres de fonctions

Le choix de qualifier ou non les paramètres avec const est un aspect central de la conception d'interfaces en C++.

Passage par référence const : le choix par défaut pour les objets

Pour tout objet dont la copie a un coût significatif (std::string, std::vector, classes personnalisées), le passage par référence const est la convention standard en C++ :

// ✅ Référence const : pas de copie, pas de modification
double compute_average(const std::vector<int>& data);

// ❌ Passage par valeur : copie potentiellement coûteuse
double compute_average(std::vector<int> data);

// ❌ Référence non-const : laisse croire que la fonction modifie data
double compute_average(std::vector<int>& data);

La signature const std::vector<int>& communique un contrat clair : « je lis les données, je ne les modifie pas, et je ne les copie pas ». C'est l'option qui offre le meilleur compromis entre performance et sécurité.

Passage par valeur : pour les types légers et les sink parameters

Pour les types primitifs (int, double, bool, pointeurs), le passage par valeur est préférable au passage par référence, car il est souvent plus efficace (un registre suffit à passer la valeur) :

// ✅ Par valeur pour les types primitifs
int square(int n);  
bool is_valid(double value);  

Ajouter const sur un paramètre par valeur est possible mais ne fait pas partie de la signature visible de la fonction — c'est un détail d'implémentation. Certains développeurs le font pour se protéger contre les modifications accidentelles dans le corps de la fonction :

int square(const int n) {
    // n = n * n;  // ❌ Erreur si on oublie de créer une variable locale
    return n * n;
}

D'autres considèrent que c'est de la verbosité inutile pour des fonctions courtes. Les deux positions sont défendables ; l'important est d'être cohérent au sein d'un projet.

Passage par référence non-const : pour les paramètres de sortie

Si la fonction doit modifier l'objet, le passage se fait par référence non-const :

void sort_and_deduplicate(std::vector<int>& data);
// Le & sans const indique que data sera modifié

La convention est que l'absence de const sur une référence signale au lecteur que la fonction a un effet de bord sur cet argument.


La const-correctness : une philosophie

La const-correctness ne se limite pas à saupoudrer des const dans le code. C'est une approche systématique qui consiste à qualifier comme const tout ce qui peut l'être — variables, paramètres, méthodes, valeurs de retour. Le principe directeur est : si quelque chose ne doit pas changer, marquez-le const. Le compilateur fera respecter cette intention.

Les bénéfices se cumulent à mesure que la base de code adopte cette discipline :

Le const se propage. Si une méthode est déclarée const, elle ne peut appeler que d'autres méthodes const sur ses membres. Si un paramètre est const, seules les opérations const sont possibles dessus. La constance « contamine » positivement le code — chaque const ajouté force les composants adjacents à respecter l'immutabilité :

class Sensor {
    std::string name_;
    double last_reading_;

public:
    // Méthode const — ne modifie pas l'objet
    const std::string& name() const { return name_; }

    // Méthode const — lecture seule
    double last_reading() const { return last_reading_; }

    // Méthode non-const — modifie l'objet
    void update(double reading) { last_reading_ = reading; }
};

void display(const Sensor& s) {
    std::print("{}: {}\n", s.name(), s.last_reading()); // ✅ Méthodes const
    // s.update(99.0); // ❌ Erreur : update() n'est pas const
}

Le const documente les contrats. En lisant la signature d'une fonction, le const (ou son absence) communique immédiatement les intentions : ce paramètre est-il modifié ? Cette méthode altère-t-elle l'état de l'objet ? Sans const, ces questions nécessitent une lecture approfondie du code.

Le const facilite la concurrence. Un objet partagé entre threads et accessible uniquement en lecture (const) ne nécessite aucune synchronisation — il n'y a pas de data race sur les lectures. La const-correctness est donc un prérequis pour écrire du code concurrent sûr (chapitre 21).


const au niveau global et static

Les variables const au niveau global (ou namespace) ont une propriété spéciale en C++ : elles ont un linkage interne par défaut. Cela signifie que chaque unité de traduction (fichier .cpp) qui inclut un header contenant une variable const obtient sa propre copie, sans conflit de symboles :

// config.h
const int max_connections = 100; // Linkage interne par défaut
// Chaque .cpp qui inclut config.h a sa propre copie de max_connections
// Pas d'erreur "multiple definition" à l'édition de liens

Ce comportement diffère des variables non-const globales, qui ont un linkage externe par défaut et provoqueraient une erreur de lien si elles étaient définies dans un header inclus par plusieurs fichiers.

Pour une variable const partagée entre toutes les unités de traduction (une seule instance en mémoire), il faut la déclarer inline (C++17) ou extern :

// C++17 : une seule instance partagée
inline const int max_connections = 100;

// Pré-C++17 : déclaration extern dans le header, définition dans un .cpp
extern const int max_connections;    // header  
const int max_connections = 100;     // un seul .cpp  

const vs #define : la fin des macros constantes

L'un des rôles historiques de const est de remplacer les macros #define du préprocesseur C pour définir des constantes :

// ❌ Style C : macro sans type, sans portée, sans vérification
#define MAX_BUFFER_SIZE 4096
#define PI 3.14159

// ✅ Style C++ : constante typée, scopée, vérifiée
const int max_buffer_size = 4096;  
const double pi = 3.14159;  

// ✅✅ Encore mieux en C++ moderne : constexpr (sous-section suivante)
constexpr int max_buffer_size = 4096;  
constexpr double pi = 3.14159;  

Les avantages de const (et constexpr) par rapport à #define sont nombreux : le type est vérifié par le compilateur, la constante obéit aux règles de portée et de namespace, elle est visible dans le débogueur (une macro est remplacée par le préprocesseur avant la compilation et n'a aucune existence pour le compilateur), et elle ne crée pas de surprises syntaxiques (les macros sont de simples substitutions textuelles).

En C++ moderne, il n'y a aucune raison d'utiliser #define pour des constantes. const ou constexpr sont supérieurs dans tous les cas.


Pièges courants

Piège n°1 : const ne signifie pas « constante de compilation »

int get_user_input();

const int user_choice = get_user_input(); // ✅ Valide — mais pas constexpr

// int arr[user_choice]; // ❌ VLA interdit en C++ standard
                         //    (user_choice n'est pas une constante de compilation)

constexpr int fixed_size = 100;  
int arr[fixed_size]; // ✅ constexpr est une constante de compilation  

Si vous avez besoin d'une valeur connue à la compilation (taille de tableau, paramètre de template), utilisez constexpr, pas simplement const.

Piège n°2 : le const de haut niveau est ignoré dans les signatures de fonctions

Le const sur un paramètre par valeur n'affecte pas la signature de la fonction vue de l'extérieur — il est un détail d'implémentation :

void f(int n);  
void f(const int n); // ❌ Erreur : redéclaration de la même fonction  
                     //    (const sur un paramètre par valeur est ignoré dans la signature)

En revanche, le const sur un paramètre par référence ou pointeur fait partie de la signature :

void g(int& n);  
void g(const int& n); // ✅ Deux fonctions distinctes (surcharge valide)  

Piège n°3 : const et pointeurs — la position compte

L'erreur classique est de confondre const int* (pointeur vers un int constant) et int* const (pointeur constant vers un int). La section sur les pointeurs ci-dessus détaille la distinction. En cas de doute, utilisez la lecture de droite à gauche.

Piège n°4 : retourner une référence const vers un temporaire local

const std::string& dangerous() {
    std::string local = "hello";
    return local; // ❌ Dangling reference : local est détruit à la sortie de la fonction
}

Le const ne prolonge pas la durée de vie de local — la prolongation ne fonctionne que pour les temporaires liés directement à une référence locale (voir section 3.4). Ici, local est une variable nommée, pas un temporaire, et elle est détruite quand la fonction retourne. La référence retournée pointe vers de la mémoire libérée.


Bonnes pratiques

Appliquez const par défaut. Déclarez chaque variable const sauf si vous savez qu'elle devra être modifiée. C'est l'inverse de l'approche traditionnelle (tout est mutable sauf indication contraire), mais c'est plus sûr et plus expressif :

// Réflexe : const par défaut
const auto data = load_data(path);  
const auto size = data.size();  
const auto average = compute_average(data);  

// Mutable uniquement quand nécessaire
auto accumulator = 0.0;  
for (const auto& value : data) {  
    accumulator += value;
}

Passez les objets lourds par const&. C'est le mode de passage par défaut pour tout type dont la copie a un coût non trivial.

Qualifiez les méthodes const dès que possible. Si une méthode ne modifie pas l'état logique de l'objet, marquez-la const. Cela permet de l'appeler sur des instances const et des références const, et cela documente l'absence d'effets de bord.

Activez les avertissements pertinents comme -Wsuggest-override (GCC) pour que le compilateur vous signale les méthodes virtuelles qui devraient être marquées override, et plus généralement, laissez -Wall -Wextra vous guider vers une meilleure const-correctness.

Quand const ne suffit pas, passez à constexpr. Si la valeur est connue à la compilation et que vous souhaitez l'utiliser dans des contextes compile-time (tailles de tableaux, paramètres de template, static_assert), constexpr offre des garanties plus fortes — c'est le sujet de la sous-section suivante.


En résumé

const est l'outil de base de l'immutabilité en C++. Il protège les données contre les modifications accidentelles, documente les intentions, permet des optimisations et facilite l'écriture de code concurrent sûr. Sa richesse vient de sa capacité à s'appliquer à tous les niveaux du langage — variables, pointeurs (avec la distinction critique entre const T* et T* const), références, paramètres et méthodes. La const-correctness — l'application systématique de const partout où c'est possible — est l'un des marqueurs les plus fiables d'un code C++ de qualité professionnelle.


⏭️ constexpr : Calcul à la compilation