三大特性之多态

2023-10-17 08:18:29 浏览数 (1)

多态是不同继承关系的类对象去调用同一个函数,产生了不同效果的行为。

静态的多态

调用同一个函数,产生不同效果的行为,这不就是函数重载吗!函数重载其实是一种静态的多态,相同的函数名传不同的参数调用的函数也就不同,但是调用哪个函数是在编译阶段就已经被确定好了。函数重载是一种编译时绑定,也就是静态绑定。常用的流插入和流体取也是一种函数重载

动态的多态

动态的多态才是本篇文章中要讲的主要内容,它在调用函数时与与类型无关而是与它所存放的对象有关(普通调用是按类型)。具体调用哪个函数运行时才知道,又叫运行时绑定,也就是动态绑定。

多态的构成必须要满足两个条件:

1.必须要通过父类的引用或者指针作为形参来调用

为什么一定要是父类的引用或者指针,对于这个问题《深度探索C 模型》中这样说:“一个pointer或一个reference之所以支持多态,是因为它们并不引发内存任何“与类型有关的内存委托操作; 会受到改变的。只有它们所指向内存的大小和解释方式 而已”。 对于上述话可以这样理解:

  1. 指针和引用类型只是要求了基地址和这种指针所指对象的内存大小,与对象的类型无关,相当于把指向的内存解释成指针或引用的类型。
  2. 如果直接把子类对象赋值给父类对象,就牵扯到内存模型,编译器就会回避虚机制,无法达到多态的效果。

2.被调用的函数必须是虚函数

虚函数

所谓的虚函数就是被virtual关键字所修饰的函数

代码语言:javascript复制
class Person 
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }//这就是虚函数
};

虽然虚函数和虚继承都使用了virtual关键字,但是两者之间没有任何联系

虚函数的重写(覆盖)

如果子类中有和父类一样的虚函数(返回值类型,函数名称,参数列表相同),那么就称该子类的虚函数重写了父类的虚函数。

这里有一点需要注意:如果父类在声明的时候加了virtual,即使子类在声明同名函数时不加virtual也会完成重写(可以理解为子类在继承父类时将虚属性也继承下来了),但这样写是不规范的,建议不要这样写。

虚函数的重写也可以被称为虚函数的覆盖,因为带有虚函数的类都有一个虚函数表,在继承的时候子类会继承父类的虚函数表,如果子类对某一个虚函数进行重写了,那么该虚函数在子类的虚函数表中就会被重写的虚函数覆盖。

利用虚函数重写实现多态
代码语言:javascript复制
class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person 
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

可以看到虽然我都是调用Func函数,但最后Func函数帮我调用到了不同的函数,这就是多态。

有了多态以后在调用函数的时候首先要看该函数是否构成多态,如果构成多态那么就不用考虑类型,只需要看该变量中存放的是何种对象,按照对象去调用函数;如果不构成多态,那么就只看类型,无论该变量中存的是何对象都不影响,只看类型调用(这里如果不是多态就都调用Person的函数)。

重写的两个例外
1.协变

子类对于父类函数的重写,返回类型可以不同,但必须要是返回父子类关系的指针或引用(即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用),称为协变

代码语言:javascript复制
class Person 
{
public:
	virtual A* f() {return new A;}
};
class Student : public Person 
{
public:
	virtual B* f() {return new B;}
};
2.析构函数的函数名不同

析构函数的函数名要求必须要与类名相同,也就是说各个类的析构函数名都不同,但是其实编译器会将析构函数的函数名统一处理成destructor。

析构函数不但能重写,并且析构函数建议定义成虚函数

如果我定义了一个子类的对象,并将该子类对象赋值给一个父类的指针,当我释放父类的时候只会调用父类的析构函数,也就是说只释放了子类中父类的那一部分资源,而没有释放子类的资源,这就可能会导致内存泄漏。

如果我将析构函数定义为虚函数并重写,那么我在释放父类指针的时候,调用的是子类的析构函数,子类析构函数对于父类那一部分资源通过父类的析构函数清理,同时也会清理自己的资源。

代码语言:javascript复制
class Person
{
public:
	//virtual ~Person
	~Person()
	{
		
		cout << "~Person" << endl;
	}

public:
	int _a;
};

class Student :public Person
{
public:
    //virtual ~Student
	~Student()
	{
		cout << "~Student" << endl;
	}

public:
	int _b;
};

int main()
{
	Person *p1=new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;

	return 0;
}
C 11的override和final

1.被final关键字修饰的函数不能被重写

代码语言:javascript复制
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() {cout << "Benz-舒适" << endl;}
};  

2.使用override关键字检查该函数是否被重写,如果没有重写就报错

代码语言:javascript复制
class Car
{
public:
	virtual void Drive()  {}
};
class Benz :public Car
{
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
重载,重写(覆盖),重定义(隐藏)

重载

1.要在同一个作用域中 2.函数名相同,参数列表相同,返回值可以不同

重写(覆盖)

1.两个函数分别在父类和子类的作用域中 2.返回值相同(协变除外),函数名相同,参数列表相同 3.只有虚函数才构成重写

重定义(隐藏)

1.两个函数分别在父类和子类的作用域中 2.函数名相同只要不构成重写就是重定义

抽象类

与虚函数对应的还有一个纯虚函数,只要在虚函数声明的最后加上=0那么这个虚函数就变成了纯虚函数。如果一个类包含纯虚函数,那么这个类就是抽象类。抽象类不能实例化对象,并且如果继承抽象类的子类不对纯虚函数进行重写的话,子类也是一个抽象类无法实例化对象。纯虚函数规范了子类必须重写,此外纯虚函数更体现了接口继承。

代码语言:javascript复制
class A
{
public:
	virtual void test() = 0;
	int _a;
};

class B :public A
{
public:
	virtual void test()
	{
		cout << "重写了纯虚函数" << endl;
	}
	int _b;
};
int main()
{
	A a;
	B b;

	return 0;
}
接口继承和实现继承

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

多态实现的原理

首先我们来计算一下下面这个类的大小

代码语言:javascript复制
class A
{
public:
    virtual void test();
}

按照我们类和对象阶段所说,一个没有成员变量的类就是空类,空类的大小为1字节,用来占位。 但这个类的大小为4字节

这是因为如果一个类中有虚函数,那么该类中会有一个隐藏的指针,该指针指向一个虚函数表。

虚函数表

可以看到虚函数表中存放的是虚函数的地址,所谓虚函数的重写其实就是将重写过的虚函数的地址覆盖到原虚函数地址上。

因为每个类都有自己独立的虚函数表,所以不同的类对象就可以通过不同的虚函数表访问到不同的虚函数。 在vs下虚函数表都是以空结尾,但是Linux下就不是;

一个变量中如果存放的是子类的对象,那么该变量中的前四个字节就是子类所对应的虚函数表,该表中存放的是子类所对应的虚函数。如果放的是父类的对象,那么该变量的前四个字节就是父类的虚函数表,其中存放的也就是父类所对应虚函数的地址。多态的产生也就是根据对象的前四个字节找到虚函数表,调用其中的虚函数实现的。

虚函数表的存储位置

此外虚函数表存储在常量区中(代码段),通过下面的代码可以看到各个区域的地址,虚表的地址和哪个最为接近就可以说明虚表在哪个区域,经观察确实是在代码段上。

代码语言:javascript复制
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;
	char _ch;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	int a = 0;
	cout << "栈:" << &a << endl;

	int* p1 = new int;
	cout << "堆:" << p1 << endl;

	const char* str = "hello world";
	cout << "代码段/常量区:" << (void*)str << endl;

	static int b = 0;
	cout << "静态区/数据段:" << &b << endl;
	Base be;
	cout << "虚表:" << (void*)*((int*)&be) << endl;

	Base* ptr1 = &be;
	int* ptr2 = (int*)ptr1;
	printf("虚表:%pn", *ptr2);

	Derive de;
	cout << "虚表:" << (void*)*((int*)&de) << endl;

	Base b1;
	Base b2;

	return 0;
}

补充

子类不但有从父类继承下来的成员,还有子类自己特有的成员,也就是说子类除了有重写父类的虚函数还有自己特有的虚函数。 对于单继承的类来说:子类特有的虚函数和重写的虚函数放在同一张虚函数表中 对于多继承的子类来说:它继承了多少个有虚函数的类就有几张虚函数表,但子类所特有的虚函数会放在第一张虚函数表中

一些关于多态的问题
  1. 什么是多态

答:多态就是多种形态,不同的对象来做同一件事产生不同效果的行为就可以称为多态。

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

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

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

​ 答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

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

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

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

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

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

答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。

0 人点赞