代码由于子表达式的未指定评估顺序而表现出未指定行为,尽管在此情况下所有副作用都在函数内完成(
引入了一个顺序关系)。尽管如此,它并不会调用未定义的行为。
这个例子提到在建议书
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
相对于 4
和 7
的潜在计算顺序选择解释了在评估 f2()
时 clang
和 gcc
结果的差异。在我的测试中,clang
在计算 4
和 7
之前计算 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 ]
s.replace( s.replace( s.replace(0, 4, "" ).find( "even" ), 4, "only" ).find( " don't" ), 6, "" );
- Ben Voigtcout << a << b << c
等价于operator<<(operator<<(operator<<(cout, a), b), c)
,但后者稍微更为冗长。 - Oktalist