在上节课程组合(Composition)中,我们指出:对象组合是利用简单对象构建复杂对象的过程,并讨论了其中一种子类型——组合。在组合关系里,整体对象对部分对象的生命周期负责。
本节课将探讨对象组合的另一种子类型:聚合(aggregation)。
聚合
要成为聚合,整体对象与其部分对象必须满足以下关系:
- 部分(成员)是整体(类)的组成部分;
- 部分(成员)在需要时可以同时属于多个整体(类);
- 部分(成员)的生命周期不由整体(类)管理;
- 部分(成员)不知道整体(类)的存在。
与组合一样,聚合仍是部分-整体关系,且是单向关系;但与组合不同,部分可同时属于多个整体,且整体不负责部分的创建与销毁。聚合创建时不会创建其部分;聚合销毁时也不会销毁其部分。
例如,人与家庭住址的关系:为简单起见,假设每个人都地址,但该地址可同时属于多人(如你与室友)。地址的存在并不由个人管理——地址在你入住前便已存在,搬离后亦继续存在;人知道自己住在哪,地址却不知道谁住在那儿——这就是聚合。
又如汽车与发动机:发动机是汽车的一部分,但发动机也可属于车主;汽车不负责发动机的创建与销毁;汽车知道自己有发动机,发动机却不知道自己是哪辆车的一部分。
在物理对象建模时,“销毁”一词有时易引发误解。有人可能说:“若陨石砸毁汽车,零件不也一起毁了吗?” 的确如此,但那是陨石的责任。关键在于:汽车本身不负责销毁其零件(外部因素可能负责)。
因此,聚合刻画的是“has-a”关系(系里有老师,汽车有发动机)。
与组合类似,聚合的部分可以是单一或多重的。
聚合的实现
由于聚合与组合都是部分-整体关系,实现方式几乎相同,区别主要在语义。
- 组合通常用普通成员变量(或指针,由组合类负责分配/回收)。
- 聚合通常用引用或指针成员,指向在类作用域之外创建的对象。 因此,聚合类一般通过构造函数接收外部对象,或先留空再通过访问函数/运算符添加子对象。
由于部分位于类作用域之外,类析构时只会销毁引用/指针成员本身,而不会销毁所指向的对象。
以 Teacher 与 Department 为例:
- Teacher 类
class Teacher
{
private:
std::string m_name{};
public:
Teacher(std::string_view name) : m_name{ name } {}
const std::string& getName() const { return m_name; }
};
- Department 类
class Department
{
private:
const Teacher& m_teacher; // 为简化,只存一位老师
public:
Department(const Teacher& teacher) : m_teacher{ teacher } {}
};
- main
int main()
{
Teacher bob{ "Bob" }; // 在 Department 作用域外创建老师
{
Department department{ bob }; // 将老师传入
} // department 销毁,但 bob 仍在
std::cout << bob.getName() << " still exists!\n";
return 0;
}
bob 独立于 department 创建,随后传入其构造函数;department 销毁时,m_teacher 引用销毁,但 bob 对象继续存在。
为所建模的关系选择恰当类型
上例中教师不知道自己属于哪个系似乎不合常理,但在某些程序语境下完全可行。决定实现何种关系时,应选择能满足需求的最简单关系,而非看上去最符合现实的关系。
例如:
- 汽修店仿真:汽车与发动机可建模为聚合,以便把发动机拆下放架子上。
- 赛车仿真:汽车与发动机可建模为组合,因该语境下发动机绝不会离开汽车。
最佳实践 实现能满足程序需求的最简单关系类型,而非看起来最符合现实的关系。
组合与聚合小结
组合:
- 通常使用普通成员变量
- 可用指针成员,但由类自身负责分配/回收
- 负责部分的创建与销毁
聚合:
- 通常使用指针或引用成员,指向聚合类作用域外的对象
- 不负责部分的创建/销毁
同一类中可自由混合组合与聚合。例如 Department 类可包含名称(组合:随 Department 创建销毁)和 Teacher(聚合:独立创建销毁)。
尽管聚合非常有用,但也更危险:聚合不负责释放部分,必须由外部负责。若外部失去指针/引用或忘记清理,就会内存泄漏。因此应优先使用组合。
几点警告 / 勘误
由于历史及语境差异,聚合的定义不如组合精确——其他资料可能采用不同定义,请注意区分。
另一点:在课程《结构体、成员与成员选择简介》中,我们把“聚合数据类型”(如 struct、class)定义为将多个变量分组的类型。你还可能遇到“aggregate class”术语,指无自定义构造、析构、重载赋值,所有成员 public,且未用继承的“平凡结构”。 Though name is similar, aggregate 与 aggregation 完全不同,请勿混淆。
std::reference_wrapper
上例用引用保存单个 Teacher,但若一个 Department 需保存多位 Teacher?我们想把 Teacher 存于列表(如 std::vector),但固定数组及标准库容器不能持有引用(因元素需可赋值,引用不可再绑定)。
std::vector<const Teacher&> m_teachers{}; // 非法
可用指针,但可能引入空指针。若不允许空指针,可用 std::reference_wrapper。
std::reference_wrapper 行为类似引用,却支持拷贝与赋值,故可与 std::vector 等容器兼容。
使用方法只需记住三点:
- 位于
<functional>
头文件; - 创建时不能绑定匿名对象(匿名对象作用域仅限表达式,会导致悬空引用);
- 用
get()
成员函数取出原对象。
示例:
#include <functional>
#include <iostream>
#include <vector>
#include <string>
int main()
{
std::string tom{ "Tom" };
std::string berta{ "Berta" };
std::vector<std::reference_wrapper<std::string>> names{ tom, berta }; // 引用存储
std::string jim{ "Jim" };
names.emplace_back(jim);
for (auto name : names)
name.get() += " Beam";
std::cout << jim << '\n'; // 输出 Jim Beam
return 0;
}
若要存储 const 引用,需加 const:
std::vector<std::reference_wrapper<const std::string>> names{ tom, berta };
测验
问题 1
下列情形更可能用组合还是聚合?
a) 球具有颜色
b) 雇主雇佣多名雇员
c) 大学的各院系
d) 你的年龄
e) 一袋弹珠
展示解答
问题 2
修改 Department/Teacher 示例,使 Department 能容纳多位 Teacher。要求执行以下代码:
#include <iostream>
// ...
int main()
{
Teacher t1{ "Bob" };
Teacher t2{ "Frank" };
Teacher t3{ "Beth" };
{
Department department{};
department.add(t1);
department.add(t2);
department.add(t3);
std::cout << department;
}
std::cout << t1.getName() << " still exists!\n";
std::cout << t2.getName() << " still exists!\n";
std::cout << t3.getName() << " still exists!\n";
return 0;
}
应输出:
Department: Bob Frank Beth
Bob still exists!
Frank still exists!
Beth still exists!