3.10. Hello object 多态版
公元前209年,7月。秦朝著名的两位民工,陈胜、吴广说了一句话:“将相王候,宁有种乎?”。他们在表达一种不满:难道那些有钱人或当官人,天生就和我们有不同人种吗?
我们的例子程序似乎也有这个问题。
Person xiaoA;
Beauty jiaLing;
xiaoA天生就是普通人类,而jiaLing天生就是美人胚子。她们的“类型”不一样。
通常我们提出一个问题,就代表一个新需求出现了,然后我们就解决它。不过有关“权贵、财富、美貌、人种”的问题实在太复杂了,因此今天的代码仅仅对现状做一个小小改进。
C++规定,一个基类的“堆变量”,允许被实例为一个派生类的“对象”。比如:
Person *someone;
表面上看,someone是一个普通人类(Person)。不过,C++规定它可以被创建成一个派生类(Beauty)的对象。
Person *someone;
someone = new Beauty; //“new出来”的是“Beauty”
第一行,我们定义一个堆对象。不过具体它将是Person,还是Beauty,此时未确定,直到第二行,才通过new,创建了一个Beauty对象。
基类的对象变量,可以创建实际上是派生类的对象,这可是一项新鲜技术。我们快来试试。
请新建一个控制台应用。main.cpp代码内容来自前一样例,包括记得修改其编码为系统默认;然后将main函数内容修改如下:
int main()
{
036 Person *xiaoA = new Beauty;
037 xiaoA->name = "Xiao A";
039 xiaoA->Introduction();
040 delete xiaoA;
//------------------------------------------------
042 Beauty* zhiLing = new Beauty;
zhiLing->name = "志玲";
zhiLing->Introduction();
delete zhiLing;
return 0;
}
以前,xiaoA一直是一个“栈对象”,但现在,036行中xiaoA被定义成一个Person类型的堆对象,但随后(就在同一行),它就被实际创建成Beauty类型对象。
042行开始完整地重复了之前有关zhiLing对象的创建,使用与释放的代码,作为对比,zhiLing在定义时,就是一个Beauty类型。
037行和039行中,“.”变成“->”;040行则用于删除xiaoA——这一切变化,都缘于xiaoA现在是一个堆对象了。
“最重要的是”,xiaoA含着眼泪对苍天呼喊:“我也是美女了!”。还是编译、并运行程序之后再说吧——苍天啊,你看到了什么?这一切又是为什么?
(图 26 xiaoA是美女,但是她的问候方式还是……)
小A在“析构函数”中,也就临死前的“呜~呜~”两声,倒也情真意切,因为她心有不甘啊!同样是美人,怎么差别就这么大呢?
3.10.1. 虚函数
xiaoA和zhiLing的本质区别在于: zhiLing是名实一致,而xiaoA却名实不一:
036 Person *xiaoA = new Beauty;
042 Beauty* zhiLing = new Beauty;
当然,这里的“名”并不是指“名字”,名字和一个人是不是美人无关,这一点我们很早就清楚。这里的“名”指的是一个对象定义时的“类型”。
虽然,xiaoA实质上创建成一个Beauty对象,但它在定义时指定的类型是Person。所以在这一行的程序:
039 xiaoA->Introduction();
xiaoA仍然被当成Person——苍天啊,原来在c++的世界里,也讲究“出身”论,虽然xiaoA明明长成了一个Beauty,可是C++没有忘记它的出生是一个普通的Person——于是它的自我介绍,也就仍然是普通人的方式了。
“出身决定一切”,或许在某些情况下有其用途,但在当前情况下,这显然不是我们所愿意接受的情况,还好C++为我们提供了解决方法。很简单,只要为基类Person的Introduction函数增加一个修饰词:virtual。
有virtual这个关键词修改的成员函数,被称为“虚函数”。
C++规定:当一个基类中的某一个函数是虚函数,那么在其派生类(以及派生类的派生类……)中,如果有存在相同的函数,那么这些函数就算不加virtual修饰,也是虚函数。
这里的相同函数是指:函数名相同,并且函数参数列表相同(次序和类型相同),并且返回值也相同,或者是原返回类型的派生类。
结合本例,如果我们让Person(基类)的中的Introduction是虚函数,那么Beauty(派生类)中的Introduction也就成为虚函数,并且假设今后Beauty也有自己的派生类,那么这条规定同样有效。
C++规定:如果一个函数是虚函数,那么在调用该函数时,程序将确保使用该对象的实际创建的对象类型所拥有的那个函数。
说起来似乎复杂,但实际做起来很容易:
struct Person
{
//此处略去构造函数与析构函数
018 virtual void Introduction()
{
cout << "Hi, my name is " << name << "." << endl;
}
//……
};
全部的变化就是在018行添加virtual这个关键字。请记住它加的位置:在函数返回类型void之前。
现在编译并运行程序,xiaoA就可以按照美人的方式来自我介绍了。不过出于个人的C++编程风格, 我建议大家Beauty的Introduction前面也加上virtual。
〖小提示〗:派生类中的函数加不加virtual ?
C++ 程序的代码风格可以分成两派:一派是一个多余的字母都不写。另一派是愿意写一些多余的字词——如果这些字词有助于代码阅读理解的话。由于一个函数在基类中 加了virtual的话,在派生类中该函数就自然也是虚函数了,所以这时在派生类中针对该函数再写“virtual”,至少在语法上显得多余。
本书作者属于第二派,我向大家推荐我的风格:在派生类中,以至在派生类的派生类中,都明确地加上virtual来向代码阅读者表明一个事实:这个函数是虚拟的,请注意。
3.10.2. 虚“析构函数”
析构函数也是成员函数,因此虚函数的规则对它也起作用。不过析构函数和普通成员函数相比,有以下特殊之处:
第一、 派生类的析构函数名字肯定和基类叫法不一样,但这不影响虚函数的规则对它起作用。
第二、 析构函数的特别之处是它是对象在死亡之前必定要做的一件事。通常复杂的对象会在析构函数内释放额外占用的内存等资源。
第三、 派生类的调用完自己的析构函数之后,会自动调用基类的析构函数。其目的是为了确保基类的资源也能自动释放。
在我们的例子中,美人的死和普通的人死没有什么区别,干脆,我们给美人类专门设计一个死亡告别方式——我们准备为美人类提供自定义的析构函数。
struct Beauty : public Person
{
~Beauty()
{
cout << "wu~wu~人生似蚍蜉、似朝露;似秋天的草,似夏日的花……" << endl;
}
//此处略去美人类的Introduction()函数
};
按Ctrl + F9编译当前程序,我们会看Code::Blocks消息栏出现几条编译警告:
warning: `struct Person' has virtual functions but non-virtual destructor
意思是,Person类型拥有虚函数(Introduction),但它的析构函数却不是虚的。这是一个警告,暂时我们不理会它,运行该程序,观察xiaoA在被释放时,屏幕将输出什么内容?
没错,Person的析构函数现在不是虚的,所以对于xiaoA这样定义为Person但实例化为Beauty的对象,则在释放它时:
040 delete xiaoA;
该行的屏幕输出,将仍然只有基类的:“Wu~Wu~”。
〖重要〗:警告消息同样重要!
编译时出现warning类型的消息,通常表示程序仍然能完成编译,但是程序运行结果可能会有问题,比如本例中,程序虽然正常运行,但结果却不是我们想要的逻辑。
事实上,忽略warning是一个极其危险的行为,许许多的警告,往往都暗含着由于我们的一些不可忽略的疏忽,比如:本该是virtual的析构函数,却忘了加virtual修饰。
接下来,就让我们把基类Person的析构函数也修饰成为虚函数,同样地,出于一致编程风格,我们在~Beauty之前也加上virtual关键字。再次编译,运行。用了“virtual”,结果正确了,警告消息也没了……
3.10.3. 应用
“ 这个函数是虚拟的,请注意”——现在你一定能理解这句话的含意,因为我们已经尝试过两次了,事实证明一个函数“非虚拟”,或者是“虚拟”的,其间的差别真 的很大:如果不是虚拟的,那么出身决定一切;如果是,那么还可以后天努力。下面我们做一个有关virtual函数的实际应用。
在前面代码的基础上,我们继续修改main函数,目的是交互,让用户来决定一个人是不是美人。
这个例子不仅用到对象和虚函数,而且还将用上“条件分支/if”和“循环流程/while”等等我们已经接触过的内容。新内容则包括:在讲到while时,本例使用了break来打破死循环,以及使用continue用于直接进入下次循环。
除了getline可以读取一行字符串以外,我们还将在本例中学习到另一个读取输入的方法:流输入操作符/>> 。另外本例还将涉及cin.sync() 函数的使用。
int main()
{
041 while(true)
042 {
043 Person *someone;
045 cout << "请选择(1/2/3):" << endl
<< "1----普通人" << endl
<< "2----美人" << endl
048 << "3----退出" << endl;
050 int sel = 0;
051 cin >> sel;
053 if (cin.fail ())
054 {
055 cin.clear();
056 }
058 cin.sync();
060 if (3 == sel)
061 {
062 break;
063 }
065 if (1 == sel)
066 {
067 someone = new Person;
068 }
069 else if (2 == sel)
070 {
071 someone = new Beauty;
072 }
073 else //用户输入的,即不是1,也不是2,也不是3...
074 {
075 cout << "输入有误吧?请重新选择。" << endl;
076 continue;
077 }
cout << "请输入姓名:";
082 string name;
083 getline(cin, name);
084 someone->name = name;
cout << name << "的自我介绍:" << endl;
087 someone->Introduction();
089 delete someone;
090 }
092 return 0;
}
041行,我们再次看到while(true),这似乎又是一个死循环,因此Ctrl + C 对这个程序也有效。
043行,我们定义一个“Person”类别的堆变量:someone。这是一个关键,随后我们将根据用户的选择,来决定someone是普通人对象,还是美人对象。
045~048, 我们通过cout在屏幕上打印出一些提示信息,否则用户不知道输入什么。后面的代码的几处cout与此相似。唯一需要特别说明的,或许是这4行代码,实际 上只对应了一行C++语句。我们只使用了一个cout,并且也只有048行的行末有分号。我们故意折成几行,仅仅是为了让代码更美观,实际上这四行写成长 长的一行。
通过屏幕提示,用户现在知道可以输入1、2、3这三个数字来做出相应选择。050行我们定义了一个整数类型(int)变量sel,并初始化为0。然后紧接着下一行:
051 cin >> sel;
cin代表标准输入设备,也就是控制台。这些行代表示程序将停下来,等待用户从控制台通过键盘输入一个整数,程序将读入这个整数,并且保存到sel变量中。
在 此情况下,如果用户输入的不是数字,比如用户随便输入一个字符串:“abcd”,并按下回车,那么程序同样会结束等待(因为用户已经完成输入),但由于 “abcd”不是一个合法的整数,所以程序将出错。这种情况下,如果调用cin的成员函数fail ()这,将会返回“真”,表示cin现在处于“fail/失败”状态。
当cin处于“fail”状态时,所有对其的后续操作都会继续失败,除非程序调用它的clear()成员函数来清除“fail”状态。因此,053行~057行,是一个简单的判断,如果发现出错了,就清除cin的出错标志:
053 if (cin.fail ())
054 {
055 cin.clear();
056 }
058 行调用了cin.sync(),用以清除所有未读取的内容。假设用户输入的是1,那么通常用户需要按一个回车键以表示输入完成。此时,cin >> sel这一行读入了1,但回车键就会留在cin的缓冲区中。如果用户是随便地输入“abcd”,由于它们不是整数,cin没办法正确读入,这时候 “abcd”加上回车符全部滞留在cin的缓冲区中。如此,当后面我们要读取“姓名”时,这些滞留内容就会被读入,这显然不是我们的目的,所以此时调用 sync()非常必要。
--广告:本课程将于2009年出书,请关注www.d2school.com--
060行判断用户输入是不是整数3,如果是,表明用户要退出本程序。063行的break关键字用于完成此任务。break可以让程序跳出当前所处的循环,在本例子,程序跳出while循环之后,就落在main函数的最后一行(092行)。
065~077 行先判断sel值是不是1?是则实际创建一个Person对象;否则,继续判断sel值是不是2?是则实际创建一个Beauty对象;如果再不是,说明用 户输入的内容非法,程序将在屏幕上输出出错提示,并且通过conitnue关键字,直接继续下一次循环,即程序将要求用户重新输入。
082行定义了一个变量name,081行通过getline读入用户输入的姓名。之所以我们还是采用getline来读取用户输入,而不是这样:
cin >> name;
原因在于,>> 在读到空格时,就会认为本次读取完成。如此,当用户输入 “JingJing Guo”时,就会仅仅将“JingJing”读入,而漏掉空格之后的姓氏。
084行代码,将临时变量name的值,赋给当前someone->name。087行代码调用someone->Introduction(),完成自我介绍。
089行通过delete someone;“杀死”新创建出来的这个“人”。由于代码处于while循环中,因此程序将重复前述过程。注意,此时的delete操作显得非常重要,否则每次new出来的内存,都不会得到释放。
为了便于理解,我们给出一个完整的运行结果图,并加以注释。
(图 27 完整的多态版Hello OO运行结果)
3.10.4. 多态VS.非多态
如果您已经搞定前例的90多行代码,现在请考虑,假设该例子中不允许采用virtual函数,那么实现相同功能的代码,应该如何写?请大家动手实现,并思考C++的“多态”特性,在本例中带来哪些方便。
〖危险〗: 看懂代码 != 真懂代码
很多新学习者懒得动写代码——这差不多注定了他们不可能成为合格的程序员。又有些学习者对采用旧技术没有兴趣,他们说:“我都已经学会‘多态’了,何苦再用‘非多态’的方法写一遍呢?那多浪费时间啊”——这差不多注定了他们将成为对新知识一知半解的,半桶水程序员。