在逗号运算符中,如果没有副作用,是否保证不会实际执行左操作数?

In the comma operator, is the left operand guaranteed not to be actually executed if it hasn't side effects?

为了展示主题,我将使用 C,但同样的宏也可以在 C++ 中使用(有或没有 struct),提出同样的问题。

我想出了这个宏

#define STR_MEMBER(S,X) (((struct S*)NULL)->X, #X)

它的目的是拥有struct的现有成员的字符串(const char*),这样如果该成员不存在,编译就会失败。一个最小的用法示例:

#include <stdio.h>

struct a
{
    int value;
};

int main(void)
{
    printf("a.%s member really exists\n", STR_MEMBER(a, value));
    return 0;
}

如果 value 不是 struct a 的成员,代码将无法编译,这就是我想要的。

逗号运算符应该计算左操作数然后丢弃表达式的结果(如果有的话),所以我的理解是通常在左操作数的计算有副作用时使用这个运算符。

然而,在这种情况下,没有(预期的)副作用,但它当然有效 iff​​ 编译器实际上并不生成计算表达式的代码,否则它将访问位于 NULLstruct 并且会发生 分段错误

Gcc/g++ 6.3 和 4.9.2 从未产生过危险代码,即使使用 -O0,就好像它们总是能够“看到”求值没有副作用并且所以可以跳过。

在宏中添加 volatile(例如,因为访问该内存地址 所需的副作用)是迄今为止触发分段错误的唯一方法。

所以问题是:C 和 C++ 语言标准中是否有任何内容可以保证编译器始终避免对逗号运算符的左操作数进行实际求值,而编译器可以确定求值没有副作用吗?

注释和修正

我不是要对宏 原样 以及使用它或改进它的机会进行判断。出于这个问题的目的,宏是错误的当且仅当它会引发未定义的行为——即当且仅当它是有风险的,因为即使没有副作用,编译器也可以生成“评估代码”。

我已经想到了两个明显的修复方法:“具体化”struct 和使用 offsetof。前者需要一个与我们用作 STR_MEMBER 的第一个参数的最大 struct 一样大的可访问内存区域(例如,静态联合可以做到……)。后者应该完美地工作:它提供了一个我们不感兴趣的偏移量并避免了访问问题——实际上我假设 gcc,因为它是我使用的编译器(因此标记) ,并且它的 offsetof 内置行为。

通过 offsetof 修复,宏变为

#define STR_MEMBER(S,X) (offsetof(struct S,X), #X)

volatile struct S 而不是 struct S 不会导致段错误。

也欢迎就其他可能的“修复”提出建议。

已添加注释

实际上,真正的用例是在静态存储中的 C++ 中 struct。这在 C++ 中似乎没问题,但是当我尝试使用更接近原始代码而不是为这个问题煮沸的代码的 C 时,我意识到 C 对此一点都不满意:

error: initializer element is not constant

C 希望结构在编译时可初始化,而不是 C++ 就可以了。

comma operator (C documentation 说的非常相似)没有这样的保证。

In a comma expression E1, E2, the expression E1 is evaluated, its result is discarded ..., and its side effects are completed before evaluation of the expression E2 begins

省略无关信息

简而言之,E1 将被评估,尽管如果编译器能够确定没有副作用,它可能会通过 as-if 规则将其优化掉。

Is there anything in the C and C++ languages standard which guarantees that compilers will always avoid actual evaluation of the left operand of the comma operator ?

恰恰相反。该标准保证对左操作数进行求值(确实如此,没有任何例外)。结果被丢弃。


注意:对于左值表达式,"evaluate"不代表"access the stored value"。相反,它意味着找出指定的内存位置。包含左值表达式的其他代码可能会或可能不会继续访问内存位置。从内存位置读取的过程在 C 中称为 "lvalue conversion",在 C++ 中称为 "lvalue to rvalue conversion"。

在 C++ 中,弃值表达式(例如逗号运算符的左操作数)只有在 volatile 并且满足其他一些条件(请参阅 C+ +14 [expr]/11 详情)。在 C 中,左值转换 do 发生在其结果未被使用的表达式中 (C11 6.3.2.1/2).

在您的示例中,是否发生左值转换没有实际意义。在两种语言中X->Y,其中X是一个指针,定义为(*X).Y;在 C 中,将 * 应用于空指针的行为已经导致未定义的行为(C11 6.5.3/3),而在 C++ 中, . 运算符仅针对左操作数实际指定的情况定义对象 (C++14 [expr.ref]/4.2)。

Gcc/g++ 6.3 and 4.9.2 never produced that dangerous code, even with -O0, as if they were always able to “see” that the evaluation hasn't side effects and so it can be skipped.

clang 将生成代码,如果您向它传递 -fsanitize=undefined 选项,则会引发错误。哪个应该回答您的问题:至少一个主要实现的开发人员清楚地认为代码具有未定义的行为。他们是正确的。

Suggestions about other possible “fixes” are welcome, too.

我会寻找保证不会计算表达式的东西。您对 offsetof 的建议可以完成这项工作,但有时可能会导致代码被拒绝,否则会被接受,例如当 Xa.b 时。如果您希望它被接受,我的想法是使用 sizeof 强制表达式保持未计算状态。

逗号运算符的左操作数是丢弃值表达式

5 Expressions
11 In some contexts, an expression only appears for its side effects. Such an expression is called a discarded-value expression. The expression is evaluated and its value is discarded. [...]

还有未求值的操作数,顾名思义,不求值。

8 In some contexts, unevaluated operands appear (5.2.8, 5.3.3, 5.3.7, 7.1.6.2). An unevaluated operand is not evaluated. An unevaluated operand is considered a full-expression. [...]

在您的用例中使用丢弃值表达式是未定义的行为,但使用未计算的操作数不是。

例如使用 sizeof 不会导致 UB,因为它采用未计算的操作数。

#define STR_MEMBER(S,X) (sizeof(S::X), #X)

sizeof 优于 offsetof,因为 offsetof 不能用于静态成员和非标准布局的 类:

18 Language support library
4 The macro offsetof(type, member-designator) accepts a restricted set of type arguments in this International Standard. If type is not a standard-layout class (Clause 9), the results are undefined. [...] The result of applying the offsetof macro to a field that is a static data member or a function member is undefined. [...]

你问,

is there anything in the C and C++ languages standard which guarantees that compilers will always avoid actual evaluation of the left operand of the comma operator when the compiler can be sure that the evaluation hasn't side effects?

正如其他人所说,答案是 "no"。相反,标准都无条件地声明逗号运算符 的左侧操作数被计算 ,并且结果被丢弃。

这当然是对抽象机执行模型的描述;允许实现以不同方式工作,只要可观察到的行为与抽象机器行为产生的行为相同即可。如果左侧表达式的计算确实没有产生副作用,那么 允许 完全跳过它,但是在任何一个标准中都没有提供 requiring 跳过它。

至于修复它,您有多种选择,其中一些仅适用于您指定的两种语言中的一种或另一种。我倾向于喜欢您的 offsetof() 替代方案,但其他人注意到在 C++ 中,有些类型无法应用 offsetof。另一方面,在 C 中,该标准专门描述了它在 structure 类型中的应用,但对联合类型只字未提。它在联合类型上的行为,虽然很可能是一致和自然的,但在技术上是未定义的。

仅在 C 中,您可以使用复合文字来避免方法中的未定义行为:

#define HAS_MEMBER(T,X) (((T){0}).X, #X)

这同样适用于结构和联合类型(尽管您需要为此版本提供完整的类型名称,而不仅仅是标签)。当给定类型确实有这样一个成员时,它的行为是明确定义的。扩展违反了语言约束——因此需要发出诊断——当类型没有这样的成员时,包括当它既不是结构类型也不是联合类型时。

您也可以使用 sizeof,正如@alain 所建议的,因为虽然 sizeof 表达式将被计算,但 它的 操作数将不会被计算(除了在 C 中,当它的操作数具有可变修改类型时,这将不适用于您的使用)。我认为这种变体在 C 和 C++ 中都可以工作,而不会引入任何未定义的行为:

#define HAS_MEMBER(T,X) (sizeof(((T *)NULL)->X), #X)

我重新编写了它,以便它适用于结构和联合。

由于as-if rule,语言不需要说任何关于"actual execution"的事情。毕竟,在没有副作用的情况下,您如何判断表达式是否已求值? (查看程序集或设置断点不算数;这不是程序执行的一部分,这是语言描述的全部内容。)

另一方面,取消对空指针的引用是未定义的行为,因此该语言对发生的事情完全没有说明。你不能指望 as-if 来拯救你:as-if 是对实现的其他看似合理的限制的 放宽 ,而未定义的行为是 all 的放宽限制执行。因此 "this doesn't have side effects, so we can ignore it" 和 "this is undefined behavior, so nasal demons" 之间没有 "conflict";他们在同一边!