跳转至

类的组合

用小模块搭建大模块,表达复杂对象“由什么构成”的关系。

什么是组合

在面向对象程序设计中,组合(Composition) 是一种基本的代码组织方式。 它允许一个类将另一个类的对象作为自己的数据成员,从而构建出更复杂、更高层次的对象。

组合的核心思想

组合表达的是 “has-a”(拥有) 关系:一个对象由其他对象构成。

组合关系在现实世界中随处可见:

  • 一辆汽车拥有四个轮子、一个发动机、一个方向盘
  • 一台计算机包含CPU内存硬盘
  • 一条线段由两个端点确定。

这些例子都体现了 “整体-部分” 的关系:整体对象由部分对象构成,部分对象是整体对象的组成部分。

Point 与 Line 案例导入

在图形学中,线段由两个端点确定,这是组合关系的自然例子。

Line(线段)
    ├── p1: Point(起点)
    └── p2: Point(终点)
  • Point 表示二维平面上的一个点。
  • Line 表示由两个 Point 构成的线段。
  • 关系Line 拥有(has-a)两个 Point 对象。

Point 类的设计

首先,我们需要一个可复用的 Point 类,它表示二维平面上的一个点。

Point 类的声明与实现

class Point {
public:
    // 构造函数:支持默认构造和指定坐标
    Point(int x = 0, int y = 0) : x(x), y(y) {}

    // 移动点坐标
    void move(int dx, int dy) {
        x += dx;
        y += dy;
    }

    // 显示点坐标
    void show() const {
        cout << "(" << x << ", " << y << ")";
    }

    // 获取坐标(用于读取)
    int getX() const { return x; }
    int getY() const { return y; }

private:
    int x;   // x 坐标
    int y;   // y 坐标
};

Point 类的设计要点

  • 状态xy 保存坐标值。
  • 行为move() 修改点的位置,show() 显示点信息。
  • 复用性Point 是一个独立、完整的小对象,可以被其他类作为成员使用。
  • 封装:坐标数据设为 private,通过公开接口访问。

Line 类的设计:组合的体现

Line 类通过两个 Point 成员表达端点,体现了组合关系。

Line 类的声明与实现

class Line {
public:
    // 构造函数:通过两个 Point 对象构造 Line
    Line(Point a, Point b) : p1(a), p2(b) {}

    // 显示线段信息
    void show() const {
        cout << "Line from ";
        p1.show();
        cout << " to ";
        p2.show();
        cout << endl;
    }

    // 移动整条线段
    void move(int dx, int dy) {
        p1.move(dx, dy);
        p2.move(dx, dy);
    }

    // 计算线段长度(示例扩展功能)
    double length() const {
        int dx = p2.getX() - p1.getX();
        int dy = p2.getY() - p1.getY();
        return sqrt(dx * dx + dy * dy);
    }

private:
    Point p1;   // 起点(成员对象)
    Point p2;   // 终点(成员对象)
};

Line 类的设计要点

  • 成员对象p1p2 都是 Point 类型的对象,作为 Line 的组成部分。
  • 初始化列表:在构造函数的初始化列表中初始化成员对象,调用 Point 的复制构造函数。
  • 组合语义Line 拥有 Point 对象,而不是继承 Point
  • 行为委托Linemove() 方法通过调用 p1.move()p2.move() 实现,体现了“整体”通过“部分”完成工作。

成员对象的构造与析构

Line 对象被创建时,其成员对象 p1p2 也会被自动构造;当 Line 对象被销毁时,p1p2 也会被自动析构。组合关系下,成员对象的生命周期由外层对象管理。

组合类的构造函数设计

声明组合类的对象实例时,其构造函数需要负责初始化所有数据成员,既包括普通类型成员也包括对象类型成员,这通常在初始化列表中完成。

方式一:接收成员对象的参数

如果成员对象没有默认构造函数,或者需要传入特定参数,组合类应在初始化列表中调用成员对象的构造函数。

接收成员对象参数的构造函数

class Line {
public:
    // 通过坐标参数直接构造成员对象
    Line(int x1, int y1, int x2, int y2)
        : p1(x1, y1), p2(x2, y2) {}

    // 其他成员函数保持不变
private:
    Point p1;
    Point p2;
};

// 使用
Line line(3, 4, 10, 20);   // 直接通过坐标构造

方式二:接收已有对象

组合类也可以接收已存在的对象作为参数,通过复制构造来初始化成员。

接收已有对象的构造函数

1
2
3
4
5
6
7
8
class Line {
public:
    // 接收两个 Point 对象作为参数
    Line(Point a, Point b) : p1(a), p2(b) {}

    // 或接收 Point 的 const 引用(避免不必要的复制)
    Line(const Point& a, const Point& b) : p1(a), p2(b) {}
};

方式三:使用复制构造函数

复制构造函数用于从已有对象创建新对象。对于组合类,复制构造函数的职责是递归复制所有成员对象。

为 Line 类声明复制构造函数

class Line {
public:
    // 普通构造函数
    Line(int x1, int y1, int x2, int y2)
    : p1(x1, y1), p2(x2, y2) {}

    // 复制构造函数:用已有的 Line 对象初始化新对象
    Line(const Line& other)
        : p1(other.p1), p2(other.p2) {
        // 成员对象 p1 和 p2 分别用 other.p1 和 other.p2 复制构造
        // 这本质上是对每个成员递归执行复制构造
    }

    // 其他成员函数
    void show() const {
        cout << "Line from ";
        p1.show();
        cout << " to ";
        p2.show();
        cout << endl;
    }

private:
    Point p1;
    Point p2;
};

// 使用复制构造函数
Line line1(3, 4, 10, 20);
Line line2(line1);   // 调用复制构造函数,line2 成为 line1 的副本
line1.show();        // Line from (3, 4) to (10, 20)
line2.show();        // Line from (3, 4) to (10, 20)(独立副本)

组合类的复制构造函数的实现要点

  • 初始化列表:在初始化列表中分别用源对象的对应成员初始化当前对象的成员。
  • 依赖成员自己的复制语义:组合类的复制正确性依赖于每个成员类型的复制构造函数行为正确。
  • 无需特殊处理:如果所有成员类型都支持正确的复制(基本类型或标准库类型),组合类通常不需要自定义复制构造函数。

什么时候需要为组合类自定义复制构造函数?

大多数情况下,编译器生成的缺省复制构造函数对组合类已经足够。需要自定义的场景包括:

  • 成员对象不支持复制:某个成员类型显式禁用了复制(= delete),导致组合类不可复制。
  • 需要深拷贝:成员是原始指针(T*),需要执行深拷贝而非浅拷贝。
  • 需要额外的复制逻辑:复制时需要同时更新其他状态(如引用计数、日志记录等)。

方式四:使用移动构造函数

移动构造函数通过“转移”而非“复制”的方式初始化成员对象,可以提高性能。

使用移动构造函数的组合类

    class Line {
    public:
        // 移动构造函数
        Line(Line&& other) noexcept
            : p1(std::move(other.p1)),   // 移动构造 p1
              p2(std::move(other.p2)) {  // 移动构造 p2
            // other 的资源已被转移,处于有效但未指定的状态
        }

    private:
        Point p1;
        Point p2;
    };

    // 使用
    Line line1(3, 4, 10, 20);
    Line line2(std::move(line1));   // 调用移动构造函数
    // line1 的资源已被转移,不应再使用

组合类中的移动语义传递

组合类的移动构造函数通过 std::move 将移动操作传递给每个成员对象。 只要每个成员类型都支持移动构造,组合类的移动构造就是自动且高效的。

构造函数设计总结

组合类构造函数的多种形式

方式 优点 缺点 关键语法
接收坐标参数 直接构造,效率高;调用者无需先构造 Point 构造函数参数较多时可能代码繁琐 : p1(x1, y1), p2(x2, y2)
接收 Point 对象 接口清晰,调用者可复用已有 Point 需要先构造 Point 对象,多一次构造 : p1(a), p2(b)
复制构造函数 用已有对象创建副本 需要先构造 Line 对象 : p1(other.p1), p2(other.p2)
移动构造函数 转移临时对象的资源,提升性能 语法相对复杂 : p1(std::move(other.p1)), ...

在实际设计中,通常根据使用场景选择合适的方式,也可以同时提供多种构造函数。

构造函数中的参数传递方式

组合类的构造函数参数可以通过多种方式传递,每种方式有不同的语义和性能特征。

方式 关键语法 说明
传值(会拷贝) Line(Point a, Point b)
: p1(a), p2(b) {}
参数 a 和 b 是实参的副本,然后
用 a 和 b 再拷贝构造 p1 和 p2,
适用需要保存副本,且对象较小的场景
const 引用(推荐) Line(const Point& a, const Point& b)
: p1(a), p2(b) {}
直接引用实参来构造 p1 和 p2,
避免了一次拷贝,适用对象较大,
或构造开销较高的场景
移动语义(C++11) Line(Point a, Point b)
: p1(std::move(a)), p2(std::move(b)) {}
配合传值参数,通过移动避免拷贝,
适用实参是临时对象,或不再需要
源对象的场景

最佳实践

对于组合类,通常推荐使用 const 引用传递成员对象参数,既避免了不必要的拷贝,又清晰地表达了“只读传入”的语义。

成员对象的初始化顺序

重要规则

组合类中,成员对象的初始化顺序由它们在类中的声明顺序决定,而不是初始化列表中的书写顺序。

初始化顺序示例

class Line {
public:
    // 注意:初始化列表先写 p2,再写 p1
    Line(int x1, int y1, int x2, int y2)
        : p2(x2, y2), p1(x1, y1) {   // 虽然先写 p2
        // 但实际初始化顺序是:先 p1,后 p2
    }

private:
    Point p1;   // 先声明 → 先初始化
    Point p2;   // 后声明 → 后初始化
};

为什么顺序重要?

成员对象的初始化顺序受 C++ 语言规则约束,主要原因是:

  1. 确定性:无论初始化列表如何书写,析构顺序始终与初始化顺序相反,保证了析构的可预测性。
  2. 依赖关系:如果成员之间存在依赖(一个成员的初始化需要另一个成员的值),必须按照声明顺序设计。

最佳实践

虽然初始化列表的书写顺序不影响实际初始化顺序,但为了代码的可读性和减少误解,建议初始化列表的书写顺序与成员声明顺序保持一致

组合的深层含义

生命周期管理

在组合关系中,成员对象的生命周期由外层对象管理:

组合的生命周期规则

  • 构造:外层对象构造时,成员对象自动构造(先构造成员,再进入外层对象构造函数体)。
  • 生存:成员对象随外层对象一同生存。
  • 析构:外层对象析构时,成员对象自动析构(先执行外层对象析构函数体,再逆序析构成员)。

这意味着组合类不需要额外代码来管理成员对象的创建和销毁,编译器会处理一切。

访问控制

成员对象是外层类的私有成员,外部代码无法直接访问成员对象,必须通过外层类提供的公开接口。

访问控制示例

class Line {
public:
    void show() const {
        p1.show();   // 类内可以访问私有成员 p1
        p2.show();
    }
private:
    Point p1;   // 私有成员,外部不可直接访问
    Point p2;
};

int main() {
    Line line(3, 4, 10, 20);
    // line.p1.show();   // 错误!p1 是 private,外部不能访问
    line.show();         // OK,通过公开接口访问
    return 0;
}

组合的层次结构

组合关系可以形成多层次的嵌套结构,大对象由小对象构成,小对象由更小的对象构成。

多层组合示例

Computer(计算机)
    ├── cpu: CPU(处理器)
    │       ├── cores: int
    │       └── frequency: double
    ├── memory: Memory(内存)
    │       └── size: int
    └── disk: Disk(硬盘)
            ├── type: string
            └── capacity: int

每个层次的对象都独立封装自己的状态和行为,外层对象通过调用内层对象的接口来实现功能。

小结

类的组合是面向对象程序中最重要的代码复用方式之一,它通过“整体-部分”关系构建复杂的系统。

  1. 组合表达“has-a”关系:一个对象由其他对象构成,如 Line 拥有两个 Point
  2. 成员对象的构造:在组合类的初始化列表中初始化成员对象。
  3. 初始化顺序:由成员声明顺序决定,而非初始化列表书写顺序。
  4. 生命周期管理:成员对象的构造和析构由编译器自动管理,无需额外代码。
  5. 设计原则:优先使用组合而非继承,可以使系统更灵活、耦合度更低。

理解组合关系是掌握面向对象设计的重要一步。在实际项目中,合理运用组合可以构建出结构清晰、易于维护的软件系统。