C++--多态

发布于:2025-07-05 ⋅ 阅读:(14) ⋅ 点赞:(0)

多态

1. 多态基础知识

1. 1 多态的概念

**概念:**通俗来说,就是多种形态。

**分类:**多态分为编译时多态(静态多态)和运行时多态(动态多态)。

编译时多态(静态多态)

主要指前面提到的函数重载函数模版,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实际传给形参的参数匹配是在编译时完成的,所以把编译时一般归为静态,运行时归为动态。

运行时多态(动态多态)

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。

比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是“喵喵”,传狗对象过去,就是“汪汪”。

1.2 多态的定义及实现

下面所提及的多态都是运行时多态(动态多态)。

1.2.1 多态的构成条件

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

多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。在继承中要想构成多态需要满足两个条件:

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

(因为只有基类的指针或引用才能既指向基类对象又指向派生类对象)

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

(只有对基类函数重写了,基类和派生类才能有不同的函数,多态的不同形态效果才能达到)

在这里插入图片描述

1.2.2 虚函数

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

class Person
{
public:
	//被virtual修饰的类成员函数
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

注意:

  1. 只有类的非静态成员函数前可以加 virtual ,普通函数前不能加 virtual
  2. 虚函数这里的 virtual 和虚继承中的 virtual 是同一个关键字,但是它们之间没有任何关系。虚函数这里的 virtual 是为了实现多态,而虚继承的 virtual 是为了解决菱形继承的数据冗余和二义性。
1.2.3 虚函数的重写

虚函数的重写也叫做虚函数的覆盖,若派生类中有一个和基类完全相同的虚函数(返回值类型相同、函数名相同以及参数列表完全相同),此时我们称该派生类的虚函数重写了基类的虚函数。

例如,以下 Student 子类重写了父类 Person 的虚函数。

//父类
class Person
{
public:
	//父类的虚函数
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

//子类
class Student : public Person
{
public:
	//子类的虚函数重写了父类的虚函数
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
};

现在就可以通过父类 Person 的指针或者引用调用虚函数 BuyTicket ,此时不同类型的对象,调用的就是不同的函数,产生的也是不同的结果,进而实现了函数调用的多种形态。

1.2.4 虚函数重写的特例

子类虚函数中的 virtual 可以省略

子类虚函数中的 virtual 可以省略,但是父类的 virtual 一定不能省略,虚函数的继承是接口继承,也就是说,子类中继承得到的虚函数和父类虚函数的函数接口是完全相同的,而子类如果对虚函数进行重写,重写的也只是虚函数的实现,并没有改变虚函数的接口,所以即使不加 virtual 子类虚函数的类型也和父类一样,是虚函数类型,其本质是因为继承后基类的虚函数被继承下来但为了程序的可读性,建议子类虚函数也加上 virtual

在这里插入图片描述

补充:如果父类函数不是虚函数,这子类和父类会因为函数名相同而构成隐藏

1.2.4 有关虚函数重写的一些其他问题
1.2.4.1 协变

概念:派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。但是需要满足基类虚函数返回的是一个基类对象的指针或者引用,派生类虚函数返回的是一个派生类对象的指针或者引用,即称为协变,满足多态的语法要求。

例如,下列代码中基类 Person 当中的虚函数 fun 的返回值类型是A对象的指针,派生类 Student 当中的虚函数 fun 的返回值类型是B对象的指针,此时子类和父类的虚函数返回值不同,但是因为A和B也构成父子关系,也就是满足协变,所以此时也认为派生类 Student 的虚函数重写了基类 Person 的虚函数,语法是正确的。

//基类
class A{};

//子类
class B : public A{};

//基类
class Person
{
public:
	//返回值为基类A的指针
	virtual A* fun()
	{
		cout << "A* Person::f()" << endl;
		return new A;
	}
};

//子类
class Student : public Person
{
public:
	//返回值为派生类B的指针
	virtual B* fun()
	{
		cout << "B* Student::f()" << endl;
		return new B;
	}
};

此时,去掉A和B,将 Person 和 Student 作为返回值,因为其满足父子关系,所以也是可以的。

//基类
class Person
{
public:
	//返回值为基类A的指针
	virtual Person* fun()
	{
		cout << "A* Person::f()" << endl;
		return nullptr;
	}
};

//子类
class Student : public Person
{
public:
	//返回值为派生类B的指针
	virtual Student* fun()
	{
		cout << "B* Student::f()" << endl;
		return nullptr;
	}
};
1.2.4.2 析构函数的重写

**概念:**虽然基类与派生类析构函数名字不同,但是如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。

例如,下面代码中父类 A 和子类 B 的析构函数构成重写。

//父类
class A
{
public:
 	virtual ~A()
 	{
 		cout << "~A()" << endl;
 	}
};

class B : public A 
{
public:
 	virtual ~B()
 	{ 
 		cout << "~B()->delete:"<<_p<< endl;
		delete _p;
 	}
protected:
 	int* _p = new int[10];
};

那父类和子类的析构函数构成重写的意义何在呢?

试想以下场景:分别 new 一个父类对象和子类对象,并均用父类指针指向它们,然后分别用delete调用析构函数并释放对象空间。

int main()
{
	//分别new一个父类对象和子类对象,并均用父类指针指向它们
	A* p1 = new A;
	A* p2 = new B;

	//使用delete调用析构函数并释放对象空间
	delete p1;
	delete p2;
    
	return 0;
}

在这种场景下,若是父类和子类的析构函数没有构成重写就可能会导致内存泄漏,因为此时 delete p1delete p2 都是调用的父类的析构函数,如果都调用父类的析构函数,那p2所指向的子类对象中所开辟的 _p 就没有办法释放就是导致内存泄漏,而所期望的是 p1 调用父类的析构函数, p2 调用子类的析构函数,即期望的是一种多态行为

此时只有父类和子类的析构函数构成了重写,才能使得 delete 按照预期进行析构函数的调用,才能实现多态。因此,为了避免出现这种情况,建议将父类的析构函数定义为虚函数。

**补充:**继承当中,子类的析构函数和父类的析构函数构成隐藏的原因就在这里,这里表面上看子类的析构函数和父类的析构函数的函数名不同,但是为了构成重写,编译后析构函数的名字会被统一处理成destructor(); ,这样表面上看起来的名字不同的析构函数,实际底层的函数名都是一样的,也就满足构成虚函数重写的条件。

1.2.5 override和final关键字

从上面可以看出,C++对函数重写的要求比较严格,有些情况下由于疏忽可能会导致函数名的字母次序写反而无法构成重写,而这种错误在编译期间是不会报错的,直到在程序运行时没有得到预期结果再来进行调试会得不偿失,因此,C++11提供了final和override两个关键字,可以帮助用户检测是否重写。

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

例如,父类 Person 的虚函数 BuyTicketfinal 修饰后就不能再被重写了,子类若是重写了父类的 BuyTicket 函数则编译报错。

//父类
class Person
{
public:
	//被final修饰,该虚函数不能再被重写
	virtual void BuyTicket() final
	{
		cout << "买票-全价" << endl;
	}
};

//子类
class Student : public Person
{
public:
	//重写,编译报错
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
};

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

例如,子类 Student 的虚函数 BuyTicketoverride 修饰,编译时就会检查子类的这两个 BuyTicket 函数是否重写了父类的虚函数,如果没有则会编译报错。

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

//子类
class Student : public Person
{
public:
	//子类完成了父类虚函数的重写,编译通过
	virtual void BuyTicket() override
	{
		cout << "买票-半价" << endl;
	}
};
1.2.6 重载、覆盖(重写)、隐藏(重定义)的对比

在这里插入图片描述

1.3 有关多态的面试题

以下程序输出结果是什么( )
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不准确

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

解题思路:

  • 在主函数中首先观察到**创建了一个子类指针 p 用来指向一个子类对象 B **,然后通过 p 调用父类的 test() 函数。

  • 这里可以看到 test 函数中调用了 func 函数,但是这里是由 this 去调用的 func 函数,这里考虑的重点就为 this 调用 func 函数是否满足多态。就需要从构成多态的两个条件入手。

  • 第一个条件必须是由父类的指针和引用进行调用。这里的 this 本质是 A* ,也就是父类的指针,满足第一个条件。

    继承的本质:

    这里可能会产生疑问:虽然 test 是父类函数,但是本质是由子类指针进行调用,而调用的不应该是继承下来的 test 函数吗?所以这里的 this 不应该是 B* 吗?

    其实并不是这样,继承并不会将真的将父类的成员都拷贝一份拿到子类中,而继承复用的真实情况是,当指向子类的指针 p 调用 test 函数时会先在子类中寻找有没有 test 函数,如果没有再去父类中找,如果再找不到就会报错。所以其实调用的 test 函数还是父类中的函数。所以这里的 this 的本质就是 A*

    同样如果这里父类 A 中有一个成员变量 _a ,子类 B 中有一个成员变量 _b ,此时并不会在继承的时候拷贝一份成员变量 _a 给子类 B,而是当创建一个派生类 B 的对象时,编译器会为该对象分配一块连续的内存,这块内存足以容纳基类 A 的所有成员和派生类 B 新增的所有成员。在内存布局上,属于基类 A 的成员 (_a) 通常位于这块内存的起始部分,紧接着是派生类 B 自己的成员 (_b)。在对象构造时,会首先调用基类 A 的构造函数来初始化 B 对象中属于 A 的子对象部分(即初始化 _a),然后才初始化 B 自己的成员变量(初始化 _b),最后 B 的构造函数体。。

  • 第二个条件是否满足三同+虚函数重写。这里可以看到 B 的 func 函数满足三同,并且进行了虚函数的重写。虽然这里的 func 没有加 virtual 但是根据前文可知,这种写法是允许的。满足第二个条件。

  • 经过以上分析可以满足多态,如果满足多态就是基类指针/引用指向谁就调用谁的函数,其本质就是调用的函数版本由指针所指向的对象的实际类型决定,这里的基类指针(this/A*)是由一个指向子类对象的指针传递的,所以这里应该调用子类中的 func 函数。

  • 但是最后最重要的需要注意的是满足多态的情况下,如果需要调用子类重写的虚函数,但是这里的重写的虚函数由父类的声明和子类的函数体构成。所以实际调用的函数为:

    **注意:**所以这也提醒,后续写多态的时候,一定要注意父类和子类的虚函数的函数缺省值一定要一样。

    virtual void func(int val = 1){std::cout << "B->" << val << std::endl;}
    
  • 最后选择答案B: B->1

2. 纯虚函数和抽象类

2.1 概念

#include <iostream>
using namespace std;

//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};

int main()
{
	Car c; //抽象类不能实例化出对象,error报错
	return 0;
}

派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

#include <iostream>
using namespace std;

//抽象类(接口类)
class Car
{
public:
	//纯虚函数
	virtual void Drive() = 0;
};

//派生类1
class Benz : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};

//派生类2
class BMV : public Car
{
public:
	//重写纯虚函数
	virtual void Drive()
	{
		cout << "BMV-操控" << endl;
	}
};

int main()
{
	//派生类重写了纯虚函数,可以实例化出对象
	Benz b1;
	BMV b2;
    
	//不同对象用基类指针调用Drive函数,完成不同的行为,实现多态
	Car* p1 = &b1;
	Car* p2 = &b2;
    
	p1->Drive();  //运行结果:Benz-舒适
	p2->Drive();  //运行结果:BMV-操控
	return 0;
}

抽象类既然不能实例化出对象,那抽象类存在的意义是什么?

  1. 抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
  2. 抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

2.2 接口继承和实现继承

实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。

接口继承: 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。

建议: 所以如果不实现多态,就不要把函数定义成虚函数。

3. 多态的原理

3.1 虚函数表

3.1.1 概念

引入:Base类实例化出对象的大小是多少?

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

int main()
{
	Base b;
	cout << sizeof(b) << endl; //运行结果:8
	return 0;
}

**运行结果:**8

通过测试,发现 Base 类实例化的对象b的大小是8个字节。

这是因为b对象当中除了 _b 成员外,实际上还有一个 _vfptr 放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。

在这里插入图片描述

对象中的这个指针叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少有一个虚表指针。虚表的本质是一个指针数组。

3.1.2 虚函数表的作用

那么虚函数表中到底放的是什么?

下面 Base 类当中有三个成员函数,其中 Func1Func2 是虚函数, Func3 是普通成员函数,子类 Derive 当中仅对父类的 Func1 函数进行了重写。

#include <iostream>
using namespace std;

//父类
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:
	//重写虚函数Func1
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

通过调试可以发现,父类对象b和基类对象d当中除了自己的成员变量之外,父类和子类对象都有一个虚表指针,分别指向属于自己的虚表。

在这里插入图片描述

补充:

  • 实际上虚表当中存储的就是虚函数的地址,因为父类当中的 Func1Func2 都是虚函数,所以父类对象b的虚表当中存储的就是虚函数 Func1Func2 的地址。

  • 而子类虽然继承了父类的虚函数 Func1Func2 ,但是子类对父类的虚函数 Func1 进行了重写,因此,子类对象d的虚表当中存储的是父类的虚函数 Func2 的地址和重写的 Func1 的地址。

    这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。

注意:

  • Func2 是虚函数,所以继承下来后放进了子类的虚表,而 Func3 是普通成员函数,继承下来后不会放进子类的虚表。
  • 此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个 nullptr

**总结:**派生类的虚表生成步骤如下:

  1. 先将基类中的虚表内容拷贝一份到派生类的虚表。
  2. 如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。
  3. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
3.1.3 虚表的初始化和存储

虚表实际上是在构造函数初始化列表阶段进行初始化的,注意虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在代码段的,只是他的地址又存到了虚表当中。另外需要注意,实例化的对象中存的不是虚表而是指向虚表的指针。

至于虚表是存在哪里的,可以通过以下这段代码进行判断。

int j = 0;
int main()
{
	Base b;
	Base* p = &b;
	printf("vfptr:%p\n", *((int*)p)); //运行结果:000FDCAC
	int i = 0;
	printf("栈上地址:%p\n", &i);       //运行结果:005CFE24
	printf("数据段地址:%p\n", &j);     //运行结果:0010038C

	int* k = new int;
	printf("堆上地址:%p\n", k);       //运行结果:00A6CA00
	char* cp = "hello world";
	printf("代码段地址:%p\n", cp);    //运行结果:000FDCB4
	return 0;
}

代码当中打印了对象b当中的虚表指针,也就是虚表的地址,可以发现虚表地址与代码段的地址非常接近,由此可以得出虚表实际上是存在代码段(常量区)的

但是以上仅仅是在VS编译器中测试得出来的结果,实际C++并没有规定虚函数表需要存在哪里。

3.2 多态的原理

**引入:**例如,下面代码中,为什么当父类 Person 指针指向的是父类对象 Mike 时,调用的就是父类的 BuyTicket ,当父类 Person 指针指向的是子类对象 Johnson 时,调用的就是子类的 BuyTicket ?

#include <iostream>
using namespace std;

//父类
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
	int _p = 1;
};

//子类
class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
	int _s = 2;
};

int main()
{
	Person Mike;
	Student Johnson;
    
	Johnson._p = 3; //以便观察是否完成切片
    
	Person* p1 = &Mike;
	Person* p2 = &Johnson;
    
	p1->BuyTicket(); //运行结果:买票-全价
	p2->BuyTicket(); //运行结果:买票-半价
    
	return 0;
}

通过调试可以发现,对象 Mike 中包含一个成员变量 _p 和一个虚表指针,对象 Johnson 中包含两个成员变量 _p_s 以及一个虚表指针,这两个对象当中的虚表指针分别指向自己的虚表。

在这里插入图片描述

由此可分析出多态的原理:

  1. 父类指针 p1 指向 Mike 父类对象,p1->BuyTicketMike 的虚表中找到的虚函数就是 Person::BuyTicket
  2. 父类指针 p2 指向 Johnson 子类对象, p2->BuyTicketJohnson 的虚表中找到的虚函数就是 Student::BuyTicket

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

总结:

多态:指向谁调用谁的虚函数

指向父类,运行时到指向父类对象的虚函数表中找到对应的虚函数进行调用。
指向子类,运行时到指向子类切片出的父类对象的虚函数表中找到对应的虚函数进行调用。

3.3 动态绑定和静态绑定

静态绑定: 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态,比如:函数重载。

动态绑定: 动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

 // ptr是指针+BuyTicket是虚函数满⾜多态条件。 
 // 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址 
 ptr->BuyTicket();
00EF2001 mov eax,dword ptr [ptr] 
00EF2004 mov edx,dword ptr [eax] 
00EF2006 mov esi,esp 
00EF2008 mov ecx,dword ptr [ptr] 
00EF200B mov eax,dword ptr [edx] 
00EF200D call eax
    
 // BuyTicket不是虚函数,不满⾜多态条件。 
 // 这⾥就是静态绑定,编译器直接确定调⽤函数地址 
 ptr->BuyTicket();
00EA2C91 mov ecx,dword ptr [ptr] 
00EA2C94 call Student::Student (0EA153Ch)

网站公告

今日签到

点亮在社区的每一天
去签到