用于大型程序的工具

在大型程序中,除了基本语法、类、模板、STL 之外,还需要一些更“工程化”的机制来帮助我们:

  • 处理运行时错误
  • 组织大量名字,避免冲突
  • 表达更复杂的类层次结构

常见内容有:

  • 异常处理
  • 命名空间
  • 多重继承与虚继承

异常处理

为什么需要异常处理

程序运行时可能出现各种错误,例如:

  • 文件打不开
  • 输入非法
  • 内存分配失败
  • 下标越界
  • 类型转换失败

如果只靠返回值处理错误,会有几个问题:

  • 容易遗漏检查
  • 正常逻辑和错误处理混在一起
  • 一层层传递错误很麻烦

因此 C++ 提供了:

异常处理机制(exception handling)

异常处理的核心思想

异常处理把“正常逻辑”和“错误处理逻辑”分离开。

基本机制:

  • throw:抛出异常
  • try:监控可能出错的代码
  • catch:捕获并处理异常

基本语法

1
2
3
4
5
6
try {
// 可能出错的代码
}
catch (异常类型 变量) {
// 处理异常
}

例如:

1
2
3
4
5
6
try {
throw runtime_error("something wrong");
}
catch (runtime_error err) {
cout << err.what() << endl;
}

throw:抛出异常

使用 throw 抛出一个对象:

1
throw runtime_error("file open failed");

也可以抛出内置类型,但实际编程中更推荐抛出标准库异常类对象。

例如:

1
throw 42;   // 可以,但不推荐

catch:捕获异常

catch 的参数类型决定它能捕获什么异常:

1
catch (runtime_error e) { ... }

更常见、更推荐的写法是:

1
catch (const runtime_error& e) { ... }

原因:

  • 避免拷贝
  • 避免对象切片
  • 可保持多态信息

所以复习时记住:

捕获异常通常用 const 引用

异常的匹配规则

抛出的异常会按顺序与 catch 匹配。

例如:

1
2
3
4
5
6
7
8
9
try {
throw runtime_error("error");
}
catch (const logic_error& e) {
// 不匹配
}
catch (const runtime_error& e) {
// 匹配
}

如果没有任何 catch 匹配,则程序会调用:

1
terminate()

通常直接终止程序。

一个完整例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double divide(double a, double b) {
if (b == 0)
throw runtime_error("divide by zero");
return a / b;
}

int main() {
try {
cout << divide(10, 0) << endl;
}
catch (const runtime_error& e) {
cout << "error: " << e.what() << endl;
}
}

标准异常类

标准库提供了一组异常类,头文件主要是:

1
2
#include <exception>
#include <stdexcept>

常见异常类层次

比较常见的有:

  • exception
  • runtime_error
  • range_error
  • overflow_error
  • underflow_error
  • logic_error
  • invalid_argument
  • out_of_range
  • length_error

what()

标准异常类通常提供:

1
e.what()

返回错误描述信息。

例如:

1
2
3
catch (const exception& e) {
cout << e.what() << endl;
}

两大类错误

logic_error

表示:

程序逻辑错误,理论上运行前就应能发现

例如:

  • invalid_argument
  • length_error
  • out_of_range

runtime_error

表示:

只有运行时环境才能知道的错误

例如:

  • 文件打不开
  • 网络连接失败
  • 输入数据异常

栈展开(stack unwinding)

当异常被抛出后,程序会沿调用链向上寻找匹配的 catch
这个过程中,已经构造的局部对象会依次销毁,这叫:

栈展开

例如:

1
2
3
4
void f() {
string s = "hello";
throw runtime_error("err");
}

throw 发生时,s 会在离开作用域时自动析构。

意义

这也是为什么 C++ 强调:

用对象管理资源(RAII)

因为发生异常时,局部对象照样会自动析构,从而自动释放资源。

异常处理与构造函数

构造函数中也可以抛出异常。
如果构造过程中出错,说明对象没有成功构造完成。

此时:

  • 已经构造完成的成员会被销毁
  • 尚未构造的部分不会继续构造

析构函数与异常

一个重要原则:

析构函数不应该抛出异常

因为如果程序正在处理一个异常,此时析构函数再抛出新异常,通常会导致程序直接终止。

所以复习时强记:

析构函数尽量不要抛异常

异常的重新抛出

catch 中可以继续把异常抛出去:

1
2
3
4
catch (const exception& e) {
// 做一些处理
throw; // 重新抛出当前异常
}

注意:

1
throw;

表示重新抛出当前异常对象。
不要写成:

1
throw e;

后者可能导致拷贝,甚至丢失动态类型信息。

catch 的顺序

catch 是按顺序匹配的,因此:

派生类异常应放前面,基类异常应放后面

例如:

1
2
3
4
5
6
7
8
9
try {
// ...
}
catch (const out_of_range& e) {
// 先捕获更具体的类型
}
catch (const exception& e) {
// 再捕获更一般的类型
}

如果把 exception 放前面,后面的 out_of_range 基本就没机会执行了。

函数 try 语句块

有时希望连构造函数初始化列表中的异常也捕获,可以使用函数 try 块:

1
2
3
4
5
6
7
8
9
10
class A {
public:
A() try : data(init()) {
// 构造函数体
} catch (...) {
// 处理异常
}
private:
int data;
};

这个属于进阶内容,复习时知道即可。

noexcept

C++11 引入了 noexcept,表示某个函数不应抛出异常

1
void f() noexcept;

如果该函数真的抛出异常,程序通常会终止。

常见用途

  • 析构函数通常默认不抛异常
  • 移动构造/移动赋值若声明 noexcept,有助于提高容器性能

noexcept 表示函数承诺不抛异常

异常处理小结

基本流程

  • throw 抛出异常
  • try 包围可能出错代码
  • catch 捕获处理

重要规则

  • 异常通常按 const 引用 捕获
  • catch 顺序:先派生类,后基类
  • 析构函数不要抛异常
  • 栈展开时局部对象会自动析构
  • throw; 表示重新抛出当前异常

命名空间

在大型程序中,名字冲突是非常常见的问题。

例如不同库里可能都定义了:

  • print
  • begin
  • vector
  • size

为了避免全局命名污染,C++ 提供:

命名空间(namespace)

基本定义

1
2
3
4
5
6
namespace mylib {
int value = 10;
void print() {
cout << value << endl;
}
}

使用时:

1
2
mylib::print();
cout << mylib::value << endl;

作用

命名空间的核心作用是:

把名字放到不同作用域中,避免冲突

例如:

1
2
3
4
5
6
7
namespace A {
void f() {}
}

namespace B {
void f() {}
}

调用时:

1
2
A::f();
B::f();

互不冲突。

命名空间的定义可以分离

同一个命名空间可以在多个地方分开写:

1
2
3
4
5
6
7
namespace mylib {
void f();
}

namespace mylib {
void g();
}

它们最终属于同一个命名空间。

这对大型项目很有用。

嵌套命名空间

1
2
3
4
5
6
7
namespace A {
namespace B {
namespace C {
int x = 0;
}
}
}

使用:

1
A::B::C::x

C++17 可以写成:

1
2
3
namespace A::B::C {
int x = 0;
}

using 声明

可以把命名空间中的某个名字引入当前作用域:

1
2
using std::cout;
using std::endl;

这样就可以直接写:

1
cout << "hello" << endl;

using 指示

1
using namespace std;

表示把 std 中所有名字都引入当前作用域。

区别

using std::cout;

只引入一个名字

using namespace std;

引入整个命名空间里的所有名字

大型程序中的建议

在大型程序里:

不建议在头文件中写 using namespace std;

原因:

  • 污染命名空间
  • 容易造成冲突
  • 影响所有包含该头文件的源文件

这是非常重要的工程实践规则。

命名空间别名

可以给长名字起别名:

1
namespace primer = cplusplus_primer;

之后可写:

1
primer::Query q;

未命名命名空间

写法:

1
2
3
namespace {
int local = 0;
}

作用:

其中的名字只在当前源文件有效

可以把它理解为“当前文件私有”。

常用于 .cpp 文件中定义只给本文件使用的变量/函数。

命名空间与头文件

如果头文件中定义了一个库组件,通常应该放在命名空间中,例如:

1
2
3
4
5
6
namespace mylib {
class A {
public:
void f();
};
}

这样可以避免和用户代码冲突。

ADL(了解)

函数调用时,编译器有时会根据实参类型所在命名空间自动查找函数,这叫:

参数相关查找(ADL)

例如很多运算符重载、泛型算法相关代码会涉及它。

复习时知道这个概念即可,不必展开太深。

命名空间小结

核心作用

避免名字冲突,组织大型程序代码

高频规则

  • namespace 定义命名空间
  • :: 访问其中名字
  • using 声明 引入单个名字
  • using 指示 引入整个命名空间
  • 头文件中不要随意写 using namespace ...
  • 未命名命名空间中的名字只在当前文件有效

多重继承

多重继承指的是:

一个派生类有多个直接基类

写法:

1
2
3
class A {};
class B {};
class C : public A, public B {};

这里 C 同时继承自 AB

意义

多重继承可以把不同基类的功能组合到一个派生类中。

例如:

  • 一个类既是 Window
  • 又是 Serializable

派生列表

多重继承时,派生列表里可以列出多个基类:

1
class Derived : public Base1, private Base2, protected Base3 {};

每个基类都可有各自的继承方式。

多重继承下的构造与析构

构造顺序:

按基类在派生列表中出现的顺序构造

例如:

1
class D : public B1, public B2 {};

则构造顺序是:

  1. B1
  2. B2
  3. D

与初始化列表书写顺序无关。

析构顺序相反:

  1. D
  2. B2
  3. B1

这是高频考点。

多重继承中的名字冲突

如果多个基类中有同名成员,派生类中直接使用可能产生二义性。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A {
public:
void f() {}
};

class B {
public:
void f() {}
};

class C : public A, public B {};

C c;
// c.f(); // 错误:二义性

需要显式指定:

1
2
c.A::f();
c.B::f();

多重继承中的类型转换

D 同时继承 B1B2,则:

  • D* 可以转换为 B1*
  • D* 可以转换为 B2*

但如果路径不唯一,某些转换可能出现二义性。

菱形继承问题

这是多重继承最经典的问题。

例如:

1
2
3
4
5
6
7
8
class Base {
public:
int x;
};

class D1 : public Base {};
class D2 : public Base {};
class Final : public D1, public D2 {};

这形成“菱形结构”:

1
2
3
4
5
   Base
/ \
D1 D2
\ /
Final

此时 Final 中会有 两份 Base 子对象

  • 一份来自 D1
  • 一份来自 D2

所以:

1
2
Final f;
// f.x = 10; // 错误:不知道是哪个 Base::x

这就是:

二义性 + 数据冗余

虚继承

为了解决菱形继承中“公共基类重复出现”的问题,C++ 提供:

虚继承(virtual inheritance)

基本写法

1
2
3
4
5
6
7
8
class Base {
public:
int x;
};

class D1 : virtual public Base {};
class D2 : virtual public Base {};
class Final : public D1, public D2 {};

此时 Final 中只保留 一份 Base 子对象

虚继承的作用

虚继承的核心目的:

让继承体系中的某个公共基类在最终派生类中只保留一个共享实例

效果

现在:

1
2
Final f;
f.x = 10; // 正确,只有一份 Base

虚基类的初始化

这是虚继承中最重要的规则。

如果某个类使用了虚继承,则:

虚基类由最底层派生类负责初始化

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
Base(int i) : x(i) {}
int x;
};

class D1 : virtual public Base {
public:
D1() : Base(1) {} // 不起最终决定作用
};

class D2 : virtual public Base {
public:
D2() : Base(2) {}
};

class Final : public D1, public D2 {
public:
Final() : Base(100), D1(), D2() {}
};

最终 Base 会由 Final 初始化为 100

记住一句:

虚基类由最末层派生类初始化

虚继承下的构造顺序

若有虚基类,则构造顺序一般是:

  1. 先构造虚基类
  2. 再构造普通基类(按派生列表顺序)
  3. 最后构造派生类自己

析构顺序相反。

多重继承中的虚函数

多重继承下仍然遵循普通虚函数规则:

  • 基类中声明为 virtual
  • 派生类可 override
  • 通过基类指针/引用调用时动态绑定

但如果多个基类有同名虚函数,设计时要更小心二义性问题。

一个典型例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Animal {
public:
virtual void eat() { cout << "Animal eat\n"; }
virtual ~Animal() = default;
};

class Flyable {
public:
virtual void fly() { cout << "Fly\n"; }
virtual ~Flyable() = default;
};

class Bird : public Animal, public Flyable {
public:
void eat() override { cout << "Bird eat\n"; }
void fly() override { cout << "Bird fly\n"; }
};

这里 Bird 同时具备两种接口:

  • Animal
  • Flyable

这是多重继承比较合理的用法。

多重继承的优缺点

优点

  • 能同时继承多个接口/能力
  • 表达能力强
  • 某些设计非常自然

缺点

  • 容易产生二义性
  • 继承关系复杂
  • 构造/析构顺序更复杂
  • 菱形继承会引入重复基类问题

所以实际工程中通常:

多重继承要慎用

尤其是带状态的数据类多重继承更要小心。

工程实践上的常见建议

异常处理

  • 用异常处理真正的“错误情况”
  • 不要把异常当普通流程控制
  • 异常尽量按 const 引用 捕获
  • 析构函数不要抛异常

命名空间

  • 大型项目中的所有库代码最好放入命名空间
  • 头文件中避免 using namespace std;
  • .cpp 文件中可适度使用局部 using

多重继承

  • 优先考虑接口型多重继承
  • 避免不必要的复杂层次
  • 出现菱形结构时考虑虚继承
  • 牢记虚基类由最底层派生类初始化

易错点总结

异常应优先按 const 引用 捕获

1
catch (const exception& e)

catch 顺序不能乱

先具体,后一般。

析构函数不要抛异常

头文件中不要写 using namespace std;

同一命名空间可以分开定义

未命名命名空间中的名字只在当前文件有效

多重继承构造顺序看派生列表,不看初始化列表

菱形继承会导致公共基类出现多份副本

虚继承能解决菱形继承中的重复基类问题

虚基类由最底层派生类初始化

这是虚继承最重要的规则。

结论速记

异常处理

throw 抛出,try 监控,catch 捕获

命名空间

用来组织名字,避免冲突

多重继承

一个类可以有多个直接基类

虚继承

解决菱形继承中公共基类重复出现的问题


总结

异常处理用于分离错误处理逻辑,命名空间用于组织名字避免冲突,多重继承用于组合多个基类能力,而虚继承用于解决菱形继承中的公共基类重复问题。