加载中...
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 多态版
第17节:Hello Object 封装版 - 上
第18节:Hello Object 封装版 - 下
课文封面

一步步教你练习 “选美大赛海选报名程序”,从需求到设计再到实现,完整体验,同时边学边用以下知识点:

  1. 转换构造、复制构造、转发构造
  2. 特定成员函数默认实现
  3. 成员数据初始化列表
  4. explicit
  5. 静态局部数据
  6. 自增操作

0. 需求与基本设计

需求与设计

场景:选美海选阶段报名程序

1、选手都以普通人身份报名,要求填写姓名、性别、年龄、身高、体重;
2、按规则评测选手,如淘汰,需生成原因;
3、通过的选手,升级为美女类型,并填写三围数据;
4、生成评测结果,并由选手依据结果,发表参赛感言,需包括落选或胜选说明,同时,美女需公示三围数据。

可能的淘汰原因
①:淘汰不是女性的选手
②:淘汰年龄不在 18~38 之间的选手
③:淘汰身高不在 151~189 cm 之间的选手
④:淘汰体重不在 35~85 kg 之间的选手
⑤:淘汰 BMI 指数不在理想范围(18.5~24)的选手

BMI 计算公式

体重(公斤)÷ 身高(米)的平方。其中,体重精确到公斤,身高精确厘米,录入时均不考虑小数。

简要思路

1、定义一个 “取号机” 类,用于实现为每个选手分配惟一的参赛号码。
2、Person 类定义:对象构造时,录入海选所需数据:姓名、性别(0女,1男)、年龄、身高、体重等;
3、Beauty 类派生自 Person,并且必须以现有的Person对象为入参,实现选手在胜选后,从“普通人”转换(升级)为“美女”的过程,在此过程中,输入美女的三围数据。
4、提供名为 “SignUp()” 的自由函数,实现报名,该函数返回一个 Person 类的堆对象。
5、提供名为 “Check(Person* p)” 的自由函数,对入参 p 指向的选手逐项检查,胜选按第3点升级为 Beauty 对象(同样是堆对象)。
6、Check() 函数返回一个特定结果,需带:a) 是否落选,b) 选手(Person或Beauty),c) 说明(落选原因或胜选评价)。
7、胜选评价仅是客套话,共设置 5 个评价(如:“貌若天仙”、“倾国倾城”等 ),以选手的号码为自变量,依序分配。

1. 开胃菜:取号机

通过自增,实现号码惟一,借助 “静态局部变量”,实现仅能在报名函数内使用唯一的一台取号机。

1.1 静态局部变量

  • 局部变量

函数内的变量称为“局部变量”,意为:它们仅在函数内(空间)以及函数的当前执行过程(时间)中生效。如:

struct Test { Test() { cout << "Test生" << endl; } ~Test() { cout << "Test死" << endl; } }; void test() { Test t; } int main() { test(); }

上述代码中,test() 函数内的 t ,无法在该函数外面访问(空间)。并且,每一次调用 test(),它都会重新构造(生),然后析构(死)。

  • 静态局部变量

如果在函数内为变量加上 static 关键字修饰,则该变量称为静态局部变量,它仍然只能于所在的函数内被访问,但它只会被构造一次,并且需等到整个程序的退出过程,才被析构。

请同学自行动手对比:

struct Test { /* 保持原有定义不变 */ }; void test() { static Test t; // 加上 static } int main() ... // 保持原有实现不变

1.2 NumberMachine 定义

取号机类型命名为 NumberMachine,其类定义如下:

class NumberMachine // 取号机 { public: int Inc() // 取号方法 { // 号码增1,然后返回 return ++no; } private: int no = 0; // 号码初始为 0 };

其中:

  1. “int no = 0” 称为 “成员默认初始化”。即,在定义一个结构(或类)的成员数据时,直接为它设定初始值;
  2. “++no” 称为“前置自增”。可实现整数 no 的值自增,即原来为0,则 ++no 后,no变成 1,如再执行则变成 2……

1.3 演示视频:取号机

2. 普通人的构造

2.1 类定义及相关知识点

Person(普通人)对象在构造过程将输入姓名、性别、年龄、身高、体重;唯 “号码” 一项,由外部传入,因此,Person 类的构造函数需要带一个整数入参:

struct Person { Person(int no); // 此处的 no 为入参,是由外部的取号机生成的唯一号码 int no; // 此处的 no 为 特定 Person 对象拥有的参赛编号。 ... // 其它成员数据 };

成员数据初始化列表” 是构造函数特有的语法,用于实现当前类的成员能在“第一时间”(数据分配内存时)就能高效地得到初始化。

  • 成员函数初始化列表

写在构造函数头和体之间,先写一个冒号,而后接待初始化的成员数据,再接一对圆括号,括号内是用作初始化的值,通常就是当前构造函数的某个入参。Person类只有 no 这么一个成员数据需要由外部传入的值初始化,因此可写为:

struct Person { Person(int no) : no(no) // 成员数据初始化列表 { // 这里由用户输入其它项数据 } };

示例的初始化列表中,成员数据的名字和入参的名字重名(都是 no),但编译器在此可自动区分出二者各自身份。

如果有多个成员数据需要初始化,则需使用逗号分隔(本课后续马上会遇到)。另,成员数据初始化次序,最好和它们在类内部定义时的次序保持一致,以避免复杂情况下,因成员数据初始化之间存在依赖关系而出现问题。

Person 的构造函数需要录入多个数据,代码长,写在 Person 类定义内部容易影响对该类代码的整体阅读。此时,我们可将类的成员函数(包括构造、析构函数)写在类定义之外。在类外定义成员函数时,需要在函数之前加上类名和“::”,而类定义内部,仅留对应的函数声明。

先看类定义:

class Person { public: Person(int no); // 只留声明 ... private: int no; std::string name; int age; ... };

再看在外部定义的构造函数:

// 注意:Person:: 前缀 Person::Person(int no) : no(no) // 成员数据初始化列表 { std::cout << no << " 号选手,请填写基本信息\n"; std::cout << "姓名:"; std::getline(std::cin, name); std::cout << "性别(0女,1男):"; std::cin >> gender; …… }
  • 特定成员函数的默认实现

在本例中,Person 是基类,且它和派生类间存在多态行为,因此需要将它的析构设置为虚析构;不过,在本例中 Person 类又没有需要定制析构行为(派生类 Beauty 也一样),此时,我们可以使用 C++11 提供的新语法:特定成员函数(主要是构造、析构、赋值等)的默认实现,以本例所需的析构为例,写法如下:

class Person { public: ... virtual ~Person() = default; // = default ... };

2.2 演示视频:Person类基本定义

3. 报名函数 / SignUp()

3.1 SignUp() 函数

有了取号机 NumberMachine 和 Person 类的定义,报名函数的实现显得特别简单:

Person* SignUp() { // 取号机 static NumberMachine nm; // 注意 static std::cout << "\n【报名】\n"; // 关键步骤提示,仅为方便用户清楚当前操作 int no = nm.Inc(); // 取号 return new Person(no); // 构造 Person 对象,需填写基本信息 }

当我们使用 new 创建出一个堆对象后,可以将它赋值给一个临时变量,比如 p,然后 return p;也可以像上面的代码一样,直接 return new XXX ……。

通过 new ,报名函数返回的是 Person 的对象。为什么要使用堆对象,后续在谈到海选评测时,请大家重点关注其评测结果的类型定义。

3.2 演示视频:选美报名函数

4. 赛后感言的分析、设计与实现

4.1 业务流程分析

在写更多代码之前,有必要看一眼完整的业务过程:

主要流程

可见,不管落选还是胜选,选手都需要发表感言。胜者已从普通人升级成美女身份,而落选的人保持原来的身份。反过来说就是:美女对象发表的感言一定是胜选感言,普通人对象发表的感言一定是落选感言

依据需求,胜选感言和落选感言有相同之处:选手都需要如实说出自己的基础数据(身高、体重等 );也有不同:胜选者说得比较开心,落选者说得比较伤心,另外,胜选者还要说出自己的三围数据。

4.2 赛后感言设计

某个行为,派生类和基类有相同之处,也有不同之处。相同的地方,我们可以使用继承来获得,不同的地方,我们可以使用多态来支持。这就是派生和多态的配合使用,是实际项目中高频出现的场景。结合本例,有如下设计需要注意:

我们会把部分相同的逻辑,独立成一个函数,称为 “AboutMe()”,即 “关于”,用以得到一个选手自我介绍的基本信息。这个函数是虚函数,因为对 Person (基类)而言,它需要输出选手的基础数据,对 Beauty(派生类)而言,它将先利用“继承”,调用基类的 AboutMe()以得到自我介绍的基本内容,然后再加上对三围数据的介绍。

派生类的某个成员函数,要调用基类的同名函数,需要加上基类的类名和“::”。比如,Beauty类的AboutMe() 部分代码就是长这样子:

// 派生类的 AboutMe() 实现示例 std::string Beauty::AboutMe() const override { // 先调用基类的同名函数,以获得 Person 的自我介绍内容 // 注意 Person:: 前缀 std::string s = Person::AboutMe(); s += ... // 这边加上美女选手才有的三围数据 return s; }

另外,出于强烈的“自我约束”意愿。AboutMe() 被我们限定为受保护的级别(protected)。因为:

  1. 它无需在类(Person 或 Beauty)调用,所以不需要设为公开的(public);
  2. 同时,派生类需要调用基类版本的 AboutMe() ,所以它也不能是私有的(private)。

实际感言函数则由另一个名为 “Speech()” 的成员方法实现,它先调用本类的 AboutMe() 以输出基本信息,然后再输出落选或胜选的说明。Speech() 需要一个入参,用来表达评测结果的附加说明,对于基类,它是落选原因,对于派生类,它是评委给的一个客套词。由于这个附加说明只是用来输出,所以使用 std::string_view 类型。

  • Person 类赛后感言代码示意
// 基类的赛后演讲(肯定是落选感言) void virtual Person::Speech(std::string_view desc)const { cout << AboutMe() ... // 先介绍基本信息 << "呜呜~,我落选了……因为 " << desc // 心情有些悲伤... }
  • Beauty 类赛后感言代码示意
void Beauty::Speech(std::string_view desc)const override { cout << AboutMe() ... // 先介绍基本信息 << "哇哦!,我胜选了……评委夸我 " << desc // 心情很happy... }

以上代码仅是示意,具体的实现请看演示视频。

请一定掌握这个非常实用的,C++编程惯用法,重点了解 “继承” 和 “多态” 的配合。

4.3 演示视频:赛后感言——基类的实现

4.4 演示视频:赛后感言——派生类的实现

5. 升级:从Person对象到Beauty对象

5.1 题外话:代码体现人品

工作中,要懂得经常换位思考。

假设现在你以前也是程序员,赚钱后创业当上一家电商公司老板。年底面向客户举办抽奖活动。规定今年累计消费1万元以上客户,可以参加本次抽奖(你有10万个用户,其中年度累计消费过万的用户为1000个)。

系统由公司程序员小A负责,快上线时,出于职业习惯,身为老板的你粗略审查了下代码,发现代码中存在零条件构建出一名合格的抽奖用户的功能……你感到有些不安……

这时,人事主管过来向你汇报:这个 小A 在公司上次的内部抽奖iPhone活动中,曾经偷偷修改代码,从而让自己的中奖率远大于其他同事。你更加不安了……

言归正传,依据需求,我们这次选美比赛,一个“美女选手”,只能由一名已存在的普通人选手“升级”而来——落到设计,就是:Beauty 类有且只能有一个构造函数,并且该构造函数必须以一个 Person 对象作为入参。

瓜田不纳履,李下不正冠,和非亲密关系异性独处一室,勿门窗紧闭……

请深刻理解并在职业生涯中坚持南老师今天教你的这种严格的设计,你将更有机会获得上司的信任——不仅是技术上的,也是人品上的。

5.2 转换构造函数

将一个 Person 对象 “升级” 为一个 Beauty 对象,可视将一种类型的对象“转换”为另一种类型的特例。

转换构造函数的特征是:a.该构造函数有且仅有一个入参(称为“源”);b.且该入参的类型,和当前类型不一样;c.构造过程中通常仅读取而不修改“源”对象。

按如上标准,我们前面就遇到过一个转换构造,一个将整数转换成“人”的构造函数:

Person::Person(int no) // a.惟一入参,b.且类型不同 : no(no) // c.只读 { ... }

有了这个转换构造,相当于告诉编译器一件事:给我一个整数,我能“变出”一个“人”。于是,编译器允许我们写出如下代码:

Person p = 1984; // 在表达什么?
  • 显式转换构造:explicit

一个 “人” 等于一千九百八十四?这样的代码阅读起来容易有歧义,事实上复杂情况下还会有更奇怪的隐式转换发生。因此,除非你明确知道它就是你想要的(某个类型的对象可以隐式地转换成另一种类型的对象),否则通常我们为此类转换构造,加上 explicit 修饰,实现只允许通过明确的构造函数调用,来实现转换:

class Person { public: explicit Person(int no); // 加上 explicit ... }; void test() { Person p(1); // 正确 Person p = 1; // 错误,编译失败 };

回到“升级”,虽然我们确实存在将 Person 对象升级到 Beauty 对象的业务需求,但下面这种写法也并非必要:

Person p(1); // 已有的普通人对象 Beauty b = p; // 使用 = 升级(其实背后调用了构造过程),很 cool,但非必要

所以,我们一样为该转换构造加上 explicit ,让它成为一个只可以显式调用的构造:

class Beauty { public: explicit Beauty(Person const& p); // 从Person到Beauty的显式构造 private: };

explicit Person(int no) 略有不同,当转换构造的入参类型也是复合类型(struct 或 class)时,我们传递的是源对象的引用,以避免复制整个源对象,但又为了保证前述的特征 c,我们为它加上 const 让它成为常量,从而确保不会在构造函数中被有意无意地修改。

转换构造的最后一个重点是:C++程序中的不少“转换”和现实生活中的“转换”含义并不完全一样。现实生活中,你把一块生肉“转换”成一块熏肉,作为“源”的生肉将不复存在,而C++程序中的A转换成B,结果通常得到B,但A还在。

重点

因为转换并不会自动删除源对象,所以在本例中,当选手升级后,我们一边得到 Beauty 对象,一边要记得主动 delete 原来的 Person 对象。

5.3 复制构造函数

我们已经知道,从 Person 对 Beauty 的转换构造函数长什么样子,但它应该如何实现呢?这里又有一个C++的设计“套路”。

首先,我们会把派生类对象,在且仅在内存结构上视为其头部嵌有一个基类对象,以Person对象为例:

派生类对象内存结构示意

基于这一结构,不难产生两种想法:

  1. 要将一个基类对象升级为一个派生类对象,可以先把基类对象的值(图中绿色部分),复制到新对象头部的那个“伪”基类对象上去;然后派生类的构造仅需处理自己独有的那部分数据(图中粉色部分);
  2. 派生类可以先构造出自己“头”部的那个“伪”基类对象……

为了满足第1点,我们需要先能将一个 Person 对象(的内部值)复制给另一个 Person 对象。听起来也是一个“转换构造”,但 “源”和“目标” 的类型完全相同,何来“转换”?只有“复制”。因此此类构造又有一个新名字: “复制构造函数”。

又由于很早以前,英文 “Copy” 被音译为 “拷贝”,于是不少像我这样的编程老人,也习惯称它为 “拷贝构造函数”。

以 Person 为例,它的复制构造函数可定义如下:

// Person 类型的拷贝构造 Person (Person const & o) //o: other : name(o.name), gender(o.gender), age(o.age), height(o.height), weight(o.weight) { }

其中一大串的 “成员数据初始化列表”我们在前面刚解释过。

想一想

(a) 复制构造函数也是单一参数,为什么我们没加 explicit 修饰?
(b) 为什么在成员数据初始化列表中,可以访问 o 的私有数据?

为了让派生类可以显式调用基类 Person 的复制构造,我们又有一个新语法:转发构造。它的写法和“成员数据初始化列表” 乍一看,有点像:

explicit Beauty::Beauty(Person const& p) : Person(p) // <-- 这里显式调用Person的复制构造 { // 这里可以让用户输入三围了 std::cout << "胸腰臀三围数据(cm,空格间隔):"; std::cin >> bust >> waist >> hip; }

5.4 演示视频:从Person对象构造出Beauty对象

注:视频标题为 (五),实为 (六)

6. 准备结果

6.1 Result 结构

我们使用以下结构定义海选评选结果:

// 评测结果(评测报告) struct Result { bool lost; // 是否失败 Person const* contestant; // 指向选手 std::string desc; // 说明(落选:原因,胜选:评价) };

contestant 是一个指针,且你不能通过它去修改,它所指向的选手对象(的内容)。所以这是一个指向常量的指针。毕竟,都已经出结果了,怎么还可以修改选手数据呢? (参见 本课堂 5.1 小节关于人品的讨论)。

6.2 结果的“工厂方法”

选手有各种原因落选,比如性别不对、年龄太小或太大,太高或太矮、太胖或太瘦……也就是说,我们需要花样百出地构造 Result 对象。为了让代码简洁,可以为 Result 添加多个构造函数。不过,在C++中,对于 Result 这样纯结构(所有数据都对外开放),更简捷,更有扩展性,同时也更直观的做法是:写特定的自由函数来组装它——仿佛工厂组装各种配置的产品——这种专门用来生产拥有不同配置的对象的函数,称作 “工厂方法”。

先看如何生产出一个表示落选的结果:

Result OnLost (Person const* p, std::string_view desc) { std::cout << p->GetNo() << " 号选手落选\n"; Result r; r.lost = true; r.contestand = p; r.desc = desc; return r; }

相比名字无法改变的构造函数,工厂方法之所以更直观,很大原因是它可以有合适的名字,比如这里的 OnLost(),以及我们很快就会有的 OnWin()

这个工厂方法其实是先“生”出一个 Result,然后再一个个设置它的成员数据。C++11后,更简捷的写法是:

return Result { true, p, std::string(desc) }; // 聚合初始化,注意是花括号

其中的 desc ,因为我们拥有的是一个 std::string_view,但 Result 实质需要的是一个 std::string,而 C++ 标准库的作者们,在这个地方,和本课的精髓不谋而合:他们也不想让一个 string_view 对象,可以 “偷偷地”,“自动地”,“隐式地” 转换成一个 string 对象。也就是说,他们肯定在 std::string (std::string_view sv) 这个转换构造身上加了 explicit 修饰。结果就是:上面代码中,我们必须 “明确地”,“手动地”,“显式地” 以 desc 为入参,调用 std::string 的构造。

这种写法称为 “复合对象的聚合初始化”,在本例中,它甚至还可以继续简化。因为 OnLost() 函数已经明确返回结果是 Result,所以在 return 时可以不再写结果类型:

return { true, p , std::string(desc) }; // Result 被省略

再看如何生产出一个表示胜选的结果:

// 通过时,升级选手,并加上 “客套话”,返回升级后的结果 Result OnWin (Person const* p, std::string_view desc) { std::cout << p->GetNo() << " 号选手胜选"; // 升级: Beauty * b = new Beauty(*p); delete p; // “杀死”原来身份数据 return { false, r.contestand, std::string(desc) }; // 聚合初始化 }

6.3 生成评价

我敢说,每个软件系统中都会一些没有实质作用的功能……本例中的 “评价” 就是这样的一个设计。

// 生成胜选评价 (利用选手编号,从一组评价中选出一个) std::string MakePraise(int no) { // % 是求余数操作(也称求模 mod) // 显然:任何整数除以5的余数,只能是 0,1,2,3,4 int index = no % 5; if (index == 0) { return "天生丽质"; } if (index == 1) { return "貌若天仙"; } if (index == 2) { return "倾国倾城"; } if (index == 3) { return "国色天香"; } return "秀外慧中"; // 最后一个就不用再判断了 }

6.4 演示视频:准备结果

7. 检测函数

7.1 Check() 函数

评测函数当然是本项目中最核心的业务,不过,在完成前面的代码后,它显得特别简单。

// 海选检测函数(通过的选手将升级) Result Check(Person const * p) { std::cout << "\n【检测】\n"; std::cout << p->GetNo() << " 号选手正在接受检测……\n"; if (p->GetGender() != 0) { return OnLost(p, "不是女性"); } std::cout << "\t>性别合规\n"; if (p->GetAge() < 18) { return OnLost(p, "未满十八"); } if (p->GetAge() > 38) { return OnLost(p, "年龄超标"); } std::cout << "\t年龄合规\n"; if (p->GetHeight() < 151){ return OnLost(p, "身高略逊"); } if (p->GetHeight() > 189) { return OnLost(p, "身高超标"); } std::cout << "\t>身高合规\n"; if (p->GetWeight() < 35) { return OnLost(p, "体重过轻"); } if (p->GetWeight() > 85) {return OnLost(p, "过于丰盈"); } std::cout << "\t>体重合规\n"; double m = p->GetHeight() / 100.0, bmi = p->GetWeight() / (m * m); if (bmi < 18.5) { return OnLost(p, "BMI指数过低"); } if (bmi > 24) { return OnLost(p, "BMI指数过高"); } std::cout << "\t>BMI达标\n"; std::string desc = MakePraise(p->GetNo()); // 生成评价(客套话) return OnWin(p, desc); }

计算 BMI 指数,需要先将身高从厘米转为米,因此需要除以 100。身高是整数,假设你的身高 173 ,除以 100 后,将得到 1 米而不是 1.73米。这是本函数唯一需要注意的地方:请把除数写成 100.0 ,这样编译就会知道本次计算需要在意小数,于是它会将参与计算的其它精度较低的数值(比如整数),先全部提升为 double (双精度浮点数)类型,再参与计算。

7.2 演示视频:评测函数

8.主函数

8.1 main() 函数

用主函数把以上逻辑与实现串起来。注意这是一个死循环的主函数,用户可不断地输入新报名的选手信息,然后评测,得到结果,展现结果……

对了,还有赞助商广告——多么真实的一个实践项目啊。

int main() { while(true) { Result r = Check(SignUp()); std::cout << "\n【感言】\n"; std::cout << r.contestant->GetNo() << " 号选手发表参赛感言\n"; r.contestant->Speech(r.desc); // 赛事广告展现 std::cout << "\n~感谢第2学堂赞助本赛事~\n~自学编程,从此开始~\n\n"; // 释放选手 delete r.contestant; std::cin.ignore(99, '\n'); } }

8.2 演示函数:主函数