为什么我= v 未定义?
从C ++(C ++ 11)标准,讨论评估顺序的§1.9.15,是以下代码示例:
void g(int i, int* v) { i = v[i++]; // the behavior is undefined }
如代码示例中所述,行为是未定义的。
(注:另一个问题的答案与i + i++
略有不同, 为什么a = i + i ++未定义而不是未指定的行为 , 可能适用于此:答案本质上是由于历史原因行为未定义,而不是然而,这个标准似乎暗示了一些没有定义的理由 – 参见下面的引用,而且,这个相关的问题表明同意这个行为应该是不确定的 ,而在这个问题中,我问的是为什么这个行为不是很好,指定) 。
标准给出的未定义行为的推理如下:
如果对标量对象的副作用相对于同一个标量对象的另一个副作用或者使用相同标量对象的值进行值计算而言是不确定的,则行为是不确定的。
在这个例子中,我认为子expression式i++
在评估子expression式v[...]
之前将被完全评估,并且子expression式的评估结果是i
(在增量之前),但是i
的值是该子expression式之后的递增值已被完全评估。 我认为在那个时候(在子expression式i++
被完全评估之后),评估v[...]
发生,然后是指派i = ...
因此,虽然i
的增加毫无意义,但我仍然认为这应该被定义 。
为什么这个未定义的行为?
我打算devise一台病理电脑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[]
加载的那个值)。 而且,实际上,这些写入是唯一回写到内存的操作。
想象一下,如果写入内存是这个计算机devise的真正缓慢的部分,并且它们被批量处理成一个由并行内存修改单元处理的事物的队列,该单元在每个字节的基础上进行处理。
所以write(i, 0x00000001)
和write(i, 0xBAADF00D)
执行无序并行。 每个变成字节级的写入,并且它们是随机排列的。
我们最终将0x00
然后0xBA
写入高字节,然后0xAD
和0x00
到下一个字节,然后0xF0
0x00
到下一个字节,最后0x0D
0x01
到低字节。 在i中产生的值是0xBA000001
,很less有人会期望,但是对于你的未定义的操作来说是有效的结果。
现在,我在那里所做的只是导致了一个不明确的价值。 我们没有把系统崩溃。 但编译器可以自由地完成未定义 – 可能发送两个这样的请求到同一批处理指令中的相同地址的内存控制器实际上会使系统崩溃。 这仍然是编译C ++的“有效”方法,也是一个“有效的”执行环境。
请记住,这是一种限制指向8位指针大小的语言,它仍然是一个有效的执行环境。 C ++允许编译为相当威望的目标。
1 :正如下面的@ SteveJessop的评论所指出的,这个笑话是,这个病态的计算机很像现代的台式计算机,直到你开始进行字节级的操作。 在某些硬件上(例如,当int
不与CPU想要alignment的方式alignment时),CPU的非primefacesint
写入并不是那么罕见。
我认为子expression式i ++在评估子expression式v之前将被完全评估
但是你为什么这么想呢?
这个代码是UB的一个历史原因是允许编译器优化在序列点之间的任何地方移动副作用。 序列点越less,优化的潜在机会就越多,但程序员就越困惑。 如果代码说:
a = v[i++];
标准的意图是发射的代码可以是:
a = v[i]; ++i;
这可能是两个指示,其中:
tmp = i; ++i; a = v[tmp];
会超过两个。
当a
是i
,“优化代码”就会中断, 但标准仍然允许优化 ,当a
是i
时,原始代码的行为是不确定的。
这个标准很容易就可以说,我们必须按照你的build议评估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的问题的数量表明,提问者会喜欢less一点的优化和一些更加明确的行为,但是绘制线还是不容易的。
除了行为是否完全定义之外,还应该是UB的问题,还是与子expression式相对应的某些明确定义的操作的执行顺序。 C代表UB的原因与序列点的概念有关,事实上编译器实际上不需要修改对象的值的概念,直到下一个序列点为止。 因此,与其约束优化器说“该”值在某个非特定点处发生了变化,标准只是说(要解释):(1)任何依赖于下一个序列点之前的修改对象的值的代码UB; (2)任何修改修改对象的代码都有UB。 如果“修改对象”是自从子expression式的一个或多个合法评估顺序中的最后一个顺序点以来已经被修改的任何对象。
其他语言(例如Java)则完整地定义了expression式副作用的顺序,所以肯定会遇到C方法的情况。 C ++只是不接受这种情况。
原因不仅仅是历史。 例:
int f(int& i0, int& i1) { return i0 + i1++; }
现在,这个调用会发生什么:
int i = 3; int j = f(i, i);
当然可以在f
join代码,这样调用的结果就可以很好地定义(Java是这样做的),但是C和C ++并没有强加约束。 这给了优化者更多的自由。
你特别提到了C ++ 11标准,所以我要回答C ++ 11的答案。 然而,它与C ++ 03的答案非常相似,但是sorting的定义是不同的。
C ++ 11在单个线程的评估之间的关系之前定义了一个序列 。 它是不对称的,传递和成对的。 如果在评估B和B之前某些评估A没有被测序,那么这两个评估是不确定的 。
评估expression式包括值计算(计算某些expression式的值)和副作用。 副作用的一个实例是修改一个对象,这对于回答问题是最重要的。 其他的东西也算作副作用。 如果一个副作用相对于同一个对象上的另一个副作用或值计算是不确定的,那么你的程序就有未定义的行为。
这就是设置。 第一条重要规则是:
每一个与全expression式相关的数值计算和副作用在每一个与下一个要被评估的全expression式相关的数值计算和副作用之前被sorting。
因此,在下一个完整expression式之前完整地评估任何完整expression式 在你的问题中,我们只处理一个完整的expression式,即i = v[i++]
,所以我们不需要担心这个。 下一个重要规则是:
除了注意到的地方之外,对个别操作符和个别expression式的操作数的评估是不确定的。
这意味着,在a + b
,例如, a
和b
的评估是不确定的(它们可以按任何顺序评估)。 现在对于我们最后的重要规则:
运算符操作数的值计算在运算符结果的值计算之前被sorting。
因此,对于a + b
,关系之前的顺序可以用一棵树表示,其中一个指向箭头表示关系之前的顺序:
a + b (value computation) ^ ^ | | ab (value computation)
如果两个评估发生在树的不同分支中,则它们是不确定的,所以这棵树显示a
和b
的评估是相互不相关的。
现在,让我们对你的i = v[i++]
例子做同样的事情。 我们利用v[i++]
被定义为等价于*(v + (i++))
的事实。 我们也使用一些关于后缀增量sorting的额外知识:
++
expression式的值计算在操作数对象的修改之前被sorting。
所以在这里我们去(树的一个节点是一个值计算,除非指定为副作用):
i = v[i++] ^ ^ | | i★ v[i++] = *(v + (i++)) ^ | v + (i++) ^ ^ | | v ++ (side effect on i)★ ^ | i
在这里,你可以看到在i
, i++
上的副作用与赋值运算符前面的i
的用法分开(我用★标记了每个赋值)。 所以我们肯定有不确定的行为! 如果您想知道您的评估顺序是否会给您带来麻烦,我强烈build议您绘制这些图表。
所以现在我们得到一个问题, i
赋值运算符之前的i
的值并不重要,因为我们无论如何都要写它。 但实际上,在一般情况下,情况并非如此。 我们可以重写赋值操作符,并在赋值之前使用对象的值。 标准并不关心我们不使用这个值 – 规则被定义为使任何具有副作用的值不被计算的值都是未定义的行为。 没有但是。 这个未定义的行为是为了让编译器发出更多优化的代码。 如果我们为赋值运算符添加sorting,则不能使用此优化。
在这个例子中,我认为子expression式i ++在评估子expression式v之前将被完全评估,并且子expression式的评估结果是i(在增量之前),但是i的值是该子expression式之后的递增值已被完全评估。
i++
的增量必须在对v
进行索引之前进行评估,因此在分配给i
之前,将该增量的值存储回存储器不需要在之前发生。 在声明i = v[i++]
,有两个子操作可以修改i
(即最终将从一个寄存器导致一个存储到variablesi
)。 expression式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]
有两条规则。
第一条规则是关于引起“写 – 写危险”的多次写入: 同一个对象不能在两个序列点之间多次修改。
第二条规则是关于“读写危害”。 它是这样的: 如果一个对象在一个expression式中被修改,并且被访问,那么对它的值的所有访问都必须是为了计算新的值。
像i++ + i++
的expression式和你的expression式i = v[i++]
违反了第一条规则。 他们修改一个对象两次。
像i + i++
这样的expression式违反了第二条规则。 左边的子expression式i
观察修改对象的值,而不涉及其新值的计算。
所以, i = v[i++]
违背了i + i++
(坏的读写)的不同规则(坏的写入)。
规则过于简单化,引起了一系列令人费解的expression。 考虑一下:
p = p->next = q
这似乎有一个健全的数据stream依赖,没有危害:分配p =
不能发生,直到新的值已知。 新值是p->next = q
的结果。 值q
不应该“向前跑”并进入p
,从而p->next
会受到影响。
然而,这个expression式打破了第二个规则: p
被修改,并且用于与计算其新值无关的目的,即确定q
的值被放置的存储位置!
因此,相反,编译器可以部分地评估p->next = q
来确定结果是q
,并将其存储到p
,然后返回并完成p->next =
赋值。 或者看起来如此。
这里的关键问题是赋值expression式的价值是什么? C标准规定赋值后赋值expression式的值是左值的值。 但是这是模棱两可的:它可以被解释为“一旦分配发生后,左值将会有的价值”或者“在分配发生之后在左值中可以观察到的价值”的含义。 在C ++中,通过“在所有情况下,在左右操作数的值计算之后,赋值expression式的值计算之前,赋值是按顺序排列的”,所以p = p->next = q
似乎是有效的C ++,但可疑C.
如果这个例子是v[++i]
,我会分享你的论点,但是因为i++
i
修改为一个副作用,所以在修改这个值的时候没有定义。 标准可能会以某种方式要求结果,但是没有真正的方法来知道我的价值应该是: (i + 1)
还是(v[i + 1])
。