C++方法链中的执行顺序

121

该程序的输出:

#include <iostream> 
class c1
{   
  public:
    c1& meth1(int* ar) {
      std::cout << "method 1" << std::endl;
      *ar = 1;
      return *this;
    }
    void meth2(int ar)
    {
      std::cout << "method 2:"<< ar << std::endl;
    }
};

int main()
{
  c1 c;
  int nu = 0;
  c.meth1(&nu).meth2(nu);
}

是:

method 1
method 2:0

为什么在meth2()开始时nu不是1?


45
虽然我知道答案,但我不认为它在任何意义上都可以被称为“显而易见”,即使是这样,也没有理由马虎地投下负评。令人失望! - Lightness Races in Orbit
4
当你修改参数时会得到这样的结果。修改其参数的函数更难阅读,对于下一个程序员来处理代码,其效果是意想不到的,并且会导致意外情况的发生。我强烈建议避免修改除调用者(invocant)以外的任何参数。在这里修改调用者不是问题,因为第二个方法是在第一个方法的结果上调用的,所以它的效果是有序的。然而还有一些情况无法避免这种问题的发生。 - Jan Hudec
这也是一个相关的问题。 - Shafik Yaghmour
2
例如,基于堆栈的调用约定可能更愿意按顺序将nu&nuc推入堆栈,然后调用meth1,将结果推入堆栈,然后调用meth2;而基于寄存器的调用约定则希望将c&nu加载到寄存器中,调用meth1,将nu加载到寄存器中,然后调用meth2 - Neil
1
这个问题的答案取决于C++标准。自C++17以来,它已经发生了变化,其中包括P0145被纳入规范。另请参见:此SO帖子cppref相关主题CoreCpp 2019演示文稿中的此点 - Amir Kirsh
显示剩余2条评论
5个回答

69

因为求值顺序是未指定的。

在调用meth1之前,您会看到main中的nu被评估为0。这就是链接的问题。我建议不要这样做。

只需创建一个漂亮、简单、清晰、易读、易懂的程序即可:

int main()
{
  c1 c;
  int nu = 0;
  c.meth1(&nu);
  c.meth2(nu);
}

16
有可能会在C++17中通过一个提案澄清某些情况下的评估顺序,从而解决这个问题。 - Revolver_Ocelot
7
我喜欢使用方法链编程(例如使用<<输出,以及使用“对象构建器”创建复杂的对象,因为这些对象的构造函数有太多参数) - 但是它与输出参数混合使用时会出现问题。 - Martin Bonner supports Monica
36
我理解的是:meth1meth2的评估顺序已经定义,但是在调用meth1之前可能会先评估meth2的参数...是这样吗? - Roddy
8
只要方法合理且仅修改调用者(第二个方法是在第一个方法的结果上调用的,因此效果是有序的),那么方法链就可以使用。 - Jan Hudec
6
想一想,这很合乎逻辑。它的工作原理类似于 meth2(meth1(c, &nu), nu) - BartekChom
显示剩余5条评论

32

我认为这篇草案标准关于求值顺序的部分是相关的:

1.9 程序执行

...

  1. 除非有注明,各个运算符的操作数和各个表达式的子表达式的求值是未排序的。运算符操作数的计算在运算符结果的计算之前。
  2. 如果对标量对象的副作用与使用相同标量对象的值计算没有排序关系,并且它们不可能同时发生,则行为未定义。

还有:

5.2.2 函数调用

...

  1. [注意:后缀表达式和参数的求值都彼此无序。参数求值的所有副作用发生在进入函数之前,结束注释]

所以针对你的代码行c.meth1(&nu).meth2(nu);,考虑在最终调用meth2时,在函数调用运算符方面发生了什么,因此我们清楚地将其分解为后缀表达式和参数nu

operator()(c.meth1(&nu).meth2, nu);
< p > 最终函数调用中后缀表达式和参数的评估(即后缀表达式 c.meth1(&nu).meth2 和 nu)与以上的函数调用规则相对无序。因此,在 nu 参数评估之前的 meth2 函数调用之前,将标量对象 ar 计算后缀表达式的副作用与 nu 参数评估的顺序无关。根据以上的程序执行规则,这是未定义的行为。换句话说,编译器没有要求在 meth2 调用时评估 nu 参数之前评估 meth1 的调用结果——它可以自由地假设 meth1 的副作用不会影响 nu 的评估。上述汇编代码在 main 函数中包含以下序列: < / p >
  1. 变量 nu 在堆栈上分配并初始化为 0。
  2. 一个寄存器(在我的情况下为 ebx)接收 nu 的值的副本
  3. 加载 nu 和 c 的地址到参数寄存器中
  4. 调用 meth1
  5. 返回值寄存器和 ebx 寄存器中先前缓存的 nu 值被加载到参数寄存器中
  6. 调用 meth2

关键是,在步骤 5 中,编译器允许在对 meth2 的函数调用中重用步骤 2 中缓存的 nu 值。在这里,它忽略了 meth1 可能会更改 nu 的可能性——'未定义行为'正在发生。注意:此答案从其原始形式中发生了实质性变化。我最初关于操作数计算的副作用不能在最终函数调用之前排序的解释是错误的,因为它们是可以排序的。问题在于操作数本身的计算是不确定排序的。


2
这是错误的。函数调用在调用函数的其他计算中具有不确定性顺序(除非另外强制执行顺序约束);它们不会交错。 - T.C.
1
@T.C. - 我从未说过函数调用是交错的。我只提到了运算符的副作用。如果您查看上面生成的汇编代码,您会发现meth1meth2之前执行,但是meth2的参数是在调用meth1之前缓存到寄存器中的nu的值 - 即编译器已经忽略了潜在的副作用,这与我的答案一致。 - Smeeheey
1
你的确声称 - “它的副作用(即设置ar的值)不能保证在调用之前被排序”。函数调用中后缀表达式(即c.meth1(&nu).meth2)的评估和该调用的参数(nu)的评估通常是无序的,但是 1)它们的副作用都在进入meth2之前排序,2)由于c.meth1(&nu)是一个函数调用,因此它与nu的评估具有不确定的顺序。如果在meth2中以某种方式获得了指向main中变量的指针,则始终会看到1。 - T.C.
2
然而,操作数计算的副作用(即设置ar值)不能保证在上述2)任何事物之前排序。正如您引用的cppreference页面中的第3项所指出的那样,它绝对保证在调用“meth2”之前排序(您也未正确引用)。 - T.C.
1
你把本来错得不严重的事情搞得更糟了。这里绝对没有未定义行为。继续阅读 [intro.execution]/15,跳过示例部分。 - T.C.
显示剩余9条评论

9
在1998年的C++标准中,第5节,第4段中提到:除非另有说明,否则单个运算符的操作数和单个表达式的子表达式的求值顺序以及副作用发生的顺序是未指定的。在前一个和下一个序列点之间,标量对象在评估表达式时最多只能修改其存储的值一次。此外,先前的值只能被访问以确定要存储的值。每个允许的子表达式排序都必须满足本段的要求;否则行为未定义。
基本上,在调用c1::meth1()之前必须计算&nu,并且在调用c1::meth2()之前必须计算nu。然而,并没有要求在计算&nu之前必须计算nu(例如,可以先计算nu,然后计算&nu,然后调用c1::meth1() - 这可能是您的编译器正在执行的操作)。因此,在c1::meth1()中的*ar = 1表达式不能保证在计算main()中的nu并将其传递给c1::meth2()之前被计算。
后来的C++标准(我今晚使用的PC上没有)基本上具有相同的条款。

8

我认为在编译时,在调用函数meth1和meth2之前,参数已经被传递给它们了。我的意思是,当你使用“c.meth1(&nu)。meth2(nu);”时,值nu = 0已经被传递给meth2,因此无论“nu”以后是否更改都没有关系。

你可以尝试这个:

#include <iostream> 
class c1
{
public:
    c1& meth1(int* ar) {
        std::cout << "method 1" << std::endl;
        *ar = 1;
        return *this;
    }
    void meth2(int* ar)
    {
        std::cout << "method 2:" << *ar << std::endl;
    }
};

int main()
{
    c1 c;
    int nu = 0;
    c.meth1(&nu).meth2(&nu);
    getchar();
}

它将为您得到所需的答案。


OP的结果(在C++17之前)的真正原因是求值顺序是未指定的。就像@BartekChom在被接受的答案中评论的那样,它就像meth2(meth1(c, &nu), nu)。但是似乎在C++17中,标准已经改变了关于方法链的规定。请参考@AmirKirsh的答案。 - undefined

3
这个问题的答案取决于C++标准。自从C++17以来,规则已经发生了变化,P0145被纳入规范。自C++17以来,评估顺序已被定义,并且参数评估将根据函数调用的顺序执行。请注意,单个函数调用内的参数评估顺序仍未指定。
因此,链接表达式中的评估顺序是有保证的,自C++17以来,按照链的实际顺序工作:自C++17以来,可以保证代码将打印:
method 1
method 2:1

在 C++17 之前,它可能会输出上述内容,但也可能会输出:
method 1
method 2:0

另请参阅:


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接