欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 养生 > C++感受13-Hello Object 多态版

C++感受13-Hello Object 多态版

2025/2/2 19:25:09 来源:https://blog.csdn.net/nanyu/article/details/140614032  浏览:    关键词:C++感受13-Hello Object 多态版

警告:您已进入C++面向对象编程的深水区。

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

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

 

1. 什么是多态?

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

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

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

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

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

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 操作,将:

  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
}

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com