14.1 C++类-成员函数、对象复制与私有成员
14.2 C++类-构造函数详解、explicit与初始化列表
14.3 C++类-inline、const、mutable、this与static
14.4 C++类-类内初始化、默认构造函数、“=default;”和“=delete;”
14.5 C++类-拷贝构造函数
14.6 C++类-重载运算符、拷贝赋值运算符与析构函数
14.7 C++类-子类、调用顺序、访问等级与函数遮蔽
14.8 C++类-父类指针、虚/纯虚函数、多态性与析构函数
14.9 C++类-友元函数、友元类与友元成员函数
14.10 C++类-RTTI、dynamic_cast、typeid、type-info与虚函数表
14.11 C++类-基类与派生类关系的详细再探讨
14.12 C++类-左值、右值、左值引用、右值引用与move
14.13 C++类-临时对象深入探讨、解析与提高性能手段
14.14 C++类-对象移动、移动构造函数与移动赋值运算符
14.15 C++类-继承的构造函数、多重继承、类型转换与虚继承
14.16 C++类-类型转换构造函数、运算符与类成员指针
8.父类指针、虚/纯虚函数、多态性与析构函数
8.1 父类指针与子类指针
Human* phuman = new Human(); //完全没问题
Men* pmen = new Men; //完全没问题
当学习了子类的概念之后,又遇到了新的new对象的方法——父类指针可以new一个子类对象:
Human* phuman2 = new Men; //这个可以
但是反过来可不行——子类指针new一个父类对象是不可以的。例如如下代码,编译器会报错:
Men* pmen2 = new Human; //这个报错,子类指针指向父类对象不可以
通过以上的演示说明,父类指针很强大,不仅可以指向父类对象,也可以指向子类对象。
现在,在Human.h文件的Human父类定义中,增加一个用public修饰的成员函数定义(注意:函数体保持为空即可):
void funchuman(){};
再在Men.h文件的Men子类定义中,增加一个用public修饰的成员函数定义:
void funcmen(){};
在main主函数中,增加如下代码:
phuman2->funchuman();//可以,父类类型,可以调用父类的成员函数
phuman2->funcmen();//不可以,虽然new子类对象,但是父类指针,无法调用子类成员函数
上面的情形似乎比较尴尬,既然父类指针没有办法调用子类的成员函数,那为什么还允许父类指针new(指向)一个子类对象呢?有什么用处吗?这就是下面要讲到的问题。
8.2 虚函数
现在,再来定义一个Human类的子类,名字为Women。专门创建Women.h和Women.cpp文件来定义和实现这个子类。注意观察子类的写法。
Women.h文件中,内容如下:
#ifndef __WOMEN__
#define __WOMEN__
#include <iostream>
#include "human.h"
class Women : public Human
{
public:
Women();
};
#endif
Women.cpp文件中,内容如下(记得要把Women.cpp加入到项目中来):
#include "women.h"
#include <iostream>
Women::Women()
{
}
现在,在父类Human和两个子类Men、Women的定义中,都加入如下的用public修饰的同名成员函数eat定义(都加在各自类定义的.h头文件中):
void eat(){}
完善eat成员函数,增加一些输出语句,当该函数被调用的时候可以输出一些信息。
在Human类(Human.h)中,完整的eat成员函数定义如下:
void Human::eat()
{
std::cout << "人类吃各种粮食" << std::endl;
};
在Men类(Men.h)中,完整的eat成员函数定义如下:
void Men::eat()
{
std::cout << "男人喜欢吃米饭" << std::endl;
};
在Women类(Women.h)中,完整的eat成员函数定义如下:
void Women::eat()
{
std::cout << "女人喜欢吃面食" << std::endl;
}
在main主函数中,代码如下:
Human* phuman = new Human;
phuman->eat();//调用了Human类的eat函数
从上面这行代码可以看到,调用的是Human类的成员函数eat,因为phuman是Human类型指针,而new的时候new的也是Human类对象(Human类指针指向Human类对象)。
那么,如何调用Men和Women类中的eat成员函数呢?有的读者说,很简单,定义两个子类对象,每个子类对象调用自己的eat成员函数不就行了。此外,上一节也讲过“函数遮蔽”问题——子类可以遮蔽父类的同名函数。
在MyProject.cpp的开头,把Women.h头文件包含进来,在main主函数中,增加如下代码:
{
Men* pmen = new Men;
pmen->eat(); //调用了Men类的eat函数
Women* pwomen = new Women;
pwomen->eat(); //调用了Women类的eat函数
}
能够感觉到,上面的解决方案并不好,为了调用不同子类的同名函数,竟然又定义了两个子类的对象指针。
有没有一个解决办法,能够做到只定义一个对象指针,就能够调用父类以及各个子类的同名成员函数eat呢?有,这个指针就是刚才说过的父类对象指针。请注意,该指针定义时的类型必须是父类类型。看如下代码:
Human* phuman2 = new Men;//父类指针指向子类对象
phuman2->eat();//调用了Human类的eat函数
现在的需求是想通过一个父类指针(定义时的类型是父类类型,phuman2就是一个父类类型的指针),既能够调用父类,也能够调用子类中的同名同参成员函数(eat),这是可以做到的。但是对这个同名同参的成员函数有要求:在父类中,这个成员函数的声明的开头必须要增加virtual关键字声明,将该成员函数声明为虚函数。当然,如果该成员函数直接定义在.h文件中,则在成员函数定义的行首位置加virtual关键字即可。
这里注意,virtual关键字是增加在父类的成员函数(eat)的声明中,这是必须的要求。否则通过父类指针就没有办法调用子类的同名同参成员函数了。
那么在子类中,该函数(eat)声明前是否增加virtual没有强制要求,但笔者建议加上,不加也可以。因为一旦某个类中的成员函数被声明为虚函数,那么所有子类中(被子类覆盖后)它都是虚函数。所以,子类中在eat函数声明前面是否加virtual都一样,但为方便他人阅读,建议增加virtual。
另外,值得强调的是,子类的虚函数(eat)的形参要和父类的完全一致。否则会被认为是和父类中的虚函数(eat)完全不同的两个函数了。
为了演示得更清晰,对范例程序做一些改造,现在把Human类中的eat成员函数的声明和实现分开。
在Human.h文件的Human类定义中,只保留eat成员函数的声明部分,注意,在声明的时候前面增加virtual关键字,表明eat成员函数是虚函数:
virtual void eat();
在Human.cpp中,增加成员函数eat的实现代码,不过在实现代码中,不需要在前面增加virtual关键字:
void Human::eat()
{
std::cout << "人类吃各种粮食" << std::endl;
}
按照同样的方式来修改Men类。
在Men.h文件的Men类定义中,保留eat成员函数的声明部分:
virtual void eat();
在Men.cpp中,增加成员函数eat的实现代码:
void Men::eat()
{
std::cout << "男人喜欢吃米饭" << std::endl;
};
按照同样的方式来修改Women类。
在Women.h文件的Women类定义中,保留eat成员函数的声明部分:
virtual void eat();
在Women.cpp中,增加成员函数eat的实现代码:
void Women::eat()
{
std::cout << "女人喜欢吃面食" << std::endl;
}
好了,现在可以在main主函数中增加代码进行演示了:
{
Human* phuman = new Men; //父类Human指针指向子类Men对象
phuman->eat(); //男人喜欢吃米饭,调用的是Men类的eat函数
delete phuman;
phuman = new Women; //父类Human指针指向子类Women对象
phuman->eat(); //女人喜欢吃面食,调用的是Women类的eat函数
delete phuman;
phuman = new Human; //父类Human指针指向父类(本身)对象
phuman->eat(); //人类吃各种粮食,调用的是Human类的eat函数
delete phuman;
}
观察上面的代码,当执行“Human*phuman=new Men;”后,调用“phuman->eat();”调用的是Men子类的eat成员函数(指针始终是父类类型,而new的是哪个对象,执行的就是哪个对象的eat虚函数),那么,当phuman指向一个子类(Men类)对象时,能否实现用phuman调用Human类的eat成员函数(而不是Men类的eat成员函数)呢?当然也是可以的。看如下代码,注意实现方法:
{
Human* phuman = new Men; //父类Human指针指向子类Men对象
phuman->eat(); //男人喜欢吃米饭,调用的是Men类的eat函数
phuman->ovr();
phuman->Human::eat(); //人类吃各种粮食,调用的是Human类的eat函数,注意调用格式
delete phuman;
}
为了避免在子类中写错虚函数,在C++11中,可以在函数声明所在行的末尾增加一个override关键字。注意,这个关键字是用在子类中,而且是虚函数专用的。修改Men.h和Women.h的相关类定义中的eat成员函数声明(成员函数实现中不需要加):
virtual void eat() override;
override这个关键字主要就是用来说明派生类中的虚函数,用了这个关键字之后,编译器就会认为这个eat是覆盖了父类中的同名的虚成员函数(virtual)的,那么编译器就会在父类中找同名同参的虚成员函数,如果没找到,编译器就会报错。这样,如果不小心在子类中把虚函数写错了名字或者写错了参数,编译器就会帮助开发者找出错误,方便开发者的修改。
例如,如果在Men.h的eat虚成员函数中加一个参数,编译器一定会报错,如图14.10所示。
与override关键字相对的还有一个final关键字,final关键字也是用于虚函数和父类中的。如果在函数声明的末尾增加final,那么任何在子类中尝试覆盖该成员函数的操作都将引发错误。
假如,在Human.h文件的Human类定义中将eat成员函数的声明这样修改:
virtual void eat() final;
那么无论在Men类还是在Women类中的eat成员函数的声明语句都会引发编译错误:
virtual void eat() override;//这将引发编译错误 因为用final声明的函数不能被覆盖
另外,子类的虚函数返回类型一般也和父类所要覆盖的虚函数返回类型一样,也可以有点小差别。这里详细描述以下这件事:
(1)例如随便一个类CSuiBian,它有一个子类名字为CSuiBian_Sub。
(2)如果Human父类中有一个虚函数ovr,返回的类型是CSuiBian*,代码如下:
virtual CSuiBian_Sub* ovr() {return NULL;}
(3)那么子类Men或者Women中,对应的虚函数可以返回CSuiBian 类型,也可以返回CSuiBian_Sub类型(CSuiBian的子类类型指针)。看如下代码,两种写法都可以:
virtual CSuiBian_Sub *ovr(){return NULL;}
virtual CSuiBian *ovr(){return NULL;}
通过上面的演示,已经看到virtual关键字定义的虚函数的作用了。总结一下:
(1)用父类的指针调用一个虚成员函数时,执行的是动态绑定的eat函数。什么叫动态绑定呢?所谓动态,表示的就是在程序运行的时候(运行到调用eat函数这行代码时)才能知道调用了哪个子类的eat函数(虚成员函数)。读者知道,一个函数如果不去调用,编码时可以只写该函数的声明部分,不写该函数的定义部分。但是虚函数,因为是在程序运行的时候才知道调用了哪个虚函数,所以虚函数必须写它的定义部分(以备编译器随时使用随时就存在),否则会编译出错。
可以看到,程序运行的时候,作为父类的指针phuman,如果new的是Men子类对象(也叫实例),那么调用的eat函数就是Men类的虚函数eat;如果new的是Women子类对象,那么调用的eat函数就是Women类的虚函数eat,这就叫动态绑定——运行的时候(根据new的是哪个类的对象)才决定phuman调用哪个eat函数。
(2)如果不是用phuman父类类型指针,而是用普通对象来调用虚函数,那虚函数的作用就体现不出来了,因为这就不需要运行时(根据new的是哪个类的对象)决定绑定哪个eat函数,而是在编译的时候就能确定。看如下代码:
{
Men* pmen = new Men;
pmen->eat(); //调用了Men类的eat函数
Women* pwomen = new Women;
pwomen->eat(); //调用了Women类的eat函数
Human* phuman = new Human;
phuman->eat();//调用了Human类的eat函数
}
8.3 多态性
多态性只是针对虚函数说的,这一点请读者牢记——非虚函数,不存在多态的说法。
“多态”(也叫“多态性”)这种概念(或者称为“性质”)是面向对象程序设计的核心思想之一。在13.1.1节也曾提及过。随着虚函数的提出,“多态性”的概念也就浮出了水面。
多态性的解释有如下两方面:
(1)体现在具有继承关系的父类和子类之间。子类重新定义(覆盖/重写)父类的成员函数eat,同时父类和子类中又把这个eat函数声明为了virtual虚函数。
(2)通过父类的指针,只有到了程序运行时期,根据具体执行到的代码行,才能找到动态绑定到父类指针上的对象(new的是哪个具体的对象),这个对象有可能是某个子类对象,也有可能是父类对象,而后,系统内部实际上是要查类的“虚函数表”,根据虚函数表找到函数eat的入口地址,从而调用父类或者子类的eat函数,这就是运行时期的多态性。
“虚函数表”的概念超出了本书的研究范围,笔者会在《C++新经典:对象模型》书籍中专门论述,这里就不多谈。
8.4 纯虚函数与抽象类
就算是没有子类,也可以使用虚函数,而且,如果子类中不需要自有版本的虚函数,可以不在子类中声明和实现(定义)该虚函数。如果不在子类中定义该虚函数,则调用该虚函数时,调用的当然是父类中的虚函数。
纯虚函数是在父类中声明的虚函数,它在父类中没有函数体(或者说没有实现,只有一个声明),要求任何子类都要定义该虚函数自己的实现方法,父类中实现纯虚函数的方法是在函数原型后面加“=0”,或者可以说成是在该虚函数的函数声明末尾的分号之前增加“=0”。
为了方便演示,在Human.h文件中的Human类定义前面,增加个新类(临时类)定义,取名为Human2。其内容如下:
class Human2
{
public:
virtual void eat() = 0; //这是一个纯虚函数
};
这时请注意,一个类中一旦有了纯虚函数,那么就不能生成这个类的对象了。例如如下代码都不合法:
Human2 *phuman2 = new Human2;
Human2 human2;
抽象类:这种带有纯虚函数的类(Human2)就叫抽象类。抽象类不能用来生成对象,主要目的是统一管理子类(或者说建立一些供子类参照的标准或规范)。
请记住几点:
(1)含有纯虚函数的类叫抽象类。抽象类不能用来生成对象,主要当作父类用来生成子类。
(2)子类中必须要实现父类(抽象类)中定义的纯虚函数(否则就没法用该子类创建对象——创建对象就会编译错误)。在Human.h文件中的Human2类定义的后面,再新定义一个新类Human2_sub,这个新类继承Human2类,并且必须要实现Human2类中的eat纯虚函数:
class Human2_sub : public Human2
{
public:
virtual void eat() //子类必须实现父类的纯虚函数,才能用该子类创建对象
{
std::cout << "Human2_sub::eat()" << std::endl;
}
};
(3)这样在main主函数中,就可以用类Human2_sub来创建对象了:
Human2_sub * psubhuman2 = new Human2_sub;//没问题
Human2_sub subhuman;
在抽象类这个问题上可以这样理解:
(1)抽象类不是必须用,不用当然也可以。
(2)抽象类中的虚函数不写函数体,而是推迟到子类中去写。抽象类(父类)就可以“偷懒”少写点代码。
(3)抽象类主要是用来做父类,把一些公共接口写成纯虚函数,这些纯虚函数就相当于一些规范,所有继承的子类都要实现这些规范(重写这些纯虚函数)。
当然,有些读者可能会认为,压根就不需要有抽象类(父类),每个类(子类)都实现自己的eat接口不就可以了吗?如果是这样,那还怎么实现多态功能?
(1)请不要忘记,多态的实现是:父类指针指向子类对象。如果没有父类,也就不存在多态。
(2)请不要忘记,纯虚函数也是虚函数,因此是支持多态的。
8.5 父类的析构函数一般写成虚函数
为了后面讲解的方便,这里来完善一下代码。
在Human.h文件的Human类定义中已经有了默认构造函数的声明,在Human.cpp中已经有了默认构造函数的实现。
在Human.h文件的Human类定义中增加析构函数的声明:
public:
~Humen();
在Human.cpp文件中增加析构函数的实现代码:
Human::~Human()
{
std::cout << "执行了Human::~Human()析构函数" << std::endl;
}
在Men.h文件的Men类定义中增加析构函数的声明:
public:
~Men();
在Men.cpp文件中增加析构函数的实现代码:
Men::~Men()
{
std::cout << "执行了Men::~Men()析构函数" << std::endl;
}
继续在Women.h文件的Women类定义中增加析构函数的声明:
public:
~Women();
在Women.cpp文件中增加析构函数的实现代码:
Women::~Women()
{
std::cout << "执行了Women::~Women()析构函数" << std::endl;
}
完善一下Women.cpp中Women构造函数的实现代码——增加一条输出语句如下:
Women::Women()
{
std::cout << "执行了Women::Women()构造函数" << std::endl;
}
现在,在Human父类、Men子类和Women子类中都有了构造函数和析构函数,而且在每个构造函数和析构函数中都有输出语句std::cout,这样,当执行这些函数的时候,可以看到一些输出结果。
在main主函数中,增加如下代码:
Men men;
程序执行后,显示结果如图14.11所示。
从图14.11不难看出,当定义一个子类对象时,先执行的是父类的构造函数体,再执行子类的构造函数体。当对象超出作用域范围被系统回收时,先执行的是子类的析构函数体,再执行父类的析构函数体。
继续测试,在main主函数中,增加如下代码:
Men *pMen = new Men();
程序执行后,上面这行代码显示结果如图14.12所示。
从图14.12不难看出,当用new的方式创建子类对象时,也是先执行父类的构造函数体,再执行子类的构造函数体。但是,new出来的对象内存并没有释放(没有被系统回收),这需要程序员自己释放。继续在main主函数中增加如下代码来释放内存,回收对象:
delete pmen; //先调用子类的析构函数,再调用父类的析构函数
程序执行后,上面这行代码显示结果如图14.13所示。
请读者注意执行构造函数的顺序以及执行析构函数的顺序,千万不要记错。
以上这些显示结果都在意料之中,也是开发者所需要的——开发者正需要创建对象时系统既调用父类的构造函数,也调用子类的构造函数,释放时既调用子类的析构函数,也调用父类的析构函数。
但是,现在请读者注意了,如果像下面这样创建对象,在main主函数中增加如下代码(父类指针,指向子类对象):
Human * phuman = new Men(); //先调用父类构造函数,再调用子类构造函数
程序执行后,上面这行代码显示结果如图14.14所示。
继续,请读者再次注意了,继续在main主函数中增加如下代码来释放内存,回收对象:
delete phuman //只调用了父类析构函数,这就坏了,没有调用子类的析构函数
程序执行后,上面这行代码显示结果如图14.15所示。
不难发现,图14.16所示的结果比图14.15多做了一件事——执行了Men类的析构函数。这样,子类Men和父类Human的析构函数都被调用了,那就再也不担心在Men类的构造函数中new出来一块内存,而不能在Men类的析构函数中释放的问题(因为Men的析构函数能够被执行),只需要把delete这块内存的代码放在Men类的析构函数中即可。
所以请记住:
(1)只有虚函数才能做到用父类指针phuman调用到子类的虚函数eat。也是因为这种虚函数的调用特性,所以只要把析构函数声明为虚函数,系统内部就能够正确处理调用关系,从而在图14.16中可以看到,子类Men和父类Human的析构函数都被执行,这是非常正确的。
(2)另外,父类中析构函数的虚属性也会被继承给子类,这意味着子类中的析构函数也就自然而然地成为虚函数了(就算不用virtual修饰也没关系),虽然名字和父类的析构函数名字不同。所以,Men类和Women类的析构函数Men和Women其实都是虚函数。等价于如下代码:
virtual ~Men();
virtual ~Women();
总而言之,delete phuman时肯定是要调用父类Human的析构函数体,但在调用父类析构函数之前要想让系统先调用子类Men的析构函数,那么Human这个父类中的析构函数就要声明为virtual的。也就是说,C++中为了获得运行时的多态行为,所调用的成员函数必须得是virtual的,这些概念在前面讲虚函数时其实已经讲过。
所以给出如下结论,请读者牢记:
(1)如果一个类想要做父类,务必要把这个类的析构函数写成virtual析构函数。只要父类的析构函数是virtual(虚)函数,就能够保证delete父类指针时能够调用正确的析构函数。
(2)普通的类可以不写析构函数,但如果是一个父类(有孩子的类),则必须要写一个析构函数,并且这个析构函数必须是一个虚析构函数(否则肯定会出现内存泄漏)。
(3)虚函数(虚析构函数也是虚函数的一种)会增加内存和执行效率上的开销,类里面定义虚函数,编译器就会给这个类增加虚函数表,在这个表里存放虚函数地址等信息。
(4)读者将来在寻找C++开发工作时,遇到面试官考核诸如“为什么父类(基类)的析构函数一定要写成虚函数”的问题时,一定要慎重回答,简而言之的答案就是:唯有这样,当delete一个指向子类对象的父类指针时,才能保证系统能够依次调用子类的析构函数和父类的析构函数,从而保证对象(父指针指向的子对象)内存被正确地释放。