首页 > 编程语言 >【C++】深究C++三大特性之多态

【C++】深究C++三大特性之多态

时间:2024-09-13 13:21:16浏览次数:3  
标签:虚表 函数 基类 多态 C++ 派生类 重写 三大

1. 多态的概念

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

例如:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时时是优先买票。

2. 多态的定义及实现

2.1 多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买全价票,Student对象买半价票。

代码示例:

class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "购买全价票" << endl;
	}
};
class Student:public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "购买半价票" << endl;
	}
};
void Func(Person& people)
{
	people.BuyTicket();
}
int main()
{
	Person p;
	Func(p);
	Student s;
	Func(s);
	return 0;
}

继承中构成多态的两个条件:

1.必须通过基类的指针或者引用调用虚函数。

2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。

2.2 虚函数

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

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

virtual关键字:

1.可以修饰成员函数,为了完成虚函数的重写,满足多态的条件之一。

2.也可以在菱形继承中,去完成虚继承,解决数据冗余和二义性。

他们都使用了同一个关键字,但是他们互相之间没有关联。

2.3 虚函数的重写

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

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

注意:

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

那么如何看对象调用的是谁的虚函数呢?

看是否满足多态

1.满足多态:跟指向的对象有关,指向的对象是谁,就调用谁的虚函数。

2.不满足多态(构成多态的两个条件不满足其中一个):跟调用对象的类型有关,类型是谁,就调用谁的虚函数。

代码示例1:

代码示例2:

2.3.1 笔试题:

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;
}

程序的运行结果是什么?

A: A->0   B: B->1   C: A->1   D: B->0

答案:B。

思路解析:

1、p调用test()相当于把p传给了test(),而test( A*this),此时,把p传给了this,相当于父类A的指针指向了子类对象。

2、子类func虚函数完成了重写(隐藏了virtual),test函数调用func虚函数时,指向的对象为B,所以调用B的func虚函数。

3、子类虚函数的重写只是继承了父类虚函数的函数名、参数、返回值,继承的是函数的接口,然后重写函数具体的实现。

4、所以,此时,B的func默认参数不起作用,结果就是"B->1"。

2.3.2 虚函数重写的两个例外

1.协变(基类与派生类虚函数返回值类型不同)

派生类重写虚函数时,基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用。

class A
{};
class B :public A
{};
class Person
{
public:
	virtual A* func()
	{	}
};
class Student : public Person
{
public:
	virtual B* func()
	{	}
};

2.析构函数的重写(基类与派生类析构函数的名字不同)

面试题:析构函数是否需要定义为虚函数?

说明:

只有派生类Student的析构函数重写了Person的析构函数,delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。

注意:

如果基类的析构函数为虚函数,此时派生类的析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同,似乎违背了重写的规则,其实,编译器会对析构函数的名称做特殊处理,编译后统一处理成destructor。

2.4 C++11 override和final

C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名、字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才debug。

因此,C++11提供了override和final两个关键字,可以帮助用户检测是否重写

1.final:修饰虚函数,表示该虚函数不能再被重写

final也可以修饰类,表示这个类不能被继承

之前我们说过如何设计一个可以不被继承的类(上一篇博客中写到),可以把父类的构造函数私有化(C++98),这里给出了一个C++11的方式,用final修饰这个类。

2.override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

2.5 重载、覆盖(重写)、隐藏(重定义)的对比

3. 抽象类

3.1 概念

在虚函数的后面写上=0,这个函数为纯虚函数。包含纯虚函数的类叫抽象类(接口类)。

抽象类不能示例化出对象。派生类继承后也不能实例化对象。

说明:

为什么子类Benz也不能实例化出对象呢?

因为Benz继承了父类的纯虚函数,因此,Benz也是一个抽象类。

只有重写纯虚函数,派生类才能实例化出对象。

纯虚函数的作用:

1.强制子类完成重写。

2.表示抽象类。抽象就是现实生活中没有对应的实体。

3.2 接口继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数, 继承的是函数的实现 。 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态, 继承的是接口 。 所以如果不实现多态,不要把函数定义成虚函数。

4. 多态的原理

4.1 虚函数表

先看一道常考的笔试题,计算sizeof(Base)是多大?
为什么是8个字节呢? 我们知道 成员函数是编译成了指令,放到了代码段 ,不计算,只计算成员变量的大小就可以了。而这里_b是一个int类型,应该是4个字节,为什么是8呢? 通过监视窗口可以看到,这里b对象除了_b成员,还多了一个_vfptr的指针。这里是32位平台指针大小为4,所以b对象大小为8。
对象中的这个指针叫做虚函数表指针(虚表指针),指向虚函数表(虚表)。 虚函数表本质就是一个指针数组,存放的是虚函数的地址。

4.2 多态的原理

了解了虚函数表和虚表指针的存在以后,我们再来探究多态的原理:

图文分析: 说明: 1.子类的虚表指针和父类的是同一个,因为继承了父类,父类已经有了虚表指针。 2.重写其实就是虚表里原本父类的虚函数指针会变成子类自己的虚函数指针。 3.所以重写也叫覆盖,从原理上讲是子类自己的虚函数指针覆盖了虚表父类的虚函数指针。 如果没有重写,虚表也不会覆盖: 我们通过反汇编再来简单了解一下多态的原理:
了解了多态的原理,再来深究一下虚函数表:
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

说明: 1. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表 中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数 的覆盖。重写是语法的叫法,覆盖是原理层的叫法。 2. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。 3. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。 4.  总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
容易混淆的面试题:虚函数存在哪?虚表存在哪? 答:虚函数存在虚表,虚表存在对象。 注意: 这样的回答是错误的。 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。 那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。

1调试验证:

class Base
{
public:
	virtual void Func1()
	{		cout << "Base::Func1()" << endl;	}
	virtual void Func2()
	{		cout << "Base::Func2()" << endl;	}
	void Func3()
	{		cout << "Base::Func3()" << endl;	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{		cout << "Derive::Func1()" << endl;	}
private:
	int _d = 2;
};
void test1()
{
	Base b1;
	Derive d1;
}
void test2()
{
	Base b2;
	Derive d2;
}
int main()
{
	test1();
	test2();
	return 0;
}

通过调试窗口看到,同类型对象的虚函数表指针相同,也就是说同类型的对象共用一个虚表。所以虚表只能存放到代码段。

2代码验证:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
void test()
{
	Base b;
	printf("vfptr虚表地址:%p\n", *(int*)&b);
    //printf("vfptr虚表地址:%p\n,(int)b);直接转语法不接受,要用指针转再解引用
	int i = 0;
	int* p1 = &i;
	int* p2 = new int;
	const char* p3 = "hello";
	printf("栈变量地址:  %p\n", p1);
	printf("堆变量地址:  %p\n", p2);
	printf("代码段常量:  %p\n", p3);
	printf("虚函数地址:  %p\n", &Base::Func1);
	printf("普通函数地址:%p\n", test);
}
int main()
{
	test();
	return 0;
}

可以看到,虚表地址和代码段中的地址非常接近,所以虚表放在了代码段。

虚表地址 *(int*)&b的求解:

我们只需要取出b对象的前四个字节就是虚表的地址,因为虚表指针放在了成员对象_b的前面。&b是一个Base类型的地址,所以要强制转换为int*类型,再解引用就取出了b对象的前四个字节。

4.3 动态绑定与静态绑定

1. 静态绑定又称为前期绑定(早绑定),在 程序编译期间 确定了程序的行为,也称为静态多态, 比如:函数重载。 2. 动态绑定又称后期绑定(晚绑定),是在 程序运行期间 ,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。 代码示例:

5. 单继承和多继承关系的虚函数表

下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经研究过了。

5.1 单继承中的虚函数表

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

说明: 监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。那么我们如何查看d的虚表呢?下面我们使用代码打印 出虚表中的函数。 打印虚表:
class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};
typedef void(*VF_PTR)();//typedef函数指针类型
//打印虚表->虚表本质就是一个虚函数指针数组(最后面放了一个nullptr)
void PrintVFTable(VF_PTR* ptable)
{
	for (int i = 0; ptable[i] != 0; i++)
	{
		printf("vftable[%d]:%p->", i, ptable[i]);
		VF_PTR f = ptable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
	//取对象中前四个字节就是虚表指针
	PrintVFTable((VF_PTR*)*(int*)&b);
	PrintVFTable((VF_PTR*)*(int*)&d);
	return 0;
}

需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要清理解决方案,再编译就好了。

5.2 多继承中的虚函数表

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
int main()
{
    Derive d;
    return 0;
}

计算一下sizeof(d)是多大?

20个字节,Base1的虚表指针和b1,Base2的虚表指针和b2,Derive的d1。总共20字节。

可以看到Base1、Base2分别有一个虚表指针,Derive重写了Base1和Base2的func1()。

Derive未重写的func3放到了谁的虚表中呢?

我们打印虚表来看一下:

打印虚表:

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* ptable)
{
	printf("虚表地址:%p\n", ptable);
	for (int i = 0; ptable[i] != 0; i++)
	{
		printf("vftable[%d]:%p->", i, ptable[i]);
		VF_PTR f = ptable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	PrintVFTable((VF_PTR*)*(int*)&d);
	PrintVFTable((VF_PTR*)*(int*)((char*)&d+sizeof(Base1)));
	return 0;
}

说明: 多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

6. 面试题

1.inline函数可以是虚函数吗?

可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为inline要放到虚表中。

2.静态成员可以是虚函数吗?

不能,静态成员函数没有this指针,使用类型::成员函数的方式无法访问虚表。

3.构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

4.对象访问普通函数快还是虚函数更快?

如果是普通对象是一样快的。如果是指针对象或者引用,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚表中查找。

5.虚函数表是在什么阶段生成的,存在哪?

编译阶段,代码段(常量区)。

6.什么是多态?

xxx

7.多态的实现原理?

xxx

8.什么是重载、重写、重定义?

xxx

9.析构函数可以是虚函数吗?什么场景下是?

xxx

10.什么是抽象类,作用?

xxx

标签:虚表,函数,基类,多态,C++,派生类,重写,三大
From: https://blog.csdn.net/2202_75924820/article/details/142092472

相关文章

  • Android生成C++ AIDL
    生成C++[Android]接口cpp和ndk的区别cpp:生成的代码是为了在Android源码中编译,代码中会调用Android源码中的native接口。例如,引用的头文件:,,,ndk:生成的代码是为了使用ndk独立编译,调用的是ndk的接口,例如,引用的头文件:–lang=cpp,参数指定生成Android源码下编译的C++接口文件......
  • 复合Simpson求积算法-C++【可直接复制粘贴/欢迎评论点赞】
    背景复合Simpson求积算法是基于Simpson1/3法则的推广。Simpson1/3法则是一种数值积分方法,它通过将积分区间划分为多个小区间,并在每个小区间上采用一个二次多项式来逼近原函数,进而求得积分的近似值。复合Simpson求积算法则是将这种方法应用于整个积分区间,即将整个区间划分为......
  • Gauss列主元素消去法-C++【可直接复制粘贴/欢迎评论点赞】
    Gauss列主元素消去法(也称为列主元Gauss消去法)是Gauss消去法的一种改进版本,主要用于求解线性方程组。在C++中实现时,它具有一些显著的优点和缺点,并且有着深厚的数学和计算背景。优点提高数值稳定性:列主元Gauss消去法通过在每一列中选择绝对值最大的元素作为主元,从而避免了在消......
  • 牛顿插值法-C++【可直接复制粘贴/欢迎评论点赞】
    牛顿插值法是一种基于给定数据点集构造插值多项式的方法,用于近似未知函数的值。该方法通过构造差商表并利用该表逐步构建插值多项式。相较于拉格朗日插值法,牛顿插值法的一个显著优势是,当需要增加插值点时,只需重附上一项即可,无需重新计算所有插值点的值。基本概念牛顿插值法的......
  • 【C++基础概念理解——std::invoke()函数基础知识】
    std::invoke定义std::invoke是C++17引入的一个标准库函数,用于通用地调用可调用对象(如函数指针、成员函数指针、函数对象、lambda表达式等)。它提供了一种统一的方式来调用这些可调用对象,而不需要关心它们的具体类型。功能std::invoke可以调用以下类型的可调用对象:......
  • C++17新特性探索:拥抱std::optional,让代码更优雅、更安全
    std::optional背景在编程时,我们经常会遇到可能会返回/传递/使用一个确定类型对象的场景。也就是说,这个对象可能有一个确定类型的值也可能没有任何值。因此,我们需要一种方法来模拟类似指针的语义:指针可以通过nullptr来表示没有值。解决方法是定义该对象的同时再定义一个附加的......
  • 分享一些程序员常用的C++知识点
    以下是一些C++中的常用知识点:一、基础语法数据类型基本数据类型:整型(int):用于表示整数,通常占用4个字节(32位系统)。例如:intnum=10;浮点型(float、double):用于表示小数,float精度较低,double精度较高。如floatf=3.14f;(注意f后缀表示float类型),doubled=3.1415926......
  • C++入门基础
    个人主页:Jason_from_China-CSDN博客所属栏目:C++系统性学习_Jason_from_China的博客-CSDN博客前言 这一篇章算是C++栏目的语法的第一篇章,主要是为类和对象打下基础,介绍一些C++基本的语法结构这里提醒一下,要是看不懂第一步创建文件的,其实更建议先学习一下C语言,因为C++的......
  • Qt C++设计模式->建造者模式
    建造者模式简介建造者模式(BuilderPattern)是一种创建型设计模式,它通过使用多个简单的对象一步步构建一个复杂对象。建造者模式允许你分步骤创建复杂对象,并且你可以控制每个步骤如何执行。该模式常用于对象的创建过程非常复杂且需要有多个可选参数的场景。建造者模式的应用场......
  • VSCode配置C++环境
    前言VSCode作为一款“宇宙级”的编辑软件,为用户提供了丰富的插件。本文是一片保姆级关于VSCode配置C++环境教程;步骤一:MinGW安装MinGW(MinimalistGNUforWindows)是一款在Windows平台使用的开发工具集,提供了C/C++编译工具。MinGW下载地址:WinLibs-适用于Windows的GCC+Mi......