C++继承、组合、聚合:选错了是屎山,选对了是神器

有时候我们写代码时总会盯着屏幕,陷入沉思:

"这些设计到底哪个是对的?什么时候该用继承,什么时候该用组合?聚合又是啥?为啥有人说'优先使用组合而不是继承'?那多继承又该怎么处理?"

如果你也有过这种困惑,别急------今天我们就来聊聊C++实际项目里绕不开的三个关系:继承、组合与聚合。

你可以把它们想象成三种不同的"人际关系":

  • 继承是"血脉关系"------子类继承父类的基因(接口和实现),是"is-a"的关系。就像你长得像你爸,因为你们有血缘关系。但血脉不能乱改,一旦继承,就绑死了。
  • 组合是"器官关系"------类里面"拥有"另一个类的对象,同生共死,是"has-a"的关系。就像人有心脏,心脏随着人的生死而存亡。组合是强耦合,但很稳固。
  • 聚合是"公司和员工关系"------类里面"引用"另一个类的对象,但对方可以独立存在,是"uses-a"的关系。就像你有份工作,你可以在那上班,但你不想干了可以随时跑路。

在实际项目中,选错了关系,就会导致代码难以维护、扩展性差,甚至引发"重构血案"。

今天就给你讲讲这些玩意儿到底咋用,啥时候用,为啥有时候用了就跟吃了苍蝇一样难受,有时候又爽得飞起。

基础概念解析

继承:那个"富二代"关系

定义:继承是面向对象编程中,一个类(子类)获取另一个类(父类)的成员和方法的机制,形成"is-a"(是一个)关系。

继承就像"儿子继承老爹的家产"。老爹有的(public成员),儿子基本都有;老爹会的手艺(虚函数),儿子可以学,也可以改成自己的风格(override)。

c++ 复制代码
// 基类 - 动物
class Animal 
{
public:
    virtual void speak() 
    {
        std::cout << "动物发出声音" << std::endl;
    }
protected:
    int age;
private:
    int secretDNA; // 这个子类都看不到!
};

// 子类 - 狗
class Dog : public Animal 
{
public:
    void speak() override // 改写基类的方法
    {  
        std::cout << "汪汪汪!" << std::endl;
    }

    void setAge(int a) { age = a; } // protected的age能访问
    // void setDNA(int d) { secretDNA = d; } // 编译错误!基类的秘密不能碰
};

记得以前学完C++后感觉自己天下无敌,兴致冲冲的去做个游戏,把"怪物"类继承自"角色"类,看着挺美。

后来来了个"石头怪",不会动不会说话,我那个继承体系当场就崩了------石头怪继承"移动方法"干啥?这不是坑爹吗!

(现在来看当时的自己真是个"小可爱")

所以现在我的经验是:继承不是代码复用的首选,而是"真的是一类东西"才用。鸭子是鸟,但企鹅也是鸟,你让企鹅继承鸟的"飞行方法"试试?人家会恨死你。

组合:那个"连体婴儿"关系

定义:组合是一种强耦合的"has-a"(有一个)关系,部分和整体生命周期相同,部分不能独立于整体存在。

组合就像"人的心脏"。人没了,心脏也就没了意义;心脏不能脱离人体独立存活。代码里就是A类创建B类,B跟着A同生共死:

c++ 复制代码
class Heart 
{
public:
    void beat() { std::cout << "咚咚咚" << std::endl; }
};

class Person 
{
private:
    Heart heart; // 组合:Person创建并拥有Heart
    std::string name;

public:
    Person(const std::string& n) : name(n) 
    {
        // heart在这里自动构造
    }

    void live() 
    {
        heart.beat(); // 人活着,心脏就跳
        std::cout << name << "还活着呢" << std::endl;
    }

    // 析构时,heart自动析构
};

假设我们自己要写个网络库,Connection类里组合了Socket。连接一建立,socket就创建;连接一关,socket也完蛋。

这就是组合,生命周期绑死,省心省力,不用操心资源释放问题。

聚合:那个"室友关系"

定义:聚合是一种松耦合的"has-a"关系,部分可以独立于整体存在,通常通过指针或引用来实现。

聚合就像"我和我室友"。我们住一起,但他是他我是我。他搬走了(析构),我还在;我搬走了,他也能活。代码里就是A类拿着B类的指针/引用,但B不是A创建的:

c++ 复制代码
class Roommate 
{
public:
    void doDishes() { std::cout << "洗碗中..." << std::endl; }
};

class Apartment 
{
private:
    std::vector<Roommate*> roommates; // 聚合:只是拿着指针

public:
    void addRoommate(Roommate* rm) 
    {
        roommates.push_back(rm); // roommate是外面创建的
    }

    void cleanUp() 
    {
        for (auto rm : roommates) {
            rm->doDishes(); // 让室友干活
        }
    }
    // Apartment析构时,不会delete roommates,因为他们还要活
};

就像订单系统,Order类聚合了Product。产品是独立存在的,删了订单,产品还在仓库里;删了产品,历史订单还得能查看产品信息(只是标记"已下架")。这就是聚合的典型场景。

三者的"相亲相爱一家人"对比

特性 继承 组合 聚合
关系类型 is-a 强has-a 弱has-a
生命周期 子类依赖父类 同生共死 各自独立
耦合度 高(父子绑定)
C++实现 冒号+继承 成员对象 指针/引用

现在写代码前要问自己三个问题:

  1. 真的是"是"吗?(继承)→ 不是就pass
  2. 能离开对方活吗?(能就聚合,不能就组合)
  3. 未来会怎么变?(耦合度越低,改起来越爽)

C++ 中的实现细节

这次深入代码层面,看看C++里这些关系到底是咋实现的。特别是现在有了智能指针,写起来更优雅了,但也容易踩坑。

继承的实现:那个"拼爹"的底层逻辑

继承在C++里实现起来其实挺"粗暴"的,编译器给你搞了个内存布局:子类对象里包含一个父类子对象(就像套娃)。你看代码:

c++ 复制代码
class Animal 
{
public:
    Animal(const std::string& name) : name_(name) {}
    virtual void speak() const { std::cout << name_ << "发出声音" << std::endl; }
    virtual ~Animal() = default; // 父类析构要虚
protected:
    std::string name_;
};

class Dog : public Animal 
{
public:
    Dog(const std::string& name, int barkVolume)
        : Animal(name), barkVolume_(barkVolume) 
    {
    }

    void speak() const override 
    {
        std::cout << name_ << "汪汪叫,音量:" << barkVolume_ << std::endl;
    }
private:
    int barkVolume_;
};
  1. 构造顺序:先构造父类(Animal),再构造子类自己的成员。析构相反。
  2. virtual函数表(vtable):有虚函数的类会有一个隐藏的指针(vptr)指向虚函数表,实现多态。
  3. 访问控制:protected只能子类自己用,对外还是private。

组合的实现:用 std::unique_ptr 实现"独占所有权"

组合要求整体拥有部分,部分的生命周期随整体。传统写法直接成员对象就行,我们之前已经介绍过了。

但如果Heart对象很大,或者需要动态创建(比如延迟初始化),或者需要多态,那就得用指针了。

这时候std::unique_ptr最合适------它表示独占所有权,正好符合组合的"整体独占部分"语义。

c++ 复制代码
class Person 
{
private:
    std::unique_ptr<Heart> heart_; // 独占所有权
public:
    // 方式1:构造时创建
    Person() : heart_(std::make_unique<Heart>()) {}

    // 方式2:从外部传入(但转移所有权,表示Person接管)
    Person(std::unique_ptr<Heart> heart) : heart_(std::move(heart)) {}

    void live() 
    {
        if (heart_) heart_->beat();
    }

    // 不需要手动delete,unique_ptr自动处理
};

// 使用
auto p = Person(); // heart自动创建
// 或者
auto customHeart = std::make_unique<Heart>();
auto p2 = Person(std::move(customHeart)); // customHeart现在空了,所有权转移

为什么是unique_ptr?

  • 组合强调"整体拥有部分,部分不能独立存活",所以整体对部分有唯一且明确的所有权。unique_ptr正是这种独占语义。
  • 如果整体被销毁(比如Person析构),unique_ptr自动释放Heart,完美。
  • 禁止拷贝,只能移动,防止多个Person共享同一个Heart(那就变成聚合了)。

不过要记住千万别用裸指针自己管理,容易忘了delete。

如果Heart需要多态,比如有HumanHeart、RobotHeart继承自Heart,那unique_ptr可以指向派生类,完美支持组合的多态性。

聚合的实现:用 std::shared_ptr 实现"共享所有权"

聚合是弱拥有关系,部分可以独立存在,多个整体可以共享同一个部分。

典型例子:学生和课程,一个学生可以选多门课,一门课有多个学生,谁都不拥有谁,大家都是独立的。

c++ 复制代码
class Student; // 前向声明
class Course 
{
private:
    std::string name_;
    std::vector<std::shared_ptr<Student>> students_; // 聚合:学生指针

public:
    Course(const std::string& name) : name_(name) {}

    void addStudent(std::shared_ptr<Student> s) 
    {
        students_.push_back(s);
    }
    // 注意:Course析构时,不会销毁学生,只是减少引用计数
};

class Student : public std::enable_shared_from_this<Student>
{
private:
    std::string name_;
    std::vector<std::weak_ptr<Course>> courses_; // 注意用weak_ptr避免循环引用

public:
    Student(const std::string& name) : name_(name) {}

    void enroll(std::shared_ptr<Course> c) 
    {
        courses_.push_back(c);
        c->addStudent(shared_from_this()); // 需要继承enable_shared_from_this
    }
};

为什么用shared_ptr?

  • 聚合允许多个整体共享同一个部分,所以引用计数正合适。
  • 当最后一个指向部分的整体销毁时,部分自动释放(如果不再被其他地方使用)。
  • 如果两个类互相用shared_ptr引用(比如Course里有shared_ptr,Student里有shared_ptr),就会形成循环引用,导致内存泄漏!所以要在其中一个方向用weak_ptr打破循环。

weak_ptr的作用:

像上面的例子,Student里存weak_ptr,这样Course析构时,Student里的weak_ptr会自动过期,不会阻止Course销毁。

访问时需要lock()提升为shared_ptr,如果Course还在,就能得到有效指针,否则得到null。

用shared_ptr实现聚合时,要警惕循环引用这个大坑。记住:谁持有谁,谁生命周期短,谁就用weak_ptr。

总结对比

关系 所有权 C++实现方式 智能指针选择
继承 无所有权,只有复用和多态 冒号继承,虚函数 基类指针可用unique_ptr或shared_ptr,但注意虚析构
组合 整体独占部分 成员对象 或 unique_ptr成员 unique_ptr(独占所有权)
聚合 整体共享部分,但部分独立 原始指针/引用 或 shared_ptr成员 shared_ptr + weak_ptr 避免循环

写C++就像谈恋爱,组合是"你是我的唯一"(独占),聚合是"我们只是朋友"(共享),继承是"我像我爸"(is-a)。

选错了关系,代码里全是泪。用好智能指针,幸福一辈子!

实际项目中的选择原则

前面把概念和实现撸明白了,现在该上战场了------实际项目里到底怎么选?

这问题问得漂亮!因为很多人代码写多了,容易变成"手里有把锤子,看啥都是钉子"。

继承、组合、聚合这三个家伙,用对了是神器,用错了就是屎山的源头。

经典原则:先别动手,先问三个问题

依旧灵魂三问:

  1. 这是"是"的关系吗?(is-a?)------ 是的话考虑继承;否则直接跳过。
  2. 这俩东西能分开活吗?(生命周期独立?)------ 能独立就聚合,不能就组合。
  3. 未来可能会怎么变?(需求变化方向?)------ 耦合度越低,以后改起来越爽。

这三板斧下去,80%的场景都能搞定。剩下的20%,就要靠下面这些原则来微调。

原则一:组合优于继承

这话你可能听得耳朵起茧了,但我还是要说:能用组合就别用继承,除非你真的需要多态。

为啥?

因为继承是"白盒复用",子类能看到父类的实现细节,耦合度高得吓人。今天改个父类,明天子类全崩。

组合是"黑盒复用",你只管用人家提供的接口,内部怎么实现跟你没关系,想换就换。

假设我们设计了一个GameObject类,然后各种物体继承它:Player、Enemy、Bullet。

开始挺美,后来需求来了:要加一个"可渲染"的物体,还要加"可移动"的,还要加"可受伤"的......我们的继承树开始疯狂长,最后变成了这样:

然后Player要同时继承RenderableObject、MovableObject、DamageableObject?

C++又不允许多继承(虚继承搞得我头大)。最后用了"组件模式",把渲染、移动、伤害都做成组件,GameObject里组合一堆组件。世界清净了。

这就是组合的力量:把功能拆成小零件,然后像搭积木一样组装对象。

原则二:依赖倒置

这条和继承组合选择相关:要依赖于抽象,不要依赖于具体实现。

怎么理解?假如你设计一个ReportGenerator类,里面组合了一个PDFFormatter:

c++ 复制代码
class ReportGenerator 
{
private:
    PDFFormatter formatter_;  // 直接依赖具体类
public:
    void generate() { formatter_.format(); }
};

哪天老板说:"咱们也要支持Excel!"你只能改ReportGenerator代码,加个ExcelFormatter,还要加if else。这不优雅。

更好的做法是:定义抽象接口Formatter,然后组合Formatter*:

c++ 复制代码
class Formatter { public: virtual void format() = 0; };
class PDFFormatter : public Formatter { ... };
class ExcelFormatter : public Formatter { ... };

class ReportGenerator 
{
private:
    std::unique_ptr<Formatter> formatter_;  // 依赖抽象
public:
    ReportGenerator(std::unique_ptr<Formatter> f) : formatter_(std::move(f)) {}
    void generate() { formatter_->format(); }
};

现在ReportGenerator不关心具体是啥格式,只要传入一个Formatter就行。这就是组合 + 多态的威力。继承只在"抽象类"和"实现类"之间用,业务类之间用组合。

原则三:分清 is-a 和 has-a,别乱认亲戚

很多人一上来就继承,觉得"代码重用"就是继承。结果造出"正方形继承矩形"这种反人类的玩意儿。

经典案例:正方形是矩形吗?

  • 数学上:是的,正方形是一种特殊的矩形。
  • 代码里:如果矩形有setWidth()和setHeight(),那正方形继承矩形后,这俩方法就冲突了------正方形的宽和高必须相等,你只能在setWidth里同时改高。
    所以数学上的"is-a"在代码里不一定成立!

正确做法:正方形和矩形可以都继承自"形状"抽象类,各自实现自己的规则,或者干脆用组合(正方形内部包含一个矩形,但控制宽高一致)。

判断is-a的简单方法:如果你说"A是B",那B能做的事情A都能做吗?如果A不能做某件B能做的事(比如鸟会飞,但企鹅是鸟却不会飞),那就不是真正的is-a。

原则四:根据耦合度由低到高排序

设计时尽量选择耦合度低的方式:

  • 聚合:耦合度最低。双方只知道对方存在,但不拥有对方生命周期,可以独立变化。比如订单和商品。
  • 组合:耦合度中等。整体知道部分的接口,但部分不能脱离整体存在。比如人和心脏。
  • 继承:耦合度最高。子类依赖父类的实现,父类改了子类可能崩。

所以我的选择顺序是:优先聚合,其次组合,最后继承。

原则五:多态需求决定是否用继承

如果你需要多态(运行时根据实际类型调用不同方法),那继承几乎不可避免。

但注意,继承不是唯一实现多态的方式,模板也能实现编译时多态(静多态)。但在运行时多态场景,继承 + 虚函数是主流。

不过即使要用多态,也可以结合组合。比如策略模式,就是把变化的行为组合进来,而不是继承。

c++ 复制代码
class Character 
{
private:
    std::unique_ptr<WeaponBehavior> weapon_;  // 组合一个武器策略
public:
    void fight() { weapon_->use(); }  // 多态调用
};

这样Character可以动态换武器,比继承"战士""弓箭手"灵活得多。

原则六:生命周期管理决定用组合还是聚合

  • 如果A创建了B,并且B的生死完全由A控制(比如A构造时创建B,A析构时销毁B),那就用组合。
  • 如果B是外部传入的,A只是临时用一下,B有自己的生命周期,那就用聚合。

举个栗子:

  • 组合:Connection类内部有个Socket对象,连接创建时创建socket,关闭时销毁socket。socket不能独立存在。
  • 聚合:Room类里有多个Student对象,学生是从外面来的,房间关了学生还在。用vector<shared_ptr>(共享所有权)。

最后:写代码是给明天的人看的

不管你选哪种关系,记住一句话:代码是写给人看的,顺便给机器执行。

选择原则时,考虑下未来维护你代码的人(可能就是三个月后的你自己)。清晰、直观、易改,比炫技重要。

比如有人为了用继承而用继承,结果一个类继承了三个父类,虚继承套娃,看得人脑壳疼。其实完全可以用组合 + 接口拆开。这就叫"过度设计"。

所以我的最后一条原则:保持简单,但不要过于简单。能用聚合解决问题,就别硬上继承;能用组合,就别搞复杂的生命周期共享。

相关推荐
不想写代码的星星1 天前
std::function 详解:用法、原理与现代 C++ 最佳实践
c++
樱木Plus3 天前
深拷贝(Deep Copy)和浅拷贝(Shallow Copy)
c++
blasit5 天前
笔记:Qt C++建立子线程做一个socket TCP常连接通信
c++·qt·tcp/ip
肆忆_6 天前
# 用 5 个问题学懂 C++ 虚函数(入门级)
c++
不想写代码的星星6 天前
虚函数表:C++ 多态背后的那个男人
c++
端平入洛8 天前
delete又未完全delete
c++
端平入洛9 天前
auto有时不auto
c++
哇哈哈202110 天前
信号量和信号
linux·c++
多恩Stone10 天前
【C++入门扫盲1】C++ 与 Python:类型、编译器/解释器与 CPU 的关系
开发语言·c++·人工智能·python·算法·3d·aigc