【c++知识铺子】最后一块拼图-多态

关注我,学习c++不迷路:

个人主页:爱装代码的小瓶子

专栏如下:

  1. c++学习
  2. Linux学习

后续会更新更多有趣的小知识,关注我带你遨游知识世界

期待你的关注。


文章目录

  • [1. 前言:](#1. 前言:)
  • [2. 动态多态:](#2. 动态多态:)
    • [2-1 要求:](#2-1 要求:)
    • [2-2 第一个程序解释:](#2-2 第一个程序解释:)
    • 2-3第二个程序:
    • [2-4 协变:](#2-4 协变:)
    • [2-5 析构函数构成重写(覆盖):](#2-5 析构函数构成重写(覆盖):)
    • [2-6 final和override](#2-6 final和override)
  • [3. 虚函数的原理:](#3. 虚函数的原理:)
    • [3-1 虚函数表指针:](#3-1 虚函数表指针:)
    • [3-2 什么是虚函数表指针:](#3-2 什么是虚函数表指针:)
  • [3. 总结:](#3. 总结:)

1. 前言:

在讲多态的时候,我们不妨讲讲前面我们讲了那些C++的重要特性吧.

  1. 封装:核心思想:将数据和操作数据的方法绑定在一起,隐藏内部实现细节。这也是我们在之前的文章中讲到的。
  2. 继承:核心思想:建立类之间的层次关系,实现代码复用。简称子承父类。是代码复用的一种高效手段。

最后一个就是多态,简单来说就是:同一接口,多种实现方式。

多态的实现有两种方式,一种就是静态,一种则是动态。第一种我们已经在前面的函数重载和函数模板中已经讲过了.另一种则是今天需要讲的重点对象。


2. 动态多态:

2-1 要求:

动态多态一般要求两个特点:

  • 必须是基类(父类)的指针或者引用来调用函数。
  • 被调用的函数必须是虚函数 ,而且要求函数构成重写

我们来解释一下什么是虚函数:

虚函数是在函数前面加上virtual,这个关键词我们在菱形继承的时候见到过。是为了防止二义性而设计的,在这里virtual则是显示该为虚函数。

在来讲一下什么是重写,重写一般有三个要求:

  • 重写一般要求出现在继承关系中,不能在同一个类中出现。
  • 要求函数名,返回值,参数一致,函数体可以不一致。
  • 最后要求权限不能缩小。即如果父类是共有,子类并不能是私有或者保护。

2-2 第一个程序解释:

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

int main()
{
	person p1;
	student s1;
	func(p1);
	func(s1);
	return 0;
}

在这段程序中,我们有两个类,一个学生类,继承了人这一类。在买票的时,学生买票是半价。具体实现:定义两个虚函数,注意这两个满足重写的要求,同时在func函数中也满足了动态多态的要求,同时注意是父类的引用(基类)。

看结果:

我们可以看到,如果满足以上条件,就能完成多态。如果不是引用或者指针呢?

此时两个都是全价,已经不能构成多态。

2-3第二个程序:

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();
	p->func();

	return 0;
}

我们再看第二个程序。定义了两个类,一个是父类A,他有两个函数,一个是打印A和val的值,第二个则是利用this指针调用func函数。另一个则是继承A类的子类B,他的func函数与父类构成了重写。那么结果是什么呢?看起来构成了动态多态,结果似乎是B->0。

他的结果是什么呢?

其实第一个函数调用test,test的this还是父类对象,构成了多态,父类对象去调用func函数的时候val的值是不会变化的,保持val的值是1.第二个去调用func函数是直接去调用的,不构成多态,所以两个答案不一致。

总结:

  1. p->test() 的执行
    test() 是在类A中定义的,它调用 func()。由于 func() 是虚函数,且 p 指向的是B类对象,所以会调用B的 func()。
    关键点:默认参数是在编译时根据调用者的静态类型决定的,而不是运行时根据对象的实际类型决定的。test() 在类A中,所以调用 func() 时使用的是类A的默认参数 val = 1。
    结果:B->1
  2. 这里直接调用B的 func()。p 是B*类型,所以使用B的默认参数 val = 0。
    结果:B->0

2-4 协变:

在多态这一章节的中指的是:协变"特指虚函数重写时,子类方法的返回值可以是父类方法返回值的子类。这样构成了多态,我们来看一个程序:

cpp 复制代码
class Animals {
public:
	virtual const Animals* talk()const
	{
		return this;
	}
};

class dog:public Animals{
public:
	virtual const dog* talk()const
	{
		cout << "旺旺" << endl;
		return this;
	}
};

class cat :public Animals{
public:
	virtual const cat* talk()const
	{
		cout << "喵喵" << endl;
		return this;
	}
};

int main()
{
	Animals* a = new cat;
	Animals* b = new dog;
	a->talk();
	b->talk();
	return 0;
}

我们发现这些类的的放回值不相同,但是他们的返回值都是父类返回值的子类,那这样就构成了协变。结果如下:

2-5 析构函数构成重写(覆盖):

其实在这里可以叫做覆盖,这个词很是准确。在C++中,析构函数可以且应该声明为虚函数,当基类指针指向派生类对象时,确保正确调用派生类的析构函数。

cpp 复制代码
class Base {
public:
    virtual ~Base() { std::cout << "Base destructor\n"; }
};

class Derived : public Base {
public:
    ~Derived() { std::cout << "Derived destructor\n"; }
    // 这里虽然名字不同(~Base vs ~Derived),但仍然是虚函数重写
};

但是这是为什么构成了呢?明明不是一个名字,看起来就不是一个函数。

  • 析构函数有特殊的命名规则(内部名称如destructor)
  • C++标准规定:派生类的析构函数会覆盖基类的析构函数
  • 即使不写virtual关键字,派生类析构函数也会自动成为虚函数(如果基类析构函数是虚的)。
cpp 复制代码
class Base {
public:
    Base() { std::cout << "父类生成\n"; }
    virtual ~Base() { std::cout << "父类销毁\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "子类生成\n"; }
    ~Derived() { std::cout << "子类销毁\n"; }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  // 正确调用:~Derived() -> ~Base()
    return 0;
}

那么这个结果是怎么样的呢?

我们可以看到子类在生成时是先父类后子类,而销毁时则恰恰相反。

那么为什么通常要设计成虚函数呢? (重点)

我们先看一段程序:

cpp 复制代码
class A {
public:
	 virtual ~A()
	{
		cout << "~a" << endl;
	}
};

class B :public A {
public:
	 ~B()
	{
		cout << "~b" << endl;
		delete[]p;
	}
private:
	int* p = new int[10];
};


int main()
{
	A* p1 = new A;
	A* p2 = new B;
	B* p3 = new B;
	delete p1;
	delete p2;
	delete p3;
	return 0;
}

这是三个变量,其中两个都是以基类作为指针的,但是我们发现如果加了virtual是可以正确调用到B的析构函数。

可以看我给的注释,但是如果去掉了虚函数,那么就有大问题了:

我们可以看到在调用函数的时候p2不能正确调用函数,这是他不是虚函数,他的对象是A,调用了A的析构函数,而需要调用B来完成内存管理。无法多态,正确管理内存和new出来的p变量,导致内存泄漏。

bash 复制代码
p2 → [A部分][B部分(p指针指向的内存)]↗
          ↑                      ↑
      只释放这里              这里永远不释放!

为什么p3可以正常呢?

cpp 复制代码
B* p3 = new B;  // 静态类型:B*,动态类型:B
delete p3;      // 正常工作
  • 调用B的析构函数 ~B(),输出 ~b,释放数组内存
  • 自动调用基类A的析构函数 ~A()(派生类析构函数会自动调用基类析构函数)
  • 输出:~b → ~a。主要是还是原本就指向B,正确调用。

2-6 final和override

这两个关键字是C++11引入的,用于增强对继承和虚函数重写的控制。我们在些函数的重写的时候,有时候函数名字写错了,但是编译器不会报错,加上override明确表示函数是重写基类的虚函数,让编译器帮你检查是否正确重写。

比如:

cpp 复制代码
class car {
public:
	virtual void Dirve()
	{

	}
};

class Benz :public car {
public:
	virtual void Dive()
	{
		cout << "Ben - 舒服" << endl;
	}
};

int main()
{
	car* p1 = new Benz;
	p1->Dirve();
	return 0;
}

上面的代码故意写错了函数的名字,导致无法构成重写。

如果加上override,就会强制检查。

final则是有两个用途:修饰类(禁止继承)和修饰虚函数(禁止重写)。

抽象类在这里我也讲一下:

如果下面的函数没有写override并且写错了,这是会报错,如图:

抽象类是无法实例化的,如果不构成重写。

3. 虚函数的原理:

3-1 虚函数表指针:

我们先看这段代码:

cpp 复制代码
class A {
public:
	virtual void base()
	{
		cout << "1" << endl;
	}

private:
	int _a;
	char _ch;
};


int main()
{
	A a1;
	cout << sizeof(a1) << endl;
	return 0;
}

我们的程序如上,按照正常的结果来看应该是4 + 1 = 5,最好在内存对齐是8,但是这里结果确是12。

这是因为这里多了一个函数数组的指针,指针在32位下是4,那么就是4 + 1 + 4 = 9。最好进行内存对齐为12.

该结构体中,内存到底如何计算:

  • 静态成员变量:不占用类实例的大小
  • 成员函数指针变量:如果作为成员变量,才占用空间
  • 虚函数表指针:如果有虚函数,会有一个隐藏的vptr(4字节)
cpp 复制代码
class Simple {
    int* p;    // 偏移0-3,4字节
    char c;    // 偏移4,1字节
};
// 大小计算:4 + 1 = 5,但对齐到4的倍数 → 8字节
cpp 复制代码
class WithVirtual {
public:
    virtual ~WithVirtual() {}  // 添加虚函数
private:
    int* p;    // 偏移:vptr(0-3), p(4-7)
    char c;    // 偏移:8
};
// 大小:vptr(4) + p(4) + c(1) = 9,对齐到4的倍数 → 12字节

3-2 什么是虚函数表指针:

在之前的程序监视窗口中,我们看到一个void** 的二级指针变量vfptr。

它指向一个数组,数组中存储了虚函数的地址,这个数组是函数指针的数组。让后我们定义了一个指针指向这个数组。我们可以看到这个vfptr中有一个虚函数是void base这个虚函数。通过虚函数表我们可以快速查询调用函数。

我们再通过监视子类和父类的虚函数表指针,发现很多东西:虚函数表指针所存贮的地址不同,是两个函数地址数组,但是子类没写的虚函数的地址是一致的,符合继承。重写的函数则不是一致的。这里可能是bug,我也不知道为啥不是虚函数的func2会在子类B的虚函数中。

具体如何实现的呢?

  • 编译时:编译器为每个有虚函数的类创建虚函数表
  • 对象构造时:设置对象的vptr指向正确的虚函数表
  • 虚函数调用时:
    通过对象的vptr找到虚函数表
    通过偏移量找到正确的函数地址
    调用该函数
  • 继承时:派生类的虚函数表包含重写的函数指针。

其实就是裁切时,正确的虚函数表指针找到虚函数,完成调用。


3. 总结:

C++多态是指同一接口表现出不同行为的特性,分为编译时多态(通过函数重载和模板实现)和运行时多态(通过虚函数和继承实现,利用虚函数表指针动态绑定实际调用的函数)。

相关推荐
hefaxiang1 小时前
分支和循环(中)
c语言·开发语言
蚂蚁取经1 小时前
Qt C++ 小部件 QCustomPlot 的使用
c++·qt·信息可视化
okseekw1 小时前
一篇吃透函数式编程:Lambda表达式与方法引用
java·后端
程序员根根1 小时前
JavaSE 进阶:IO 流核心知识点(字节流 vs 字符流 + 缓冲流优化 + 实战案例)
java
认真敲代码的小火龙1 小时前
【JAVA项目】基于JAVA的超市订单管理系统
java·开发语言·课程设计
CryptoRzz1 小时前
对接墨西哥股票市场 k线图表数据klinechart 数据源API
开发语言·javascript·web3·ecmascript
yue0081 小时前
C# 实现电脑锁屏功能
开发语言·c#·电脑·电脑锁屏
油丶酸萝卜别吃1 小时前
在springboot项目中怎么发送请求,设置参数,获取另外一个服务上的数据
java·spring boot·后端
chilavert3181 小时前
技术演进中的开发沉思-230 Ajax:Prototype.js 重构原生 DOM
开发语言·前端·javascript