【问题标题】:Behavior difference of lambda function mutable capture from a reference to global variablelambda函数可变捕获从全局变量引用的行为差异
【发布时间】:2020-07-07 18:04:54
【问题描述】:

我发现如果我使用 lambda 通过 mutable 关键字捕获对全局变量的引用,然后修改 lambda 函数中的值,则不同编译器的结果会有所不同。

#include <stdio.h>
#include <functional>

int n = 100;

std::function<int()> f()
{
    int &m = n;
    return [m] () mutable -> int {
        m += 123;
        return m;
    };
}

int main()
{
    int x = n;
    int y = f()();
    int z = n;

    printf("%d %d %d\n", x, y, z);
    return 0;
}

VS 2015 和 GCC (g++ (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609) 的结果:

100 223 100

clang++ 的结果(clang 版本 3.8.0-2ubuntu4 (tags/RELEASE_380/final)):

100 223 223

为什么会这样? C++ 标准是否允许这样做?

【问题讨论】:

  • Clang 的行为仍然存在于主干上。
  • 这些都是相当老的编译器版本
  • 它仍然出现在最新版本的 Clang 中:godbolt.org/z/P9na9c
  • 如果您完全删除捕获,那么 GCC 仍然接受此代码并执行 clang 所做的操作。这强烈暗示存在 GCC 错误 - 简单捕获不应该改变 lambda 主体的含义。

标签: c++ c++11 lambda language-lawyer


【解决方案1】:

lambda 无法按值捕获引用本身(为此目的使用 std::reference_wrapper)。

在您的 lambda 中,[m] 按值捕获 m(因为在捕获中没有 &amp;),所以 m(作为对 n 的引用)首先被取消引用并且 它所引用的东西 (n) 的副本被捕获。这与这样做没有什么不同:

int &m = n;
int x = m; // <-- copy made!

然后 lambda 会修改该副本,而不是原始版本。正如预期的那样,这就是您在 VS 和 GCC 输出中看到的情况。

Clang 输出错误,如果尚未报告,则应将其报告为错误。

如果您希望您的 lambda 修改 n,请改为通过引用捕获 m[&amp;m]。这与将一个引用分配给另一个引用没有什么不同,例如:

int &m = n;
int &x = m; // <-- no copy made!

或者,您可以完全摆脱m,而是通过引用捕获n[&amp;n]

虽然n在全局范围内,其实根本不需要捕获它,lambda可以在不捕获的情况下全局访问它:

return [] () -> int {
    n += 123;
    return n;
};

【讨论】:

    【解决方案2】:

    我认为 Clang 可能实际上是正确的。

    根据[lambda.capture]/11,lambda 中使用的 id-expression 仅在构成 odr-use 时才指 lambda 的 by-copy-captured 成员。如果不是,那么它指的是原始实体。这适用于自 C++11 以来的所有 C++ 版本。

    根据 C++17 的 [basic.dev.odr]/3,如果对引用变量应用左值到右值转换会产生常量表达式,则不会使用引用变量。

    然而,在 C++20 草案中,左值到右值转换的要求被删除,相关段落多次更改以包含或不包含转换。请参阅CWG issue 1472CWG issue 1741,以及打开CWG issue 2083

    由于m 是用常量表达式初始化的(指的是静态存储持续时间对象),因此使用它会在[expr.const]/2.11.1 中为每个异常生成一个常量表达式。

    但是,如果应用左值到右值的转换,则情况并非如此,因为n 的值不能用于常量表达式。

    因此,根据在确定 odr 使用时是否应该应用左值到右值的转换,当您在 lambda 中使用 m 时,它可能会或可能不会引用 lambda 的成员。

    如果应该应用转换,GCC 和 MSVC 是正确的,否则 Clang 是。

    如果您将 m 的初始化更改为不再是常量表达式,您可以看到 Clang 会改变它的行为:

    #include <stdio.h>
    #include <functional>
    
    int n = 100;
    
    void g() {}
    
    std::function<int()> f()
    {
        int &m = (g(), n);
        return [m] () mutable -> int {
            m += 123;
            return m;
        };
    }
    
    int main()
    {
        int x = n;
        int y = f()();
        int z = n;
    
        printf("%d %d %d\n", x, y, z);
        return 0;
    }
    

    在这种情况下,所有编译器都同意输出是

    100 223 100
    

    因为 lambda 中的 m 将引用类型为 int 的闭包成员,该成员从 f 中的引用变量 m 复制初始化。

    【讨论】:

    • VS/GCC 和 Clang 结果都正确吗?还是只有其中之一?
    • [basic.dev.odr]/3 表示变量m 被命名它的表达式使用,除非对其应用左值到右值转换将是一个常量表达式。通过 [expr.const]/(2.7),该转换将不是核心常量表达式。
    • 如果 Clang 的结果是正确的,我认为这有点违反直觉。因为从程序员的角度来看,他需要确保他写在捕获列表中的变量确实被复制为可变大小写,并且m的初始化可能会被程序员出于某种原因更改。
    • m += 123; 这里 m 是 odr-used。
    • 我认为 Clang 的当前措辞是正确的,虽然我没有深入研究这一点,但这里的相关更改几乎可以肯定都是 DR。
    【解决方案3】:

    C++17 标准不允许这样做,但其他一些标准草案可能允许这样做。由于此答案中未解释的原因,这很复杂。

    [expr.prim.lambda.capture]/10:

    对于通过副本捕获的每个实体,在闭包类型中声明了一个未命名的非静态数据成员。这些成员的声明顺序是未指定的。如果实体是对对象的引用,则此类数据成员的类型为被引用类型;如果实体是对函数的引用,则为对被引用函数类型的左值引用,否则为相应捕获实体的类型。

    [m] 表示f 中的变量m 被拷贝捕获。实体m 是对对象的引用,因此闭包类型有一个成员,其类型是被引用的类型。也就是说,成员的类型是int,而不是int&amp;

    由于 lambda 主体内的名称 m 命名了闭包对象的成员,而不是 f 中的变量(这是有问题的部分),语句 m += 123; 修改了该成员,这是一个不同的 @987654331 @对象来自::n

    【讨论】:

      猜你喜欢
      • 2012-03-01
      • 2023-04-06
      • 1970-01-01
      • 2020-02-13
      • 2016-06-18
      • 2021-11-12
      • 1970-01-01
      相关资源
      最近更新 更多