【C++】面向对象的三大性质——多态,从使用到底层。


前言


一、多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

举例:对于买票的这个行为来说,成年人买票全价,儿童买票半价,学生买票打折,军人优先买票……不同的人虽然都是进行买票的行为,但买票过程的细节不完全相同。而为了让不同的对象,进行同一行为,产生不同的状态。我们则需要采用面向对象的三大特性之一:多态。

下面看一段简单的多态代码,后文进行解释:

class Person
{
public:
	virtual void BuyTicket() { cout << "Person::BuyTicket()" << endl; }
};
class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "Student::BuyTicket()" << endl; }
};
int main()
{
	Person pn;
	Student st;
	Person* ppn = &pn;
	ppn->BuyTicket(); //普通人买票
	ppn = &st;
	ppn->BuyTicket(); //学生买票
	return 0;
}

打印结果:
在这里插入图片描述

二、多太的定义和实现

多态分为2类:静态多态和动态多态

静态多态:编译器在编译期间完成,编译器根据函数实参的类型(可能会隐式类型转换),可推断出要调用哪个函数,如果有对应的函数就调用相应的函数,否则就报一个编译错误。如函数重载

动态多态:在函数执行期间(非编译期)判断所引用对象的实际类型,根据实际类型的调用相应的方法。使用virtual关键字修饰类的成员函数,指明该函数为虚函数,派生类需要重新实现,编译器将实现动态绑定。如继承中的虚函数

2.1 动态多太的构造条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。就像上面的代码:Student继承了Person。Person对象买票全价,Student对象买票半价。

而在继承中构成多态有两个条件(牢记):

  1. 必须通过基类的指针或者引用调用虚函数
  2. 此时基类的指针或引用已经被赋值为了派生类的对象的地址,且被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行了重写

这中间出现了两个陌生的名词:虚函数和重写,因此我们首先要了解这两个词的意思是什么?

2.2 虚函数

虚函数:即被virtual修饰的类成员函数称为虚函数

比如BuyTicket()函数

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

2.3 重写(覆盖)

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

class Person 
{
public:
 virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person 
{
public:
 virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

注意:

  1. 派生类进行重写时可以不加virtual,只要派生类里的函数和基类的虚函数的返回值类型、函数名字、参数列表完全相同,编译器会自动识别为基类虚函数的重写。但一般为了可读性还是加上
  2. 返回值类型、函数名字、参数列表完全相同。其中参数列表完全相同指的是参数类型+形参的名字完全相同,对于缺省值不作要求.
    virtual void func(int val = 3) {}
    virtual void func(int val = 4) {} 它们依然构造重写
  3. 虚函数的重写是对函数体进行重写
    基类里的虚函数:virtual void func(int val = 3) {cout << "基类" << val;}
    派生类的虚函数:virtual void func(int val = 4) {cout << "派生类" << val;}
    当你调用派生类的虚函数func()时,你会发现打印的结果是派生类3,即它会使用基类的虚函数头 + 派生类的虚函数体。后文有个面试题考察了这个知识。

虚函数重写的两个特例:

  1. 协变(基类与派生类虚函数返回值类型不同)
    上面说了虚函数重写需要返回值的类型相同,但是给了一个特例:基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时也可以是虚函数的重写,这种情况被称为协变
class A{};
class B : public A {};
class Person {
public:
 virtual A* f() {return new A;}
};
class Student : public Person {
public:
 virtual B* f() {return new B;}
};
  1. 析构函数的重写(基类与派生类析构函数的名字不同)
    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,因此所有的析构函数都满足函数名相同。
class Person {
public:
 virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
 virtual ~Student() { cout << "~Student()" << endl; }
};

问题:为什么要对析构函数的名称进行处理?博客析构函数的名称为什么统一处理为destructor


知晓了这两个条件,我们来看上面的那段代码。

class Person
{
public:
	virtual void BuyTicket() { cout << "Person::BuyTicket()" << endl; }
};
class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "Student::BuyTicket()" << endl; }
};
int main()
{
	Person pn;
	Student st;
	Person* ppn = &pn;
	ppn->BuyTicket(); //普通人买票
	ppn = &st;
	ppn->BuyTicket(); //学生买票
	return 0;
}

解释:为什么ppn能调用到派生类的BuyTicket()?

  1. ppn的类型是基类person的指针满足多态的第一个条件
  2. ppn已经被赋值为派生类对象st的地址,且BuyTicket()对基类的BuyTicket进行了重写。满足第二个条件
    因此:ppn->BuyTicket会调用子类的虚函数。

2.4 C++11 override 和 final

final:修饰虚函数,表示该虚函数不能再被重写
使用场景:当你不想某个虚函数被重写时,可以加上final

在这里插入图片描述

override:帮助派生类检查是否完成重写

在这里插入图片描述

2.5 重载,隐藏,重写

在这里插入图片描述


三、多态的原理

看完上面的内容,相信你会有以下的困惑:

  1. 为什么基类的对象或指针能调用到派生类的函数?
  2. 为什么限定为基类的指针或引用,基类的对象不行吗?

要解答这些问题,我们必须要了解定义虚函数时产生的虚函数表。

3. 1虚函数表

class A
{
public:
	virtual void func1() {};
	virtual void func2() {};
	char a;
};
int main()
{	
	A A1;
	cout << sizeof(A1);
	return 0;
}

在32位的机器下,请问上面的打印结果是什么?
如果func的前面没有加virtual,结果很明显是1,但加上virtual后,结果变成了8.
那多出的内存放了些什么东西呢?
我们此时打开监视窗口:
在这里插入图片描述

发现A1中出现了一个指针_vfptr,我们猜测它代表的什么意思:v即virtual,f即function,ptr即指针,猜测它是虚函数指针。但它下面有【0】【1】,这又表明它可能是个数组。结合以下,即_vfptr是虚函数指针数组。
事实上,它还真是一个虚函数指针数组,只不过我们将这个数组叫做虚函数表,简称虚表
这时我们可以确定2个事实

  1. 多出来的内存存储了一个指针,32位下的指针是4字节,加上char a的1字节,最后在进行内存对齐,结果就是8字节。
  2. 这个指针指向的空间并不存储在对象里面,如果存储在对象里,那么对象的大小应该大于8字节。

此时我们可以画一个简图:
在这里插入图片描述


知晓了虚函数表的存在,随之而来的就有2个问题:

  1. 虚函数表是如何完成多态的功能?
  2. 虚函数表并没有存储在对象里,那它存储在什么地方?

同时加上前文提到的问题:为什么多态的构成条件要求是基类的指针或引用?

3.2 虚函数表如何完成多态的功能

在这里插入图片描述
通过监视窗口,我们可以看到:
a1的_vfptr[0]与b1_vfptr[0]不相同,但_vfptr[1]是相同的。那就有问题了:a1和b1的_vfptr[0]和_vfptr[1]指向的是哪个函数的地址?
验证如下,直接通过虚表里的地址来调用虚函数。
在这里插入图片描述

据此,我们基本确定调用虚函数的过程如下:

  1. 基类对象和派生类对象都会创建虚函数表,基类对象的虚函数表存储基类的虚函数地址,派生类对象的虚函数表会拷贝基类的虚函数表,同时将重写的虚函数地址改为自己的
  2. 当我们使用基类的对象的指针或引用去调用时,分别取指向对象的虚表去寻找。这就解释了为什么不能使用基类的对象,因为基类的对象里的虚表存储的是基类虚函数的地址,无法找到派生类的虚函数。

代码:

#include <iostream>
class A
{
public:
	virtual void test() final { std::cout << "A test" << std::endl; }
	virtual void Func() { std::cout << "A func" << std::endl; }
};

class B : public A
{
public:
	virtual void Func() { std::cout << "B func" << std::endl; }
};

using FuncPtr = void (*) ();

void Print(FuncPtr table[])
{
	for (size_t i = 0; i < 2; i++)
	{
		auto f = table[i];
		printf("[%d]: %p ",i ,f);
		(*f)();
	}
}
int main()
{
	A a;
	B b;
	int a_vfptr = *((int*)(&a));
	int b_vfptr = *((int*)(&b));
	Print((FuncPtr*)a_vfptr);
	Print((FuncPtr*)b_vfptr);
	return 0;
}

3.3 虚函数表存储在内存空间的那个区域?

A:栈
B:堆
C:代码段(常量区)
D:数据段(静态区)
先说答案 : 代码段(常量区)

验证如下:
在这里插入图片描述
思路:比较虚函数表内存储的地址与其他存储区域的地址进行对比,看谁更接近。
通过上面的结果可以看出:虚表的地址 与 常量区的地址最为接近。

如何提取虚表的地址:首先对象第一个存储的便是虚函数表指针,因此前4个字节(32位)存储便是虚函数表的地址(int*)&ps 即是 _vfptr的地址, 再解引用便是 _vfptr存储的地址,即虚函数表的地址
在这里插入图片描述


四、奇异递归模板模式

C++中的奇异递归模板模式(CRTP, Curiously Recurring Template Pattern)是一种模板编程技巧,它允许子类将自己作为模板参数传递给基类。这种模式的主要用途是实现静态多态性,提供类似于动态多态性的行为,但避免了运行时开销。

// The Curiously Recurring Template Pattern (CRTP)
template<class T>
class Base
{
    // methods within Base can use template to access members of Derived
};
class Derived : public Base<Derived>
{
    // ...
};

举例:

template <typename Derived>
class Base {
public:
    void interface() {
        // 调用子类的方法
        static_cast<Derived*>(this)->implementation();
    }

    // 基类可以提供一个默认的实现
    void implementation() {
        std::cout << "Base implementation" << std::endl;
    }
};

class Derived : public Base<Derived> {
public:
    // 子类可以提供自己的实现
    void implementation() {
        std::cout << "Derived implementation" << std::endl;
    }
};

int main() {
    Derived d;
    d.interface();  // 输出 "Derived implementation"
    return 0;
}

静态多态性:在 Base 类的 interface() 方法中,通过 static_cast<Derived*>(this) 将基类的 this 指针转换为子类的 this 指针,从而调用子类的 implementation() 方法。这种方式实现了类似于虚函数的行为,但它是在编译时完成的,因此没有运行时开销。

平时使用最多的std::enable_shared_from_this
在C++标准库头文件中,std::shared_ptr类封装了可被共享使用的指针或资源。一个被共享的对象不能直接把自身的原始指针(raw pointer)this传递给std::shared_ptr的容器对象(如一个std::vector),因为这会生成该被共享的对象的额外的共享指针控制块。为此,std::shared_ptr API提供了一种类模板设施std::enable_shared_from_this,包含了成员函数shared_from_this,从而允许从this创建一个std::shared_ptr对象。

注:一个被共享的对象把自身的原始指针(raw pointer)this传递给std::shared_ptr错误的例子

在这里插入代码片
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值