《C++覆辙录》——2.2:捉摸不定的评估求值次序

    xiaoxiao2024-03-16  21

    本节书摘来自异步社区出版社《C++覆辙录》一书中的第2章,第2.2节,作者: 【美】Stephen C. Dewhurst(史蒂芬 C. 杜赫斯特),更多章节内容可以访问云栖社区“异步社区”公众号查看。

    2.2:捉摸不定的评估求值次序

    再没有比为迷糊的软件工程师设下的评估求值次序陷阱更能发现C++语言的C语言渊源印记了。本条款讨论同一个根源问题的若干不同表现形式,那就是C语言和C++语言在表达式如何评估求值的问题上留下了很大的处理余地。这种灵活性能够使得编译器生成高度优化的可执行代码,但同时也要求软件工程师更仔细地审视涉及这个问题的源代码,以防止对评估求值次序作出任何了无依据、先入为主的假设。

    函数实参的评估求值次序 int i = 12; int &ri = i; int f(int, int); // ... int result1 = f(i, i *= 2); // 不可移植 ``` 函数实参的评估求值并没有固定的次序。所以,传递给f的值既可能是12、242,也可能是24、243。仔细点的软件工程师可能会保证凡是在实参表里出现一次以上的变量,在传递时不改变其值。但是即使如此也并非万无一失:

    int result2 = f(i, ri *= 2); // 不可移植int result3 = f(p(), q()); // 危险……`在第一种情况下,ri是i的别名。所以,result2的值和result1一样徘徊于两可之间。在第二种情况下,我们实际上假设了p和q以什么次序来评估求值是无关紧要的。即使当前情况下这是成立的,我们也不能保证以后这就成立。问题在于,对于这个“p和q以什么次序来调用决定于编译器实现”的约束,我们在任何地方也没有文档说明4。

    最好的做法是手动消除函数实参评估求值过程中的副作用:

    result1 = f(i, i * 2); result2 = f(i, ri*2); ② int a = p(); result3 = f(a, q()); ③``` ②译者注:如果需要把i的值乘以2,可以在前或后插入i*=2。 ③译者注:这样就保证了p在q之前被调用,隐患被消除了。 子表达式的评估求值次序 子表达式的评估求值次序也一样不固定: a = p() + q(); 函数p可能在q之前调用,也可能正相反。运算符的优先级和结合性对评估求值次序没有影响。 ` a = p() + q() * r();` 3个函数p、q和r可能以6种(P3)次序中的任何一种被评估求值。乘法运算符相对于加法运算符的高优先级只能保证q和r的返回值的积在被加到p的返回值上之前被评估求值。同样的道理,加法运算符的左结合性也不决定下式中的p、q和r以何种次序被评估求值,这个结合性只是保证了先对p、q的返回值之和评估求值,再把这个和与r的返回值相加: ` a = p() + q() + r();` 加括号也无济于事: ` a = (p() + q()) * r();` p和q返回值之和会先被计算出来,但r可能是(也可能不是)第一个被评估求值的函数。唯一能保证固定的子表达式评估求值次序的做法就是使用显式的、软件工程师手工指定的中间变量5:

    a = p();int b = q();a = (a + b) * r();`这样的问题出现的频率有多高呢?反正足以每年让一两个周末泡汤就是了。考虑图2-1,一个表示语法树的继承谱系片断,它被用来实现一个做算术运算的计算器。

    图2-1 一个表示语法树的继承谱系图(简化版本),用于实现一个平凡的计算器。一个加法结果有左子树和右子树,一个赋值结果只有单个的子树以表示赋值语句的右手边操作数

    以下实现代码是不可移植的:

    1.jpg gotcha14/e.cpp int Plus::eval() const   {return l_->eval() + r_-> eval();}    int Assign::eval() const    {return id -> set(e_->eval());}``` 问题在于Plus::eval的实现,因为左子树和右子树的评估求值次序是不固定的。不过对加法而言,真的会坏事吗?毕竟,加法不是有交换律成立的吗?考虑以下的表达式: ` (a = 12) + a // ④` ④译者注:测试用例可谓用心良苦!把可能的路径都考虑到了并覆盖。 根据在Plus::eval中左子树和右子树谁先进行评估求值的次序之异,以上表达式的值既可能是24,也可能是a原先的值加上12。如果我们规定该计算器的算术规则里,赋值运算比加法运算优先,那么Plus::eval的实现必须用一个显式的中间变量来把评估求值次序固定下来:

    int Plus::eval() const {    int lft = l_eval();    return lft + r_eval();}`定位new的评估求值次序实话实说,这个问题倒并不是那么常出现的。new运算符的定位语法允许不仅向申请内存的对象的初始化函数(一般来说就是某个构造函数)传递实参,同时也向实际执行这个内存分配的函数operator new传递实参6。

    Thing *pThing =   new (getHeap(), getConstraint()) Thing(initval());``` 第一个实参列表7被传递给一个能够接受这样一些实参的`operator new`。第二个实参列表被传递给了一个Thing型别的构造函数。注意,函数的评估求值次序问题在两个函数实参列表里都存在:我们不知道`getHeap`和`getConstraint`中的哪一个函数会被先评估求值。犹有进者,我们连`operator new`和`Thing`型别的构造函数这两个函数实参列表中的哪一个列表会被先评估求值8都不得而知。当然,我们有把握的是`operator new`会比Thing型别的构造函数先调用(我们需要先为对象拿到存储,然后再去在这个存储上初始化它)。 将评估求值次序固定下来的运算符 有些运算符有着与众不同的可靠性——如果把他们单独拿出来说的话,比如逗号运算符确实能把其子表达式的评估求值次序给固定下来: ` result = expr1, expr2;` 这个语句肯定先对`expr1`评估求值,再对`expr2`评估求值,然后把`expr2`的评估求值结果赋给`result`。逗号运算符会被滥用,导致一些诡异的代码: ` return f(), g(), h(); ` 上面这段代码的作者的情商有待提高。使用更加符合习惯的代码风格,除非你有意想要使维护工程师陷入困惑:

    f();g();return h();`逗号运算符的唯一常用场合就是在for语句中的增量部分,如果迭代变量不止一个的话它就派上了用场:`for (int i=0, j=MAX; i<=j; ++i, --j) // ...`注意,后一个逗号才是逗号运算符,前一个只是声明语句的分隔符。

    逻辑运算符&&和||的短路算法特性是更有用的,利用这一点我们就有机会以一种简约的、符合习惯用法的方式表达出很复杂的逻辑条件。

    if (f() && g()) // ... if (p() || q() || r()) // ...``` 第一个表达式是说:“对f评估求值,如果结果为`false`,那么表达式的值就是false;如果结果是true,那么再对g评估求值,并以该结果作为表达式的值。”第二个表达式是说:“按照从左到右的次序依次对`p、q`和r评估求值,只要有一个结果为`true`就停下来。如果3个结果都是`false`,那么表达式的值就是`false`,否则就是`true`。”有了能把代码变得如此简约的好工具,这也就难怪以C/C++语言作为开发语言的软件工程师在他们的代码里这样频繁地应用这些运算符了。 三目条件运算符(读作“问号冒号运算符”)也起到了把其实参的评估求值次序固定下来的作用: ` expr1 ? expr2 : expr3` 第一个表达式会首先被评估求值,然后第二个和第三个表达式中的一个会被选中并评估求值,被选中并评估求值的表达式求得的结果就作为整个条件表达式的值。 ` a = f() + g() ? p() : q();` 在上面这种情况下我们对所有子表达式的评估求值次序有一定的把握。我们知道f和g肯定会比p或q先进行评估求值(尽管f和g之间的评估求值次序是不固定的),我们还知道p和q中只有其中一个会被评估求值。为增强可读性,给上面这个表达式增加一对可有可无的括号也许是个不坏的主意: ` a = (f() + g()) ? p() : q();` 如果不加这对括号,此代码的维护工程师——出于业务不精或仓促上阵——有可能会错意,以为它与下面这样的表达式等价: ` a = f() + (g() ? p() : q());` 不当的运算符重载 既然内建的运算符有着这么有用的语义,我们就不该试图去重载它们。对于C++语言来说,运算符重载只是“语法糖”,换言之,我们只是用了一种更易为人接受的语法形式来书写函数调用。举个例子来说,我们可以重载运算符&&来接受两个Thing型别的实参: ` bool operator &&(const Thing &, const Thing&); ` 当我们以中置运算符的形式来调用它的时候,维护工程师很有可能认为它和内建运算符一样具有短路算法的语义,可是这样认为就错了:

    Thing &tf1(); Thing &tf2();// ...if (tf1() && tf2()) // ... `上面这段代码和以下这个函数调用具有一模一样的语义:

    if (operator &&(tf1(), tf2())) // ...``` 相关资源:敏捷开发V1.0.pptx
    最新回复(0)