2.6 单用户文件系统的保护
经常使用计算机的人大都有过丢失重要数据的惨痛经历,起因可能只是一个不巧的语法错误,也可能是鼠标点偏了,或者只是深夜脑子突然不好使。在正确的时间删除一个错误的文件是一种常见的灾难。另一种情况是无意的编辑——在不经意间修改了一个不应该修改的文件。虽然一个高级文件系统会具备撤销功能,可以从这些不幸的事件中恢复,但我们通常更希望防患于未然。可悲的是,大多数文件系统给我们另一种不同的选择:预防或后悔⑥。
目前我们将集中精力讨论对文件系统对象(即节点)的删除和修改操作进行保护。之所以考虑保护是因为它与编程接口有直接的联系,与用户界面并没有直接的联系。我们不需要为两者的区别担心,因为我们编程时使用的抽象与用户级的抽象是有紧密联系的。另外,我们假设所使用的文件系统是一个单用户文件系统,这和一台标准的不具备网络功能的个人计算机(与之相对的是多用户计算机系统,比如Unix)所使用的文件系统相似。这样在设计的初期会比较简单。稍后我们将考虑实现多用户文件系统的保护。
文件系统的所有元素(包括文件、目录和符号化链接)都继承自Node接口,该接口包括下列操作⑦。
const string& getName();
const Protection& getProtection();
void setName(const string&);
void setProtection(const Protection&);
void streamIn(istream&);
void streamOut(ostream&);
Node* getChild(int);
void adopt(Node*);
void orphan(Node*);
除了getProtection之外,我们已经对这些操作进行了大量的讨论。从表面上看,getProtection用来获取一个节点的保护信息,但我们还不清楚这到底意味着什么。我们讨论的保护是何种类型的保护?
如果我们保护节点的目的是为了使它们免遭意外的修改或删除,那么我们只需要写保护就够了——也就是说,节点要么是可写的,要么是不可写的。如果我们想进一步保护节点,使别人不能偷看它们,那么我们还应该让节点变成不可读的。当然,这只能使它们避免被无知的人——不知道怎么修改节点的保护属性的人——偷看。如果不希望爱人或孩子访问某些节点,那么读保护可能会有用,但它并非不可或缺。在多用户环境中,读保护会变得更加重要。
让我们来概括一下,我们知道节点可以是可读的或不可读的,可以是可写的或不可写的。大多数文件系统有更多的保护模式,用来控制可执行性、自动存档之类的事情。像对可读性和可写性的处理那样,我们可以或多或少用相同的方法来处理这些保护模式。为了便于把问题讲清楚,我们的讨论将仅限于这两种保护模式。
如果一个节点是不可读的或不可写的,这对它的行为会产生什么影响?显然,一个不可读的文件不应该泄漏它的内容,这也暗示了它不应该响应streamOut操作。另一点可能不太明显,如果一个不可读的节点有子节点的话,那么还应该禁止客户访问它的子节点。因此,对不可读的节点来说,getChild应该失效。如果一个节点是不可写的,那么它应该禁止用户修改它的属性和结构,因此setName、streamIn、adopt以及orphan也应该失效。(在这一点上对setProtection的处理要谨慎。后面涉及多用户文件系统的保护时,我们会更详细地讨论这个问题。)
对一个不可写的节点进行保护,使之无法被删除,这对我们使用的编程语言提出了挑战。举个例子,客户不能像删除其他对象那样显式地删除一个节点。C++编译器可以帮助我们捕获这样的尝试,但这并非是通过把一个节点定义为const来做到的,因为节点的保护属性在运行的时候会改变。
相反,我们可以对析构函数进行保护。与一个正常的公有析构函数的不同之处在于,如果把一个析构函数声明为protected,那么从Node类层次结构之外的类中显式地删除一个节点将是非法的⑧。对析构函数进行保护的另一个好处是,它可以禁止局部Node对象,也就是在栈上创建的节点。这防止了一个不可写的节点由于超出作用域而被自动删除——这种不一致性可能会是一个bug。
现在节点的析构函数是受保护的,那么我们怎么(试图)删除一个节点呢?毫无疑问的一点是:我们最终要以待删除的节点为参数,来调用某个操作。现在燃眉之急的问题就是,谁来定义这个操作?这里有三种可能性:
(1)Node类(子类可能会对该操作进行重定义);
(2)Node类层次之外的一个类;
(3)一个全局函数。
我们可以立即排除第三种选择,因为和在一个已有的类中定义一个静态成员函数相比,它根本没有什么优势。在Node类层次之外定义一个删除操作看起来也不怎么样,因为它强迫我们把该操作所在的类定义为Node的友元类。为什么?因为如果一个节点恰好是可删除的(即可写的),那么我们必须调用它的受保护的析构函数。从Node类层次之外调用该析构函数的唯一方法,就是使删除操作所在的类成为Node的友元类。这种方法存在一个不好的副作用,因为它不仅暴露了Node的析构函数,而且暴露了封装在Node类中的所有其他成员。
让我们考虑第一种选择:在Node基类中定义一个destroy操作。如果我们将destroy定义为static操作,那么它必须在参数中接收一个Node实例;如果不将destroy定义为static操作,那么它可以不接受任何参数,因为我们有隐含的this参数。在静态成员函数、虚拟成员函数和非虚成员函数之间的选择,最终可以归结为在可扩展性和美学之间的选择。
通过派生子类,我们可以对虚拟成员函数进行扩展。但是,有些人对下面的语法感到一丝不安。
node->destroy();
虽然我并不清楚其中的原因,但我打赌有些人看到下面的语句时会感到不寒而栗,是出于同样的原因。
delete this;
也许是因为它们的“自杀味”太浓了。静态成员函数可以扫清这一障碍,但子类无法对函数进行修改。
Node::destroy(node);
同时,一个非虚成员函数无论在扩展性还是美学方面都是最差的。
让我们来看看是否可以鱼与熊掌兼得之——既能享受静态成员函数在语法上的优势,又能允许子类对destroy操作进行扩展。
先不管子类想以何种方式来扩展destroy操作,该操作的主要目的是什么?看起来有两件事情是不变的:destroy必须检查传给它的节点是否可写,如果是可写的,destroy就将之删除。子类可能想要对该操作进行扩展,来决定一个节点是否符合删除的标准,或者对如何执行删除操作进行修改。但不变的部分仍然保持不变。我们只是需要少许帮助,让我们能够以一种可扩展的方式实现它们。
进入TEMPLATE METHOD模式,它的意图部分是这样的:
定义一个操作中算法的框架,将其中的一些步骤留给子类去实现。TEMPLATE METHOD模式在不改变算法结构的前提下,允许子类对算法的某些步骤进行重定义。
根据该模式的适用性部分的第一条,如果我们想一次性实现算法中的不变部分,并将可变部分留给子类去实现,那么TEMPLATE METHOD模式就可以适用。一个模板方法通常看起来如下所示。
void BaseClass::templateMethod () {
// an invariant part goes here
doSomething(); // a part subclasses can vary
// another invariant part goes here
doSomethingElse(); // another variable part
// and so forth
}
BaseClass通过定义doSomething和doSomethingElse操作来实现默认的行为,子类可以对它们进行特化来执行不同的操作。在TEMPLATE METHOD模式中此类操作被称为基本操作(primitive operation),因为模板方法会把它们组合在一起来创建更高级的操作。
由于子类必须能够以多态的方式来对基本操作进行重定义, 因此它们应该被声明为virtual。TEMPLATE METHOD模式建议我们在基本操作的名字前面加上“do-”前缀,这样可以明确地标识出基本操作。由于基本操作在模板方法之外可能没有什么意义,因此为了防止客户代码直接调用它们,我们还应该将它们声明为protected。
对于模板方法本身,TEMPLATE METHOD模式建议我们将它声明为非虚成员(在Java中为final),以确保不变的部分保持不变。我们的实现比这还要更进一步:我们的候选模板方法destroy操作不仅是非虚的,而且是静态的。虽然这并不意味着我们不能运用该模式,但它的确会影响到我们的实现。
在完成destroy之前,让我们来设计一下基本操作。我们已经确定了该操作中不变的部分:检查节点是否可写,若可写,则将之删除。由此我们可以立即写出下面的代码。
void Node::destroy (Node* node) {
if (node->isWritable()) {
delete node;
} else {
cerr << node->getName() << " cannot be deleted."
<< endl;
}
}
isWritable是一个基本操作⑨,子类可以对它进行重定义来改变写保护的标准。基类既可以为
isWritable提供一个默认的实现,也可以将之声明为纯虚函数,来强制子类实现它。
class Node {
public:
static void destroy(Node*);
// ...
protected:
virtual ~Node();
virtual bool isWritable() = 0;
// ...
};
将isWritable声明为纯虚函数避免了在抽象基类中保存与保护有关的状态,但它同时阻止了子类对这些状态进行重用。
虽然destroy是静态函数,而不是非虚函数,但它仍然能够成为一个模板方法。这是因为它不需要引用this,而只需要把待执行的操作委托给传入的Node实例。由于destroy是Node基类的成员,因此它能够在不破坏封装的前提下调用受保护的操作,比如isWritable和delete。
现在除了析构函数之外,destroy只用到了一个基本操作。为了避免把错误消息直接写在基类中,我们应该增加另一个基本操作来让子类修改错误消息。
void Node::destroy (Node* node) {
if (node->isWritable()) {
delete node;
} else {
node->doWarning(undeletableWarning);
}
}
doWarning对警告操作进行了抽象,它允许节点就任何问题给用户以警告,而不仅仅是就无法删除节点这一个问题给用户以警告。它可以非常复杂,它可以执行任何操作,包括打印一行字符串到抛出一个异常。有了doWarning操作, 就无须为我们能想到的每种情况定义基本操作了(如doUndeletableWarning、doUnwritableWarning、doThisThatOrTheOtherWarning等)。
我们可以将TEMPLATE METHOD方法运用到Node的其他操作中,这些操作恰好不是静态的。为此,我们引入了新的基本操作。
void Node::streamOut (ostream& out) {
if (isReadable()) {
doStreamOut(out);
} else {
doWarning(unreadableWarning);
}
}
streamOut和destroy这两个模板方法的主要区别在于,streamOut可以直接调用Node的各种操作。由于destroy不能引用this,因此它无法直接调用Node的操作。这也是为什么我们必须将待删除的节点传给destroy的原因,这样它就可以把待执行的操作委托给节点的基本操作了。另外要记住的是,我们在把streamOut升级为模板方法的同时,把它变成了非虚函数。
※ ※ ※
TEMPLATE METHOD模式导致了一种被称为好莱坞原则的反向控制,或者说“不要调用我,我会调用你⑩”。子类可以对算法中可变的部分进行扩展或重新实现,但它们不能改变模板方法的控制流和其余不变的部分。因此,当我们为Node类定义一个新的子类时,我们要考虑的不是控制流,而是责任——我们必须覆盖哪些操作,我们可以覆盖哪些操作,以及我们不能覆盖哪些操作。以模板方法的形式来组织我们的操作使得这些责任变得更加明确。
好莱坞原则非常有意思,因为它是理解框架的关键之一。它让框架将体系结构和实现细节中不变的部分记录下来,而将可变的部分交给与应用程序相关的子类。
有些人不太适应框架编程,反向控制就是其中原因之一。当我们以过程化的方式来编写代码时,我们会在极大程度上关注控制流。对于一个过程化的程序来说,即使它对函数的分解无可挑剔,但如果我们不了解其中的奥妙,那么很难想象我们能够理解整个程序。但一个好的框架会把控制流的细节抽象出来,这样我们最终要加以关注的是对象。相比之下,这种方式从一方面来看比控制流更容易理解,但从另一方面来看也比控制流更不容易理解。我们必须从对象的职责和协作方面来考虑。它从一个更高的层次来看整个系统,它的视角更加侧重于做什么而不是怎么做,它具备更大的潜在作用力和灵活性。与框架相比,TEMPLATE METHOD模式在一个较小的规模上——操作级别而不是对象级别——提供了这些好处。