ChatGPT解决这个技术问题 Extra ChatGPT

为什么 f(i = -1, i = -1) 行为未定义?

我正在阅读有关 order of evaluation violations 的内容,他们举了一个让我感到困惑的例子。

1) 如果标量对象上的副作用相对于同一标量对象上的另一个副作用未排序,则行为未定义。 // 剪断 f(i = -1, i = -1); // 未定义的行为

在这种情况下,i 是一个标量对象,这显然意味着

算术类型 (3.9.1)、枚举类型、指针类型、指向成员类型的指针 (3.9.2)、std::nullptr_t 和这些类型的 cv 限定版本 (3.9.3) 统称为标量类型。

在这种情况下,我看不出该声明是如何模棱两可的。在我看来,无论是首先评估第一个参数还是第二个参数,i 都会以 -1 结束,并且两个参数也是 -1

有人可以澄清一下吗?

更新

我真的很感谢所有的讨论。到目前为止,我非常喜欢 @harmic’s answer,因为它暴露了定义此语句的陷阱和复杂性,尽管它乍一看是多么直接。 @acheong87 指出了使用引用时出现的一些问题,但我认为这与该问题的未排序副作用方面是正交的。

概括

由于这个问题引起了很多关注,我将总结要点/答案。首先,请允许我稍微题外话,指出“为什么”可以有密切相关但又存在细微差别的含义,即“出于什么原因”、“出于什么原因”和“出于什么目的”。我将按照他们所解决的“为什么”的含义对答案进行分组。

什么原因

这里的主要答案来自 Paul DraperMartin J 提供了类似但没有那么广泛的答案。保罗德雷珀的回答归结为

它是未定义的行为,因为它没有定义行为是什么。

就解释 C++ 标准所说的内容而言,答案总体上非常好。它还解决了一些与 UB 相关的案例,例如 f(++i, ++i);f(i=1, i=-1);。在第一个相关案例中,不清楚第一个参数是否应该是 i+1 和第二个 i+2 或反之亦然;其次,在函数调用之后,不清楚 i 应该是 1 还是 -1。这两种情况都是UB,因为它们属于以下规则:

如果标量对象上的副作用相对于同一标量对象上的另一个副作用未排序,则行为未定义。

因此,f(i=-1, i=-1) 也是 UB,因为它属于同一规则,尽管程序员的意图(恕我直言)是显而易见的和明确的。

Paul Draper 在他的结论中也明确表示:

是否可以定义为行为?是的。被定义了吗?不。

这给我们带来了“出于什么原因/目的将 f(i=-1, i=-1) 保留为未定义行为?”的问题。

出于什么原因/目的

尽管 C++ 标准中存在一些疏漏(可能是粗心的),但许多疏漏是有充分理由的并且服务于特定目的。虽然我知道目的通常要么是“让编译器编写者的工作更轻松”,要么是“更快的代码”,我主要想知道是否有充分的理由离开 f(i=-1, i=-1) < strong>作为 UB。

harmicsupercat 提供了为 UB 提供原因的主要答案。 Harmic 指出,一个优化编译器可能会将表面上的原子赋值操作分解为多个机器指令,并且它可能会进一步交错这些指令以获得最佳速度。这可能会导致一些非常令人惊讶的结果:i 在他的场景中最终为 -2!因此,harmic 演示了如何将 same value 多次分配给一个变量,如果操作未排序,则会产生不良影响。

supercat 提供了有关试图让 f(i=-1, i=-1) 做它看起来应该做的事情的陷阱的相关说明。他指出,在某些架构上,对同一内存地址的多个同时写入有严格的限制。如果我们正在处理比 f(i=-1, i=-1) 更简单的事情,编译器可能很难捕捉到这一点。

davidf 还提供了一个与harmic 非常相似的交错指令示例。

尽管harmic、supercat 和davidf 的每一个例子都有些做作,但将它们放在一起仍然可以为f(i=-1, i=-1) 应该是未定义的行为提供一个切实的理由。

我接受了harmic的回答,因为它在解决所有含义方面做得最好,尽管Paul Draper的回答更好地解决了“出于什么原因”部分。

其他答案

JohnB 指出,如果我们考虑重载赋值运算符(而不仅仅是简单的标量),那么我们也会遇到麻烦。

标量对象是标量类型的对象。请参阅 3.9/9:“算术类型 (3.9.1)、枚举类型、指针类型、指向成员类型的指针 (3.9.2)、std::nullptr_t 和这些类型的 cv 限定版本 (3.9.3) 统称为标量类型。”
也许页面上有错误,它们实际上是指 f(i-1, i = -1) 或类似的东西。
看看这个问题:stackoverflow.com/a/4177063/71074
@RobKennedy 谢谢。 “算术类型”是否包括布尔值?
SchighSchagh 您的更新应该在答案部分。

h
harmic

由于操作是无序的,没有什么可以说执行分配的指令不能被交错。这样做可能是最佳选择,具体取决于 CPU 架构。引用的页面说明了这一点:

如果 A 没有在 B 之前排序并且 B 没有在 A 之前排序,则存在两种可能性: A 和 B 的评估是未排序的:它们可以以任何顺序执行并且可能重叠(在单个执行线程中,编译器可能交错包含 A 和 B 的 CPU 指令)对 A 和 B 的评估是不确定顺序的:它们可以按任何顺序执行但不能重叠:A 将在 B 之前完成,或者 B 将在 A 之前完成。顺序可以下一次计算相同的表达式时相反。

这本身似乎不会导致问题 - 假设正在执行的操作是将值 -1 存储到内存位置。但是也没有什么可说的,编译器无法将其优化为具有相同效果的单独指令集,但如果该操作与同一内存位置上的另一个操作交错,则可能会失败。

例如,想象一下将内存归零然后递减它比加载值 -1 更有效。然后:

f(i=-1, i=-1)

可能变成:

clear i
clear i
decr i
decr i

现在我是-2。

这可能是一个虚假的例子,但它是可能的。


一个很好的例子,说明表达式如何在符合排序规则的同时实际做一些意想不到的事情。是的,有点做作,但我首先要询问的代码也是如此。 :)
即使分配是作为原子操作完成的,也可以设想一个超标量架构,其中两个分配同时进行,从而导致内存访问冲突,从而导致失败。该语言的设计使编译器编写者在使用目标机器的优势时拥有尽可能多的自由。
我真的很喜欢你的例子,即使在两个参数中为同一个变量分配相同的值也会导致意外的结果,因为这两个赋值是无序的
+1e+6(好的,+1)表示编译的代码并不总是您所期望的。当您不遵守规则时,优化器非常擅长向您抛出这些曲线:P
在 Arm 处理器上,一个 32 位的负载最多可能需要 4 条指令:它执行 load 8bit immediate and shift 最多 4 次。通常编译器会进行间接寻址以从表中获取数字以避免这种情况。 (-1 可以在 1 条指令中完成,但可以选择另一个示例)。
C
Community

首先,“标量对象”是指像 intfloat 或指针这样的类型(参见 What is a scalar Object in C++?)。

其次,看起来更明显的是

f(++i, ++i);

会有未定义的行为。但

f(i = -1, i = -1);

不太明显。

一个稍微不同的例子:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

“最后”、i = 1i = -1 发生了什么分配?它没有在标准中定义。确实,这意味着 i 可能是 5(请参阅harmic 的答案以获得关于这种情况的完全合理的解释)。或者你的程序可能会出现段错误。或者重新格式化你的硬盘。

但是现在您问:“我的示例呢?我对两个分配都使用了相同的值 (-1)。这有什么可能不清楚的地方?”

你是对的……除了 C++ 标准委员会描述的方式。

如果标量对象上的副作用相对于同一标量对象上的另一个副作用未排序,则行为未定义。

他们可以为您的特殊情况设置一个特殊例外,但他们没有。 (他们为什么要这样做?那可能有什么用处?)所以,i 仍然可以是 5。或者您的硬盘驱动器可能是空的。因此,您的问题的答案是:

它是未定义的行为,因为它没有定义行为是什么。

(这值得强调,因为许多程序员认为“未定义”意味着“随机”或“不可预测”。它不是;它意味着没有被标准定义。行为可能是 100% 一致的,但仍然是未定义的。)

是否可以定义为行为?是的。被定义了吗?不,因此,它是“未定义的”。

也就是说,“未定义”并不意味着编译器会格式化您的硬盘驱动器......这意味着它可以并且仍然是符合标准的编译器。实际上,我确信 g++、Clang 和 MSVC 都会按照您的预期进行。他们只是不会“必须”。

一个不同的问题可能是为什么 C++ 标准委员会选择不排序这种副作用?该答案将涉及委员会的历史和意见。或者,在 C++ 中不排序这种副作用有什么好处?这允许任何理由,无论它是否是标准委员会的实际推理。您可以在此处或在programmers.stackexchange.com 上提出这些问题。


@hvd,是的,事实上我知道如果您为 g++ 启用 -Wsequence-point,它会警告您。
“我确信 g++、Clang 和 MSVC 都会做你所期望的”我不相信现代编译器。他们是邪恶的。例如,他们可能会认识到这是未定义的行为,并假设此代码无法访问。如果他们今天不这样做,他们明天可能会这样做。任何 UB 都是定时炸弹。
@BlacklightShining“你的回答不好,因为它不好”不是很有用的反馈,是吗?
@BobJarvis 面对未定义的行为,编译器绝对没有义务生成甚至远程正确的代码。它甚至可以假设这段代码甚至从未被调用,因此用 nop 替换整个代码(注意编译器实际上是在面对 UB 时做出这样的假设)。因此,我想说对此类错误报告的正确反应只能是“关闭,按预期工作”
@SchighSchagh 有时人们需要对术语进行改写(仅在表面上似乎是同义反复的答案)。大多数刚接触技术规范的人认为 undefined behavior 表示 something random will happen,但大多数情况下并非如此。
C
Community

不因为两个值相同而从规则中例外的一个实际原因:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

考虑这种情况是允许的。

现在,几个月后,需要改变

 #define VALUEB 2

看起来无害,不是吗?然而突然 prog.cpp 不再编译了。然而,我们认为编译不应该依赖于文字的值。

底线:该规则没有例外,因为它会使成功编译取决于常量的值(而不是类型)。

编辑

@HeartWare pointed outB 为 0 时,某些语言中不允许使用 A DIV B 形式的常量表达式,这会导致编译失败。因此,更改常量可能会在其他地方导致编译错误。恕我直言,不幸的是。但是将这些事情限制在不可避免的情况下当然是好的。


当然,但是示例 确实 使用整数文字。您的 f(i = VALUEA, i = VALUEB); 肯定有可能出现未定义的行为。我希望您并没有真正针对标识符背后的值进行编码。
@Wold但是编译器看不到预处理器宏。即使不是这样,也很难在任何编程语言中找到一个示例,在该示例中,源代码会一直编译,直到将某个 int 常量从 1 更改为 2。这简直是不可接受和无法解释的,尽管您在这里看到了很好的解释为什么即使使用相同的值,该代码也会被破坏。
是的,编译没有看到宏。但是,这是问题吗?
您的答案没有抓住重点,请阅读 harmic's answer 和 OP 对此的评论。
它可以做SomeProcedure(A, B, B DIV (2-A))。无论如何,如果语言声明必须在编译时对 CONST 进行全面评估,那么当然,我的主张对于这种情况无效。因为它以某种方式模糊了编译时和运行时的区别。如果我们写 CONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y; ,它也会注意到吗?还是不允许使用功能?
d
davidf

令人困惑的是,将常量值存储到局部变量中并不是每个 C 设计运行的架构上的原子指令。在这种情况下,代码运行的处理器比编译器更重要。例如,在每条指令不能携带完整的 32 位常量的 ARM 上,将 int 存储在变量中需要多条指令。使用此伪代码的示例,您一次只能存储 8 位,并且必须在 32 位寄存器中工作,i 是 int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

你可以想象如果编译器想要优化它可能会交叉两次相同的序列,你不知道什么值会被写入 i;假设他不是很聪明:

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

然而,在我的测试中,gcc 能够很好地识别出相同的值被使用了两次并生成了一次并且没有做任何奇怪的事情。我得到 -1, -1 但我的例子仍然有效,因为重要的是要考虑到即使是常数也可能不像看起来那么明显。


我想在 ARM 上,编译器只会从表中加载常量。你描述的似乎更像MIPS。
@AndreyChernyakhovskiy 是的,但如果它不仅仅是-1(编译器存储在某处),而是3^81 mod 2^32,而是恒定的,那么编译器可能会完全按照这里所做的,并且在某种程度的 omtimization ,我交错呼叫序列以避免等待。
@tohecz,是的,我已经检查过了。事实上,编译器太聪明了,无法从表中加载每个常量。无论如何,它永远不会使用同一个寄存器来计算这两个常数。这肯定会“取消定义”已定义的行为。
@AndreyChernyakhovskiy 但是您可能不是“世界上每个 C++ 编译器程序员”。请记住,有些机器只有 3 个短寄存器可用于计算。
@tohecz,考虑示例 f(i = A, j = B),其中 ij 是两个独立的对象。这个例子没有UB。具有 3 个短寄存器的机器不是编译器将 AB 的两个值混合在同一个寄存器中的借口(如@davidf 的答案所示),因为它会破坏程序语义。
s
supercat

如果出于某种可以想象的原因,试图“帮助”的编译器可能会做一些会导致完全意想不到的行为的事情,那么行为通常被指定为未定义。

在一个变量被多次写入而没有确保写入发生在不同时间的情况下,某些种类的硬件可能允许使用双端口存储器同时对不同地址执行多个“存储”操作。但是,一些双端口存储器明确禁止两个存储同时访问相同地址的情况,无论写入的值是否匹配。如果此类机器的编译器注意到两次未排序的写入同一变量的尝试,它可能会拒绝编译或确保无法同时安排两次写入。但是,如果其中一个或两个访问是通过指针或引用进行的,编译器可能并不总是能够判断两个写入是否会命中相同的存储位置。在这种情况下,它可能会同时安排写入,从而导致访问尝试出现硬件陷阱。

当然,有人可能在这样的平台上实现 C 编译器这一事实并不意味着当使用足够小的类型存储以进行原子处理时,不应在硬件平台上定义这种行为。如果编译器没有意识到,试图以无序的方式存储两个不同的值可能会导致奇怪;例如,给定:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}

如果编译器内联对“moo”的调用并且可以告诉它没有修改“v”,它可能会将 5 存储到 v,然后将 6 存储到 *p,然后将 5 传递给“zoo”,然后将 v 的内容传递给“动物园”。如果“zoo”没有修改“v”,那么这两个调用就不应该被传递不同的值,但这很容易发生。另一方面,如果两个存储都写入相同的值,则不会发生这种奇怪的情况,并且在大多数平台上,实现做任何奇怪的事情都没有合理的理由。不幸的是,除了“因为标准允许”之外,一些编译器编写者不需要任何愚蠢行为的借口,因此即使这些情况也不安全。


K
Kevin

this 的情况下,大多数实现中的结果是相同的这一事实是偶然的;评估顺序仍未确定。考虑 f(i = -1, i = -2):在这里,顺序很重要。在您的示例中无关紧要的唯一原因是两个值都是 -1

鉴于表达式被指定为具有未定义行为的表达式,当您评估 f(i = -1, i = -1) 并中止执行时,恶意兼容的编译器可能会显示不适当的图像 - 并且仍然被认为是完全正确的。幸运的是,我所知道的编译器都没有这样做。


M
Martin J.

在我看来,与函数参数表达式排序有关的唯一规则在这里:

3) 在调用函数时(无论函数是否内联,以及是否使用显式函数调用语法),与任何参数表达式或与指定被调用函数的后缀表达式相关的每个值计算和副作用是在被调用函数主体中的每个表达式或语句执行之前排序。

这并没有定义参数表达式之间的顺序,所以我们最终在这种情况下:

1) 如果标量对象上的副作用相对于同一标量对象上的另一个副作用是未排序的,则行为未定义。

实际上,在大多数编译器上,您引用的示例将运行良好(与“擦除硬盘”和其他理论上的未定义行为后果相反)。然而,这是一种责任,因为它取决于特定的编译器行为,即使两个分配的值相同。此外,显然,如果您尝试分配不同的值,结果将是“真正”未定义的:

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}

A
AlexD

C++17 定义了更严格的评估规则。特别是,它对函数参数进行排序(尽管未指定顺序)。

N5659 §4.6:15 当 A 在 B 之前排序或 B 在 A 之前排序时,评估 A 和 B 的排序不确定,但未指定哪个。 [注意:不确定顺序的评估不能重叠,但可以先执行。 —尾注] N5659 § 8.2.2:5 参数的初始化,包括每个相关的值计算和副作用,相对于任何其他参数的初始化是不确定的。

它允许一些以前是 UB 的情况:

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one

感谢您为 c++17 添加此更新,因此我不必这样做。 ;)
太棒了,非常感谢这个答案。轻微跟进:如果 f 的签名是 f(int a, int b),C++17 是否保证 a == -1b == -2 如果在第二种情况下调用?
是的。如果我们有参数 ab,则 i-then-a 被初始化为 -1,然后 i-then-b 被初始化为 -2,或者反过来。在这两种情况下,我们都会得到 a == -1b == -2。至少我是这样读到“参数的初始化,包括每个相关的值计算和副作用,相对于任何其他参数的初始化顺序是不确定的”。
我认为它在 C 中一直是一样的。
@fuz 可观察行为与定义行为不同
J
JohnB

赋值运算符可能会被重载,在这种情况下,顺序可能很重要:

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true

确实如此,但问题是关于标量类型,其他人指出这基本上意味着 int 系列、float 系列和指针。
在这种情况下,真正的问题是赋值运算符是有状态的,所以即使是对变量的常规操作也容易出现这样的问题。
P
Peng Zhang

这只是在回答“我不确定“标量对象”除了诸如 int 或 float 之类的东西之外还意味着什么”。

我会将“标量对象”解释为“标量类型对象”的缩写,或者只是“标量类型变量”。那么,pointerenum(常数)是标量类型。

这是 Scalar Types 的 MSDN 文章。


这读起来有点像“仅链接答案”。您可以将该链接中的相关位复制到此答案(在块引用中)吗?
@ColeJohnson 这不是仅链接的答案。该链接仅用于进一步说明。我的答案是“指针”、“枚举”。
我没有说您的答案是仅链接的答案。我说它“读起来像 [one]”。我建议您在帮助部分阅读为什么我们不希望仅链接答案。原因是,如果 Microsoft 更新其站点中的 URL,则该链接会中断。
p
polkovnikov.ph

实际上,有理由不依赖于编译器会检查 i 是否被分配了两次相同的值,以便可以用单个分配替换它。如果我们有一些表达怎么办?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}

无需证明费马定理:只需将 1 分配给 i。要么两个参数都分配 1 并且这会做“正确”的事情,要么参数分配不同的值并且它是未定义的行为,所以我们的选择仍然是允许的。