【重走C++学习之路】14、多态
目录
一、多态的基本知识
1.1 概念
1.2 静态多态
1.3 虚函数
1.4 动态多态及构成的条件
1.5 C++11 override 和 final
1.6 虚析构函数
1.7 重载、重写和重定义
二、抽象类
三、多态的原理
3.1 虚函数表
3.2 虚函数指针
3.3 内存布局
3.4 关键技术细节
1. 虚函数表的创建时机
2. 构造函数与析构函数中的虚函数行为
3. 多继承下的虚函数表
4. 实现多态必须是父类的指针或引用,而不是是父类对象
四、几个面试常见的问题
结语
一、多态的基本知识
1.1 概念
多态是面向对象编程的核心特性之一,允许不同类的对象对同一消息(方法调用)做出不同响应。核心思想是用统一的接口操作不同类型的对象,具体行为由对象的实际类型决定。C++ 中多态分为 静态多态(编译时多态) 和 动态多态(运行时多态)。
1.2 静态多态
在编译时确定具体调用的函数,通过以下两种方式实现:
-
函数重载
- 同一作用域内定义多个同名函数,参数列表不同(类型、顺序、数量)。
- 编译时根据实参类型匹配函数。
-
模板
泛型编程实现代码复用,编译器根据类型生成具体代码。
函数模板和类模板均支持静态多态。
1.3 虚函数
基类中声明
virtual
的函数,派生类可覆盖。调用时根据对象的实际类型选择函数版本。
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
1.4 动态多态及构成的条件
在运行时根据对象的实际类型确定调用的函数,通过虚函数实现,是面向对象的核心特性。
构成条件:
继承关系:基类与派生类形成层次结构。
虚函数:基类函数声明为
virtual
,派生类用override
显式标记(C++11)。基类指针/引用:通过基类指针或引用调用虚函数。
重写:派生类必须对基类的虚函数进行重写
虚函数的重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。(重写是对函数体进行重写)
class Animal
{
public:virtual void speak() { cout << "Animal sound" << endl; }
};class Dog : public Animal
{
public:void speak() override { cout << "Woof!" << endl; } // 覆盖基类虚函数
};Animal* pet = new Dog();
pet->speak(); // 输出 "Woof!" (动态绑定)
虚函数重写的两个例外:
- 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
- 析构函数的重写(基类与派生类析构函数的名字不同)
编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。
1.5 C++11 override 和 final
- final:
修饰虚函数,表示该虚函数不可以被重写(还可以修饰类,表示该类不可以被继承)
class Base final
{ ...
}; // 类不可被继承class Animal
{
public:virtual void speak() final { ... } // 函数不可被覆盖
};
- override:
显式标记派生类函数覆盖基类虚函数,增强代码可读性,编译器检查签名一致性。
class Dog : public Animal
{
public:void speak() override { ... } // 明确表示覆盖
};
1.6 虚析构函数
问题:若基类指针指向派生类对象,且基类析构函数非虚,则 delete
时仅调用基类析构函数,导致派生类资源泄漏。
解决:基类析构函数声明为 virtual
。
class Base
{
public:virtual ~Base() { cout << "Base destructor" << endl; }
};class Derived : public Base
{
public:~Derived() { cout << "Derived destructor" << endl; }
};Base* obj = new Derived();
delete obj; // 正确调用 Derived 和 Base 的析构函数
1.7 重载、重写和重定义
作用域 | 函数名 | 解释 | |
重载 | 两个函数在同一个作用域中 | 相同 | 参数类型不同,构成重载 |
重写 | 两个函数分别在基类和派生类中 | 相同 | 基类函数用virtual修饰,达到了多态的标准 |
重定义 | 两个函数分别在基类和派生类中 | 相同 | 基类函数没有用virtual修饰,只是基类和派生类的函数的内容不同 |
二、抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Shape
{
public:virtual double area() const = 0; // 纯虚函数
};class Circle : public Shape
{
public:double area() const{ return 3.14 * radius * radius; }
};
接口继承和实现继承:
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
三、多态的原理
动态多态的实现原理基于虚函数表和 虚函数指针的机制,使得程序在运行时能根据对象的实际类型动态绑定函数调用。
3.1 虚函数表
每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储该类所有虚函数的实际地址。
基类的虚函数表包含其所有虚函数的地址。
派生类继承基类的虚函数表,并覆盖需要重写的虚函数地址。
新增虚函数会被添加到虚函数表的末尾。
3.2 虚函数指针
每个对象实例中隐含一个指向其类虚函数表的指针。
虚函数指针通常位于对象内存布局的起始位置(具体由编译器决定)。
对象通过虚函数指针找到对应的虚函数表,进而调用正确的虚函数。
3.3 内存布局
class Base
{
public:virtual void func1() {}virtual void func2() {}int a;
};class Derived : public Base
{
public:void func1() override {} // 覆盖基类 func1virtual void func3() {} // 新增虚函数int b;
};
Base 对象: +----------------+ | vptr (Base) | → 指向 Base 的虚函数表 | int a | +----------------+Derived 对象: +----------------+ | vptr (Derived) | → 指向 Derived 的虚函数表 | int a | | int b | +----------------+Base 的虚函数表(vtable for Base): +----------------+ | &Base::func1 | | &Base::func2 | +----------------+Derived 的虚函数表(vtable for Derived): +----------------+ | &Derived::func1| // 覆盖基类 func1 | &Base::func2 | // 继承基类 func2 | &Derived::func3| // 新增 func3 +----------------+
因此计算多态的对象大小时不仅需要计算成员变量的大小,还要加上虚函数指针的大小。
3.4 关键技术细节
1. 虚函数表的创建时机
-
编译阶段:
编译器为每个包含虚函数的类生成虚函数表,存放在程序的只读数据段。
-
运行阶段:
对象构造时,其虚函数指针vptr被初始化为指向对应类的虚函数表。
2. 构造函数与析构函数中的虚函数行为
-
构造函数:
在构造函
数中调用虚函数时,虚函数机制未生效,此时调用的是当前类的函数版本。原因:派生类对象的 vptr 在基类构造函数执行期间指向基类的虚函数表。
-
析构函数:
在析构函数中调用虚函数时,同样调用当前类的函数版本。
原因:派生类析构后,对象被视为基类类型。
3. 多继承下的虚函数表
-
多个虚函数表:
派生类继承多个基类时,每个基类对应一个虚函数表。
-
调整 this 指针:
调用不同基类的虚函数时,需要调整 this 指针的偏移量。
4. 实现多态必须是父类的指针或引用,而不是是父类对象
子类对象给父类对象赋值时,会调用父类的拷贝构造对父类的成员变量进行拷贝构造,但是虚表指针不会参与切片,这样父类对象无法找到子类的虚表,所以父类对象不能够调用子类的虚函数。但是子类对象给父类的指针或引用赋值时,是让父类的指针指向父类的那一部分或引用父类的那一部分,这样父类还是可以拿到子类的虚表指针,通过虚表指针找到子类的虚表,从而可以调用虚表中的虚函数。
四、几个面试常见的问题
1. 内联函数可以是虚函数吗?
答:不可以,因为内联函数没有地址,但是编译器会忽略inline属性(inline只是一种建议),最后实现的时候其实就变成了一个普通的虚函数,这样才实现了把地址放到虚表中去。
2. 构造函数可以是虚函数吗?
答:不可以,因为对象中虚函数指针是在构造函数初始化列表阶段才初始化的。
3. 析构函数可以是虚函数吗?
答:可以,且建议设计成虚函数,基类指针指向派生类时析构才能先释放派生类资源。
4. 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
5. 虚函数表是在什么阶段生成的?
答:在编译阶段生成的,存在于代码段。
6. 什么是抽象类?有什么意义?
答:含有纯虚函数的基类称为抽象类。抽象类不能实例化对象,派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。抽象类体现出了接口继承关系。
7. 静态成员可以是虚函数吗?
答:不可以。因为静态成员没有this指针,使用类域(::)访问成员函数的调用方式无法访问到虚表,所以静态成员函数无法放进虚表。
结语
面向对象编程的三大特点之一的多态使得不同类的对象对同一函数做出不同响应,至此面向对象编程的三大特性就结束了,下一篇将会介二叉树进阶的内容,也是面试中和学习数据结果中难度比较大的一个内容,有兴趣的朋友可以关注一下。