在 C++11 lambda 中按引用捕获引用

Posted

技术标签:

【中文标题】在 C++11 lambda 中按引用捕获引用【英文标题】:Capturing a reference by reference in a C++11 lambda 【发布时间】:2014-02-21 23:03:45 【问题描述】:

考虑一下:

#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();

这个程序是否保证在不调用未定义行为的情况下输出5

如果我按值 ([=]) 捕获 x,我了解它是如何工作的,但我不确定我是否通过引用捕获它来调用未定义的行为。是不是在make_function 返回后我会得到一个悬空引用,或者只要最初引用的对象仍然存在,捕获的引用就可以保证工作?

在这里寻找基于标准的明确答案 :) 它在实践中运行良好到目前为止 ;)

【问题讨论】:

请注意,捕获x 位置的另一种安全解决方案是:std::function&lt;void()&gt; make_function(int&amp; x) auto px = &amp;x; return [=]() std::cout &lt;&lt; *px &lt;&lt; std::endl; ; 是的,这值得一提。谢谢。 刚刚更新了上面的评论,表明参数可以作为参考。重要的是按值关闭指针。 真的有“引用引用”之类的东西吗?我的理解一直是,任何引用都只是对原始实例的引用,而不是可能从中创建的引用。即,即使存在从其他引用生成的引用链,只要原始项目仍然是最新的,临时引用也可以超出范围而不会影响从它们创建的引用。 【参考方案1】:

代码保证可以工作。

在我们深入研究标准措辞之前:C++ 委员会的意图是该代码有效。然而,目前的措辞被认为在这方面不够明确(事实上,对标准后 C++14 所做的错误修复破坏了使其工作的微妙安排),因此提出CWG issue 2011 来澄清问题,现在正在通过委员会。据我所知,没有任何实现会出错。


我想澄清几件事,因为 Ben Voigt 的回答包含一些造成混淆的事实错误:

    “作用域”是 C++ 中的静态词汇概念,它描述了程序源代码的一个区域,其中非限定名称查找将特定名称与声明相关联。它与生命无关。见[basic.scope.declarative]/1。

    同样,lambda 的“到达范围”规则是确定何时允许捕获的语法属性。例如:

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

    n在这里是在作用域内,但是lambda的到达作用域不包括它,所以不能被捕获。换句话说,lambda 的到达范围是它可以到达和捕获变量的“向上”距离——它可以到达封闭的(非 lambda)函数及其参数,但它不能到达那个范围之外并且捕获出现在外面的声明。

所以“达到范围”的概念与这个问题无关。被捕获的实体是make_function的参数x,在lambda的可达范围内。


好的,让我们看看标准在这个问题上的措辞。根据 [expr.prim.lambda]/17,只有引用由副本捕获的实体的 id-expression 被转换为对 lambda 闭包类型的成员访问;引用通过引用捕获的实体的 id-expression 被单独保留,并且仍然表示它们在封闭范围中表示的同一实体。

这看起来很糟糕:引用x 的生命周期已经结束,那么我们如何引用它呢?好吧,事实证明几乎(见下文)没有办法在其生命周期之外引用引用(您可以看到它的声明,在这种情况下它在范围内,因此可能可以使用,或者它是一个类成员,在这种情况下,类本身必须在其生命周期内才能使成员访问表达式有效)。因此,该标准直到最近才禁止在其生命周期之外使用引用。

lambda 措辞利用了这样一个事实,即在其生命周期之外使用引用不会受到惩罚,因此不需要为引用捕获的实体的访问方式给出任何明确的规则——它只是意味着您使用该实体;如果是引用,则名称表示它的初始化器。这就是保证直到最近(包括在 C++11 和 C++14 中)才能正常工作的方式。

但是,您不能在其生命周期之外提及引用,这不是完全正确的;特别是,您可以从它自己的初始化程序中引用它,从比引用更早的类成员的初始化程序中引用它,或者如果它是一个命名空间范围的变量并且您从另一个在它之前初始化的全局变量中访问它。引入CWG issue 2012 是为了解决这个疏忽,但它无意中通过引用引用破坏了 lambda 捕获规范。我们应该在 C++17 发布之前修复这个回归;我已经提交了国家机构的评论,以确保它得到适当的优先级。

【讨论】:

我的回答中没有事实错误,我提到达到范围是证明捕获的实体是x 而不是i,您在没有证据的情况下声明。我们似乎在所有观点上都同意,包括“[将]使其工作的安排”在 C++14 中被破坏了。很高兴看到 C++17 中的修复,您会注意到我的回答准确地说“我绝对希望看到标准澄清。” CWG2011 问题是否已经在 c++17 中审查过? 在the last C++17 draft 中,相关部分现在是 8.1.5.2(11), p. 106. 这里的 C++ 初学者:根据问题 Is this program guaranteed to output 5 without invoking undefined behavior? 中的确切措辞,即使标准正确,编译器优化/指令重新排序也可能无法保证输出 5。由于i = 5 出现无操作,编译器可能会忽略分配。这种理解正确吗?【参考方案2】:

TL;DR:标准不保证问题中的代码,并且有合理的 lambdas 实现导致它中断。假设它是不可移植的,而是使用

std::function<void()> make_function(int& x)

    const auto px = &x;
    return [/* = */ px] std::cout << *px << std::endl; ;

从 C++14 开始,您可以使用初始化捕获来取消显式使用指针,这会强制为 lambda 创建一个新的引用变量,而不是重用封闭范围内的引用变量:

std::function<void()> make_function(int& x)

    return [&x = x] std::cout << x << std::endl; ;


乍一看,似乎应该是安全的,但标准的措辞引起了一些问题:

最小封闭范围是块范围(3.3.3)的 lambda 表达式是本地 lambda 表达式;任何其他 lambda-expression 在其 lambda-introducer 中不应有 capture-default 或 simple-capture。 本地 lambda 表达式的到达范围是一组封闭范围,直到并包括 最内层的封闭函数及其参数。

...

所有此类隐式捕获的实体都应在 lambda 表达式的到达范围内声明。

...

[ 注意:如果实体通过引用隐式或显式捕获,则在实体的生命周期结束后调用相应 lambda 表达式的函数调用运算符可能会导致未定义的行为。 ——尾注]

我们预计会发生的是,make_function 中使用的x 引用main() 中的i(因为这就是引用所做的),并且实体i 被引用捕获。由于该实体在 lambda 调用时仍然存在,所以一切都很好。

但是! “隐式捕获的实体”必须是“在 lambda 表达式的到达范围内”,并且 main() 中的 i 不在到达范围内。 :( 除非参数 x 算作“在到达范围内声明”,即使实体 i 本身在到达范围之外。

这听起来是,与 C++ 中的任何其他地方不同,创建了引用到引用,并且引用的生命周期是有意义的。

绝对是我希望看到标准澄清的事情。

同时,TL;DR 部分中显示的变体绝对是安全的,因为指针是通过值捕获的(存储在 lambda 对象本身中),并且它是指向对象的有效指针,通过调用拉姆达。我还希望通过引用捕获实际上最终会存储一个指针,因此这样做不应该有运行时损失。


仔细观察,我们还认为它可能会破裂。请记住,在 x86 上,在最终的机器代码中,局部变量和函数参数都是使用 EBP 相对寻址来访问的。参数有一个正偏移,而本地是负的。 (其他体系结构具有不同的寄存器名称,但许多以相同的方式工作。)无论如何,这意味着可以通过仅捕获 EBP 的值来实现按引用捕获。然后可以通过相对寻址再次找到局部变量和参数。事实上,我相信我听说过 lambda 实现(在 C++ 之前很久就有 lambda 的语言中)正是这样做的:捕获定义 lambda 的“堆栈帧”。

这意味着当make_function 返回并且它的堆栈帧消失时,所有访问本地和参数的能力也会消失,即使是引用的那些。

并且该标准包含以下规则,可能专门用于启用这种方法:

对于通过引用捕获的实体,是否在闭包类型中声明了其他未命名的非静态数据成员是未指定的。

结论:标准不保证问题中的代码,并且有合理的 lambdas 实现导致它中断。假设它是不可移植的。

【讨论】:

“可能导致未定义的行为”。这是正常的标准口语吗? :) 这听起来出人意料地模糊:P @dyp:再看看 /18(或 /17)。 “作为通过复制捕获的实体的 odr-use (3.2) 的每个 id 表达式都转换为对闭包类型的相应未命名数据成员的访问。”它被转换为对闭包类型成员的访问。在此转换之前,它是对原始实体的访问。并且转换不是针对按引用捕获的。这些仍然是对到达范围内的原始实体的访问。 @dyp:所以我们知道,在 lambda 内部,未转换的变量访问是对到达范围内的原始实体的访问。编译器如何完成这种“引用捕获”不受限制。 (/17 或 /18 明确说明了这一点,用于不使用 odr 且未转换的访问) @BenVoigt 当然,具有引用类型的变量是“通过引用捕获的”,但没有定义该术语,以便我们可以推断当通过引用捕获的变量超出范围时会发生什么. C++11 5.1.2/22 是非规范的,我觉得有些困惑,指的是变量的“生命周期”,而不是范围。我的结论是,结果是未定义的行为,仅仅是因为标准未能定义行为。 @kriss:很抱歉您不同意规范。非常明确的是 (1) 捕获的实体是引用,而不是引用对象,并且 (2) 未定义捕获实体的生命周期结束后对 lambda 的使用。 “通过引用捕获的实体,并且实体本身就是引用”不是对引用的引用?如果您想要的是指针的副本,那么有一个语法,这是我过去六个月回答的一部分。

以上是关于在 C++11 lambda 中按引用捕获引用的主要内容,如果未能解决你的问题,请参考以下文章

在 lambda 中,引用的按值捕获是不是会复制底层对象?

g++ 不允许在 lambda 中通过引用对 const 对象进行广义捕获?

如何在lambda中按值捕获`this`和局部变量?

在 lambda 中移动捕获

C++Lambda Expression 的学习笔记

为啥我不能在 lambda 中捕获这个引用('&this')?