23-2 组合
对象组合
在现实生活中,复杂的对象通常由更小、更简单的对象构成。例如,汽车由金属框架、发动机、轮胎、变速箱、方向盘以及大量其他部件组成。个人电脑则由CPU、主板、内存等部件构成。就连你也是由更小的部件组成的:你有头部、躯干、双腿、双臂等等。这种由简单对象构建复杂对象的过程称为对象组合object composition。
广义而言,对象组合建模了两个对象之间的“具有has-a”关系。汽车“具有”变速箱,你的计算机“具有”CPU“,你”具有"心脏。复杂对象有时称为整体或父对象,简单对象通常称为部件、子对象或组件。
在C++中,你已经看到结构体和类可以包含不同类型的数据成员(如基本类型或其他类)。当我们用数据成员构建类时,本质上就是通过简单部件构建复杂对象,这正是对象组合。因此结构体和类有时被称为组合类型composite types。
对象组合在C++语境中具有重要价值,它使我们能够通过组合更简单、更易管理的部件来创建复杂类。这种方式既能降低复杂度,又能加快开发速度并减少错误——因为我们可以复用那些已经编写、测试并验证有效的代码。
对象组合的类型
对象组合包含两种基本子类型:组合composition与聚合aggregation。本节将探讨组合,聚合将在下一节中讲解。
术语说明:术语“组合”常同时指代组合与聚合,而不仅限于组合子类型。本教程中,当同时指代两者时使用“对象组合”,特指组合子类型时则使用“组合”。
组合
要构成组合关系,对象与部件必须满足以下条件:
- 部件(成员)属于对象(类)
- 部件(成员)每次只能属于一个对象(类)
- 部件(成员)的存在由对象(类)管理
- 部件(成员)不了解对象(类)的存在
组合关系的一个典型现实例子是人体与心脏的关系。让我们深入探讨这些关系。
组合关系是部分-整体关系,其中部分必须构成整体对象的一部分。例如,心脏是人体的一部分。组合关系中的部分每次只能属于一个对象。属于某人身体的心脏不可能同时属于其他人的身体。
在构成关系中,主体负责维持部分的存在。通常这意味着部分随主体诞生而诞生,随主体消亡而消亡。更广泛而言,主体通过管理部分的生命周期,使使用者无需介入其中。例如当身体诞生时,心脏亦随之诞生。当人体被摧毁时,心脏亦随之消亡。正因如此,组合关系有时被称为“死亡关系”。
最后,部分对整体的存在一无所知。心脏在运作时全然不知自己属于更宏大的结构。我们称之为单向unidirectional关系——身体知晓心脏的存在,反之则不然。
需注意组合关系并不涉及部分的可转移性。心脏可从一个身体移植到另一个身体。然而即使被移植后,它仍符合组合关系的要求(心脏现在属于受体,除非再次转移,否则只能成为受体对象的一部分)。
我们随处可见的Fraction类就是组合关系的绝佳范例:
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
Fraction(int numerator=0, int denominator=1)
: m_numerator{ numerator }, m_denominator{ denominator }
{
}
};
该类包含两个数据成员:分子和分母。分子与分母属于分数(被其包含),且不能同时属于多个分数。分子与分母本身并不知晓其隶属关系,仅作为整数容器存在。当创建分数实例时,分子与分母随之生成。当分数实例被销毁时,分子和分母也将被销毁。
虽然对象组合模型体现了“拥有”关系(身体拥有心脏,分数拥有分母),但更精确地说,组合模型体现的是“组成部分”关系(心脏是身体的组成部分,分子是分数的组成部分)。组合常用于建模物理关系,即一个对象物理上包含于另一个对象之中。
对象组合的组成部分可以是单数或可乘的——例如心脏是身体的单一组成部分,但身体包含10根手指(可建模为数组)。
实现组合
组合是C++中最易实现的关系类型之一。它们通常以结构体或类的形式创建,包含常规数据成员。由于这些数据成员直接作为结构体/类的一部分存在,其生命周期与类实例本身绑定。
需要动态分配或释放内存的组合结构,可通过指针数据成员实现。此时组合类应自行承担所有必要的内存管理职责(而非由类的使用者负责)。
总的来说,若能采用组合设计实现类,就应选择组合设计。基于组合设计的类具有结构清晰、灵活多变且健壮可靠的特点(因其能自动完成完善的清理工作)。
更多示例
许多游戏和模拟程序中都存在能在棋盘、地图或屏幕上移动的生物或物体。这些生物/物体有一个共同点:它们都拥有位置信息。在本例中,我们将创建一个生物类,使用点类来存储生物的位置。
首先设计点类。由于生物生活在二维空间中,点类将包含两个维度:X轴和Y轴。假设世界由离散的正方形构成,因此这两个维度始终为整数。
Point2D.h
#ifndef POINT2D_H
#define POINT2D_H
#include <iostream>
class Point2D
{
private:
int m_x;
int m_y;
public:
// A default constructor
Point2D()
: m_x{ 0 }, m_y{ 0 }
{
}
// A specific constructor
Point2D(int x, int y)
: m_x{ x }, m_y{ y }
{
}
// An overloaded output operator
friend std::ostream& operator<<(std::ostream& out, const Point2D& point)
{
out << '(' << point.m_x << ", " << point.m_y << ')';
return out;
}
// Access functions
void setPoint(int x, int y)
{
m_x = x;
m_y = y;
}
};
#endif
请注意,由于我们为保持示例简洁而在头文件中实现了所有函数,因此不存在Point2D.cpp文件。
Point2D类由其组成部分构成:位置值x和y属于Point2D的组成部分,其生命周期与特定Point2D实例绑定。
现在设计Creature类。该类将包含若干属性:名称(字符串类型)和位置(采用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同样由其组成部分构成。生物体的名称与位置共享一个父节点,其生命周期与所属生物体的生命周期紧密相连。
最后是 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)
{
// print the creature's name and location
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;
}
以下是该代码运行时的输出记录:

组合主题的变体
尽管大多数组合在创建时会直接生成其组成部分,并在销毁时直接销毁这些部分,但某些组合变体会略微偏离这些规则。
例如:
- 组合可能推迟某些部分的创建,直至需要时才生成。例如字符串类可能不会立即创建动态字符数组,而是在用户为字符串赋值时才创建。
- 组合体可选择使用作为输入传递的部件,而非自行创建部件。
- 组合体可将部件的销毁委托给其他对象(例如垃圾回收程序)。
关键在于:组合体应自主管理其部件,而无需用户进行任何干预。
组合与类成员
新手程序员在探讨对象组合时常会问:“何时该使用类成员而非直接实现功能?”例如,我们本可直接在Creature类中添加两个整数来处理坐标定位,而非使用Point2D类来实现生物的位置。然而,将Point2D设为独立类(并作为Creature的成员)具有多重优势:
- 每个独立类都能保持相对简单明了,专注于高效完成单一任务。这种聚焦特性使类更易编写且更易理解。例如Point2D仅处理与点相关的逻辑,从而保持其简洁性。
- 每个类都能自成体系,从而实现复用。例如,我们可在完全不同的应用中复用Point2D类。若生物需要新增坐标点(如目标位置),只需添加另一个Point2D成员变量即可。
- 外部类可让内部类承担大部分核心工作,自身专注于协调成员间的数据流。这种设计能降低外部类的整体复杂度,因为它能将任务委托给已掌握实现逻辑的成员类。例如移动生物时,外部类将任务委托给Point类——该类已具备设置坐标点的实现能力。因此Creature类无需关心具体实现细节。
技巧:
经验法则是:每个类应专注完成单一任务。该任务要么是存储和操作某种数据(如Point2D、std::string),要么是协调成员间协作(如Creature),理想情况下不应兼顾两者。
在本例中,Creature无需关心Point的具体实现方式或名称存储机制是合理的。Creature的职责并非了解这些底层细节,而是协调数据流并确保每个类成员明确自身职责。具体实现方式应由各类自行负责。

浙公网安备 33010602011771号