类的组合¶
用小模块搭建大模块,表达复杂对象“由什么构成”的关系。
什么是组合¶
在面向对象程序设计中,组合(Composition) 是一种基本的代码组织方式。 它允许一个类将另一个类的对象作为自己的数据成员,从而构建出更复杂、更高层次的对象。
组合的核心思想
组合表达的是 “has-a”(拥有) 关系:一个对象由其他对象构成。
组合关系在现实世界中随处可见:
- 一辆汽车拥有四个轮子、一个发动机、一个方向盘。
- 一台计算机包含CPU、内存、硬盘。
- 一条线段由两个端点确定。
这些例子都体现了 “整体-部分” 的关系:整体对象由部分对象构成,部分对象是整体对象的组成部分。
Point 与 Line 案例导入¶
在图形学中,线段由两个端点确定,这是组合关系的自然例子。
Point表示二维平面上的一个点。Line表示由两个Point构成的线段。- 关系:
Line拥有(has-a)两个Point对象。
Point 类的设计¶
首先,我们需要一个可复用的 Point 类,它表示二维平面上的一个点。
Point 类的声明与实现
Point 类的设计要点
- 状态:
x、y保存坐标值。 - 行为:
move()修改点的位置,show()显示点信息。 - 复用性:
Point是一个独立、完整的小对象,可以被其他类作为成员使用。 - 封装:坐标数据设为
private,通过公开接口访问。
Line 类的设计:组合的体现¶
Line 类通过两个 Point 成员表达端点,体现了组合关系。
Line 类的声明与实现
Line 类的设计要点
- 成员对象:
p1和p2都是Point类型的对象,作为Line的组成部分。 - 初始化列表:在构造函数的初始化列表中初始化成员对象,调用
Point的复制构造函数。 - 组合语义:
Line拥有Point对象,而不是继承Point。 - 行为委托:
Line的move()方法通过调用p1.move()和p2.move()实现,体现了“整体”通过“部分”完成工作。
成员对象的构造与析构
当 Line 对象被创建时,其成员对象 p1 和 p2 也会被自动构造;当 Line 对象被销毁时,p1 和 p2 也会被自动析构。组合关系下,成员对象的生命周期由外层对象管理。
组合类的构造函数设计¶
声明组合类的对象实例时,其构造函数需要负责初始化所有数据成员,既包括普通类型成员也包括对象类型成员,这通常在初始化列表中完成。
方式一:接收成员对象的参数¶
如果成员对象没有默认构造函数,或者需要传入特定参数,组合类应在初始化列表中调用成员对象的构造函数。
接收成员对象参数的构造函数
方式二:接收已有对象¶
组合类也可以接收已存在的对象作为参数,通过复制构造来初始化成员。
接收已有对象的构造函数
方式三:使用复制构造函数¶
复制构造函数用于从已有对象创建新对象。对于组合类,复制构造函数的职责是递归复制所有成员对象。
为 Line 类声明复制构造函数
组合类的复制构造函数的实现要点
- 初始化列表:在初始化列表中分别用源对象的对应成员初始化当前对象的成员。
- 依赖成员自己的复制语义:组合类的复制正确性依赖于每个成员类型的复制构造函数行为正确。
- 无需特殊处理:如果所有成员类型都支持正确的复制(基本类型或标准库类型),组合类通常不需要自定义复制构造函数。
什么时候需要为组合类自定义复制构造函数?
大多数情况下,编译器生成的缺省复制构造函数对组合类已经足够。需要自定义的场景包括:
- 成员对象不支持复制:某个成员类型显式禁用了复制(
= delete),导致组合类不可复制。 - 需要深拷贝:成员是原始指针(
T*),需要执行深拷贝而非浅拷贝。 - 需要额外的复制逻辑:复制时需要同时更新其他状态(如引用计数、日志记录等)。
方式四:使用移动构造函数¶
移动构造函数通过“转移”而非“复制”的方式初始化成员对象,可以提高性能。
使用移动构造函数的组合类
组合类中的移动语义传递
组合类的移动构造函数通过 std::move 将移动操作传递给每个成员对象。
只要每个成员类型都支持移动构造,组合类的移动构造就是自动且高效的。
构造函数设计总结¶
组合类构造函数的多种形式¶
| 方式 | 优点 | 缺点 | 关键语法 |
|---|---|---|---|
| 接收坐标参数 | 直接构造,效率高;调用者无需先构造 Point | 构造函数参数较多时可能代码繁琐 | : p1(x1, y1), p2(x2, y2) |
| 接收 Point 对象 | 接口清晰,调用者可复用已有 Point | 需要先构造 Point 对象,多一次构造 | : p1(a), p2(b) |
| 复制构造函数 | 用已有对象创建副本 | 需要先构造 Line 对象 | : p1(other.p1), p2(other.p2) |
| 移动构造函数 | 转移临时对象的资源,提升性能 | 语法相对复杂 | : p1(std::move(other.p1)), ... |
在实际设计中,通常根据使用场景选择合适的方式,也可以同时提供多种构造函数。
构造函数中的参数传递方式¶
组合类的构造函数参数可以通过多种方式传递,每种方式有不同的语义和性能特征。
| 方式 | 关键语法 | 说明 |
|---|---|---|
| 传值(会拷贝) | Line(Point a, Point b) : p1(a), p2(b) {} |
参数 a 和 b 是实参的副本,然后 用 a 和 b 再拷贝构造 p1 和 p2, 适用需要保存副本,且对象较小的场景 |
| const 引用(推荐) | Line(const Point& a, const Point& b) : p1(a), p2(b) {} |
直接引用实参来构造 p1 和 p2, 避免了一次拷贝,适用对象较大, 或构造开销较高的场景 |
| 移动语义(C++11) | Line(Point a, Point b) : p1(std::move(a)), p2(std::move(b)) {} |
配合传值参数,通过移动避免拷贝, 适用实参是临时对象,或不再需要 源对象的场景 |
最佳实践
对于组合类,通常推荐使用 const 引用传递成员对象参数,既避免了不必要的拷贝,又清晰地表达了“只读传入”的语义。
成员对象的初始化顺序¶
重要规则
组合类中,成员对象的初始化顺序由它们在类中的声明顺序决定,而不是初始化列表中的书写顺序。
初始化顺序示例
为什么顺序重要?
成员对象的初始化顺序受 C++ 语言规则约束,主要原因是:
- 确定性:无论初始化列表如何书写,析构顺序始终与初始化顺序相反,保证了析构的可预测性。
- 依赖关系:如果成员之间存在依赖(一个成员的初始化需要另一个成员的值),必须按照声明顺序设计。
最佳实践
虽然初始化列表的书写顺序不影响实际初始化顺序,但为了代码的可读性和减少误解,建议初始化列表的书写顺序与成员声明顺序保持一致。
组合的深层含义¶
生命周期管理¶
在组合关系中,成员对象的生命周期由外层对象管理:
组合的生命周期规则
- 构造:外层对象构造时,成员对象自动构造(先构造成员,再进入外层对象构造函数体)。
- 生存:成员对象随外层对象一同生存。
- 析构:外层对象析构时,成员对象自动析构(先执行外层对象析构函数体,再逆序析构成员)。
这意味着组合类不需要额外代码来管理成员对象的创建和销毁,编译器会处理一切。
访问控制¶
成员对象是外层类的私有成员,外部代码无法直接访问成员对象,必须通过外层类提供的公开接口。
访问控制示例
组合的层次结构¶
组合关系可以形成多层次的嵌套结构,大对象由小对象构成,小对象由更小的对象构成。
多层组合示例
Computer(计算机)
├── cpu: CPU(处理器)
│ ├── cores: int
│ └── frequency: double
├── memory: Memory(内存)
│ └── size: int
└── disk: Disk(硬盘)
├── type: string
└── capacity: int
每个层次的对象都独立封装自己的状态和行为,外层对象通过调用内层对象的接口来实现功能。
小结¶
类的组合是面向对象程序中最重要的代码复用方式之一,它通过“整体-部分”关系构建复杂的系统。
- 组合表达“has-a”关系:一个对象由其他对象构成,如
Line拥有两个Point。 - 成员对象的构造:在组合类的初始化列表中初始化成员对象。
- 初始化顺序:由成员声明顺序决定,而非初始化列表书写顺序。
- 生命周期管理:成员对象的构造和析构由编译器自动管理,无需额外代码。
- 设计原则:优先使用组合而非继承,可以使系统更灵活、耦合度更低。
理解组合关系是掌握面向对象设计的重要一步。在实际项目中,合理运用组合可以构建出结构清晰、易于维护的软件系统。