这段来自《C++程序设计语言》第四版36.3.6节的代码是否具有明确定义的行为?

96
在Bjarne Stroustrup的《C++程序设计语言》第四版的第36.3.6STL-like Operations中,下面的代码被用作链接的一个例子:
void f2()
{
    std::string s = "but I have heard it works even if you don't believe in it" ;
    s.replace(0, 4, "" ).replace( s.find( "even" ), 4, "only" )
        .replace( s.find( " don't" ), 6, "" );

    assert( s == "I have heard it works only if you believe in it" ) ;
}

这个 assertgcc (查看实例) 和 Visual Studio (查看实例) 中失败,但是使用 Clang (查看实例) 时不会失败。

为什么我会得到不同的结果?这些编译器中有任何一个错误地评估了链接表达式吗?还是这段代码展示了某种形式的 未指定行为未定义行为


更好的写法:s.replace( s.replace( s.replace(0, 4, "" ).find( "even" ), 4, "only" ).find( " don't" ), 6, "" ); - Ben Voigt
21
除了bug以外,我是不是唯一一个认为那样的丑陋代码不应该出现在这本书中的人? - Karoly Horvath
5
请注意,cout << a << b << c 等价于 operator<<(operator<<(operator<<(cout, a), b), c),但后者稍微更为冗长。 - Oktalist
1
@Oktalist::) 至少我理解了意图。它以简洁的格式教授参数相关名称查找和运算符语法,而且并不会给人留下你应该真的像那样编写代码的印象。 - Karoly Horvath
2个回答

106
代码由于子表达式的未指定评估顺序而表现出未指定行为,尽管在此情况下所有副作用都在函数内完成(引入了一个顺序关系)。尽管如此,它并不会调用未定义的行为。
这个例子提到在建议书N4228:细化惯用C++表达式评估顺序中,并且该建议书对问题代码进行了以下说明:

[...] 这段代码已经被全球的 C++ 专家审查过,并发表了(《C++程序设计语言》第四版)。然而,它对未指定的评估顺序的脆弱性是最近才被工具发现的 [...]

详细信息 许多人可能很明显地知道函数参数的评估顺序是未指定的,但是当这种行为与链接的函数调用相互作用时,这种行为可能并不那么明显。当我第一次分析这种情况时,对我来说并不明显,显然也不是所有的专家评审员都知道。
乍一看,似乎每个replace都必须从左到右进行评估,因此相应的函数参数组也必须按照从左到右的顺序进行评估。但这是不正确的,函数参数具有未指定的评估顺序,尽管链接函数调用确实为每个函数调用引入了从左到右的评估顺序,但每个函数调用的参数仅在与其所属的成员函数调用相关时才被排序。特别地,这会影响以下调用:
s.find( "even" )

并且:

s.find( " don't" )

这些元素在顺序上是不确定的:

s.replace(0, 4, "" )

这两个find调用可以在replace之前或之后进行评估,这很重要,因为它对s产生了副作用,会改变find的结果,它改变了s的长度。因此,根据replace相对于这两个find调用的评估时间,结果将有所不同。

如果我们查看链接表达式并检查一些子表达式的评估顺序:

s.replace(0, 4, "" ).replace( s.find( "even" ), 4, "only" )
^ ^       ^  ^  ^    ^        ^                 ^  ^
A B       |  |  |    C        |                 |  |
          1  2  3             4                 5  6

并且:

.replace( s.find( " don't" ), 6, "" );
 ^        ^                   ^  ^
 D        |                   |  |
          7                   8  9

请注意,我们忽略了数字4和7可以进一步分解成更多的子表达式。因此:
- A 在 B 之前被排序,B 在 C 之前被排序,C 在 D 之前被排序 - 数字1到9与其他子表达式的排序是不确定的,但以下情况除外:
- 数字1到3在 B 之前排序 - 数字4到6在 C 之前排序 - 数字7到9在 D 之前排序
关键在于:
- 数字4到9与 B 的排序是不确定的

关于 B 相对于 47 的潜在计算顺序选择解释了在评估 f2()clanggcc 结果的差异。在我的测试中,clang 在计算 47 之前计算 B,而 gcc 则在之后计算。我们可以使用以下测试程序来演示每种情况下发生的情况:

#include <iostream>
#include <string>

std::string::size_type my_find( std::string s, const char *cs )
{
    std::string::size_type pos = s.find( cs ) ;
    std::cout << "position " << cs << " found in complete expression: "
        << pos << std::endl ;

    return pos ;
}

int main()
{
   std::string s = "but I have heard it works even if you don't believe in it" ;
   std::string copy_s = s ;

   std::cout << "position of even before s.replace(0, 4, \"\" ): " 
         << s.find( "even" ) << std::endl ;
   std::cout << "position of  don't before s.replace(0, 4, \"\" ): " 
         << s.find( " don't" ) << std::endl << std::endl;

   copy_s.replace(0, 4, "" ) ;

   std::cout << "position of even after s.replace(0, 4, \"\" ): " 
         << copy_s.find( "even" ) << std::endl ;
   std::cout << "position of  don't after s.replace(0, 4, \"\" ): "
         << copy_s.find( " don't" ) << std::endl << std::endl;

   s.replace(0, 4, "" ).replace( my_find( s, "even" ) , 4, "only" )
        .replace( my_find( s, " don't" ), 6, "" );

   std::cout << "Result: " << s << std::endl ;
}

gcc的结果(查看实时演示

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position  don't found in complete expression: 37
position even found in complete expression: 26

Result: I have heard it works evenonlyyou donieve in it

clang的结果(现场查看):

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position even found in complete expression: 22
position don't found in complete expression: 33

Result: I have heard it works only if you believe in it

Visual Studio的结果(现场查看):

position of even before s.replace(0, 4, "" ): 26
position of  don't before s.replace(0, 4, "" ): 37

position of even after s.replace(0, 4, "" ): 22
position of  don't after s.replace(0, 4, "" ): 33

position  don't found in complete expression: 37
position even found in complete expression: 26
Result: I have heard it works evenonlyyou donieve in it
标准细节

我们知道,除非另有说明,否则子表达式的计算顺序是未排序的,这来自于draft C++11 standard的第1.9程序执行,其中写道:

除非有说明,否则将不按顺序评估每个运算符的操作数和各个表达式的子表达式。[...]

我们知道函数调用引入了一个函数调用后缀表达式和参数与函数体之间的顺序关系,来自于第1.9节:

[...] 在调用函数时(无论函数是否内联),与任何参数表达式相关联的所有值计算和副作用,或与指定被调用函数的后缀表达式相关联的所有值计算和副作用,在调用函数体中的每个表达式或语句之前都已排序执行。[...]

我们还知道类成员访问以及链接将从左到右进行求值,来自于第5.2.5类成员访问,其中写道:

点号或箭头前的后缀表达式被评估;64该评估的结果与id表达式一起,决定整个后缀表达式的结果。

请注意,在id表达式最终成为非静态成员函数的情况下,它不指定()内的expression-list的评估顺序,因为那是一个单独的子表达式。来自5.2 Postfix expressions的相关语法:

postfix-expression:
    postfix-expression ( expression-listopt)       // function call
    postfix-expression . templateopt id-expression // Class member access, ends
                                                   // up as a postfix-expression

C++17变化

提案p0145r3: 用于精细C++表达式求值顺序的修正进行了几项更改。包括加强对后缀表达式及其表达式列表求值顺序规则的规定,以使代码具有良好的规范行为。

[expr.call]p5中说:

The postfix-expression is sequenced before each expression in the expression-list and any default argument. The initialization of a parameter, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other parameter. [ Note: All side effects of argument evaluations are sequenced before the function is entered (see 4.6). —end note ] [ Example:

void f() {
std::string s = "but I have heard it works even if you don’t believe in it";
s.replace(0, 4, "").replace(s.find("even"), 4, "only").replace(s.find(" don’t"), 6, "");
assert(s == "I have heard it works only if you believe in it"); // OK
}

—end example ]


7
我有点惊讶地发现“许多专家”忽略了这个问题,众所周知,在所有版本的C和C++中,评估函数调用的后缀表达式不会在评估参数之前进行排序。 - M.M
@ShafikYaghmour 函数调用在彼此和其他所有内容方面都是不确定顺序的,除了您指出的有序关系之外。但是,对于1、2、3、5、6、8、9、"even"、"don't"以及s的几个实例,它们相对于彼此是无序的。 - T.C.
4
@TC 不是这样的(这就是导致这个“bug”的原因)。例如,foo().func(bar())可以在调用bar()之前或之后调用foo()后缀表达式foo().func。参数和后缀表达式在func()函数体之前被排序,但它们在彼此之间没有排序。 - M.M
@MattMcNabb 哦,对了,我看错了。你说的是 后缀表达式 本身而不是调用。是的,没错,它们是无序的(除非当然还有其他规则适用)。 - T.C.
@MattMcNabb 很遗憾我没有自然地找到这段代码,当你知道代码存在问题时,与看到你没有任何先入为主观念的代码不同。我必须说,即使我知道我在寻找什么,特定问题的位置也不是很明显,所以我可以理解它可能被忽略了。 - Shafik Yaghmour
6
还有一个因素是,人们往往会认为出现在B.Stroustrup的书中的代码是正确的,否则早就会有人注意到了!(相关;SO用户仍然会发现K&R中的新错误) - M.M

4
这是为了增加关于C++17的相关信息。提案(Refining Expression Evaluation Order for Idiomatic C++ Revision 2)针对C++17解决了上述问题,引用了上面的代码作为样例。
如建议所述,我添加了提案中的相关信息并进行了引用(重点是我的):

The order of expression evaluation, as it is currently specified in the standard, undermines advices, popular programming idioms, or the relative safety of standard library facilities. The traps aren't just for novices or the careless programmer. They affect all of us indiscriminately, even when we know the rules.

Consider the following program fragment:

void f()
{
  std::string s = "but I have heard it works even if you don't believe in it"
  s.replace(0, 4, "").replace(s.find("even"), 4, "only")
      .replace(s.find(" don't"), 6, "");
  assert(s == "I have heard it works only if you believe in it");
}

The assertion is supposed to validate the programmer's intended result. It uses "chaining" of member function calls, a common standard practice. This code has been reviewed by C++ experts world-wide, and published (The C++ Programming Language, 4th edition.) Yet, its vulnerability to unspecified order of evaluation has been discovered only recently by a tool.

该论文建议修改关于表达式求值顺序的预C++17规则,这个规则受到了C语言的影响并已存在了三十多年。它提出"语言应保证当代惯用语",否则就会面临"陷阱和难以发现的错误来源",例如上述代码样例中所发生的情况。
对于C++17的提案是要求每个表达式都有明确定义的求值顺序:
- 后缀表达式从左到右求值,包括函数调用和成员选择表达式。 - 赋值表达式从右到左求值,包括复合赋值。 - 移位运算符的操作数从左到右求值。 - 涉及重载运算符的表达式的求值顺序由相应内置运算符关联的顺序决定,而不是函数调用的规则。
上述代码在GCC 7.1.1和Clang 4.0.0下编译成功。

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