43

来自 C++ (C++11) 标准的第 1.9.15 节讨论了评估的顺序,是以下代码示例:

void g(int i, int* v) {
    i = v[i++]; // the behavior is undefined
}

如代码示例中所述,行为未定义。

(注意:另一个问题的答案与构造略有不同i + i++Why is a = i + i++ undefined and not unspecified behavior可能适用于此处:答案本质上是由于历史原因,行为未定义,而不是出于必要。但是,该标准似乎暗示了一些未定义的理由-请参见下面的引用。此外,该链接的问题表明同意该行为应未指定,而在此问题中,我要问为什么该行为未明确指定。)

标准给出的未定义行为的推理如下:

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

在这个例子中,我认为子表达式将在子表达式被评估之前i++被完全评估,并且子表达式的评估结果是(在增量之前),但是 的是该子表达式之后的增量值已经完全评估。我认为在这一点上(在子表达式被完全评估之后),评估发生,然后是赋值。v[...]iii++v[...]i = ...

因此,尽管增加i是没有意义的,但我仍然认为应该定义

为什么这是未定义的行为?

4

8 回答 8

42

我认为在评估子表达式 v[...] 之前,将完全评估子表达式 i++

但你为什么会这样想?

这段代码是 UB 的一个历史原因是允许编译器优化在序列点之间的任何地方移动副作用。序列点越少,优化的潜在机会越多,但程序员越困惑。如果代码说:

a = v[i++];

该标准的目的是发出的代码可以是:

a = v[i];
++i;

这可能是两条指令,其中:

tmp = i;
++i;
a = v[tmp];

会超过两个。

a“优化代码”在is时中断i但标准允许优化,因为原始代码的行为在 is 时a未定义i

该标准可以很容易地说,i++必须按照您的建议在分配之前进行评估。然后行为将被完全定义,优化将被禁止。但这不是 C 和 C++ 做生意的方式。

还要注意,在这些讨论中提出的许多示例比一般情况下更容易判断周围存在 UB。这导致人们说应该定义行为并且禁止优化是“显而易见的”。但请考虑:

void g(int *i, int* v, int *dst) {
    *dst = v[(*i)++];
}

此函数的行为是在 时定义的i != dst,在这种情况下,您希望获得所有优化(这就是 C99 引入 的原因restrict,以允许比 C89 或 C++ 进行更多的优化)。为了给您优化,行为未定义 when i == dst。C 和 C++ 标准在别名方面走得很好,介于程序员不期望的未定义行为和禁止在某些情况下失败的理想优化之间。SO 上有关它的问题数量表明,提问者更喜欢少一点优化和多一点定义的行为,但划清界限仍然并不简单。

除了行为是否完全定义之外,还有一个问题是它应该是 UB,还是仅仅是与子表达式相对应的某些明确定义的操作的未指定执行顺序。C 选择 UB 的原因完全与序列点的概念有关,而且编译器实际上不需要知道修改对象值,直到下一个序列点。因此,与其通过说“该”值在某个未指定的点发生变化来限制优化器,标准只是说(解释一下):(1)任何依赖于在下一个序列点之前修改对象的值的代码,都有UB; (2) 任何修改被修改对象的代码都有 UB。“修改过的对象”是指任何可以修改的对象自子表达式求值的一个或多个合法顺序中的最后一个序列点以来已被修改。

其他语言(例如Java)一路走来,完全定义了表达式副作用的顺序,所以肯定有反对C方法的理由。C++ 只是不接受这种情况。

于 2012-12-06T13:33:43.600 回答
30

我要设计一台病态计算机1。它是一个多核、高延迟、单线程系统,具有线程内连接,使用字节级指令进行操作。因此,您请求某事发生,然后计算机运行(在其自己的“线程”或“任务”中)一组字节级指令,并在一定数量的周期后完成操作。

同时,执行的主线程继续:

void foo(int v[], int i){
  i = v[i++];
}

变成伪代码:

input variable i // = 0x00000000
input variable v // = &[0xBAADF00D, 0xABABABABAB, 0x10101010]
task get_i_value: GET_VAR_VALUE<int>(i)
reg indx = WAIT(get_i_value)
task write_i++_back: WRITE(i, INC(indx))
task get_v_value: GET_VAR_VALUE<int*>(v)
reg arr = WAIT(get_v_value)
task get_v[i]_value = CALC(arr + sizeof(int)*indx)
reg pval = WAIT(get_v[i]_value)
task read_v[i]_value = LOAD_VALUE<int>(pval)
reg got_value = WAIT(read_v[i]_value)
task write_i_value_again = WRITE(i, got_value)
(discard, discard) = WAIT(write_i++_back, write_i_value_again)

所以你会注意到我没有等到write_i++_back最后,与我等待的时间相同write_i_value_again(我从哪个值加载v[])。而且,事实上,这些写入是唯一回写到内存的。

想象一下,如果写入内存是这个计算机设计中真正缓慢的部分,它们会被分批成一个队列,然后由并行内存修改单元处理,该单元按字节进行处理。

所以write(i, 0x00000001)andwrite(i, 0xBAADF00D)无序并行执行。每个都变成字节级写入,并且它们是随机排序的。

我们最终写入高字节,然后0x00写入下一个字节,然后写入下一个字节,最后写入低字节。i 中的结果值是,很少有人会想到,但对于您的未定义操作来说,这将是一个有效的结果。0xBA0xAD0x000xF0 0x000x0D 0x010xBA000001

现在,我所做的只是导致一个未指定的值。我们没有使系统崩溃。但是编译器可以自由地使其完全未定义——也许在同一批指令中向内存控制器发送两个这样的请求以获得相同的地址实际上会使系统崩溃。这仍然是编译 C++ 的“有效”方式和“有效”执行环境。

请记住,在这种语言中,将指针大小限制为 8 位仍然是有效的执行环境。C++ 允许编译成相当古怪的目标。

1:正如@SteveJessop 在下面的评论中所指出的,开玩笑的是,这台病态计算机的行为很像现代台式计算机,直到您进行字节级操作。CPU 的非原子int写入在某些硬件上并不罕见(例如,当intCPU 未按照 CPU 希望对齐的方式对齐时)。

于 2012-12-06T13:50:27.547 回答
24

原因不仅仅是历史原因。例子:

int f(int& i0, int& i1) {
    return i0 + i1++;
}

现在,这个调用会发生什么:

int i = 3;
int j = f(i, i);

当然可以对代码提出要求,f以便明确定义此调用的结果(Java 会这样做),但 C 和 C++ 不会施加约束;这给优化器更多的自由。

于 2012-12-06T13:37:25.090 回答
9

您专门参考了 C++11 标准,所以我将用 C++11 的答案来回答。然而,它与 C++03 的答案非常相似,但排序的定义不同。

C++11 定义了单个线程上的求值之间的顺序关系。它是不对称的、传递的和成对的。如果某个评估 A 没有在某个评估 B 之前排序,并且 B 也没有在 A 之前排序,那么这两个评估是unsequenced

评估表达式包括值计算(计算某个表达式的值)和副作用。副作用的一个实例是对象的修改,这是回答问题最重要的一个。其他事情也算作副作用。如果一个副作用相对于同一对象上的另一个副作用或值计算是未排序的,那么您的程序具有未定义的行为。

所以就是这样设置的。第一个重要的规则是:

与完整表达式关联的每个值计算和副作用在与要评估的下一个完整表达式关联的每个值计算和副作用之前排序。

因此,任何完整表达式都会在下一个完整表达式之前进行完整评估。在你的问题中,我们只处理一个完整的表达式,即i = v[i++],所以我们不需要担心这个。下一个重要规则是:

除非另有说明,否则单个运算符的操作数和单个表达式的子表达式的求值是无序的。

这意味着在 中a + b,例如,对a和的评估b是无序的(它们可以按任何顺序进行评估)。现在是我们最后的重要规则:

运算符的操作数的值计算在运算符结果的值计算之前排序。

所以对于a + b,sequenced before 关系可以用一棵树来表示,其中有向箭头表示sequenced before 关系:

a + b (value computation)
^   ^
|   |
a   b (value computation)

如果两个评估发生在树的不同分支中,则它们是无序的,因此这棵树显示 和 的评估ab对于彼此是无序的。

i = v[i++]现在,让我们对您的示例做同样的事情。我们利用v[i++]定义为等价的事实*(v + (i++))。我们还使用了一些关于后缀增量排序的额外知识:

表达式的值计算++在操作对象的修改之前排序。

所以我们开始了(树的一个节点是一个值计算,除非指定为副作用):

i = v[i++]
^     ^
|     |
i★  v[i++] = *(v + (i++))
                  ^
                  |
               v + (i++)
               ^     ^
               |     |
               v     ++ (side effect on i)★
                     ^
                     |
                     i

i在这里,您可以看到, , 的副作用是在赋值运算符前面i++的用法的单独分支中(我用 ★ 标记了这些评估中的每一个)。i所以我们肯定有未定义的行为!如果您想知道您的评估顺序是否会给您带来麻烦,我强烈建议您绘制这些图表。

所以现在我们得到了一个问题,即i赋值运算符之前的值无关紧要,因为无论如何我们都会重写它。但实际上,在一般情况下,这是不正确的。我们可以覆盖赋值运算符并在赋值之前使用对象的值。标准并不关心我们不使用该值 - 规则被定义为使任何具有副作用的未排序的值计算都将是未定义的行为。没有但是。这种未定义的行为允许编译器发出更优化的代码。如果我们为赋值运算符添加排序,则无法使用此优化。

于 2012-12-06T13:59:35.777 回答
4

在此示例中,我认为子表达式 i++ 将在评估子表达式 v[...] 之前完全评估,并且子表达式的评估结果是 i (在增量之前),但 i 的值是该子表达式被完全计算后的增量值。

i++必须在索引之前评估in 的增量,v因此在分配给 之前i但是之前不需要将该增量的值存储回内存。在语句i = v[i++]中有两个修改的子操作i(即最终会导致从寄存器存储到变量中i)。该表达式i++等价于x=i+1, i=x,并且不要求两个操作都需要按顺序进行:

x = i+1;
y = v[i];
i = y;
i = x;

通过这种扩展, 的结果i与 中的值无关v[i]。在不同的扩展中,i = x赋值可能发生赋值之前,i = y结果将是i = v[i]

于 2012-12-06T13:33:35.633 回答
4

有两条规则。

第一条规则是关于导致“写-写危险”的多次写入:在两个序列点之间不能多次修改同一个对象。

第二条规则是关于“读写危险”的。就是这样:如果一个对象在表达式中被修改,并且也被访问过,那么对其值的所有访问都必须是为了计算新值。

i++ + i++和你的表达这样的表达i = v[i++]违反了第一条规则。他们修改一个对象两次。

like 表达式i + i++违反了第二条规则。i左边的子表达式观察被修改对象的值,而不参与其新值的计算。

因此,违反了与(bad read-write)i = v[i++]不同的规则 (bad write- write)。i + i++


规则过于简单,产生了令人费解的表达方式。考虑一下:

p = p->next = q

这似乎有一个健全的数据流依赖性,没有危险:在p =知道新值之前不能进行分配。新值是 的结果p->next = q。该值q不应“领先”并进入内部p,这样p->next会受到影响。

然而,这个表达式打破了第二条规则:p被修改,并且还用于与计算其新值无关的目的,即确定q放置值的存储位置!

因此,反常地,编译器被允许部分评估p->next = q以确定结果是q,并将其存储到p中,然后返回并完成p->next =分配。或者看起来是这样。

这里的一个关键问题是,赋值表达式的值是什么?C 标准说赋值表达式的值是左值的值,在 assignment 之后。但这是模棱两可的:它可以解释为“一旦发生赋值,左值具有的值”或“在赋值发生后可以在左值中观察到的值”。在 C++ 中,“[i] 在所有情况下,赋值都在左右操作数的值计算之后,赋值表达式的值计算之前进行排序。”,因此p = p->next = q看起来是有效的 C++ , 但可疑 C.

于 2012-12-06T22:41:21.843 回答
2

如果示例是 ,我会分享您的论点v[++i],但是由于i++修改i是副作用,因此未定义何时修改值。该标准可能会以一种或另一种方式强制要求结果,但没有真正的方法知道i应该是什么值:(i + 1)(v[i + 1]).

于 2012-12-06T13:25:53.347 回答
1

假设给定的声明有效,请考虑以下每个赋值语句所需的机器操作序列:

extern int *foo(void);
extern int *p;

*p = *foo();
*foo() = *p;

如果左侧下标和右侧值的求值是无序的,那么处理这两个函数调用的最有效方法可能是:

[For *p = *foo()]
call foo (which yields result in r0 and trashes r1)
load r0 from address held in r0
load r1 from address held in p
store r0 to address held in r1

[For *foo() = *p]
call foo (which yields result in r0 and trashes r1)
load r1 from address held in p
load r1 from address held in r1
store r1 to address held in r0

在任何一种情况下,如果在调用 foo 之前将 p 或 *p 读入寄存器,那么除非“foo”承诺不会干扰该寄存器,否则编译器将需要在调用“foo”之前添加一个额外的步骤来保存其值,以及之后恢复该值的另一​​个额外步骤。可以通过使用“foo”不会干扰的寄存器来避免这个额外的步骤,但这只有在有一个这样的寄存器不包含周围代码所需的值时才会有所帮助。

让编译器在函数调用之前或之后读取“p”的值,在空闲时,将允许有效地处理上述两种模式。要求“=”的左侧操作数的地址始终在右侧之前进行评估可能会使上述第一个赋值的效率低于其他情况,并要求评估左侧操作数的地址在右侧之后会使第二次分配效率降低。

于 2018-05-31T19:41:39.737 回答