Skip to content

补充

关于C语言的表达式求值顺序的问题

首先,我们要解释一下什么是表达式求值顺序的问题:

在C语言中,一个表达式当中每一个运算数的计算先后顺序,包括函数调用表达式的函数参数求值顺序,这就是表达式的求值顺序问题。举例来说就是:

  1. int a = f1() + f2() + f3();
    • 其中f1()f2()f3()分别是三个函数的函数调用表达式
    • 于是这里的问题就是:这三个函数调用究竟哪一个函数调用先执行呢?
    • 这个问题就是表达式求值顺序的问题。
  2. f(a, b, c);
  • 其中f()是一个函数调用表达式,a、b、c代表此函数调用的三个实参(表达式)
  • 于是这里的问题就是:这三个实参(表达式)哪一个先求值计算呢?
  • 这个问题仍然是表达式求值顺序的问题。

关于表达式求值顺序的问题,C Reference上有一段比较权威的解释,参考网站:C语言求值顺序 - cppreference.com

比较重要的就是该网站开头的一段话:

除下列标出者,任意 C 运算符的运算数求值顺序,包括函数调用表达式的函数参数求值顺序,及任何表达式的子表达式求值顺序都是未指定的。编译器会以任意顺序对其求值,而且在同一表达式被再度求值时可选用另一种顺序。

C 中没有从左到右或从右到左求值的概念,不要将其与运算符的从左到右或从右到左结合性混淆:表达式 f1() + f2() + f3() 因为 运算符+ 的从左到右结合性而被分析成 (f1() + f2()) + f3(),但运行时对 f3 的函数调用可以最先、最后,或在 f1()f2() 之间求值。

这段话的意思就是说:

  1. int a = f1() + f2() + f3(); 这个表达式中,究竟哪个函数先调用执行是不确定的,是未定义行为,具体要看编译器平台的实现。
  2. f(a, b, c); 这个表达式中,三个实参表达式究竟哪个先计算求值是不确定的,也是未定义行为,具体要看编译器平台的实现。

我们再举两个具体的代码示例:

示例一:

cpp
i = ++i + i++;
i = i++ + 1;
f(++i, ++i);

这其实就是我们在上面文档中,已经提到过的:"自增自减运算符连接的操作数变量,不应该在同一个表达式中出现多次。"

因为这样的话,i变量计算的顺序是未定义的,最终的结果是什么也就无法确定了。

示例二:

cpp
int test(int *p) {
    *p = 100;
    return 100;
}


int main(void) {
    int num = 0;
    printf("ret = %d, num = %d\n", test(&num), num);
    return 0;
}

这段代码的理论作用是:

利用test函数调用将main函数内部的局部变量num的取值改为100

并且test函数的返回值也是100

也就是说我们期望printf函数打印的结果是:

"ret = 100, num = 100"

但实际上,这段代码输出的最终结果是无法确定的,因为:

在函数调用表达式printf("ret = %d, num = %d\n", test(&num), num);中:

  1. test(&num)num这两个实参表达式,究竟谁先计算谁后计算,是无法确定的,是未定义行为,要看编译器平台的实现。
  2. 如果num先计算,test(&num)这个函数调用表达式后计算,那么此时的输出结果是**"ret = 100, num = 0"**
  3. 如果num后计算,test(&num)这个函数调用表达式先计算,那么此时的输出结果是**"ret = 100, num = 100"**

实际上,如果在MSVC平台运行上述代码,结果大概率是:"ret = 100, num = 0",说明MSVC平台num这个实参是先计算的。

总之,我们这里给出以下总结:

  1. 在C语言中,我们通过运算符的优先级和结合性来判断某个表达式的运算顺序以及结果,但具体到运行时表达式操作数的求值顺序是未定义行为。
    • 比如f1() + f2() + f3()这个表达式
    • 基于运算符+的左结合性,我们认为的运算顺序是(f1() + f2()) + f3()
    • 但这三个函数调用,就一定是f1最先调用,f3最后调用吗?
    • 显然不是这样的,实际上编译器平台实现可以自己选择f3函数最先调用。
  2. 既然表达式操作数的具体求值顺序是未定义行为,那么为了规避这种未定义行为,那么我们在编码时,就应该遵循这样的原则:如果某个操作数在表达式中被修改了,那么这个操作数在该表达式中就只能出现一次!!!!

最后这个建议原则非常重要,比如上述的示例代码二:

由于num变量的取值在test函数调用中被修改了,如果在printf函数的实参中再次出现num变量,这就会引发未定义行为。

所以示例代码应该改成:

cpp
int test(int *p) {
    *p = 100;
    return 100;
}

int main(void) {
    int num;

    // printf函数调用产生未定义行为,不要这么写
    // printf("ret = %d, num = %d\n", test(&num), num); 

    // 调用test函数,手动接收返回值,此时函数调用的副作用就是修改了num变量的取值
    int ret = test(&num);
    // 此时printf函数调用就没有未定义行为了
    printf("ret = %d, num = %d\n", ret, num);

    return 0;
}

这样代码中就不会再出现未定义行为了,此时的代码就是完全符合我们预估的,输出结果是:

"ret = 100, num = 100"

以上。s