第6章 多态与虚函数

面向对象程序设计语言有封装、继承和多态三种机制,这三种机制能够有效提高程序的可读性、可扩充性和可重用性。
“多态”指的是同一名字的事物可以完成不同的功能。多态可以分为编译时的多态和运行时的多态。前者主要是指函数的重载(包括运算符重载)、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数,因此叫编译时的多态;而后者则和继承、虚函数等概念有关,是本章要讲述的内容。本书后面提及的多态都是指运行时的多态。

【通过基类指针实现多态】
派生类对象的地址可以赋值给基类指针。对于通过基类指针调用基类和派生类中都有的同名、同参数表的虚函数语句,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行的该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就叫做“多态”。
所谓“虚函数”,就是在声明时前面加了virtual关键字的成员函数。virtual关键字只在类定义中的成员函数声明处使用,不能在类外部写成员函数体时使用。静态成员函数不能是虚函数。
包含虚函数的类成为“多态类”。
多态可以简单地理解为同一条函数调用语句能调用不同的函数;或者说,对不同对象发送同一消息,使得不同对象有各自不同的行为。
多态的语句调用哪个类的成员函数是在运行时才确定的,编译时不能确定。因此,多态的函数调用语句被称为是“动态联编”的,而普通的函数调用语句是“静态联编”的。

【通过基类引用实现多态】
通过基类的引用调用虚函数的语句也是多态的。即,通过基类的引用调用基类和派生类中同名、同参数列表的虚函数时,若其引用的是一个基类的对象,则被调用的是基类的虚函数;若其引用的是一个派生类的对象,则被调用的是派生类的虚函数。

【多态的实现原理】

#include<iostream>
using namespace std;
class A
{
public:
	int i;
	virtual void func(){}
	virtual void func2(){}
};
class B:public A
{
	int j;
	void func(){}
};
int main()
{
	cout<<sizeof(A)<<","<<sizeof(B);//输出8,12
	return 0;
}

在32位计算机中的输出结果是:8 12
如果将程序中的virtual关键字去掉,输出结果变为:4 8
对比发现,有了虚函数以后,对象所占用的存储空间比没有虚函数时多了4个字节。实际上,任何有虚函数的类及其派生类的对象都包好这多出来的4个字节,这4个字节就是实现多态的关键——它位于对象存储空间的最前端,其中存放的是虚函数表的地址。
每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表,该类的任何对象中都放着该虚函数表的指针(可以认为这是由编译器自动添加到构造函数中的指令完成的)。虚函数表是编译器生成的,程序运行时被载入内存。一个类的虚函数表中列出了该类的全部虚函数地址。
多态的函数调用语句被编译成根据基类指针所指向的(或基类引用所指向的)对象中存放的虚函数表的地址,在虚函数表中查找虚函数地址,并调用虚函数的一系列指令。
多态机制能够提高程序的开发效率,但是也增加了程序运行时的开销。虚函数表、各个对象中包含的4个字节的虚函数表的地址都是空间上的额外开销;而查虚函数表的过程则是时间上的额外开销。在计算机发展的早期,计算机非常昂贵稀有,运行速度慢,计算机的运算时间和内存是宝贵的,因此人们不惜多花人力编写运行速度更快、更节省内存的程序;如今,计算机的运算时间和内存往往没有人的时间宝贵,运算速度也很快,因此,在用户可以接受的前提下,降低程序运行的效率以提升人员的开发效率就是值得的了。“多态”的应用就是典型例子。

【关于多态的注意事项】
1.在成员函数中调用虚函数
类的成员函数之间可以互相调用。在成员函数(静态成员函数、构造函数和析构函数除外)中调用其他虚成员函数的语句是多态的。

#include<iostream>
using namespace std;
class CBase
{
	public:
		void func1()
		{
			func2();
		}
		virtual void func2(){cout<<"CBase::fun2()"<<endl;}
};
class CDerived:public CBase
{
	public:
		virtual void func2(){cout<<"CDerived:func2()"<<endl;}
};
int main()
{
	CDerived d;
	d.func1();
	return 0;
}

程序输出:CDerived:func2()

【在构造函数和析构函数中调用虚函数】
在构造函数和析构函数中调用虚函数不是多态,因为编译时即可确定调用的是哪个函数。如果本类有该函数,调用的就是本类的函数,如果本类没有,调用的就是直接基类的函数;如果直接基类没有,调用的就是间接基类的函数,以此类推。

#include<iostream>
using namespace std;
class A
{
	public:
		virtual void hello(){cout<<"A::hello"<<endl;}
		virtual void bye(){cout<<"A::bye"<<endl;}
};
class B:public A
{
	public:
		virtual void hello(){cout<<"B::hello"<<endl;}
		B(){hello();}
		~B(){bye();}
};
class C:public B
{
	public:
	virtual void hello(){cout<<"C::hello"<<endl;}
};
int main()
{
	C obj;
	return 0;
}

类A派生出类B,类B派生出类C、obj对象生成时会调用类B的构造函数,在类B的构造函数中调用hello成员函数。由于在构造函数中调用虚函数不是多态,所以此时不会调用类C的hello成员函数,而是调用类B自己的hello成员函数。obj对象消亡时,会引发类B析构函数的调用,在类B析构函数的调用,在类B的析构函数中调用了bye函数。类B没有自己的bye函数,只有从基类A继承的bye函数,因此执行的就是类A的bye函数。
将在构造函数中调用虚函数实现多态是不合适的。以上面的程序为例,obj对象生成时,要先调用基类构造函数初始化其中的基类部分。在基类构造函数的执行过程中,派生类部分还未完成初始化。此时,在基类B的构造函数中调用派生类C的hello成员函数,很可能是不安全的。在析构函数中调用虚函数不能是多态的原因也与此类似,因为执行基类的析构函数时,派生类的析构函数已经执行,派生类对象中的成员变量的值可能已经不正确了。

【注意多态和非多态的情况】
初学者往往弄不清楚一条函数调用语句是否是多态的。要注意,通过基类指针或引用调用,只有当该成员函数是虚函数时才会是多态。如果该成员函数不是虚函数,那么这条函数调用语句就是静态联编的,编译时就能确定调用的是哪个类的成员函数。
另外,C++规定,只要基类中的某个函数被声明为虚函数,则派生类中的同名、同参数表的成员函数即使前面不写virtual关键字,也自动成为虚函数。

【虚析构函数】
有时会让一个基类指针指向用new运算符动态生成的派生类对象。用new运算符动态生成的对象都是通过释放指向它的指针来释放的。如果一个基类指针指向用new运算符动态生成的派生类对象,而释放该对象时是通过释放该基类指针来完成的。

#include<iostream>
using namespace std;
class CShape
{
	public:
		~CShape(){cout<<"CShape::destructor"<<endl;}
};
class CRectangle:public CShape
{
	public:
	int w,h;
	~CRectangle(){cout<<"CRectangle::destructor"<<endl;}
};
int main()
{
	CShape* p=new CRectangle;
	delete p;
	return 0;
}

输出结果说明,“delete p;“只引发了CShape类的析构函数被调用,没有引发CRectangle类的析构函数被调用。这是因为该语句是静态联编的,编译器编译到此时,不可能知道此时p到底指向哪个类型的对象,它只根据p的类型是CShape*来决定应该调用CShape类的析构函数。按理说,“delete p”,会导致一个CRectangle类的对象消亡,应该调用CRectangle类的析构函数才符合逻辑,否则可能引发程序的问题。
综上所述,人们希望"delete p",这样的语句能够聪明地根据p所指向的对象执行相应的析构函数。实际上,这也是多态。为了在这种情况下实现多态,C++规定,需要基类的虚构函数声明为虚函数,即虚析构函数。改写上面程序中的CShape类,在析构函数前加上virtual关键字,将其声明为虚函数:

class CShape
{
	public:
		virtual ~CShape(){cout<<"CShape::destrutor"<<endl;}
};

则程序输出变为:

CRectangle::destrutor
CShape::destrutor

说明CRectangle类的析构函数被调用了。实际上,派生类的析构函数会自动调用基类的析构函数。
只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,都将自动成为虚析构函数。
一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数。
析构函数可以是虚函数,但是构造函数不能是虚函数。

【纯虚函数和抽象类】
纯虚函数就是没有函数体的虚函数。包含纯虚函数的类就叫抽象类。

class A{
private:int a;
public:
virtual void Print()=0;//纯虚函数
void fun1(){cout<<"fun1";}
};

之所以把包含纯虚函数的类称为“抽象类”,是因为这样的类不能生成独立的对象。抽象类可以作为基类,用来派生基类。可以定义抽象类的指针或引用,并让它们指向或引用抽象类的派生类的对象,这就为多态的实现创造了条件。独立的抽象类对象不存在,但是被包含在派生类对象中的抽象类的对象是可以存在的。
如果一个类从抽象类派生而来,那么当且仅当它对基类中的所有纯虚函数都进行覆盖并都写出函数体,它才能成为非抽象类。
在抽象类的成员函数内可以调用纯虚函数,但是在构造函数或析构函数内部不能调用纯虚函数。

【小结】
通过基类的指针调用基类或派生类都有的同名虚函数时,如果基类指针指向的是基类对象,执行的就是基类的虚函数;如果基类指针指向的是派生类对象,执行时就是派生类的虚函数,这就叫多态。多态也适用于通过基类引用调用基类或派生类中都有的同名虚函数的情况。
多态的作用是提高程序的可扩充性,简化编程。多态是通过虚函数表实现的。
在普通成员函数中调用虚函数是多态,但在构造函数和析构函数中调用虚函数不是多态。
有虚函数的类,其析构函数也应该实现为虚函数。
包含纯虚函数的类叫抽象类。不能用抽象类定义对象。抽象类的派生类,仅当实现了所有的纯虚函数,才会变成非抽象类。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值