web-dev-qa-db-fra.com

Capture d'une référence par référence dans un lambda C ++ 11

Considère 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 pour afficher 5 sans invoquer de comportement indéfini?

Je comprends comment cela fonctionne si je capture x par valeur ([=]), mais je ne sais pas si j'invoque un comportement non défini en le capturant par référence. Serait-ce que je vais me retrouver avec une référence pendant après make_function retourne, 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 ici :) Cela fonctionne assez bien dans la pratique jusqu'à présent;)

58
Magnus Hoff

Le code est garanti pour fonctionner.

Avant de nous plonger dans le libellé des normes: c'est l'intention du comité C++ que ce code fonctionne. Cependant, le libellé actuel n'était pas suffisamment clair à ce sujet (et en effet, les corrections de bogues apportées à la norme post-C++ 14 ont rompu l'arrangement délicat qui le faisait fonctionner), donc CWG issue 2011 = a été soulevé pour clarifier les choses et fait actuellement son chemin au sein du comité. Pour autant que je sache, aucune implémentation ne se trompe.


Je voudrais clarifier certaines choses, car la réponse de Ben Voigt contient des erreurs factuelles qui créent une certaine confusion:

  1. "Scope" est une notion lexicale statique en C++, qui décrit une région du code source du programme dans laquelle la recherche de nom non qualifié associe un nom particulier à une déclaration. Cela n'a rien à voir avec la vie. Voir [basic.scope.declarative]/1 .
  2. Les règles de "portée atteinte" pour les lambdas sont également 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. Autrement dit, la portée de la lambda est de savoir jusqu'où elle peut atteindre et capturer des variables - elle peut atteindre la fonction englobante (non lambda) et ses paramètres, mais elle ne peut pas atteindre en dehors de cela 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's paramètre x, qui est dans la portée du lambda.


OK, regardons donc le libellé de la norme sur cette question. Selon [expr.prim.lambda]/17, seulement id-expression s faisant référence aux entités capturées par copie sont transformés en accès membre sur le type de fermeture lambda; id-expression s faisant référence aux entités capturées par référence sont laissés seuls, et désignent toujours la même entité qu'ils auraient dénotée dans la portée englobante.

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

Le libellé 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 de règles explicites sur l'accès à une entité capturé par référence - cela signifie simplement que vous l'utilisez entité; s'il s'agit d'une référence, le nom désigne son initialiseur. Et c'est ainsi que cela était garanti jusqu'à très récemment (y compris en C++ 11 et C++ 14).

Cependant, ce n'est pas tout à fait vrai que vous ne pouvez pas mentionner une référence en dehors de sa durée de vie; en particulier, vous pouvez le référencer à partir de son propre initialiseur, à partir de l'initialiseur d'un membre de classe antérieur à la référence, ou s'il s'agit d'une variable de portée d'espace de noms et que vous y accédez à partir d'un autre global qui est initialisé avant qu'il ne le soit. CWG issue 2012 a été introduit pour corriger cette erreur, mais il a brisé par inadvertance la spécification pour la capture lambda par référence de références. Nous devrions corriger cette régression avant la livraison de C++ 17; J'ai déposé un commentaire de l'organisme national pour m'assurer qu'il est correctement hiérarchisé.

25
Richard Smith

TL; DR: Le code dans la question n'est pas garanti par la norme, et il existe des implémentations raisonnables de lambdas qui provoquent sa rupture. Supposez qu'il ne soit pas portable et utilisez 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 supprimer l'utilisation explicite d'un pointeur à l'aide d'une capture initialisée, ce qui force la création d'une nouvelle variable de référence pour le lambda, au lieu de réutiliser celle dans la portée englobante:

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

À première vue, il semble que devrait être sûr, mais le libellé de la norme cause un peu de problème:

Une expression lambda dont la plus petite étendue englobante est une étendue de bloc (3.3.3) est une expression lambda locale; aucune autre expression lambda ne doit avoir de capture par défaut ou de capture simple dans son introducteur lambda. La portée atteignant d'une expression lambda locale est l'ensemble des étendues englobantes jusqu'à et y compris la fonction englobante la plus interne et ses paramètres. =

...

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

...

[Remarque: si une entité est implicitement ou explicitement capturée par référence, l'invocation de 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. - note de fin]

Ce que nous attendons, c'est que x, tel qu'utilisé dans make_function, Fait référence à i dans main() (puisque c'est ce que font les références), et l'entité i est capturée par référence. Puisque cette entité vit encore au moment de l'appel lambda, tout va bien.

Mais! "les entités implicitement capturées" doivent être "dans la portée de l'expression lambda", et i dans main() n'est pas dans la portée de portée. :( Sauf si le paramètre x compte comme "déclaré dans l'étendue de portée" même si l'entité i elle-même est en dehors de l'étendue de portée.

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

Certainement quelque chose que j'aimerais voir 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 tout au long de l'appel du lambda. Je m'attendrais également à ce que la capture par référence finisse par stocker un pointeur de toute façon, il ne devrait donc pas y avoir de pénalité d'exécution pour cela.


En y regardant de plus près, nous imaginons également qu'il pourrait se casser. N'oubliez pas que sur x86, dans le code machine final, les variables locales et les paramètres de fonction sont accessibles à l'aide de l'adressage relatif à EBP. Les paramètres ont un décalage positif, tandis que les sections locales sont négatives. (D'autres architectures ont des noms de registre 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 d'EBP. Ensuite, les paramètres locaux et les paramètres peuvent à nouveau être trouvés via l'adressage relatif. Et en fait, je crois avoir entendu parler d'implémentations lambda (dans des langages qui avaient des lambdas bien avant C++) faisant exactement cela: capturer le "cadre de pile" où le lambda a été défini.

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

Et la norme contient la règle suivante, susceptible spécifiquement de permettre cette approche:

Il n'est pas spécifié 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 existe des implémentations raisonnables de lambdas qui provoquent sa rupture. Supposons qu'il ne soit pas portable.

27
Ben Voigt