Skip to content

Latest commit

 

History

History
333 lines (240 loc) · 12.1 KB

File metadata and controls

333 lines (240 loc) · 12.1 KB

🔝 Retour au Sommaire

4.3.2 — Passage par référence (&)

Chapitre 4 · Structures de Contrôle et Fonctions · Niveau Débutant


Principe

Quand un paramètre est passé par référence, la fonction ne reçoit pas une copie de l'argument mais un alias — un autre nom pour la même variable. Toute modification effectuée sur le paramètre à l'intérieur de la fonction affecte directement la variable de l'appelant.

#include <iostream>

void doubler(int& x) {
    x *= 2;
}

int main() {
    int n = 5;
    doubler(n);
    std::cout << "n = " << n << "\n";  // n = 10
}

Contrairement au passage par valeur (section 4.3.1), aucune copie n'est effectuée. Le paramètre x dans doubler est la variable n de main — il s'agit du même emplacement mémoire, simplement désigné par un nom différent.


Ce qui se passe en mémoire

Visualisons la stack au moment de l'appel doubler(n) :

┌──────────────────────────┐
│  main()                  │
│    n = 5                 │  ← variable originale
├──────────────────────────┤
│  doubler()               │
│    x ──────────────────────→ (alias vers n, même adresse)
└──────────────────────────┘

Après x *= 2 :

┌──────────────────────────┐
│  main()                  │
│    n = 10                │  ← modifié via l'alias x
├──────────────────────────┤
│  doubler()               │
│    x ──────────────────────→ (toujours le même n)
└──────────────────────────┘

En interne, le compilateur implémente généralement une référence comme un pointeur constant déréférencé automatiquement, mais du point de vue du développeur, une référence se manipule exactement comme une variable ordinaire — sans * ni & dans le corps de la fonction.


Cas d'usage : paramètre en entrée-sortie (in-out)

Le passage par référence est le mécanisme standard en C++ moderne pour les paramètres in-out — c'est-à-dire des données que la fonction lit et modifie.

Modifier une variable simple

void incrementer(int& compteur) {
    ++compteur;
}

int main() {
    int visites = 0;
    incrementer(visites);
    incrementer(visites);
    incrementer(visites);
    std::cout << visites << "\n";  // 3
}

Modifier un conteneur

void ajouter_defauts(std::vector<std::string>& config) {
    config.push_back("timeout=30");
    config.push_back("retries=3");
    config.push_back("verbose=false");
}

int main() {
    std::vector<std::string> params = {"host=localhost"};
    ajouter_defauts(params);
    // params contient maintenant 4 éléments
}

Ici, passer le vecteur par référence évite une copie coûteuse et permet à la fonction de modifier le contenu original. Les deux bénéfices (performance et sémantique) sont réunis.

Échanger deux valeurs

L'exemple classique de la fonction swap illustre parfaitement le passage par référence — les deux paramètres doivent être modifiés :

void swap(int& a, int& b) {
    int tmp = a;
    a = b;
    b = tmp;
}

int main() {
    int x = 1, y = 2;
    swap(x, y);
    std::cout << "x=" << x << " y=" << y << "\n";  // x=2 y=1
}

En pratique, la STL fournit std::swap (qui utilise la sémantique de mouvement pour les types complexes), mais l'exemple montre bien le mécanisme.

Retourner plusieurs valeurs

Avant C++17 et les structured bindings, le passage par référence était le moyen principal de « retourner » plusieurs valeurs depuis une fonction :

void decomposer(double valeur, int& partie_entiere, double& partie_decimale) {
    partie_entiere = static_cast<int>(valeur);
    partie_decimale = valeur - partie_entiere;
}

int main() {
    int entier;
    double decimale;
    decomposer(3.14, entier, decimale);
    std::cout << entier << " + " << decimale << "\n";  // 3 + 0.14
}

En C++ moderne, cette approche est de moins en moins recommandée. On préfère retourner une structure, un std::pair, un std::tuple, ou utiliser les structured bindings (C++17) :

// ✅ Approche moderne — retour par valeur (RVO s'applique)
struct Decomposition {
    int partie_entiere;
    double partie_decimale;
};

Decomposition decomposer(double valeur) {
    return {
        static_cast<int>(valeur),
        valeur - static_cast<int>(valeur)
    };
}

int main() {
    auto [entier, decimale] = decomposer(3.14);  // Structured binding (C++17)
    std::cout << entier << " + " << decimale << "\n";
}

L'approche par valeur de retour est plus lisible, plus sûre (pas de paramètres non initialisés en entrée) et souvent aussi performante grâce à la copy elision (section 10.5).


Propriétés fondamentales des références

Quelques caractéristiques des références en C++ qu'il est essentiel de connaître.

Une référence doit être initialisée à la déclaration

Contrairement à un pointeur, une référence ne peut pas exister sans être liée à un objet. Elle doit être initialisée au moment de sa création :

int n = 42;  
int& ref = n;   // ✅ ref est un alias pour n  

// int& bad;     // ❌ Erreur de compilation — référence non initialisée

Une référence ne peut pas être réassignée

Une fois liée à une variable, une référence désigne cette variable pour toute sa durée de vie. L'opérateur = sur une référence modifie la valeur de l'objet référencé, pas le lien lui-même :

int a = 10;  
int b = 20;  
int& ref = a;   // ref est un alias pour a  

ref = b;        // a vaut maintenant 20 — ref pointe toujours vers a  
std::cout << a << "\n";  // 20  
std::cout << &ref << " == " << &a << "\n";  // mêmes adresses  

Une référence ne peut pas être nulle

Il n'existe pas de « référence nulle » en C++ standard. Lier une référence à nullptr (via un déréférencement de pointeur nul) est un comportement indéfini :

int* ptr = nullptr;
// int& ref = *ptr;  // ❌ Comportement indéfini — ne faites jamais ça

C'est cette propriété qui rend les références plus sûres que les pointeurs pour la majorité des cas d'usage : si une fonction reçoit une référence, elle a la garantie qu'un objet valide existe derrière.


Référence vs pointeur : résumé des différences

Propriété Référence (T&) Pointeur (T*)
Doit être initialisée Oui — toujours Non — peut être déclaré sans valeur
Peut être nulle Non Oui (nullptr)
Peut être réassignée Non — alias permanent Oui — peut pointer vers un autre objet
Syntaxe d'accès Directe (ref.member) Déréférencement (ptr->member ou *ptr)
Arithmétique Non Oui (ptr + 1, ptr++)
Usage principal en C++ moderne Passage de paramètres in-out Nullable, interop C, structures de données

Pièges courants

Retourner une référence vers une variable locale

C'est l'erreur la plus grave et la plus classique avec les références. Quand une fonction se termine, ses variables locales sont détruites. Une référence vers l'une d'entre elles devient un dangling reference — son utilisation est un comportement indéfini.

// ❌ DANGER — référence pendante (dangling reference)
int& creer_valeur() {
    int local = 42;
    return local;  // ⚠️ Warning du compilateur — 'local' est détruit ici
}

int main() {
    int& ref = creer_valeur();
    std::cout << ref << "\n";  // Comportement indéfini — mémoire libérée
}

Avec -Wall, GCC et Clang émettent un warning explicite (reference to local variable returned). Ne l'ignorez jamais.

Il est en revanche parfaitement correct de retourner une référence vers un objet dont la durée de vie dépasse celle de la fonction — un membre de classe, un paramètre reçu par référence, un objet statique :

// ✅ Correct — l'objet référencé survit à l'appel
int& premier_element(std::vector<int>& v) {
    return v[0];  // Le vecteur appartient à l'appelant — il survit
}

// ✅ Correct — variable statique, durée de vie = programme entier
int& compteur_global() {
    static int count = 0;
    return count;
}

Modifier involontairement l'original

Le passage par référence rend la modification de l'original silencieuse — il n'y a aucune indication syntaxique au site d'appel que la variable va être modifiée :

void traiter(std::vector<int>& data) {
    data.clear();  // Supprime tous les éléments
}

int main() {
    std::vector<int> mesures = {1, 2, 3, 4, 5};
    traiter(mesures);
    // mesures est maintenant vide — surprise ?
}

À l'appel traiter(mesures), rien n'indique que mesures sera modifié. C'est un problème de lisibilité. Deux approches aident à le mitiger.

La première est d'utiliser des noms de fonctions explicites qui suggèrent la modification : vider_mesures, reset_config, trier_en_place. Si le nom de la fonction ne laisse pas présager une modification, le paramètre devrait probablement être const &.

La seconde est une convention de documentation. Les C++ Core Guidelines recommandent de réserver les paramètres T& exclusivement aux cas in-out, et de systématiquement utiliser const T& pour les paramètres en lecture seule. Si une fonction prend un T&, le lecteur sait qu'elle va modifier l'argument.

Passer un temporaire à une référence non-const

Une référence non-const (T&) ne peut pas se lier à un temporaire (une rvalue). C'est une protection du langage : modifier un temporaire n'aurait aucun sens puisqu'il sera détruit immédiatement.

void modifier(int& x) { x = 42; }

int main() {
    // modifier(5);           // ❌ Erreur — un littéral n'est pas une lvalue
    // modifier(get_value()); // ❌ Erreur — un temporaire n'est pas une lvalue

    int n = 5;
    modifier(n);              // ✅ OK — n est une lvalue
}

Si la fonction n'a pas besoin de modifier l'argument, utiliser const int& permet d'accepter à la fois les lvalues et les temporaires — c'est le sujet de la section 4.3.3.

Aliasing inattendu

Quand deux références désignent le même objet, des effets de bord surprenants peuvent survenir :

void copier(int& destination, const int& source) {
    destination = source;
}

int main() {
    int x = 5;
    copier(x, x);  // destination et source désignent le même objet
    // Fonctionne ici, mais dans des cas plus complexes,
    // l'aliasing peut provoquer des bugs subtils
}

Dans cet exemple simple, le résultat est correct. Mais dans des algorithmes plus complexes (par exemple un swap mal implémenté, ou des opérations sur des buffers), l'aliasing — le fait que deux références pointent vers le même objet — peut produire des résultats incorrects. C'est un problème à garder en tête, surtout dans le code générique.


Résumé

Aspect Passage par référence
Syntaxe void f(T& x)
Copie de l'argument ? Non — alias vers l'original
Fonction modifie l'original ? Oui
Accepte un temporaire ? Non — uniquement des lvalues
Peut être nul ? Non — garantie d'un objet valide
Usage principal Paramètres in-out, modification de l'appelant
Danger principal Dangling reference (retour de variable locale)

Ce qui suit

La section 4.3.3 présente le passage par référence constante (const &), qui combine l'absence de copie (comme la référence) et l'impossibilité de modifier l'original (comme le passage par valeur). C'est le mode de passage le plus utilisé en C++ moderne pour les types non primitifs.

⏭️ Par référence constante (const &)