跳到主要内容
C++
C++ 继承与多态机制详解 综述由AI生成 详细阐述了 C++ 面向对象编程中的继承与多态机制。内容包括继承的本质、单继承与多继承的语法及内存布局、虚函数与动态绑定原理、抽象类设计以及虚析构函数的必要性。重点讲解了向上转型、对象切割、虚表指针 vptr 与虚函数表 vtable 的实现细节,并探讨了私有继承、纯虚函数接口设计、菱形继承的虚继承解决方案以及 override 与 final 关键字的安全用法。通过代码示例分析了构造函数调用顺序、访问控制权限变化及缺省参数陷阱,旨在帮助开发者深入理解 C++ 类型系统与安全编程实践。
赛博行者 发布于 2026/3/16 更新于 2026/4/27 10 浏览继承概览
继承的本质
继承不仅仅是代码的复制粘贴,它是一种对事物进行分类的逻辑。核心在于:让新代码利用已有的代码,实现增量开发。
为什么我们需要继承?
基于目标代码的复用 (Code Reuse)
对事物进行层级分类 (Classification)
在 C++ 中,上面的层级就是基类 (Base Class),下面的层级就是派生类 (Derived Class)。核心关系是 Is-a 关系。继承建立了一种'是'的关系。
派生类是基类的具体化 :比如,'学生'是'人'的具体化。
基类是派生类的抽象化 :比如,'人'是'学生'的抽象概念。
**增量开发 (Incremental Development)**这是一种很聪明的开发方式。我们不需要一开始就设计一个无所不能的超级大类。我们可以先写一个基础的(比如 Shape),然后根据需求,一点点增加新的具体类(比如 Circle, Square)。
好处 :把复杂问题拆解成层次结构,更有利于描述和解决问题。
单继承 (Single Inheritance)
基础语法
所谓单继承,就是一个派生类只从一个基类那里继承。
格式定义:class 派生类名 : 继承方式 基类名
class Student { ... };
class Undergraduate_Student : public Student { ... };
内存布局:像'搭积木'一样
当我们在内存中创建一个 Undergraduate_Student 对象时,内存里是什么样子的?
派生类对象的大小 = 基类部分大小 + 派生类新增部分大小 (+ 内存对齐)。
指针转换 :这也是为什么基类指针可以指向派生类对象的基础(因为开头部分是一样的)。
基类在前,派生类在后 :编译器会先由上而下,把 Student (基类) 的数据成员放进去,紧接着再放 Undergraduate_Student (派生类) 自己的成员。**举例:**假设 Student 有 id, nickname,派生类有 dept_no。内存样子如下(连续空间):
[ id ] <-- 来自 Student (基类)
[ nickname ] <-- 来自 Student (基类)
[ dept_no ] <-- 来自 Undergraduate_Student (派生类)
核心推论 :
权限控制:Protected 的引入
我们在 C 语言结构体或者 C++ 类封装时,只知道 public 和 private。但在继承中,我们需要一种'传家宝'级别的权限。
private (私有) :
规则 :除了基类自己,谁都不能动。连派生类(亲儿子)都看不见、摸不着。
protected (保护) :
规则 :对外人(类外代码)来说,它是 private 的(不可访问);但对派生类(自家人)来说,它是 public 的(可以访问)。
用途 :专门为了让派生类能复用基类的内部数据,同时又不暴露给全世界。
声明的一个'坑' 继承关系必须在类定义时指定,不能在前向声明时指定。
错误写法 :class Undergraduated_Student : public Student; // 编译器不知道 Student 多大,没法分配内存
正确写法 :class Undergraduated_Student; // 只是告诉编译器有这么个名字
对象的构造与析构次序 批注 :这里有个高频考点:如果基类没有默认构造函数(即 A()),而是只有带参数的构造函数(如 A(int i)),编译器就不会自动调用了。这时候,派生类必须在自己的'初始化列表'里显式地调用基类的构造函数!
析构函数的执行次序 (Destruction Order) 核心原则:与构造完全相反(对称美)。
Derived (派生类自己) ----> Member Objects (成员对象) ----> Base (基类)
构造函数的执行次序 (Construction Order)
Base (基类) ----> Member Objects (成员对象) ----> Derived (派生类自己)
构造函数
默认情况下的'隐形操作' 如果基类有一个默认构造函数(就是那个不带参数的 A()),那你什么都不用做。
编译器会在派生类构造函数执行之前,偷偷帮你调用一次 A()。
真正的挑战:非默认构造函数
问题场景 : 如果基类只定义了一个带参数的构造函数 A(int x),没有默认的 A(),怎么办?这时候,编译器不知道该传什么参数给 A,如果你不显式指定,程序就会报错。
解决方式:成员初始化表 (Member Initialization List) 这是唯一的合法途径。我们必须在派生类构造函数的冒号后面,手动'喂'给基类参数。
语法格式 :派生类构造函数(...) : 基类名 (参数), 成员 (...) { ... }
**为什么不能在 {} 里赋值?**很多同学喜欢这样写(这是错误的):
B (int i, int j) {
A (i);
y = j;
}
记住原理 :在进入 { 之前,基类部分必须已经存在。初始化列表就是在 { 之前干活的地方。
class A {
public :
A (int i) { x = i; }
};
class B : public A {
int y;
public :
B (int i, int j) : A (i) {
y = j;
}
};
'偷懒'的高级写法:继承构造函数 (C++11) 课堂中提到了 using Student::nickname 来解决名字掩盖,这里有个异曲同工的妙用!
用法 :using A::A;
含义 :这句话告诉编译器:'把基类 A 的所有构造函数,都直接拿来给 B 用!'这样你就不用苦哈哈地把 B(int i) : A(i) {} 这种代码重写一遍了。这也是一种复用。
如果你用了 using 继承构造函数,同时派生类又有新变量,一定要给新变量类内初始值:
类型相容与赋值兼容 如果说前面的继承是'拼积木',那这里就是'变魔术'。我们要研究的是:当把一个派生类对象当成基类用的时候,会发生什么?
核心法则:向上转型 (Upcasting) 只有'派生类'可以被当作'基类'来使用。反之不行。
逻辑 :因为'学生'肯定是一个'人',但'人'不一定是'学生'。
结论 :在需要基类的地方,我们可以把派生类传进去。
这里有两种截然不同的方案,一定要区分开,这是常见考点!
玩法一:对象赋值 —— '切割' (Slicing)
**发生了什么?**这叫对象切割 (Object Slicing)。
b 中属于 A (基类) 的那部分数据,被拷贝给了 a。
b 中属于 B (派生类特有) 的那部分数据(比如 dept_no),直接丢失了!被切掉了!
结果 :变量 a 只是一个普通的基类对象,它完全不知道自己曾经和'学生'有过接触。
A a;
B b;
class B : public A
a = b;
玩法二:指针/引用 —— '戴上面具' (Pointer/Reference)
发生了什么?
内存没变 :内存里依然完整地躺着一个 B 对象(包含 id, nickname, dept_no)。
视线变窄 :但是!指针 p 是 A*类型的。它就像戴了一副'有色眼镜',它只看得到 b 里面属于 A 的那部分。
特有成员不可见 :如果你试图写 p->dept_no,编译器会报错,因为在 p 眼里,这就是个 A。
B b;
A *p = &b;
A &r = b;
⚠️ 灵魂发问:到底调用的谁?(The "Binding" Problem)
基类 A 有个函数 void f() { cout << "I am A"; }
派生类 B 重写了这个函数 void f() { cout << "I am B"; }
输出结果:"I am A"
原因 :静态绑定 (Static Binding)。编译器很'死板',它只看 p 的类型是 A*,不管它实际指向谁,直接就去调用 A::f() 了。
这就好比:你虽然是学生(B),但你穿了便装(被 A*指向),老师(编译器)没认出你,只把你当路人甲(A)对待了。
虚函数和动态绑定 (Virtual Functions)
绑定的革命:前期 vs 后期 所谓'绑定 (Binding)',就是把函数调用和函数体代码连接起来的过程。也就是解决 p->f() 到底去执行哪一段代码的问题。
前期绑定 (Early Binding / Static Binding)
时间 :编译时刻就决定好了。
依据 :对象的静态类型(即指针/引用的类型 A*)。
特点 :效率极高(编译器直接生成跳转指令),但灵活性差(就是刚才的'近视眼')。
C++ 的默认设置 。
动态绑定 (Late Binding / Dynamic Binding)
时间 :运行时刻才决定。
依据 :对象的实际类型(即内存里真正躺着的是 A 还是 B)。
特点 :灵活性高(多态),但效率略低(需要多查一次表,后面会讲)。
开启方式 :必须显式使用 virtual。
开启多态的钥匙:virtual 只要在基类的函数声明前加上 virtual,动态绑定的魔法就生效了。
class A {
public :
virtual void f () ;
};
class B : public A {
public :
void f () ;
};
没加 virtual :调用 A::f()(看衣服)。
加了 virtual :调用 B::f()(看灵魂)。这就是多态!
虚函数的'传家宝'规则 'Once Virtual, Always Virtual'
自动继承 :如果在基类中 f() 被声明为虚函数,那么在所有的派生类(儿子、孙子...)中,它自动成为虚函数。
写法 :派生类里写不写 virtual 关键字都可以(建议写上,或者用 override,为了可读性)。
#include <iostream>
using namespace std;
class Animal {
public :
virtual void speak () {
cout << "Animal speaks" << endl;
}
};
class Dog : public Animal {
public :
void speak () {
cout << "Wang! Wang!" << endl;
}
};
class Husky : public Dog {
public :
void speak () {
cout << "Awoooo~~ (Husky style)" << endl;
}
};
int main () {
Animal* p1 = new Dog ();
Animal* p2 = new Husky ();
p1->speak ();
p2->speak ();
return 0 ;
}
❌ 谁不能当虚函数?(避坑清单)
构造函数 :绝对不行!
原因 :虚函数调用依赖于对象里的'虚表指针'(vptr),而在构造函数执行时,对象还没生出来呢,哪来的指针?
静态成员函数 (static) :不行。
内联函数 (inline) :通常不行。
原因 :inline 是编译期展开,virtual 是运行时决定,这俩逻辑是矛盾的。
注意析构函数 :可以,而且往往必须是!
伏笔 :这点非常重要,后面会有专门一页讲这个。现在先记下:基类的析构函数最好写成 virtual。
虚函数表 —— vptr 与 vtable 接上面,我们需要知道这里的动态绑定是怎么实现的!这也是整个虚函数机制的心脏!
两个核心概念 (The Big Two) 为了实现'动态绑定',编译器在幕后偷偷做了两件事:
虚函数表 (vtable) :
是什么 :一个函数指针数组(你可以理解为一张'菜单')。
存哪里 :每个类只有一张表(静态的,所有对象共用这一张菜单)。
存什么 :里面按顺序存放了这个类所有虚函数的地址。
虚表指针 (vptr) :
是什么 :一个隐藏的指针变量(就是刚才说的'身份证')。
存哪里 :存在每个对象的内存里(通常是对象内存的最开头)。
存什么 :指向该对象所属类的 vtable 的地址。
结合 PPT 实例图解 我们来看看 Class A 和 Class B 到底发生了什么。
基类 A (Class A)
定义:virtual f(), virtual g()
A 的 vtable 菜单 :
&A::f
&A::g
派生类 B (Class B)
定义:继承了 A,但是重写 (Override) 了 f(),没改 g()。
B 的 vtable 菜单 (关键点!):
&B::f <-- 注意!这里被替换成了 B 自己的 f,这就是多态的根本!
&A::g <-- B 没改 g,所以还是指向爸爸的 g。
找指针 :通过 p 找到对象内存。
找表 :读取对象头部的 vptr(比如 p 指向 b 对象,那就读到了 B_vtable 的地址)。
找函数 :根据 f() 在声明中的顺序(比如是第 0 个),去表里拿第 0 项的地址(拿到了 &B::f)。
调用 :跳转去执行那个地址的代码。
当编译器看到 p->f() 且 f 是虚函数时 它不会直接生成 call A::f 的指令,而是生成类似下面的一套'动作':
伪代码翻译(本质逻辑): (*p->vptr)[0](p)
翻译:通过 p 找到 vptr,去表里取第 0 个函数指针,然后调用它。
普通函数 h() 怎么办?
处理方式 :静态绑定。
编译器编译时直接确定地址,不需要查表,不需要 vptr,效率最高。所以图里的 vtable 只有 f 和 g,没有 h。
构造函数为什么不能是虚函数? 上面只给出了哪些地方不能用虚函数,这里我们尝试去解决这个重要的问题。
**核心规则:由于时间差导致的'降级'为什么?(原理回顾)**还记得我们刚才说的 vptr(身份证)吗?
当我们创建一个 B 对象时,先执行 A 的构造函数。
此时,B 的部分还没生出来(内存可能是随机值)。
为了防止你访问到不存在的 B 成员,编译器做了一个保护措施:
在进入 A 的构造函数时,把 vptr 指向 A 的虚表。
只有等到 A 构造完,开始执行 B 的构造函数时,才把 vptr 改成 B 的虚表。
所以,在 A 的构造函数里,编译器'认为'当前就是个 A 对象。
执行流程拆解 :
调用 A::A() (基类构造)。
A 构造函数里调用了 f()。
关键点 :此时对象还是 A,vptr 指向 A。
结果 :调用 A::f()。(多态失效)
调用 B::B() (派生类构造)。
f 是虚函数。
此时对象已经构造完毕,vptr 已经是 B 的了。
结果 :调用 B::f()。(多态生效)
g 在 A 中不是虚函数。
虽然 B 里也有个 g,但 p 是 A*类型。
结果 :编译器只看指针类型,直接绑定 A::g()。(无多态,看衣服)
h 是 A 的成员函数,它在 A 中定义:void h() { f(); g(); }
这里的 f() 其实是 this->f(),g() 是 this->g()。
分析 f() :
f 是虚函数。
this 指向的是一个完整的 B 对象。
结果 :查表,调用 B::f()。
分析 g() :
g 不是虚函数。
this 的类型是 A* (因为 h 是 A 的函数)。
结果 :静态绑定,调用 A::g()。
为什么呢?原则:进入哪个函数,这里的指针变为哪种,这里是 A*,普通函数会由此去静态判断,虚函数根据 vptr 判断
class A {
public :
virtual void f () ;
void g () ;
};
class B : public A{
public :
void f () { g (); }
void g () ;
};
B b;
A* p = &b;
p -> f ()
B::g ();
class A {
public :
A () { f (); }
virtual void f () ;
void g () ;
void h () { f (); g (); }
};
class B : public A{
public :
void f () ;
void g () ;
};
B b;
A *p = &b;
p->f ();
p->g ();
p->h ();
现代 C++ 的安全写法——override 与 final
override:防手滑神器 作用:显式告诉编译器,'我是来重写基类虚函数的,请帮我检查一下!'。
旧时代的痛点 :如果你在派生类里重写函数时,不小心把参数类型写错了(比如 int 写成了 float),或者函数名少打了一个字母。
结果 :编译器不会报错,而是认为你新定义了一个完全无关的函数。多态直接失效!
新时代的写法 :在函数声明后面加上 override。
struct B {
virtual void f1 (int ) const ;
};
struct D : B {
void f1 (int ) const override ;
};
建议:以后写派生类虚函数,无脑加上 override。
final:到此为止 作用:禁止继承,或禁止重写。就像给代码做了'绝育手术'。
struct Base {
virtual void f () final ;
};
struct Derived : Base {
};
struct Base final { ... };
访问控制的'双重人格' PPT 展示了一个非常反直觉但合法的操作:子类可以修改虚函数的访问权限。
基类 B :f() 是 protected(只能自家或者孩子用,外人不能调)。
派生类 D :重写了 f(),并把它改成了 public(大家都能用)。
struct B {
protected :
virtual void f () {}
};
struct D : B {
public :
void f () override {}
};
int main () {
D d;
d.f ();
B* pb = &d;
pb->f ();
}
极其分裂的现象 :虽然 pb->f() 在运行时真正执行的是 D::f(它是 public 的),但编译器在编译阶段就被 B::f 的 protected 挡回去了。
结论:访问权限检查 (Access Control) 发生在 编译期,看静态类型;函数逻辑调用 发生在 运行期,看动态类型。
Protected 和友元的一个问题
核心规则:Protected 的'自私' protected 成员:允许派生类访问,但有一个严格的前提——派生类只能访问'属于它自己'的那部分基类成员,不能访问'别人家'基类对象的成员。
代码破案 class Base {
protected :
int prot_mem;
};
class Sneaky : public Base {
friend void clobber (Sneaky&) ;
friend void clobber (Base&) ;
int j;
};
void clobber (Sneaky &s) {
s.j = s.prot_mem = 0 ;
}
分析 :s 是一个 Sneaky 对象。作为 Sneaky 的友元,我可以访问 s 继承下来的 prot_mem。这是合法的'继承权'。
void clobber (Base &b) {
b.prot_mem = 0 ;
}
分析 :参数 b 是一个独立的 Base 对象。
编译器逻辑 :虽然 Sneaky 继承自 Base,但这不代表 Sneaky 可以随意去动任意一个 Base 对象的私有财产!
通俗比喻 :你(派生类)可以花你爸爸给你的零花钱(继承来的 protected),但你不能跑去隔壁老王(也是个父亲,Base)家里拿他的零花钱,哪怕老王和你爸爸是同一类人。
纯虚函数与抽象类 它把继承从'代码复用'提升到了'接口设计'的高度。
纯虚函数 (Pure Virtual Function) 定义:一个只有声明,没有实现(或者不需要基类给实现)的虚函数。
语法 :virtual 返回值 函数名 (参数) = 0;
注意 :这个 = 0 不是说函数值是 0,而是告诉编译器:'不用给我生成函数体,我就是个空壳/接口。'
抽象类 (Abstract Class) 定义:只要一个类里至少包含一个纯虚函数,这个类就变成了抽象类。
特点(铁律) :
不能实例化 :你不能写 AbstractClass a; 或者 new AbstractClass。
为什么? 因为里面有函数是空的,如果创建了对象,调用那个空函数怎么办?程序会崩。
只能做基类 :它的存在就是为了给派生类提供一个'规范'或'框架'。
可以用指针指向派生类
派生类的义务
实现所有纯虚函数 :这样派生类就变成了'具体类',可以创建对象了。
继续摆烂(不实现) :那么派生类也会继承那个纯虚函数,它自己也变成了抽象类,依然不能创建对象。
多态数组与异构容器 我们为什么要费劲去写抽象类。目标:用统一的方式,管理不同的东西。
场景设定
基类 (抽象) :Figure (图形)。它不知道怎么画自己,所以 display() 是纯虚函数。
派生类 (具体) :Rectangle (矩形), Ellipse (椭圆), Line (线)。它们都知道怎么画自己。
优势 :循环体内的代码不需要修改。哪怕以后你加了一个新的图形 Circle,这个 for 循环一行代码都不用动!这就是'开闭原则'(对扩展开放,对修改关闭)。
Figure *a[100 ];
a[0 ] = new Rectangle ();
a[1 ] = new Ellipse ();
a[2 ] = new Line ();
for (int i=0 ; i<num; i++) {
a[i]->display ();
}
进阶:抽象工厂模式 (Abstract Factory Pattern)
**遇到的麻烦 (The Problem)**假设我们要写一个软件,既要跑在 Windows 上,又要跑在 Mac 上。如果我们在代码里到处写 if (isWindows) ... else ...,代码会变得极其丑陋,难以维护
Windows 的按钮叫 WinButton。
Mac 的按钮叫 MacButton。
Button (抽象按钮) -> 派生出 WinButton, MacButton
Label (抽象标签) -> 派生出 WinLabel, MacLabel
*代码中的 client 只认 Button 和 Label,不关心具体是哪个系统的。
这就是那个'中介'的标准。
WinFactory :生产 WinButton 和 WinLabel。
MacFactory :生产 MacButton 和 MacLabel。
AbstractFactory* fac;
if (style == WIN) fac = new WinFactory ();
else fac = new MacFactory ();
Button* btn = fac->CreateButton ();
核心思想:我们不直接 new 具体的按钮,而是找一个'中介'(工厂)来帮我们要按钮。
第一步:定义抽象产品 (Abstract Products)
第二步:定义抽象工厂 (Abstract Factory)
class AbstractFactory {
public :
virtual Button* CreateButton () = 0 ;
virtual Label* CreateLabel () = 0 ;
};
第三步:实现具体工厂 (Concrete Factories)
虚析构函数
p 的静态类型是 B* (基类指针)。
p 实际指向的是 D (派生类对象)。
如果基类 B 的析构函数不是虚函数(没有 virtual),那么 delete p 时采用的是静态绑定。
后果就是编译器只去将调用了基类的析构函数,拆去了 p 中基类的资源,但是第 2 层(派生类特有的资源)悬在空中没人管了!
一旦析构函数变成了虚函数,delete p 就会触发动态绑定。
编译器会去查 vptr(身份证),发现 p 指向的其实是个 D 对象。
它会先调用 ~D()(清理派生类资源),再调用 ~B()(清理基类资源)。这里的顺序问题可以参考前面的说明。
完美释放,无残留! ✅
解决方案:加上 virtual (The Fix)
class B {
public :
virtual ~B () = default ;
};
公有继承的里氏代换原则
生物学 vs 代码学:企鹅是鸟吗? PPT 案例:class Penguin : public FlyingBird 现实中企鹅是鸟,但在代码里,如果 FlyingBird 定义了 virtual fly(),而企鹅不会飞,这就会出大问题。
错误做法 :在企鹅的 fly() 里报错 error("Penguins can't fly!")。
这违反了'里氏替换原则':派生类必须能完全替代基类。如果我调用 bird->fly(),我期望它飞,而不是程序崩溃。
结论 :Public 继承必须是严格的 "Is-a" 关系(行为上的一致,而不仅仅是概念上)。
禁忌:遮盖非虚函数 (Right Box) PPT 案例:class B 和 class D 都有 void mf(),但它不是 virtual。
现象 :D 定义了一个和 B 名字一模一样的非虚函数 mf()。
后果(精神分裂) :
B* pB = &x; -> pB->mf() 调用的是 B::mf。
D* pD = &x; -> pD->mf() 调用的是 D::mf。
同一个对象 x ,仅仅因为指针类型不同,表现出的行为就不同!这会让使用你代码的人疯掉。
规则 :绝对不要重新定义继承而来的非虚函数。要改写行为,基类必须是 virtual。
经典的几何悖论:正方形是矩形吗? 这是一个极其经典的面试题!数学上'正方形是矩形',但在 C++ 里不是。
矩形 (Rectangle) :setWidth 只改宽,不改高。这是矩形的特性。
正方形 (Square) :继承自矩形。但为了保证是正方形,重写了 setWidth -> 同时也修改高度。
函数 Widen(Rectangle& r) 的目的是:把矩形拉宽。
它有个假设:改宽度不会影响高度 (oldHeight 应该保持不变)。
传入正方形 :
你传了个 Square 进去(向上转型为 Rectangle&)。
调用 r.setWidth()。
因为是 virtual(假设),正方形把宽和高都改了。
assert(r.height() == oldHeight) -> 断言失败!程序崩溃!💥
核心教训 :正方形的行为(改宽同时也改高)和矩形的预期行为(改宽不改高)不兼容。所以,不要让 Square 继承 Rectangle。
如果继承导致你需要写 if (type == Penguin) 或者导致 assert 失败,那就说明不要用继承!
私有继承:一种'不可告人'的关系 我们通常用的继承是 public(公有)的,代表 "Is-a"(是)。但 C++ 还允许 private 继承,这代表什么呢?
核心定义 Private Inheritance 意味着 "Implemented-in-terms-of" (根据...实现)。
关系 :派生类内部利用了基类的代码来实现功能,但在外界看来,派生类不是基类。利用关系,就像汽车和引擎的关系
此时私有继承中,编译器不会把子类指针自动转换为父类指针(向上转型失效)。// Engine* p = &myCar; // ❌ 报错!
举例
Human (人类) :有 eat()(吃饭)的功能。
Student (学生) :需要吃饭,所以想复用 Human 的代码。
**但!**我们不想让别人把 Student 当作普通 Human 对待(比如不希望 Student 暴露某些 Human 的接口)。
做法 :class Student : private Human { ... }
在 Student 内部,可以调用 Human::eat()。
在 main 函数里,eat(student)报错!❌ 因为对外来说,他和 Human 没关系。
接口继承的'三种境界' 对虚函数机制的哲学总结,非常经典!它把类里的函数分成了三类,每一类都有明确的语义。
纯虚函数 (Pure Virtual)
形式 :virtual void draw() = 0;
含义 :你必须提供这个接口,但我没办法给你实现。
继承内容 :只继承接口 (Interface)。
普通虚函数 (Virtual)
形式 :virtual void error(msg);
含义 :你应该提供这个接口,如果你懒得写,我这里有一个默认版本给你用。
继承内容 :继承接口 + 缺省实现 (Default Implementation)。
非虚函数 (Non-Virtual)
形式 :int objectID();
含义 :这个行为是强制的、不变的,所有派生类都必须一样,谁也不许改!
继承内容 :继承接口 + 强制实现 (Mandatory Implementation)。
建议:我们在设计基类时,要想清楚每一个函数属于哪一类。如果是想让子类改的,一定要加 virtual;如果不想让子类改(比如获取 ID),千万别加 virtual。
⚠️ 缺省参数的陷阱
问题场景 class A {
public :
virtual void f (int x = 0 ) = 0 ;
};
class B : public A {
public :
virtual void f (int x = 1 ) {
cout << x;
}
};
诡异的输出
你的直觉 :调用的是 B::f,B 的默认值是 1,所以输出 1。
残酷的现实 :输出 0!
原理解析:静态绑定 vs 动态绑定 为什么会这样?因为 C++ 编译器为了效率,把参数和函数体分开处理了:
函数体 (Function Body) :是动态绑定的。
p->f() 确实调用了 B::f() 的函数体(多态生效)。
缺省参数 (Default Param) :是静态绑定的!
编译器在编译 p->f() 这行代码时,只看 p 的静态类型是 A*。
所以它直接把 A 的默认值 0 填到了参数里。
结果就是:你调用了子类的函数,却用了父类的参数! —— 这就是所谓的'身首异处'。
怎么避坑? 如果非要用默认参数,可以由非虚函数(NVI 模式)来做跳板:
class A {
public :
void f (int x = 0 ) {
do_f (x);
}
private :
virtual void do_f (int x) = 0 ;
};
多继承
定义 多继承:一个派生类同时拥有两个或以上的基类。就像现实生活中,你既继承了爸爸的特征,也继承了妈妈的特征。
能力 :SleepSofa 对象既能调用 Bed 的方法(比如 Sleep()),也能调用 Sofa 的方法(比如 WatchTV())。
这也是 PPT P27 展示的理想状态,看起来很美好。 ✨
class SleepSofa : public Bed, public Sofa { ... };
灾难降临:菱形继承 当这两个基类(爸妈)又来自同一个祖先(爷爷)时,麻烦大了。
场景还原 (The Scenario)
爷爷 :Furniture (家具)。有一个成员变量 weight (重量)。
爸爸 :Bed (床)。继承自 Furniture。
妈妈 :Sofa (沙发)。继承自 Furniture。
孙子 :SleepSofa (沙发床)。同时继承 Bed 和 Sofa。
**内存里的'双重人格' (The Problem)**当我们创建一个 SleepSofa 对象时,内存里发生了什么?结果 :SleepSofa 里竟然有两份 Furniture,也就有两个 weight 变量!
它包含一个 Bed 对象。而 Bed 里包含了一个 Furniture(爷爷 A)。
它包含一个 Sofa 对象。而 Sofa 里也包含了一个 Furniture(爷爷 B)。
这叫二义性 。
**二义性 (Ambiguity)**如果你写这行代码:
SleepSofa ss;
ss.setWeight (10 );
编译器崩溃了 :'大哥,你让我改哪个 weight?是'床'那一脉的爷爷,还是'沙发'那一脉的爷爷?'
拯救者:虚继承 (Virtual Inheritance)
内存变化 :现在,SleepSofa 的对象里,只有唯一的一份 Furniture 对象了。
逻辑变化 :ss.setWeight(10) ✅ 成功!因为只有一个 weight,没有歧义了。
虚继承的作用:告诉编译器,'如果我们拥有同一个祖先,请在孙子辈里共享这一份祖先,不要复制多份。'
语法:virtual 关键字 注意,virtual 是加在中间层(爸爸妈妈)身上的。
class Furniture { ... };
class Bed : virtual public Furniture { ... };
class Sofa : virtual public Furniture { ... };
class SleepSofa : public Bed, public Sofa { ... };
💡 灵魂拷问:谁负责初始化爷爷? 在普通继承里,Bed 负责初始化它的爷爷,Sofa 负责初始化它的爷爷。但在虚继承里,既然爷爷只有一份,那到底听谁的?
结论 :由孙子 (SleepSofa) 直接负责初始化爷爷 (Furniture)!中间层 (Bed, Sofa) 对爷爷的初始化会被忽略。
多继承的'交通规则' **名字冲突怎么办?(Name Conflict)**如果 B 有个成员 x,C 也有个成员 x,D 继承 B 和 C
d.B::x = 10 ;
d.C::x = 20 ;
**谁先谁后?(构造/析构顺序)**在多继承中,基类构造函数的调用顺序,只取决于类声明时的顺序!与你在初始化列表中怎么写无关。
class D : public B, public C { ... };
硬核底层:多继承的内存布局 当一个对象有多个基类时,它的内存和指针发生了什么扭曲。
**对象里有多个 vptr!**在单继承中,对象只有一个 vptr。但在多继承中(比如 D 继承 B1, B2, B3),对象内存里会有多个 vptr!
B1 部分有一个 vptr(指向 B1 家族的虚表)。
B2 部分也有一个 vptr(指向 B2 家族的虚表)。
p1 == p2 吗?
如果你直接比地址值:不一样。
如果你在 C++ 代码里比 if (p1 == p2):一样(编译器会帮你隐式调整后再比较)。
**thunk 与 this 指针调整 (Thunk)**PPT 左下角有个词叫 this adjustor。这是啥?
场景 :
问题 :
解决 (Thunk) :编译器在虚函数表里动了手脚。它不直接跳到 D::f(),而是先跳到一个叫 Thunk 的小代码段。
D 重写了 B2 的虚函数 f()。
你用 B2* p2 调用了 p2->f()。
程序跳转到了 D::f()。
D::f() 作为一个成员函数,它预期的 this 指针应该指向 D 对象的开头。
但是你传进来的 p2 指向的是 D 对象中间的 B2 部分。
如果直接用,this 指针就偏了,访问成员变量就会错位!💥
Thunk 的作用 :this -= offset; (把指针往回拨,修正到 D 的开头)。
然后再跳转到真正的 D::f()。
**指针的'漂移' (Pointer Adjustment)**这是最反直觉的地方。请看图:
D d;
B1* p1 = &d;
B2* p2 = &d;
现象 :虽然 p1 和 p2 都是指向同一个对象 d,但它们的 内存地址值是不一样的!
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online