#面向对象程序设计07
错误从哪里来
用 Fibonacci 数列作为例子:
1 | int fib(int n) { |
这段代码在小范围内看起来没有问题,比如 fib(0)、fib(1)、fib(10) 都能得到合理结果
但:它的参数 n 可以取任意整数吗?返回值 int 可以容纳所有 Fibonacci 数吗?
答案都是否定的
n 表示第几项,它的定义域至少应该是 n >= 0,如果调用 fib(-1),循环不会执行,函数会返回一个看似合法的数值,但这个返回值和“第 -1 项 Fibonacci 数”没有任何数学意义
返回值也有问题。通常 int 是 32 位有符号整数,范围大约是 \(-2^{31}\) 到 \(2^{31}-1\),Fibonacci 数增长很快,到了第 47 项附近就会超出 32 位有符号整数的范围。整数溢出之后,结果可能变成负数或其他错误值。此时程序已经违反了函数承诺。
因此,函数设计不能只写计算过程,还要写边界意识:
1 |
|
这里做了两类检查:入口处检查参数是否属于定义域;计算过程中检查结果是否会超出值域
错误码、断言与异常
C++ 中常见的错误处理方式有三类:错误码、断言和异常,适用于不同场景
错误码
错误码:函数不直接返回结果,而是返回一个状态码;真正的结果通过引用、指针或输出参数传出
1 | enum class FibError { |
错误码的优点是控制流非常显式,调用者一眼能看到每一步是否成功,很多对异常有严格限制的工程环境也会使用它
它的缺点是错误处理代码会和正常逻辑混在一起,而且调用者很容易忘记检查返回值:
1 | int value = 0; |
如果每一层函数都要手工判断错误码、再把错误码继续往上传,代码会变得啰嗦
部分工业代码为了性能和可预测性会禁用异常,转而使用错误码或类似 expected 的结构;但这不意味着错误处理可以省略,只是换了一种表达形式
断言
断言用于检查程序员自己的假设,典型场景是内部不变量或调试期检查
1 |
|
这里的 assert(size_ > 0) 表示:按照这个类的使用约定,调用 top() 时栈不应该为空
断言失败说明程序内部逻辑有 bug,应该在开发阶段暴露出来
断言不适合处理用户输入错误、文件打不开、网络超时这类运行期错误,因为发布版本可能会关闭断言;即使不关闭,断言失败通常也是直接终止程序,而不是给用户一个可恢复的错误路径
异常
异常适合处理“当前函数无法在本地恢复,但上层调用者可能知道如何处理”的错误
比如解析配置文件失败、申请内存失败、打开文件失败、参数不符合公开接口要求等
异常机制由三个关键字组成:
1 |
|
throw 创建并抛出异常对象;try 标出可能发生异常的代码范围;catch 根据异常类型匹配处理逻辑
通常捕获标准异常时使用 const std::exception& 或更具体的引用类型,避免对象切片
异常的重要优点是把正常逻辑和错误逻辑分开:正常路径可以专注于“事情应该怎样做”;失败路径通过异常传播到合适的层次集中处理。一个函数不必知道错误最终该展示给用户、写入日志还是转化成接口返回值,它只需要在无法完成承诺时抛出合适的异常
但异常不应该用来处理普通分支,比如循环中用异常表示“找到了某个元素”、用异常代替 if 判断,都属于滥用
异常应当表示异常情况,而不是正常控制流
异常传播与栈展开
异常不是简单的 goto
当一个函数抛出异常而本层没有匹配的 catch 时,异常会沿调用栈向上传播,传播过程中,C++ 会销毁已经构造完成的局部对象,这个过程称为栈展开
1 |
|
当 g() 抛出异常后,g local 会先析构,然后回到 f() 的栈帧,f local 也会析构,最后异常到达 main() 中匹配的 catch。这就是异常能够和资源管理配合的基础
如果异常一路传播到最外层都没有被捕获,程序会调用 std::terminate 终止,此时行为不再是一个可控的恢复过程。因此实际程序通常会在较高层
例如 main()、线程入口函数、请求处理入口处,放一个兜底的 try-catch,把未处理异常转化成日志、错误响应或可理解的退出信息
1 | int main() { |
catch (...) 可以捕获所有类型的异常,但它不应该随便吞掉错误
合理的做法是在边界层记录错误并结束,或者在需要继续传播时使用 throw; 重新抛出当前异常
RAII 与异常路径上的资源释放
异常一旦发生,普通语句会被跳过
如果资源释放依赖手工写在后面的 delete、close、unlock,异常路径就很容易泄露资源
错误示例:
1 | void write_file_bad(const std::string& path, const std::string& text) { |
如果 fputs 失败并抛出异常,fclose 就不会执行
这类代码在正常路径上没问题,但在异常路径上会泄露文件句柄
C++ 的标准做法是 RAII:Resource Acquisition Is Initialization
资源在对象构造时获取,在对象析构时释放。由于栈展开会自动调用局部对象析构函数,所以异常路径也能释放资源
1 |
|
同样,动态内存应优先交给 std::vector、std::string、std::unique_ptr、std::shared_ptr 等对象管理;互斥锁应使用 std::lock_guard 或 std::unique_lock 管理;文件流、容器和智能指针本质上都是 RAII 思想的具体应用
异常安全保证
异常安全讨论的是:函数执行中发生异常后,程序还剩下什么保证。课堂把它分成三个层次:不抛保证、强保证和基本保证。
不抛保证
不抛保证表示函数承诺绝不抛出异常。C++ 中可以用 noexcept 表达这个承诺
1 | class Buffer { |
析构函数、移动构造函数、移动赋值运算符、简单的 swap、get 等基础操作经常需要不抛保证
原因:这些操作常常发生在资源清理、容器扩容、异常恢复等关键路径上,如果它们自己也可能失败,程序就很难建立更高层的可靠性
如果一个被标记为 noexcept 的函数真的抛出异常,运行时会调用 std::terminate,所以只有在确实能保证不抛时才应该写 noexcept
强保证
强保证表示操作要么完全成功,要么失败后对象状态保持不变
在 C++ 中,强保证常见实现方式是“先在副本上修改,成功后再交换”
1 |
|
如果复制或 push_back 失败,异常会向上传播,原来的 data_ 没有被修改
如果所有操作成功,最后通过不抛的 swap 一次性提交新状态,这就是 copy-and-swap 思路
另一种思路是事务日志:修改前先记录旧状态,失败时根据日志回滚
数据库、文件系统、支付系统之所以重视日志,就是因为不能接受“做到一半坏掉以后无法恢复”的状态
基本保证
基本保证表示函数抛出异常后不会泄露资源,对象仍处于有效状态,但状态不一定回到调用前
比如容器插入失败后,容器仍然可以析构、可以继续使用,但里面的元素顺序或容量可能已经变化
基本保证是异常安全的底线。它通常依赖 RAII、标准库容器、智能指针和清晰的所有权设计来实现
实际工程中,默认至少要提供基本保证;涉及钱、库存、订单状态、持久化数据等关键业务时,应尽量设计强保证或事务机制;对析构、移动、释放资源等基础操作,则应追求不抛保证
标准异常类型与捕获顺序
C++ 标准库提供了一组常用异常类型:
std::invalid_argument:参数不满足函数定义域。std::out_of_range:下标或访问范围越界。std::overflow_error:算术结果溢出。std::runtime_error:运行期错误,常用于文件、网络、外部环境失败。std::logic_error:程序逻辑错误的基类。std::bad_alloc:内存分配失败。
捕获异常时要从具体到一般:
1 | try { |
如果先写 catch (const std::exception&),后面的 std::out_of_range 和 std::invalid_argument 就没有机会匹配,因为它们都是 std::exception 的派生类
类型转换
类型转换解决的是“一个类型的值能否被当作另一个类型使用”的问题
C++ 的类型转换大致分为三类:
- 编译器自动完成的隐式转换
- 程序员明确写出的显式转换
- 类通过构造函数和转换运算符定义的用户自定义转换
在函数重载中也存在过隐式转换的影响:如果有 abs(int) 和 abs(double),但没有 abs(float),当调用 abs(1.0f) 时,编译器可能会把 float 提升为 double,再选择 abs(double)
这件事发生在编译期,是函数重载的一部分
隐式转换让代码更方便,但也可能让代码不够清楚。C++ 允许从小整数类型提升到 int,从 float 提升到 double,也允许某些数值类型之间转换。但转换不总是安全的:double 转 int 会丢小数,较大的整数转较小整数可能溢出,基类和派生类之间的指针转换还涉及对象真实类型。
隐式转换与显式转换
常见的隐式转换包括:
1 | short s = 10; |
前两种通常比较自然,第三种虽然能编译,但语义上很危险
现代 C++ 更鼓励把可能丢信息的转换写显式:
1 | double x = 3.14; |
这样读代码的人至少能看到:这里确实有一次从浮点到整数的截断
初始化方式也会影响隐式转换。列表初始化会阻止很多窄化转换:
1 | int a = 3.14; // 可能只是警告 |
所以在新代码中,列表初始化有助于更早暴露类型边界问题
转换构造函数与 explicit
如果一个类有只接收一个主要参数的构造函数,它可能成为转换构造函数,让其他类型自动转换成这个类。
1 | class BigInt { |
这种写法很方便,比如从 int 构造一个大整数对象
但也可能带来意外:调用者传了一个 int,函数却悄悄构造了一个 BigInt
如果这种转换不是非常自然,就应该加 explicit
1 | class BigInt { |
explicit 的意义是把类型边界显式化
特别是资源类、句柄类、业务语义很强的类,不应该轻易允许隐式转换
转换运算符 operator T
类也可以定义“把自己转换成另一个类 型”的运算符:
1 | class Meter { |
如果写成非 explicit operator double(),对象就可能在算术表达式、函数调用、比较表达式中自动变成 double,导致类型语义被削弱,对这类转换,通常也应该优先使用 explicit
少数例外是非常明确的布尔语义
例如智能指针可以在条件判断中表示“是否持有对象”:
1 | std::unique_ptr<int> p = std::make_unique<int>(1); |
这里不是把智能指针当整数使用,而是在条件上下文中表达资源是否存在
四种 C++ 风格强制转换
C 风格强转写法短,但它把多种完全不同的转换混在一起:
1 | int* p = (int*)raw; |
读者看不出来这里到底是普通数值转换、去掉 const、继承层次转换,还是直接按位重新解释
C++ 更推荐使用四种命名转换
static_cast
static_cast 用于编译期可检查、语义相对正常的转换
1 | double d = 3.14; |
它也可以用于继承层次中的向上转型和部分向下转型:
1 | class Base { |
向上转型是把派生类指针或引用当作基类使用,天然符合“Derived is a Base”的关系
向下转型则危险得多,因为一个 Base* 指向的真实对象不一定是 `Derived``
`static_cast 不做运行期检查,错了就是未定义行为或逻辑错误
dynamic_cast
dynamic_cast 用于带运行期检查的多态类型转换,主要解决安全向下转型问题
1 | class Shape { |
如果 shape 指向的真实对象是 Circle,转换成功;否则返回 nullptr
如果转换引用类型失败,则会抛出 std::bad_cast
dynamic_cast 有一个关键前提:基类必须是多态类型,也就是至少有一个虚函数,通常基类会有虚析构函数
没有虚函数的类型没有运行期类型信息,dynamic_cast 无法完成这种检查
使用 dynamic_cast 也要克制,频繁向下转型可能说明设计上过度依赖类型判断,而不是通过虚函数、多态接口或访问者模式把行为放回对象内部
const_cast
const_cast 用于添加或移除 const、volatile 限定
1 | void legacy_api(char* p); |
这类代码只在非常有限的场景下合理,例如调用一个历史遗留接口,而你确定它不会修改数据
真正危险的是对本来就是 const 的对象移除 const 后再修改:
1 | const int x = 1; |
const_cast 应作为在接口不完美时做边界适配
reinterpret_cast
reinterpret_cast 是最低层、最危险的转换,用于把一段比特按另一种类型解释。
1 | std::uintptr_t addr = 0x1000; |
它常见于系统编程、硬件寄存器、序列化、网络协议、与 C API 或操作系统接口交互等场景,普通业务代码中几乎不应该出现
它不负责保证对象真的存在,也不保证对齐、生命周期和别名规则正确
能不用就不用;必须使用时,应把它封装在很小的边界里,并用注释说明底层假设
向上转型与向下转型
面向对象程序中最常见的类型转换发生在继承层次中
向上转型是从派生类到基类:
1 | class Animal { |
这通常是安全的,也是多态的基础
我们可以用 Animal* 或 Animal& 管理一组不同的动物对象,再通过虚函数调用具体行为
向下转型是从基类回到派生类:
1 | void feed(Animal* animal) { |
向下转型需要谨慎。它要求程序员知道对象的真实动态类型
如果只是为了调用派生类中的某个行为,可能应该重新思考接口设计:这个行为是否应该成为基类虚函数?是否应该用组合或策略对象表达差异?类型转换能解决眼前问题,但也可能暴露设计没有抽象到位