自学编程,从此开始

上第2学堂,听有趣的编程课

课文: 《你好,对象!封装版(下)》 (点击查看完整内容:视频+评测+讨论+……)

作者:null

建立和维护不变式,要从娘胎里抓起!

课文题图

 

你好,对象!封装版(下)——基本应用

1. 不变式的更多知识

1.1 不变式在类设计上的体现

类型封装的重要目标,是为了建立并维护特定的不变式。不变式通常体现在以下设计中:

第一、所拥有成员数据是否合理?

成员数据可以是实体数据,也可以是逻辑数据。比如,汽车可以拥有“车门、车轮、车灯……”这样看到得摸得着,实实在在的数据,也可以拥有“速度、续航里程、保值……”这样的逻辑数据。

但无论如何,汽车不应该拥有“翅膀”或“飞行高度”这样的成员数据。如果拥有,不变式被破坏。车就不是车了。

第二、成员数据值是否合理?

汽车拥有车轮数据,很好,但车轮数量不应该是80对。如果有这么多车轮,不变式被破坏,汽车不再是汽车,可能是火车。汽车拥有速度数据,这也很好,同样,但速度的值,不应该是每秒760千米,否则,不变式被破坏,车不再是车,是超音速飞机。

第三、成员数据和成员数据之间的关系是否合理?

当数据“剩余油量”小于1公升,则数据“续航里程”不应大于100公里,否则,虽然很美好,但汽车的不变式被破坏了,车不再是车,是永动机。

第四、所提供的成员函数(方法)是否合理?

汽车不应该有“起飞”或“发射炮弹”的方法。至少民用的不应该有。

第五、成员函数之间的配合是否合理?

没错,操作和操作存在配合关系。你加油门,然后还要急转弯,那么,你一定是想让汽车“漂移”出去,如果这不是你的期望,那不怪车的设计,人家坚持了自己的不变式,只能怪你的车技不行。

第六、成员函数和成员数据之间的配合是否合理?

上一节课,我们就举了大量这方面的例子,比如:加油函数导车(转)速提升、刹车导致降速、方向盘向左打,导致行驶方向左转……有任一条不符合,不变式被破坏,车就不是车了,是杀人与自杀的好帮手。

1.2 不变式经常是一种“组合态”

正是视频课程所提到的迷语 “什么动物小时候四条腿走路,长大了两条腿走路,老了以后三条腿走路”,说的是人。因为人小时用爬的,长大了走,老了后柱着拐杖走。

迷语通常很有艺术性和趣味性,在严谨性方面就有欠缺。不过我们能猜到迷底,说明迷面的描述多少体现了人类在不同年龄状态下的不同形式。很多事物都很复杂,因此它们的不变式都不是简单的一句描述能全部涵盖;通常需要将不同状态下的多个不变式进行组合,才能得出这个事物相对合理的不变式。学习高中数学时,我们也经常有这样的描述:

  1. 当 x 值在 0到1区间,则y值……
  2. 当 x 值小于0……
  3. 当 x 值大于1……

事物的不变式是多态的、或组合的,所以类型的设计当然也不简单。幸好我们通常并不是要在代码中完完整整地重建一比一的现实世界,所以,类型的设计所要展现的对应事物的不变式,通常是有选择,有侧重、有取舍的

1.3 在初始化阶段建立不变式

我们今天的的关注重点,不在事物老了时,也不在事物长大时,甚至不是事物的小时候,我们要关注的是,是事物受精到出生的的那一段时间,我们认为,这时候就应该关注它的不变式了。

据说,人类由于源于海洋动物,所以在胚胎阶段,人类有尾巴。那么,是不是要在“Person/人类”这个结构体中,加上“尾巴”这么一个成员数据,这个结构体才合理的?人类的不变式才得以不被破坏?

当然不是这样!刚刚说过:

“类型的设计所要展现的对应事物的不变式,通常是有选择,有侧重、有取舍的”

设计一定要结合实际应用需求。比如视频课程中所说的,如果不限定特定的应用场景,仅仅就是一个数学概念上的坐标(并且不考虑小数精度),那么,以下结构就是一个非常简单而美好的二维坐标点设计:

struct Point
{
    int x;
    int y;
};

通过视频课程我们已经知道,由于该结构定义并没有指出该如何初始化成员数据(x和y),所以定义一个该类的一个局部对象,则对象内的x,y值将是不确定的(不确定的意思就是无论编译器还是人类,都无法事先准确知道它们的值)。完整测试代码如下:

//main.cpp  
#include <iostream>

using namespace std;

struct Point
{
    int x;
    int y;
};

int main()
{
      //定义一个局部(非全局)变量:
    Point pt;
    cout << pt.x << ", " << pt.y << endl;
}

在我的机器的某一次运行时,屏幕输出 “16, 0 ”。x是16来得莫明其妙,y是0看起来干净一点,但其实也莫明其妙。但这不是重点!重点是,哪怕Point所生成的对象数据的内部状态如此的任性,但它仍然维持了一个数学意义上的坐标点的不变式,只要x或y的值不是“你好”及“安妮”这样的字符串就好——我们确信这种事不会发生,因为int类型提供了强有力的“规矩”保障——不要小瞧这一点,在许多语言里,坐标值变成一堆字符串的情况说发生就发生——看起来很自由,但代价是很容易耗尽程序员近乎衰竭的脑力。

言归正传,如果将“坐标点“限定在枪支瞄准镜里的那一小块范围(假设横坐标和纵坐标都限定在[-5,5]之间),那么现在的Point设计就不合理了——尽管你可以迅速抹掉点对象生成之后那一小段黑历史,比如:

……
Point pt;
pt.x = 0; //大手一抹……
pt.y = 0; //同上
cout << pt.x << ", " << pt.y << endl;
……

你这样做也许并不会良心不安,但我会,所有参加过大型系统开发的程序员,都会。一来是参加过大型系统开发的程序员通常道德感都比较强烈(或者反过来,通常道德感强烈的人才有机会开发大型系统),二来,我们都知道当代码写多了时,很有可能就会忘记在一万次定义对象之后,某一次忘记做“大手一抹”的动作,于是一个从出生就坏了规矩(不变式)的对象,就这样被传递出去……

道德感其实没什么屁用,关键还是看制度,我们需要有一种“制度/语法”,来实现对结构的成员做合理的初始化。

2 在对象创建时,初始化其成员数据

2.1 让类型决定如何初始化

为了有所区分,我们使用 "AimPoint"表达瞄准镜需求下那个点结构定义。让它任何一个新对象在出生时,x和y就落在坐标原点是,是相当合理、简约、甚至美好的设计。而方法也很简单,并且有很多方法。

先看C++11新标准下的新方法:声明式成员数据初始化。

struct AimPoint
{
    int x = 0;
    int y = 0;
};

为什么要强调是“声明”式?

在《你好,对象!成员版》我们说过,结构类型定义中的每个成员数据,其实都只是“声明”,并不是定义了一个新结构就会在内存中开辟其成员数据所需要占用的空间,比如我们定义AimPoint这个结构(struct),并不会在程序运行时,造成两个整数数据的内存空间损耗。必须等到使用该类型(结构)定义一个实实在在的变量(对象)之后,该对象才真正拥有自己的成员数据。既然AimPoint结构中的x和y只是在声明:“这个类型的对象,将拥有名为x和y这样一前一后的两个int类型的数据。”。那么加上两处“ = 0 ”,当然也只是在描述:“这个类型的对象,将拥有名为x和y这样一前一后的两个int类型的数据,并且它们都会被初始化为0值。”

测试代码:

……
struct AimPoint
{
    int x = 0;
    int y = 0;
};

……
    AimPoint ap;
    cout << ap.x << "," << ap.y; //0,0
……

2.3 让调用者决定如何初始化

回到丁小明问候同事或被同事问候时所需要的“Person”类的定义:

struct Person
{
    string name;
    int age;
};

同样,Person类的临时对象,age将是随机的,可能是负数,也可能是一个大到不合理的数,比如9000,彭祖都没这么长寿。name的类型是string,string本身也一个类(来自标准库),所以它倒是会初始化内部的字符串内容为空串,但放在当前需求下,丁小明上班时遇到同事,但这个同事没有名字,显然也是不合理的。

如果使用“声明式”初始化,问题在于给name和age什么初值都不合理。在某些应用场景下,某一类的对象很难有一个大家都喜欢的默认初始状态,这很常见。这时候可以逼迫(请记住这个硬气的词)创建这个对象的人(程序员),在创建这个对象时就主动给出精准的初始数据。这种方法叫:通过“构造函数初始化”。具体做法是,提供一个构造函数,并且让这个构造函数必须拥有一些入参,然后在函数体中用这些入参的值,来初始化各个成员数据:

struct Person
{
    //蛋生版:
      Person(string const& name, int const age) // step1 :这个构造函数 要求 两个入参
    {
        //step2 :使用入参来初始化各个成员:
        this->name = name; 
        this->age = age;
    }

    string name;
    int age;
};

正如代码中的注释,重点就是以下两步:第一步:构造函数需要两个入参,一个是字符串,一个是整数。在视频中,二者的名字分别是nm 和 a,但在这里,我们使用表意更明确的 name 和 age 。这是推荐的一种写法(另一种推荐做法是入参加一个‘a’前缀,比如aname、aage),代价是它们和结构的成员数据正好重名。请看第二步:使用入参来分别初始化对应的成员数据。为了区分名字,我们使用“this->”来明确指出赋值操作中的左值是当前对象的成员,而右值则是入参——这也暗示了,在成员函数体中,该函数入参的名字优先权要大于成员数据。

如果你有看视频课程,应当知道我们对以上代码并不满意,而且至少要大致知道不满意的原因。如果你看得特别认真,那么应该能看到视频中把上面的方案,标示为“蛋生”。相比“胎生”,“蛋生”是生物上的一种比较低级的形式;举世闻名的生物大难题“先有鸡?先有蛋?”正源于此。在C++编程中,当一个进入其构造函数 ,所要创建的对象其实就已经存在了——以占用内存为标志——这就相当该对象已经生出来了。对应到本例,就是进入Person(……)构造函数时,对象就创建了,然后我们才尝试初始化它的name和age 。这就让对象的创建对象不变式的创建 之间产生了一个短暂的时间差。放在鸡的身上,那就是:生下来了,不过是一颗蛋;还得孵化一阵才能得到真正的鸡对象。尽管在程序执行时,这个过程不过是电花火石的一瞬间,但我们哪怕就有那么一瞬间的违反不变式,也不完美——实际上很多人写程序并不需要如此的“处女座”,相反他们看重的原因还有性能啊什么的追求——但听我的,别的程序员可以不严格要求自己,C++程序员一定要在各种小处严格要求自己。三国时期伟大的程序员刘备就曾经说过:“勿以恶小而为之,勿以善小而不为”。当然,举这个例子,我并没有自我嘲讽C++程序员都是阿斗的意思。

解决方法是进化成“胎生”:

struct Person
{
    //胎生版:
      Person(string const& name, int const age) // step1 :这个构造函数 要求 两个入参
        :  name(name), age(age) //step2 :使用入参来初始化各个成员
    {
    }

    string name;
    int age;
};

视频配音提到 “娘胎“ 的比喻 ,说了它的以下信息:

接下来又是视频中没有提到的重点:

一旦我们为一个类型提供了需带有参数的构造函数(称为“带参构造”),并且没有再刻意提供无参数的构造函数(称为默认构造或无参构造),那么想要使用这个类型创建一个对象,就只能调用当前已经存在的“带参构造 ”。这正是刚才提到“逼迫”对象创建者的含义,下面代码编译失败:

Person p;   //编译失败,因为当前Person结构的构造函数要求入参

而这正是我们想要的:逼迫Person对象的创建者,一定要为新对象提供初始化参数。因为我们认为,在当前应用场景下,一个Person的对象就是必须有名字和确切的年龄。比如:

Person p("王昭君", 16);   //编译成功,完美! 二八年华的美女

哪怕那家伙就是很神经病,非要传入一些不合理的参数,比如:

Person p2("李白乘舟将欲行,忽闻岸上踏歌声。", 752);  //编译也成功,但这是什么鬼?

我们以后还会学习碰上这么傻叉甚至是恶意地创建对象的家伙该如何对付,但哪怕我们什么也不做,我们也已经在道义上赢了对方。这很重要。通常技术经理在处罚时,会有道义上的考虑。如果你觉得道义不可靠,那你至少也可以做到一点:让对方的行为留下痕迹,比如,你可以在构造检查这些非法值,然后输出到屏幕,一旦发现,就截屏留下证据……(这样做有点LOW了就是……)

3. 成员访问控制

3.1 私有、保护、公开

语法上很简单,在类型中一个或多个成员(数据或函数)声明之前,加上你想要的“public:”、"protected: "或者“private: ”即可,注意以冒号结束 。三者的含义也很好理解:

3.2 成员默认开放级别

struct 的成员默认是公开的,比如:

struct SPoint 
{
    int x;
    int y;
};

……
    SPoint pt;
    pt.x = 100;
    pt.y = -100;

C++中还可以使用class来定义复合类型,和struct非常类似,但class成员的默认受私有访问控制:

struct CPoint
{
    int x;
    int y;
};

……
    CPoint pt;
    pt.x = 100;   //编译失败
    pt.y = -100;  //同上

struct(结构)和class(类)代表了中国文化史上的两种观念 :前者认为“人之初,性本善”,后者认为“人之初,性本恶”。所以前者默认开放自我,后者默认封闭自我。类似的行为差异还体现在二者的默认派生方式上。

3.3 默认派生方式

和“public、protected、private”对应,C++有三种派生方式,编程中主要使用第一种(public继承/公开继承)和第三种(private继承/私有继承)。

注意:以上提到的“改变”,并不影响原有成员数据在基类中的开放级别,而是特指派生类因继承而得到这些成员后,派生类是想保持基类的现有控制方式,还是要更加严格(或自私地)的加以控制?

例子:

struct Person
{
      string name;  
};

struct Beauty : Person
{
    int bust;
    int waist;
    int hips;
};

void test()
{
    Beauty b;
    b.name = "林字铃";   //编译通过 
}

英语好的同学可能已经在一脸花痴地想象……但重点是思考代码中特别注释编译通过的那一行背后的原理: b的name继承自基类Person。而Person中name的开放级别是(默认的)public,到了派生类仍然保持开放,所以可以在外部访问 。

换成class ,测试代码如下:

struct Person  //基类还是struct
{
      string name;  
};

class Beauty : Person  //派生类换成class啦
{
    int bust;   //这三个数据也仍然不是重点,请停止幻想。
    int waist;
    int hips;
};

void test()
{
    Beauty b;
    b.name = "林字铃";   //编译失败 
}

class若用作派生类,则默认觉得从基类得到的数据,哪怕原来是公开的,到我这里也要变成私有的,不让外部使用。这显得很“自私”——真实差异是在语义有重大区别,但《感受》篇不会谈这么“高深”的问题——大家就只当是为了不当一个自私的人吧,我们在程序员确实更多使用“公开继承”。想让class做到这一点,只需明确指定采用公开派生即可,方法是在定义派生关系时,在基类前面加上public:

……
class Beauty : public Person   //注意这一行的 public的位置:冒号之后,基类名称之前
{
     ……
};
……

struct当然也可以明确指定自己想要的派生方式 ,比如:

struct B
{
};

struct D1 : public B
{
};

struct D2 : private B
{
};