在C++语言之父本贾尼·斯特劳斯特卢普的作品《C++程序设计语言(第4版)》中有一段这样的代码:
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"); // OK
}这段代码的本意是描述std::string成员函数replace的用法,但令人意想不到的是,在C++17之前它隐含着一个很大的问题,该问题的根源是表达式求值顺序。具体来说,是指一个表达式中的子表达式的求值顺序,而这个顺序在C++17之前是没有具体说明的,所以编译器可以以任何顺序对子表达式进行求值。比如说foo(a, b, c),这里的foo、a、b和c的求值顺序是没有确定的。回到上面的替换函数,如果这里的执行顺序为:
1. replace(0, 4, "")
2. tmp1 = find("even")
3. replace(tmp1, 4, "only")
4. tmp2 = find(" don't")
5. replace(tmp2, 6, "")那结果肯定是“I have heard it works only if you believe in it”,没有任何问题。但是由于没有对表达式求值顺序的严格规定,因此其求值顺序可能会变成:
1. tmp1 = find("even")
2. tmp2 = find(" don't")
3. replace(0, 4, "")
4. replace(tmp1, 4, "only")
5. replace(tmp2, 6, "")相应的结果就不是那么正确了,我们会得到“I have heard it works evenonlyyou donieve in it”。
为了证实这种问题发生的可能性,我找到了两个版本的GCC编译运行上面的代码,在最新GCC中可以得到期望的字符串,其中间代码GIMPLE也很好地描述了编译后表达式求值的顺序:
_1 = std::__cxx11::basic_string<char>::replace (&s, 0, 4, "");
_2 = std::__cxx11::basic_string<char>::find (&s, "even", 0);
_3 = std::__cxx11::basic_string<char>::replace (_1, _2, 4, "only");
_4 = std::__cxx11::basic_string<char>::find (&s, " don\'t", 0);
std::__cxx11::basic_string<char>::replace (_3, _4, 6, "");但是在使用GCC5.4的时候,出现了“I have heard it works evenonlyyou donieve in it”的结果,查看GIMPLE以后会发现其表达式求值顺序发生了变化:
D.22309 = std::__cxx11::basic_string<char>::find (&s, " don\'t", 0);
D.22310 = std::__cxx11::basic_string<char>::find (&s, "even", 0);
D.22311 = std::__cxx11::basic_string<char>::replace (&s, 0, 4, "");
D.22312 = std::__cxx11::basic_string<char>::replace (D.22311, D.22310, 4, "only");
std::__cxx11::basic_string<char>::replace (D.22312, D.22309, 6, "");除了上述的例子之外,我们常用的<<操作符也面临同样的问题:
std::cout << f() << g() << h();虽然我们认为上面的表达式应该按照f()、g()、h()顺序对表达式求值,但是编译器对此并不买单,在它看来这个顺序可以是任意的。
从C++17开始,函数表达式一定会在函数的参数之前求值。也就是说在foo(a, b, c)中,foo一定会在a、b和c之前求值。但是请注意,参数之间的求值顺序依然没有确定,也就是说a、b和c谁先求值还是没有规定。对于这一点我和读者应该是同样的吃惊,因为从提案文档上看来,有充分的理由说明从左往右进行参数列表的表达式求值的可行性。我想一个可能的原因是求值顺序的改变影响到代码的优化路径,比如内联决策和寄存器分配方式,对于编译器实现来说也是不小的挑战吧。不过既然标准已经这么定下来了,我们就应该去适应标准。在函数的参数列表中,尽可能少地修改共享的对象,否则会很难确认实参的真实值。
对于后缀表达式和移位操作符而言,表达式求值总是从左往右,比如:
E1[E2]
E1.E2
E1.*E2
E1->*E2
E1<<E2
E1>>E2在上面的表达式中,子表达式求值E1总是优先于E2。而对于赋值表达式,这个顺序又正好相反,它的表达式求值总是从右往左,比如:
E1=E2
E1+=E2
E1-=E2
E1*=E2
E1/=E2
…在上面的表达式中,子表达式求值E2总是优先于E1。这里虽然只列出了几种赋值表达式的形式,但实际上对于E1@=E2这种形式的表达式(其中@可以为+、−、*、/、%等)E2早于E1求值总是成立的。
对于new表达式,C++17也做了规定。对于:
new T(E)这里new表达式的内存分配总是优先于T构造函数中参数E的求值。最后C++17还明确了一条规则:涉及重载运算符的表达式的求值顺序应由与之相应的内置运算符的求值顺序确定,而不是函数调用的顺序规则。
表达式求值顺序的问题是很少有人会注意到的,但是通过本章的介绍我想读者应该已经感受到表达式求值顺序引起的问题的严重之处了,它可怕的地方是我们很难及时地甄别到这种错误,无论是C++的新手还是C++专家(除作者本人之外《C++程序设计语言》出版前可是有很多专家检查过的)。另外我可以告诉读者的是,这个问题已经持续了30多年了。之所以一直没有修改,应该是有C++的历史原因的,我们知道一门语言的出现是为了解决当时编程中所面临的挑战,我完全可以想象当时可能面临了很多问题,为了解决当时最主要的问题,所以放弃求值顺序的标准。不过现在,C++委员会的专家们似乎觉得是时候要发生点改变了。
在经过C++17标准一系列对于表达式求值顺序的改善之后,《C++程序设计语言》中的那段代码就可以确保最终获得的字符串为:“I have heard it works only if you believe in it”。