C++ —— 多态

目录

1.多态的概念

2.多态的定义和实现

[2.1 多态的构成条件](#2.1 多态的构成条件)

[2.1.1 实现多态还有两个必须重要条件](#2.1.1 实现多态还有两个必须重要条件)

[2.1.2 虚函数](#2.1.2 虚函数)

[2.1.3 虚函数的重写/覆盖](#2.1.3 虚函数的重写/覆盖)

[2.1.4 多态场景的一个选择题](#2.1.4 多态场景的一个选择题)

[2.1.5 虚函数重写的一些其他问题](#2.1.5 虚函数重写的一些其他问题)

[2.1.6 override 和 final 关键字](#2.1.6 override 和 final 关键字)

[2.1.7 重载/重写/隐藏的对比](#2.1.7 重载/重写/隐藏的对比)

[3. 纯虚函数和抽象类](#3. 纯虚函数和抽象类)

[4. 多态的原理](#4. 多态的原理)

[4.1 虚函数表指针](#4.1 虚函数表指针)

[4.2 多态的原理](#4.2 多态的原理)

[4.2.1 多态是如何实现的](#4.2.1 多态是如何实现的)

[4.2.2 动态绑定和静态绑定](#4.2.2 动态绑定和静态绑定)

[4.2.3 虚函数表](#4.2.3 虚函数表)


1.多态的概念

多态的概念:通俗的来说,就是多种形态。 多态的分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点介绍运行时多态,编译时多态(静态多态)和 运行时多态(动态多态)。编译时多态(静态多态 )主要是我们前面提到的函数重载函数模板,它们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为它们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人去买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是 "喵~",传狗对象过去,就是 "汪汪"。

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票全价" << endl;
	}
};

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

void Func(Person* ptr)
{
	//这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	//但是跟ptr没关系,而是由ptr指向的对象决定的
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

运行结果:(调用不同的函数达到的,是运行时达到的)

2.多态的定义和实现

2.1 多态的构成条件

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

2.1.1 实现多态还有两个必须重要条件

  • 必须是基类的指针或者引用调用虚函数(既可以指向基类又可以指向派生类)
  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。

没有进行虚函数的重写:

不是指针或者引用,就是一个简单的对象

运行结果:

2.1.2 虚函数

成员函数 前面加virtual修饰,那么这个成员函数被称为虚函数。**注意:**非成员函数不能加virtual修饰。(不能修饰普通的全局函数)

(这里使用的virtual和虚继承里面使用的virtual是同一个关键字,但是没有一点关联;在虚继承中修饰的是继承方式,解决的是菱形继承的数据冗余二义性;而在这里的virtual是为了实现多态)

2.1.3 虚函数的重写/覆盖

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

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写因为继承后基类的虚函数被继承了下来,在派生类中依旧保持虚函数属性 ),但是这种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。(基类不写virtual是不构成多态的)

对 "派生类的虚函数在不加virtual关键字也可以构成重写" 的理解:

cpp 复制代码
class Animal
{
public:
	virtual void talk() const
	{ }
};

class Dog :public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << endl;
	}
};

class Cat :public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "喵~" << endl;
	}
};

void letsHear(const Animal& animal)
{
	animal.talk();
}

int main()
{
	Cat cat;
	Dog dog;

	letsHear(cat);
	letsHear(dog);

	return 0;
}

运行结果:

2.1.4 多态场景的一个选择题

以下程序输出结果是什么()

A: A->0 B: B->1 C:A->1 D:B->0 E:编译错误 F:以上都不正确

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();
	return 0;
}
cpp 复制代码
int main(int argc, char* argv[])
{
	p->func();
	return 0;
}

此时,这个时候的运行结果又是多少?

不构成多态,效果的实现是调用自己的声明和实现,只有重写调用的时候用的才是父类的声明和派生类的组合实现。

2.1.5 虚函数重写的一些其他问题

  • 协变(了解)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变(协变也构成虚函数的重写)。协变的实际意义并不大,所以我们了解一下即可。

cpp 复制代码
class A{};
class B : public A{};
class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);
	Func(&st);

	return 0;
}

运行结果:

cpp 复制代码
class A{};
class B : public A{};
class Person {
public:
	virtual Person* BuyTicket()   //这样也行
	//virtual A* BuyTicket()
	{
		//......
	}
};

class Student : public Person
{
public:
	virtual Student* BuyTicket()   //这样也行
	//virtual B* BuyTicket()
	{
		//......
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);
	Func(&st);

	return 0;
}

运行结果:

  • 析构函数的重写

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

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用A的析构函数,没有调用B的析构函数,就会导致内存泄漏的问题,因为~B()中在释放资源。

注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

cpp 复制代码
class A
{
public:
	~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 aa1;
	B bb1;
	return 0;
}

运行结果:

A的析构函数为什么要调用两次?

因为派生类的析构函数不需要显示的去调用父类的析构函数,因为会自动调用,析构先子后父,先析构B,B调用B的析构函数,B调用完了,会自动调用A的析构函数,B由两部分构成,一部分是自己,一部分是B里面有个父类。

A的指针指向A,B的指针指向B,这样也没问题:

cpp 复制代码
int main()
{
	//A aa1;
	//B bb1;

	A* p1 = new A;
	B* p2 = new B;

	delete p1;
	delete p2;

	return 0;
}

运行结果:

以下情况就有问题:

运行结果:

cpp 复制代码
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];
};

//想实现一个多态
void func(A* ptr)
{
	// ptr->f();//假设调用某个函数
	delete ptr;
	
	//delete是一个运算符,调用析构函数和operator delete
	//1.ptr->析构函数()    //构成多态:a.父类的指针或引用在调用(既指向父类也指向子类)b.虚函数的重写
	// ptr -> destructor( )
	// 调用析构函数调用的是不对的,new A,调用A的析构函数;new B,也调用A的析构吗?
	//这里并没有正确的调用析构函数,要构成多态才能正确的调用析构函数,没有正确调用析构导致内存泄漏 
	//2.operator delete(ptr)
}
cpp 复制代码
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];
};

void func(A* ptr)
{
	// ptr->f();//假设调用某个函数
	delete ptr;
	
	// ptr -> destructor( )    //要构成多态才能正确的调用析构函数,没有正确调用析构导致内存泄漏 
	//2.operator delete(ptr)
}

int main()
{


	func(new A);
	func(new B);


	A* p1 = new A;
	A* p2 = new B;
	////父类的指针既指向A对象,又可以指向B对象

	//delete p1;
	//delete p2;

	return 0;
}

运行结果:

为什么派生类重写的时候不加virtual?如果这个基类期望被别人继承,就加上virtual。我们设计一个类的时候,如果这个类可能会被继承,析构函数就将virtual加上。所以基类只要加上virtual就构成重写,delete一个父类的指针,指向父类调用父类析构函数,指向子类调用子类的析构函数就不会调错了。

面试问题:

析构函数是否建议加上virtual?建议加上,否则可能会导致内存泄露。

2.1.6 override 和 final 关键字

从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错,参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有的到预期的结果才来debug,会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。 如果我们不想让派生类重写这个虚函数,那么就可以用final去修饰。

cpp 复制代码
class Car
{
public:
	 void Dirve()
	{ }
};

class BC : public Car {
public:
	virtual void Dirve() override
	{
		cout << "BC-舒适" << endl;
	}
};

int main()
{
	return 0;
}

此时就会报错:

不加上override的话,编译不会发生错误:

final 可以修饰一个类,这个类不能被继承;也可以修饰一个虚函数,让这个虚函数不能被重写。

cpp 复制代码
class Car
{
public:
	virtual void Dirve()final{ }

};

class BC : public Car {
public:
	virtual void Dirve() 
	{
		cout << "BC-舒适" << endl;
	}
};

int main()
{
	return 0;
}

2.1.7 重载/重写/隐藏的对比

3. 纯虚函数和抽象类

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

cpp 复制代码
class Car
{
public:
	virtual void Dirve() = 0;

};


int main()
{
	Car car;
	return 0;
}

报错:

派生类继承纯虚函数,如果不重写,派生类也是抽象类,实例化不出对象来。

cpp 复制代码
class Car
{
public:
	virtual void Dirve() = 0;

};

class BC : public Car {
public:

};


int main()
{
	// Car car;  //"Car": 无法实例化抽象类
	BC bc;
	return 0;
}
cpp 复制代码
class Car
{
public:
	virtual void Dirve() = 0;

};

class BC : public Car {
public:
	virtual void Dirve()
	{
		cout << "BC-舒适" << endl;
	}

};

class BM : public Car
{
public:
	virtual void Dirve()
	{
		cout << "BM-操控" << endl;
	}
};




int main()
{
	// Car car;  //"Car": 无法实例化抽象类
	BC bc;

	Car* pBC = new BC;
	pBC->Dirve();

	Car* pBM = new BM;
	pBM->Dirve();

	return 0;
}

运行结果:

4. 多态的原理

4.1 虚函数表指针

下面编译为32位程序的运行结果是什么()

A.编译报错 B.运行报错 C.8 D.12

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

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

运行结果:

只要一个类里面有虚函数就要多一个指针,这个指针就是虚表指针

virtual function table:存放虚函数的指针

我们有多个虚函数的时候,指针是不会变大的:

cpp 复制代码
class Base
{
public:
	//多个虚函数,指针是不会变大的
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	 void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'a';
};

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

运行结果:

4.2 多态的原理

4.2.1 多态是如何实现的

从底层的角度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调用Person::BuyTicket,ptr指向Student对象调用Student::BuyTicket的呢?

通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。

多态的原理是怎样实现的呢?怎样实现指向谁调用谁呢?

编译器在编译的调用,编译的时候会去检查满不满足多态,分两种情况:

1. 不满足多态

2. 满足多态

4.2.2 动态绑定和静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就叫做动态绑定。

4.2.3 虚函数表

  • 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
cpp 复制代码
class Base
{
public:
	//多个虚函数,指针是不会变大的
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	 void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'a';
};

int main()
{
	Base b1;
	Base b2;
	return 0;
}

b1 , b2 是同一个类型,所以指向同一张虚函数表

不完成重写,它们的虚函数表也是不一样,如下:

cpp 复制代码
class Base
{
public:
	//多个虚函数,指针是不会变大的
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	 void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'a';
};

class Drive : public Base
{
public:
protected:
	int _d = 0;
};

int main()
{
	Base b1;
	Base b2;

	Drive d1;
	Drive d2;

	return 0;
}

同类型的共用虚表,但是不同类型,不管重不重写都不会一样

  • 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的是,这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象成员和派生类对象中的基类对象成员也独立的。
  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  • 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址三个部分。
cpp 复制代码
class Base
{
public:
	//多个虚函数,指针是不会变大的
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	 void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'a';
};

class Drive : public Base
{
public:
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	virtual void Func4()
	{
		cout << "Func4()" << endl;
	}
protected:
	int _d = 0;
};

int main()
{
	Base b1;
	Base b2;

	Drive d1;
	Drive d2;

	return 0;
}

监视窗口是会进行修饰的,例如:list,应该是一个一个的结点相连,但是在监视窗口中我们看见的却像是数组一样的结构,所以监视窗口看见的不一定是原生的最初始的结构。

所以说上面的代码应该有3个虚函数,但是监视窗口处理了,没有将Func4()显示出来

cpp 复制代码
#include<list>

int main()
{
	list<int> lt = { 1,2,3,4,5 };

	return 0;
}

但是在内存中就可以看见第三个虚函数的存在

  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会在后面放个0x00000000标记,g++系列编译不会放)
  • 虚函数存在哪里的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存在了虚表中。(为了实现多态)
  • 虚函数表存在哪里的?这个问题严格说并没有标准答案,C++标准并没有规定,下面的代码可以对比验证一下,vs下是存在代码段(常量区)

虚函数表 肯定不会存在栈上,栈上本质是存栈帧,栈帧用完就销毁;存在堆上的是动态开辟的,堆上面的很多东西都是要释放的,但是是谁释放的呢?感觉也不合理;那就还剩代码段或数据段,代码段更多的存放的是编译好的代码指令,其实常量也是放在这个区域的,所以从语言的角度喜欢将代码段称为常量区,从系统的角度称为代码段。但是感觉存放在代码段或在数据段都可以。其实,是存放在代码段的。验证代码:

cpp 复制代码
class Base
{
public:
	//多个虚函数,指针是不会变大的
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	 void Func3()
	{
		cout << "Func3()" << endl;
	}

protected:
	int _b = 1;
	char _ch = 'a';
};

class Drive : public Base
{
public:
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	virtual void Func4()
	{
		cout << "Func4()" << endl;
	}
protected:
	int _d = 0;
};

int main()
{
	//Base b1;
	//Base b2;

	//Drive d1;
	//Drive d2;

	//list<int> lt = { 1,2,3,4,5 };

	int i = 0;    //局部变量  
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);
	
	Base b;
	Drive d;
	Base* p3 = &b;
	Drive* p4 = &d;
	printf("Base虚表地址:%p\n", *(int*)p3);
	printf("Drive虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::Func1);
	printf("普通函数地址:%p\n", &Base::Func3);

	return 0;
}

运行结果:

相关推荐
小小怪7502 小时前
C++中的代理模式高级应用
开发语言·c++·算法
AMoon丶2 小时前
Golang--协程调度
linux·开发语言·后端·golang·go·协程·goroutine
格林威2 小时前
工业相机图像高速存储(C++版):直接IO存储方法,附海康相机实战代码!
开发语言·c++·人工智能·数码相机·计算机视觉·视觉检测·工业相机
代码雕刻家2 小时前
3.1.课设实验-Java核心技术-检索简历
java·开发语言
小此方2 小时前
Re:从零开始的 C++ STL篇(七)二叉搜索树增删查操作系统讲解(含代码)+key/key-value场景联合分析
开发语言·c++
共享家95272 小时前
Java 入门(IDEA 高效调试 与 数组)
java·开发语言·intellij-idea
火山上的企鹅2 小时前
Qt/QGroundControl 实战:接入 Skydroid(云卓) G20 遥控器 Android SDK 并实时显示摇杆与信号质量
android·开发语言·qt·qgroundcontrol·云卓sdk
曾阿伦2 小时前
Python项目管理从Poetry迁移到uv:极速体验与实操指南
开发语言·python·uv
2401_891482172 小时前
C++中的观察者模式
开发语言·c++·算法