C++——多态

多态,就是多种形态。

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

静态多态我们已经在之前的章节中学过了解过:1.同一个命名空间下的函数重载;2.函数模板、类模板;3.继承下的基类和父类的同名函数构成隐藏(重命名)。这些都是在编译时就能够确定的。

而这一节内容中,我们着重实现和讲解的是运行时多态,也就是动态多态。

一. 多态的定义和实现

首先,多态存在于继承关系下。去调用基类和派生类中的同一函数时,会由于对象的不同,从而产生不同的行为。

首先来看下面这段程序:

cpp 复制代码
class Person
{
public:
	virtual void Buyticket() 
	{
		cout << "Person-All price"<< endl;
	}
};

class Student :public Person
{
public:
	virtual void Buyticket() 
	{
		cout << "Student-half price" << endl;
	}
};

void print(Person* p)//必要条件是用基类的指针或者引用来调用虚函数
{
	//具体调用到的是哪个,就看指针具体指向了一个派生类还是基类
	p->Buyticket();
}


int main()
{
	Person p1;
	Student s1;

	print(&p1);
	print(&s1);
	return 0;
}

这段程序实现了一个基类Person,内部存在一个Buyticket函数;同时存在一个Student派生类,内部同样存在一个Buyticket函数。在继承章节中讲过基类的指针可以指向派生类对象,实现了一个print函数,参数为一个基类指针。

分别创建一个基类对象p1和一个派生类对象s1,传参给print来调用Buyticket函数,根据之前的内容来看,print函数中是一个基类指针,应该只能调用到Person中的Buyticket,但运行结果如下:

可看到,指向基类对象的指针运行了基类中的函数;而指向派生类对象的指针运行了派生类对象的函数。

这就是多态。

1.1 多态的实现

多态的实现有两个必要条件:

1.必须是基类的指针或引用,调用虚函数。

2.被调用的函数必须是虚函数,且该虚函数完成了重写/覆盖。

在上面的代码示例中,首先通过print传递的参数是Person* ,是一个基类指针。基类和派生类中的Buyticket函数均加了virtual关键字,成了虚函数。而基类和派生类中均实现了该虚函数,构成了重写,所以调用时构成多态。

如果调用时构成多态,那么调用的函数会根据该指针/引用指向的对象而决定。例如将Person对象p1取地址传递给print函数,那么该指针指向一个Person对象,调用的应该是Person对象的函数。将Student对象s1取地址传递给print函数,该指针指向一个Student对象,调用结果为Student中的函数。

再看下面这段代码示例:

cpp 复制代码
class Person
{
public:
	virtual void Buyticket(int val  = 200) const
	{
		cout << "Person-All price:"<< val << endl;
	}
};

class Student :public Person
{
public:
	virtual void Buyticket(int val  = 100) const
	{
		cout << "Student-half price:"<<val << endl;
	}
};

void print(const Person* p)//必要条件是用基类的指针或者引用来调用虚函数
{
	//具体调用到的是哪个,就看指针具体指向了一个派生类还是基类
	p->Buyticket();
}

void print1(const Person& p)
{
	p.Buyticket();
}

int main()
{
	Person p1;
	Student s1;
	print(&p1);
	print(&s1);
	//通过输出可以看出,虽然说调用到了派生类的Buyticket函数
	//但本质是利用了基类的函数声明,以及派生类的函数定义
	//所以,哪怕用了派生类的定义,函数声明的位置仍然用的是200的默认值
	print1(p1);
	print1(s1);

	//这里多态,本质是用了基类的函数声明,用了派生类的函数定义
	return 0;
}

与上面虚函数不同的地方在于,这里的虚函数有着一个参数val,在基类中给了默认值200;派生类中缺省值100。运行程序:

可看到,引用也是可以实现多态的。这里是多态形式输出,但派生类的输出结果虽然调用到了派生类的函数体,但是却用了基类的函数缺省值。从这里我们也可以发现:多态本质是通过调用基类的函数名、参数列表以及派生类的函数定义来实现。

如果要实现虚函数,一般来说需要在基类和派生类中都要添加关键字virtual;但是只在基类中添加也可以。

同时,虚函数的重写要满足"三同":返回值、函数名、参数列表 都相同

有了上面这个基础,下面的一道经典例题就变得容易理解:

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()
{
	B* pb = new B;

	pb->test();//这里是通过B*类型的指针调用test
	//强调说明一点,继承并不是拷贝一份基类到派生类对象中
	//B*的this会现在B中找test,如果找不到再到A中利用A* this调用test
	//注意在基类中找函数用的就是基类的this指针

	//所以这里,利用基类指针,构成多态
	//多态利用基类的函数声明和派生类的函数定义
	//所以输出结果应该是B->1

	return 0;
}

这里创建了一个基类A,内部有一个func函数,缺省值设为1;还有一个test函数,用来调用func函数;同时还有一个派生类B,内部有一个func函数,缺省值设置为0;

主函数中有着一个B* 指针pb,调用了test。此时程序会优先在派生类中寻找test,而派生类中如果没有,就会在基类中寻找。

再说继承,继承并不是把基类对象拷贝一份到派生类中。所以这里在基类中寻找test的过程用的是A* this指针,此时通过A* this调用test函数,又继续调用func函数。

基类有virtual关键字,且满足三同,构成虚函数重写;A*指针指向的又是一个B类型的派生类对象,此时构成多态:利用基类的函数名和参数列表+派生类的函数定义。所以输出结果应该是B->1;

再看下面这个例题:

cpp 复制代码
class A 
{ 
public: 
	//这就不构成多态
	//多态要求三同
	//这里是隐藏/重定义
	virtual void test(float a = 1.1) 
	{ 
		cout << a <<":" << "class A " << endl;
	} 
};
class B :public A 
{
public:  
	virtual void test(int b = 1) 
	{ 
		cout << b << ":" <<"class B :public A " << endl;
	} 
}; 

int main() 
{ 
	A* a = new B; 
	B* b = new B; 
	
	a = b; 
	a->test(1.1); 
	b->test(1.1);
	return 0;
}

仔细看两个类中的test函数,参数列表不同,所以不构成函数重写。构成隐藏/重定义关系。

所以A类型的指针a,哪怕重新用b来赋值,类型也是A*,所以调用到的函数是基类A中的test,参数为float。

指针b,类型为B*,如果调用test,由于构成函数隐藏,所以哪怕参数是float类型的1.1也会调用到B自己的test,将其类型转换为int输出。

在这里要格外注意是否形成了多态问题。

1.2 协变

虚函数形成重写要满足三个条件:返回值、函数名、参数列表都相同。

协变则可以返回值不同:派⽣类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派⽣类虚函数返回派生类对象的指针或者引用时,称为协变。

cpp 复制代码
class A
{
};
class B : public A
{

};

class Person
{
public:
	//协变的情况下,返回值可以基类为一个基类指针

	virtual A* Buyticket(int val = 200) const
	{
		cout << "Person-All price:" << val << endl;
		return nullptr;
	}

	//virtual const Person* Buyticket(int val  = 200) const
	//{
	//	cout << "Person-All price:"<< val << endl;
	//	return this;
	//}
};

class Student :public Person
{
public:
	//派生类为一个派生类指针

	virtual B* Buyticket(int val = 100) const
	{
		cout << "Student-half price:" << val << endl;
		return nullptr;
	}


	//virtual const Student* Buyticket(int val  = 100) const
	//{
	//	cout << "Student-half price:"<<val << endl;
	//	return this;
	//}
};


int main()
{
	Person* pp1 = new Person;

	Person* pp2 = new Student;

	pp1->Buyticket();
	pp2->Buyticket();

	return 0;
}

在这里就运用了协变的方式来实现虚函数重写和多态:

只要是基类返回一个基类指针、派生类返回一个派生类指针就能实现协变。

二. 析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

cpp 复制代码
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 p2时,只能调用基类的析构函数

	delete p1;
	delete p2;

	return 0;
}

A*类型的两个指针p1和p2,分别new了一个基类对象和一个派生类对象。对这两个对象delete,会先调用析构函数再调用operator delete。

可以看到,将基类的析构函数设置为虚函数,就能正常调用到派生类的析构函数。如果不设置为虚函数:

这样就无法调用到派生类的析构函数,会出现内存泄漏的问题。所以建议将析构函数设置为虚拟。

三. override和final关键字

从第一节的最后两个练习题示例可以看出,C++对于多态的要求较为严格,一个地方出错就可能无法实现多态,从而是隐藏或者其他类型。

如果想要检查一个虚函数是否实现了多态,可以利用override关键字:

cpp 复制代码
class A
{
	virtual void fun1(int val = 1) 
	{
		cout << "	void fun1(int val = 1):" << val << endl;
	}
};

class B:public A
{
	virtual void fun1(float val = 0) override
	{
		cout << "void fun1(int val = 0):" << val << endl;
	}
};

例如这段代码,基类实现了fun1函数,而派生类fun1函数参数为float类型,与基类不同,此时报错为:

如果一个虚函数不想被实现为多态,则可以利用final关键字:

cpp 复制代码
class A
{
	virtual void fun1(int val = 1) final
	{
		cout << "	void fun1(int val = 1):" << val << endl;
	}
};

class B:public A
{
	virtual void fun1(int val = 0) 
	{
		cout << "void fun1(int val = 0):" << val << endl;
	}
};

基类中,将虚函数fun1后添加final关键字,此时虽然派生类中的fun1和基类的fun1构成多态的条件,但是会有如下报错:

之前在实现一个无法被继承的类时,也用到了final关键字。

四. 纯虚数-抽象类

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

cpp 复制代码
class A
{
public:
	virtual void fun() = 0;
};

class B:public A
{
public:
	virtual void fun() 
	{
		cout << "void fun(B)" << endl;
	}
};
class C :public A
{
public:
	virtual void fun();
};
void C::fun()
{
	cout << "void fun(C)" << endl;
}
int main()
{
	B b;
	C c;
	A* pa = &b;
	A& a = c;
	pa->fun();
	a.fun();

	return 0;
}

这里实现了一个A的基类,此时A中实现了一个函数fun,但在后面加上了=0,此时fun函数就为纯虚函数(纯虚函数当然可以定义,但没有任何意义,选择题可能会出概念性的问题)。此时A类为一个抽象类,A类就无法实例化对象,如果要实例化,报错类型为:

但是可以用抽象类的指针和引用来实现多态,例如代码中创建了B类型的对象b和C类型的对象c,分别用抽象类A的指针和引用指向b和c,均构成多态,运行结果如下:

可以看出,抽象类虽然无法实例化对象,但仍然可以正常使用它的指针和引用从而实现多态。

五. 多态的原理

首先以一个示例:

cpp 复制代码
class Base
{
public:
	 virtual void Func1()
	 {
		cout << "Func1()" << endl;
	 }
protected:
	 int _b = 1;
	 char _ch = 'x';
};

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

这里实现了一个Base类,内部存放着一个int类型变量,一个char类型变量,理论大小为4+1=5字节,内存对齐为最小对齐数的整数倍,应该为8。

但在64位的运行环境下,运行结果是16;

根据调试结果可以看到,该类型内部不仅存在着两个成员变量,还存在着一个指针,该指针指向虚函数。

对象中的这个指针,我们叫做------虚函数表指针,所有的虚函数指针会存放到该虚函数表中,虚函数表指针类型为函数指针,所以虚函数表为函数指针列表。

虚函数本质和函数一样,存放到代码段;虚函数表也存放与代码段(常量区)。

5.1 多态下虚函数的调用方式

cpp 复制代码
class Person
{
public:
	virtual void Buyticket(double val  = 200.15) const
	{
		cout << "Person-All price:"<< val << endl;
	}
};

class Student :public Person
{
public:
	virtual void Buyticket(double val  = 100) const
	{
		cout << "Student-half price:"<<val << endl;
	}
};

void print(const Person* p)//必要条件是用基类的指针或者引用来调用虚函数
{
	p->Buyticket();
}

int main()
{
	Person p1;
	Student s1;
	print(&p1);
	print(&s1);

	return 0;
}

继续这段代码:通过调试界面可以看到:

可以看到,p1和s1中都存放了一个虚函数表,通过print函数的p指针调用时,与p指针本身的类型无关,而是和p指针指向的对象究竟是基类还是派生类有关。如果是派生类则会通过派生类对象中的虚函数列表找到派生类中的虚函数;如果是基类,则会通过基类的虚函数列表找到基类。

5.2 动态绑定与静态绑定

通过上述示例可以发现,继承关系中实现的多态,我们在最初称其为动态多态(运行时多态)。在这里将其称为动态绑定,满⾜多态条件的函数调⽤是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数 的地址,也就做动态绑定。

而不是继承条件下的多态的常规函数则是在编译时就可确定函数的地址,叫做静态绑定。

5.3 虚函数表相关

  1. 不同类型的对象有着不同的虚函数表,而同类对象有着相同的虚函数表。
cpp 复制代码
	Person p1;
	Student s1;

	Person p2;

以上面的继承类为例,这里创建了两个基类对象分别为p1和p2,和一个派生类对象s1.

可以观察到,p1和p2这两个同类对象,其无论是虚函数表还是虚函数表中存放的虚函数都是相同的。明显看出与派生类s1中的虚函数表和存放的函数不同。

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

cpp 复制代码
class Person
{
public:
	virtual void fun1()
	{
		cout << "class Person_fun1" << endl;
	}

	virtual void fun2()
	{
		cout << "class Person_fun2" << endl;
	}

	virtual void fun3()
	{
		cout << "class Person_fun3" << endl;
	}
};

class Student :public Person
{
public:
	virtual void fun1()
	{
		cout << "class Student :public Person_fun1" << endl;
	}

	virtual void fun3()
	{
		cout << "class Student :public Person_fun3" << endl;
	}

};


int main()
{
	Person p1;
	Student s1;

	return 0;
}

例如上面这段代码,基类Person中实现了三个函数,fun1、fun2、fun3均设置为虚拟函数;而派生类中仅设置了两个虚拟函数fun1和fun3。观察下图的调试:

可以看到p1的虚函数列表中,存放着三个Person::的函数;这里注意观察,s1中存放的也是三个,注意第二个却是Person::fun2。这就说明,派生类的虚函数表是拷贝到派生类中的,同时如果实现多态,那么虚函数就会拷贝覆盖原函数;基类中的虚函数在派生类中没有实现多态的情况下,也会保留。
3. 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖(3)派⽣类自己的虚函数地址三个部分。
4. 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进⾏规定,各个编译器自行定义的,vs系列编译器会再后⾯放0x00000000标记,g++系列编译不会放)

六. 总结三种常见的多态定义

首先,重载和隐藏都是编译时的多态,也叫静态多态:重载指的是在同一作用域下的重名函数的重载,要求函数名相同,参数不同,才可形成重载,函数的返回值类型不同不能作为重载的条件。

隐藏/重定义发生在继承关系中,是基类和派生类中分别有着一个同名函数,就会发生隐藏。

重写/覆盖,是本节所学的编译时多态,也叫动态多态,同样发生在继承关系中,限制条件较为严格:首先需要是基类和派生类中均实现了虚函数,且虚函数要完成重写满足三同:函数名-参数列表-返回值相同(协变的语法则可以返回值不同),同时还要是基类对象的指针/引用来调用该函数。

相关推荐
越甲八千1 小时前
STL stack为何没有迭代器
c++
心平气和量大福大2 小时前
C#-WPF-Window主窗体
开发语言·c#·wpf
从零开始的代码生活_3 小时前
C++ 继承详解:访问控制、对象模型、菱形继承与设计取舍
开发语言·c++·后端·学习·算法
云小逸3 小时前
【C++ 第七阶段:模板、泛型编程与工程综合详解】
开发语言·c++
GIS阵地4 小时前
QgsSingleBandPseudoColorRenderer 完整详解(QGIS 3.40.13 C++)
开发语言·前端·c++·qt·qgis
王维同学4 小时前
[原创][Windows C++]LSA 认证、安全与通知包的注册表枚举
c++·windows·安全
程序员Rock4 小时前
上位机开发-MODBUS面试常见问题
c语言·c++·面试·职场和发展·上位机
yaoxin5211234 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python
groundhappy4 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python