烹饪披萨机
110.73M · 2026-03-10
有时候我们写代码时总会盯着屏幕,陷入沉思:
如果你也有过这种困惑,别急——今天我们就来聊聊C++实际项目里绕不开的三个关系:继承、组合与聚合。
你可以把它们想象成三种不同的“人际关系”:
在实际项目中,选错了关系,就会导致代码难以维护、扩展性差,甚至引发“重构血案”。
今天就给你讲讲这些玩意儿到底咋用,啥时候用,为啥有时候用了就跟吃了苍蝇一样难受,有时候又爽得飞起。
定义:继承是面向对象编程中,一个类(子类)获取另一个类(父类)的成员和方法的机制,形成"is-a"(是一个)关系。
继承就像"儿子继承老爹的家产"。老爹有的(public成员),儿子基本都有;老爹会的手艺(虚函数),儿子可以学,也可以改成自己的风格(override)。
// 基类 - 动物
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同生共死:
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创建的:
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++实现 | 冒号+继承 | 成员对象 | 指针/引用 |
现在写代码前要问自己三个问题:
这次深入代码层面,看看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_;
};
组合要求整体拥有部分,部分的生命周期随整体。传统写法直接成员对象就行,我们之前已经介绍过了。
但如果Heart对象很大,或者需要动态创建(比如延迟初始化),或者需要多态,那就得用指针了。
这时候std::unique_ptr最合适——它表示独占所有权,正好符合组合的“整体独占部分”语义。
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?
不过要记住千万别用裸指针自己管理,容易忘了delete。
如果Heart需要多态,比如有HumanHeart、RobotHeart继承自Heart,那unique_ptr可以指向派生类,完美支持组合的多态性。
聚合是弱拥有关系,部分可以独立存在,多个整体可以共享同一个部分。
典型例子:学生和课程,一个学生可以选多门课,一门课有多个学生,谁都不拥有谁,大家都是独立的。
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?
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)。
选错了关系,代码里全是泪。用好智能指针,幸福一辈子!
前面把概念和实现撸明白了,现在该上战场了——实际项目里到底怎么选?
这问题问得漂亮!因为很多人代码写多了,容易变成“手里有把锤子,看啥都是钉子”。
继承、组合、聚合这三个家伙,用对了是神器,用错了就是屎山的源头。
依旧灵魂三问:
这三板斧下去,80%的场景都能搞定。剩下的20%,就要靠下面这些原则来微调。
这话你可能听得耳朵起茧了,但我还是要说:能用组合就别用继承,除非你真的需要多态。
为啥?
因为继承是“白盒复用”,子类能看到父类的实现细节,耦合度高得吓人。今天改个父类,明天子类全崩。
组合是“黑盒复用”,你只管用人家提供的接口,内部怎么实现跟你没关系,想换就换。
假设我们设计了一个GameObject类,然后各种物体继承它:Player、Enemy、Bullet。
开始挺美,后来需求来了:要加一个“可渲染”的物体,还要加“可移动”的,还要加“可受伤”的……我们的继承树开始疯狂长,最后变成了这样:
然后Player要同时继承RenderableObject、MovableObject、DamageableObject?
C++又不允许多继承(虚继承搞得我头大)。最后用了“组件模式”,把渲染、移动、伤害都做成组件,GameObject里组合一堆组件。世界清净了。
这就是组合的力量:把功能拆成小零件,然后像搭积木一样组装对象。
这条和继承组合选择相关:要依赖于抽象,不要依赖于具体实现。
怎么理解?假如你设计一个ReportGenerator类,里面组合了一个PDFFormatter:
class ReportGenerator
{
private:
PDFFormatter formatter_; // 直接依赖具体类
public:
void generate() { formatter_.format(); }
};
哪天老板说:“咱们也要支持Excel!”你只能改ReportGenerator代码,加个ExcelFormatter,还要加if else。这不优雅。
更好的做法是:定义抽象接口Formatter,然后组合Formatter*:
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的简单方法:如果你说“A是B”,那B能做的事情A都能做吗?如果A不能做某件B能做的事(比如鸟会飞,但企鹅是鸟却不会飞),那就不是真正的is-a。
设计时尽量选择耦合度低的方式:
所以我的选择顺序是:优先聚合,其次组合,最后继承。
如果你需要多态(运行时根据实际类型调用不同方法),那继承几乎不可避免。
但注意,继承不是唯一实现多态的方式,模板也能实现编译时多态(静多态)。但在运行时多态场景,继承 + 虚函数是主流。
不过即使要用多态,也可以结合组合。比如策略模式,就是把变化的行为组合进来,而不是继承。
class Character
{
private:
std::unique_ptr<WeaponBehavior> weapon_; // 组合一个武器策略
public:
void fight() { weapon_->use(); } // 多态调用
};
这样Character可以动态换武器,比继承“战士”“弓箭手”灵活得多。
举个栗子:
不管你选哪种关系,记住一句话:代码是写给人看的,顺便给机器执行。
选择原则时,考虑下未来维护你代码的人(可能就是三个月后的你自己)。清晰、直观、易改,比炫技重要。
比如有人为了用继承而用继承,结果一个类继承了三个父类,虚继承套娃,看得人脑壳疼。其实完全可以用组合 + 接口拆开。这就叫“过度设计”。
所以我的最后一条原则:保持简单,但不要过于简单。能用聚合解决问题,就别硬上继承;能用组合,就别搞复杂的生命周期共享。