C++程序员应该知道的所有常见未定义行为是什么?

200

一个C++程序员应该知道的常见未定义行为有哪些?

例如:

a[i] = i++;


3
你确定吗?那看起来很明确。 - Martin York
17
6.2.2 评估顺序 [expr.evaluation] 在 C++ 编程语言中是这样说的。我没有其他参考资料。 - yesraaj
4
他是正确的。我刚看了《C++程序设计语言》6.2.2章节,它说v[i] = i++是未定义的。 - dancavallaro
4
我猜测是因为编译器会在计算v[i]的内存位置之前或之后执行i++操作。虽然i始终会被赋值,但它可能会将值写入v[i]或v[i+1]中,这取决于操作顺序。 - Evan Teran
2
这些天来,对于版主来说,似乎成了一种必经之路,即强制关闭一个受欢迎的问题,甚至是那些灰色地带的问题(比如这个问题),而这些问题本应由整个社区决定,而不是一个过于热衷的单个版主。 - BlueRaja - Danny Pflughoeft
显示剩余6条评论
11个回答

232

指针

  • NULL指针进行解引用
  • 解引用返回大小为零的新分配指针
  • 使用已经结束生命周期的对象的指针(例如,栈分配的对象或已删除的对象)
  • 解引用尚未被明确初始化的指针
  • 执行指针算术运算,导致结果超出数组边界(上方或下方)
  • 解引用指向数组末端之外的指针
  • 转换类型不兼容的对象指针
  • 使用memcpy复制重叠缓冲区

缓冲区溢出

  • 读取或写入对象或数组的偏移量为负数或超出该对象的大小(堆栈内存溢出)

整数溢出

  • 有符号整数溢出
  • 求值不是数学上定义的表达式
  • 左移负数位(右移负数位是实现定义)
  • 将值向左移动个数大于或等于数字中的位数(例如,int64_t i = 1; i <<= 72是未定义的)

类型、转换和常量

  • 将数值转换为目标类型无法表示的值(直接或通过static_cast)
  • 在明确分配之前使用自动变量(例如,int i; i++; cout << i;
  • 在接收到信号时使用任何非volatilesig_atomic_t类型的对象的值
  • 在其生命周期内修改字符串文字或任何其他const对象
  • 在预处理期间连接窄字符串和宽字符串字面量

函数和模板

  • 从有返回值的函数中不返回值(直接或通过流出try块)
  • 对于相同的实体(类、模板、枚举、内联函数、静态成员函数等),定义多个不同的定义
  • 在实例化模板时出现无限递归
  • 使用不同的参数或链接方式调用函数,这与函数定义时使用的参数和链接方式不同

OOP

  • 具有静态存储期的对象的级联销毁
  • 将内容部分重叠的对象赋值给另一个对象
  • 在静态对象初始化期间递归地重新进入函数
  • 从构造函数或析构函数中的对象向纯虚函数进行虚函数调用
  • 引用未被构造或已被析构的对象的非静态成员变量

源文件和预处理

  • 不以换行符结尾的非空源文件,或以反斜杠结尾(C++11之前)
  • 在字符或字符串常量中,反斜杠后跟不是指定转义码的字符(在C++11中这是实现定义的)
  • 超出实现限制(嵌套块的数量、程序中的函数数量、可用堆栈空间等)
  • 预处理器数值不能由long int表示
  • 函数式宏定义左侧的预处理指令
  • #if表达式中动态生成定义的标记

待分类

  • 在具有静态存储期的程序销毁期间调用退出函数

1
C99标准在附录J.2中列出了一些未定义的行为。要将此列表适应于C ++,需要进行一些工作。您必须更改引用正确的C ++条款而不是C99条款,删除任何不相关的内容,并检查所有这些内容在C++中是否也未定义。但它提供了一个起点。 - Steve Jessop
1
@new123456 - 并非所有浮点数单元都兼容IEE754。如果C++需要IEE754兼容性,编译器将需要通过显式检查来测试和处理RHS为零的情况。通过使行为未定义,编译器可以避免通过说“如果您使用非IEE754 FPU,则不会获得IEEE754 FPU行为”来避免那种开销。 - SecurityMatt
将值按大于容器的log2大小的量移位(例如,__int64 i =(37 << 72))是未定义的。这有点误导人,而且并不总是正确的。只有当sizeof(int) * CHAR_BIT <= 72为真时,才是未定义行为。您放置37 << 72结果的对象是无关紧要的,表达式本身就会调用未定义的行为。例如,uint8_t n = (1 << 15);是完全可以的。首先评估1 << 15,其为2 ** 15。然后将结果从“int”隐式转换为“uint8_t”,这意味着n == 0(由于模算术)。 - David Stone
1
"评估表达式的结果不在相应类型范围内" ... 整数溢出对于无符号整数类型是定义良好的,但对于有符号整数类型则不是。 - nacitar sevaht
我正要发表与 @nacitarsevaht 相同的评论。 - user541686
显示剩余3条评论

31

函数参数的求值顺序是未指定的行为。(这不会使您的程序崩溃,爆炸或订购披萨……与未定义行为不同。)

唯一的要求是在调用函数之前必须完全对所有参数进行求值。


这个:

// The simple obvious one.
callFunc(getA(),getB());

可以等同于这个:

int a = getA();
int b = getB();
callFunc(a,b);

或者这个:

int b = getB();
int a = getA();
callFunc(a,b);

这取决于编译器,可能会对副作用产生影响,也可能不会。


23
顺序未指定,但并非未定义。 - Rob Kennedy
1
我讨厌这个 :) 有一次我因为追踪其中一个案例而浪费了一天的工作时间... 不管怎样,我吸取了教训,幸运的是再也没有犯同样的错误。 - Robert Gould
2
@Rob:我本想和你辩论这里的意思变化,但是我知道标准委员会对这两个词的确切定义非常挑剔。所以我会改掉它 :-) - Martin York
2
我这次运气不错。在大学时,我曾被它咬过,当时我的教授一眼就看出了问题,只用了大约5秒钟的时间告诉我。否则,我要浪费多少时间去调试呢? - Bill the Lizard

27

编译器可以自由地重新排列表达式的计算部分(假设意义不变)。

来自原始问题:

a[i] = i++;

// This expression has three parts:
(a) a[i]
(b) i++
(c) Assign (b) to (a)

// (c) is guaranteed to happen after (a) and (b)
// But (a) and (b) can be done in either order.
// See n2521 Section 5.17
// (b) increments i but returns the original value.
// See n2521 Section 5.2.6
// Thus this expression can be written as:

int rhs  = i++;
int lhs& = a[i];
lhs = rhs;

// or
int lhs& = a[i];
int rhs  = i++;
lhs = rhs;

双重检查锁定。这是一个容易犯错的技巧。

A* a = new A("plop");

// Looks simple enough.
// But this can be split into three parts.
(a) allocate Memory
(b) Call constructor
(c) Assign value to 'a'

// No problem here:
// The compiler is allowed to do this:
(a) allocate Memory
(c) Assign value to 'a'
(b) Call constructor.
// This is because the whole thing is between two sequence points.

// So what is the big deal.
// Simple Double checked lock. (I know there are many other problems with this).
if (a == null) // (Point B)
{
    Lock   lock(mutex);
    if (a == null)
    {
        a = new A("Plop");  // (Point A).
    }
}
a->doStuff();

// Think of this situation.
// Thread 1: Reaches point A. Executes (a)(c)
// Thread 1: Is about to do (b) and gets unscheduled.
// Thread 2: Reaches point B. It can now skip the if block
//           Remember (c) has been done thus 'a' is not NULL.
//           But the memory has not been initialized.
//           Thread 2 now executes doStuff() on an uninitialized variable.

// The solution to this problem is to move the assignment of 'a'
// To the other side of the sequence point.
if (a == null) // (Point B)
{
    Lock   lock(mutex);
    if (a == null)
    {
        A* tmp = new A("Plop");  // (Point A).
        a = tmp;
    }
}
a->doStuff();

// Of course there are still other problems because of C++ support for
// threads. But hopefully these are addresses in the next standard.

1
哦...那很糟糕,尤其是因为我曾经看到在Java中推荐使用这种确切的结构。 - Tom
嗨,我在想第二个版本的单例实现还有哪些问题。看起来它已经完美了。 - Joey.Z
@zoujyjs:如果你想用指针创建一个单例,那么你的方法是错误的。请参考C++ 单例设计模式 - Martin York
是的,我也在《Effective C++》的第四项中读到了这个。在C++0x之后,这个是保证线程安全的。但为什么不用指针呢?如果不用指针,那我返回指针的解引用会怎样呢?因为单例模式应该在整个进程的生命周期中存在,所以我不需要使用显式指针来销毁它。 - Joey.Z
特别是,我想知道上面的代码是否被认为是DCLP的相当好的实现。 - Joey.Z
显示剩余13条评论

5

我最喜欢的是"Infinite recursion in the instantiation of templates",因为我认为这是唯一一个在编译时发生未定义行为的例子。


之前做过这个,但我不明白为什么它是未定义的。很明显你在事后进行无限递归。 - Robert Gould
问题在于编译器无法检查您的代码并准确地决定它是否会遭受无限递归的问题。这是停机问题的一个实例。请参见:https://dev59.com/qnVC5IYBdhLWcg3wnCj6 - Daniel Earwicker
是的,这绝对是一个停机问题。 - Robert Gould
由于内存不足引起的交换导致系统崩溃。 - Johannes Schaub - litb
2
预处理器常量如果不适合int类型也是编译时的。 - Joshua

5
除了未定义行为之外,还有同样令人讨厌的实现定义行为
当程序执行某些操作其结果未被标准规定时,就会发生未定义行为。
实现定义行为是指程序执行某些操作其结果未被标准规定,但实现必须进行文档记录。例如,“多字节字符文字”来自Stack Overflow问题Is there a C compiler that fails to compile this?
只有在开始移植时(但升级到新版本的编译器也是移植!)才会遇到实现定义行为的问题。

5

使用const_cast<>去除常量属性后将值赋给常量:

const int i = 10; 
int *p =  const_cast<int*>( &i );
*p = 1234; //Undefined

4

在表达式中,变量只能更新一次(在技术上,在序列点之间只能更新一次)。

int i =1;
i = ++i;

// Undefined. Assignment to 'i' twice in the same expression.

实际上,在两个序列点之间至少一次。 - Prasoon Saurav
2
@Prasoon:我想你的意思是:两个序列点之间最多只出现一次。 :-) - Nawaz

3
基本了解各种环境限制。完整列表在C规范的5.2.4.1节中。以下是其中的几个:
  • 一个函数定义中最多有127个参数
  • 一个函数调用中最多有127个参数
  • 一个宏定义中最多有127个参数
  • 一个宏调用中最多有127个参数
  • 逻辑源代码行最多有4095个字符
  • 字符串文字或宽字符串文字(在连接后)最多有4095个字符
  • 一个对象最多有65535字节(仅在托管环境中)
  • #include文件的嵌套级别最多为15
  • switch语句的case标签最多为1023个(不包括任何嵌套switch语句的标签)
我对switch语句的1023个case标签的限制感到有些惊讶,我可以预见这很容易被生成的代码/lex/parsers超过。
如果超过这些限制,就会出现未定义的行为(崩溃、安全漏洞等)。
我知道这是来自C规范,但C++也共享这些基本支持。

9
如果你达到了这些限制,那么你面临的问题比未定义行为还要多。 - new123456
你可以很容易地超过65535字节的对象大小,例如STD::vector。 - Demi

2

使用memcpy函数在重叠的内存区域之间进行复制。例如:

char a[256] = {};
memcpy(a, a, sizeof(a));

根据C标准,这种行为是未定义的,这个标准被C++03标准所包含。

7.21.2.1 memcpy函数

概要

1/ #include void *memcpy(void * restrict s1, const void * restrict s2, size_t n);

描述

2/ memcpy函数将n个字符从s2指向的对象复制到s1指向的对象中。如果在重叠的对象之间进行复制,则行为是未定义的。返回值:3 memcpy函数返回s1的值。

7.21.2.2 memmove函数

概要

1 #include void *memmove(void *s1, const void *s2, size_t n);

描述

2 memmove函数将n个字符从s2指向的对象复制到s1指向的对象中。复制的过程就好像首先将来自s2指向的对象的n个字符复制到一个不重叠于s1和s2指向的对象的临时数组中,然后再将来自临时数组的n个字符复制到s1指向的对象中。返回值:3 memmove函数返回s1的值。


2

不同编译单元中的命名空间级对象不应该彼此依赖进行初始化,因为它们的初始化顺序是未定义的。


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