C++多态详解
C++多态
1. 多态的概念
多态是面向对象编程的三大特性之一(封装、继承、多态),它允许使用统一的接口来处理不同类型的对象。
C++中主要有两种多态:
-
编译时多态(静态多态):编译时确定函数的调用过程。
-
函数重载
-
模板
-
-
运行时多态(动态多态):运行时确定函数的调用过程。
- 虚函数的调用
多态主要通过虚函数和继承机制实现。
2. 多态的定义及实现
2.1 多态的必要条件
-
必须是基类的指针或者引用调用虚函数。
-
被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
2.2 虚函数
类成员函数前添加 virtual
关键字修饰,那么该成员函数被称为虚函数。注意非成员函数不能添加 virtual
修饰。
class Person
{
public:virtual void language() {}
};
2.3 虚函数的重写/覆盖
派⽣类中有⼀个跟基类完全相同的虚函数(即派⽣类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派⽣类的虚函数重写了基类的虚函数。
class Animal
{
public:virtual void talk() const {}
};class Dog : public Animal
{
public:virtual void talk() const{std::cout << "汪" << std::endl;}
};class Cat : public Animal
{
public:virtual void talk() const{std::cout << "喵" << std::endl;}
};void letsHear(const Animal& animal)
{animal.talk();
}int main()
{Cat cat;Dog dog;letsHear(cat);letsHear(dog);return 0;
}
2.4 多态的一些特殊情况
2.4.1 派生类可以省略virtual
重写基类虚函数时,派生类的虚函数不加virutal关键字,也可以构成重写。
2.4.2 协变
派⽣类重写基类虚函数时,与基类虚函数返回值类型可以不同。即基类虚函数返回基类对象的指针或者引⽤,派⽣类虚函数返回派⽣类对象的指针或者引⽤时,称为协变。
2.4.3 多态重写时基类和派生类具有不同的缺省值
当基类虚函数和派生类重写版本具有不同的默认参数时,会出现一个需要注意的行为。
-
默认参数是静态绑定的,而虚函数调用是动态绑定的。
- 调用哪个函数版本(基类或派生类)在运行时决定(动态绑定)。
- 用哪个默认参数在编译时决定(静态绑定)。
实例代码
#include <iostream>
using namespace std;class Base {
public:virtual void print(int x = 10) { // 基类默认参数10cout << "Base::print(), x = " << x << endl;}
};class Derived : public Base {
public:void print(int x = 20) { // 派生类默认参数20cout << "Derived::print(), x = " << x << endl;}
};int main() {Base* b = new Derived();b->print(); // 会使用哪个默认参数?delete b;return 0;
}
输出结果
Derived::print(), x = 10
为什么会这样?
-
默认参数是编译时确定的:
-
编译器根据指针/引用的静态类型(这里是
Base*
)决定使用哪个默认参数。 -
在编译时,编译器看到
Base*
,所以使用Base
类的默认参数10。
-
-
函数调用是运行时确定的:
- 运行时根据实际对象类型(
Derived
)调用正确的函数版本。
- 运行时根据实际对象类型(
-
子类重写的是实现,函数声明应当与父类保持一致,默认参数使用了父类的默认参数10。
Effective C++ 条款之一:应避免在虚函数中使用不同的默认参数。
2.4.4 析构函数的重写
基类中的析构函数建议设计为虚函数。
当基类中的析构函数添加 virtual
关键字,此时派生类析构函数只要定义,则派生类中的析构函数与基类的析构函数构成重写。虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor。
为什么要这样?
析构函数需要声明为虚函数并构成重写,主要是为了解决通过基类指针指向派生类对象时的析构函数资源释放问题。
因为基类指针或引用可能指向的是基类对象也可能指向派生类对象,如果析构函数没有构成多态,会导致派生类的析构函数不会被调用,从而引发内存泄漏;当析构函数构成多态时,编译器会先调用派生类的析构函数,随后自动调用基类的析构函数。
下⾯的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调⽤的A的析构函数,没有调⽤B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源。
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};class B : public A {
public:~B(){cout << "~B()->delete:"<<_p<< endl;delete _p;}
protected:int* _p = new int[10];
};// 只有派⽣类Student的析构函数重写了Person的析构函数,下⾯的delete对象调⽤析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调⽤析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
2.5 override和final关键字
-
override(修饰派生类函数)
-
明确表明意图是重写基类虚函数。
-
编译器会检查是否真的重写了基类虚函数,如果没有则报错。
-
-
final(修饰基类函数)
- 如果我们不想让派⽣类重写这个虚函数,那么可以⽤final去修饰。
class Base {
public:virtual void func1() { // 基类虚函数cout << "Base::func()" << endl;}virtual void func2() final {cout << "Base::func()" << endl;}
};class Derived : public Base {
public:void func1() override { // 重写基类虚函数cout << "Derived::func()" << endl;}
};
2.6 重载/重写/隐藏的区别
-
重载
-
两个函数在同一作用域。
-
函数名相同,参数不同(参数的类型、个数、顺序不同)。与返回值无关。
-
-
重写/覆盖
-
两个函数分别在继承体系中父类和子类的作用域中。
-
函数名、参数、返回值必须相同,协变除外。
-
两个函数必须都是虚函数。
-
-
隐藏
-
两个函数分别在继承体系中父类和子类的作用域中。
-
函数名/变量名相同。
-
两个函数只要不构成重写,就是隐藏。
-
3. 纯虚函数和抽象类
在虚函数的后⾯写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派⽣类重写虚函数。
class Car
{
public:virtual void Drive() = 0;
};class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒适" << endl;}
};class BMW :public Car
{
public:virtual void Drive(){cout << "BMW-操控" << endl;}
};int main()
{// Car car; 编译报错,⽆法实例化抽象类Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
4. 多态的原理
4.1 虚函数表(vtable)机制
-
虚函数表:每个包含虚函数的类(或从包含虚函数的类派生的类)都有一个虚函数表,这是一个函数指针数组,存放该类所有虚函数的地址。
-
虚函数表指针(vptr):每个对象在内存布局中会包含一个隐藏的指针成员,指向该类的虚函数表。这个指针通常位于对象内存布局的最前面。
-
基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表,不同类型的对象各⾃有独⽴的虚表,所以基类和派⽣类有各⾃独⽴的虚函数表,虚函数表指针指向的是不同的虚函数表。
-
当基类包含虚函数时,即使派生类没有重写这些虚函数,派生类的虚函数表中仍会包含基类虚函数的地址。如果派生类还定义了新的虚函数,这些额外的虚函数也会被添加到派生类的虚函数表中。
-
当派生类重写了基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。
-
派⽣类的虚函数表中包含,(1) 基类的虚函数地址,(2) 派⽣类重写的虚函数地址完成覆盖,(3) 派⽣类⾃⼰的虚函数地址三个部分。
-
虚函数存放在代码段,虚函数表存放在常量区。(C++ 标准没有严格规定虚函数表的存放位置,不同编译器可能有不同的处理方式)
当类声明虚函数时:
-
编译器会为该类创建一个虚函数表。
-
类的每个对象会包含一个指向该虚函数表的指针(vptr)。
-
调用虚函数时,实际上是通过vptr找到vtable,再从vtable中找到正确的函数地址进行调用。
4.2 没有构成多态的情况
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func3() { cout << "Base::func3" << endl; }
};class Derived : public Base {
public:void func3() { cout << "Derived::func3" << endl; }virtual void func4() { cout << "Derived::func4" << endl; }
};
内存布局
4.3 构成多态的情况
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func3() { cout << "Base::func3" << endl; }
};class Derived : public Base {
public:void func1() override { cout << "Derived::func1" << endl; }virtual void func4() { cout << "Derived::func4" << endl; }
};
内存布局
多态的调用过程
Base* ptr = new Derived();
ptr->func1(); // 调用Derived::func1
-
通过ptr找到对象的vptr。
-
通过vptr找到vtable。
-
在vtable中找到func1的地址(第0个位置)。
-
调用该地址指向的函数。
4.4 静态绑定与动态绑定
-
静态绑定:对不满⾜多态条件(指针或者引⽤+调⽤虚函数)的函数调⽤是在编译时绑定,也就是编译时确定调⽤函数的地址。
-
动态绑定:满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址。