Skip to content

Latest commit

 

History

History
456 lines (323 loc) · 16.8 KB

File metadata and controls

456 lines (323 loc) · 16.8 KB

🔝 Retour au Sommaire

11.1.2 — Capture par référence [&]

Principe

La capture par référence donne à la lambda un accès direct à la variable originale dans la portée englobante. Aucune copie n'est effectuée — la lambda stocke en interne une référence vers la variable existante. Toute modification de la variable originale est visible depuis la lambda, et toute modification effectuée par la lambda affecte directement la variable originale.

int x = 10;  
auto modifier = [&x]() { x += 5; };  

modifier();  
std::print("{}\n", x);  // Affiche : 15 — la variable originale est modifiée  

L'équivalent foncteur rend le mécanisme explicite :

class __lambda {
    int& x;  // Membre de type référence
public:
    __lambda(int& ref) : x(ref) {}
    void operator()() const { x += 5; }  // const sur operator(), mais x est une référence
};

Un point subtil : l'operator() est toujours const par défaut, mais cela n'empêche pas la modification de x. La qualification const porte sur le membre de la closure — ici, c'est la référence elle-même qui est const (elle ne peut pas être rebindée), pas l'objet référencé. C'est le même principe qu'un membre int& const dans une classe — la référence est fixe, la valeur pointée est modifiable. Le mot-clé mutable n'est donc pas nécessaire pour modifier une capture par référence.


Capture explicite vs capture par défaut

Comme pour la capture par valeur, on peut nommer chaque variable individuellement ou utiliser le défaut &.

Capture explicite [&x]

On liste chaque variable précédée de & :

int total = 0;  
int count = 0;  

auto accumulate = [&total, &count](int value) {
    total += value;
    ++count;
};

accumulate(10);  
accumulate(20);  
std::print("Total: {}, Count: {}\n", total, count);  // Total: 30, Count: 2  

Seules les variables nommées sont accessibles. Tenter d'utiliser une variable non listée provoque une erreur de compilation, ce qui constitue une protection contre les dépendances accidentelles.

Capture par défaut [&]

Le symbole & comme défaut capture par référence toutes les variables utilisées dans le corps :

int total = 0;  
int count = 0;  
double average = 0.0;  

auto accumulate = [&](int value) {
    total += value;
    ++count;
    average = static_cast<double>(total) / count;
};

accumulate(10);  
accumulate(20);  
std::print("Average: {}\n", average);  // Average: 15  

Comme avec [=], le compilateur ne capture que les variables effectivement référencées dans le corps. Une variable déclarée dans la portée englobante mais non utilisée dans la lambda n'est pas capturée.


Mutation bidirectionnelle

La caractéristique fondamentale de la capture par référence est la synchronisation bidirectionnelle entre la lambda et la portée englobante. Les modifications circulent dans les deux sens :

int shared_state = 0;  
auto increment = [&shared_state]() { ++shared_state; };  
auto read      = [&shared_state]() { return shared_state; };  

increment();  
increment();  
std::print("{}\n", read());           // 2  
std::print("{}\n", shared_state);     // 2  

shared_state = 100;                   // Modification externe  
std::print("{}\n", read());           // 100 — visible immédiatement  

Cette synchronisation est ce qui rend la capture par référence à la fois puissante et dangereuse. Elle est puissante parce qu'elle permet à une lambda de produire des effets de bord observables. Elle est dangereuse parce que la lambda dépend entièrement de la durée de vie et de l'état de la variable externe.


Cas d'usage légitime : les algorithmes STL

Le terrain de jeu naturel de la capture par référence est l'utilisation avec les algorithmes de la STL, où la lambda est exécutée immédiatement dans la même portée. La durée de vie est garantie et le coût de la copie est évité :

void analyze(const std::vector<int>& data, int threshold) {
    int above = 0;
    int below = 0;

    // ✅ Capture par référence sûre — exécution immédiate, portée locale
    std::for_each(data.begin(), data.end(), [&above, &below, threshold](int v) {
        if (v > threshold) ++above;
        else ++below;
    });

    std::print("Above: {}, Below: {}\n", above, below);
}

Ici, above et below sont capturés par référence pour accumuler des résultats, tandis que threshold est capturé par valeur car il est en lecture seule. Ce pattern — référence pour les résultats, valeur pour les paramètres — est idiomatique et sûr dans un contexte local.

Autre exemple typique, la construction d'une transformation avec un état accumulé :

std::vector<int> values = {3, 1, 4, 1, 5, 9};

int running_sum = 0;  
std::vector<int> prefix_sums;  
prefix_sums.reserve(values.size());  

std::for_each(values.begin(), values.end(), [&](int v) {
    running_sum += v;
    prefix_sums.push_back(running_sum);
});
// prefix_sums = {3, 4, 8, 9, 14, 23}

Le danger : dangling references

Le risque principal de la capture par référence est le dangling reference — la lambda survit à la variable capturée, et tout accès ultérieur est un comportement indéfini. Ce problème se manifeste dans trois scénarios récurrents.

Scénario 1 : Lambda retournée depuis une fonction

// ⚠️ UNDEFINED BEHAVIOR
auto make_adder(int base) {
    return [&base](int x) { return base + x; };  // base est détruit au return !
}

auto add_ten = make_adder(10);
// add_ten(5);  // UB : base n'existe plus

Le paramètre base vit sur la stack de make_adder. Lorsque la fonction retourne, base est détruit, mais la lambda conserve une référence vers cet emplacement mémoire désormais invalide.

Correction — capturer par valeur :

auto make_adder(int base) {
    return [base](int x) { return base + x; };  // ✅ Copie sûre
}

Scénario 2 : Lambda passée à un thread

// ⚠️ UNDEFINED BEHAVIOR potentiel
void start_background_task() {
    std::string config = "production";

    std::thread worker([&config]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::print("Config: {}\n", config);  // config peut être détruite
    });

    worker.detach();  // Le thread survit à la fonction
}
// config est détruit ici — le thread y accède peut-être encore

Avec detach(), le thread continue indépendamment après le retour de la fonction. La référence vers config devient dangling dès que start_background_task retourne.

Correction — capturer par valeur ou par shared_ptr :

void start_background_task() {
    std::string config = "production";

    std::thread worker([config]() {  // ✅ Copie — le thread possède sa propre version
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::print("Config: {}\n", config);
    });

    worker.detach();
}

Scénario 3 : Lambda stockée dans un conteneur

std::vector<std::function<int()>> callbacks;

void register_callbacks() {
    for (int i = 0; i < 5; ++i) {
        // ⚠️ UNDEFINED BEHAVIOR : i est détruit à la fin de la boucle
        callbacks.push_back([&i]() { return i; });
    }
}
// Tous les callbacks référencent un i qui n'existe plus

Ce cas est particulièrement traître car le code semble fonctionner parfois — la mémoire de la stack n'est pas immédiatement réutilisée, et les valeurs fantômes peuvent persister temporairement. Le bug ne se manifeste qu'en production, sous charge, ou avec un niveau d'optimisation différent.

Correction — capturer par valeur :

void register_callbacks() {
    for (int i = 0; i < 5; ++i) {
        callbacks.push_back([i]() { return i; });  // ✅ Chaque lambda a sa copie
    }
}

Capture par référence de variables const

Capturer une variable const par référence préserve la qualification — la lambda ne peut pas modifier la variable :

const int max_retries = 3;  
auto check = [&max_retries](int attempt) {  
    // ++max_retries;  // ERREUR : max_retries est const
    return attempt < max_retries;
};

C'est un bon pattern pour éviter la copie de types lourds en lecture seule. Cependant, la syntaxe ne permet pas de capturer une variable non-const comme const par référence. La syntaxe [const &x] n'existe pas en C++. Pour obtenir cet effet, on utilise une init capture avec std::as_const (C++17) :

std::vector<int> data = {1, 2, 3, 4, 5};

// Capture une référence const vers data — empêche toute modification
auto reader = [&cdata = std::as_const(data)]() {
    // cdata.push_back(6);  // ERREUR : cdata est const
    return cdata.size();
};

Capture par référence et boucles : le piège classique

Le piège le plus fréquent en capture par référence concerne les boucles. Toutes les lambdas créées dans la boucle partagent la même variable de boucle :

std::vector<std::function<int()>> getters;

for (int i = 0; i < 5; ++i) {
    getters.push_back([&i]() { return i; });  // ⚠️ Toutes référencent le même i
}

for (auto& fn : getters) {
    std::print("{} ", fn());
}
// Comportement indéfini car i n'existe plus après la boucle
// Même si i existait encore, toutes les lambdas retourneraient la même valeur (5)

Le problème est double. D'abord, i est une seule variable mutée à chaque itération — toutes les lambdas voient la dernière valeur. Ensuite, i est détruit à la sortie de la boucle for — les références deviennent dangling.

Comparaison avec la capture par valeur qui produit le résultat attendu :

std::vector<std::function<int()>> getters;

for (int i = 0; i < 5; ++i) {
    getters.push_back([i]() { return i; });  // ✅ Chaque lambda a sa propre copie
}

for (auto& fn : getters) {
    std::print("{} ", fn());
}
// Affiche : 0 1 2 3 4

Capture par référence et concurrence

Lorsque plusieurs threads partagent une variable capturée par référence, les mêmes règles de synchronisation s'appliquent que pour n'importe quel accès concurrent. Sans synchronisation, on obtient une data race — un comportement indéfini :

// ⚠️ DATA RACE — undefined behavior
int counter = 0;

auto task = [&counter]() {
    for (int i = 0; i < 100'000; ++i) {
        ++counter;  // Accès concurrent non synchronisé
    }
};

std::thread t1(task);  
std::thread t2(task);  
t1.join();  
t2.join();  

// counter pourrait valoir n'importe quoi — pas forcément 200'000

Les solutions classiques s'appliquent — mutex, atomiques, ou évitement pur et simple du partage :

// ✅ Solution 1 : std::atomic
std::atomic<int> counter{0};

auto task = [&counter]() {
    for (int i = 0; i < 100'000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
};
// ✅ Solution 2 : accumulation locale puis fusion
auto task = [](int iterations) {
    int local = 0;  // Pas de partage
    for (int i = 0; i < iterations; ++i) {
        ++local;
    }
    return local;
};

auto f1 = std::async(std::launch::async, task, 100'000);  
auto f2 = std::async(std::launch::async, task, 100'000);  
int total = f1.get() + f2.get();  // 200'000 garanti  

La section 21.6 (Thread-safety et data races) couvre ces problématiques en profondeur.


Capture par référence de temporaires : interdit

Le compilateur refuse la capture par référence d'une expression temporaire ou d'un rvalue — seules les lvalues peuvent être capturées par référence :

// ERREUR : impossible de capturer un temporaire par référence
// auto bad = [&x = 42]() { return x; };

// ERREUR : impossible de capturer le résultat d'un appel par référence
// auto bad = [&s = std::string("hello")]() { return s; };

Pour capturer un temporaire, on utilise une init capture par valeur :

auto ok = [x = 42]() { return x; };                      // ✅ Init capture par valeur  
auto ok2 = [s = std::string("hello")]() { return s; };   // ✅ Init capture par valeur  

Ou une init capture par référence const via une lvalue intermédiaire, bien que ce soit rarement nécessaire.


Performance : référence vs valeur

La capture par référence a un coût fixe et prévisible : le stockage d'un pointeur interne (généralement 8 octets sur une architecture 64-bit), quel que soit le type de la variable capturée. C'est ce qui la rend attractive pour les types volumineux :

std::map<std::string, std::vector<double>> large_dataset;

// Par valeur : copie complète de la map et de tous ses vecteurs
auto slow = [large_dataset]() { return large_dataset.size(); };

// Par référence : stocke un pointeur (8 octets)
auto fast = [&large_dataset]() { return large_dataset.size(); };

Cependant, la capture par référence peut avoir un coût indirect en termes d'indirection. Chaque accès à la variable passe par un pointeur, ce qui peut empêcher certaines optimisations du compilateur (notamment l'inlining de la valeur dans les registres). Pour les types primitifs utilisés intensivement dans une boucle critique, la capture par valeur peut paradoxalement être plus rapide :

int limit = 1000;

// Par référence : le compilateur ne peut pas garantir que limit ne change pas
auto ref_version = [&limit]() {
    int sum = 0;
    for (int i = 0; i < limit; ++i) sum += i;  // Accès indirect à chaque itération
    return sum;
};

// Par valeur : le compilateur peut placer limit dans un registre
auto val_version = [limit]() {
    int sum = 0;
    for (int i = 0; i < limit; ++i) sum += i;  // Valeur locale, optimisable
    return sum;
};

En pratique, les compilateurs modernes avec les optimisations activées (-O2, -O3) résolvent souvent cette différence. Mais dans du code critique en performance, mesurer avec Google Benchmark (chapitre 35) reste la meilleure approche.


Détection des dangling references

Les outils et les compilateurs offrent plusieurs niveaux de protection contre les captures par référence dangereuses.

Warnings du compilateur. GCC et Clang émettent des warnings dans certains cas évidents, comme le retour d'une lambda capturant une variable locale par référence. L'option -Wreturn-local-addr (GCC) et -Wdangling (Clang) couvrent certains de ces cas.

Analyse statique. clang-tidy dispose de checks dédiés, notamment bugprone-dangling-handle et les checks liés aux lifetimes. Ces analyses détectent des patterns dangereux que le compilateur seul ne signale pas. La configuration est détaillée en section 32.1.

Sanitizers à l'exécution. AddressSanitizer (-fsanitize=address) détecte les accès à de la mémoire désallouée, y compris les accès via des références dangling dans les lambdas. C'est la méthode la plus fiable mais elle ne couvre que les chemins d'exécution effectivement empruntés lors des tests. Voir section 29.4.1 pour la configuration.

Aucun de ces outils n'est infaillible — la discipline du développeur et le respect des guidelines restent la première ligne de défense.


Règle décisionnelle : référence ou valeur ?

Le choix entre capture par valeur et par référence se résume à une question de durée de vie et d'intention :

Situation Mode recommandé Raison
Lambda passée à un algorithme STL [&] ou [&var] Exécution immédiate, pas de risque de dangling
Lambda retournée depuis une fonction [var] ou [var = std::move(x)] La lambda survit à la portée — copier ou déplacer
Lambda passée à un thread [var] Le thread peut survivre à la portée englobante
Lambda stockée dans un conteneur [var] Durée de vie imprévisible
Accumulation de résultats locaux [&result] Mutation nécessaire, portée locale garantie
Gros objet en lecture seule, usage local [&obj] Évite la copie, sûr si usage local
Gros objet, lambda stockée [obj = std::move(x)] Transfert de propriété sans copie

La règle mnémotechnique : si la lambda peut vivre plus longtemps que les variables capturées, ne jamais capturer par référence.


Récapitulatif

Caractéristique Comportement
Mécanisme Stockage d'une référence (pointeur interne) vers la variable originale
Copie Aucune — accès direct à l'original
Modification Bidirectionnelle — lambda ↔ portée englobante
mutable requis ? Non — la référence permet la modification même sans mutable
Coût mémoire Fixe (taille d'un pointeur, typiquement 8 octets)
Dangling reference Risque si la lambda survit à la variable capturée
Temporaires Non capturables par référence
Concurrence Mêmes règles que tout accès partagé — synchronisation requise
Cas d'usage principal Lambdas éphémères, algorithmes STL, accumulation locale

⏭️ Capture de this