条款23:宁以 non-member、non-friend替换member函数
这是C++设计的一个基本原则,主要目的是减少面向对象设计中的耦合,提高软件的内聚性和可复用性。non-member、non-friend函数可以不受类内部实现的影响,因此更加灵活和可复用。
想象有个 class 用来表示web浏览器。这样的 class 可能提供的众多函数中,有一些用来清除下载的类,这样一个类提供了清除下载缓存,清除URL访问历史,从系统中移除所有cookies接口:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
许多用户想将这些动作一块执行,所以web浏览器为此可以提供一个函数:
class WebBrowser {
public:
...
void clearEverything(); // calls clearCache, clearHistory,
// and removeCookies
...
};
当然,这个功能也可以通过非成员函数来提供,让它调用合适的成员函数就可以了:
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
哪种方法才是更好的呢?是成员函数clearEverying还是非成员函数clearBrower?
一、非成员、非友元函数更好的原因
面向对象准则指出数据以及操作数据的函数应该被捆绑到一起,这就表明它建议成员函数是更好的选择。不幸的是,这个建议是不正确的。它曲解了面向对象的含义。面向对象准则指出数据应该尽可能的被封装。违反直觉的是,成员函数clearEverything实际上并没有比非成员函数clearBrower有更好的封装性。并且提供非成员函数能够为web浏览器的相关功能提供更大的包装(packaging)灵活性,相应的,就可以产生更少的编译依赖和更好的可扩展性。因此非成员函数比成员函数在许多方面都要好。重要的是,我们需要知道原因。
1、非成员非友元封装性更好
以封装开始。如果一些东西被封装了,它就不可见了,被隐藏了。封装的东西越多,就有更少的客户能看到它们。更少的客户能看到它们就意味着我们有更大的灵活性来进行对它们进行修改,因为我们的修改直接影响的是能看到这些修改的客户。因此封装性越好,就赋予我们更大的能力来对其进行修改。这也是我们将封装摆在第一位的原因:它以一种只影响有限数量的客户的方式为我们修改东西提供了灵活性。
考虑同一个对象相关联的数据。看到这些数据的代码越少(也就是可访问它),数据就被封装的越好,我们就有更加自由的修改这个对象的数据的一些特征,例如改变成员变量的数量,类型等等。通过确认有多少代码能够看到数据来判断数据的封装性是粗粒度的方法,我们可以计算出能够访问数据的函数的数量,能访问的函数越多,封装性越拉。
(1)减少暴露内部实现
成员函数(特别是public和protected成员函数)是类接口的一部分,它们直接暴露了类内部的一部分实现细节。当你通过成员函数提供对类内部数据的访问或操作时,你实际上是在告诉类的使用者:“这是你可以做的事情,这是类内部的一部分”。相比之下,非成员函数可以通过接收类的实例作为参数来操作数据,而无需成为类接口的一部分。这样,类的内部实现细节就可以更好地被隐藏和保护起来。
(2)限制访问权限
虽然成员函数可以通过访问控制符(public、protected、private)来限制对类内部数据的访问,但非成员函数本身就不具备直接访问类私有成员的能力(除非它们是友元)。这意味着你可以通过非成员函数来提供对类数据的有限访问,而无需将这些数据暴露给类的所有使用者。这种限制访问的方式有助于减少误用和潜在的错误。
(3)降低耦合度
当类的成员函数依赖于其他类的内部实现时,这些类之间就会形成紧密的耦合关系。如果将来需要修改这些内部实现,就可能会影响到依赖于它们的成员函数。相比之下,非成员函数可以更容易地与类的实现细节解耦,因为它们不依赖于类的内部状态。这样,当类的实现发生变化时,使用这些非成员函数的代码通常不需要进行修改。
(4)促进模块化
2、用非成员非友元可以减少编译依赖
在c++中,一个更加自然的方法是使clearBrower成为同WebBrowser有相同namespace(命名空间)的非成员函数:
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
然而这不仅仅是看起来更加自然,因为命名空间不像类,它是可以跨文件的。这是很重要的,因为像clearBrower这样的函数是很便利的函数。既不是成员也不是友元,对WebBrower类没有特殊访问权,因此它不能提供WebBrowser客户没有获取到的其他任何功能。举个例子,如果clearBrower这个函数不存在,客户只好自己调用clearCache,clearHistory,和removeCookies。
一个像webBrower这样的类可以有大量的便利函数,一些和标签相关,另一些和打印相关还有一些和cookie管理相关等等。通常大多数客户只对其中的一部分有兴趣。没有理由让只对标签便利函数感兴趣的客户编译依赖于cookie相关的便利函数。将它们分开的直接的方法是将它们声明在不同的头文件中。
// 头文件“webbrowser.h”
namespace WebBrowserStuff {
class WebBrowser { ... };
... // 核心机能,如所有用户都要用到
}
// 头文件“webbrowserbookmarks.h”
namespace WebBrowserStuff {
... // 书签相关的便利函数
}
// 头文件“webbrowsercookies.h”
namespace WebBrowserStuff {
... // cookie相关的便利函数
}
注意标准C++库就是这么组织的。它并没有在std命名空间中将所有东西包含在一个单一的<C++ Stand Library>头文件中,而是有许多头文件(,,等等),每个头文件声明了std命名空间中的一部分功能。只使用vector相关功能客户不需要#include ;不需要使用list的客户不必#include 。这就允许客户只编译依赖于它们实际用到的部分。(条款31中讨论了减少编译依赖的其他方法)。以此种方式切割机能并不适用于 class 成员函数,因为一个 class 必须整体定义不能被分割为片片段。
通过将功能分散到非成员函数中,你可以更容易地将代码组织成更小的、更可管理的模块。这些模块可以独立地进行编译和测试,从而提高了代码的可维护性和可扩展性。此外,非成员函数还可以为相关的功能提供更大的包装灵活性,使得你可以更容易地为特定的用例定制解决方案。
3、非成员非友元有更好的扩充性
将所有的便利函数放在不同的头文件内,但隶属同一个命名空间,同样意味着客户可以很容易的对便利函数进行扩展。他们需要做的是向命名空间中添加更多的非成员非友元函数。举个例子,如果一个WebBrower客户决定实现图片下载相关的便利函数,他只需要创建一个头文件,在命名空间WebBrowserStuff中将这些函数进行声明。新函数能像旧的函数一样同它们整合在一起。这也是类不能提供的另外一个性质,因为客户是不能对类定义进行扩展的。当然,客户可以派生出新类,但派生类没有权限访问基类的封装成员(像private成员),这样的“扩展功能”就是二等身份。此外,正如条款7中解释的,并不是所有类都被设计成基类。
二、方法论
- 如果某个函数与一个类密切相关,但不需要访问类的任何private或protected成员,那么考虑将其作为非成员函数或者非友元函数实现。
- 如果需要修改类的行为,但不应该修改类对象的状态,考虑使用const成员函数或者non-const的non-member、non-friend函数。
- 如果需要访问类的私有成员,考虑将函数声明为该类的友元。
- 如果需要在类内部实现一些简单的操作,考虑将其实现为inline成员函数。
三、总结
宁可拿非成员 非友元函数替换成员函数。这样做可以增加封装性、包裹弹性和机能扩充性。