74 votes

Capturer une référence par référence dans un lambda C++11

Considérez ceci :

#include <functional>
#include <iostream>

std::function<void()> make_function(int& x) {
    return [&]{ std::cout << x << std::endl; };
}

int main() {
    int i = 3;
    auto f = make_function(i);
    i = 5;
    f();
}

Ce programme est-il garanti de produire 5 sans invoquer un comportement indéfini ?

Je comprends comment cela fonctionne si je capture x par valeur ( [=] ), mais je ne suis pas sûr d'invoquer un comportement non défini en le capturant par référence. Se pourrait-il que je me retrouve avec une référence pendante après que make_function ou la référence capturée est-elle garantie de fonctionner tant que l'objet référencé à l'origine est toujours là ?

Vous cherchez des réponses définitives basées sur des normes :) Cela fonctionne assez bien dans la pratique jusqu'à présent ;)

2 votes

Notez qu'une autre solution sûre pour capturer l'emplacement des x est : std::function<void()> make_function(int& x) { auto px = &x; return [=](){ std::cout << *px << std::endl; }; }

0 votes

Oui, cela vaut la peine d'être mentionné. Merci.

0 votes

Je viens de mettre à jour le commentaire ci-dessus pour montrer que le paramètre peut rester une référence. L'important est de fermer sur un pointeur, par valeur.

42voto

Richard Smith Points 3935

Le code est garanti pour fonctionner.

Avant de nous plonger dans la formulation des normes : le comité C++ souhaite que ce code fonctionne. Cependant, la formulation actuelle a été jugée insuffisamment claire à ce sujet (et en effet, les corrections de bogues apportées à la norme après C++14 ont brisé l'arrangement délicat qui permettait son fonctionnement). Numéro de CWG 2011 a été soulevée afin de clarifier les choses, et fait actuellement son chemin au sein de la commission. Pour autant que je sache, aucune mise en œuvre ne se trompe à ce sujet.


J'aimerais clarifier quelques points, car la réponse de Ben Voigt contient quelques erreurs factuelles qui créent une certaine confusion :

  1. Le "Scope" est une notion statique et lexicale en C++, qui décrit une région du code source du programme dans laquelle la recherche de noms non qualifiés associe un nom particulier à une déclaration. Elle n'a rien à voir avec la durée de vie. Voir [basic.scope.declarative]/1 .
  2. Les règles d'"atteinte de la portée" pour les lambdas sont, de même, une propriété syntaxique qui détermine quand la capture est autorisée. Par exemple :

    void f(int n) {
      struct A {
        void g() { // reaching scope of lambda starts here
          [&] { int k = n; };
          // ...

    n est dans la portée ici, mais la portée de la lambda ne l'inclut pas, donc elle ne peut pas être capturée. En d'autres termes, la portée de la lambda est la distance "vers le haut" qu'elle peut atteindre et capturer les variables -- elle peut atteindre la fonction englobante (non lambda) et ses paramètres, mais elle ne peut pas atteindre l'extérieur et capturer les déclarations qui apparaissent à l'extérieur.

La notion de "portée" n'est donc pas pertinente pour cette question. L'entité capturée est make_function Le paramètre de l'entreprise x qui se trouve dans le champ d'application de la lambda.


OK, regardons donc la formulation de la norme sur cette question. Selon la norme [expr.prim.lambda]/17, seuls les éléments suivants sont autorisés id-expression se référant à des entités capturées par copie sont transformés en un accès membre sur le type de fermeture lambda ; id-expression se référant à des entités capturées par référence sont laissés seuls, et dénotent toujours la même entité qu'ils auraient dénotée dans la portée englobante.

Cela semble immédiatement mauvais : la référence x Sa durée de vie est terminée, alors comment pouvons-nous y faire référence ? Eh bien, il s'avère qu'il n'y a presque (voir ci-dessous) aucun moyen de se référer à une référence en dehors de sa durée de vie (vous pouvez soit voir une déclaration de celle-ci, auquel cas elle est dans la portée et donc vraisemblablement OK à utiliser, ou c'est un membre de la classe, auquel cas la classe elle-même doit être dans sa durée de vie pour que l'expression d'accès au membre soit valide). Par conséquent, jusqu'à très récemment, la norme ne comportait aucune interdiction d'utiliser une référence en dehors de sa durée de vie.

La formulation lambda a profité du fait qu'il n'y a pas de pénalité pour l'utilisation d'une référence en dehors de sa durée de vie, et n'a donc pas eu besoin de donner des règles explicites sur ce que signifie l'accès à une entité capturée par référence -- cela signifie simplement que vous utilisez cette entité ; si c'est une référence, le nom indique son initialisateur. Et c'est ainsi que le fonctionnement était garanti jusqu'à très récemment (y compris dans C++11 et C++14).

Cependant, ce n'est pas tout à fait Il est vrai que vous ne pouvez pas mentionner une référence en dehors de sa durée de vie ; en particulier, vous pouvez la référencer à partir de son propre initialisateur, à partir de l'initialisateur d'un membre de classe antérieur à la référence, ou s'il s'agit d'une variable à portée d'espace de nom et que vous y accédez à partir d'un autre global initialisé avant elle. Numéro de CWG 2012 a été introduit pour réparer cet oubli, mais il a cassé par inadvertance la spécification pour la capture de lambda par référence de références. Cette régression devrait être corrigée avant la sortie de C++17 ; j'ai déposé un commentaire de l'organisme national pour m'assurer qu'elle soit correctement traitée en priorité.

30voto

Ben Voigt Points 151460

TL;DR : Le code dans la question n'est pas garanti par la norme, et il y a des implémentations raisonnables de lambdas qui le font casser. Supposons qu'il ne soit pas portable et utilisons plutôt

std::function<void()> make_function(int& x)
{
    const auto px = &x;
    return [/* = */ px]{ std::cout << *px << std::endl; };
}

À partir de C++14, vous pouvez vous passer de l'utilisation explicite d'un pointeur en utilisant une capture initialisée, qui force la création d'une nouvelle variable de référence pour la lambda, au lieu de réutiliser celle de la portée englobante :

std::function<void()> make_function(int& x)
{
    return [&x = x]{ std::cout << x << std::endl; };
}

A première vue, il semble que devrait être sûr, mais la formulation de la norme pose un petit problème :

Une expression lambda dont la plus petite portée englobante est une portée de bloc (3.3.3) est une expression lambda locale ; toute autre expression lambda ne doit pas avoir de capture-default ou de simple-capture dans son introducteur lambda. Le site portée de l'action d'une expression lambda locale est l'ensemble des portées englobantes jusqu'à et y compris l'élément fonction englobante la plus intérieure et ses paramètres.

...

Toutes ces entités capturées implicitement doivent être déclarées dans la portée de l'expression lambda.

...

[Note : Si une entité est implicitement ou explicitement capturée par référence, invoquer l'opérateur d'appel de fonction de l'expression lambda correspondante après la fin de la durée de vie de l'entité est susceptible d'entraîner un comportement non défini. - fin de la note ]

Ce que nous attendons, c'est que x utilisé à l'intérieur make_function Il s'agit de i en main() (puisque c'est ce que font les références), et l'entité i est capturé par référence. Puisque cette entité vit toujours au moment de l'appel lambda, tout est bon.

Mais ! les "entités capturées implicitement" doivent être "dans le champ d'application de l'expression lambda", et i en main() n'est pas dans le périmètre d'atteinte :( A moins que le paramètre x compte comme "déclarée dans le champ d'application" même si l'entité i elle-même est en dehors du champ d'application.

Ce à quoi ça ressemble, c'est que, contrairement à tout autre endroit en C++, une référence à une référence est créée et la durée de vie d'une référence a un sens.

C'est définitivement quelque chose que j'aimerais voir la norme clarifier.

En attendant, la variante présentée dans la section TL;DR est définitivement sûre car le pointeur est capturé par valeur (stocké à l'intérieur de l'objet lambda lui-même), et c'est un pointeur valide vers un objet qui dure jusqu'à l'appel de la lambda. Je m'attends également à ce que la capture par référence finisse par stocker un pointeur de toute façon, donc il ne devrait pas y avoir de pénalité d'exécution pour faire cela.


En y regardant de plus près, on imagine aussi qu'il pourrait se casser. Rappelez-vous que sur x86, dans le code machine final, les variables locales et les paramètres de fonction sont accessibles en utilisant l'adressage relatif EBP. Les paramètres ont un décalage positif, tandis que les variables locales sont négatives. (D'autres architectures ont des noms de registres différents mais beaucoup fonctionnent de la même manière). Quoi qu'il en soit, cela signifie que la capture par référence peut être implémentée en capturant uniquement la valeur de EBP. Ensuite, les locales et les paramètres peuvent à nouveau être trouvés via l'adressage relatif. En fait, je crois avoir entendu parler d'implémentations de lambdas (dans des langages qui avaient des lambdas bien avant C++) faisant exactement cela : capturer le "stack frame" où le lambda a été défini.

Ce que cela implique, c'est que lorsque make_function retourne et son cadre de pile disparaît, ainsi que toute capacité d'accès aux locaux ET aux paramètres, même ceux qui sont des références.

Et la norme contient la règle suivante, sans doute spécifiquement pour permettre cette approche :

Il n'est pas précisé si des membres de données non statiques supplémentaires non nommés sont déclarés dans le type de fermeture pour les entités capturées par référence.

Conclusion : Le code dans la question n'est pas garanti par la norme, et il y a des implémentations raisonnables de lambdas qui provoquent sa rupture. Supposons qu'il ne soit pas portable.

3 votes

"est susceptible d'entraîner un comportement non défini". Est-ce du langage standard normal ? :) Cela semble étonnamment vague :P

0 votes

@MagnusHoff : Eh bien, un lambda pourrait avoir capturé quelque chose et ensuite finir par ne pas l'utiliser réellement.

0 votes

Est-il même précisé ce que signifie un accès à une entité saisie par référence ? Pour les entités capturées par copie, il y a /17, mais je ne trouve rien de similaire pour les références

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X