C++多态详解:运行时多态、虚函数重写与析构函数重写规则

文章目录

多态

多态

简单来说就是多种形态

分类:编译时多态(静态多态)、运行时多态(动态多态)

编译时多态(静态多态)一般指的是函数重载和函数模版(重载函数编译链接时编译器不会通过函数名去查找,各编译器会根据自己的规则对函数名进行修饰,把形参带入函数名使得编译链接时方便查找。重载的规则也是依赖此形成的)

运行时多态(动态多态)指同一个接口(调用同一个函数)在不同对象上表现出不同的行为,就达到多种形态

构成多态的条件:

  1. 必须是基类指针或引用调用虚函数
  2. 被调用函数必须是虚函数,并且完成了虚函数的重写/覆盖

说明:

  • 必须是基类指针或引用的原因是:一,按值传递会发生切片,最终形成的对象完全是基类的;二,使用基类的指针或引用保证了指向内容的类型是派生类同时指针或引用也保存了基类的类型

  • 派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。

‍

虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。

cpp 复制代码
class Person
{
public:
	 virtual void BuyTicket() { cout << "买票-全价" << endl; }
 };

虚函数的重写/覆盖

虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(三同:即派生类虚函数与基类虚函数的返回值类型 、函数名字 、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。

‍

下面我们看看完整实现多态的代码:

cpp 复制代码
class Person {
public:
	virtual void BuyTicket()//虚函数
	{
		std::cout << "买票-全价" << std::endl;
	}
};

class Student :public Person {
public:
	virtual void BuyTicket()
	{
		std::cout << "买票-半价" << std::endl;
	}
};
void Func(const Person* ptr)//通过该函数使得用基类指针来调用虚函数
{
	//根据调用结果我们也可发现虽然都是Person指针调用
	// 但结果与ptr无关,与ptr指向对象的类型有关
	//当然我们也可以去掉这个函数直接在main函数中实现
	ptr->BuyTicket();
}

int main()
{
	Person a;
	Student b;
	Func(&a);
	Func(&b);

	return 0;
}

‍

虚函数重写的一些其他问题

协变

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。

‍

cpp 复制代码
class A {};
class B : public A {};
class Person {
public:
	virtual A* BuyTicket()
	{
		std::cout << "买票-全价" << std::endl;
		return nullptr;
	}
};
class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		std::cout << "买票-打折" << std::endl;
		return nullptr;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

返回值不一定是别的两个类的继承关系,自己拥有的继承关系也可以做返回值,只要是对应关系的指针或引用的返回值就好(即将上述代码A、B类删除,后面两个类的返回值分别改成​Person*​与​Student*也行)

析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

下面的代码我们可以看到,如果​~A()​不加virtual,那么deletep p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为​~B()中在释放资源。

注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

cpp 复制代码
class A
{
public:
	virtual ~A()
	{
		std::cout << "~A()" << std::endl;
	}
};
class B : public A {
public:
	~B()
	{
		std::cout << "~B()->delete:" << _p << std::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;
}

在上述例子中就是派生类析构函数必须是虚函数的情形:通过基类指针删除派生类对象

如果基类析构函数不是虚函数,执行上述代码时就只是因为指针时​A*​类型调用基类的析构函数,只会打印出​~A(),会造成内存泄漏的问题。

‍

‍

override和final关键字

override​:在函数声明之后,函数体之前加上​override​关键字,帮助用户检测是否重写,使用​override修饰的成员函数不能重写基类成员

final​:写法与上相同,加上​final关键字,用该关键字修饰后派生类就不能重写这个虚函数

‍

看看下面的反例(均会编译失败):

cpp 复制代码
// error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法
class Car {
public:
	virtual void Dirve()
	{}

};

class Benz :public Car {
public:
	virtual void Drive() override { std::cout << "Benz-舒适" << std::endl; }
};
 int main()
{
	 return 0;
	 }
cpp 复制代码
// error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写

#include <iostream>

using namespace std;

class Car
{
public:
    virtual void Drive() final
    {
    }
};

class Benz : public Car
{
public:
    virtual void Drive()
    {
        cout << "Benz-舒适" << endl;
    }
};

int main()
{
    return 0;
}

‍

纯虚函数和抽象类

纯虚函数:在虚数函数后加上​=0

抽象类:包含纯虚函数的类叫抽象类,特点是无法实例化出对象。如果派生类继承之后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制派生类重写虚函数,因为不重写就无法实例化出对象

cpp 复制代码
class Car
{
public:
	virtual void Drive() = 0;//纯虚函数,无法实例化出对象
};
class Benz :public Car
{
public:
	virtual void Drive()//重写
	{
		std::cout << "Benz-舒适" << std::endl;
	}
};
class BMW :public Car
{
public:
    virtual void Drive()
    {
        cout << "BMW-操控" << endl;
    }
};

int main()
{
    // 编译报错:error C2259: "Car":无法实例化抽象类
    Car car;

    Car* pBenz = new Benz;
    pBenz->Drive();			//仍能实现多态

    Car* pBMW = new BMW;
    pBMW->Drive();			//仍能实现多态

    return 0;
}

‍

多态原理

虚函数表指针

直接看一个例子

cpp 复制代码
class Base {
public:
	virtual void Func1()
	{
		std::cout << "Func1()" << std::endl;
	}
private:
	int _i = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	std::cout << sizeof(b) << std::endl;
	return 0;
}

我们计算该类大小应该是8bytes,但是运行结果显示是12bytes,再通过监视窗口看看是为什么

我们发现多了一个东西

展开后我们可以发现存了个指向基类虚函数地址的指针,我们为基类多添加几个虚函数看看

新增一个虚函数和一个普通成员函数我们再监视看看

我们现在可以确定新增的这个函数指针数组中储存的就是虚函数的地址,该数组是函数指针数组。这就是虚函数表指针

cpp 复制代码
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
    string _name;
};

class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
    string _id;
};

class Soldier: public Person {
public:
    virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
    string _codename;
};

void Func(Person* ptr)
{
    // 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
    // 但是跟ptr没关系,而是由ptr指向的对象决定的。
    ptr->BuyTicket();
}

int main()
{
    // 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
    // 多态也会发生在多个派生类之间。
    Person ps;
    Student st;
    Soldier sr;

    Func(&ps);
    Func(&st);
    Func(&sr);

    return 0;
}

我们编译代码转到汇编看看

多态的原理

动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

虚函数表(虚表)

  • 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。

  • 派生类对象里包含一份基类部分,但这份基类部分不是原来那个基类对象,而是一份独立的基类子对象。

  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。

    看一下这个代码

    cpp 复制代码
    #include<iostream>
    using namespace std;
    class Base {
    public:
        virtual void func1() { cout << "Base::func1" << endl; }
        virtual void func2() { cout << "Base::func2" << endl; }
    
    protected:
        int a = 1;
    };
    
    class Derive : public Base
    {
    public:
        // 重写基类的func1
        virtual void func1() { cout << "Derive::func1" << endl; }
        virtual void func3() { cout << "Derive::func1" << endl; }
    
        void func4() { cout << "Derive::func4" << endl; }
    protected:
        int b = 2;
    };
    
    int main()
    {
        Base b;
        Derive d;
        return 0;
    }

    派生类中的基类虚函数表与原基类虚函数表并不是一个。在代码中我们可以知道,派生类继承了基类的​func1​和​func2​,但在派生类中并没有重写​func2。虽然我们在监视窗口中只是看到虚函数表中只有继承和重写的函数,但是通过查看地址我们也可以看见明显还有一个虚函数指针,只不过没有在监视窗口中体现,这就是派生类中非继承的虚函数。同时这样能让我们进一步理解"覆盖"的含义,基类虚函数被继承后通过重写覆盖,然后加上派生类自己的虚函数

  • 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。

  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,VS系列编译器会再后面放个0x00000000标记,g++系列编译不会放,这从上面的图中的内存窗口可以体现)

  • 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

  • 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。Vs下是存在代码段(常量区)

补充:对象的头四个字节就是虚表的地址

cpp 复制代码
class Person {
public:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }

    void func5() { cout << "Base::func5" << endl; }
protected:
    int a = 1;
};

class Student : public Person
{
public:
    // 重写基类的func1
    virtual void func1() { cout << "Person::func1" << endl; }
    virtual void func3() { cout << "Person::func1" << endl; }

    void func4() { cout << "Person::func4" << endl; }
protected:
    int b = 2;
};


int main()
{
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxxx";
    printf("栈:%p\n", &i);
    printf("静态区:%p\n", &j);
    printf("堆:%p\n", p1);
    printf("常量区:%p\n", p2);

    Person b;
    Student d;

    printf("Person虚表地址:%p\n", *((int*)&b));//这样的代码学习时可以使用,但这依赖具体编译器和
    printf("Student虚表地址:%p\n", *((int*)&d));//ABI,只适合调试、学习内存布局,不应写进正常业务代码。
    printf("虚函数地址:%p\n", &Student::func1);
    printf("普通函数地址:%p\n", &Student::func4);

    return 0;
}

我们可以看到在我的此次编译中虚表的地址与常量区的地址挨得非常近,说明虚表本身也是放在常量区的;虚函数地址与普通函数地址也挨得非常近,所以说他们俩也是储存在一起的

指向虚表的指针通常也是在对象内部,因此通常在栈上


完

相关推荐
白杨尚青11 天前
C++进阶篇(二):多态——虚函数、重写、虚函数表与三个经典坑(附完整代码仓库)
开发语言·c++·多态·虚函数·重写·动态绑定
无忧.芙桃12 天前
C++语言原理与实践(十六):多态机制讲解
开发语言·c++·多态
by2099919 天前
C++ 多态:同一个基类接口,调用不同的函数
开发语言·c++·经验分享·多态
huisheng_qaq1 个月前
【Python基础篇-07】深入理解python的面向对象编程
python·多态·继承·面向对象编程·封装
布莱克6051 个月前
在C++中,构造函数能不能是虚函数?析构函数为什么要虚?
开发语言·c++·构造函数·析构函数·虚函数
.YM.Z1 个月前
C++——【继承】的底层逻辑与【多态】实现机制
c++·多态·继承
djjjx.3 个月前
【 C++ 】多态
开发语言·c++·多态
clerly3 个月前
C语言如何实现继承?
c语言·多态·继承·封装·结构体
红豆诗人3 个月前
C++ 继承进阶梳理:权限、转换、隐藏和菱形继承
c++·多态·面向对象