Loupe

[C++ / C#] Lambdas et capture de variables

C# et C++ 11 sont 2 langages supportant les expressions lambdas. C# favorisant la simplicité et la concision avant tout (capture de variables automatique, inférence de type des paramètres…) là où C++ favorise l’efficacité et le contrôle (possibilité d’inlining du corps des lambdas, contrôle du mode de capture…).

Ce post a pour but d’expliquer au développeurs avec un background C# les spécificité des lambdas C++.

Mode de capture

En C#, la capture des variables se fait de manière automatique et nous n’avons pas le choix : elle se fait par référence. Cela veut dire que l’extérieur et l’intérieur de la lambda utilisent le même espace mémoire pour accéder à la variable. Pour démontrer cela, voici un exemple de code très simple:

List<Action> actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
    actions.Add(()=>Console.WriteLine(i));
}

foreach (var action in actions)
{
    action();
}

Chaque création de lambda (à la ligne 4) capture la même variable “i” par référence. Comme l’invocation de ces lambdas ne se fait qu’une fois la boucle for terminée et que chaque lambda accède au même espace mémoire que le “i” qui est incrémenté, le résultats obtenu est :

5
5
5
5
5

En C++ la capture de variable se fait de manière explicite, à l’intérieur des crochets démarrant la déclaration de la lambda. [&i] capture la variable “i” par référence, [i] capture par copie. Ainsi le code suivant produira le même résultat que la version C# :

vector<function<void(void)>> actions;
for(int i=0;i<5;++i){
    actions.push_back([&i](){cout <<i<<"\n";});
}

for(auto& action : actions){
    action();
}

Alors que celui ci :

vector<function<void(void)>> actions;
for(int i=0;i<5;++i){
    actions.push_back([i](){cout <<i<<"\n";});
}

for(auto& action : actions){
    action();
}

produira le résultat suivant:

0
1
2
3
4

Notez que la seule différence se situe au niveau du mode de capture. Du fait de ce mode de capture explicite, je trouve le résultat de l’invocation de mes lambdas C++ beaucoup plus prévisible que leur équivalent C#.

Choisir son mode de capture

Maintenant que l’on a vu la différence de comportement du mode de capture d’une variable, se pose la question du choix de mode de capture de chaque variable capturée. Pour éclairer ce choix, je voudrait cependant illustrer une différence très importante entre la manière dont le compilateur C# traduit une capture de variable, et celle dont le compilateur C++ effectue une capture par référence.

En C#, pour s’assurer à tout moment que la variable capturée soit effectivement accessible (qu’elle ne soit pas détruite à cause d’une sortie de scope ou autre), le compilateur effectue une transformation et change complètement son mode de stockage. Par exemple, voici le code décompilé du morceau de code d’introduction:

List<Action> actions = new List<Action>();
Program.<>c__DisplayClass2 <>c__DisplayClass = new Program.<>c__DisplayClass2();
<>c__DisplayClass.i = 0;
while (<>c__DisplayClass.i < 5)
{
    actions.Add(new Action(<>c__DisplayClass.<Main>b__0));
    <>c__DisplayClass.i++;
}
foreach (Action action in actions)
{
    action();
}

Sans rentrer dans les détails de la transformation opérée, on voit nettement que la variable locale “i” a été transformée en une variable membre d’une classe générée par le compilateur.

En C++, ce n’est pas le cas ! Si on capture une variable par référence, on doit s’assurer que cette variable est toujours vivante lors de l’invocation de la lambda ! Ainsi le code suivant nous plonge tout droit dans le monde cruel des “undefined behaviors” :

vector<function<void(void)>> actions;
for(int i=0;i<5;++i){
 {
 int localCopy = i;
 actions.push_back([&localCopy](){cout <<localCopy<<"\n";});
 }
}

for(auto& action : actions){
 action();
}

Le résultat de ce morceau de code n’est pas prévisible (en tout cas pas de manière “portable”), et je ne serais pas surpris que des atrocités innommables soient commises contre la population des koalas si vous en veniez à le mettre en production.

Après cet éclairage, on peut comprendre que là où la capture par référence de C# est une opération potentiellement coûteuse, celle de C++ est plutôt une “possibilité d’optimisation” qui demande quelques précautions d’usage. En effet, tout ce que fait le compilateur C++, c’est qu’il copie l’adresse de la variable référencée. Cela évite des recopie, mais demande effectivement de s’assurer que le temps de vie de la variable capturée excède celui de la lambda. C’est pourquoi il est fortement déconseillé de capturer des variables par référence, ou des pointeurs “nus” dans le contexte de l’utilisation de la PPL par exemple (on préfèrera souvent capturer des shared_pointers par copie), car on ne contrôle pas de manière exacte quand la lambda sera exécutée (il y a aussi d’autres raisons à cela, mais qui n’ont rien à voir avec ce qui nous préoccupe dans cet article).

Un cas classique où cela peut s’avérer une optimisation très efficace, c’est celui de l’invocation des fonctions de la stl prenant en paramètre une lambda (std::foreach, std::find_if etc.), car le temps de vie de la lambda est contraint au temps d’invocation de ces fonctions. On peut donc dans ces cas capturer des variables par référence de manière complètement safe, et on s’évite des recopies multiples potentiellement coûteuse.

Happy code !

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus