
关注我,学习c++不迷路:
专栏如下:
后续会更新更多有趣的小知识,关注我带你遨游知识世界

期待你的关注。

文章目录
- [1. 前言:](#1. 前言:)
- [2. 动态多态:](#2. 动态多态:)
-
- [2-1 要求:](#2-1 要求:)
- [2-2 第一个程序解释:](#2-2 第一个程序解释:)
- 2-3第二个程序:
- [2-4 协变:](#2-4 协变:)
- [2-5 析构函数构成重写(覆盖):](#2-5 析构函数构成重写(覆盖):)
- [2-6 final和override](#2-6 final和override)
- [3. 虚函数的原理:](#3. 虚函数的原理:)
-
- [3-1 虚函数表指针:](#3-1 虚函数表指针:)
- [3-2 什么是虚函数表指针:](#3-2 什么是虚函数表指针:)
- [3. 总结:](#3. 总结:)
1. 前言:
在讲多态的时候,我们不妨讲讲前面我们讲了那些C++的重要特性吧.
- 封装:核心思想:将数据和操作数据的方法绑定在一起,隐藏内部实现细节。这也是我们在之前的文章中讲到的。
- 继承:核心思想:建立类之间的层次关系,实现代码复用。简称子承父类。是代码复用的一种高效手段。
最后一个就是多态,简单来说就是:同一接口,多种实现方式。
多态的实现有两种方式,一种就是静态,一种则是动态。第一种我们已经在前面的函数重载和函数模板中已经讲过了.另一种则是今天需要讲的重点对象。
2. 动态多态:
2-1 要求:
动态多态一般要求两个特点:
- 必须是基类(父类)的指针或者引用来调用函数。
- 被调用的函数必须是虚函数 ,而且要求函数构成重写。
我们来解释一下什么是虚函数:
虚函数是在函数前面加上virtual,这个关键词我们在菱形继承的时候见到过。是为了防止二义性而设计的,在这里virtual则是显示该为虚函数。
在来讲一下什么是重写,重写一般有三个要求:
- 重写一般要求出现在继承关系中,不能在同一个类中出现。
- 要求函数名,返回值,参数一致,函数体可以不一致。
- 最后要求权限不能缩小。即如果父类是共有,子类并不能是私有或者保护。
2-2 第一个程序解释:
cpp
#include<iostream>
using namespace std;
class person {
public:
virtual void BuyTicket()
{
cout << "全价" << endl;
}
};
class student :public person {
public:
virtual void BuyTicket()
{
cout << "半价" << endl;
}
};
void func(person& ptr)
{
ptr.BuyTicket();
}
int main()
{
person p1;
student s1;
func(p1);
func(s1);
return 0;
}
在这段程序中,我们有两个类,一个学生类,继承了人这一类。在买票的时,学生买票是半价。具体实现:定义两个虚函数,注意这两个满足重写的要求,同时在func函数中也满足了动态多态的要求,同时注意是父类的引用(基类)。
看结果:

我们可以看到,如果满足以上条件,就能完成多态。如果不是引用或者指针呢?

此时两个都是全价,已经不能构成多态。
2-3第二个程序:
cpp
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
p->func();
return 0;
}
我们再看第二个程序。定义了两个类,一个是父类A,他有两个函数,一个是打印A和val的值,第二个则是利用this指针调用func函数。另一个则是继承A类的子类B,他的func函数与父类构成了重写。那么结果是什么呢?看起来构成了动态多态,结果似乎是B->0。
他的结果是什么呢?
其实第一个函数调用test,test的this还是父类对象,构成了多态,父类对象去调用func函数的时候val的值是不会变化的,保持val的值是1.第二个去调用func函数是直接去调用的,不构成多态,所以两个答案不一致。
总结:
- p->test() 的执行
test() 是在类A中定义的,它调用 func()。由于 func() 是虚函数,且 p 指向的是B类对象,所以会调用B的 func()。
关键点:默认参数是在编译时根据调用者的静态类型决定的,而不是运行时根据对象的实际类型决定的。test() 在类A中,所以调用 func() 时使用的是类A的默认参数 val = 1。
结果:B->1 - 这里直接调用B的 func()。p 是B*类型,所以使用B的默认参数 val = 0。
结果:B->0
2-4 协变:
在多态这一章节的中指的是:协变"特指虚函数重写时,子类方法的返回值可以是父类方法返回值的子类。这样构成了多态,我们来看一个程序:
cpp
class Animals {
public:
virtual const Animals* talk()const
{
return this;
}
};
class dog:public Animals{
public:
virtual const dog* talk()const
{
cout << "旺旺" << endl;
return this;
}
};
class cat :public Animals{
public:
virtual const cat* talk()const
{
cout << "喵喵" << endl;
return this;
}
};
int main()
{
Animals* a = new cat;
Animals* b = new dog;
a->talk();
b->talk();
return 0;
}
我们发现这些类的的放回值不相同,但是他们的返回值都是父类返回值的子类,那这样就构成了协变。结果如下:

2-5 析构函数构成重写(覆盖):
其实在这里可以叫做覆盖,这个词很是准确。在C++中,析构函数可以且应该声明为虚函数,当基类指针指向派生类对象时,确保正确调用派生类的析构函数。
cpp
class Base {
public:
virtual ~Base() { std::cout << "Base destructor\n"; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destructor\n"; }
// 这里虽然名字不同(~Base vs ~Derived),但仍然是虚函数重写
};
但是这是为什么构成了呢?明明不是一个名字,看起来就不是一个函数。
- 析构函数有特殊的命名规则(内部名称如destructor)
- C++标准规定:派生类的析构函数会覆盖基类的析构函数
- 即使不写virtual关键字,派生类析构函数也会自动成为虚函数(如果基类析构函数是虚的)。
cpp
class Base {
public:
Base() { std::cout << "父类生成\n"; }
virtual ~Base() { std::cout << "父类销毁\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << "子类生成\n"; }
~Derived() { std::cout << "子类销毁\n"; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // 正确调用:~Derived() -> ~Base()
return 0;
}
那么这个结果是怎么样的呢?

我们可以看到子类在生成时是先父类后子类,而销毁时则恰恰相反。
那么为什么通常要设计成虚函数呢? (重点)
我们先看一段程序:
cpp
class A {
public:
virtual ~A()
{
cout << "~a" << endl;
}
};
class B :public A {
public:
~B()
{
cout << "~b" << endl;
delete[]p;
}
private:
int* p = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
B* p3 = new B;
delete p1;
delete p2;
delete p3;
return 0;
}
这是三个变量,其中两个都是以基类作为指针的,但是我们发现如果加了virtual是可以正确调用到B的析构函数。

可以看我给的注释,但是如果去掉了虚函数,那么就有大问题了:

我们可以看到在调用函数的时候p2不能正确调用函数,这是他不是虚函数,他的对象是A,调用了A的析构函数,而需要调用B来完成内存管理。无法多态,正确管理内存和new出来的p变量,导致内存泄漏。
bash
p2 → [A部分][B部分(p指针指向的内存)]↗
↑ ↑
只释放这里 这里永远不释放!
为什么p3可以正常呢?
cpp
B* p3 = new B; // 静态类型:B*,动态类型:B
delete p3; // 正常工作
- 调用B的析构函数 ~B(),输出 ~b,释放数组内存
- 自动调用基类A的析构函数 ~A()(派生类析构函数会自动调用基类析构函数)
- 输出:~b → ~a。主要是还是原本就指向B,正确调用。
2-6 final和override
这两个关键字是C++11引入的,用于增强对继承和虚函数重写的控制。我们在些函数的重写的时候,有时候函数名字写错了,但是编译器不会报错,加上override明确表示函数是重写基类的虚函数,让编译器帮你检查是否正确重写。
比如:
cpp
class car {
public:
virtual void Dirve()
{
}
};
class Benz :public car {
public:
virtual void Dive()
{
cout << "Ben - 舒服" << endl;
}
};
int main()
{
car* p1 = new Benz;
p1->Dirve();
return 0;
}
上面的代码故意写错了函数的名字,导致无法构成重写。

如果加上override,就会强制检查。


final则是有两个用途:修饰类(禁止继承)和修饰虚函数(禁止重写)。
抽象类在这里我也讲一下:

如果下面的函数没有写override并且写错了,这是会报错,如图:

抽象类是无法实例化的,如果不构成重写。
3. 虚函数的原理:
3-1 虚函数表指针:
我们先看这段代码:
cpp
class A {
public:
virtual void base()
{
cout << "1" << endl;
}
private:
int _a;
char _ch;
};
int main()
{
A a1;
cout << sizeof(a1) << endl;
return 0;
}
我们的程序如上,按照正常的结果来看应该是4 + 1 = 5,最好在内存对齐是8,但是这里结果确是12。

这是因为这里多了一个函数数组的指针,指针在32位下是4,那么就是4 + 1 + 4 = 9。最好进行内存对齐为12.
该结构体中,内存到底如何计算:
- 静态成员变量:不占用类实例的大小
- 成员函数指针变量:如果作为成员变量,才占用空间
- 虚函数表指针:如果有虚函数,会有一个隐藏的vptr(4字节)
cpp
class Simple {
int* p; // 偏移0-3,4字节
char c; // 偏移4,1字节
};
// 大小计算:4 + 1 = 5,但对齐到4的倍数 → 8字节
cpp
class WithVirtual {
public:
virtual ~WithVirtual() {} // 添加虚函数
private:
int* p; // 偏移:vptr(0-3), p(4-7)
char c; // 偏移:8
};
// 大小:vptr(4) + p(4) + c(1) = 9,对齐到4的倍数 → 12字节
3-2 什么是虚函数表指针:
在之前的程序监视窗口中,我们看到一个void** 的二级指针变量vfptr。

它指向一个数组,数组中存储了虚函数的地址,这个数组是函数指针的数组。让后我们定义了一个指针指向这个数组。我们可以看到这个vfptr中有一个虚函数是void base这个虚函数。通过虚函数表我们可以快速查询调用函数。

我们再通过监视子类和父类的虚函数表指针,发现很多东西:虚函数表指针所存贮的地址不同,是两个函数地址数组,但是子类没写的虚函数的地址是一致的,符合继承。重写的函数则不是一致的。这里可能是bug,我也不知道为啥不是虚函数的func2会在子类B的虚函数中。
具体如何实现的呢?
- 编译时:编译器为每个有虚函数的类创建虚函数表
- 对象构造时:设置对象的vptr指向正确的虚函数表
- 虚函数调用时:
通过对象的vptr找到虚函数表
通过偏移量找到正确的函数地址
调用该函数 - 继承时:派生类的虚函数表包含重写的函数指针。
其实就是裁切时,正确的虚函数表指针找到虚函数,完成调用。
3. 总结:
C++多态是指同一接口表现出不同行为的特性,分为编译时多态(通过函数重载和模板实现)和运行时多态(通过虚函数和继承实现,利用虚函数表指针动态绑定实际调用的函数)。