C++继承 这块,我见过太多人把它学成了“填空题”:清楚三种继承方式的名字,知道virtual要加在析构函数前面,还背下“继承是is-a”这句口号,但自己动手写业务类的时候照样设计得一塌糊涂。我自己也是早期在一个采集设备项目里,把一套数据链路从单继承改成多接口组合时,差点被虚基类的构造规则直接炸崩,才真正把继承当成一个需要认真设计的机制去研究。
这篇就把C++继承从头到尾、从编译期到运行期、从语法到工程实践捋一遍。适合刚学完类与对象但搞不清访问控制和虚函数关系的人,也适合准备面试时拿到“讲讲C++继承”就只会念PPT的复习党,更欢迎工作中正在纠结要不要把一个类再派生一层的同学来对答案。
先说结论里最容易让人误解的一点:继承不是“为了复用代码而把类抄下来”。如果你只想要代码复用,组合(把对方变成成员)往往更干净。C++把继承设计成语言特性,真正要解决的是“类型之间可替换、接口能向下扩展、运行期能分派”这一整套问题。下面按我自己的理解顺序拆开讲。
1. 先从“为什么要有继承”讲起:它约束的是类型关系
1.1 类之间的三种关系,为什么只有is-a被语言重点支持
设计类时,类与类之间无非三种关系:
- has-a:一个对象“拥有”另一个对象,比如Engine包含Piston。实现方式就是成员对象或智能指针成员。
- uses-a:一个对象“使用”另一个对象,通过函数参数、返回值表达。
- is-a:一个对象“是一种”更具体的另一个对象,比如Dog是Animal。
C语言里没有专门的is-a表达,你想模拟,只能把公共字段原样拷贝到每个结构体里,再用手动指针强转模拟“向上转型”。C++引入继承,本质上就是把这种“子集-超集”的关系交给编译器:Derived对象内嵌了一个Base子对象,于是Derived类型的任何实例都可以被当作Base来使用。
这带来的第一个好处是类型安全:编译器在静态类型检查阶段就允许你把Derived当作Base传出去,而不需要强转。第二个好处是接口一致:一批Derived类都从同一个Base继承,外部就能用统一接口操作它们。第三个好处是运行期多态:Base中声明virtual成员后,Derived可以改写实现,通过Base指针或引用调用时,实际执行的是派生类版本。
1.2 编译器视角:继承在对象布局上做了什么
你写:
cpp复制class Base {
public:
int a;
};
class Derived : public Base {
public:
int b;
};
Derived对象真正的内存布局大致是“先放一个Base子对象,再放自己的成员”,也就是:
text复制| Base::a | Derived::b |
Base子对象并不一定要求排在起始地址,标准只规定了“完整对象中每个基类子对象都有自己独立地址”,实际布局由ABI决定。但绝大多数平台上,单一继承就是这么排的:基类字段在前,派生类扩展字段在后。
这段布局天然解释了为什么可以把Derived当作Base使用:指针值不一定要改,因为Base部分就住在Derived对象的头部。反过来,把Base当作Derived则是危险的,因为编译器根本不知道这块内存后面是否真的有Derived::b;你必须用dynamic_cast或static_cast强制执行,并自己承担布局前提成立与否的责任。
多继承稍微复杂一点:多个基类子对象依次排列,派生类指针转换成第二个基类指针时,地址往往不是原来的起始地址,而是向后偏移。这也是“多重继承的指针转换有隐式地址修正”这件事的底层原因。记住这个结论,后面看菱形继承就不会晕。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种继承方式的真正区别:public、protected、private分别防谁
2.1 访问权限矩阵,看起来很简单但真的容易记混
继承方式不是修饰基类成员,而是“把基类成员搬运到派生类时,重新设置一个访问上限”。看这张表就会很直观:
| 基类中的访问级别 | public继承后在派生类中 | protected继承后在派生类中 | private继承后在派生类中 |
|---|---|---|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 不可直接访问 | 不可直接访问 | 不可直接访问 |
前面两行比较好理解:public继承不降低访问级别;protected继承会把基类的public成员降低成protected。最容易被忽略的其实是private继承——不管基类成员原来是public还是protected,到子类里一律变成private。
实际写代码验证一下:
cpp复制class Base {
public:
void PublicFunc() {}
protected:
void ProtectedFunc() {}
private:
void PrivateFunc() {}
};
class PublicDerived : public Base {
public:
void test() {
PublicFunc(); // OK
ProtectedFunc(); // OK
// PrivateFunc(); // 编译错误,基类私有成员永远不可直接访问
}
};
class PrivateDerived : private Base {
public:
void test() {
PublicFunc(); // OK,但外部不能调用
ProtectedFunc(); // OK
}
};
int main() {
PublicDerived pd;
pd.PublicFunc(); // OK
PrivateDerived pv;
// pv.PublicFunc(); // 编译错误:PublicFunc在private继承下对外是private
}
2.2 public继承是绝大多数场景的唯一答案
如果你建模的是真正的is-a关系,希望外部把Derived对象当作Base对象使用,只能使用public继承。C++面试里常被问“为什么不要用protected继承”——因为它产生的派生类不能对外暴露基类接口,但它又不是private继承那样决绝地只想“借用实现”。protected继承真正有用的场景非常窄,最常见的可能是某些框架代码里,让孙子类能看到爷爷类的protected接口,同时对外隐藏基类身份。但这种设计很容易让后续维护者混乱,我自己的用法是:宁可显式用成员对象组合,也不轻易上protected继承。
2.3 private继承:语法上是继承,语义上应该当作“实现了”
看书时会看到一句话:private继承表达的是“implemented in terms of”,即“根据基类实现出派生类”。它不是is-a,只是借用基类的能力。最典型的一个点:private继承之后,外部无法把Derived转换成Base,所以它骗不了别人自己是Base。
举例,你想实现一个Stack,内部想复用标准库的deque能力,但不想对外暴露所有deque方法:
cpp复制class MyStack : private std::deque<int> {
public:
void push(int x) { push_back(x); }
void pop() { pop_back(); }
int top() const { return back(); }
};
外部拿到MyStack后,不能调push_front,也不能把它转成deque用。这跟组合很像。那为什么有时不用组合而用private继承?
一是空基类优化。让自定义类型继承空基类往往不增加对象大小,但如果你把空基类作为成员保存,它至少要占用一个字节,空基类优化不适用于成员对象:
cpp复制class Empty {};
struct WithMember {
Empty e; // 某些实现下对象大小会是1甚至更大
int x;
};
// sizeof(WithMember) 在常见32位平台上可能是8
struct WithPrivateInherit : private Empty {
int x;
};
// 可能只有4
二是如果基类需要访问你的protected成员,private继承比组合容易打通:基类成员函数能通过this指针调用派生类中从它继承下来的内容,不需要在派生类里手写转发。
但在现代C++代码里,组合的优先级应该高于private继承,因为组合关系更明确,不会把不必要的保护成员塞进同一个作用域。空基类优化这种优化场景,也更多出现在模板元编程中,普通业务代码少用为妙。
还有一个骚操作值得一提:private继承后可以用using把基类方法重新暴露出来,用法类似“定向开门”:
cpp复制class MyStack : private std::deque<int> {
public:
using std::deque<int>::push_back;
using std::deque<int>::pop_back;
};
但这么做基本等于承认“我其实只是不想要这个类完整接口,但我还是希望隔离”,此时用组合往往更直白。
3. 构造、析构与切片:对象生命周期里的隐藏规则
3.1 不要在构造函数里调用虚函数
先明确构造顺序。构造一个Derived对象时,编译器按这个顺序执行:
- 如果是从某个具体类继承,先调用基类构造函数。
- 再按成员声明的顺序初始化本类成员。
- 最后执行自己构造函数体。
析构顺序完全相反:先执行自己的析构函数体,再逆序析构成员,最后析构基类。
这个顺序不是随便定的。派生类构造函数体运行前,基类和成员对象必须已经准备好,否则你在函数体里访问成员或基类公共接口就是在用一个未初始化的对象。反过来,析构时也要先销毁派生新增部分,因为基类析构函数一旦运行,基类里的数据可能已经失效,如果还留着派生类资源被基类清理,就会乱套。
基于这一点,构造函数里调用virtual函数几乎总是得到你没想到的结果:
cpp复制class Base {
public:
Base() { Init(); }
virtual void Init() { std::cout << "Base::Init\n"; }
};
class Derived : public Base {
public:
Derived() : Base() {}
virtual void Init() override { std::cout << "Derived::Init\n"; }
};
int main() {
Derived d;
// 输出:Base::Init
}
Derived对象构造到Base构造函数这一步时,Derived部分还没开始构造,vptr还指向Base的虚表,于是virtual暂态绑定到Base版本。这是很多初学者把初始化逻辑塞进虚函数后踩到的第一个大坑。正确做法是构造函数只初始化状态,把需要多态行为的部分放到两段式构造或单独初始化函数中,或者干脆把基类构造函数里的初始化从虚函数调用改成普通非虚私有初始化。
3.2 基类析构函数到底加不加virtual
这是一道被问烂但依然有人翻车的题:如果基类析构函数不是virtual,通过基类指针delete派生类对象是未定义行为。实际常见的表现是“只析构了基类部分,派生类部分泄漏”。
看这段:
cpp复制class Base {
public:
~Base() {}
};
class Derived : public Base {
public:
~Derived() { /* 释放派生类资源 */ }
};
int main() {
Base* p = new Derived;
delete p; // 只调用~Base,Derived析构不执行
}
原因没有多神秘:delete表达式要决定调用哪个析构函数,它需要从对象的动态类型入手。编译器把p当作Base*,只知道调用Base的析构函数,因为它没有在Base里看到virtual标记,也就不会走虚函数分派。要让delete正确,基类析构函数必须virtual:
cpp复制class Base {
public:
virtual ~Base() = default;
};
只要基类有虚析构,delete Base*时就会先查虚表,调用最派生类版本的析构,再从内向外逐层析构。所以经验法则是:只要一个类设计出来允许被继承,而派生类可能通过基类指针释放,就给它定义virtual析构函数;如果一个类本来就不打算被继承,直接用final标记更稳当,省掉虚表指针也少一点空间开销。
3.3 切片:派生类对象按值传给基类是“有去无回”的截断
继承体系中一个非常隐蔽的坑是按值传参导致的切片。你写一个接受Animal参数的函数,然后传入Dog,Dog里自己扩展的成员、虚函数重写什么的会因为“复制出一个新的Animal对象”而全部丢失:
cpp复制class Animal {
public:
virtual void Speak() const { std::cout << "animal sound\n"; }
};
class Dog : public Animal {
public:
void Speak() const override { std::cout << "dog barks\n"; }
void Fetch() const {}
};
void Play(Animal a) {
a.Speak();
}
int main() {
Dog dog;
Play(dog); // 输出 animal sound
}
这里的dog在传入时确实构造了一个Animal对象,但构造过程只负责从Dog中切出Animal基类子对象并进行复制,Dog自己的部分连进都没进去。所以a本身的动态类型就是Animal,虚函数调用自然走Animal版本。
如果需要多态行为,传指针或引用:
cpp复制void Play(Animal& a) {
a.Speak();
}
// 输出 dog barks
用引用不会复制对象,虚函数才能根据对象动态类型分派到Dog版本。这个坑在函数参数、容器存储、返回局部对象时最容易出现:要存多态对象,就存指针或std::unique_ptr/ref,不要直接存对象值。
4. 重载、隐藏、覆盖,三个非常容易缠在一起的Name Lookup问题
很多C++面试题喜欢写一堆同名函数,让候选人判断调用哪个。背后的关键其实是两个规则:名字查找先于函数匹配;派生类作用域嵌套在基类之上。
4.1 重载只发生在同一作用域
同一个类内部写多个同名函数,靠参数列表区分,叫重载。这一点在继承出现后会产生误导,因为很多人默认“基类和派生类同名函数也能重载”,其实不能。一旦派生类声明了一个同名函数,基类里所有同名函数都会被“遮蔽”,不重载、不比较参数。
cpp复制class Base {
public:
void Print(int x) { std::cout << "Base::Print(int)\n"; }
};
class Derived : public Base {
public:
void Print(const std::string& s) { std::cout << "Derived::Print(string)\n"; }
};
int main() {
Derived d;
d.Print(42);
// 编译错误:Base::Print(int)被藏起来了,Derived::Print只接受std::string
}
要让基类所有Print版本重新参与重载,可以在Derived里写using Base::Print;:
cpp复制class Derived : public Base {
public:
using Base::Print;
void Print(const std::string& s) { /*...*/ }
};
这样Derived对象既能调用int版本,也能调用string版本。但说真的,在派生类里给基类函数起相同名字通常不是一个好设计。如果只是想覆盖接口,函数名和签名应该保持一致;如果只是扩展接口,换个名字更清晰。
4.2 隐藏 vs 覆盖:有没有virtual天差地别
同名函数在派生类里发生的情况分两种:
- 隐藏(hiding):基类函数没有virtual,或者虽然有virtual但签名不同。此时通过派生类作用域直接查找会先命中派生类版本,基类版本被藏起来。
- 覆盖(override):基类函数带virtual,且派生类函数在函数名、参数列表、const限定等关键签名上完全一致。这种覆盖能让程序在运行期通过基类指针引用找到派生类实现。
把它们放一块看:
cpp复制class Base {
public:
virtual void Update() { std::cout << "Base::Update\n"; }
void Reset() { std::cout << "Base::Reset\n"; }
};
class Derived : public Base {
public:
// 覆盖:签名一致且基类是virtual
void Update() override { std::cout << "Derived::Update\n"; }
// 隐藏:虽然没有virtual,但签名一致,把Base::Reset藏住了
void Reset() { std::cout << "Derived::Reset\n"; }
};
int main() {
Base* b = new Derived;
b->Update(); // 多态:Derived::Update
b->Reset(); // 静态绑定:Base::Reset,因为Reset不是virtual
}
这段代码能很好地说明隐藏和覆盖的分水岭:Base指针调Update,因为Base::Update是virtual,运行期会根据对象的真实类型找到Derived::Update;Base指针调Reset,编译器只看到Base::Reset是非虚函数,于是静态绑定到Base版本,Derived::Reset根本没有参与分派。
4.3 为什么一定要加override
在派生类重写函数时漏加virtual?不需要,基类virtual的虚函数属性会传递,派生类即便不写virtual也仍然是虚函数。真正危险的是漏写override导致“本想覆盖却写成了隐藏”。
C++11把override关键字给了你,就是让编译器帮你检查签名是否匹配:
cpp复制class Base {
public:
virtual void Load(int id);
};
class Derived : public Base {
public:
void Load(double id) override; // 编译错误:签名不匹配,Base中没有可覆盖的Load(double)
};
如果函数签名因为某个时候改了参数类型,而派生类没同步,运行期往往会产生“基类函数被静默调用”的诡异表现。加上override后,编译器直接把错误拦在编译期。在代码审查里看到这个关键字,等于告诉后来者“这个函数是有意参与多态的,别乱删”。同样,final关键字可以放在virtual函数后面,禁止再往下覆盖;也可以放在类名后,表示这个类不能再被继承。对一个设计已经收敛的类,声明final能有效阻止别人误继承,也帮你避免多态析构那些隐患。
5. 菱形继承和虚继承:多继承里最让人头大的部分
5.1 菱形是怎么形成的
如果在多个继承层级中,同一个基类被两个派生类同时继承,而某个最派生类又同时继承这两个中间层,就会形成菱形。代码长这样:
cpp复制class Animal {
public:
void Eat() {}
};
class FlyingAnimal : public Animal {};
class SwimmingAnimal : public Animal {};
class Duck : public FlyingAnimal, public SwimmingAnimal {};
直觉上,一只Duck应该有且只有一个Animal子对象,因为“鸭子是动物”没有歧义。但在普通多继承下,Duck里会存在两个Animal子对象:一个来自FlyingAnimal,一个来自SwimmingAnimal。这时你访问d.Eat(),编译器会报歧义,因为它不知道你到底要Eat飞行动物里的动物,还是游泳动物里的动物。
你可以通过作用域限定硬访问:
cpp复制Duck d;
d.FlyingAnimal::Eat();
但对象里确实有两份Animal状态,这个问题在数据字段上更明显:如果Animal里有一个age字段,Duck就有两个age副本,每次更新只能改到其中一份,别的代码用另一份数据时就会看到诡异的不一致。
5.2 虚继承要解决的是“只有一个共享基类子对象”
把Animal变成FlyingAnimal和SwimmingAnimal的虚基类:
cpp复制class Animal {
public:
int age = 0;
};
class FlyingAnimal : virtual public Animal {};
class SwimmingAnimal : virtual public Animal {};
class Duck : public FlyingAnimal, public SwimmingAnimal {};
现在Duck里只有一个Animal子对象,两条派生路径共享它。这样能解决重复状态和访问歧义,但它不是免费午餐。实现虚继承通常需要给每个虚继承的中间类添加一个虚基类指针或类似机制,对象布局变复杂,访问虚基类成员也不像普通成员那样固定偏移,而是间接寻址。对象变大、访问变慢,是虚继承的典型代价。
更麻烦的是构造规则。虚基类构造函数由最派生类负责调用,普通基类则是由它的直接派生类调用。改一下上面的代码:
cpp复制class Animal {
public:
explicit Animal(int age) : age(age) {}
int age;
};
class FlyingAnimal : virtual public Animal {
public:
FlyingAnimal() : Animal(1) {}
};
class SwimmingAnimal : virtual public Animal {
public:
SwimmingAnimal() : Animal(2) {}
};
class Duck : public FlyingAnimal, public SwimmingAnimal {
public:
Duck() : Animal(3), FlyingAnimal(), SwimmingAnimal() {}
};
创建Duck时,Animal(3)会被执行,FlyingAnimal和SwimmingAnimal初始化列表中的Animal(1)、Animal(2)会被忽略。理解到这个程度才算真正看清虚构造规则:谁的构造函数里写了Animal都能调,但只有最派生类的那次初始化是最终生效的。
多数业务代码不应该为了设计一个虚继承体系而增加这种复杂度。C++社区的主流意见是:多用单继承+接口,少用真正需要共享虚基类的深菱形。像Java、C#那种只允许接口多继承的语言,其实是在用一种更受控的方式解决类似问题:接口没有数据,所以不会出现多个数据副本。
5.3 让我用菱形继承的教训收个尾
我真正被虚基类炸到是改一个控件通知链时,当时有个基类叫做WidgetBase,下面有Button、Slider,再往下有个组合控件,同时继承Button和Slider,形成一个菱形。原设计者用virtual继承共享WidgetBase,看起来逻辑没问题,但后续在Duck最派生类里补构造参数时漏了直接调用WidgetBase构造,而中间两个类各自又更新了WidgetBase字段,于是每次创建控件后,有的字段来自Button路径,有的字段来自Slider路径,同一个组件内的状态互相矛盾。
排查下来发现虚继承的构造规则不是靠直觉能搞定的。从那以后我给自己立了一条规矩:菱形继承是万不得已才用的工具,如果能用多个独立接口加组合成员表达,绝不上virtual继承。接口多继承反而比较安全,因为纯接口类往往没有数据,共享同一个纯虚基类不会造成状态重复。
6. 工程现场再问一句:这个继承该不该用?
6.1 组合优先,继承是最后的选择
很多人从学校带出来的习惯是“只要能复用就继承”,这其实把继承当成了包的包装纸。实际项目里,我看到越来越多的问题不是“代码复用不了”,而是“派生类被基类的实现细节绑架”:基类改一个字段布局,所有派生类都要重编;基类加了private影响派生类寻址;公开的protected成员让派生类和外部耦合得乱七八糟。
我判断要不要用继承,会先问三个问题:
- 它真的满足is-a吗?如果不能说出“Derived在某些场景可以安全替换Base”这句话,就别继承。
- 我真的需要向上转型或运行期多态吗?如果只是为了复用某个工具的代码,组合加一个转发函数就够了。
- 派生类和基类之间能不能长期维持这种抽象?如果基类内部有一堆需要派生类知道的实现细节,继承很容易退化成共享全局变量的通道。
典型反例是“正方形继承长方形”。数学上正方形是长宽相等的长方形,但用可变长宽的长方形基类建模,正方形会破坏基类的不变量:SetWidth让正方形边长变了,但高度没跟着变,外部通过长方形接口用正方形就会出问题。此时用组合,让Square包含一个Rectangle甚至直接存边长,更贴合实际行为。
6.2 经典八股题的一线答案
把这几年被反复问到的几道题整理成一套能说出口的版本:
- 为什么基类析构函数要是虚函数:为了让delete基类指针时能沿着虚表找到最派生类析构,层层析构整棵继承链。不加virtual会未定义行为,常见表现是派生类资源泄漏。
- 为什么构造函数不能是虚函数:构造函数执行前对象还不存在,没有vptr可用。而且构造过程中对象的动态类型一直在从基类向派生类迁移,即便能虚分派也没有稳定的“当前类型”。
- 构造函数里调用虚函数为什么调用不到派生类版本:构造基类阶段vptr指向基类虚表,virtual调用被限制在正在构造的基类作用域内,属于有意为之,避免在派生类成员未初始化时就用派生逻辑。
- 继承和组合怎么选:继承适合稳定且真正的is-a关系,组合适合has-a、uses-a和大多数“只是借能力”的场景。组合修改成本低,不破坏封装,优先选组合。
6.3 一个能立刻改善代码的检查习惯
看自己写的继承代码时,我习惯做一个很廉价的检查:把派生类的每个public方法逐个念一遍,看是否有大量只是为了转发给基类方法的空壳函数。如果一页代码里全是这种转发,就说明这个类既不是纯is-a,也没有自己独立的行为,它更像是组合需求的误用继承。反过来,如果一个基类几乎没有非纯虚接口,全是一堆默认空实现,也要警惕:这可能不是抽象基类,而是某个具体类的“开洞版”,后续有大概率需要重构成接口加显式组合。
真正让C++继承显得高级的不是你用了多重继承或虚函数表技巧,而是你能准确描述出每个派生类和基类之间那层关系在什么时候成立、什么时候被破坏。掌握这一点远比背诵“创建者模式”“模板方法模式”更有用。在我目前维护的代码里,看到的大多数继承是建立在public单继承和纯接口之上的,菱形和虚继承几乎只在底层框架或特殊组件里出现。把这条经验抄进自己的项目规范,后面能少调试很多对象布局问题。
