加载中...
Hello Object 多态版
第1节:初学C++,应该学什么?
第2节:《白话C++》练的什么“功”?
第3节:《白话C++》练的什么“武”?
第4节:打开浏览器,线上玩转C++
第5节:逐字逐句,深入理解C++最小例程
第6节:做一个“会”犯错误的程序员
第7节:Hello World 中文版
第8节:Hello World 函数版
第9节:Hello World 交互版
第10节:Hello World 分支版
第11节:Hello World 循环版
第12节:Hello Object 生死版.上
第13节:Hello Object 生死版. 下
第14节:Hello Object 成员版
第15节:Hello Object 派生版
第16节:Hello Object 多态版
课文封面

多态就是表面上看起来是一样的对象,调用表面上看起来是一样的方法,但在实际执行时,代码所展现的功能形态却不一样。

  1. 什么叫多态?
  2. 虚函数发挥作用的机制
  3. 虚析构函数发挥作用的机制

1. 什么是多态?

多态就是表面上看起来是一样的对象,调用表面上看起来是一样的方法,但在实际执行时,代码所展现的功能形态却是不一样的。

我们把一样称为“单”,不一样称为“多”。

“单”的目的,是为了让程序员写代码更简单,“多”的目的,是为了让更简单的代码可以实现更丰富的功能。

结合上一节课,我们现在有三条和编程基础原则:

  1. 相同的功能,我们希望使用同一份,同一处的代码实现——而不是到处复制粘贴;
  2. 不同的功能,我们希望通过不同位置上的代码加以实现——而不是混在一起写;
  3. 如果同一份,同一处代码,就是想要实现不同的功能,请用多态——而不是用一堆分支流程。

2. 课堂视频

3. 声明类型、实现类型、空指针

上节课,我们学习了:基类做基类的事,派生类做派生类的事——事实上,这是更早之前,我们所学习的 “类型即约束” 的又一体现。

C++是静态(类型)语言,因此定义一个变量(或称对象),总是要马上给它一个类型,这就造成了一个对象所能具备的功能,会在“出生”(定义)时——也就是我们写代码时就确定了。

这显得太不灵活了,并且也违反我们的日常认知。我们总是认为,一个人所能具备的功能,应该既受它的类型(基因)影响,也会因后天因素而改变。

比如 Person 和 Beauty 的例子,善良的我们总是觉得,应该在写代码时,让变量一开始都是 Person, 而后再依据情况来确定,这个变量是继续当 Person,还是成为 Beauty。

堆对象可以拥有两块独立且又有“指向”关联的内存,这真是“冥冥之中”的安排:两块内存,不就可以为它们设置两种不同的类型吗?

当然,也不能是完全不相关的两种类型,最典型的应用,就是栈内存(本质是个指针)的类型设定为基类,而堆内存的类型设定为派生类。于是就有:

基类* p = new 派生类;

套用到我们的例子:

Person* p = new Beauty;

我们把指针一开始声明的类型,称为对象的“声明类型”,而实际指向的类型,称为对象的“实际类型”。本例中,对象 p 的声明类型是 Person,实际类型是 Beauty。

除了占用两段内存以外,指针变量的初始化过程显然也是两段式的,一是定义它是什么类型,二是指向实际的数据。之前我们都是在定义指针变量时,直接让它指向新建的某个对象,但事实上,指针可以在一开始时,什么都不指向,方法是让它指向0,或指向 NULL符号(该符号在C++中最终也被定义为 0),或指向nullptr。

nullptr是C++11标准引入的,是 null - pointer 的缩写,意为“空指针”:

Person* p = nullptr;

现在,p 是一个空指针——什么也没指向,所以它宛如白纸一张,充满了无限可能……

4. 虚函数

4.1 语法与作用

上例中, p 的声明类型和实际类型不一致,为方便表达,我们称这种情况为 “名实不一”。

在名实不一的情况下,有些语言直接就使用实际类型来约束对象,C++则非常“实在”地,默认使用代码中,该对象一开始声明的类型来约束它。因此,下面的代码中实际调用的,仍然是基类Person的Introduce()。

Person* p = new Beauty; p->Introduce(); // "大家好,我叫XXX"

想要让p能依据自己的实际类型,来作自我介绍,需让 Introduce() 成为虚函数。方法的第一步是:在基类中,将 Introduce() 加上 virtual 关键字的修饰。

struct Person { /* 构造与析构,略 */ virtual void Introduce() { // 基类自我介绍的实现,略 } string name; };

方法的第二步是,在派生类中实现一模一样(名字、入参列表、返回类型)的方法,并在函数头和函数体之间,加上 override 关键字修饰。

struct Beauty : Person { void Introduce() override { // 派生类自我介绍的实现,略 } };

现在,以下代码最终走的,就是派生类(Beauty)自身版本的 Introduce() 方法了。

Person* p = new Beauty; p->Introduce(); // "大家好,我是美女XXX,想得到大家的多多关照哦"

如课堂视频所说,派生类在函数标注上,总共有 4 种做法:

方法 派生类加注关键字 基本效果 示例 优点缺点
无为而治 不加标注 你虚我就虚,你实我就实 void Introduce() 写法简单省心,但派生类代码不利阅读
心怀二意 加注 virtual 你虚我也虚,你实我还虚 virtual void Introduce() 知道是虚的,但不知道什么时候变成虚的
忠心耿耿 加注 override 你虚我传颂,你实我罢工 void Introduce() override 既知道是虚的,又确信是从基类就开始虚的
多此一举 又加virtual又加override 和“忠心耿耿”法完全一样 virtual void Introduce() override 纯脱裤子放屁……

4.2 虚析构函数

假设 Introduce() 从基类 Person 起就已经是虚函数,以下代码:

Person* p = new Beauty; p->Introduce(); // (1) 虚函数发挥作用! delete p; // (2) 只调用了 Person 的析构函数。

我们已经知道,对一个指针对象调用 delete 操作,将:

  1. 调用对象的析构函数(用户定制或编译器默认生成的);
  2. 释放对象所占用的堆内存;

但上述代码的 delete 操作,如(2)所注释所说 delete p 只调用了它的声明类型 Person 所定义的析构;没有调用它的实际类型 Beauty 的析构函数。

有同学会说:Beauty 类本来也没有析构函数啊?
Beauty 类没有我们(程序员)自定义的析构函数,但有编译器默认生成的析构函数。

不过,为了观察方便,我们还是来为 Beauty 定制一款析构,一款充满文艺范的析构:

struct Beauty : Person { ~Beauty() { cout << "人生似蚍蜉,似朝露;似秋天的草,似夏日的花……" << endl; } void Introduce override { /*略*/ } };

大家一定要按课堂视频的内容,亲手实测,验证当前的代码中的 delete p 并不会引发 ~Beauty() 被调用。

解决办法也简单,让析构函数也成为虚的:

struct Person { virtual ~Person() {/*略*/} // 基类:虚析构 }; struct Beauty : Person { ~Beauty() override { /*略*/ } // 派生类,覆盖基类 };

虚析构函数既是虚函数,也是析构函数,所以,现在的代码中,派生类的析构函数“覆盖/override”了基类的析构函数,但并不影响当我们 delete 派生类对象—— 或者名为基类但实际指向派生类 的对象时的“拆楼”工程:先调用派生类的析构,再调用基类的析构。因此,当我们 delete p 时,你将最终既看到 “……夏日的花……”,又将听到 “哇哇~”。

4.3 隐秘的内存泄漏

假设 ~Person() 方法不是虚的,那么以下代码可能此发一种隐藏得很好的内存泄漏:

Person* p = new Beauty; delete p;

如前所述,delete 需要完成的第二个操作是:释放对象所占用的堆内存。

现在对象存在“名实不一”,而当前析构函数非虚,于是析构过程将从基类开始。注意,这将不仅造成派生类的析构函数未被调用,它将造成内存释放也从基类开始,结果就是:派生类额外占用的内存,将未被释放。

幸好,我们的派生类 Beauty 一直只拥有继承自基类的 name 属性——它会被释放——而没有自己数据,因此并没有实际泄漏内存——没有额外占用内存,何来泄漏内存?

当然,这可不是我们写有可能泄漏内存的代码的理由。干脆,反正后面的课堂里也要用到,我们就为 Beauty 加上一些成员数据吧:

// 派生类 struct Beauty : public Person { ~Beauty() override { cout << "人生似蚍蜉,似朝露,似秋天的草,似夏日的花……" << endl; } void Introduce() override { cout << "大家好,我是美女" << name << ",想得到大家的多多关照哦~" << endl; } int bust; int waist; int hips; };

丁小明同学举手问:“老师,bust 、waist、hips 都有多大啊?假如发生内存泄漏的话……”

他的后半句话还没说完,就已经被老师我请出教室了!丁小明你太流氓了!咱课堂上还有不少女同学呢!

5. 虚函数测试案例

完整代码如下:

#include <iostream> #include <string> using namespace std; // 基类 struct Person { Person() { cout << "哇哇~" << endl; } virtual ~Person() { cout << "呜呜~" << endl; } // 虚析构 virtual void Introduce() // 自我介绍,虚函数 { cout << "大家好,我叫:" << name << endl; } string name; }; // 派生类 struct Beauty : public Person { ~Beauty() override { cout << "人生似蚍蜉,似朝露,似秋天的草,似夏日的花……" << endl; } void Introduce() override { cout << "大家好,我是美女" << name << ",想得到大家的多多关照哦~" << endl; } int bust; int waist; int hips; }; int main() { Person * p = nullptr; cout << "请输入命运的数字:"; int fortune = 0; cin >> fortune; if (fortune == 9999) { p = new Beauty; p->name = "幸运的小美"; } else { p = new Person; p->name = "大春"; } cout << "开始你的自我介绍:" << endl; p->Introduce(); delete p; }

6. 综合案例

6.1 输入整数并判断正误

如前一案例,输入一个整数可以直接使用:

cin >> 整数变量;

问题难在出错情况:用户可能输入的根本不是整数,比如输入了一堆’a’、‘b’、'c’之类的字母,甚至输入的是汉字……

解决方法是检查输入流对象 cin 的状态。输入流的 fail() 方法如果返回 true,表明它已经进入失败状态。在失败状态下,cin 什么做不了,除了清除状态:

cin.clear();

但是,在明明有误的情况下,如果只是调用 clear() 来清除失败状态,岂不是掩而盗铃?我们还应该将所有有问题的输入,都忽略掉,因此,按理代码应是:

if (cin.fail()) // 输入有误 { cin.clear(); cin.ignore(...); // “吃”(忽略)掉所有出错的输入内容 }

既:出错后,再清除状态且忽略出错的输入内容。不过,正确写法确是:

if (cin.fail()) // 输入有误 { cin.clear(); } cin.ignore(...) // 忽略,具体参数暂略

这是因为,C++接收控制台(以及Linux下的终端等)的输入,以“换行”为触发。比如用户要输入一个整数 9999, 实际他需要在输入 9999 之后,再按下回车键,程序才能读到 9999,此时输入缓存区中,还有一个换行符。我们要把这个换行符也忽略掉——哪怕用户前面的输入一切正常。

在我们当前这个例程中,后面还需要读入名字,采用的是我们熟悉的 getline()操作。和 cin >> sel 读取整数但只读到回车换行符(或者空格等非数字的分隔符)对比, getline() 会主动读到且包括回车换行,因此它不会在输入缓存区中留下那个换行符。

ignore 可以完全不带参数调用:

cin.ignore();

此时它就只忽略一个字符,用于处理本例中用户规规矩矩输入一个整数的情况是可行的,但无法处理用户输入一堆字母,比如输入的是 “qewwerwerweruytru”的情况。因此,我们的策略是:让cin一直读输入缓存区,并且读一个就抛弃(“吃掉”)一个,直到以下情况再停止:

  • 缓存区里没有残留的字符了
  • 读到了一个换行符(在C/C++代码中,用 ‘\n’ 表示换行符)
  • 读了 99 个字符,上面的两种情况还是没有成立

具体代码就是:

cin.ignore(99, '\n');

这行代码作用很棒:用户规矩输入,它就只需读一个字符就发现是 ‘\n’;用户如果不规矩输入,它就能一直读,只要错误字符不超过 99 个,都能处理。

当然可以让代码更加健壮,那就是用个特殊的常量数值来取代这里的 99:

#include <limits> constexpr auto max_input_size = std::numeric_limits<std::streamsize>::max(); ... cin.ignore(max_input_size, '\n');

max_input_size将是C++程序所能一次性读取到的,以及输入缓存区最大能存储的字符个数。

6.2 综合案例的完整代码

我们把 max_input_size 也用上,现在完整代码如下。

#include <iostream> #include <string> #include <limits> using namespace std; // 基类 struct Person { Person() { cout << "哇哇~" << endl; } virtual ~Person() { cout << "呜呜~" << endl; } // 虚析构 virtual void Introduce() // 自我介绍,虚函数 { cout << "大家好,我叫:" << name << endl; } string name; }; // 派生类 struct Beauty : public Person { ~Beauty() override { cout << "人生似蚍蜉,似朝露,似秋天的草,似夏日的花……" << endl; } void Introduce() override { cout << "大家好,我是美女" << name << ",想得到大家的多多关照哦~" << endl; } int bust; int waist; int hips; }; constexpr auto max_input_size = std::numeric_limits<std::streamsize>::max(); int main() { while(true) { Person* someone = nullptr; // 菜单 cout << "请选择(1 / 2 / 3 )\n" << "1 - 普通人\n" << "2 - 美人\n" << "3 - 退出" << endl; int sel = 0; cin >> sel; // 接收用户输入的整数 if (cin.fail()) // 是否处于失败状态(比如用户输入的不是整数) { cin.clear(); // 清除失败状态 } cin.ignore(max_input_size, '\n'); //查找并跳过换行符 if (sel == 3) // 千万别写成 sel = 3 { break; } if (sel == 1) { someone = new Person; } else if (sel == 2) { someone = new Beauty; } if (someone == nullptr) { cout << "输的什么鬼?重来!" << endl; } else { // 有人诞生了哦!在此统一做以下行为: // 1、输入姓名,2、自我介绍,3、释放 cout << "=========\n"; cout << ">>>>你的姓名:"; getline(cin, someone->name); cout << ">>>>" << someone->name << ",开始你的自我介绍吧" << endl; someone->Introduce(); cout << "=========\n"; delete someone; } } // 结束 while }