C++ 继承:从语法到内存,一次讲透
前言
学完类与对象之后,继承是 C++ 面向对象编程里第一个真正让人"开窍"的概念。很多人学继承的时候只记住了 class Dog : public Animal 这种写法,但对于底层到底发生了什么、内存里到底是怎么排布的,一问三不知。
这篇文章的目标就是:让你不仅能写出继承的代码,还能在脑子里画出任意继承体系的内存布局。 不管是面试还是看 STL 源码,这些底层知识都会反复用到。
1. 继承的基本语法
三种继承方式
C++ 提供了三种继承方式:public、protected、private。很多教程一笔带过,但这三种方式对派生类访问基类成员的影响,是整个继承体系的基础。
cpp
class Base {
public:
int pub;
protected:
int prot;
private:
int priv; // 派生类永远无法直接访问
};
// public 继承:基类成员的访问权限不变
class Derived1 : public Base {
// pub → public
// prot → protected
// priv → 不可访问
};
// protected 继承:基类的 public 成员降级为 protected
class Derived2 : protected Base {
// pub → protected
// prot → protected
// priv → 不可访问
};
// private 继承:基类的所有成员降级为 private
class Derived3 : private Base {
// pub → private
// prot → private
// priv → 不可访问
};
关键结论:继承方式决定了基类成员在派生类中的最高访问权限。 比如 protected 继承意味着"基类的 public 成员到了我这儿最多只能是 protected",外部代码无法通过派生类对象访问这些成员。
is-a 关系 vs has-a 关系
这是面向对象设计里最基础也最容易搞混的概念:
- is-a(继承) :Dog is an Animal。狗是一种动物,用
public继承表达。 - has-a(组合) :Car has an Engine。车有一个引擎,用成员对象表达。
cpp
// is-a:用继承
class Animal { /* ... */ };
class Dog : public Animal { /* ... */ }; // 狗是动物
// has-a:用组合
class Engine { /* ... */ };
class Car {
Engine engine; // 车有一个引擎,把它作为成员
};
判断标准:如果 B 在所有使用 A 的场景下都能替代 A,用继承;否则用组合。 后面会讲到,滥用继承会导致菱形继承等一堆问题,能组合就别继承,这是铁律。
2. 构造与析构:谁先谁后?
构造函数调用链
派生类的构造函数执行顺序是固定的,记死就行:
基类构造函数 → 成员对象构造函数 → 派生类构造函数体
cpp
#include <iostream>
using namespace std;
class Base {
public:
Base() { cout << "Base 构造" << endl; }
~Base() { cout << "Base 析构" << endl; }
};
class Member {
public:
Member() { cout << "Member 构造" << endl; }
~Member() { cout << "Member 析构" << endl; }
};
class Derived : public Base {
public:
Derived() { cout << "Derived 构造" << endl; }
~Derived() { cout << "Derived 析构" << endl; }
private:
Member m; // 成员对象
};
int main() {
Derived d;
return 0;
}
输出:
Base 构造
Member 构造
Derived 构造
Derived 析构
Member 析构
Base 析构
析构顺序与构造顺序完全相反,像栈一样后进先出。 这个顺序保证了派生类的析构函数可以安全地使用基类和成员对象的资源,因为它们在派生类析构时仍然存活。
为什么析构函数要声明为 virtual?
看一段有问题的代码:
cpp
class Base {
public:
~Base() { cout << "Base 析构" << endl; } // 非虚析构函数!
};
class Derived : public Base {
public:
~Derived() { cout << "Derived 析构" << endl; }
private:
int* data = new int[100]; // 动态分配的资源
};
int main() {
Base* p = new Derived();
delete p; // 只调用了 Base::~Base(),Derived::~Derived() 没被调用!
return 0; // data 指向的内存泄漏了
}
输出只有 Base 析构,Derived 的析构函数根本没被调用,data 泄漏了。
核心原因:非虚析构函数是静态绑定,delete 只根据指针的静态类型(Base*)来决定调用哪个析构函数。
把 ~Base() 改成 virtual ~Base() 后,析构函数调用变为动态绑定,delete p 会先调 Derived::~Derived() 再调 Base::~Base(),资源被正确释放。
铁律:只要一个类可能被继承,就把析构函数声明为 virtual。 STL 容器不这么干是因为它们不是为继承设计的,而且加虚函数会增加一个 vptr 的开销。
初始化列表中的基类构造
如果基类没有默认构造函数,你必须在派生类的初始化列表中显式调用基类构造函数:
cpp
class Base {
public:
Base(int x) : value(x) {} // 没有默认构造函数
private:
int value;
};
class Derived : public Base {
public:
// 必须在初始化列表中调用 Base(int)
Derived(int x, double y) : Base(x), ratio(y) {}
private:
double ratio;
};
初始化列表中的书写顺序不影响实际执行顺序,实际顺序永远是:基类 → 成员(按声明顺序)→ 派生类自身。
3. 派生类的内存布局
理解内存布局是区分"会用 C++"和"理解 C++"的分水岭。很多人用 sizeof 只为了算大小,但从内存布局的角度看 sizeof,能看到整个继承体系的骨架。
基类子对象的位置
派生类对象中,基类子对象排在最前面,紧跟着是派生类自己的成员变量。 这是 C++ 标准规定的,也是实现多态的基础。
cpp
class Base {
public:
int a;
int b;
};
class Derived : public Base {
public:
int c;
};
int main() {
Derived d;
cout << "&d = " << &d << endl;
cout << "&d.a = " << &(d.a) << endl;
cout << "&d.b = " << &(d.b) << endl;
cout << "&d.c = " << &(d.c) << endl;
cout << "sizeof = " << sizeof(Derived) << endl;
}
典型输出(64 位系统):
&d = 0x7fff1234
&d.a = 0x7fff1234 ← 和对象地址相同,基类在最前面
&d.b = 0x7fff1238
&d.c = 0x7fff123c
sizeof = 12
内存布局一目了然:
+--------+ ← d 的起始地址
| a (4) | ← Base 子对象
+--------+
| b (4) |
+--------+
| c (4) | ← Derived 自己的成员
+--------+
为什么基类必须在前面?
因为 Base* p = &d 这种向上转型必须成立。如果基类子对象不在最前面,p 指向的就不是 Base 的内存区域了。编译器通过不改变地址 的方式实现向上转型------Base* 和 Derived* 指向同一个地址,只是 Base* 的"视野"只能看到基类部分。
cpp
Derived d;
Base* pb = &d; // 隐式向上转型,地址不变
Derived* pd = &d;
cout << (void*)pb << endl; // 同一个地址
cout << (void*)pd << endl; // 同一个地址
内存对齐的坑
如果有虚函数,布局会变复杂:
cpp
class Base {
public:
virtual void foo() {}
int a;
};
class Derived : public Base {
public:
int b;
};
// sizeof(Base) = 16(vptr 8 + int 4 + padding 4)
// sizeof(Derived) = 16(vptr 8 + a 4 + b 4 = 16,刚好对齐)
vptr 放在对象的最前面,占 8 字节(64 位),然后才是成员变量。 对齐规则按最大成员对齐(这里是 vptr 的 8 字节)。
4. 虚函数与多态
虚函数是 C++ 实现运行时多态的核心机制。理解它,你得搞清楚三个东西:vptr、vtable、以及它们是怎么配合的。
vptr 和 vtable 是什么
每个包含虚函数的类(或者继承自含虚函数的类),编译器会为它生成一个虚函数表(vtable) 。vtable 是一个函数指针数组,每个虚函数占一个槽位。同时,每个对象实例里会多一个隐藏的指针------虚函数指针(vptr),指向它所属类的 vtable。
cpp
class Animal {
public:
virtual void speak() { cout << "Animal speaks" << endl; }
virtual void move() { cout << "Animal moves" << endl; }
};
class Dog : public Animal {
public:
void speak() override { cout << "Woof!" << endl; }
// move() 继承自 Animal,不重写
};
class Cat : public Animal {
public:
void speak() override { cout << "Meow!" << endl; }
void move() override { cout << "Cat pounces" << endl; }
};
内存布局(64 位,简化):
Animal 对象: Animal vtable:
+--------+ +------------------+
| vptr --|--------------> | &Animal::speak() |
+--------+ +------------------+
| ... | | &Animal::move() |
+--------+ +------------------+
Dog 对象: Dog vtable:
+--------+ +------------------+
| vptr --|--------------> | &Dog::speak() | ← 重写了
+--------+ +------------------+
| ... | | &Animal::move() | ← 没重写,指向基类版本
+--------+ +------------------+
Cat 对象: Cat vtable:
+--------+ +------------------+
| vptr --|--------------> | &Cat::speak() | ← 重写了
+--------+ +------------------+
| ... | | &Cat::move() | ← 重写了
+--------+ +------------------+
虚函数调用到底发生了什么?
当你写 p->speak() 而 p 是 Animal* 类型时,编译器的实际动作是:
- 通过
p找到对象的 vptr(对象的前 8 字节) - 通过 vptr 找到 vtable
- 在 vtable 中找到
speak()对应的槽位(编译时就知道是第几个槽位) - 调用该槽位指向的函数
cpp
Animal* p = new Dog();
p->speak(); // 输出 "Woof!"
// 实际执行:(*(p->vptr[0]))(p);
// vptr[0] 是 speak 在 vtable 中的位置(编译时确定)
// 运行时 vptr 指向 Dog 的 vtable,所以调用 Dog::speak()
虚函数调用的开销:两次指针解引用(vptr → vtable → 函数指针),比普通函数调用多一次间接寻址。 大多数场景下这可以忽略不计,但如果你在嵌入式或高频交易场景,这个开销就需要考虑了。
override 和 final
这俩关键字是 C++11 引入的,用来防止虚函数相关的低级错误:
cpp
class Base {
public:
virtual void foo(int) {}
virtual void bar() {}
};
class Derived : public Base {
public:
void foo(double) override {} // 编译错误!参数类型不匹配,不是重写
void foo(int) override {} // 正确,重写了 Base::foo(int)
void bar() final {} // 正确,且禁止子类再重写 bar()
};
class MoreDerived : public Derived {
public:
void bar() override {} // 编译错误!bar() 被标记为 final
};
习惯:派生类中重写虚函数时一律加 override,让编译器帮你检查。 这个习惯能避免不少因为函数签名不匹配而导致的"我以为重写了但其实没有"的 bug。
纯虚函数与抽象类
纯虚函数把一个类变成抽象类------不能实例化,只能被继承:
cpp
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual void draw() const = 0;
// 纯虚函数也可以有实现,但子类仍然必须重写
};
class Circle : public Shape {
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
void draw() const override { /* 画圆 */ }
private:
double radius;
};
// Shape s; // 编译错误!不能实例化抽象类
Circle c(5.0); // OK
含有纯虚函数的类 = 抽象类 = 接口。在 C++ 中,抽象类用来定义接口契约,类似于 Java 的 interface。
5. 多重继承
C++ 是为数不多的支持多继承的主流语言。多继承很强大,但用不好会带来各种麻烦。
多继承的内存布局
cpp
class A {
public:
int a;
virtual void fa() {}
};
class B {
public:
int b;
virtual void fb() {}
};
class C : public A, public B {
public:
int c;
void fa() override {} // 重写 A 的虚函数
void fb() override {} // 重写 B 的虚函数
};
内存布局(64 位,简化):
C 对象:
+--------+ ← C* 和 A* 指向这里
| vptr_A |--→ C 的 vtable(A 部分)
+--------+
| a (4) |
+--------+ ← padding(对齐到 8 字节)
| pad |
+--------+ ← B* 指向这里(地址偏移!)
| vptr_B |--→ C 的 vtable(B 部分)
+--------+
| b (4) |
+--------+
| c (4) |
+--------+
多继承的关键:第二个及之后的基类子对象不在对象起始位置,向上转型时指针地址会变!
cpp
C obj;
A* pa = &obj;
B* pb = &obj;
cout << (void*)pa << endl; // 假设地址 X
cout << (void*)pb << endl; // 地址 X + 16(跳过了 A 子对象和 padding)
cout << (void*)&obj << endl; // 地址 X,和 pa 相同
这个地址偏移是编译器自动处理的,写代码时一般不用关心,但当你做 reinterpret_cast 或者其他危险操作时,必须知道这个偏移的存在。
多继承的构造顺序
构造顺序按继承声明顺序,不是初始化列表顺序:
cpp
class C : public A, public B { // 先 A 后 B
public:
C() : B(), A() {} // 初始化列表写 B 在前没用
// 实际构造顺序:A() → B() → C()
};
二义性问题
如果两个基类有同名成员,直接访问会产生二义性:
cpp
class A { public: void func() {} };
class B { public: void func() {} };
class C : public A, public B {};
C obj;
// obj.func(); // 编译错误!二义性
obj.A::func(); // OK,明确指定用 A 的版本
obj.B::func(); // OK,明确指定用 B 的版本
6. 菱形继承与虚继承
菱形继承是 C++ 多继承中最经典的问题,也几乎是面试必考题。
菱形继承问题
cpp
class Animal {
public:
int age; // 每个动物都有年龄
};
class Mammal : public Animal {}; // 哺乳动物
class Bird : public Animal {}; // 鸟类
// 鸭嘴兽既是哺乳动物又是卵生动物(鸟类)
class Platypus : public Mammal, public Bird {};
问题来了:Platypus 对象里有两份 age------一份来自 Mammal 路径,一份来自 Bird 路径。
Platypus 对象:
+------------------+
| Mammal 子对象 |
| +-----------+ |
| | Animal | | ← 第一份 age
| | age | |
| +-----------+ |
+------------------+
| Bird 子对象 |
| +-----------+ |
| | Animal | | ← 第二份 age(冗余!)
| | age | |
| +-----------+ |
+------------------+
cpp
Platypus p;
p.age = 5; // 编译错误!二义性,哪个 age?
p.Mammal::age = 5; // OK,访问 Mammal 路径的 age
p.Bird::age = 5; // OK,访问 Bird 路径的 age
虚继承的解决方案
用 virtual 关键字继承,让编译器只保留一份共享的基类子对象:
cpp
class Animal {
public:
int age;
};
class Mammal : virtual public Animal {}; // 虚继承
class Bird : virtual public Animal {}; // 虚继承
class Platypus : public Mammal, public Bird {};
内存布局变化:
Platypus 对象(虚继承):
+------------------+
| vptr_Mammal |--→ Mammal 的 vtable
+------------------+
| Mammal 成员... |
+------------------+
| vptr_Bird |--→ Bird 的 vtable
+------------------+
| Bird 成员... |
+------------------+
| Platypus 成员... |
+------------------+
| Animal 子对象 | ← 只有一份!放在最后
| age |
+------------------+
虚继承的内存代价:每个虚继承的派生类多了两个指针------vptr 和指向虚基类子对象的偏移指针(vbptr)。
虚继承下的构造顺序
虚基类的构造顺序变得特殊:
- 所有虚基类的构造函数(按深度优先、从左到右)
- 直接非虚基类的构造函数(按声明顺序)
- 成员对象的构造函数
- 派生类自身的构造函数
关键:虚基类由最派生类(most derived class)负责构造,而不是它的直接子类。
cpp
class Animal {
public:
Animal() { cout << "Animal" << endl; }
};
class Mammal : virtual public Animal {
public:
Mammal() { cout << "Mammal" << endl; }
};
class Bird : virtual public Animal {
public:
Bird() { cout << "Bird" << endl; }
};
class Platypus : public Mammal, public Bird {
public:
Platypus() { cout << "Platypus" << endl; }
};
int main() {
Platypus p;
// 输出:
// Animal ← 虚基类最先构造,由 Platypus 负责
// Mammal
// Bird
// Platypus
}
注意:虽然 Mammal 和 Bird 的初始化列表里可能写了 Animal(),但构造 Platypus 时,Animal 的构造函数只被调用一次,且由 Platypus 直接调用。Mammal 和 Bird 初始化列表中对 Animal 的调用被忽略。
7. 常见陷阱与最佳实践
切片问题(Object Slicing)
这是 C++ 独有的坑,按值传递派生类对象给基类参数时,派生类部分被"切掉":
cpp
class Base {
public:
virtual void print() { cout << "Base" << endl; }
};
class Derived : public Base {
public:
void print() override { cout << "Derived" << endl; }
int extraData = 42;
};
void processByValue(Base b) { // 按值传递!
b.print(); // 永远输出 "Base"
}
int main() {
Derived d;
processByValue(d); // 切片!d 的 Derived 部分被切掉,只拷贝 Base 部分
// 输出 "Base",不是 "Derived"
}
切片不仅丢失了派生类的数据成员,还丢失了虚函数的多态行为------因为拷贝构造的是 Base 对象,vptr 指向 Base 的 vtable。
解决方案:传递引用或指针。
cpp
void processByRef(Base& b) { b.print(); } // 引用,保持多态
void processByPtr(Base* p) { p->print(); } // 指针,保持多态
向上转型与向下转型
- 向上转型(派生类 → 基类):隐式转换,安全。
- 向下转型(基类 → 派生类) :必须显式转换,且只有
dynamic_cast是安全的。
cpp
Base* pb = new Derived();
// Derived* pd1 = pb; // 编译错误!不能隐式向下转型
Derived* pd2 = static_cast<Derived*>(pb); // OK,但无运行时检查
Derived* pd3 = dynamic_cast<Derived*>(pb); // OK,运行时检查
Base* pb2 = new Base();
Derived* pd4 = static_cast<Derived*>(pb2); // 编译通过,但运行时行为未定义!
Derived* pd5 = dynamic_cast<Derived*>(pb2); // 返回 nullptr,安全
规则:用 static_cast 做向下转型的前提是你 100% 确定对象的实际类型。不确定时用 dynamic_cast,失败返回 nullptr(指针)或抛 std::bad_cast(引用)。
不要在构造/析构函数中调用虚函数
cpp
class Base {
public:
Base() { init(); } // 构造函数中调用虚函数
virtual void init() { cout << "Base::init" << endl; }
};
class Derived : public Base {
public:
void init() override { cout << "Derived::init" << endl; }
};
int main() {
Derived d; // 输出 "Base::init",不是 "Derived::init"!
}
原因:构造 Derived 时,先执行 Base 的构造函数。在 Base 的构造函数执行期间,对象的动态类型就是 Base,vptr 指向 Base 的 vtable,所以虚函数调用不会派发到 Derived 的版本。
同理,析构函数中调用虚函数也会派发到当前类的版本,而不是派生类的版本------因为派生类已经析构完了。
继承 vs 组合
这是面向对象设计里最常被讨论的话题:
| 维度 | 继承 | 组合 |
|---|---|---|
| 关系 | is-a | has-a |
| 耦合度 | 高(子类依赖父类实现) | 低(只依赖接口) |
| 灵活性 | 静态,编译时确定 | 动态,运行时可以替换 |
| 访问控制 | 子类可访问 protected 成员 | 只能访问 public 接口 |
| 代码复用 | 白盒复用(知道父类实现) | 黑盒复用(只关心接口) |
优先使用组合,除非有明确的 is-a 关系且需要多态。 这个原则在 GoF 设计模式里被反复强调,也是现代 C++ 的共识。
总结
回顾一下这篇文章的核心知识点,从最简单的语法到最底层的实现:
- 三种继承方式 控制的是基类成员在派生类中的最高访问权限,
private继承几乎只在实现细节中使用。 - 构造顺序 :基类 → 成员 → 自身。析构顺序相反。析构函数要声明为 virtual。
- 内存布局:基类子对象在最前面,vptr 在最前面。多继承时后面的基类有地址偏移。
- 虚函数 通过 vptr + vtable 实现运行时多态,调用开销为两次间接寻址。用
override防止低级错误。 - 多继承 带来二义性,但通过
类名::成员名可以解决。 - 菱形继承用虚继承解决,代价是额外的 vptr 和构造顺序的复杂化。虚基类由最派生类构造。
- 切片问题是 C++ 的经典陷阱,按值传递会丢失派生类信息,用引用或指针替代。
- 组合优于继承,能组合就别继承,除非你真的需要多态。
如果你能把这些知识点串起来,在白板上画出任意继承体系的内存布局,那 C++ 继承这块就过关了。下一步可以拿 STL 的 std::iostream(它就用到了虚继承)来练手,看看实际工程中这些概念是怎么用的。**