为什么我= v [i ++]未定义?
从C ++(C ++ 11)标准,讨论评估顺序的§1.9.15,是以下代码示例:
void g(int i, int* v) {
i = v[i++]; // the behavior is undefined
}
如代码示例中所述,行为未定义。
(注意:对于另一个与i + i++
结构略有不同的问题的答案,为什么a = i + i ++未定义,而不是未指定的行为,可能适用于此:答案基本上是由于历史原因未定义行为,然而,这个标准似乎暗示了这个未定义的一些理由 - 请参见下面的引用。此外,这个关联的问题表明同意行为应该没有说明,而在这个问题中,我问的是为什么行为不是很好 - 指定)。
标准给出的未定义行为的推理如下:
如果对标量对象的副作用相对于同一标量对象的另一副作用或使用同一标量对象的值进行值计算而言是不确定的,则行为是未定义的。
在这个例子中,我认为子表达式i++
在评估子表达式v[...]
之前将被完全评估,并且子表达式的评估结果是i
(在增量之前),但是i
的值是该子表达式之后的递增值已被完全评估。 我认为在那个时候(在子表达式i++
已经被完全评估之后),评估v[...]
发生,然后分配i = ...
因此,虽然i
的增加毫无意义,但我仍然认为这应该被定义。
为什么这是未定义的行为?
我要设计一台病理计算机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)
和write(i, 0xBAADF00D)
执行无序并行。 每个变成字节级写入,并且它们是随机排序的。
我们最终写入0x00
然后0xBA
到高字节,然后0xAD
和0x00
到下一个字节,然后0xF0
0x00
到下一个字节,最后0x0D
0x01
到低字节。 i中的结果值是0xBA000001
,很少有人会期望,但对于未定义的操作0xBA000001
,这将是有效的结果。
现在,我在那里所做的只是导致了一个不明确的价值。 我们没有将系统崩溃。 但是编译器可以自由地使它完全不确定 - 也许可以向同一批指令中的相同地址的内存控制器发送两个这样的请求,实际上会使系统崩溃。 这仍然是编译C ++的“有效”方法,也是一个“有效的”执行环境。
请记住,这是一种限制指向8位指针大小的语言,它仍然是一个有效的执行环境。 C ++允许编译为相当威望的目标。
1:正如@ SteveJessop在下面的评论中所指出的那样,这个笑话是,这个病态计算机的行为很像现代台式计算机,直到你开始进行字节级操作。 在某些硬件上(例如当int
未按照CPU希望对齐的方式对齐时),由CPU执行的非原子int
写入并不是那么罕见。
我会认为子表达式i ++在评估子表达式v之前会被完全评估
但是,你为什么会这么想?
这个代码是UB的一个历史原因是允许编译器优化在序列点之间的任何地方移动副作用。 序列点越少,优化的潜在机会就越多,但程序员就越困惑。 如果代码说:
a = v[i++];
标准的意图是发射的代码可以是:
a = v[i];
++i;
这可能是两条说明,其中:
tmp = i;
++i;
a = v[tmp];
会超过两个。
当a
是i
,“优化代码”就会中断,但是标准允许优化,因为当a
是i
时,原始代码的行为是不确定的。
标准很容易就可以说,我们必须按照你的建议评估i++
。 然后,行为将被完全定义,优化将被禁止。 但这不是C和C ++的业务。
同时要注意,在这些讨论中提出的很多例子可以让人们更容易地发现UB的总体状况。 这导致人们说,应该定义行为和禁止优化是“显而易见的”。 但请考虑:
void g(int *i, int* v, int *dst) {
*dst = v[(*i)++];
}
这个函数的行为是在i != dst
时定义的,在这种情况下,您希望获得所有优化(这就是为什么C99引入了restrict
,以便比C89或C ++更优化)。 为了给你优化,当i == dst
时,行为是不确定的。 C和C ++标准在涉及到别名时,在程序员未预料到的未定义行为与禁止在某些情况下失败的期望优化之间,会有细微的区别。 关于SO的问题的数量表明,提问者会偏好少一点的优化和一些更明确的行为,但绘制线仍然不是一件容易的事。
除了行为是否完全定义之外,还包括是否应该是UB的问题,还是与子表达式相对应的某些明确定义的操作的执行顺序。 C代入UB的原因都与序列点的想法有关,并且事实上编译器实际上不需要修改对象的值的概念,直到下一个序列点。 因此,并非要限制优化器说“该”值在某个未指定的点处发生了变化,该标准只是说(用于解释):(1)任何依赖于下一个序列点之前的修改对象的值的代码, UB; (2)任何修改修改对象的代码都有UB。 如果“修改对象”是自从子表达式的一个或多个合法评估顺序中的最后一个顺序点以来已经被修改的任何对象。
其他语言(例如Java)可以完整地定义表达式副作用的顺序,所以肯定会遇到C方法的情况。 C ++不接受这种情况。
原因不只是历史。 例:
int f(int& i0, int& i1) {
return i0 + i1++;
}
现在,这个调用会发生什么:
int i = 3;
int j = f(i, i);
对f
的代码提出要求当然是可能的,这样调用的结果就可以很好地定义(Java是这样做的),但是C和C ++不会强加约束; 这给了优化者更多的自由。