在带有形参的函数中,调用者可能传入语法合法而语义无意义的实参。例如,在上一课(9.4 — 错误检测与处理)中,我们给出了如下示例函数:
void printDivision(int x, int y)
{
if (y != 0)
std::cout << static_cast<double>(x) / y;
else
std::cerr << "Error: Could not divide by zero\n";
}
该函数显式检查 y 是否为 0,因为除以零属于语义错误,一旦执行将导致程序崩溃。
在前一课中,我们讨论了几种应对策略,如终止程序或跳过出错语句。
然而这两种做法都有问题:
- 若因错误而跳过语句,程序实质上在静默失败。尤其在编写和调试阶段,静默失败极不可取,因为它掩盖了真正的问题。即便打印错误信息,该信息也可能淹没在大量输出中,且难以判断其来源及触发条件。某些函数可能被调用数十或上百次,若仅有一次出错,定位十分困难。
- 若通过
std::exit
终止程序,调用栈及调试信息会丢失,不利于定位问题。std::abort
在此情境下更佳,因为通常调试器会在程序中止处暂停,方便开发者继续调试。
前置条件、不变量与后置条件
- 前置条件:某段代码(通常是函数体)执行前必须为真的条件。上例中检查
y != 0
即属于前置条件,保证除法前 y 非零。
最佳做法是将前置条件置于函数开头,并提前返回:
void printDivision(int x, int y)
{
if (y == 0) // 处理
{
std::cerr << "Error: Could not divide by zero\n";
return; // 立即返回调用者
}
// 此时已知 y != 0
std::cout << static_cast<double>(x) / y;
}
可选阅读
这种方式常被称为“门卫模式”(bouncer pattern):一旦检测到错误立即被“请出”函数。
其两大优点:
- 所有检查集中在入口,错误处理与判断相邻;
- 减少嵌套层级。
非门卫模式示例:
void printDivision(int x, int y)
{
if (y != 0)
{
std::cout << static_cast<double>(x) / y;
}
else
{
std::cerr << "Error: Could not divide by zero\n";
return; // 返回调用者
}
}
该版本更差:判断与处理分离,嵌套更深。
- 不变量:某段代码执行期间必须保持为真的条件。常用于循环,循环体仅在不变量成立时执行。
进阶读者可参阅 14.2 – 类的简介 中关于“类不变量”的讨论。
- 后置条件:某段代码执行后必须为真的条件。本函数无后置条件。
断言(assertions)
使用条件语句检测非法参数(或验证其他假设),再配合错误信息输出与程序终止,是极为常见的错误检测方式,因此 C++ 提供了快捷方法。
断言 是一个表达式,除非程序存在 bug,否则其值应为真。若表达式为真,断言语句不做任何事;若为假,则打印错误信息并调用 std::abort
终止程序。错误信息通常包括失败的表达式文本、源文件名及行号,从而能迅速定位问题,极大便利调试。
关键洞察
断言用于开发调试阶段发现错误。
断言触发时程序立即中止,可借助调试器查看程序状态并快速找出失败原因。若无断言,错误可能延迟暴露,届时定位根因将极为困难。
C++ 中的运行时断言通过预处理宏 assert
实现,定义于 <cassert>
头文件。
#include <cassert> // assert()
#include <cmath> // std::sqrt
#include <iostream>
double calculateTimeUntilObjectHitsGround(double initialHeight, double gravity)
{
assert(gravity > 0.0); // 若无正重力,物体不会落地
if (initialHeight <= 0.0)
{
// 物体已在地面或地下
return 0.0;
}
return std::sqrt((2.0 * initialHeight) / gravity);
}
int main()
{
std::cout << "Took "
<< calculateTimeUntilObjectHitsGround(100.0, -9.8)
<< " second(s)\n";
return 0;
}
当程序调用 calculateTimeUntilObjectHitsGround(100.0, -9.8)
时,assert(gravity > 0.0)
为假,断言触发,输出类似:
dropsimulator: src/main.cpp:6: double calculateTimeUntilObjectHitsGround(double, double): Assertion 'gravity > 0.0' failed.
(具体格式取决于编译器。)
断言不仅用于验证函数参数,也可在任何需要确认某条件为真的地方使用。
尽管此前建议避免预处理宏,但 assert
是少数被认为可接受的宏之一。请大胆地在代码中广泛使用断言。
关键洞察
断言优于注释:既文档化又强制条件。注释可能因代码变更而陈旧;而失效的断言会导致程序错误,因此开发者更倾向于及时修正。
让断言信息更具描述性
有时断言表达式本身描述性不足。例如:
assert(found);
若触发,输出:
Assertion failed: found, file C:\VCProjects\Test.cpp, line 34
难以理解。可在表达式后逻辑与字符串字面量:
assert(found && "Car could not be found in database");
原理:字符串字面量为真,逻辑与不影响真假;触发时字符串会出现在错误信息中:
Assertion failed: found && "Car could not be found in database", file C:\VCProjects\Test.cpp, line 34
从而提供额外上下文。
用断言标记未实现功能
断言还用于标记当时未实现、但未来可能需要的分支:
assert(moved && "Need to handle case where student was just moved to another classroom");
若后续遇到此情况,程序将以有用信息失败,提示开发者实现该分支。
NDEBUG
每次检查断言会带来微小性能开销;理想情况下,生产版本不应触发断言(因已充分测试)。因此多数开发者希望仅调试版本启用断言。C++ 提供内置机制:若预处理宏 NDEBUG
被定义,则 assert
失效。
大多数 IDE 在发布配置中默认定义 NDEBUG
。例如在 Visual Studio 的项目级预处理器定义中包含 WIN32;NDEBUG;_CONSOLE
。若希望在发布版触发断言,需移除 NDEBUG
。
若所用 IDE/构建系统未自动设置,请手动在项目或编译选项中添加。
提示
为测试,可在某个翻译单元手动启用/禁用断言:
在任何#include
之前单行写上#define NDEBUG
(禁用)或#undef NDEBUG
(启用)。行尾不要分号。
例:
#define NDEBUG // 禁用断言(必须位于任何 #include 之前)
#include <cassert>
#include <iostream>
int main()
{
assert(false); // 不会触发
std::cout << "Hello, world!\n";
return 0;
}
静态断言(static_assert)
C++ 还提供另一种断言:static_assert
。它在编译期检查,若失败则产生编译错误。与 assert
不同,static_assert
是关键字,无需头文件。
语法:
static_assert(condition, diagnostic_message)
条件不成立时,编译器输出诊断信息。示例:
static_assert(sizeof(long) == 8, "long must be 8 bytes");
static_assert(sizeof(int) >= 4, "int must be at least 4 bytes");
int main()
{
return 0;
}
在作者机器上编译报错:
1>c:\consoleapplication1\main.cpp(19): error C2338: long must be 8 bytes
须知:
- 因由编译器求值,条件必须是常量表达式。
- 可置于代码文件任意位置,包括全局命名空间。
- 发布版不会禁用
static_assert
。 - 无运行时开销。
- C++17 起,诊断信息可省略。
最佳实践
能使用static_assert
时优先于assert()
。
断言与错误处理
断言与错误处理目的相近,易混淆,现予以澄清。
- 断言:用于开发阶段检测编程错误,记录“绝不应发生”的假设;若发生,则是程序员过失。断言不允许错误恢复(既然绝不应发生,就无需恢复)。因断言通常在发布版被剔除,可大量使用而不必担心性能。
- 错误处理:用于在发布版中优雅应对可能(即使罕见)出现的状况。可能是可恢复问题(程序继续运行),或不可恢复问题(程序退出,但至少给出友好提示并妥善清理)。错误检测与处理既有运行时开销,也有开发成本。
有时界限模糊。例如:
double getInverse(double x)
{
return 1.0 / x;
}
若 x
为 0.0,函数行为异常,需要防护。应使用断言还是错误处理?最佳答案是“两者”。
- 调试时若
x
为 0.0,说明代码有 bug,需立即知晓,故断言恰当。 - 发布版也可能合理出现 0.0(例如未经测试的路径)。若断言被剔除且无错误处理,函数会返回意外值并继续出错,此时应检测并处理。
最终函数可写成:
double getInverse(double x)
{
assert(x != 0.0);
if (x == 0.0)
// 以某种方式处理错误(如抛异常)
return 1.0 / x;
}
提示
综上建议:
- 用断言检测编程错误、错误假设或“在正确代码中绝不应出现”的条件——程序员需修复,应尽早发现。
- 用错误处理应对程序正常运行中预期会出现的问题。
- 对“理论上不应出现,但万一出现需优雅失败”的情况,可两者并用。
断言的局限与注意点
- 断言本身可能写错,导致误报或漏报。
- 断言语句不得有副作用,因为在定义
NDEBUG
时表达式不会被求值,否则调试版与发布版行为将不一致。 abort()
立即终止程序,不给清理机会(如关闭文件、数据库)。因此断言仅应用于意外终止不会造成数据损坏的场景。