今天看公司代码,发现有一块代码很别扭。我思考了一下,感觉跟类的接口设计模式有关。在这里把思考过程贴出来分享一下。先给出一个暴论:类的成员函数越少越好。整个分析过程涉及:模块间交互的方式总结 和《effective c++》一书的 条款 23:使用 non-member、non-friend 函数替换 member 函数。
原始代码大概是这样的。有一棵树,每个节点中存放了孩子和父亲。
class TreeNode {
public:
void setParent(TreeNode *parent) { parent = parent}
void changeParent(TreeNode *newParent) { // 有问题的接口
setParent(newParent);
newParent->removeChild(this);
}
void onRemove();
void removeChild(TreeNode *child) {
children.erase(child); // 简写
child->onRemove();
}
private:
TreeNode *parent;
vector<TreeNode> children;
};
这里有问题的接口是 changeParent。,以调用 changeParent 的那个对象的视角来看,changeParent 干了如下几件事
- 把自己的父亲重新设置一下
- 调用父亲的removeChild 接口。
2.1 父亲的 removeChild 接口在自己的孩子列表中移除自己
2.2 父亲调用了自己的 onRemove 接口,
我认为 操作2 把自己传递给 parent 的 removeChild 接口这种事很傻很绕。但我又说不出个所以然来。
我从以下两个角度思考了这个问题
设计模式中的访问者模式
访问者模式就是把 visitor 传递给 host 对象,然后 visitor 会接受 被访问对象,并且操作 被访问对象。也是一个比较绕的操作,跟这种把 parent 传递给自己(changeParent(parent)
),然后自己又把自己传递给 parent (removeChild(this)
)这种方式类似。
但这里有一个区别:parent 和自己属于同一个抽象层次,都是 TreeNode。而访问者模式中 visitor 和被访问对象是不同的类。
关键是,我认为这种功能,完全可以通过静态函数来实现
class TreeNode {
static void changeParent(TreeNode* child, TreeNode* parent) {
parent->removeChild(child);
child->setParent(parent);
}
}
改成静态成员函数后更加直观。
模块交互的三种形式
想到访问这模式后,我又想到,模块之间进行数据交互有哪几种形式呢?我认为除了 IPC 外,在 client 视角下,只有如下三种形式:
- client 主动调用,获得返回数据。
完整的接口可以包括:返回值,入参、执行 flag、出参、异常值
Ret Foo_getReturn(Parms in, int flag, Parms& out, Error& err);
- client 主动调用,改变模块的整体状态
Ret Foo_setState(State s); // 设置某些运行行为
Ret Foo_init(); // 初始化,进入初始化完成的状态
- client 被动调用,通过注册回调函数、观察者、访问者,模块 Foo 需要 client 时会通过回调的方式来传递数据给 Client
Obj Foo_setObserver(Observer ob);
Obj Foo_setCallback(Callback cb);
Obj Foo_accept(Visitor vistor);// 设计模式中的很多行为模式都是这样
被动调用的使用要很谨慎,因为被动调用建立了反向依赖,使代码阅读更困难,更容易使人迷惑。被动调用就像双重否定表肯定一样,比较绕。有些时候能主动实现的话,尽量主动实现。就像你读:我不得不承认,他并不是不可靠
和 我得承认,他很可靠。
哪一句话更清楚是一目了然的。
但是如果有精巧的设计的话,也可以用。设计模式中的大部分行为模式都是这样相互依赖的,所以要熟悉设计模式。
《effective c++》: 条款 23:使用 non-member、non-friend 函数替换 member 函数。
看到这个条款后,我发现我的想法是正确的。确实应该把 changeParent 函数换成 静态方法。有以下两个原因
- 成员函数带来的封装性比非成员函数低。
- 非常成员函数不仅灵活性更高,而且最后的编译依赖度也更低。
书中举的例子如下
class WebBrowser{
public:
void clearCache();
void clearHistory();
void removeCookies();
// 如果想一整个执行所有这些动作
// 方式一:
// 在类中提供这样一个函数,调用上面三个函数
void clearEveryThing();
};
// 方式二:
// 也可以用一个non-member函数实现
void clearBrowser(WebBrowser& wb){
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
有一个网页浏览器类,其中有三个函数用于:清除缓存,清除历史记录,清除Cookies。现在有一个需求要清除浏览器内的全部内容。应该使用成员函数还是非成员函数?
封装
封装的本质是让内部实现不再可见。越多的东西被封装,越少的人可以看到它。越少的人看到它,我们就有越大的弹性去改变它。因为我们改变的东西,只会影响到看到它的人。
对于一个对象来说,越少的代码可以访问和修改数据,那么封装的数据越多,我们改变对象内的数据就越自由。如何测量有多少代码可以看到某一块数据呢?能够访问 private 数据的函数,只有 class 的成员函数和 friend 而已。所以在能实现相同功能的情况下,非成员函数的增加不会增加能够访问 private 数据的个数,因此不会改变 class 的封装性。而成员函数的增加会降低class 的封装性。从这个角度上来说,类的成员函数越少越好。
c++ 做法与优势
在c++中,可以把 clearBrowser 放在同 namespace 内。
namespace {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
}
clearBrowser 是一个提供便利的函数。即使不存在这个函数,用户也可以自己调用那三个清除函数,来实现相同的功能。换句话说,便利函数可以提供更高的包裹弹性。
一个像 WebBrowserStuff 这样的类,可能拥有大量的便利函数。某些与数千有关,某些与打印有关,还有一些与 cookie 管理有关。因为 namespace 可以跨文件。所以将这些相关的便利函数按照模块放在多个文件中,可以最大限度的减少编译依赖。