C++_用于大型程序的工具
用于大型程序的工具
在大型程序中,除了基本语法、类、模板、STL 之外,还需要一些更“工程化”的机制来帮助我们:
- 处理运行时错误
- 组织大量名字,避免冲突
- 表达更复杂的类层次结构
常见内容有:
- 异常处理
- 命名空间
- 多重继承与虚继承
异常处理
为什么需要异常处理
程序运行时可能出现各种错误,例如:
- 文件打不开
- 输入非法
- 内存分配失败
- 下标越界
- 类型转换失败
如果只靠返回值处理错误,会有几个问题:
- 容易遗漏检查
- 正常逻辑和错误处理混在一起
- 一层层传递错误很麻烦
因此 C++ 提供了:
异常处理机制(exception handling)
异常处理的核心思想
异常处理把“正常逻辑”和“错误处理逻辑”分离开。
基本机制:
- throw:抛出异常
- try:监控可能出错的代码
- catch:捕获并处理异常
基本语法
1 | try { |
例如:
1 | try { |
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 | try { |
如果没有任何 catch 匹配,则程序会调用:
1 | terminate() |
通常直接终止程序。
一个完整例子
1 | double divide(double a, double b) { |
标准异常类
标准库提供了一组异常类,头文件主要是:
1 |
常见异常类层次
比较常见的有:
exceptionruntime_errorrange_erroroverflow_errorunderflow_errorlogic_errorinvalid_argumentout_of_rangelength_error
what()
标准异常类通常提供:
1 | e.what() |
返回错误描述信息。
例如:
1 | catch (const exception& e) { |
两大类错误
logic_error
表示:
程序逻辑错误,理论上运行前就应能发现
例如:
invalid_argumentlength_errorout_of_range
runtime_error
表示:
只有运行时环境才能知道的错误
例如:
- 文件打不开
- 网络连接失败
- 输入数据异常
栈展开(stack unwinding)
当异常被抛出后,程序会沿调用链向上寻找匹配的 catch。
这个过程中,已经构造的局部对象会依次销毁,这叫:
栈展开
例如:
1 | void f() { |
当 throw 发生时,s 会在离开作用域时自动析构。
意义
这也是为什么 C++ 强调:
用对象管理资源(RAII)
因为发生异常时,局部对象照样会自动析构,从而自动释放资源。
异常处理与构造函数
构造函数中也可以抛出异常。
如果构造过程中出错,说明对象没有成功构造完成。
此时:
- 已经构造完成的成员会被销毁
- 尚未构造的部分不会继续构造
析构函数与异常
一个重要原则:
析构函数不应该抛出异常
因为如果程序正在处理一个异常,此时析构函数再抛出新异常,通常会导致程序直接终止。
所以复习时强记:
析构函数尽量不要抛异常
异常的重新抛出
在 catch 中可以继续把异常抛出去:
1 | catch (const exception& e) { |
注意:
1 | throw; |
表示重新抛出当前异常对象。
不要写成:
1 | throw e; |
后者可能导致拷贝,甚至丢失动态类型信息。
catch 的顺序
catch 是按顺序匹配的,因此:
派生类异常应放前面,基类异常应放后面
例如:
1 | try { |
如果把 exception 放前面,后面的 out_of_range 基本就没机会执行了。
函数 try 语句块
有时希望连构造函数初始化列表中的异常也捕获,可以使用函数 try 块:
1 | class A { |
这个属于进阶内容,复习时知道即可。
noexcept
C++11 引入了 noexcept,表示某个函数不应抛出异常。
1 | void f() noexcept; |
如果该函数真的抛出异常,程序通常会终止。
常见用途
- 析构函数通常默认不抛异常
- 移动构造/移动赋值若声明
noexcept,有助于提高容器性能
注
noexcept表示函数承诺不抛异常
异常处理小结
基本流程
- 用
throw抛出异常 - 用
try包围可能出错代码 - 用
catch捕获处理
重要规则
- 异常通常按 const 引用 捕获
catch顺序:先派生类,后基类- 析构函数不要抛异常
- 栈展开时局部对象会自动析构
throw;表示重新抛出当前异常
命名空间
在大型程序中,名字冲突是非常常见的问题。
例如不同库里可能都定义了:
printbeginvectorsize
为了避免全局命名污染,C++ 提供:
命名空间(namespace)
基本定义
1 | namespace mylib { |
使用时:
1 | mylib::print(); |
作用
命名空间的核心作用是:
把名字放到不同作用域中,避免冲突
例如:
1 | namespace A { |
调用时:
1 | A::f(); |
互不冲突。
命名空间的定义可以分离
同一个命名空间可以在多个地方分开写:
1 | namespace mylib { |
它们最终属于同一个命名空间。
这对大型项目很有用。
嵌套命名空间
1 | namespace A { |
使用:
1 | A::B::C::x |
C++17 可以写成:
1 | namespace A::B::C { |
using 声明
可以把命名空间中的某个名字引入当前作用域:
1 | using std::cout; |
这样就可以直接写:
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 | namespace { |
作用:
其中的名字只在当前源文件有效
可以把它理解为“当前文件私有”。
常用于 .cpp 文件中定义只给本文件使用的变量/函数。
命名空间与头文件
如果头文件中定义了一个库组件,通常应该放在命名空间中,例如:
1 | namespace mylib { |
这样可以避免和用户代码冲突。
ADL(了解)
函数调用时,编译器有时会根据实参类型所在命名空间自动查找函数,这叫:
参数相关查找(ADL)
例如很多运算符重载、泛型算法相关代码会涉及它。
复习时知道这个概念即可,不必展开太深。
命名空间小结
核心作用
避免名字冲突,组织大型程序代码
高频规则
- 用
namespace定义命名空间 - 用
::访问其中名字 using 声明引入单个名字using 指示引入整个命名空间- 头文件中不要随意写
using namespace ... - 未命名命名空间中的名字只在当前文件有效
多重继承
多重继承指的是:
一个派生类有多个直接基类
写法:
1 | class A {}; |
这里 C 同时继承自 A 和 B。
意义
多重继承可以把不同基类的功能组合到一个派生类中。
例如:
- 一个类既是
Window - 又是
Serializable
派生列表
多重继承时,派生列表里可以列出多个基类:
1 | class Derived : public Base1, private Base2, protected Base3 {}; |
每个基类都可有各自的继承方式。
多重继承下的构造与析构
构造顺序:
按基类在派生列表中出现的顺序构造
例如:
1 | class D : public B1, public B2 {}; |
则构造顺序是:
B1B2D
与初始化列表书写顺序无关。
析构顺序相反:
DB2B1
这是高频考点。
多重继承中的名字冲突
如果多个基类中有同名成员,派生类中直接使用可能产生二义性。
例如:
1 | class A { |
需要显式指定:
1 | c.A::f(); |
多重继承中的类型转换
若 D 同时继承 B1 和 B2,则:
D*可以转换为B1*D*可以转换为B2*
但如果路径不唯一,某些转换可能出现二义性。
菱形继承问题
这是多重继承最经典的问题。
例如:
1 | class Base { |
这形成“菱形结构”:
1 | Base |
此时 Final 中会有 两份 Base 子对象:
- 一份来自
D1 - 一份来自
D2
所以:
1 | Final f; |
这就是:
二义性 + 数据冗余
虚继承
为了解决菱形继承中“公共基类重复出现”的问题,C++ 提供:
虚继承(virtual inheritance)
基本写法
1 | class Base { |
此时 Final 中只保留 一份 Base 子对象。
虚继承的作用
虚继承的核心目的:
让继承体系中的某个公共基类在最终派生类中只保留一个共享实例
效果
现在:
1 | Final f; |
虚基类的初始化
这是虚继承中最重要的规则。
如果某个类使用了虚继承,则:
虚基类由最底层派生类负责初始化
例如:
1 | class Base { |
最终 Base 会由 Final 初始化为 100。
记住一句:
虚基类由最末层派生类初始化
虚继承下的构造顺序
若有虚基类,则构造顺序一般是:
- 先构造虚基类
- 再构造普通基类(按派生列表顺序)
- 最后构造派生类自己
析构顺序相反。
多重继承中的虚函数
多重继承下仍然遵循普通虚函数规则:
- 基类中声明为
virtual - 派生类可
override - 通过基类指针/引用调用时动态绑定
但如果多个基类有同名虚函数,设计时要更小心二义性问题。
一个典型例子
1 | class Animal { |
这里 Bird 同时具备两种接口:
AnimalFlyable
这是多重继承比较合理的用法。
多重继承的优缺点
优点
- 能同时继承多个接口/能力
- 表达能力强
- 某些设计非常自然
缺点
- 容易产生二义性
- 继承关系复杂
- 构造/析构顺序更复杂
- 菱形继承会引入重复基类问题
所以实际工程中通常:
多重继承要慎用
尤其是带状态的数据类多重继承更要小心。
工程实践上的常见建议
异常处理
- 用异常处理真正的“错误情况”
- 不要把异常当普通流程控制
- 异常尽量按
const 引用捕获 - 析构函数不要抛异常
命名空间
- 大型项目中的所有库代码最好放入命名空间
- 头文件中避免
using namespace std; .cpp文件中可适度使用局部using
多重继承
- 优先考虑接口型多重继承
- 避免不必要的复杂层次
- 出现菱形结构时考虑虚继承
- 牢记虚基类由最底层派生类初始化
易错点总结
异常应优先按 const 引用 捕获
1 | catch (const exception& e) |
catch 顺序不能乱
先具体,后一般。
析构函数不要抛异常
头文件中不要写 using namespace std;
同一命名空间可以分开定义
未命名命名空间中的名字只在当前文件有效
多重继承构造顺序看派生列表,不看初始化列表
菱形继承会导致公共基类出现多份副本
虚继承能解决菱形继承中的重复基类问题
虚基类由最底层派生类初始化
这是虚继承最重要的规则。
结论速记
异常处理
throw抛出,try监控,catch捕获
命名空间
用来组织名字,避免冲突
多重继承
一个类可以有多个直接基类
虚继承
解决菱形继承中公共基类重复出现的问题
总结
异常处理用于分离错误处理逻辑,命名空间用于组织名字避免冲突,多重继承用于组合多个基类能力,而虚继承用于解决菱形继承中的公共基类重复问题。




