警告:您已进入C++面向对象编程的深水区。
多态:表面上看起来是一样的对象,调用表面上看起来是一样的方法,但在实际执行时,代码所展现的功能形态却不一样。
- 什么叫多态?
- 虚函数发挥作用的机制
- 虚析构函数发挥作用的机制
1. 什么是多态?
多态就是表面上看起来是一样的对象,调用表面上看起来是一样的方法,但在实际执行时,代码所展现的功能形态却是不一样的。
我们把一样称为“单”,不一样称为“多”。
“单”的目的,是为了让程序员写代码更简单,“多”的目的,是为了让更简单的代码可以实现更丰富的功能。
结合上一节课,我们现在有三条和编程基础原则:
- 相同的功能,我们希望使用同一份,同一处的代码实现——而不是到处复制粘贴;
- 不同的功能,我们希望通过不同位置上的代码加以实现——而不是混在一起写;
- 如果同一份,同一处代码,就是想要实现不同的功能,请用多态——而不是用一堆分支流程。
2. 课堂视频
完整听听老师怎么讲吧。
这一节会比较长哦,因为多态比较重要且几个知识点前后关系紧密,我们一口气学完效果更好。
ff15-HelloObject-多态版
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 操作,将:
- 调用对象的析构函数(用户定制或编译器默认生成的);
- 释放对象所占用的堆内存;
但上述代码的 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
}