C++标准通过定义“未定义行为”来将某些程序状态排除在语言规范之外。未定义行为意味着标准对程序的行为没有任何要求——程序可以崩溃、产生错误结果、格式化硬盘、或者(最危险的)看起来正常工作。未定义行为的存在使编译器可以进行激进的优化,但也使C++成为最难正确使用的语言之一。理解未定义行为的“地理”——哪些操作是未定义的,为什么是未定义的,以及如何避免——是成为C++专家的必经之路。
参考:https://xgmoi.cn/category/siji.html
有符号整数溢出是最常见的未定义行为之一。标准规定,有符号整数的算术运算结果如果超出类型的表示范围,行为未定义。这一规定的历史原因在于,不同的硬件对有符号溢出有不同的处理方式——有些环绕(二进制补码),有些饱和,有些触发陷阱。C++标准要保持可移植性,就必须允许所有这些行为。现代编译器利用这一规定进行优化:例如,if (x + 1 > x)总是假,因为x+1不会溢出(否则是未定义行为),因此这个if可以被完全删除。
空指针解引用是另一个经典的未定义行为。试图通过空指针访问对象,标准认为这是未定义行为。有趣的是,在某些嵌入式平台上,地址0是有效的内存区域(例如,中断向量表),空指针解引用在这些平台上可能“正常工作”。但C++标准不承认这一点,编译器可能基于“p不是空指针”的假设进行优化。例如,if (p) *p = 1;之后,编译器可以假设p非空,从而在后续代码中省略空指针检查。
除零错误(包括整数除法和取模)是未定义行为。浮点数除零是定义良好的(产生inf或NaN),因为IEEE 754标准规定了浮点异常行为。这种不一致性反映了C++对不同类型采用不同规则的现实。
参考:https://xgmoi.cn/category/xinli.html
越界数组访问是缓冲区溢出的根源。访问数组边界之外的元素是未定义行为,即使你只是读取该位置的值,且该位置恰好属于同一进程的内存。编译器可以基于“索引在边界内”的假设优化代码,这可能导致安全检查被删除。AddressSanitizer等工具可以检测越界访问,但代价是运行时开销。
违反严格别名规则是C++中最容易被误解的未定义行为之一。严格别名规则规定:只能通过对象的动态类型或与其“兼容”的类型来访问对象。例如,不能通过float读取一个int对象,也不能通过short读取一个int对象。例外包括char、unsigned char和std::byte*——它们可以别名任何类型。这一规则使编译器可以更好地优化代码,因为不同类型的指针不会指向同一内存。违反该规则的行为通常是晦涩的:例如,通过联合体进行类型双关在某些编译器中是允许的,但不是标准规定的。
未初始化的变量读取是未定义行为。局部变量如果没有显式初始化,其值是“不确定的”。读取不确定的值是未定义行为,即使该值看起来是合理的。这允许编译器假设变量总是被初始化,从而省略某些检查。更微妙的是,“不确定”并不等同于“未指定”——未指定行为每次读取可能产生不同的值,而未定义行为可以产生任何结果。
数据竞争是多线程程序中的未定义行为。如果两个线程同时访问同一内存位置,至少有一个是写操作,且没有同步机制(如互斥锁或原子操作),程序行为未定义。数据竞争可能导致撕裂读写(read-tearing)——读取到的值部分来自一个线程的写,部分来自另一个线程的写。更糟糕的是,编译器可以基于“没有数据竞争”的假设进行优化,可能导致更严重的错误。
无效的指针操作包括:解引用无效指针(已释放、未对齐、或类型不匹配)、指针运算超出数组边界(允许指向最后一个元素之后一个位置,但不能解引用)、以及将两个不同数组的指针相减(结果是未定义行为)。这些规则源于指针的底层表示——在分段内存架构中,指针不仅仅是整数,还包含段信息。虽然现代平面内存模型使这些规则看起来过时,但标准为了保持可移植性仍然保留了它们。
参考:https://xgmoi.cn/category/yundong.html
违反异常规范:在C++11之前,throw()规范被违反时调用std::unexpected;C++11之后,noexcept被违反时调用std::terminate。后者是定义良好的行为(程序终止),但通常被视为“不可恢复的错误”。注意,noexcept函数中抛出异常不会导致未定义行为,而是程序终止——这是一个明确的、可预测的结果。
递归深度超限:C++标准没有定义递归调用的最大深度。超出栈空间限制是未定义行为,通常导致栈溢出和程序崩溃。这与Java等语言不同,后者定义了StackOverflowError异常。C++的设计假设递归深度是程序员的责任。
违反前置条件:许多标准库函数有前置条件,调用者必须保证满足。例如,std::vector::operator[]要求索引小于大小,std::sort要求迭代器范围有效且随机访问。违反这些前置条件是未定义行为,标准库实现可以进行断言检查(在调试模式下),但在发布模式下通常省略检查以获得性能。
面对未定义行为的广泛存在,C++开发者可以采取以下防御措施:
启用编译器警告:-Wall -Wextra -Wpedantic可以捕获许多常见问题,如未初始化变量和有符号/无符号比较。
使用静态分析工具:Clang Static Analyzer、Coverity、PVS-Studio等工具可以检测更多类型的未定义行为。
启用Sanitizer:AddressSanitizer、UndefinedBehaviorSanitizer、ThreadSanitizer在测试和调试阶段可以捕获大多数未定义行为。
编写防御性代码:显式检查边界、使用安全的整数操作库、优先使用at()而非operator[](在需要检查时)。
理解编译器优化:意识到编译器会利用未定义行为的假设,编写代码时不要依赖未定义行为。
未定义行为是C++契约的一部分——作为开发者,你承诺不触发未定义行为;作为回报,编译器生成快速、优化的代码。打破契约的后果是未定义的。这种关系不同于大多数现代语言,它们承诺在相同情况下提供定义良好的行为(通常是异常或错误)。C++的立场反映了其系统编程的定位——信任程序员,但后果自负。
参考:https://xgmoi.cn