在现实生活中,复杂对象通常由更小、更简单的对象构成。例如,一辆汽车由金属车架、发动机、轮胎、变速箱、方向盘以及大量其他零件组成;一台个人计算机由 CPU、主板、内存等部件构成;甚至你自身也由更小的部分构成:头部、躯干、四肢等。这种“由简单对象构建复杂对象”的过程称为对象组合。
广义而言,对象组合刻画了两个对象之间的 “有一个”(has-a)关系:汽车“有一个”变速箱;电脑“有一个”CPU;你“有一个”心脏。复杂对象有时被称为整体或父对象,简单对象则称为部分、子对象或组件。
在 C++ 中,结构体和类可以拥有各种类型的数据成员(基本类型或其他类)。当我们用数据成员来构建类时,实质上就是在用更简单的部件构造复杂对象,这正是对象组合。因此,结构体和类有时也被称为复合类型。
对象组合在 C++ 中的价值在于:它允许我们把简单、易于管理的部件组合成复杂类,从而降低复杂度,并能重用经过编写、测试与验证的代码,实现更快、更不易出错的开发。
对象组合的两种基本子类型
对象组合有两种基本子类型:组合(composition)与聚合(aggregation)。本课程先讨论组合,下节课程讨论聚合。
术语说明:单词 “composition” 常被用来同时指代“组合”与“聚合”。本课程中,凡指两者统称时用object composition,仅指组合子类型时用composition。
组合
要被认定为组合,对象与其部分必须满足下列关系:
- 该部分(成员)是对象(类)的组成部分;
- 该部分(成员)同一时刻只能属于一个对象(类);
- 该部分(成员)的生命周期由对象(类)管理;
- 该部分(成员)不知道对象(类)的存在。
现实生活中,人体与心脏的关系就是典型组合。下面逐一剖析:
- 部分-整体关系:心脏是人体的一部分。
- 排他归属:一颗心脏不能同时属于两个人。
- 生命周期管理:心脏随着身体的创建而创建、销毁而销毁;用户无需介入。因此组合有时被称为“同生共死”关系。
- 单向认知:心脏并不知道自己是某个整体的一部分——这是一种单向关系(身体知道心脏,心脏不知道身体)。
注意:组合并不限制部分的可转移性——心脏可以移植到另一身体;移植后,它仍满足组合要求(现在归新身体所有,除非再次转移)。
我们熟悉的 Fraction 类就是组合示例:
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
Fraction(int numerator = 0, int denominator = 1)
: m_numerator{ numerator }, m_denominator{ denominator }
{
}
};
该类有两个数据成员:分子与分母。它们属于 Fraction,且同一时刻只能隶属于一个 Fraction 对象;它们并不知道自己是 Fraction 的一部分,仅保存整型值。Fraction 实例创建时,分子分母随之创建;Fraction 销毁时,它们亦随之销毁。
组合既可用于表示物理上的“包含”关系(对象被物理地封装在另一对象内部),也适用于逻辑上的“部分-整体”关系。
组合中的部分可以是单一或多重的——心脏对人体是单一,而人体有十根手指,可用数组表示。
组合的实现
组合是 C++ 中最易实现的关系之一:通常以包含普通数据成员的结构体或类即可。数据成员直接隶属于类/结构体,其生命周期自然与类实例绑定。
若组合需要动态分配或释放,可用指针数据成员;此时组合类自身应负责所有必要的内存管理(而非交由用户)。
只要能用组合设计类,就应当优先使用组合。 组合类简洁、灵活且健壮——它们能自行妥善处理资源清理。
更多示例
多数游戏与仿真程序都有在地图或屏幕上移动的生物/物体,它们共同特征是具备位置。下面创建一个 Creature 类,并用 Point 类保存其坐标。
首先设计 Point2D 类。我们的生物位于二维世界,故 Point2D 含 X、Y 两维,假设世界由离散方格组成,坐标为整数。
Point2D.h:
#ifndef POINT2D_H
#define POINT2D_H
#include <iostream>
class Point2D
{
private:
int m_x;
int m_y;
public:
// 默认构造
Point2D()
: m_x{ 0 }, m_y{ 0 } {}
// 指定构造
Point2D(int x, int y)
: m_x{ x }, m_y{ y } {}
// 重载输出运算符
friend std::ostream& operator<<(std::ostream& out, const Point2D& point)
{
out << '(' << point.m_x << ", " << point.m_y << ')';
return out;
}
// 访问函数
void setPoint(int x, int y)
{
m_x = x;
m_y = y;
}
};
#endif
(为方便示例,所有函数实现在头文件中完成,故无 Point2D.cpp。)
Point2D 即为其成员(坐标值 x、y)的组合——这些值属于 Point2D,生命周期与之绑定。
接着设计 Creature。Creature 包含:名字(std::string
)与位置(Point2D
)。
Creature.h:
#ifndef CREATURE_H
#define CREATURE_H
#include <iostream>
#include <string>
#include <string_view>
#include "Point2D.h"
class Creature
{
private:
std::string m_name;
Point2D m_location;
public:
Creature(std::string_view name, const Point2D& location)
: m_name{ name }, m_location{ location } {}
friend std::ostream& operator<<(std::ostream& out, const Creature& creature)
{
out << creature.m_name << " is at " << creature.m_location;
return out;
}
void moveTo(int x, int y)
{
m_location.setPoint(x, y);
}
};
#endif
Creature 同样是其成员的组合:名字与位置有且仅有一个父对象,其生命周期与 Creature 绑定。
最后 main.cpp:
#include <string>
#include <iostream>
#include "Creature.h"
#include "Point2D.h"
int main()
{
std::cout << "Enter a name for your creature: ";
std::string name;
std::cin >> name;
Creature creature{ name, { 4, 7 } };
while (true)
{
std::cout << creature << '\n';
std::cout << "Enter new X location for creature (-1 to quit): ";
int x{ 0 };
std::cin >> x;
if (x == -1)
break;
std::cout << "Enter new Y location for creature (-1 to quit): ";
int y{ 0 };
std::cin >> y;
if (y == -1)
break;
creature.moveTo(x, y);
}
return 0;
}
运行示例:
Enter a name for your creature: Marvin
Marvin is at (4, 7)
Enter new X location for creature (-1 to quit): 6
Enter new Y location for creature (-1 to quit): 12
Marvin is at (6, 12)
Enter new X location for creature (-1 to quit): 3
Enter new Y location for creature (-1 to quit): 2
Marvin is at (3, 2)
Enter new X location for creature (-1 to quit): -1
组合主题的变体
尽管多数组合在创建整体时立即创建其部分,在整体销毁时立即销毁其部分,但仍有一些变体略微放宽上述规则:
- 延迟创建:直到需要时才创建部分。例如,字符串类可能在用户首次赋值时才动态分配字符数组。
- 外部提供:整体接收外部已创建的部分,而非自行创建。
- 代理销毁:整体将部分的生命周期管理委托给其它对象(如垃圾回收器)。
关键仍是:整体应自行管理部分,而不让使用者介入管理细节。
组合与类成员
初学者常问:“何时应将功能实现为类成员,而非直接嵌入?” 例如,Creature 的坐标可直接用两个 int 加坐标处理代码,而不必独立 Point2D 类。然而,将 Point2D 设为独立类(再作为 Creature 成员)带来诸多益处:
- 单一职责:每个类保持简单、专注,易于编写与理解。Point2D 只关心“点”,因而简洁。
- 可复用:Point2D 可在完全不同程序中复用;若 Creature 还需第二个点(如目标位置),只需再加一个 Point2D 成员。
- 委托实现:外层类只需协调成员之间的数据流。Creature 把“移动”任务交给已懂如何设置点的 Point2D,从而降低自身复杂度。
经验法则:每个类应专注完成单一任务——要么负责某类数据的存储与操作(如 Point2D、std::string),要么负责协调其成员(如 Creature),理想情况下不应两者兼顾。
在本示例中,Creature 无需关心 Point 的实现细节,也无需关心名字如何存储;它的职责是协调数据流,确保各成员各司其职。至于“怎么做”,由各成员类自行解决。