2.2 孤儿、孤儿的收养以及代用品
现在让我们深入研究一下在我们的文件系统中运用COMPOSITE模式可能会得到什么样的结果。我们首先考察在设计Node类的接口时必须采取的一个重要折中,接着会尝试给刚诞生的设计增加一些新功能。
我们使用了COMPOSITE模式来构成文件系统的主干。这个模式向我们展示了如何用面向对象的方法来表示层级文件系统的基本特征。这种模式通过继承和组合来将它的关键参与者(Component、Composite及Leaf类)联系在一起,从而支持任意大小和复杂度的文件系统结构。它同时使客户能够以统一的方式来处理文件和目录(以及文件系统中可能出现的任何其他东西)。
正如我们已经看到的那样,统一性的关键在于为文件系统中的对象提供一个共同的接口。到目前为止我们的设计中已经有了三种对象类:Node、File和Directory。我们已经解释了需要在Node基类中定义那些对文件和目录都有明确意义的操作。用来获取和设置节点的名字和保护属性的操作就属于这一类。我们还解释了,虽然有一个用来访问子节点的操作(getChild)乍一看对File对象并不合适,但为什么我们仍然需要把它放在共同的接口中。现在让我们来考虑其他一些看上去更没有什么共通之处的操作。
※ ※ ※
小孩子们是从哪里来的?虽然这听起来像是一个早熟的5岁小孩问的问题,但是我们仍然需要知道。(我猜在任何场合下这都是个不错的问题。)在一个Directory对象能够枚举它的子节点之前,它必须通过某种方式获得子节点。但是从哪里获得呢?
显然不是从它自己身上。把一个目录可能包含的每个子节点创建出来不应该是目录的责任,这样的事情应该由文件系统的用户来控制。让文件系统的用户来创建文件和目录并把它们放到相应的地方,这才是比较合理的做法。这意味着Directory对象将会收养(adopt)子节点,而不是创建子节点。因此,Directory需要一个接口来收养子节点。类似下面的接口就可以:
virtual void adopt(Node* child);
当客户代码调用一个目录对象的adopt函数时,就等于是明确地把管理这个子节点的责任转交给该目录对象。责任意味着所有权:当一个目录对象被删除的时候,这个子节点也应该被删除。这就是Directory和Node类之间(在图2-2中用菱形表示)的聚合关系的本质。
现在,如果客户代码可以让一个目录对象承担管理一个子节点的责任,那么应该还有一个函数来解除(relinquish)这份责任。因此我们还需要另外一个接口:
virtual void orphan(Node* child);
在这里“orphan”并不意味着它的父目录已经死了——被删除了,它只不过意味着该目录对象不再是这个子节点的父目录。这个子节点仍将继续存在,也许它马上就会被另一个节点收养,也许它会被删除。
这和统一性有什么关系?为什么我们不能把这些操作只定义在Directory中?
好吧,假设我们就是这样定义的。现在考虑一下客户代码如何实现改变文件系统结构的操作。一个用来创建新目录的用户级命令就是此类客户代码的一个例子。这个命令的用户界面无关紧要,我们可以假设它只不过是一个命令行界面,类似Unix的mkdir命令。mkdir有一个参数,用来传入待创建目录的名字,如下面所示:
mkdir newsubdir
事实上,用户可以在名字前面加上任何有效的路径。
mkdir subdirA/subdirB/newsubdir
只要subdirA和subdirB已经存在而且是目录而不是文件,那么这条命令就应该能够正确执行。更概括地说,subdirA和subdirB应该是Node子类的实例,而且可以有子节点。如果这一点不成立,那么用户应该得到一条错误消息。
我们怎么实现mkdir呢?首先,我们假设mkdir能够找出当前的目录是什么,也就是说它能得到一个与用户的当前目录相对应的Directory对象②。给当前目录增加一个新目录只不过是小事一桩:先创建一个Directory实例,然后调用当前目录对象的adopt函数,并将新目录作为参数传入。
Directory* current;
// ...
current->adopt(new Directory("newsubdir"));
就是这么简单。但一般情况下传给mkdir的不仅仅只是一个名字,而是一个路径,我们应该怎样处理这种情况呢?
事情从这里开始变得有些棘手了。mkdir必须
(1)找到subdirA对象(若该对象不存在则报告一个错误);
(2)找到subdirB对象(若该对象不存在则报告一个错误);
(3)让subdirB收养newsubdir对象。
第1点和第2点涉及对当前目录的子节点进行遍历,以及对subdirA(如果它存在的话)的子节点进行遍历,其目的是为了找到代表subdirB的节点。
在mkdir实现的内部,可能会有一个递归函数,该函数以路径作为它的参数。
void Client::mkdir (Directory* current, const string& path) {
string subpath = subpath(path);
if (subpath.empty()) {
current->adopt(new Directory(path));
} else {
string name = head(path);
Node* child = find(name, current);
if (child) {
mkdir(child, subpath);
} else {
cerr << name << " nonexistent." << endl;
}
}
}
这里head和subpath是字符串处理例程。head返回路径中的第一个名字,而subpath则返回剩余的部分。find操作在一个目录中根据指定的名字查找对应的子节点。
Node* Client::find (const string& name, Directory* current) {
Node* child = 0;
for (int i=0; child = current->getChild(); ++i) {
if (name == child->getName()) {
return child;
}
}
return 0;
}
值得注意的是,由于getChild返回的是Node,因此find也必须返回Node。这并没有什么不合理的地方,因为子节点既可以是一个Directory也可以是一个File。但是如果仔细地阅读代码,就会发现这个小小的细节对Client::mkdir有着致命的影响——Client::mkdir是无法通过编译的。
让我们再看一下对mkdir的递归调用。传给它的是Node,而不是所需的Directory。问题在于,当我们深入访问文件系统的层级时,我们并不知道一个子节点到底是文件还是目录。一般来说,只要客户代码不关心这种区别,这就是一件好事。但在目前的情况下,看起来我们确实需要关心这种区别,因为只有Directory才定义了用来收养子节点和遗弃子节点的接口。
但我们真地需要关心这一点吗?或者更进一步说,客户代码(mkdir命令)需要关心这一点吗?不一定。它的任务是要么创建一个新目录,要么向用户报告错误。因此让我们假设,只是假设一下,我们对所有的Node类都以统一的方式来处理adopt和orphan。
好了,好了。我知道你在想,“天啊!这些操作对File之类的叶节点来说毫无意义。”但这样的假设是不是切合实际呢?如果今后有人想定义一种新的类似垃圾箱(说得更准确一些,是回收站)的叶节点,它可以销毁它收养的所有子节点,那么这种情况该怎么处理?如果想在叶节点收养子节点时产生一条错误消息,那么这种情况又该怎么处理?我们很难证明adopt对叶节点来说绝无意义,orphan同样也是如此。
另一方面,有人可能会争辩说一开始就没有必要把File类和Directory类分开——所有的东西都应该是Directory。这样的论点是合理的,但是从实现的角度来说,它存在一些问题。一般来说,Directory对象中的许多内容对大多数文件来说是不必要的,比如用来存储子节点的数据结构、用来对子节点信息进行高速缓存以提高性能的数据结构,等等。经验表明,在许多应用程序中,叶节点的数量通常要比内部节点的数量多得多。这也是为什么COMPOSITE模式要把Leaf和Composite类分开的原因。
让我们来看一看,如果我们不仅仅只在Directory类中定义adopt和orphan,而是在所有的Node类中定义adopt和orphan,那将发生什么情况。我们让这些操作在默认的情况下产生错误消息。
virtual void Node::adopt (Node*) {
cerr << getName() << " is not a directory." << endl;
}
virtual void Node::orphan (Node* child) {
cerr << child->getName() << " not found." << endl;
}
虽然这些并不一定是最好的错误消息,但是应该足以让读者领会其中的含义。除了产生错误消息之外,这些操作还可以抛出异常,或者什么也不做——我们有许多选择。现在无论在什么情况下,Client::mkdir都可以完美地执行③。同时请注意,这种方法不需要对File类做任何改动。当然,我们必须修改Client::mkdir,在参数中用Node来代替Directory。
void Client::mkdir (Node* current, cosnt string& path) {
// ...
}
关```
键在于:虽然看起来我们不应该以统一的方式来处理adopt和orphan操作,但这样做实际上是有好处的,至少在这个应用程序中如此。另一种最有可能的选择是引入某种形式的向下转型,让客户来确定节点的类型。
void Client::mkdir (Directory* current, const string& path) {
string subpath = subpath(path);
if (subpath.empty()) {
current->adopt(new Directory(path));
} else {
string name = head(path);
Node* node = find(name, current);
if (node) {
Directory child = dynamic_cast>(node);
if (child) {
mkdir(child, subpath);
} else {
cerr << getName() << " is not a directory."
<< endl;
}
} else {
cerr << name << " nonexistent." << endl;
}
}
}
想必你已经注意到dynamic_cast引入了额外的检查和分支。为了能够处理用户在path中指定了无效目录名的情况,这样做是必需的。这个例子同时说明了不统一性会让客户代码变得更加复杂。