在 C + + 11 lambda 中通过引用捕获引用

想想这个:

#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返回之后我将得到一个悬空引用,或者只要原始引用的对象仍然存在,捕获的引用就保证能够工作?

在这里寻找基于标准的明确答案:)它在实践 目前为止中工作得很好;)

49797 次浏览

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 表达式在其 lambda 引导程序中都不应该有捕获-默认或简单捕获。< strong > 局部 lambda 表达式的 到达目的地是包含 最内圈函数及其参数

...

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

...

[注意: 如果一个实体是通过引用隐式或显式捕获的,那么在该实体的生命周期结束后调用相应 lambda 表达式的函数调用操作符可能会导致未定义行为。ー尾注]

我们期望发生的是,x(在 make_function中使用)引用 main()中的 i(因为这是引用所做的) ,并且实体 i通过引用捕获。因为那个实体在 lambda 调用时仍然存在,所以一切都很好。

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

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

我当然希望看到标准的澄清。

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


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

这意味着当 make_function返回并且它的堆栈帧消失时,所有访问局部变量 AND 参数的能力都会消失,即使那些参数是引用。

《标准》包含以下规则,可能是专门用来支持这种方法的:

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

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

代码保证能正常工作。

在我们深入研究标准措辞之前: C + + 委员会的意图是这段代码能够工作。然而,目前的措辞被认为不够明确(实际上,标准的后 C + + 14时代的错误修正打破了使其运行的微妙安排) ,因此 工作小组二零一一年号被提出来澄清问题,并正在通过委员会。据我所知,没有哪个实现会出错。


我想澄清一些事情,因为 Ben Voigt 的回答包含了一些事实上的错误,这些错误造成了一些混乱:

  1. “ Scope”是 C + + 中的一个静态词法概念,它描述了程序源代码的一个区域,在这个区域中,不限定的名称查找将一个特定的名称与一个声明关联起来。这跟一辈子没关系。参见 [ basic.scope. 声明性]/1
  2. 同样,lambdas 的“到达范围”规则也是决定何时允许捕获的语法属性。例如:

    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,只有引用通过拷贝捕获的实体的 身份表达式被转换为 lambda 闭包类型的成员访问; 引用通过引用捕获的实体的 身份表达式被保留,并且仍然表示它们在封闭范围中会表示的相同实体。

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

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

但是,不能在引用的生命周期之外提及引用并不是 没错的真实情况; 特别是,您可以从它自己的初始化器中引用它,从比引用更早的类成员的初始化器中引用它,或者如果它是一个名称空间范围变量,并且您可以从在它之前初始化的另一个全局变量访问它。引入 工作小组二零一二年号是为了修复这个疏忽,但它无意中通过引用破坏了 lambda 捕获的规范。我们应该在 C + + 17发布之前修复这个回归; 我已经提交了一份国家机构的评论,以确保它有适当的优先级。