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;
}

以下是该代码运行时的输出记录:

image


组合主题的变体

尽管大多数组合在创建时会直接生成其组成部分,并在销毁时直接销毁这些部分,但某些组合变体会略微偏离这些规则。

例如:

  • 组合可能推迟某些部分的创建,直至需要时才生成。例如字符串类可能不会立即创建动态字符数组,而是在用户为字符串赋值时才创建。
  • 组合体可选择使用作为输入传递的部件,而非自行创建部件。
  • 组合体可将部件的销毁委托给其他对象(例如垃圾回收程序)。

关键在于:组合体应自主管理其部件,而无需用户进行任何干预。


组合与类成员

新手程序员在探讨对象组合时常会问:“何时该使用类成员而非直接实现功能?”例如,我们本可直接在Creature类中添加两个整数来处理坐标定位,而非使用Point2D类来实现生物的位置。然而,将Point2D设为独立类(并作为Creature的成员)具有多重优势:

  1. 每个独立类都能保持相对简单明了,专注于高效完成单一任务。这种聚焦特性使类更易编写且更易理解。例如Point2D仅处理与点相关的逻辑,从而保持其简洁性。
  2. 每个类都能自成体系,从而实现复用。例如,我们可在完全不同的应用中复用Point2D类。若生物需要新增坐标点(如目标位置),只需添加另一个Point2D成员变量即可。
  3. 外部类可让内部类承担大部分核心工作,自身专注于协调成员间的数据流。这种设计能降低外部类的整体复杂度,因为它能将任务委托给已掌握实现逻辑的成员类。例如移动生物时,外部类将任务委托给Point类——该类已具备设置坐标点的实现能力。因此Creature类无需关心具体实现细节。

技巧:
经验法则是:每个类应专注完成单一任务。该任务要么是存储和操作某种数据(如Point2D、std::string),要么是协调成员间协作(如Creature),理想情况下不应兼顾两者。

在本例中,Creature无需关心Point的具体实现方式或名称存储机制是合理的。Creature的职责并非了解这些底层细节,而是协调数据流并确保每个类成员明确自身职责。具体实现方式应由各类自行负责。

posted @ 2026-01-30 20:53  游翔  阅读(1)  评论(0)    收藏  举报