C++ 对象组合:Aggregation(聚合)

在上节课程组合(Composition)中,我们指出:对象组合是利用简单对象构建复杂对象的过程,并讨论了其中一种子类型——组合。在组合关系里,整体对象对部分对象的生命周期负责。

本节课将探讨对象组合的另一种子类型:聚合(aggregation)

聚合

要成为聚合,整体对象与其部分对象必须满足以下关系:

  1. 部分(成员)是整体(类)的组成部分;
  2. 部分(成员)在需要时可以同时属于多个整体(类);
  3. 部分(成员)的生命周期不由整体(类)管理;
  4. 部分(成员)不知道整体(类)的存在。

与组合一样,聚合仍是部分-整体关系,且是单向关系;但与组合不同,部分可同时属于多个整体,且整体不负责部分的创建与销毁。聚合创建时不会创建其部分;聚合销毁时也不会销毁其部分。

例如,人与家庭住址的关系:为简单起见,假设每个人都地址,但该地址可同时属于多人(如你与室友)。地址的存在并不由个人管理——地址在你入住前便已存在,搬离后亦继续存在;人知道自己住在哪,地址却不知道谁住在那儿——这就是聚合。

又如汽车与发动机:发动机是汽车的一部分,但发动机也可属于车主;汽车不负责发动机的创建与销毁;汽车知道自己有发动机,发动机却不知道自己是哪辆车的一部分。

在物理对象建模时,“销毁”一词有时易引发误解。有人可能说:“若陨石砸毁汽车,零件不也一起毁了吗?” 的确如此,但那是陨石的责任。关键在于:汽车本身不负责销毁其零件(外部因素可能负责)。

因此,聚合刻画的是“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 等容器兼容。

使用方法只需记住三点:

  1. 位于 <functional> 头文件;
  2. 创建时不能绑定匿名对象(匿名对象作用域仅限表达式,会导致悬空引用);
  3. 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!

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"