首先,有必要先了解代码违反语言规则时可能遭遇的风险,应该竭力避免每一类风险都的不良后果。
C++标准中存在被称为“不符合规范但无需诊断”(Ill-Formed, No Diagnostic Required)的构造。标准文档中多次出现“如果[...],则程序不符合规范,且不要求诊断”的表述。当代码处于IFNDR状态时,说明程序存在根本性缺陷。虽然可能引发严重后果,但编译器并不强制要求发出警告(某些情况下,编译器可能根本无法检测到问题)。
我们将在“单一定义规则(ODR)”章节中,了解违反ODR就属于IFNDR范畴。其他案例包括:全局对象在不同编译单元(不同源文件)中通过alignas声明不同的对齐要求;构造函数直接或间接地委托调用自身。
示例代码:
class X {
public:
// #0 委托给 #1,而 #1 又委托给 #0... (互相踢皮球)
X(float x) : X{ static_cast<int>(x) } { // #0
}
X(int n) : X{ n + 0.5f } { // #1
}
};
int main() {}
某些编译器可能会给出诊断信息,但这并非强制要求。这不是编译器懒惰 —— 可能无法提供诊断!务必避免编写会导致IFNDR的代码。
第1章已经讨论过未定义行为(Undefined Behavior, UB)。虽然C++开发者对于UB都很头疼,但它实际上指的是C++标准未作规定的行为。如果代码中包含UB,就根本无法预测运行时会发生什么(至少对于那些需要保持可移植性的代码而言)。典型的UB例子包括解引用空指针或未初始化指针 —— 这些操作都会导致严重问题。
对编译器而言,UB本就不应该发生(遵守语言规则的代码不会包含UB)。因此,编译器会对包含UB的代码进行“绕过式优化”,有时会产生令人惊讶的效果:可能会删除条件判断、优化掉循环等。UB的影响通常是局部的,例如在下面的代码中,虽然有一处检查确保p不为空指针后才使用*p,但至少有一处p的访问未经验证:
int g(int);
int f(int *p) {
if(p != nullptr)
return g(*p); // 没问题,p不为空
return *p; // 错误,如果p == nullptr,那么这是未定义行为(UB)
}
这段代码是有问题的(未经验证的*p访问是UB),因此编译器有权重写,实际上移除所有验证*p非空的检查。如果p是nullptr,损害已经造成,所以编译器可以假设开发者传递给函数的一定是非空指针!这种情况下,编译器完全可以将f()的整个函数体合法地重写为return g(*p),而不执行return *p语句。
UB潜藏在语言的各个角落,包括有符号整数溢出、数组越界访问、数据竞争等。目前有持续的努力来减少潜在的UB情况(有一个专门的研究小组SG12负责这项工作),但在可预见的未来,UB很可能仍然是语言的一部分,需要时刻保持警惕。
标准中的某些部分属于实现定义行为(implementation-defined behavior)范畴,即特定平台可保证,但无法跨平台移植的行为。这类行为由具体实现平台负责文档化说明,但不保证在其他平台上表现一致。
实现定义行为存在于诸多场景,包括但不限于:实现定义的各类限制:嵌套括号的最大层数、switch语句中case标签的最大数量、对象实际大小、constexpr函数的递归调用深度、字节位数等;其他典型案例如int类型占用的字节数、char类型是有符号还是无符号整型等。
实现定义行为本身并非问题根源,但若在追求代码可移植性的同时又依赖非便携假设,就可能引发隐患。建议通过static_assert在编译期验证假设(若条件允许),或辅以运行时检查机制,从而在造成不可逆影响前发现平台兼容性问题。例如:
int main() {
// 代码假设整型(int)是四个字节宽,这是一个不可移植的假设
static_assert(sizeof(int)==4);
// 仅当条件为true时才进行编译...
}
除非确信代码无需移植到其他平台,否则应尽量减少对实现定义行为的依赖。若必须依赖,务必通过static_assert(首选)或运行时检查进行验证,并详细记录相关假设。
实现定义行为虽不具备可移植性,但至少会在特定平台的文档中明确说明;而未明确行为(unspecified behavior)则不同 —— 即使程序格式正确且数据无误,其具体表现仍取决于实现方式,且实现方无需提供文档说明。
典型的未明确行为包括:移动后对象的状态(标准仅要求处于“有效但未明确”的状态,严格来说属于状态未明确而非行为未明确);函数调用中子表达式的求值顺序(如f(g(),h())中g()与h()的调用顺序);新分配内存块中的初始值等。
最后这个例子尤其值得关注:调试版本可能会用特定比特模式填充新分配内存以辅助调试,而使用相同工具集的优化版本为提高速度,可能保留内存分配时的原始比特状态不作初始化。这种差异正体现了未明确行为的特性 —— 实现方有权根据需求选择不同策略,且无需对外声明。
单一定义规则(ODR)的核心要义可简单概括为:在同一个翻译单元(translation unit)内,每个“实体”(函数、作用域内的对象、枚举、模板等)只能有一个定义,但允许存在多个声明。请看下例:
int f(int); // 声名
int f(int n); // 没问题,再次声名
int f(int m) { return m; } // 没问题,函数定义
// int f(int) { return 3; } // 不正确,违反了ODR原则
C++中需要避免违反ODR,这些“错误”可能逃过编译器检查,演变成IFNDR(不符合规范但无需诊断)的情况。由于源文件独立编译的特性,若头文件中包含非内联函数的定义,每个包含该头文件的源文件都会复制这个定义。此时可能出现:每个编译单元都能通过编译;链接时可能检测到重复定义错误;更危险的是可能根本不被察觉,从而导致运行时不可预测的行为。
C++持续的安全相关研究催生了对一种新型“问题行为”的讨论,目前暂命名为错误行为(erroneous behavior)。这一新类别旨在覆盖过去视为未定义行为(UB)、但如今能够提供诊断和明确定义行为的场景。虽然这些行为本质上仍是错误的,但错误行为在某种程度上为后果设定了边界。需要注意的是,截至本文撰写时,关于错误行为的工作仍在进行中,这一新特性可能会纳入C++26标准。
错误行为的典型应用场景包括:读取未初始化变量,出于安全考虑,实现可提供固定的比特值作为读取结果,同时鼓励实现方对这种概念性错误发出诊断;非void赋值操作符中忘记返回值。
在梳理完这几大类问题行为后,接下来将深入探讨那些容易引发问题的语言特性。