2.4 访问权限
到目前为止我们已经运用了两个设计模式:我们用COMPOSITE来定义文件系统的结构,用PROXY来帮我们支持符号化链接。把我们讨论到现在的改动和其他一些改进合并起来,得到了如图2-4所示的体现了COMPOSITE模式和PROXY模式的类层次结构。
getName和getProtection用来返回节点的对应属性。Node基类为这些操作定义了默认的实现。streamIn用来把节点的内容写入文件系统,streamOut用来从文件系统读出节点的内容。(我们假设文件是按照简单的字节流来建模的,就像在Unix系统中那样。)streamIn和streamOut是抽象操作,这意味着基类声明了它们,但没有实现它们。因此它们的名字用斜体表示。getChild、adopt和orphan都有默认的实现,其目的是为了简化叶节点的定义。
说到叶节点,我们再来回顾一下:Node、File和Directory来自COMPOSITE模式。PROXY模式提供了Link类,它还指定了Node类,这个类我们原来就已经有了。因此,Node类是两个模式的交汇点。其他类只参与了Proxy模式或COMPOSITE模式,而Node类则参与了两个模式。这样的双重身份是Alexander所谓的“密集”复合模式的标志,其中两个或多个模式占据了系统中的同一个类“空间”。
密集度有它的好处,也有它的坏处。在相对较少的类中实现多个模式会让设计变得深奥,空间不大却意味深长,有点像一首诗。但另一方面,这样的密集度让我们联想起灵感匮乏的创造。
Richard Gabriel是这样说的[Gabriel95]:
在软件中,Alexandrian所说的密集度至少在一定程度上代表了低质量的代码——代码的每一部分都完成一件以上的任务。这样的代码就像是我们第一次编写的代码,它比正常的需要多占用了两三倍的内存空间。这样的代码就像是我们曾经在20世纪六七十年代编写的汇编语言代码。
说得好——“深奥的”代码不一定是好代码。事实上,Richard的担忧是另一个更大问题的症状:当一个模式被实现之后,它可能会丢失。这里有许多东西可以讨论,但我们得等一等——我们的文件系统正在向我们召唤呢!
※ ※ ※
在操作系统中,绝大多数用户级的命令都会通过某种方式对文件系统进行操作。因此,文件系统是计算机的信息仓库也就不足为奇了。随着操作系统的不断发展,这样一个重要组件必然会产生新的功能。
我们已经定义的类提供了少量功能。具体说来,Node类的接口只是把它所有子类支持的一些基本操作包括了进来。这些操作之所以基本,是因为它们不仅允许我们访问只有节点才能访问的信息,还允许我们执行只有节点才能执行的操作。
很自然,我们可能还想在这些类上执行其他一些操作。考虑一个用来统计文件中字数的操作。一旦我们认识到需要这样的操作,可能就想在Node基类中增加一个getWordCount操作。这是一件很糟糕的事情,因为我们最终至少得修改File类,而且可能还要修改其余的每个类。我们迫切地希望能避免修改已有的代码(可以理解为“向已有的代码中添加bug”)。但是我们没有必要恐慌,因为在基类中有流处理操作,文件系统的客户代码可以使用它们来检查文件中的文本。这样,我们就得以解脱,不必再对已有的代码进行修改了,因为客户代码可以通过已有的操作来实现字数统计。
事实上,我可以肯定地说,设计Node接口最主要的挑战在于找出一组最少的操作,客户代码可以通过这组操作来构建新功能而不受任何约束。另一种可供选择的方法是为了每个新功能而对Node及其子类进行改造,相比之下这种方法不但具有扩散性,而且容易出错。它还会使Node的接口发展成一个具有各种操作的大杂烩,并最终把Node对象的本质属性掩埋掉。所有的类会变得难以理解、难以扩展以及难以使用。因此,要定义一个简单有序的Node接口,把注意力集中在一组够用的基本操作上是关键。
但那些应该以不同的方式来处理不同的节点的操作该怎么办呢?我们怎样才能把它们放到Node类的外部呢?让我们以Unix的cat操作为例,它只是把文件的内容输出到标准输出设备上。但是,当我们将它用于目录的时候,它会报告无法输出节点的内容,也许是因为目录的文本表示不太好看吧。
由于cat的行为取决于节点的类型,看起来似乎有必要在基类中定义一个操作,并让File和Directory以不同的方式来实现该操作。因此我们最终还得修改已有的类。
有没有别的方法?假设我们坚持不把这个功能放到Node类中,而要把它放到客户代码中。那么看来除了引入向下转型来让客户代码判断节点的类型之外,我们没有什么其他的选择。
void Client::cat (Node* node) {
Link*l;
if (dynamic_cast<File*>(node)) {
node->streamOut(cout); // stream out contents
} else if (dynamic_cast<Directory*>(node)) {
cerr << "Can't cat a directory." << endl;
} else if (l = dynamic_cast<Link*>(node)) {
cat(l->getSubject()); // cat the link's subject
}
}
向下转型似乎又是难以避免的了。而且,它使客户代码变得更加复杂。没错,我们是故意不把功能放到Node类中,而要把功能放到客户代码中的。但是除了功能本身,我们还增加了类型测试和条件分支,这合起来就构成了对方法的二次分派。
如果说把功能放到Node类中令人反感,那么使用向下转型就令人恶心了。但是,在我们为了避免向下转型而不假思索地将cat()操作弄到Node及其子类中之前,让我们来看一看VISITOR模式,这个设计模式为我们提供了第三种选择。它的意图如下:
表示一个用来处理某对象结构中各个元素的操作。VISITOR让我们无需修改待处理元素的类,就可以定义新的操作。
模式的动机部分讨论了一个编译器,这个编译器用抽象语法树来表示程序。它所面临的问题是支持一组各式各样的分析器,比如类型检查、精美的打印以及代码生成,而不需要对实现抽象语法树的类进行修改。这个编译器问题和我们的问题相似,唯一的不同之处在于我们要处理的是文件系统结构,而不是抽象语法树,而且我们想要对文件系统结构执行完全不同的操作。(但话又说回来,也许精美地打印一个目录的结构还能沾得上边。)无论如何,操作本身并不重要,重要的是把操作从Node类中分离出来,但又无需引入向下转型和额外的条件分支。
VISITOR只要在它的“Element”参与者中加入一个操作,就可以达到这一目的。这个操作在我们的Node类中如下所示。
virtual void accept(Vistor&) = 0;
accept让一个“Visitor”对象访问一个指定的节点。Visitor对象封装了要对节点执行的操作。所有的Element具体子类实现accept的方式不仅简单,而且看起来也完全相同。
void File::accept (Visitor& v) { v.visit(this); }
void Directory::accept (Visitor& v) { v.visit(this); }
void Link::accept (Visitor& v) { v.visit(this); }
所有这些实现看起来完全相同,但它们实际上是不同的——在每个实现中,this的类型是不一样的。上述实现暗示了Visitor的接口看起来应该像下面这样。
class Visitor {
public:
Visitor();
void visit(File*);
void visit(Directory*);
void visit(Link*);
};
这里最有意思的特性是,当一个节点的accept操作调用Visitor对象的visit时,它同时向Visitor表明了自己的类型。然后,被调用的Visitor操作可以根据节点的类型对它进行相应的处理。
void Visitor::visit (File*f) {
f->streamOut(cout);
}
void Visitor::visit (Directory* d) {
cerr << "Can't cat a directory." << endl;
}
void Visit::visitor (Link*l) {
l->getSubject()->accept(**this);
}
最后一个操作需要做些解释。它调用了getSubject(),这个操作返回该符号化链接指向的节点,也就是它的Subject⑤。我们不能直接把Subject的内容打印出来,因为它可能是一个目录。相反,我们让它接受一个Visitor对象,就像我们对Link类本身所做的那样。这使得Visitor能够根据Subject的类型来做相应的处理。Visitor会通过这种方式挨个访问任意数量的链接,直到最终到达一个文件或目录,这时它就终于可以做些有用的事情了。
因此,现在我们只要创建一个Visitor并让节点接受它,就可以对任何节点执行cat操作。
Visitor cat;
node->accept(cat);
节点反过来调用Visitor,这个调用会根据节点的实际类型(File、Directory或Link)被解析成与之对应的visit操作,从而得到相应的处理。结果是Visitor无需进行类型测试,就可以把cat之类的功能打包在单个类中。
把cat操作封装到Visitor中非常漂亮,但如果想对节点执行cat之外的操作,看起来我们还是得修改已有的代码。假设我们想要实现另一个命令,这个命令用来列出一个目录中所有子节点的名字,它和Unix中的ls命令相似。此外,如果节点是一个目录,那么应该给输出添加“/”后缀,如果节点是一个符号化链接,那么应该给输出添加“@”后缀。
我们需要把“访问Node的权限”授予给另一个类似于Visitor的类,但我们不想再给Node基类增加另一个accept操作。事实上我们也不必那样做。任何Node对象都可以接受任何类型的Visitor对象。只不过我们目前只有一种类型的Visitor。但在Visitor模式中,Visitor实际上是一个抽象类。
class Visitor {
public:
virtual ~Visitor() { }
virtual void visit(File*) = 0;
virtual void visit(Directory*) = 0;
virtual void visit(Link*) = 0;
protected:
Visitor();
Visitor(const Visitor&);
};
我们为每一个新功能从Visitor派生一个子类,并根据每种可访问的节点的类型来实现相应的visit操作。例如,CatVisitor子类会像前面所讲的那样实现所有操作。我们还可以定义SuffixPrinterVisitor,用它来为节点打印正确的后缀。
class SuffixPrinterVisitor : public Visitor {
public:
SuffixPrinterVisitor() { }
virtual ~SuffixPrinterVisitor() { }
virtual void visit(File*) { }
virtual void visit(Directory*) { cout << "/"; }
virtual void visit(Link*) { cout << "@"; }
};
我们可以在实现了ls命令的客户代码中使用SuffixPrinterVisitor。
void Client::ls (Node* n) {
SuffixPrinterVisitor suffixPrinter;
Node* child;
for (int i=0; child = n->getChild(i); ++i) {
cout << child->getName();
child->accept(suffixPrinter);
cout << endl;
}
}
一旦给Node类增加了accept(Visitor&)操作,我们就获得了对节点的访问权。此后无论我们要给Visitor定义多少子类,我们都再也不需要修改Node类及其派生类了。
之前我们使用了函数重载,这样Visitor的操作就可以使用相同的名字。另一种可选的方法是将节点的类型信息嵌入到visit操作的名字中。
class Visitor {
public:
virtual ~Visitor() { }
virtual void visitFile(File*) = 0;
virtual void visitDirectory(Directory*) = 0;
virtual void visitLink(Link*) = 0;
protected:
Visitor();
Visitor(const Visitor&);
};
对这些操作的调用会变得更加清晰一些,也更冗长一些。
void File::accept (Visitor& v) { v.visitFile(this); }
void Directory::accept (Visitor& v) { v.visitDirectory(this); }
void Link::accept (Visitor& v) { v.visitLink(this); }
如果存在一种合理的默认处理方法,而且Visitor的子类往往只覆盖(override)所有操作中的一小部分,那么这种做法还有另一个显著的好处。当我们使用重载的时候,子类必须覆盖所有的函数,否则我们常常使用的C++编译器可能会抱怨我们对虚拟重载函数的选择性覆盖隐藏了基类中的一个或多个操作。当我们给Visitor操作以不同的名字时,我们就避开了这个问题。然后子类就可以重新定义操作的一个子集,而不会受到C++编译器的限制。
基类的各个操作可以为每种类型的节点实现默认处理方法。当默认处理方法适用于两种或多种类型时,我们可以把公共功能放到一个“全能”(catch-all)的visitNode(Node*)操作中,供其他操作在默认情况下调用。
void Visitor::visitNode (Node* n) {
// common default behavior
}
void Visitor::visitFile (File*f) {
Visitor::visitNode(f);
}
void Visitor::visitDirectory (Directory* d) {
Visitor::visitNode(d);
}
void Visitor::visitLink (Link*l) {
Visitor::visitNode(l);
}