C++封装、继承(单继承)、多态详细分析。

系列文章目录



摘要

多态性特征是 C++中最为重要的一个特征,熟练使用多态是学好 C++的关键,而理解多态的实现机制及实现过程则是熟练使用多态的关键。文章在分析多态性基本属性的基础上,结合具体程序实例重点分析了动态多态的实现机制,并结合虚函数编原理分析了动态多态的实现过程。
关键词: C++; 多态性; 虚函数


一、基本概念

封装、继承和多态是面向对象设计的 3 大特点。

  • 封装就是把客观事物抽象得到的数据和行为封装成一个整体,在
    C++中,实现数据和行为封装的程序单元就叫类。封装就是将代码模块化,实现了类内部对象的隐蔽。
  • 继承是由已经存在的类创建新类的机制,体现在类的层次关系中,子类拥有父类中的数据和方法,子类继承父类的同时可以修改和扩充自己的功能。
  • 多态是指父类的方法被子类重写、可以各自产生自己的功能行为。封装和继承的目的是代码的重用,多态就是实现接口重用,即“一个接口,多种方法”。

相比封装和继承,多态因其复杂性、灵活性更难以掌握和理解。

二、多态的分类

C++中利用类继承的层次关系来实现多态,通常是把具有通用功能的声明存放在类层次高的地方,而把实现这一个功能的不同方法放在层次较低的类中,C++语言通过子类重定义父类函数来实现多态。
多态通常分为两种: 通用多态特定多态


三、多态的实现

在 C++中利用类继承的层次关系来实现多态,通常是把具有通用功能的声明存放在类层次高的地方,而把实现这一个功能的不同方法放在层次较低的类中,C++语言通过子类重定义父类函数来实现多态

3.1 类型兼容与函数重写

  • C++中的继承遵循了类型兼容性原则
    即当子类以 Public方式继承父类时,将继承父类的所有属性和方法,因此,可以变相的理解成子类是一种特殊的父类,可以使用子类对象初始化父类,也可以使用父类的指针或引用来调用子类的对象。
  • C++中的函数重写
    在程序设计过程中,很多时候会出现这样一种情况,子类继承父类的 A 函数,但父类的 A 函数不能满足子类的需求,此时需要在子类中对 A 函数进行重写。C++中的函数重写是指: 函数名、参数、返回类型均相同。

如果程序中类型兼容性原则遇到了函数重写会怎么样,调用父类的 A 函数还是子类中重写的 A函数,类型兼容与函数重写之间的关系可以用以下程序代码阐释
代码示例:

#include<iostream>
using namespace std;

class Animal // 父类
{
public:
	void Speak()
	{
		cout << "动物在说话" << endl;
	}
};
class Dog : public Animal // 子类
{
public:
	void Speak()
	{
		cout << "小狗在汪汪叫" << endl;
	}
};
int main()
{
	// 第一种定义
	Dog dog;
	dog.Speak();
	dog.Animal::Speak();

	// 第二种定义
	Animal animal1 = dog;
	animal1.Speak();

	// 第三种定义
	Animal* animal2 = &dog;
	animal2->Speak();

	return 0;
}

运行截图:
在这里插入图片描述
上述程序中定义了 Animal 和 Dog 两个类,其中,Dog 类以 Public 方式继承了 Animal 类,并且重写了
Speak( ) 方法。

  1. 根据程序运行结果不难看出: main( )函数中定义的 Dog 类对象 dog 的调用方法 dog.Speak( )
    是通过子类对象的 Speak( ) 函数来实现小狗在汪汪叫功能。
  2. dog.Animal: : Speak( ) 是子类对象通过使用操作符作用域调用父类的 Speak( ) 函数来实现:
    动物在说话。定义的 Animal 的对象 animal1 通过调用拷贝构造函数,把 dog 的 数 据 拷 贝 到 animal1
    中,animal1 仍为父类对象,所以animal1.Speak( )执行的结果是动物在说话。
  3. 最终定义了一个指向 Animal 类的指针 animal2,将派生类对象 dog 的地址赋给父类指针 animal2,利用该变量调用animal2 –>speak ( ) 方法。得到的结果是: 动物在说话。

原因
a) C++编译器进行了类型转换,允许父类和子类之间进行类型转换,即父类指针可以直接指向子类对象。根据赋值兼容,编译器认为父类指针指向的是父类对象,因此,编译结果只可能是调用父类中定义的同名函数。
b) 在此时,C++认为变量animal2中保存的就是 Animal 对象的地址,即编译器不知道指针 animal2指向的是一个什么对象,编译器认为最安全的方法就是调用父类对象的函数,因为父类和子类肯定都有相同的 Speak( )函数。因此,在 main() 函数中执行 animal2 –>Speak( ) 时,调用的是 Animal 对象的 Speak( ) 函数。

3.2 动态联编与静态联编

  • 以上程序出现这种情况的原因涉及 C++在具体编译过程中函数调用的问题,这种确定调用同名函数的哪个函数的过程就叫做联编( 又称绑定) 。在C++中联编就是指函数调用与执行代码之间关联的过程,即确定某个标识符对应的存储地址的过程,在C++程序中,程序的每一个函数在内存中会被分配一段存储空间,而被分配的存储空间的起始地址则为函数的入口地址。
  • 按照程序联编所进行的阶段,联编可分为两种:静态联编和动态联编。静态联编就是在程序的编译与连接阶段就已经确定函数调用和执行该调用的函数之间的关联。在生成可执行文件中,函数的调用所关联执行的代码是确定好的,因此,静态联编也称为早绑定动态联编是在程序的运行时根据具体情况才能确定函数调用所关联的执行代码,因此,动态联编也称为晚绑定
  • 当类型兼容原则与函数重写发生冲突时,程序员希望根据程序设计的子类对象类型来调用子类对象的函数,而不是编译器认为的调用父类的对象函数。也就是说,如果父类指针(引用) 指向( 引用) 父类的对象时,程序就应该调用父类的函数,如果父类指针( 引用) 指向( 引用)子类的对象时,程序就应该调用子类的函数。这一功能可以通过动态联编实现。与静态联编相比,动态联编是在程序运行阶段,根据成员函数基于对象的类型不同,编译的结果就不同,这就是动态多态。动态多态的基础是虚函数。虚函数是用来表现父类和子类成员函数的一种关系。

3.3 虚函数

虚函数的定义方法是用关键字 virtual 修饰类的成员函数,虚函数的定义格式:

 virtual〈返回值类型〉〈函数名〉( 〈形式参数表〉) { <函数体>} 

在类的层次结构中,成员函数一旦被声明为虚函数,那么,该类之后所有派生出来的新类中其都是虚函数。父类的虚函数在派生类中可以不重新定义,若在子类中没有重新改写父类的虚函数,则调用父类的虚函数。对兼容性与函数重写程序,进行适当的修改,将父 类 Animal 中 的 Speak ( ) 函数使用关键子Virtual 将其定义为虚函数,代码如下所示。

#include<iostream>
using namespace std;
class Animal // 父类
{
public:
	virtual void Speak() //用virtual 关键子定义 Speak() 为虚函数
	{
		cout << "动物在说话" << endl;
	}
};
class Dog : public Animal // 子类 Dog以public 方式继承了 Animal
{
public:
	void Speak() //重写了 Speak() 函数
	{
		cout << "小狗在汪汪叫" << endl;
	}
};
int main()
{
	Dog dog;
	dog.Speak();
	dog.Animal::Speak();

	Animal animal1 = dog;
	animal1.Speak();

	Animal* animal2 = &dog;
	animal2->Speak();

	return 0;
}

运行截图:
在这里插入图片描述

Animal * animal2 = &dog,animal2.Speak( ) 时,由于在父类 Animal 的 Speak( ) 函数前加关键字 Virtual,
使得 Speak( ) 函数变成虚函数,编译器在编译的时候,发现 animal 类中有虚函数,此时,编译器会为每个包含虚函数的类创建一个虚函数表,该表是一个一维数组,在这个数组中存放每个虚函数的地址,这样就实现了动态联编,也就是晚绑定。也就实现了前面说的当调用父类指针( 引用) 指向( 引用) 子类对象函数时,调用的是子类对象的函数,实现了动态多态。通过分析发现,要想实现动态多态
要满足以下 3个条件:

  1. 必须存在继承关系,程序中的 Dog 类以public 的方式继承了 Animal 类。
  2. 继承关系中必须要有同名的虚函数。在两个类中 Speak( ) 函数为同名虚函数,子类重写父类的虚函数。
  3. 存在父类的指针或引用调用子类该虚函数。

了解多态是如何实现的之前,先要了解虚函数的调用原理,虚函数的调用原理和普通函数不一样,编译器在程序编译的时候,发现类中有关键字 virtual 的虚函数时,编译器会自动为每个包含虚函数的类创建一个虚函数表用来存放类对象中虚函数的地址,并同时创建一个虚函数表指针指向该虚函数表[6]。每个类使用一个虚函数表,每个类对象用一个指向虚表地址的虚表指针。父类对象包含一个指针指向父类所有虚函数的地址,子类对象也包含一个指向独立地址的指针。
如果子类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址,如果子类提供了虚函数的新定义,该虚函数表将保存新函数的地址。示例程序中定义了两个类 A 和 B,类 B 继承自类 A,父类 A
中定义了两个虚函数,子类 B 中重写了其中一个虚函数,代码如下所示:

class A
{
public:
	virtual void fun1()
	{
		cout << " fun1 是类 A 虚函数";
	}
	virtual void fun2()
	{
		cout << " fun2 是虚类 A 函数";
	}
};
class B : public A
{
public:
	virtual void fun1()
	{
		cout << " fun1 是类 B 的虚函数";
	}
};

分析上述程序,对于父类 A 中的两个虚函数 fun1( ) 和 fun2( ) ,由于子类 B 重写了类 A 中的 fun1( ) 函
数,就导致子类 B 的虚函数表的第一个指针指向的是类 B 的 fun1( ) 的函数而不是父类 A 的 fun1( ) 函数,
具体如下表所示:

类 A 的虚函数表类 B 的虚函数表
0: 指向类 A 的 fun1 的指针0: 指向类 B 的 fun1 的指针
1: 指向类 A 的 fun2 的指针1: 指向类 A 的 fun2 的指针

3.4 动态多态的实现过程

编译器进行编译程序时发现有 virtual 声明的函数,就会在这个类中产生一个虚函数表。即使子类中没有用 virtual 定义虚函数,由于父类中的定义,子类通过继承后仍为虚函数。程序中 Animal 类和 Dog 类都包含一个虚函数 Speak( ) ,因此,编译器会为这两个类都建立一个虚函数表,将虚函数地址存放到该表中。
在这里插入图片描述

编译器在为每个类创建虚函数表的同时,还为每个类的对象提供了一个虚函数表指针( vfptr) ,虚函数表指针指向了对象所属类的虚表。根据程序运行的对象类型去初始化虚函数表指针。虚函数表指针在没有初始化的情况下,程序是无法调用虚函数的。虚函数表的创建和虚函数表指针的始化是在构造函数中实现的,在构造子类对象时,先调用父类的构造函数,并初始化父类的虚函数指针,指向父类的虚函数表,当子类对象执行构造函数时,子类对象的虚函数表指针也被初始化,指向子类的虚函数表。实现了在调用虚函数时,就能够找到正确的函数,如下图所示。
在这里插入图片描述

总结

多态性作为面向对象程序设计语言的 3 大要素之一,因其灵活性、伸缩性和复杂性而难以掌握。本文着重分析多态的分类、特征及动态多态的实现机制和原理,但本文对于动态多态的分析仅仅局限于单继承的情况,对于多继承的情况原理基本相同,本文未作过多说明。

参考文献

[1]李家宏,孙庆英.C++多态性的实现过程[J].无线互联科技,2023,19(02):131-134.

网址链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

暴躁茹

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值