C++ 对象组合:Composition(组合)

在现实生活中,复杂对象通常由更小、更简单的对象构成。例如,一辆汽车由金属车架、发动机、轮胎、变速箱、方向盘以及大量其他零件组成;一台个人计算机由 CPU、主板、内存等部件构成;甚至你自身也由更小的部分构成:头部、躯干、四肢等。这种“由简单对象构建复杂对象”的过程称为对象组合

广义而言,对象组合刻画了两个对象之间的 “有一个”(has-a)关系:汽车“有一个”变速箱;电脑“有一个”CPU;你“有一个”心脏。复杂对象有时被称为整体父对象,简单对象则称为部分子对象组件

在 C++ 中,结构体和类可以拥有各种类型的数据成员(基本类型或其他类)。当我们用数据成员来构建类时,实质上就是在用更简单的部件构造复杂对象,这正是对象组合。因此,结构体和类有时也被称为复合类型

对象组合在 C++ 中的价值在于:它允许我们把简单、易于管理的部件组合成复杂类,从而降低复杂度,并能重用经过编写、测试与验证的代码,实现更快、更不易出错的开发。

对象组合的两种基本子类型

对象组合有两种基本子类型:组合(composition)与聚合(aggregation)。本课程先讨论组合,下节课程讨论聚合。

术语说明:单词 “composition” 常被用来同时指代“组合”与“聚合”。本课程中,凡指两者统称时用object composition,仅指组合子类型时用composition

组合

要被认定为组合,对象与其部分必须满足下列关系:

  1. 该部分(成员)是对象(类)的组成部分;
  2. 该部分(成员)同一时刻只能属于一个对象(类);
  3. 该部分(成员)的生命周期由对象(类)管理
  4. 该部分(成员)不知道对象(类)的存在。

现实生活中,人体与心脏的关系就是典型组合。下面逐一剖析:

  • 部分-整体关系:心脏是人体的一部分。
  • 排他归属:一颗心脏不能同时属于两个人。
  • 生命周期管理:心脏随着身体的创建而创建、销毁而销毁;用户无需介入。因此组合有时被称为“同生共死”关系。
  • 单向认知:心脏并不知道自己是某个整体的一部分——这是一种单向关系(身体知道心脏,心脏不知道身体)。

注意:组合并不限制部分的可转移性——心脏可以移植到另一身体;移植后,它仍满足组合要求(现在归新身体所有,除非再次转移)。

我们熟悉的 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 的实现细节,也无需关心名字如何存储;它的职责是协调数据流,确保各成员各司其职。至于“怎么做”,由各成员类自行解决。

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

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

公众号二维码

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