$ cat ~ / posts /c++ /oop7 5.1k Words ~ 20 Mins
cover.png
面向对象程序设计07

#面向对象程序设计07

exdoubled Lv5

错误从哪里来

用 Fibonacci 数列作为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
int fib(int n) {
int prev = 0;
int cur = 1;

while (n > 0) {
int next = prev + cur;
prev = cur;
cur = next;
--n;
}

return prev;
}

这段代码在小范围内看起来没有问题,比如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <limits>
#include <stdexcept>

int fib(int n) {
if (n < 0) {
throw std::invalid_argument("fib: n must be non-negative");
}

int prev = 0;
int cur = 1;

while (n > 0) {
if (cur > std::numeric_limits<int>::max() - prev) {
throw std::overflow_error("fib: result overflows int");
}

int next = prev + cur;
prev = cur;
cur = next;
--n;
}

return prev;
}

这里做了两类检查:入口处检查参数是否属于定义域;计算过程中检查结果是否会超出值域

错误码、断言与异常

C++ 中常见的错误处理方式有三类:错误码、断言和异常,适用于不同场景

错误码

错误码:函数不直接返回结果,而是返回一个状态码;真正的结果通过引用、指针或输出参数传出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
enum class FibError {
Ok,
NegativeInput,
Overflow
};

FibError fib(int n, int& result) {
if (n < 0) {
return FibError::NegativeInput;
}

int prev = 0;
int cur = 1;

while (n > 0) {
if (cur > std::numeric_limits<int>::max() - prev) {
return FibError::Overflow;
}

int next = prev + cur;
prev = cur;
cur = next;
--n;
}

result = prev;
return FibError::Ok;
}

错误码的优点是控制流非常显式,调用者一眼能看到每一步是否成功,很多对异常有严格限制的工程环境也会使用它

它的缺点是错误处理代码会和正常逻辑混在一起,而且调用者很容易忘记检查返回值:

1
2
3
4
5
int value = 0;
auto err = fib(50, value);
if (err != FibError::Ok) {
// 处理错误
}

如果每一层函数都要手工判断错误码、再把错误码继续往上传,代码会变得啰嗦

部分工业代码为了性能和可预测性会禁用异常,转而使用错误码或类似 expected 的结构;但这不意味着错误处理可以省略,只是换了一种表达形式

断言

断言用于检查程序员自己的假设,典型场景是内部不变量或调试期检查

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <cassert>

class Stack {
public:
int top() const {
assert(size_ > 0);
return data_[size_ - 1];
}

private:
int data_[100]{};
int size_ = 0;
};

这里的 assert(size_ > 0) 表示:按照这个类的使用约定,调用 top() 时栈不应该为空

断言失败说明程序内部逻辑有 bug,应该在开发阶段暴露出来

断言不适合处理用户输入错误、文件打不开、网络超时这类运行期错误,因为发布版本可能会关闭断言;即使不关闭,断言失败通常也是直接终止程序,而不是给用户一个可恢复的错误路径

异常

异常适合处理“当前函数无法在本地恢复,但上层调用者可能知道如何处理”的错误

比如解析配置文件失败、申请内存失败、打开文件失败、参数不符合公开接口要求等

异常机制由三个关键字组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <stdexcept>

int parse_age(int age) {
if (age < 0 || age > 150) {
throw std::out_of_range("age is out of range");
}
return age;
}

int main() {
try {
int age = parse_age(200);
std::cout << age << '\n';
} catch (const std::out_of_range& e) {
std::cerr << "invalid age: " << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "error: " << e.what() << '\n';
}
}

throw 创建并抛出异常对象;try 标出可能发生异常的代码范围;catch 根据异常类型匹配处理逻辑

通常捕获标准异常时使用 const std::exception& 或更具体的引用类型,避免对象切片

异常的重要优点是把正常逻辑和错误逻辑分开:正常路径可以专注于“事情应该怎样做”;失败路径通过异常传播到合适的层次集中处理。一个函数不必知道错误最终该展示给用户、写入日志还是转化成接口返回值,它只需要在无法完成承诺时抛出合适的异常

但异常不应该用来处理普通分支,比如循环中用异常表示“找到了某个元素”、用异常代替 if 判断,都属于滥用

异常应当表示异常情况,而不是正常控制流

异常传播与栈展开

异常不是简单的 goto

当一个函数抛出异常而本层没有匹配的 catch 时,异常会沿调用栈向上传播,传播过程中,C++ 会销毁已经构造完成的局部对象,这个过程称为栈展开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <stdexcept>
#include <string>

struct Trace {
explicit Trace(std::string name) : name(std::move(name)) {
std::cout << "construct " << this->name << '\n';
}

~Trace() {
std::cout << "destroy " << name << '\n';
}

std::string name;
};

void g() {
Trace t("g local");
throw std::runtime_error("failed in g");
}

void f() {
Trace t("f local");
g();
}

int main() {
try {
f();
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}

g() 抛出异常后,g local 会先析构,然后回到 f() 的栈帧,f local 也会析构,最后异常到达 main() 中匹配的 catch。这就是异常能够和资源管理配合的基础

如果异常一路传播到最外层都没有被捕获,程序会调用 std::terminate 终止,此时行为不再是一个可控的恢复过程。因此实际程序通常会在较高层

例如 main()、线程入口函数、请求处理入口处,放一个兜底的 try-catch,把未处理异常转化成日志、错误响应或可理解的退出信息

1
2
3
4
5
6
7
8
9
10
11
int main() {
try {
// 程序主体
} catch (const std::exception& e) {
std::cerr << "fatal error: " << e.what() << '\n';
return 1;
} catch (...) {
std::cerr << "unknown fatal error\n";
return 1;
}
}

catch (...) 可以捕获所有类型的异常,但它不应该随便吞掉错误

合理的做法是在边界层记录错误并结束,或者在需要继续传播时使用 throw; 重新抛出当前异常

RAII 与异常路径上的资源释放

异常一旦发生,普通语句会被跳过

如果资源释放依赖手工写在后面的 deletecloseunlock,异常路径就很容易泄露资源

错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
void write_file_bad(const std::string& path, const std::string& text) {
FILE* fp = std::fopen(path.c_str(), "w");
if (fp == nullptr) {
throw std::runtime_error("open failed");
}

if (std::fputs(text.c_str(), fp) < 0) {
throw std::runtime_error("write failed"); // fp 没有关闭
}

std::fclose(fp);
}

如果 fputs 失败并抛出异常,fclose 就不会执行

这类代码在正常路径上没问题,但在异常路径上会泄露文件句柄

C++ 的标准做法是 RAII:Resource Acquisition Is Initialization

资源在对象构造时获取,在对象析构时释放。由于栈展开会自动调用局部对象析构函数,所以异常路径也能释放资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <fstream>
#include <stdexcept>
#include <string>

void write_file(const std::string& path, const std::string& text) {
std::ofstream out(path);
if (!out) {
throw std::runtime_error("open failed");
}

out << text;
if (!out) {
throw std::runtime_error("write failed");
}
} // out 的析构函数自动关闭文件

同样,动态内存应优先交给 std::vectorstd::stringstd::unique_ptrstd::shared_ptr 等对象管理;互斥锁应使用 std::lock_guardstd::unique_lock 管理;文件流、容器和智能指针本质上都是 RAII 思想的具体应用

异常安全保证

异常安全讨论的是:函数执行中发生异常后,程序还剩下什么保证。课堂把它分成三个层次:不抛保证、强保证和基本保证。

不抛保证

不抛保证表示函数承诺绝不抛出异常。C++ 中可以用 noexcept 表达这个承诺

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Buffer {
public:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}

~Buffer() noexcept {
delete[] data_;
}

private:
int* data_ = nullptr;
int size_ = 0;
};

析构函数、移动构造函数、移动赋值运算符、简单的 swapget 等基础操作经常需要不抛保证

原因:这些操作常常发生在资源清理、容器扩容、异常恢复等关键路径上,如果它们自己也可能失败,程序就很难建立更高层的可靠性

如果一个被标记为 noexcept 的函数真的抛出异常,运行时会调用 std::terminate,所以只有在确实能保证不抛时才应该写 noexcept

强保证

强保证表示操作要么完全成功,要么失败后对象状态保持不变

在 C++ 中,强保证常见实现方式是“先在副本上修改,成功后再交换”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <algorithm>
#include <vector>

class IntList {
public:
void add(int value) {
std::vector<int> copy = data_; // 这里可能抛异常,但原对象还没变
copy.push_back(value); // 这里也可能抛异常
data_.swap(copy); // vector::swap 通常不抛
}

private:
std::vector<int> data_;
};

如果复制或 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
2
3
4
5
6
7
8
9
try {
// ...
} catch (const std::out_of_range& e) {
// 处理越界
} catch (const std::invalid_argument& e) {
// 处理非法参数
} catch (const std::exception& e) {
// 处理其他标准异常
}

如果先写 catch (const std::exception&),后面的 std::out_of_rangestd::invalid_argument 就没有机会匹配,因为它们都是 std::exception 的派生类

类型转换

类型转换解决的是“一个类型的值能否被当作另一个类型使用”的问题

C++ 的类型转换大致分为三类:

  • 编译器自动完成的隐式转换
  • 程序员明确写出的显式转换
  • 类通过构造函数和转换运算符定义的用户自定义转换

在函数重载中也存在过隐式转换的影响:如果有 abs(int)abs(double),但没有 abs(float),当调用 abs(1.0f) 时,编译器可能会把 float 提升为 double,再选择 abs(double)

这件事发生在编译期,是函数重载的一部分

隐式转换让代码更方便,但也可能让代码不够清楚。C++ 允许从小整数类型提升到 int,从 float 提升到 double,也允许某些数值类型之间转换。但转换不总是安全的:doubleint 会丢小数,较大的整数转较小整数可能溢出,基类和派生类之间的指针转换还涉及对象真实类型。

隐式转换与显式转换

常见的隐式转换包括:

1
2
3
4
5
6
7
short s = 10;
int i = s; // 整型提升

float f = 1.0f;
double d = f; // 浮点提升

int n = 3.14; // 允许,但丢失小数,应该避免

前两种通常比较自然,第三种虽然能编译,但语义上很危险

现代 C++ 更鼓励把可能丢信息的转换写显式:

1
2
double x = 3.14;
int n = static_cast<int>(x);

这样读代码的人至少能看到:这里确实有一次从浮点到整数的截断

初始化方式也会影响隐式转换。列表初始化会阻止很多窄化转换:

1
2
int a = 3.14;  // 可能只是警告
// int b{3.14}; // 错误:窄化转换

所以在新代码中,列表初始化有助于更早暴露类型边界问题

转换构造函数与 explicit

如果一个类有只接收一个主要参数的构造函数,它可能成为转换构造函数,让其他类型自动转换成这个类。

1
2
3
4
5
6
7
8
9
10
11
class BigInt {
public:
BigInt(int value) : value_(value) {}

private:
int value_;
};

void print(BigInt x);

print(42); // int 隐式转换成 BigInt

这种写法很方便,比如从 int 构造一个大整数对象

但也可能带来意外:调用者传了一个 int,函数却悄悄构造了一个 BigInt

如果这种转换不是非常自然,就应该加 explicit

1
2
3
4
5
6
7
8
9
10
11
12
class BigInt {
public:
explicit BigInt(int value) : value_(value) {}

private:
int value_;
};

void print(BigInt x);

// print(42); // 不允许隐式转换
print(BigInt{42}); // 调用者必须明确表达意图

explicit 的意义是把类型边界显式化

特别是资源类、句柄类、业务语义很强的类,不应该轻易允许隐式转换

转换运算符 operator T

类也可以定义“把自己转换成另一个类 型”的运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Meter {
public:
explicit Meter(double value) : value_(value) {}

explicit operator double() const {
return value_;
}

private:
double value_;
};

Meter m{3.5};
double x = static_cast<double>(m);

如果写成非 explicit operator double(),对象就可能在算术表达式、函数调用、比较表达式中自动变成 double,导致类型语义被削弱,对这类转换,通常也应该优先使用 explicit

少数例外是非常明确的布尔语义

例如智能指针可以在条件判断中表示“是否持有对象”:

1
2
3
4
std::unique_ptr<int> p = std::make_unique<int>(1);
if (p) {
// p 非空
}

这里不是把智能指针当整数使用,而是在条件上下文中表达资源是否存在

四种 C++ 风格强制转换

C 风格强转写法短,但它把多种完全不同的转换混在一起:

1
int* p = (int*)raw;

读者看不出来这里到底是普通数值转换、去掉 const、继承层次转换,还是直接按位重新解释

C++ 更推荐使用四种命名转换

static_cast

static_cast 用于编译期可检查、语义相对正常的转换

1
2
double d = 3.14;
int n = static_cast<int>(d);

它也可以用于继承层次中的向上转型和部分向下转型:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
virtual ~Base() = default;
};

class Derived : public Base {
public:
void work() {}
};

Derived d;
Base* b = static_cast<Base*>(&d); // 向上转型,安全
Derived* p = static_cast<Derived*>(b); // 向下转型,编译通过,但程序员负责保证真实类型

向上转型是把派生类指针或引用当作基类使用,天然符合“Derived is a Base”的关系

向下转型则危险得多,因为一个 Base* 指向的真实对象不一定是 `Derived``

`static_cast 不做运行期检查,错了就是未定义行为或逻辑错误

dynamic_cast

dynamic_cast 用于带运行期检查的多态类型转换,主要解决安全向下转型问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Shape {
public:
virtual ~Shape() = default;
};

class Circle : public Shape {
public:
void radius() const {}
};

void handle(Shape* shape) {
if (auto* circle = dynamic_cast<Circle*>(shape)) {
circle->radius();
}
}

如果 shape 指向的真实对象是 Circle,转换成功;否则返回 nullptr

如果转换引用类型失败,则会抛出 std::bad_cast

dynamic_cast 有一个关键前提:基类必须是多态类型,也就是至少有一个虚函数,通常基类会有虚析构函数

没有虚函数的类型没有运行期类型信息,dynamic_cast 无法完成这种检查

使用 dynamic_cast 也要克制,频繁向下转型可能说明设计上过度依赖类型判断,而不是通过虚函数、多态接口或访问者模式把行为放回对象内部

const_cast

const_cast 用于添加或移除 constvolatile 限定

1
2
3
4
5
void legacy_api(char* p);

void call_legacy(const std::string& s) {
legacy_api(const_cast<char*>(s.c_str()));
}

这类代码只在非常有限的场景下合理,例如调用一个历史遗留接口,而你确定它不会修改数据

真正危险的是对本来就是 const 的对象移除 const 后再修改:

1
2
3
const int x = 1;
int* p = const_cast<int*>(&x);
// *p = 2; // 未定义行为

const_cast 应作为在接口不完美时做边界适配

reinterpret_cast

reinterpret_cast 是最低层、最危险的转换,用于把一段比特按另一种类型解释。

1
2
std::uintptr_t addr = 0x1000;
auto* p = reinterpret_cast<int*>(addr);

它常见于系统编程、硬件寄存器、序列化、网络协议、与 C API 或操作系统接口交互等场景,普通业务代码中几乎不应该出现

它不负责保证对象真的存在,也不保证对齐、生命周期和别名规则正确

能不用就不用;必须使用时,应把它封装在很小的边界里,并用注释说明底层假设

向上转型与向下转型

面向对象程序中最常见的类型转换发生在继承层次中

向上转型是从派生类到基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Animal {
public:
virtual ~Animal() = default;
virtual void speak() const = 0;
};

class Dog : public Animal {
public:
void speak() const override {}
};

Dog dog;
Animal* animal = &dog; // 隐式向上转型

这通常是安全的,也是多态的基础

我们可以用 Animal*Animal& 管理一组不同的动物对象,再通过虚函数调用具体行为

向下转型是从基类回到派生类:

1
2
3
4
5
6
void feed(Animal* animal) {
auto* dog = dynamic_cast<Dog*>(animal);
if (dog != nullptr) {
// 只有真实对象是 Dog 时才执行
}
}

向下转型需要谨慎。它要求程序员知道对象的真实动态类型

如果只是为了调用派生类中的某个行为,可能应该重新思考接口设计:这个行为是否应该成为基类虚函数?是否应该用组合或策略对象表达差异?类型转换能解决眼前问题,但也可能暴露设计没有抽象到位

$ discussion
# Comments
waline