你遇到过的C语言常见未定义/未指定行为是什么?

72

C语言中未指定的行为示例是函数参数的求值顺序。它可能是从左到右或者从右到左,你不知道。这会影响foo(c++, c)foo(++c, c) 的求值方式。

还有哪些未指定的行为会让不知情的程序员感到惊讶?


5
foo(c++, c)foo(++c, c) 都是未定义行为,这比未指定的情况更严重。 - Pascal Cuoq
11个回答

91

一个语言律师的问题。好的。

我个人的前三:

  1. 违反了严格别名规则

  2. 违反了严格别名规则

  3. 违反了严格别名规则

    :-)

编辑 这里有一个做错两次的小例子:

(假设32位整数和小端)

float funky_float_abs (float a)
{
  unsigned int temp = *(unsigned int *)&a;
  temp &= 0x7fffffff;
  return *(float *)&temp;
}

该代码通过位处理浮点数的符号位来获取浮点数的绝对值。

然而,通过将一个类型强制转换为另一个类型来创建对象的指针是无效的C语言。编译器可能会认为不同类型的指针没有指向同一块存储器。这对所有类型的指针都适用,除了void*和char*(符号无关)。

在上面的情况中,我做了两次。一次是为了获得float a的int别名,一次是将值转换回float。

有三种有效的方法可以完成相同的操作:

使用char或void指针进行强制转换。它们总是与任何内容兼容,因此很安全。

float funky_float_abs (float a)
{
  float temp_float = a;
  // valid, because it's a char pointer. These are special.
  unsigned char * temp = (unsigned char *)&temp_float;
  temp[3] &= 0x7f;
  return temp_float;
}

使用memcpy。Memcpy接受void指针,因此它也会强制别名。

float funky_float_abs (float a)
{
  int i;
  float result;
  memcpy (&i, &a, sizeof (int));
  i &= 0x7fffffff;
  memcpy (&result, &i, sizeof (int));
  return result;
}

第三种有效的方法:使用联合体。这在C99中是明确不会产生未定义情况的:
float funky_float_abs (float a)
{
  union 
  {
     unsigned int i;
     float f;
  } cast_helper;

  cast_helper.f = a;
  cast_helper.i &= 0x7fffffff;
  return cast_helper.f;
}

1
顺便说一下 - 联合使用仍然是未定义行为,不是因为 IEEE 位表示,而是因为理论上不允许在字段 f 中写入并从字段 i 中读取。另外,我假设32位整数和小端序。 - Nils Pipenbrinck
1
http://www.csci.csusb.edu/dick/c++std/cd2/basic.html#basic.lval 的第15条弹道似乎暗示了通过联合进行类型游戏是安全的。C标准中的措辞是相同的。 - Greg Rogers
3
C99标准允许通过联合体进行类型转换,参见脚注82,该脚注是在TC3中添加的:“如果用于访问联合体对象内容的成员与最后用于将值存储在对象中的成员不同,则将值的对象表示的适当部分重新解释为新类型中的对象表示形式,如6.2.6所述(有时称为“类型切换”)。这可能会产生陷阱表示。” - Christoph
我不明白char和void有什么特别之处(除了字节对齐)。我们不能将第一个奇怪的函数重写如下吗? float funky_float_abs(float a) { unsigned int temp = (unsigned int) a; temp = (unsigned int)((int)temp & 0x7fffffff); return (float)temp; } - AIB
@NilsPipenbrinck:糟糕。请忽略我之前的两条评论。我的意思是当你执行 temp[3] &= 0x7f; 时,它不会改变从函数返回的 temp_float 中的任何内容(temp_float 是一个副本,当你更改原始对象时它不能改变)。那么你认为这个函数是否达到了预期的效果? - Nawaz
显示剩余5条评论

33

我的个人最喜欢的未定义行为是:如果一个非空源文件没有以换行符结尾,则其行为是未定义的。

我怀疑,除了发出警告之外,我所见过的编译器都没有按照源文件是否以换行符结尾来进行不同的处理。因此,这并不是一些不知情的程序员会感到惊讶的事情,除非他们会对警告感到惊讶。

因此,针对真正的可移植性问题(这些问题大多是与实现相关而不是未指定或未定义的,但我认为这符合问题的精神):

  • char不一定(无)符号。
  • int的大小可以是16位及以上。
  • 浮点数不一定符合IEEE标准。
  • 整数类型不一定是二进制补码,并且整数算术溢出会导致未定义行为(现代硬件不会崩溃,但某些编译器优化将导致行为与环绕不同,即使硬件确实执行环绕。例如,当x具有有符号类型时,if(x+1
  • #include中的“/”、“。”和“..”没有定义的意义,并且不同的编译器可以对其进行不同的处理(实际上确实有所不同,如果出现问题,将会破坏您的工作日)。

真正严重的问题可能会令人惊讶,即使在您开发的平台上,因为行为只部分为未定义或未指定:

  • POSIX线程和ANSI内存模型。并发访问内存没有像新手想象的那么好定义。易失性并不是新手想象的那样。内存访问的顺序并没有像新手想象的那么好定义。在某些方向上,访问可以跨越内存障碍移动。内存缓存一致性不是必需的。

  • 代码调优并不像你想象的那么简单。如果您的测试循环没有效果,编译器可以删除部分或全部内容。 inline没有定义的效果。

而我认为尼尔斯顺便提到了:

  • 违反严格的别名规则。

Steve - 我在90年代初的68K系列Microtec编译器中遇到了你描述的问题(换行符问题)。当时我认为这个工具有bug,但后来我只是"添加了换行符以解决这个愚蠢的工具问题"。与我那个自信过度的同事不同(请参见我在此主题上的其他评论),我并没有太过自信地觉得我会写一个缺陷报告... 幸好我没有这样做。 - Dan
1
有符号整数溢出的未定义并不只是小题大做;至少GCC基于这种情况从中应用优化,例如'if (a + 1 > a)'总是通过而不检测环绕。 - BCoates
@BCoates:我对整数溢出产生部分不确定值并没有问题,这足以证明GCC在指定情况下进行优化的语义是正确的。不幸的是,一些编译器编写者似乎认为整数溢出应该否定时间和因果律(如果代码被重新排序,假设它不会溢出,我或许可以接受时间的否定;但否定因果律在我看来应该被视为疯狂的行为,但不幸的是,并非每个人都同意这一点)。 - supercat

21

我最喜欢的是这个:

// what does this do?
x = x++;

回答一些评论,根据标准它是未定义的行为。看到这一点,编译器可以做任何事情,甚至包括格式化您的硬盘。

例如,请参见此处的评论。重点不在于您可以看到某些行为可能会有合理的期望。由于C++标准和定义序列点的方式,这行代码实际上是未定义的行为。

例如,如果在上面的那行之前我们有x = 1,那么之后的有效结果将是什么?有人评论说应该是

x增加了1

所以之后我们应该看到x == 2。然而事实并非如此,您将会发现有些编译器之后会出现 x == 1,或者甚至 x == 3。要想知道为什么会出现这种差异,您必须仔细查看生成的汇编代码,但这些差异源于底层问题。本质上,我认为这是因为编译器允许按任意顺序评估两个赋值语句,因此它可以先执行x ++,或者先执行x =


17
在标准的C和C++中,明确规定在两个序列点之间多次修改变量是未定义行为。 - KTC
13
想到有人写了一个C编译器,一旦看到 "x = x++" 这样的表达式就会格式化你的硬盘,我现在忍不住开怀大笑,因为它在标准中是未定义的 :-) - dancavallaro
5
+1,特别是“格式化硬盘部分”。实际上,对于像这样编写代码的人来说,格式化硬盘可能会为未来的维护程序员节省很多麻烦... - sleske
1
两件事情:1)这是绝对未定义的行为;大约15年前,我曾与我的一个小组中的某人辩论过,当他写下这段代码(除了他使用“i”而不是“x”)时,他向编译器供应商提交了一个缺陷报告(天哪!),并且“i”被卡在了1;2)当我读到格式化硬盘的部分时,我笑了,可能是因为那是我也会说的话。 - Dan
1
我会说x被增加了,然后被赋予它的先前值,因为x++返回并且优先于赋值。但是是的,这是未定义的......就像语言中的许多其他事情一样(让人头痛不已...) - Calmarius
显示剩余4条评论

20

将某个值除以指向该值的指针。出于某种原因,这段代码无法编译... :-)

result = x/*y;

1
哈哈,好的,我记下了 :-) - Drealmer
因为“/”被视为注释,所以只需在“/”和“”之间添加一个空格即可解决问题(至少在我的gcc 8.1.1上有效)。 - Charles Gueunet
3
这是一个令人难以置信的滑稽和不正确的答案?它根本没有回答问题,并暗示了C代码的错误假设。给定的代码是语法错误。它与未定义的行为无关。也许你想除以基本类型的值和指针值,但这不是你展示的内容。将基本类型的值除以解引用指针并不是不正确的,例如:double x = 2; int z = 1, *y; y = &z; int result = x / *y; - 这个答案需要被彻底编辑或紧急删除。-1 - RobertS supports Monica Cellio

11

我已经无法数清有多少次我为了匹配printf中的格式说明符而进行更正。 任何不匹配的操作都是未定义的行为

  • 不可以将int(或long)传递给%x - 必须使用unsigned int
  • 不可以将unsigned int传递给%d - 必须使用int
  • 不可以将size_t传递给%u%d - 应该使用%zu
  • 不可以使用%d%x打印指针 - 应该使用%p 并强制转换为void *

5
标准暗示(在一个非规范脚注中)传递 int 类型的值给 %x,或者传递 unsigned int 类型的值给 %d,只要这些值在两种类型的范围内是可以的。不过,我更喜欢避免这样做。 - Keith Thompson

11

我遇到的另一个问题(已定义,但绝对是意想不到的)。

char类型很难处理。

  • 它由编译器决定是否为带符号或无符号类型
  • 不是强制规定为8位

3
只要你使用它所预定的用途,也就是用于“字符”,那么它并不是邪恶的…… - sleske
2
实际上,有三种不同类型的char:charunsigned charsigned char。它们是明确不同的类型。 - Lstor
处理字符串时,必须使用(指向或数组的纯)char。许多标准库函数(如所有的str*()函数)都需要指向char的指针,给它们其他任何东西都需要丑陋的转换。 - Jens
1
谁说要用字符串了?嵌入式程序员有时会为了效率而玩弄变量大小。假设 char 的任何内容都不适用于跨平台。调用针对字符串的库函数,但在字符串仅是 char* 且 Unicode 尚未发明的情况下定义可能没问题,但如果我直言不讳... 不编写至少支持 Unicode 字符的程序是愚蠢的。 - itj

9

我见过很多经验不足的程序员因为多字符常量而受到影响。

这个例子:

"x"

这是一个字符串字面量(类型为char[2],在大多数情况下会衰减为char*)。

以下是示例:

'x'

这是一个普通的字符常量(由于历史原因,它的类型是int)。

下面是示例:

'xy'

也是一个完全合法的字符常量,但它的值(仍为int类型)是由实现定义的。这几乎是一个无用的语言特性,主要是造成混淆。


在Macintosh上编写C语言时,它非常有用,因为经常使用32位整数来保存四字符文件类型、应用程序签名等,尽管三字符序列会相当讨厌地破坏'????' - supercat
这在接受char*char的函数被重载时尤其危险。我看过很多人因此而深受其害(例如)。 - rustyx
4
问题涉及到C语言,而不是C++。没有重载函数。 - Keith Thompson

8

如果函数原型不可用,编译器在调用带有错误参数数量/错误参数类型的函数时不一定会告诉您。


是的。然而,仁慈的编译器通常会通过警告来帮助您... - sleske
从C99开始,调用没有可见声明的函数需要进行诊断。该声明不一定是原型(即指定参数类型的声明),但它总是应该是原型。(可变参数函数如printf仍可能存在问题。) - Keith Thompson

6

一段时间前,clang开发者发布了一篇每个C程序员都应该阅读的文章,其中包含一些很棒的例子。以下是一些之前未提到的有趣例子:

  • 有符号整数溢出 - 不,将有符号变量超过其最大值进行包装是不可以的。
  • 对空指针解引用 - 是的,这是未定义的行为,可能会被忽略,请参见链接的第二部分。

2

这里的EE们刚刚发现a>>-2有些棘手。

我点了点头并告诉他们这是不自然的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接