在C++编程社区中,一个看似简单的问题近日引发了激烈讨论:后缀表达式中的子表达式f是否一定在函数调用表达式f()之前完成求值?这个问题的核心在于C++标准中对求值顺序(sequencing)的界定,而答案可能颠覆许多程序员的直觉。

问题起源:一个看似微不足道的细节

一位开发者在一个技术论坛上提出了这样一个场景:

int f = 0;
// 更多代码...

当存在一个名为f的变量,同时又有一个同名的函数f()时,表达式f(作为后缀表达式的一部分)与f()的求值顺序是否存在明确的先后关系?问题迅速发酵,因为表面上看,这是一个经典的“名称查找”与“求值顺序”交织的边界案例。

标准怎么说?C++17后的微妙变化

根据C++17及以后的标准,函数调用表达式的求值顺序有明确规定:后缀表达式(即函数名或函数指针)的求值序列化在(sequenced before)参数表达式之前,而参数表达式又序列化在所有参数默认值之前,最后才是函数体执行。但问题在于,当f本身既是一个变量名又是一个函数名时,情况变得复杂。

C++标准规定,后缀表达式f在被求值时,应该完成名称解析,确定其代表的是函数、变量还是其他实体。如果f是函数指针变量,那么f的求值就是读取该变量值,而f()是调用该值指向的函数。标准并未明确说明“读取变量f”的动作是否一定发生在“调用f()”这个整体表达式的求值开始之前。

核心争议:f是“求值”还是“名称解析”?

多数编译器行为显示,当写f()时,编译器首先查找名称f,找到后将其作为函数调用处理。但如果f是一个变量(如函数指针),那么f的求值会先于函数调用发生,这是直观的。然而,如果f既是函数名又是变量名,则名称解析会优先处理函数名(根据作用域规则)。但这里讨论的是同一个作用域内,f的声明形态决定了一切。

真正引发争议的是另一种情况:f是一个后缀表达式,而f()是另一个函数调用。例如:

int (*fp)() = someFunction;
fp(); // 这里fp是后缀表达式,fp()是函数调用

标准明确:fp的求值(读取指针值)序列化在fp()调用之前。但问题标题中表述的“the postfix expression f”与“the function call expression f()”实际上是同一个表达式整体?还是两个不同的表达式?标题似乎暗示f作为独立子表达式与f()作为整体表达式的关系。

专家解读:求值顺序与副作用

C++标准委员会成员、著名开发者Herb Sutter曾指出,C++17引入了更严格的求值顺序规则,主要针对之前未定义的行为。但本问题触及的是:一个后缀表达式中的子表达式(如f本身)是否一定在它作为函数调用的一部分之前求值?答案并非绝对。

如果f是一个纯左值(比如一个函数指针变量),那么对f的求值(即确定它代表的函数地址)确实发生在函数调用之前。但如果我们考虑f本身是一个函数调用表达式的结果,比如g()(),那么情况更复杂:g()的求值(后缀表达式)序列化在g()()之前,但g()内部的求值顺序又取决于g的定义。

实际影响:代码可读性与编译器优化

这场讨论的现实意义在于,编写可移植、符合标准的C++代码时,开发者需要明确表达式的求值顺序依赖。特别是当存在复杂重载、Lambda表达式或模板元编程时,隐含的顺序假设可能导致难以调试的bug。

大多数主流编译器(GCC、Clang、MSVC)在处理f()时,都会将f的名称解析与求值视为函数调用的原子步骤。但严格来说,标准并未要求“名称解析”必须发生在所有其他求值之前——只是要求“后缀表达式”的求值结果必须是一个函数或函数指针。如果f是一个变量,读取它属于求值;如果f是一个函数名,则不存在“读取”动作,只有名称解析。

结论:规范与现实的差距

截至C++20,标准文本并未直接回答标题中的问题,因为它混淆了“表达式”与“名称”两个层次。实际的程序员共识是:在单一表达式中,子表达式f的求值必然先于其作为函数调用整体完成;但若f本身就是一个函数名(而非变量),则不存在“求值”动作,只有编译期的名称解析。

这一争议再次提醒我们,C++作为一门复杂语言,其形式化规范与直觉之间永远存在张力。对于日常开发,遵循“避免在同一个表达式中对同一对象进行多重重写或读取”的原则,依然是避免未定义行为的最佳实践。