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
};
其中:
- “int no = 0” 称为 “成员默认初始化”。即,在定义一个结构(或类)的成员数据时,直接为它设定初始值;
- “++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)。因为:
- 它无需在类(Person 或 Beauty)调用,所以不需要设为公开的(public);
- 同时,派生类需要调用基类版本的 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点,我们需要先能将一个 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');
}
}