3.11. Hello object 封装版
小A做某事,成功了;小B做另一件事,失败了。分析其间的差别,无非两种可能:一是小A比小B牛,犯的错误少;二是小A做的事比小B做的事简单。
可见要做成一件事,第一要尽量让事情简单,第二尽量让人少犯错误。
除了个别世界观有问题的以外,多数编程语言都希望通过使用它,事情能变得简单些,程序员能变得强一些。
C++ 也如此。前面提到的“派生”、“多态”,这二者让C++语言可以更加逼真地表达现实问题。因此可以认为,“派生”及“多态”是“OO世界”中两样锋利的武 器,程序员拿着它们驰骋疆场,杀戮无数问题。显然,这是C++让事情变得简单的重要方法。紧接着,我们又要提到了“封装”,C++通过“封装”,允许将重 要数据的封闭起来,避免重要数据被错误地直接使用。 它是一项让程序员尽量少犯错误的重要方法。
〖轻松一刻〗:“封装”究竟封装了什么?
“封装”这项技术,表面上它保护的是代码中的数据,其实它是一件坚硬的盔甲,保护住的是程序员一颗容易犯错的脑袋瓜,还有程序员一颗脆弱的心。
3.11.1. 类型即封装
其实,当我们通过struct来定义一个新的数据类型时,就是一种“封装”。
struct Person
{
string name;
};
想像我们还想关注“人类”的年龄信息,那么我们可以Person类再添加一个成员数据。
struct Person
{
string name;
int age;
};
看,和“人”有关的数据,被我们统一包装在Person这个类型信息中。假设我们要在程序中管理到2个人,那么使用Person这个类型,代码如下:
Person xiaA;
Person zhiLing;
2个人,对应定义两个对象,直观,轻松。如果不使用自定义类型来封装人的信息,代码该如何写呢?
string nameOfXiaA;
string nameOfZhiLing;
int ageOfXiaoA;
int ageOfZhiLing;
是 不是有一种所有数据被“拆散”得“支离破碎”的感觉?如果有,恭喜你,说明你具备程序员的气质;如果没有,也恭喜你,说明你拥有机器一般的超强能力的大 脑。 不过,当前Person只有两个信息,如果是10个呢?人脑一定会记不过来。所以,定义一个拥有复合信息的类型,本身就是一种封装。
不过,C++还有更强劲的一层封装,那就是,一个(复杂)数据类型,可以用来设置访问类成员的权限。
3.11.2. 私有、保护、公开
C++允许对自定义类型(struct和class)中的成员(数据、函数)及嵌套类型,指定三种访问权限:公开、保护、私有,对应的关键字是:public、protected、private。
-
- 私有/private:只允许在该类型内部进行访问。
- 保护/protected:允许在该类型,以及其派生类系内部进行访问。
- 公开/public:允许在该类型及派生类系内部访问,也允许在类型外部进行访问。
为 什么有区分“公开”、“私有”数据呢?这其实也是对真实社会的一种对应。C++的访问规则是针对“类型”而言,为了直观,我们暂时先混淆一下“对象”与“ 类”的区别。比如“人类”有一个“胃”,“胃”数据是典型的“私有”数据。在人的内部,食管、直接连着胃,神经、肌肉控制着胃,但在人的外部,你会允许把 他人把一个苹果直接塞进你的胃吗?我们需要一个“吃东西”的成员函数,那么,“吃东西”就是一个public的成员。
〖轻松一刻〗:“胃”真的是“私有数据”吗?
那时——医生把长长的胃镜导管强行从我嘴里塞到我胃里,医生很兴奋地指着屏幕说:看,看,这就是你的贲门……我眼泪哗的下来了——就在那时,我突然意识到,胃应该是人类的私有数据,只不过在医生面前,它被暴力破解了。C++也一样,当你只为图一时方便,想把本应private的数据设置成public时,你应该去医院感受一下胃镜。
不谈胃了,医学我实在了解太少,还是谈社会学吧:Person有一个数据:name。我们来考虑一下,对于人类来说,“姓名”应该是“private”,还是“public”呢?
“当然是公开(public)的!”有些人略作思索,“名字就是要给别人叫的,如果是私有的,那人类取名做何用?”——这个回答似是而非。
如果“姓名”成为“人类”的公开数据,那就意味着在类就外部可以直接修改一个人的姓名。没错,我们其实一直在这么做的,复习一下曾经的代码:
int main()
{
003 Person xiaoA;
004 xiaoA.name = "Xiao A";
……
}
我们在main函数里——而不是在Person的内部——通过004行代码,直接修改了xiaoA的名字。认真地考虑现实生活中,最普遍的情况是:名字应该在出生时设定,然后一辈子都不再被修改。C++是如何实现此类逻辑呢?
除了“私有”与“公开”之外,C++还提供夹在二者之间的“保护”访问权限。采用“保护”访问权限的成员,允许在类内部,以及派生类内部访问,而不允许在其它范围内访问。
3.11.3. 语法
struct Person
{
public:
/*
此处成员的访问权限为“公开”;
*/
protected:
/*
引处成员的访问权限为“保护”
*/
private:
/*
此处成员的访问权限为“私有”
*/
};
在类型定义中,加入访问权限关键字,并以“:”结束。其后所定义的成员,都采用该访问权限,直接到有新的访问权限关键字。
〖小提示〗:代码排版风格
1) 本书推荐把public等关键字与 struct 及一对花括号的起始列对齐。
2) 本书推荐以public、protected、private 的次序安排成员。
在本章之前,我们从未使用“public、protected、private”三者之一,但我们的代码一直可以工作,这是因为对于“struct”,如果不提供访问权限关键字,则默认采用“public”修饰。也就是说:
struct Person
{
virtual void Introduction();
string name;
};
等同于:
struct Person
{
public:
virtual void Introduction();
string name;
};
3.11.4. 应用
新建一个控制台应用项目,命名为“HelloOOEncapsulation”。复制上一小节“HelloOOVirtual” 项目main.cpp的最后代码内容,取代新项目的main.cpp的内容。如有必要,记得将main.cpp的文件编码改为“系统默认”。
首先将Person类型改造成“封装”版。
struct Person
{
008 public:
Person()
{
cout << "Wa~Wa~" << endl;
}
virtual ~Person()
{
cout << "Wu~Wu~" << endl;
}
virtual void Introduction()
{
cout << "Hi, my name is " << name << "." << endl;
}
023 private:
std::string name;
};
区别就在于008行和023行分别添加的“公开”和“私有”的访问权限标志符。
按Ctrl + F9,编译代码……出错了。
(图 28 有关访问权限出错的编译信息)
g++的编译信息在这方面提示得非常明了:24行的 name 成员是“私有”的,但是36行和85行的代码,都试图在Person类之外访问。
双击出错信息中具体某一行,会跳出对应行号的代码。
请先查看“24”行,现在,在派生类(Beauty)中访问基类(Person)的name成员,是非法的了。因为“私有”成员,只允许在当前类中访问,“保护”或“公开”的成员,才允许在派生类中访问。
再跳到“85”行,这回是在main函数内访问,现在,只有“公开”的成员才能满足了。可惜name成员已经被我们限定为Person的私有成员。
这两处的出错代码,还有一样不同,第24行代码要“读”姓名,而85行代码则试图修改姓名。
如何解决这两个问题?我们来为Person提供一个公开的成员函数,专门用来返回name。
struct Person
{
public:
//此处略去构造函数与析构函数
019 string GetName()
020 {
021 return name;
022 }
virtual void Introduction()
…
private:
std::string name;
};
019~022行,我们为Person类,定义一个名为GetName的成员函数。该函数的返回结果类型为“string”类型,事实上它返回的内容是成员数据:name的值。
接下来,我们修改原036行,现041行处的代码,原来直接访问name,现在改为通过GetName()函数。
struct Beauty : public Person
{
virtual ~Beauty()
…
virtual void Introduction()
{
041 cout << "大家好,我是美女: " << GetName() << ",请多多关照!" << endl;
}
};
再 次编译,编译出错消息少了一半。余的那一个,就是试图在main函数中修改一个新生儿的姓名的问题了。关于这个问题,有两种考虑方案。其一是“宽松型”, 即认为人出生后,可以先不要有名字,并且可以外部修改;其二是“严格型”,即认为人人出生后,就应及时起名,并且不允许外部修改。为了体现“封装”特性, 我们采用第二种。所以我们的做法是,将设置名字这个过程,移到Person的构造函数中去。
struct Person
{
public:
Person()
{
cout << "Wa~Wa~" << endl;
cout << "为这个哇哇哭的人,起个名字吧:";
getline(cin, name);
}
//略去后面代码...
}
现 在,构造Person对象时,就会被要求输入姓名。构造Beauty的对象呢?由于派生类的构造函数会自动调用基类的默认构造函数,所以这个过程也将在美 女出生时起作用。如此,在main函数中输入姓名的代码就多余了,我们将它删除掉。另外,在调用自我介绍之前用于提示的那行代码,需要稍做修改。最终完整 代码如下:
#include <iostream>
#include <string>
using namespace std;
struct Person
{
public:
Person()
{
cout << "Wa~Wa~" << endl;
cout << "为这个哇哇哭的人,起个名字吧:";
getline(cin, name);
}
virtual ~Person()
{
cout << "Wu~Wu~" << endl;
}
string GetName()
{
return name;
}
virtual void Introduction()
{
cout << "Hi, my name is " << name << "." << endl;
}
private:
std::string name;
};
struct Beauty : public Person
{
virtual ~Beauty()
{
cout << "wu~wu~人生似蚍蜉、似朝露;似秋天的草,似夏日的花……" << endl;
}
virtual void Introduction()
{
cout << "大家好,我是美女: " << GetName() << ",请多多关照!" << endl;
}
};
int main()
{
while(true)
{
Person *someone;
cout << "请选择(1/2/3):" << endl
<< "1----普通人" << endl
<< "2----美人" << endl
<< "3----退出" << endl;
int sel = 0;
cin >> sel;
if (cin.fail())
{
cin.clear();
}
cin.sync();
if (3 == sel)
{
break;
}
if (1 == sel)
{
someone = new Person;
}
else if (2 == sel)
{
someone = new Beauty;
}
else //用户输入的,即不是1,也不是2,也不是3...
{
cout << "输入有误吧?请重新选择。" << endl;
continue;
}
cout << someone->GetName() << "的自我介绍:" << endl;
someone->Introduction();
delete someone;
}
return 0;
}
3.11.5. 常量成员函数
再次考虑一下“封装”,“封装”的目的之一是为了避免外界对类成员非必要的“访问”。而“访问数据”又可以分成两个操作:“读取数据”和“修改数据”(或者合称为“读写数据”)。
会有些数据,我们既不希望外部“修改”它,也不希望外部“读取”它——比如你的私房钱,你既不想让老婆知道,更不想它被没收。
会有些数据,我们允许外部“读取”它,但不允许外部“修改”它;——比如你们的新婚照,老同学来了,或许你会很开心地影集拿出来供大家“欣赏”,但若有不识相哥们想拿一张回去,并且准备将它PS一下,你一定会严辞拒绝。
至于,允许外部“修改”它,却不允许外部“读取”的它的情况比较少见。
相比两件事:“防止被读取”和“防止被修改”,显然“防止被修改”这件事更为重要。C++也在这方面提供技术保障,允许我们更加关注:“成员数据”是不是被修改了?技术之一就是“常量成员函数”。
请看以下修改过的代码:
struct Person
{
public:
//……
string GetName()
{
name = "王二麻子";
return name;
}
//……
}
天啊!成员函数GetName()的本意是读出成员数据name的值,但现在发生了什么?也许是程序员失恋了,也许是程序员喝醉了,也许是二者兼而有之,总之这哥们在代码直接修改了name于是造成了天下人只有一个名字。
C++认为,虽然成员函数,统统允许“访问”成员数据(因为它们同属一个类),但是仍有必要限定某些成员函数只允许“读取”成员数据,而不允许“修改”成员数据。这样的函数,就被称为“常量成员函数”。
定义常量成员函数,只需要函数体开始之前,加入const关键字。比如:
struct Person
{
public:
//……
string GetName() const
{
name = "王二麻子";
return name;
}
//……
}
〖课堂作业〗:感受“常量成员函数”
请打开项目,然后先在GetName()函数中添加将名字设置为“王二麻子”的代码,然后编译,你会发现编译成功了。然后再如上将GetName()改为常量版本,再次编译,这时编译将失败,请按F2查看编译出错信息,理解,并解决问题。
依据“常量成员数据”的定义,不难理解,一个常量成员数据如果要调用类中的其它成员数据,那么要求被调用的函数必须同样是“常量成员函数”。