详解C++中的多态和虚函数

一、将子类赋值给父类

在C++中经常会出现数据类型的转换,比如 int-float等,这种转换的前提是编译器知道如何对数据进行取舍。类其实也是一种数据类型,也可以发生数据转换,但是这种转换只有在 子类-父类 之间才有意义。并且只能将子类赋值给父类,子类的对象赋值给父类的对象,子类的指针赋值给父类的指针,子类的引用赋值给父类的引用。这在C++中称为向上转型。相反的称为向下转型,但是向下转型有风险,本文只介绍向上转型。

1.1 将子类对象赋值给父类对象

下面我们通过一个具体地实例来看一下:

class people{
protected:
	string name;
	int age;
public:
	people(string name, int age); // 有参构造函数声明
	void display();
};
people::people(string name, int age){	// 有参构造函数定义
	this->name = name;
	this->age = age;
}
void people::display(){
	cout << "name: " << name << "\tage: " << age << "\t是个无业游民。" << endl;
}

class teacher : public people{
private:
	int salary;	// 子类中不仅包含从父类中继承的 name 和 age,还有自己本身的 salary
public:
	teacher(string name, int age, int salary);	// 子类中的有参构造函数声明
	void display();
};

// 显示调用父类中的有参构造函数来初始化从父类中继承的 name 和 age 成员
teacher::teacher(string name, int age, int salary):people(name, age){
	this->salary = salary;
}
void teacher::display(){
	cout << "name: " << name << "\tage: " << age << "\t是个教师,收入为:" << salary << endl;
}
people p("张三", 24);	// 创建一个父类对象
p.display();	
teacher t("李四", 25, 5000);	// 创建一个子类对象
t.display();

p=t;	// 将子类对象赋值给父类对象
p.display();	// 显示复制后的父类对象

在上述例子中,首先定义了一个父类 people 和其子类 teacher,并将子类对象 t 赋值给父类对象 p,输出结果如下:

name: 张三      age: 24 是个无业游民。	// 没有赋值之前的父类输出
name: 李四      age: 25 是个教师,收入为:5000	// 没有赋值之前的子类输出
name: 李四      age: 25 是个无业游民。	// 将子类赋值给父类之后的父类输出

通过结果我们可以发现,将子类赋值给父类对象之后,父类对象相应的成员变量改变了,但是成员函数依旧没有改变,还是父类中的成员函数。因为赋值的本质就是将现有的数据写入已经分配好的内存中,我们在 详解C++中继承的基本内容 - ZhiboZhao - 博客园 (cnblogs.com) 中分析过类对象的内存模型,对象的内存只包含了成员变量,因此对象之间的赋值时成员变量的赋值,而成员函数不存在赋值的问题。 但是子类中的成员变量不仅包含父类中继承的变量,也包含自己定义的变量,那么在将子类赋值给父类的过程中,父类中没有对应的内存空间,所以子类自己定义的变量会被舍弃。即在子类赋值给父类的过程中,父类只拿回属于自己的那一部分,如下图所示:

由于成员函数不存在对象的内存模型中,所以 p.display() 调用的永远都是父类的 display()函数,即:对象之间的赋值不会影响成员函数,也不会影响 this 指针。

1.2 将子类指针赋值给父类指针

下面我们还根据上面的例子来解析一下指针之间的赋值:

people* p = new people("张三", 24);	// 创建指向父类对象的指针 p 
p->display();	// 显示父类对象成员
cout << "p的地址为:" << p << endl;	// 输出指针 p,即父类对象的地址

teacher* t = new teacher("李四", 25, 5000);	// 创建指向子类对象的指针 t 
t->display();	// 显示子类对象成员
cout << "t的地址为:" << t << endl;	// 输出指针 t,即子类对象的地址

p=t;	// 将子类对象指针赋值给父类对象指针
p->display();	// 显示赋值后的父类对象成员
cout << "p的地址为:" << p << endl;	// 输出赋值后的指针 p,即赋值后的父类对象地址

输出结果为:

name: 张三      age: 24 是个无业游民。	// 没有赋值之前的父类输出
p的地址为:014663B0	// 没有赋值之前的父类对象地址
    
name: 李四      age: 25 是个教师,收入为:5000	// 没有赋值之前的子类输出
t的地址为:0146BA50	// 没有赋值之前的子类对象地址
    
name: 李四      age: 25 是个无业游民。	// 将子类指针赋值给父类指针之后的父类输出
p的地址为:0146BA50	// 赋值之后的父类地址

通过输出结果我们发现,通过指针赋值的方式与对象赋值的方式得到的输出结果一致,即 p.display() 始终调用的都是父类中的成员函数。然而与对象变量之间的赋值不同的是,指针赋值其实只是改变了指针的指向,并没有拷贝对象的成员,也没有改变对象的数据。

1.3 将子类引用赋值给父类引用

在文章 C++中指针与引用详解 - ZhiboZhao - 博客园 (cnblogs.com) 中我们详细解释了C++中指针与引用的关系,因此我们可以大致得出结论:对象之间引用赋值的结果与对象之间指针赋值的结果时一致的,为了验证猜想,我们定义如下实例:

people p("张三", 24);
teacher t("李四", 25, 5000);	// 创建了两个对象

people &rp = p;	// 分别创建指向对象的引用
teacher &rt = t;

rp.display();	// 显示赋值前引用的成员变量
rt.display();

rp = rt;	// 引用赋值
rp.display();	// 显示赋值后的成员变量

输出结果如下:

name: 张三      age: 24 是个无业游民。
name: 李四      age: 25 是个教师,收入为:5000
name: 李四      age: 25 是个无业游民。

二、多态的产生原因与实现

通过上一小节,我们可以发现:编译器通过 指针(引用或者对象)来访问成员变量,指针(引用或者对象)指向哪个对象就使用哪个对象的数据;编译器通过指针(引用或者对象)的类型来访问成员函数,指针(引用或者对象)属于哪个类的类型就使用哪个类的函数。但是从直观上来讲,如果指针指向了派生类对象,那么就应该使用派生类的成员变量和成员函数,这符合人们的思维习惯。但是上节的运行结果却告诉我们,当基类指针 p 指向派生类 teacher 的对象时,虽然使用了 teacher 的成员变量,但是却没有使用它的成员函数,换句话说,通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。

为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 virtual 关键字。

我们将父类 person中的 display 函数改写为虚函数,然后测试一下输出结果:

class people{
protected:
	string name;
	int age;
public:
	people(string name, int age);
	virtual void display();	// 将父类中的display函数改写为虚函数
};
people::people(string name, int age){
	this->name = name;
	this->age = age;
}
void people::display(){
	cout << "name: " << name << "\tage: " << age 
		<< "\t是个无业游民。" << endl;
}
people* p = new people("张三", 24);	// 创建指向父类对象的指针 p 
p->display();	// 显示父类对象成员

teacher* t = new teacher("李四", 25, 5000);	// 创建指向子类对象的指针 t 
t->display();	// 显示子类对象成员

p=t;	// 将子类对象指针赋值给父类对象指针
p->display();	// 显示赋值后的父类对象成员

输出结果为:

name: 张三      age: 24 是个无业游民。
name: 李四      age: 25 是个教师,收入为:5000
name: 李四      age: 25 是个教师,收入为:5000

有了虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事, 它有多种形态,或者说有多种表现方式,这种现象称为多态(Polymorphism)

C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。上面我们提到过,通过指针调用成员函数时会根据指针的类型来判断调用哪个类的成员函数,但是对于虚函数而言,其调用时根据指针的指向来确定的,即指针指向哪个类的对象,就调用哪个类的虚函数。

总的来说,多态的使用条件是:父类的指针或者引用指向子类对象。

三、多态的内存模型

我们都知道,类内的普通成员函数并不占用对象的内存空间,当对象调用普通成员函数的时候,编译器默认的将对象的地址作为函数参数的一部分(this 指针),从而找到对应的成员函数。所以个人观点认为,当父类对象的指针指向子类对象时,再调用父类的成员函数,编译器还是会把父类的 this 指针作为参数传递给成员函数,因此调用的还是父类的函数。那么多态的实现基于虚函数,而虚函数的调用根据指针的指向来确定,那么虚函数的内存模型是怎样的呢?

我们再来看一个之前例子:

class A{
public:
	void show();	// A中只定义了普通成员函数
};
class B{
public:
	virtual void show();	// B中定义了虚函数
};
A a;
B b;
cout << "a 占的内存空间为:" << sizeof(a) << endl;
cout << "b 占的内存空间为:" << sizeof(b) << endl;

输出结果为:

a 占的内存空间为:1
b 占的内存空间为:4

通过对比发现:A 中的普通成员函数只是占了预先分配的一个字节,而 B中的虚函数却占了4个字节的地址,存的是每个虚函数的入口地址,这个就是虚指针。下图中简单地描述了一下带有虚函数的类的内部结构:

从上图中可以看到,子类继承了父类并重写了父类中的虚函数后,虚函数表的内部会更新为子类中的成员函数地址。所以在发生多态时(父类的引用指向了子类对象),会从虚函数表中找到对应子类对象的函数入口地址。
posted @ 2021-07-07 16:50  ZhiboZhao  阅读(1385)  评论(1编辑  收藏  举报