请假想一个表示网页浏览器的类。这个类可以提供诸多功能,其中包括清除下载缓存、清除访问历史、删除系统中保存的cookie等等:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
许多用户可能需要同时执行这些操作,所以WebBrowser类应该提供一个函数来做这件事情:
class WebBrowser {
public:
...
void clearEverything(); // 调用clearCache、clearHistory
// 以及removeCookies
...
};
当然,这一功能也可以通过使用一个非成员函数调用适当的成员函数来实现:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
哪一个更好呢?是成员函数clearEverything,还是非成员函数clearBrowser?
面向对象的基本原理要求数据和对其进行操作的函数应该被包装在一起,同时建议成员函数为更优秀的选择。但不幸的是,这一建议并不是正确的。它是建立在对“面向对象的东西意味着什么”这一点的误解之上的。面向对象的基本原理要求数据应该尽可能的被封装起来。通过理性分析可以得知,成员函数clearEverything的封装性实际上比非成员函数clearBrowser还要差。还有,非成员函数可以为WebBrowser相关的功能提供更便利的打包方法,从而减少编译时依赖,提高WebBrowser的可扩展性。很多情况下,非成员函数的方法都比成员函数的方法要好。理解这一结论的原因是十分重要的。
我们从封装问题开始。如果一个物件被封装了,那么它就是不可见的。它的封装度越高,其它人或物件能看到它的机会就越少。能看到它的东西越少,我们对其进行修改的灵活性就越高,因为只有很少的物件能看到我们的改动。也就是说,一个物件的封装度越高,系统赋予我们修改它的能力就越强。这就是我们为什么将封装置于首要位置的原因:他为我们提供了改变物件的灵活性的方法,使用这一方法只会有很少的客户会受到影响。
请考虑与对象相关的数据。可以看到(也就是访问)某一数据的代码越少,就有更多的数据被封装起来,从而我们修改这一对象的数据特征(比如数据成员的个数、类型等等)时就更为自由,粗略计算一下某一数据可以被多少代码所访问,我们就可以计算出有多少函数可以访问这一数据:可以访问它的函数越多,这一数据的封装度就越低。
条目22中解释了数据成员为什么要声明为私有的,因为如果不这么做,那么就有无穷的函数可以访问它们。它们就毫无封装性可言。对于声明为私有的数据成员来说,可以访问它们的函数的个数就等于成员函数的个数加上友元函数的个数,因为只有成员和友元可以访问类中的私有数据。无论是成员函数(不仅仅可以访问类中的私有数据,还可以访问私有函数、enum类型、由typedef生成的类型符号,等等)还是非成员非友元函数(上述成员函数可以访问的所有内容都不可访问)所提供的功能都是完全相同的,选择非成员非友元函数可以带来更完整的封装度,因为它不会使可以访问类中私有部分函数的数量增加。这就解释了为什么使用clearBrowser(非成员非友元函数)比clearEverything(成员函数)更理想:clearBrowser可以为WebBrowser类提供更高的封装度。
此刻我们需要注意两件事情。第一,上面的推理过程仅仅适用于非成员非友元函数。由于友元对类中的私有成员的访问权与成员函数相仿,因此使用友元对封装度带来的影响与成员函数是一致的。如果充分考虑封装的因素,我们并不是在成员或非成员函数之间做出选择,而是成员函数和非成员非友元函数之间做出选择。(当然,封装也不是唯一需要考虑的因素。条目24中将为你介绍,当问题转向“隐式类型转换”时,选择就在成员和非成员函数之间进行。)
需要关注的第二件事仅仅是由于:封装要求函数不应为类一个成员,而并不意味着要求它也不是其它类的成员。在某些语言中(比如Eiffel、Java、C#等等),所有函数必须包含在类中。对于习惯于使用这些语言的程序员来说,这第二件事多多少少可以算是对他们的一剂安抚药。比如说,我们可以将clearBrowser定义为某个“实用工具类”中的静态成员函数。只要它不是WebBrowser的一部分(或它的友元),它就不会影响到WebBrowser中私有成员的封装性。
在C++中可以使用一个更为自然的方法:将clearBrowser定义为一个非成员函数,并将其与WebBrowser放置在同一个名字空间中:
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
然而,你得到的东西远远要比更自然的代码要多。因为与类不一样的是,名字空间可以延伸至多个源代码文件中。这一点十分重要。ClearBrowser这样的函数是“便利函数”。由于它们既不是成员函数也不是友元,所以它在访问WebBrowser时就没有任何特权,从而它也就不能够以其它的什么办法提供WebBrowser的客户端代码所不具备的功能。举例说,如果clearBrowser不存在的话,那么客户就只能自己动手调用clearCache、clearHistory和removeCookies这些函数了。
一个类似WebBrowser的类可能会有大量的便利函数,一些是关于书签的,另一些是关于打印的,还有关于cookie管理的,等等。作为一个一般守则,大多数客户只会对这些便利函数中的一部分感兴趣。举例说,一个客户可能只对与书签相关的便利函数感兴趣,但是书签相关便利函数又依赖于cookie相关的便利函数,没有理由让这个程序员去关心那些额外信息。将着些便利函数分离开来的最直接的办法就是:将书签相关的便利函数声明在一个头文件中,cookie相关的为与另一个头文件,打印相关的在第三个:
// 头文件 "webbrowser.h" — 为WebBrowser自身所定义的头文件
// 同时也包含“核心的” WebBrowser相关的功能
namespace WebBrowserStuff {
class WebBrowser { ... };
... // “核心”相关功能
// 比如几乎所有客户所需的非成员函数
}
// 头文件 "webbrowserbookmarks.h"
namespace WebBrowserStuff {
... // 书签相关的便利函数
}
// 头文件 "webbrowsercookies.h"
namespace WebBrowserStuff {
... // cookie相关的便利函数
}
...
请注意:上面就是C++标准库的组织方式。标准库使用了多个头文件(包括<vector>、<algorithm>、<memory>等等),每一个都声明了std名字空间中的某一些功能。而不是使用单一的、庞大的<C++StandardLibrary>头文件,并将std中所有的功能都罗列于此。对于仅希望使用vector相关功能的客户,不应该强迫他们去#include <memory>;不希望使用list的客户端也不需要去#include <list>。这样就使得所有客户仅仅需要考虑他们正在使用的那部分系统中的编译依赖问题。(参见条目31,其中介绍了解决减缓编译依赖问题的其它途径。)让我们重新考虑成员函数的方案,以这种方式分开管理功能是不可行的,因为一个类必须要保证其完整性,它不能够被分割成块。
将所有的便利函数放置于多个头文件中(但位于同一个名字空间中),同时也意味着客户可以方便地扩展便利函数集。他们所需要做的仅仅是向同一名字空间中添加更多的非成员非友元函数。比如说,如果一个WebBrowser的客户希望编写一系列用于下载图片的便利函数,他或她仅仅需要在WebBrowserStuff名字空间中创建一个新的头文件来声明这些函数。新的函数与其它的便利函数一样可用,一样具有整合性。这是类无法提供的又一特性,因为类定义并不为客户提供扩展性。当然,客户可以派生新类,但是派生类仍无法访问基类中的封装(即私有)成员,所以我们说这样的“扩展功能”只有“二等”身份。同时,如同条目7中所讲,并不是所有的类都设计成了基类。
时刻牢记
l 多用非成员非友元函数,少用成员函数。这样做可以增强封装性,以及包装的灵活性和功能的扩展性。