C++语言原理与实践(十六):多态机制讲解

本篇目标:

1.了解构成多态的条件

2.搞清楚多态的部分原理

一.多态

1.多态的概念

多态的概念:通俗来说,就是多种形态 。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态 ,编译时多态(静态多态)和运行时多态(动态多态)。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在 编译时完成的,我们把编译时⼀般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种 形态。比如买票这个行为,当普通⼈买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军 ⼈买票时是优先买票。再比如,同样是动物叫的⼀个行为(函数),传猫对象过去,就是"(>^ω^<) 喵",传狗对象过去,就是"汪汪"。

2. 多态的定义及实现

2.1.条件

要实现多态需要满足以下的条件:

构成条件 :多态是⼀个继承关系的下的类对象,去调用同⼀函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象优惠买票。

还有两个必须重要条件:

复制代码
• 必须是基类的指针或者引用调⽤虚函数

• 被调⽤的函数必须是虚函数,并且完成了虚函数重写/覆盖。

2.2.虚函数的重写/覆盖

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

注意:非成员函数不能加virtual修饰。

例如:

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

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

示例一:

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)
{
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr->BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

运行结果:

示例二:

cpp 复制代码
#include <iostream>
using namespace std;

class Animal
{
public:
	virtual void talk() const
	{}
};
class Dog : public Animal
{
public:
	virtual void talk()  const
	{
		cout << "汪汪" << std::endl;
	}
};
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		cout << "(>^ω^<)喵" << std::endl; 
	}
};
void letsHear(const Animal&ani)
{
	ani.talk();
}
int main()
{
	Cat cat;
	Dog dog;
	letsHear(cat);
	letsHear(dog);
	return 0;
}

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写 (因为继承 后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

例如有这么个选择题:

cpp 复制代码
以下程序输出结果是什么()

A: A->0 B:B->1 C:A->1 D:B->0 E:编译出错 F:以上都不正确
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();
    return 0;
}

相信这道题大部分的人要么选C,要么选D,但是正确答案是B,解释如下:

这道题的关键是:虚函数决定调用谁的函数体,缺省参数取决于调用处看到的函数声明。

① p->test() 执行的是 A::test()。

虽然 p 指向 B 对象,但 B 没有重写 test(),所以执行从基类继承的版本:

cpp 复制代码
virtual void test()
{
    func();
}

② func() 使用的是 A 中声明的缺省参数 1。

这里的 func() 相当于:

cpp 复制代码
this->func();

由于这段代码位于 A::test() 中,this 的类型是 A*。编译器根据 A::func 的声明补上缺省参数,因此这次调用可以理解为:

cpp 复制代码
this->func(1);

③ 通过虚函数机制,最终执行 B::func(1)。

func 是虚函数,B 重写了它,而当前对象的实际类型是 B,所以运行时调用的是:

cpp 复制代码
void B::func(int val)
{
    std::cout << "B->" << val << std::endl;
}

传入的参数已经是 1,不会再使用 B 中的缺省值 0,因此最终输出 B->1。

3.协变

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

例如:

cpp 复制代码
#include <iostream>
using namespace std;

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

运行结果:

4.析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了 vialtual修饰,派生类的析构函数就构成重写。 下面的代码我们可以看到,如果~A(),不加virtual,那么deletep2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中没有释放资源。

例如:

cpp 复制代码
#include <iostream>
using namespace std;
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];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;

	return 0;
}

运行结果:

没有加virtual时的运行结果:

此时就没有调用派生类的析构函数,就会导致派生类的资源无法得到有效的释放。

5.override 和final关键字

从上⾯可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写 。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。

示例一:

cpp 复制代码
#include <iostream>
using namespace std;
class Car 
{
public:
	virtual void Drive()
	{
		cout << "Car" << endl;
	}
};
class Benz :public Car
{
public:
	virtual void Drive() override
	{
		cout << "Benz舒适" << endl;
	}
};
int main()
{

	return 0;
}

示例二:

cpp 复制代码
#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;
}

运行结果:

6.纯虚函数和抽象类

在虚函数的后⾯写上=0,则这个函数为纯虚函数 ,纯虚函数不需要定义实现 (实现没啥意义因为要被 派生类重写,但是语法上可以实现),只要声明即可。**包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。**纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

示例一:

cpp 复制代码
#include <iostream>
using namespace std;
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;
	}
};
cpp 复制代码
int main()
{
	Car car;
	return 0;
}

运行结果:

cpp 复制代码
int main()
{
    Car* pBenz = new Benz;
    pBenz->Drive();
    Car* pBMW = new BMW;
    pBMW->Drive();
    return 0;
}

运行结果:

7.多态的原理

7.1.虚函数表指针

首先呢,看一道选择题吧:

cpp 复制代码
下⾯编译为32位程序的运⾏结果是什么()

A. 编译报错 B.运⾏报错 C.8 D.12
cpp 复制代码
#include <iostream>
using namespace std;
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
protected:
    int _b = 1;
    char _ch = 'x';
};

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

相信大部分的人都会选择C这个选项,但是正确的答案选择D,但是上面题目运行结果12bytes,除了_b和_ch成员,还多⼀个__vfptr 放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针 (v代表virtual,f代 表function)。⼀个含有虚函数的类中都至少都有⼀个虚函数表指针,因为**⼀个类所有虚函数的地址要 被放到这个类对象的虚函数表中,虚函数表也简称虚表。**

如图所示:

7.2.多态是如何实现的?

通过下面的代码来解释吧,

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

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)
{
    ptr->BuyTicket();
}

int main()
{
    Person ps;
    Student st;
    Soldier sr;

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

    return 0;
}

从底层来看,Func 中同样一句 ptr->BuyTicket(),为什么能根据 ptr 指向的对象,调用不同版本的函数呢?

在常见的编译器实现中,这是通过虚函数表和虚表指针完成的。对象中保存着虚表指针,指向对应的虚函数表;虚函数表中记录着虚函数的地址。

调用 ptr->BuyTicket() 时,可以将过程理解为:

  1. 通过 ptr 找到它指向的对象。
  2. 读取对象中的虚表指针,找到对应的虚函数表。
  3. 找到表中 BuyTicket 对应的函数地址,并调用该函数。

因此:

  • ptr 指向 Person 对象,通过其虚表找到 Person::BuyTicket,输出"买票全价",如图:
  • ptr 指向 Student 对象。由于 Student 重写了 BuyTicket,其虚表中对应位置记录的是 Student::BuyTicket 的地址,因此输出"买票打折"。

这种根据对象的实际类型,在运行时确定调用哪个虚函数的机制,称为动态绑定。如果派生类没有重写该虚函数,则仍然调用继承下来的版本。

7.3.动静态绑定

对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。

如图:

解释:通过这里的调用目标已经在编译时确定Person::BuyTicket的函数地址 ,不需要在运行时读取对象的虚表,再查找函数地址。

满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

如图:

可以简单的这样子理解:

cpp 复制代码
mov rax, qword ptr [ptr]  ; ① 获取 ptr 指向的对象地址
mov rax, qword ptr [rax]  ; ② 读取对象中的虚表指针,找到虚函数表
mov rcx, qword ptr [ptr]  ; ③ 将对象地址作为 this 指针传给成员函数
call qword ptr [rax]      ; ④ 取出虚表中对应的函数地址并调用

这里最关键的是:

复制代码
call qword ptr [rax]

它没有直接指定调用 Person::BuyTicket,而是通过运行时读取的虚表找到调用目标

7.4.虚函数表

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

<2>.派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的是,这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。

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

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

例如有这么个代码:

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{
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 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;
}

如图所示:

解释:基类和派生类所指向的虚函数表的地址是不一样的,都有独立的虚表,

<5>.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 0x00000000 标记。(这个 C++ 并没有进行规定,各个编译器自行定义的,VS 系列编译器会在后面放个 0x00000000 标记,g++ 系列编译器不会放)。

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

<7>.虚函数表存在哪的? 这个问题严格说并没有标准答案C++标准并没有规定,下面的代码可以 对比验证⼀下,vs下是存在代码段(常量区)。

cpp 复制代码
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);

    Base b;
    Derive d;

    Base* p3 = &b;
    Derive* p4 = &d;

    printf("Person虚表地址:%p\n", *(int*)p3);
    printf("Student虚表地址:%p\n", *(int*)p4);

    printf("虚函数地址:%p\n", &Base::func1);
    printf("普通函数地址:%p\n", &Base::func5);

    return 0;
}

运行结果:

总结:

多态让同一个函数调用在面对不同类型的对象时,产生不同的行为。运行时多态通常通过基类指针或引用调用虚函数,并由派生类重写该虚函数来实现。函数体根据对象的实际类型选择,而缺省参数仍根据调用处的函数声明确定。

虚函数重写需要遵守函数签名规则,返回类型在满足条件时可以协变。override 用于检查是否正确重写,final 用于禁止继续重写。纯虚函数用于定义接口,尚有纯虚函数未被实现的类是抽象类,不能直接实例化。当需要通过基类指针删除派生类对象时,基类应提供虚析构函数。

在常见实现中,对象通过虚表指针找到虚函数表,再取得对应函数地址,完成动态绑定。同类型对象通常共享虚表,但各自保存虚表指针;派生类重写虚函数后,其虚表中的对应表项指向重写后的版本。虚表保存的是函数地址,函数代码本身并不存放在虚表中。

相关推荐
李纲明2 小时前
WordPress 白屏排查手册:关插件、换主题、开调试、查 PHP(附停手信号)
开发语言·php·wordpress·故障排查·白屏·插件冲突
ShineWinsu2 小时前
对于JavaScript:JavaScript基础语法的解析
开发语言·前端·javascript·面试·web·基础语法·循环
普通人63 小时前
php+html+canvas实现树状打开的结构
开发语言·javascript·html5·1024程序员节
wuminyu3 小时前
JMM学习总结:深入理解happens-before关系
java·linux·c语言·jvm·c++
电商API_180079052473 小时前
反向海淘多币种结算:汇率该在哪个时点被冻结
java·开发语言·数据库·跨境电商·1688 api
xxwl5853 小时前
区域赛分类题解
c++·笔记·学习·算法
开开心心就好3 小时前
多份文档怎么批量打印?支持多种格式
java·服务器·开发语言·pdf·ocr·散列表·启发式算法
Want5953 小时前
Python炫酷烟花①
开发语言·python
青少儿编程课堂4 小时前
蓝桥杯+全国青少年信息素养大赛+电子学会PythonC/C++考级:最长公共子序列LCS解析
c++·python·算法·bfs·信息学竞赛