C++ 面向对象(二)多态 : 虚函数、多态原理、抽象类、虚函数表、继承与虚函数表


多态

多态的概念

什么是多态呢?就是一种事物,多种形态。就是对于同一个行为,不同的对象去完成就会产生不同的结果。

举个生活中的例子,当你去旅游景点游玩时,不同的身份买票的价格也不一样。比如对于普通人是原价购买,而对于学生和孩子则是半价购买,对于军人则是优先购买。明明同样是购买,不同身份带来的不同结果,就是多态的作用。

在C++中,多态就是对于同一个函数,当调用的对象不同,他的操作也不同。就是指针和引用指向指向哪一个对象,就调用哪一个对象的虚函数

例如:

class Human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}
};

class Student : public Human
{
public:
	virtual void print()
	{
		cout << "i am a student" << endl;
	}
};

class Teacher : public Human
{
public:
	virtual void print()
	{
		cout << "i am a teacher" << endl;
	}
};

void ShowIdentity(Human& human)
{
	human.print();
}

int main()
{
	Human h;
	Teacher t;
	Student s;

	ShowIdentity(h);
	ShowIdentity(t);
	ShowIdentity(s);
}

在这里插入图片描述


多态的构成条件

这里先给出条件,底下的原理解析那一块会具体讲原因

多态是继承体系中的一个行为,如果要在继承体系中构成多态,需要满足两个条件

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

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


虚函数

虚函数就是被virtual修饰的类成员函数(这里的virtual和虚继承的virtual虽然是同一个关键字,但是作用不一样)

如:

class Human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}
};

虚函数的重写

当派生类中有一个和基类完全相同的虚函数(函数名,返回值,参数完全相同),则说明子类的虚函数重写了基类的虚函数(只重写了函数实现)

如:

class Human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}
};

class Student : public Human
{
public:
	virtual void print()
	{
		cout << "i am a student" << endl;
	}
};

void ShowIdentity(Human &human)
{
	human.print();
}


int main()
{
	Human h;
	Student s;

	ShowIdentity(h);	
	ShowIdentity(s);
}

在这里插入图片描述
如果不满足上面的条件,例如参数不同则会变成重定义。

注意:

#include <iostream>

class Base{
public:
    virtual void Show(int n = 10)const{    //提供缺省参数值
        std::cout << "Base:" << n << std::endl;
    }
};
 
class Base1 : public Base{
public:
    virtual void Show(int n = 20)const{     //重新定义继承而来的缺省参数值
        std::cout << "Base1:" << n << std::endl;
    }
};
 
int main(){
 
    Base* p1 = new Base1;        
    p1->Show();           
 
    return 0;
}

此时输出的是Base1:10, 这是出自Effective C++中的一个问题
如果子类重写了缺省值,此时的子类的缺省值是无效的,使用的还是父类的缺省值
原因是因为多态是动态绑定,而缺省值是静态绑定。对于P1,他的静态类型也就是这个指针的类型是Base,所以这里的缺省值是Base的缺省值,而动态类型也就是指向的对象是Base1,所以这里调用的虚函数则是Base1中的虚函数,所以这里就是Base1中的虚函数,Base中的缺省值,也就是Base1:10。

或者可以更简单的一句话描述,虚函数的重写只重写函数实现,不重写缺省值

这道题最近考试做错了,就拿出来讲了一下

但是也存在两种例外的情况。


协变(返回值不同)

当基类和派生类的返回值类型不同时,如果基类对象返回基类对象的引用或者指针,派生类对象也返回的是派生类对象的引用或者指针时,就会引起协变。协变也能完成虚函数的重写

例如:

class Human
{
public:
	virtual Human& print()
	{
		cout << "i am a human" << endl;
		
		return *this;
	}
};

class Student : public Human
{
public:
	virtual Student& print()
	{
		cout << "i am a student" << endl;

		return *this;
	}
};

如果返回值不是引用或者指针则不会构成协变

class Student : public Human
{
public:
	virtual Student print()
	{
		cout << "i am a student" << endl;

		return *this;
	}
};

在这里插入图片描述


析构函数的重写(函数名不同)

析构函数虽然函数名不同,但是也能构成重写,因为编译器为了让析构函数实现多态,会将它的名字处理成destructor,这样就能也能构成重写。

为什么编译器要通过这种方式让析构函数也能构成重写呢?
假设存在这种情况,我用一个基类指针或者引用指向派生类对象,如果不构成多态会怎样

class Human
{
public:
	~Human()
	{
		cout << "~Human()" << endl;
	}
};

class Student : public Human
{
public:
	~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	Human* h = new Student;
	delete h;

	return 0;
}


在这里插入图片描述
可以看到,如果不构成多态,那么指针是什么类型的,就会调用什么类型的析构函数,这也就导致了一种情况,如果派生类的析构函数中有资源释放,而这里却没有释放掉那些资源,就会导致内存泄漏的问题。

所以为了防止这种情况,必须要将析构函数定义为虚函数。这也就是编译器将析构函数重命名为destructor的原因
在这里插入图片描述


final和override

final和override是C++11中提供给用户用来检测是否进行重写的两个关键字。

final

使用final修饰的虚函数不能被重写。
如果某一个虚函数不想被派生类重写,就可以用final来修饰这个虚函数

class Human
{
public:
	virtual void print() final
	{
		cout << "i am a human" << endl;
	}

};

class Student : public Human
{
public:
	virtual void print()
	{
		cout << "i am a student" << endl;
	}
};

在这里插入图片描述

override

override关键字是用来检测派生类虚函数是否构成重写的关键字。
在我们写代码的时候难免会出现些小错误,如基类虚函数没有virtual或者派生类虚函数名拼错等问题,这些问题不会被编译器检查出来,发生错误时也很难一下子锁定,所以C++增添了override这一层保险,当修饰的虚函数不构成重写时就会编译错误。

class Human
{
public:
	void print()
	{
		cout << "i am a human" << endl;
	}

};

class Student : public Human
{
public:
	virtual void print() override
	{
		cout << "i am a student" << endl;
	}
};

在这里插入图片描述


重载, 重写, 重定义对比

重载:
1.在同一作用域
2.函数名相同,参数的类型、顺序、数量不同。

重写(覆盖):
1.作用域不同,一个在基类一个在派生类
2.函数名,参数,返回值必须相同(协变和析构函数除外)
3.基类和派生类必须都是虚函数(派生类可以不加virtual,基类的虚函数属性可以继承,但是最好要加上virtual)

重定义(隐藏):
1.作用域不同,一个在基类一个在派生类
2.函数名相同
3.派生类和基类同名函数如果不构成重写那就是重定义


抽象类

如果在虚函数的后面加上 =0,并且不进行实现,这样的虚函数就叫做纯虚函数。而包含纯虚函数的类,也叫做抽象类或者接口类。抽象类不能实例化出对象,因为他所具有的信息不足以描述一个对象,派生类继承后也只有在重写纯虚函数后才能实例化出对象。

抽象类就像是一个蓝图,为派生类描述好一个大概的架构,派生类必须实现完这些架构,至于要在这些架构上面做些什么,增加什么,就属于派生类自己的问题。

例如:

class Human
{
public:
	virtual void print() = 0;
};

class Student : public Human
{
public:
	virtual void print()
	{
		cout << "i am a student" << endl;
	}
};

class Teacher : public Human
{
public:
	virtual void print()
	{
		cout << "i am a teacher" << endl;
	}
};

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


多态的原理

虚函数表

这里还是用这两个类举例子

class Human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}

	virtual void test1()
	{
		cout << "1test1" << endl;
	}
	void test2()
	{
		cout << "1test1" << endl;
	}

	int _age;
};

class Student : public Human
{
public:
	virtual void print() 
	{
		cout << "i am a student" << endl;
	}

	void test2()
	{
		cout << "2test2" << endl;
	}

	int _stuNum;
};

还是和上次一样,首先看看h的大小,按照正常情况,因为h中只有一个成员变量_age,大小应该是四个字节。

在这里插入图片描述
但是这里却是8个。

打开监视窗口观察
在这里插入图片描述
可以看到里面除了_age以外,还有个指针_vfptr,这个指针指向了一个函数指针数组,这个函数指针数组也就是虚函数表,其中的每一个成员指向的都是之前我们实现的虚函数,这个_vfptr也被称为虚函数表指针
而不是虚函数的test2则没有被放入表中。

多态的实现也正是借助了这个虚函数表。
首先观察这个虚函数表,我们可以看到,如果派生类实现了某个虚函数的重写,那么在派生类的虚函数表中,重写的虚函数就会覆盖掉原有的函数,如Student::print。而没有完成重写的test1则依旧保留着从基类继承下来的虚函数Human::test1。

为了进一步验证基类和派生类虚函数表的关系,我将派生类所有的虚函数重写去掉在这里插入图片描述
结合上面的内容可以发现,派生类会继承基类的虚函数表,如果派生类完成了重写,则会将重写的虚函数覆盖掉原有的函数。所以指针或引用指向哪一个对象,就调用对象中虚函数表中对应位置的虚函数,来实现多态。
这也就是为什么需要派生类函数也为虚函数,并且必须要重写才能实现的原因

继续分析构成多态的另一个条件,为什么必须要指针或者引用才能构成多态。

int main()
{
	Student s;

	Human h1 = s;
	Human* h2 = &s;
}

在这里插入图片描述
这里可以看到,如果将派生类对象赋值给基类对象,会因为对象切割,导致他的内存布局整个被修改,完全转换为基类对象的类型,虚函数表也与基类相同,所以不能实现多态。

而如果用基类指针或者引用指向派生类对象,虽然指向的是派生类对象,但是他们的内存布局是兼容的,他不会像赋值一样改变派生类对象的内存结构,所以派生类对象的虚函数表得到了保留,所以他可以通过访问派生类对象的虚函数表来实现多态。

总结一下派生类虚函数表的生成过程:
1.首先派生类会将基类的虚函数表拷贝过来
2.如果派生类完成了对虚函数的重写,则用重写后的虚函数覆盖掉虚函数表中继承下来的基类虚函数
3.如果派生类自己又新增了虚函数,则添加在虚函数表的最后面

常见问题解析:
内联函数可以是虚函数吗?
不可以,内联函数没有地址,无法放进虚函数表中。
静态成员函数可以是虚函数吗?
不可以,静态成员函数没有this指针,无法访问虚函数表。
构造函数可以是虚函数吗?
不可以,虚函数表指针也是对象的成员之一,是在构造函数初始化列表初始化时才生成的,不可能是虚函数
析构函数可以是虚函数吗?
可以,上面有写,最好把基类析构函数声明为虚函数,防止使用基类指针或者引用指向派生类对象时,派生类的析构函数没有调用,可能导致内存泄漏。

对象访问虚函数快还是普通函数快?
如果不构成多态的话,虚函数和普通函数的访问是一样快的,但是如果构成多态,调用虚函数就得到虚函数表中查找,就会导致速度变慢,所以普通函数更快一些。


虚函数表的存储位置

从上面的观察可以看出来,虚函数存于虚函数表中,那么虚函数又存储在哪里呢?
这里就来验证一下

int main()
{
	Student s1;

	int a = 0;
	int* p1 = &a;
	char* p2= "helloworld";
	int* p3 = new int;

	printf("栈变量:%p\n", p1);
	printf("代码段常量:%p\n", p2);
	printf("堆变量:%p\n", p3);
	printf("普通函数地址:%p\n", ShowIdentity);
	printf("虚函数地址:%p\n", &Student::print);
	printf("虚函数表地址:%p\n", *(int*)&s1);
}

在这里插入图片描述
通过对比可以看到,虚函数表与常量,函数一样存储于代码段中。

所以得出结论,虚函数表在编译阶段生成,存储于代码段。


动态绑定和静态绑定

对象的静态类型:对象在声明时采用的类型。是在编译期确定的。(比如下面的h1,Human也就是他原本的类型就是静态类型,而他指向的对象的类型Student也就是动态类型)
对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改
静态绑定:绑定的是对象的静态类型,某特性(比如函数)依赖于对象的静态类型,发生在编译期。
动态绑定:绑定的是对象的动态类型,某特性(比如函数)依赖于对象的动态类型,发生在运行期。

接着我们通过汇编代码,来观察多态是在哪个阶段实现的, 就可以知道它是静态还是动态。

int main()
{
	Student s1;

	Human& h1 = s1;
	Human h2 = s1;

	h1.print();
	h2.print();

	return 0;
}

在这里插入图片描述
可以看到h1的print是满足多态的,这里调用的函数是在在这里插入图片描述
这一阶段中找到eax中存储的虚函数指针,所以可以发现,满足多态的调用是在运行的时候,到对象中的找到虚函数指针来完成的调用

在这里插入图片描述
而下面h2的print则不满足多态,所以是直接在编译时从符号表中找到函数的地址后调用。

所以可以得出的结论是,满足多态的函数调用时在运行的时候调用的,也就是动态多态。而之前重载那一章节也曾经说过重载也是一种多态的表现,只不过重载是在编译的时候完成的调用,所以也被静态多态

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

继承与虚函数表

单继承与虚函数表

class Human
{
public:
	virtual void print()
	{
		cout << "Human::print" << endl;
	}
	int _age;
};

class Student : public Human
{
public:
	virtual void print()
	{
		cout << "Student::print" << endl;
	}

	virtual void test1()
	{
		cout << "Student::test1" << endl;
	}

	int _stuNum;
};

在这里插入图片描述
对于单继承的虚函数表,他会直接继承基类的虚函数表,如果完成了重写,则会覆盖掉原来的虚函数,如果有新的虚函数test1(),则会加在基类虚函数表的尾部。但是由于编译器的问题所以这里并不会显示出来。

所以可以通过代码直接从内存中查看

typedef void(*vfPtr) ();

void Print(vfPtr vfTable[])
{
	cout << " 虚表地址>" << vfTable << endl;

	for (int i = 0; vfTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vfTable[i]);

		vfPtr f = vfTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Human b;
	Student d;

	vfPtr* vfTable = (vfPtr*)(*(int*)& b);
	Print(vfTable);

	vfPtr* vfTable = (vfPtr*)(*(int*)& d);
	Print(vfTable);

	return 0;
}

在这里插入图片描述
可以看到,如果派生类有新的虚函数,则会加在虚函数表的尾部。


多继承与虚函数表

class Human
{
public:
	virtual void print()
	{
		cout << "i am a human" << endl;
	}

	virtual void test1()
	{
		cout << "1test1" << endl;
	}

	int _age;
};

class Student : public Human
{
public:
	virtual void print() 
	{
		cout << "i am a student" << endl;
	}

	virtual void test1()
	{
		cout << "2test1" << endl;
	}

	int _stuNum;
};

class Test : public Human, public Student
{
public:
	virtual void print()
	{
		cout << "i am a Test" << endl;
	}

	virtual void test2()
	{
		cout << "2test2" << endl;
	}
};

对于多继承来说,派生类会拷贝两个基类的虚函数表
在这里插入图片描述
同样的,编译器无法显示,所以继续用代码从内存中读取。

typedef void(*vfPtr) ();

void Print(vfPtr vfTable[])
{
	cout << " 虚表地址>" << vfTable << endl;

	for (int i = 0; vfTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vfTable[i]);

		vfPtr f = vfTable[i];
		f();
	}
	cout << endl;
}

int main()
{
	Test t;

	vfPtr* vfTableb = (vfPtr*)(*(int*)& t);
	Print(vfTableb);

	vfPtr* vfTabled = (vfPtr*)(*(int*)& t + sizeof(Human));
	Print(vfTabled);

	return 0;
}

在这里插入图片描述
同样的,重写的虚函数会覆盖原有虚函数,而派生类未重写的虚函数test2()则会放到第一个继承基类部分的虚函数表中,也就是这里的Human的虚函数表中。


对于多态和对象模型这一部分的问题,我还有很多地方理解的不够好,可以参考一些陈皓大佬的这几篇博客来进一步学习。
C++ 虚函数表解析
C++ 对象的内存布局(上)
C++ 对象的内存布局(下)

猜你喜欢

转载自blog.csdn.net/qq_35423154/article/details/106692379