C++断言(assert)与静态断言(static_assert)详解

在带有形参的函数中,调用者可能传入语法合法而语义无意义的实参。例如,在上一课(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):一旦检测到错误立即被“请出”函数。
其两大优点:

  1. 所有检查集中在入口,错误处理与判断相邻;
  2. 减少嵌套层级。
    非门卫模式示例:
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;
}

提示
综上建议:

  • 用断言检测编程错误、错误假设或“在正确代码中绝不应出现”的条件——程序员需修复,应尽早发现。
  • 用错误处理应对程序正常运行中预期会出现的问题。
  • 对“理论上不应出现,但万一出现需优雅失败”的情况,可两者并用。

断言的局限与注意点

  1. 断言本身可能写错,导致误报或漏报。
  2. 断言语句不得有副作用,因为在定义 NDEBUG 时表达式不会被求值,否则调试版与发布版行为将不一致。
  3. abort() 立即终止程序,不给清理机会(如关闭文件、数据库)。因此断言仅应用于意外终止不会造成数据损坏的场景。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"