为什么i = v [i ++]未定义?

时间:2012-12-06 13:21:01

标签: c++ language-lawyer

从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 behaviour的另一个问题的答案可能适用于此:答案基本上是< em>历史的原因,并非出于必要性。但是,该标准似乎意味着某些理由因此未定义 - 请参阅下面的引用。此外,该链接问题表明同意行为应该未指定,而在这个问题中我问的是为什么行为不是明确指定。)

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

  

如果标量对象的副作用相对于其中任何一个都没有排序   对同一标量对象或值计算的另一个副作用   使用相同标量对象的值,行为未定义。

在此示例中,我认为子表达式i++将在评估子表达式v[...] 之前完全评估,并且结果子表达式的评估是i(在增量之前),但i是在完全评估该子表达式之后的递增值。我认为在那时(在完全评估子表达式i++之后),进行评估v[...],然后进行作业i = ...

因此,虽然i的递增是没有意义的,但我仍然认为这应该定义

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

8 个答案:

答案 0 :(得分:42)

  

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

为什么你会这么想?

此代码为UB的一个历史原因是允许编译器优化在序列点之间的任何位置移动副作用。序列点越少,优化的机会越多,但程序员越混乱。如果代码说:

a = v[i++];

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

a = v[i];
++i;

可能是两条指令:

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

将超过两个。

ai时,“优化代码”会中断,但标准允许优化,在{{{{em>}时,原始代码的行为未定义1}}是a

标准很容易说,i必须在作业建议之前进行评估。然后行为将被完全定义,并且将禁止优化。但这并不是C和C ++如何做生意。

还要注意,在这些讨论中提出的许多例子使得更容易分辨出周围的UB而不是一般情况。这导致人们说“明显”应该定义行为并禁止优化。但请考虑:

i++

此函数的行为是在void g(int *i, int* v, int *dst) { *dst = v[(*i)++]; } 时定义的,在这种情况下,您希望获得所有优化(这就是C99引入i != dst的原因),以允许比C89更优化或C ++做)。为了给您优化,在restrict时未定义行为。当涉及别名时,C和C ++标准在程序员不期望的未定义行为与禁止在某些情况下失败的期望优化之间存在细微差别。在SO上关于它的问题数量表明,提问者更喜欢一点点优化和一些更明确的行为,但绘制线仍然不是很简单。

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

其他语言(例如Java)全面完成并完全定义表达式副作用的顺序,所以肯定存在针对C的方法的情况。 C ++只是不接受这种情况。

答案 1 :(得分: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)write(i, 0xBAADF00D)执行无序并行。每个都变成字节级写入,并且它们是随机排序的。

我们最终将0x00然后0xBA写入高字节,然后0xAD0x00写入下一个字节,然后0xF0 {{1}到下一个字节,最后0x00 0x0D到低字节。 i中的结果值是0x01,很少有人会期望,但对于未定义的操作将是有效的结果。

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

请记住,这是一种将指针大小限制为8位的语言仍然是一个有效的执行环境。 C ++允许编译而不是winkey目标。

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

答案 2 :(得分:24)

原因不仅仅是历史性的。例如:

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

现在,这次电话会发生什么:

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

当然可以在f中对代码提出要求,以便很好地定义此调用的结果(Java会这样做),但C和C ++不会施加约束;这为优化者提供了更多的自由。

答案 3 :(得分:9)

您特别参考C ++ 11标准,因此我将回答C ++ 11的答案。然而,它与C ++ 03的答案非常相似,但是排序的定义是不同的。

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

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

这就是设置。第一条重要规则是:

  

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

因此,在下一个完整表达式之前,将完全评估任何完整表达式。在您的问题中,我们只处理一个完整的表达式,即i = v[i++],因此我们不必担心这一点。下一个重要规则是:

  

除非另有说明,否则对单个运算符的操作数和单个表达式的子表达式的评估是不确定的。

这意味着在a + b中,ab的评估未被排序(可以按任何顺序评估)。现在我们最后的重要规则是:

  

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

因此,对于a + b,关系之前的顺序可以用树表示,其中有向箭头表示关系之前的顺序:

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

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

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

答案 4 :(得分:4)

  

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

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

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

通过该扩展,i的结果与v[i]中的值无关。在不同的展开式广告中,i = x作业可以在 i = y作业之前进行,结果将为i = v[i]

答案 5 :(得分:4)

有两条规则。

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

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

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

i + i++这样的表达式违反了第二条规则。左侧的子表达式i观察修改后的对象的值,而不涉及其新值的计算。

因此,i = v[i++]违反了i + i++的不同规则(写入错误)(写入错误)。


这些规则过于简单化,导致令人费解的表达类型。考虑一下:

p = p->next = q

这似乎具有无危害的合理数据流依赖性:在知道新值之前,无法进行赋值p =。新值是p->next = q的结果。值q不应该“提前竞赛”并进入p,以致p->next受到影响。

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

因此,反过来,允许编译器部分评估p->next = q以确定结果为q,并将其存储到p中,然后返回并完成{{1}赋值。或者看起来如此。

这里的一个关键问题是,赋值表达式的值是什么? C标准表示赋值表达式的值是赋值后的左值的值。但这是模棱两可的:它可以解释为“一旦赋值发生,左值 将具有的值”或“作为赋值后在左值中可以观察到的值”地点”。在C ++中,通过措辞“[i] n所有情况,在左右操作数的值计算之后,在赋值表达式的值计算之前对赋值进行排序。”,所以p->next =似乎是有效的C ++,但可疑的C。

答案 6 :(得分:2)

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

答案 7 :(得分: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”的值,在其闲暇时,将允许有效地处理上述两种模式。要求始终在右侧之前评估“=”的左侧操作数的地址可能会使第一次分配的效率低于其他方式,并要求评估左侧操作数的地址在右侧之后会使第二次任务效率降低。