我知道以下是未定义的,因为我试图在同一个表达式中读取和写入变量的值,即
int a=5;
a=a++;
但如果是这样,那么为什么下面的代码片段不是未定义的
int a=5;
a=a+1;
就像这里一样,我也在尝试修改它的值a
并同时写入它。
还要解释为什么标准没有解决这个问题或删除这个未定义的行为,尽管他们知道它是未定义的?
我知道以下是未定义的,因为我试图在同一个表达式中读取和写入变量的值,即
int a=5;
a=a++;
但如果是这样,那么为什么下面的代码片段不是未定义的
int a=5;
a=a+1;
就像这里一样,我也在尝试修改它的值a
并同时写入它。
还要解释为什么标准没有解决这个问题或删除这个未定义的行为,尽管他们知道它是未定义的?
为什么下面的代码片段不是未定义的
int a=5; a=a+1;
该标准指出
在前一个和下一个序列点之间,对象的存储值最多只能通过表达式的评估修改一次。此外,只能访问先验值以确定要存储的值。
的情况下a = a + 1
;a
仅修改一次,并且仅a
访问的先前值以确定要存储的值a
。
而在 , 的情况下a=a++;
被a
修改了不止一次——通过++
子表达式中a++
的=
运算符和将结果分配给 left的运算符a
。现在没有定义哪个修改,无论是 by++
还是 by =
,将首先发生。
几乎所有带有标志的现代编译器-Wall
都会在编译第一个片段时发出警告,例如:
[Warning] operation on 'a' may be undefined [-Wsequence-point]
它未定义的原因不是你读写,而是你写了两次。
a++
表示读取 a 并在读取后递增它,但我们不知道 ++ 是在赋值之前发生=
(在这种情况下,=
将用 a 的旧值覆盖)还是之后,在这种情况下,a 将递增。
只需使用a++;
:)
a = a + 1
没有问题,因为 a 只写了一次。
++ 运算符将为 a 加一,这意味着变量 a 将变为 a+1。实际上,以下两个语句是相等的:
a++;
a = a + 1;
最后一条语句 a + 1 不会增加 a - 它会生成一个值为 a + 1 的结果。如果您希望 a 变为 a+1,则必须将 a + 1 的结果分配给 a
a = a + 1;
您所做的第一个语句不起作用的原因是因为您编写了类似的内容
a = (a = a + 1);
长话短说,您可以在标准中找到每个定义的行为。此处未按定义提及的所有内容 - 都是未定义的。
对您的示例的直观解释:
a=a++;
您想a
在单个语句中修改变量两次。
1) a= //first time
2) a++ //second time
如果你看这里:
a=a+1;
您只修改变量 a 一次:
a= // (a+1) - doesn't change the value of a
为什么标准不定义a=a++
行为?
可能的原因之一是: 编译器可以执行优化。您在标准中定义的案例越多,编译器优化代码的自由度就越小。因为不同的架构可以有不同的递增指令实现,编译器不会使用所有处理器指令,以防它们破坏标准行为。或者在某些情况下编译器可以更改评估顺序,但是如果您想修改两次,此限制将强制编译器禁用此类优化。
其他人已经讨论了您的特定示例的详细信息,因此我将添加一些有助于捕获未定义行为的一般信息和工具。
没有最终的工具或方法可以捕获未定义的行为,因此即使您使用所有这些工具,也不能保证您的代码中没有未定义的内容。但是 IME 这些将捕获相当多的常见问题。我没有列出您应该使用的软件开发的标准良好实践,例如单元测试。
clang(-analyze) 有几个选项可以帮助在编译时和运行时捕获未定义的行为。它有 -ftrapv,它新获得了对金丝雀值的支持,它的地址清理器,--fcatch-undefined-behaviour 等等。
gcc 也有几个选项来捕获未定义的行为,例如挡泥板、它的地址清理器、堆栈保护器。
valgrind 是一个出色的工具,用于在运行时查找与内存相关的未定义行为。
frama-c 是一个静态分析工具,可以发现和可视化未定义的行为。它能够找到死代码(未定义的行为通常会导致代码的其他部分变为死代码)是追踪潜在安全问题的非常有用的工具。frama-c 具有许多更高级的功能,但可以说比...更难使用
存在其他可以捕获未定义行为的商业静态分析工具,例如 PVS-studio、klocwork 等。不过,这些通常要花很多钱。
使用不同的编译器和奇怪的架构进行编译。如果可以,为什么不在 8 位 AVR 芯片上编译和运行代码?树莓派(32 位 ARM)?使用 emscripten 将其编译为 javascript 并在 V8 中运行?这样做往往是一种捕获可能导致崩溃的未定义行为的实用方式(但对于捕获可能导致安全问题的潜伏 UB 几乎/没有作用)。
现在,关于为什么存在未定义行为的本体论原因......基本上是出于性能和易于实现的原因。C 中的许多 UB 允许编译器优化其他语言无法优化的某些内容。如果你比较一下 java、python 和 C 如何处理有符号整数类型的溢出,你会发现在一个极端,python 以一种方便程序员的方式对它进行了很好的定义——int 实际上可以变得无限大。频谱另一端的 C 未定义 - 您有责任永远不会溢出您的有符号整数。Java 介于两者之间。
但另一方面,这意味着在 python 中不知道“int + int”操作在执行时将实际执行什么工作。它可能执行数百条指令,通过操作系统来回分配一些内存,等等。如果您非常关心性能,或者更具体地说,是一致的性能,这将非常糟糕。另一方面,C 允许编译器将“+”映射到添加整数的 CPU 本地指令(如果存在)。当然,不同的 CPU 可能会以不同的方式处理溢出,但由于 C 未定义,这很好 -作为程序员,您必须注意不要溢出您的整数。这意味着 C 为编译器提供了编译“int + int”的选项
请注意,C 不保证 + 实际上直接映射到本机 CPU 指令,它只是为编译器留下了以这种方式进行操作的可能性——显然这是任何编译器编写者都渴望利用的东西。Java 定义有符号整数溢出的方法比 python 更不可预测(在性能方面),但可能不会导致 + 在 C 允许的许多 CPU 类型上变成单个 CPU 指令。
所以本质上,C 试图接受未定义的行为,并选择(一致的)速度和易于实现,而其他语言选择安全或可预测的行为(从程序员的角度来看)。这不是一个好的决定,例如尊重到安全/安保,但这就是 C 的立场。它归结为“了解手头工作的合适工具”,并且在很多情况下,C 为您提供的性能可预测性绝对是必不可少的。