🔝 Retour au Sommaire
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.
Comme pour la capture par valeur, on peut nommer chaque variable individuellement ou utiliser le défaut &.
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.
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.
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.
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 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.
// ⚠️ 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 plusLe 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
}// ⚠️ 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 encoreAvec 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();
}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 plusCe 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
}
}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();
};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 4Lorsque 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'000Les 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.
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.
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.
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.
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.
| 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 |