设计模式沉思录

978-7-115-36721-1
作者: 【美】John Vlissides
译者: 葛子昂
编辑: 杨海玲

图书目录:

详情

本书在《设计模式》的基础上进行了拓展,运用其中的概念,介绍了一些技巧,帮助读者决定在不同的情况下应该使用哪些模式,以及不应该使用哪些模式。本书不仅对已有的一些模式提出新的见解,还让读者见证开发新模式的整个过程。

图书摘要

版权信息

书名:设计模式沉思录

ISBN:978-7-115-36721-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] John Vlissides

  译    葛子昂

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Authorized translation from the English language edition, entitled Pattern Hatching: Design Patterns Applied, 9780201432930 by John Vlissides, published by Pearson Education, Inc., publishing as Addison-Wesley Professional. Copyright © 1998 Pearson Education, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD. and POSTS & TELECOM PRESS Copyright © 2015.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


本书在GoF的《设计模式》一书的基础上进行了拓展,运用其中的概念,介绍了一些技巧,帮助读者决定在不同的情况下应该使用哪些模式,以及不应该使用哪些模式。本书不仅对已有的一些模式提出新的见解,还让读者见证开发新模式的整个过程。

本书适合使用设计模式的软件开发人员阅读。


我接下本书的翻译工作的时候,正值《Windows核心编程(第5版)》翻译完成,中文版新书上架后没多久。翻译《Windows核心编程(第5版)》耗时9个多月,这是与另外两位译者一起合作完成的一本700多页的大部头。这项工作让我感觉有些疲惫,原本想好好歇一歇,不料却邂逅本书。

最终,我无法拒绝,接下了本书的翻译工作,主要出于两方面的原因。首先,作为一名设计模式的拥护者和践行者,我非常高兴地看到另一本与设计模式有关的重要著作在问世10多年后即将在国内出版,同时也希望自己能够为国内的设计模式社区尽一份绵薄之力。其次,本书的英文原版不到200页,在翻译了《.NET设计规范》和《Windows核心编程(第5版)》两本译作之后,我满怀信心地认为自己应该可以很快完成。

事实很快证明我错误地估计了翻译本书的工作量,而且错得不轻。本书虽然短小,但却当之无愧地是我翻译过的最难的技术书籍,以至于在开工后不久我不得不向出版社告急,要求对原先的翻译进度和出版计划进行调整。这本不足200页的书最终花了我将近半年的时间才完成,这远远超出了我的预期。出版社的编辑工作同样反映了本书的难度,译稿在2009年上半年就已交付给出版社进行编辑,但最终出版上架却要到2010年初,其难度可见一斑。

正是由于这个原因,最初交付的中文版译稿并未能完全达到我希望的流畅程度。对我来说,虽然我尽量追求译文的准确和流畅,但当两者不能兼得时,我会牺牲语言的流畅来保证内容的准确。在此我要感谢本书的编辑陈兴璐小姐和图灵公司的总经理武卫东先生,是他们的编辑和润色使得本书变得更加流畅。即便如此,书中难免还会存在一些生涩的字句和不够流畅的地方,甚至是错误之处。作为译者,我对此负有全部责任。为此我建立了一份网上勘误表, 如果读者发现任何错误,都请通过该网页与我联系,一旦确认我会立即将其更新到勘误表中。勘误表的网址为www.gesoftfactory.com/ge/Pattern-Hatching/。

我要感谢我的同事吴宇进、田超和张险峰,是他们在繁忙的工作之余审阅译稿,提出了许多宝贵的意见和建议,从而使得本书的质量更上一层楼。最后,我要感谢我的妻儿,他们的支持和鼓励,是我前进的动力。

本书不仅通过一些通俗易懂的实例对如何运用设计模式进行了深入的讲解,而且还介绍了一些新的设计模式。但与其他的设计模式书籍相比,本书的独特之处在于向读者揭开了模式开发的神秘面纱,让读者了解模式背后鲜为人知的一些故事,并领略其中的苦与乐。我满怀着激动和忐忑之情,将这本设计模式领域的重要著作的中文版呈现给国内的广大读者。希望本书能够帮助你们更好地理解和运用设计模式,甚至有朝一日编写出自己的模式!

葛子昂

2010年3月


John写信告诉我,他打算为C++ Report撰写模式专栏,他的这个决定填补了我生命中的一个空白。具体来说,他一年填补了大概5个空白——那时我正在撰写一个关于模式的专栏,每两个月一期,Stan Lippman建议我和John轮流撰写。John主要关注设计模式,而我在专栏中将继续关注更为广泛的主题。我们俩搭档把模式介绍给C++社区,对此我感到兴奋。不仅如此,我也喜欢John介绍这个主题的方式。我在给他的信中写到:

孵化(hatching)的比喻不仅讨人喜欢,而且很有道理。我刚刚又阅读了Alexander的Notes on Systhesis第2版的前言,显然他认为对那些潜藏在自然中的事物,我们应该去挖掘和发现它们,而不是关注创造它们的“方法”,甚或是从其璀璨的裂缝中窥探它们。

能够与GoF(模式四人组)之一共同写专栏,我不仅感到高兴,更感到荣幸。如果没有GoF的《设计模式》一书,读者也许都不曾听说过模式。通过对模式的介绍,该书成为了这个全新科目的极佳教材。GoF的23个设计模式奠定了一个不大但却非凡的基础,并发展壮大成为我们今天知道的模式社区。而凭借本书读者可以直接深入了解GoF作者之一John的思考过程,同样对更加广阔的过程有一些间接的了解。

为了总结出一个好模式,突破一些局限在所难免,就像小鸡破壳而出,而John的专栏对那些在《设计模式》背后发生的“破壳”的对话进行了探索。例如,John在类结构不断演变的情况下,对Visitor的限制进行了探索。他还谈论了一些模式,比如GENERATION GAP(见本书的第3章)。这些模式未能被收录到《设计模式》中,但它们可能已经足够好了,值得公之于众。读者会发现GoF关于MULTICAST模式的对话,这段对话让John陷入沉思:“一旦了解了我们在模式开发过程中所经历的混乱,那些认为GoF具备非凡能力的人一定会感到震惊。”本书传达了一个重要的事实,它没有出现在更为学术化和更加完善的《设计模式》一书中:模式源自一群认真努力的程序员,虽然他们不可能每次一开始就把事情都做对,但他们努力地让那些重复出现的设计实践变得实用。我认为阅读本书的模式用户将会感谢GoF为他们的模式而付出的心血,我还认为阅读本书的模式编写者在今后发掘和编写模式时会比以前更加谦逊和勤勉。

乱中求序是自然科学的主旋律,那么,我们不应该认为设计的科学会有任何的不同之处。模式是人们在工作中共同发现一些构成人们高品质生活的因素,并将它们加以记录的整个过程。这不可避免是个有机过程。贯穿本书,读者将得以洞察各模式背后的有机过程,得以了解普通(但非常有经验而且非常尽职的)软件开发人员在努力形成自己对设计的理解时的思考过程。《设计模式》是对他们集体理解的提炼,而本书是对产生理解的过程的提炼,我们不应该低估它在解释GoF模式方面所带来的价值。请允许我引用一封我在1997年晚些时候收到的来自Richard Helm的信,我相信它进一步证实了这一点。

GoF的设计模式只解决了微观架构(micro-architecture)。你仍然必须把宏观架构(macro-architecture)设计好:分层、分布、功能隔离……。而且就像Cope说的,你仍然必须把纳米架构(nano-architecture)设计好:封装、Liskov……。在所有这些中的某个地方,你也许会用到一个模式,也许用不上。即使用上了,也可能和某本书中的介绍和描述很不一样。

这本书将帮助你理解如何将《设计模式》——其实是任何关于设计模式的书籍——当作一本珍贵的指南,而不是当作一些累赘的规定。它可以帮助你在更广阔的面向对象设计的基本原则下,将设计模式运用到合适的地方。它道出了虽然不正式但却严格的标准和紧张的迭代过程,《设计模式》中的23个模式正是基于这样的标准,经历了这样的迭代过程产生的。知道有这样的过程,以及这样的过程如何发生,让人感到释然,因为它把模式带回到更加讲究实用的日常工作中。我认为这将有助于读者认识到必须根据手头的问题来对模式进行调整,有助于读者加入自己的思考而不仅仅是盲目地遵循“某本书中说过的”教条。我不认为计算机科学家会喜欢这本书,但现实的程序员会反复品味,获得共鸣,并高度欣赏它。

James O. Coplien

朗讯科技公司

贝尔实验室


我永远不会忘记1994年秋天的那个下午。那天我收到一封来自Stan Lippman(时任C++ Report杂志的主编)的电子邮件,他邀我为该杂志撰写一个专栏,该专栏每两个月一期。

我们算得上是老相识了,早在他参观Watson实验室的时候我们就认识了。那一次我们简单地聊了他在开发工具方面所做的工作,以及GoF在模式方面所做的工作。与那时大多数人不一样的是,Stan熟悉模式的概念——他接连阅读了《设计模式》的一些预览本,并说过一些令人鼓舞的话。尽管如此,我们的谈话很快就转移到了写作上。随着谈话的进行,我记得自己愈加炫耀起来,仿佛我已经不是自己了。而Stan,这位知名的专栏作家,是两本非常成功的图书(还有一本即将出版)的作者,却称自己的写作只是业余水平。我不清楚我们的谈话是否让他感到愉快,还是在他的下一个约会之前他一直都在耐着性子和我谈话。(此后我认识到,如果还有什么能胜过Stan的忍耐力,那就是他的真诚!)

几个月后我收到他的电子邮件,心潮起伏,此前的歉疚感就不值一提了。想象着自己为全球的读者定期撰写专栏,这既让我兴奋,又让我恐惧。写了几次之后我是否还能继续?人们是否在乎我写些什么?我应该写些什么?我写的东西对别人是否有帮助?

我在恐惧中沉溺了将近一小时。然后我想起我父亲的一些告诫:局促不安只能使人无所作为。只要关注最基本的东西,其他东西会随之而来的。“只管去做”(Just do it),他说这句话比耐克要早得多。

于是我就接受了。

选择专栏主题非常容易。那时我深陷于模式的研究中已有三年了。我们最近刚完成《设计模式》,但我们都知道它远远没有说完这个话题。专栏会是一个很好的论坛,可以对《设计模式》一书进行解释,可以对它进行扩展,还可以在新问题出现时展开讨论。如果说专栏有助于《设计模式》图书的销售,那也无妨,只要它立场公正,不乱吹嘘。

现在,我的“模式孵化”专栏已经连载了10多篇文章了,回过头看,我的恐惧是没有依据的。我从来没有因为要找东西写而为难,而且写作时我乐在其中。我还从世界各地收到了大量令人鼓舞的反馈,包括一些人要求阅读过去的专栏,而且这样的要求一再出现。后来我想到了把我的专栏,以及其他一些尚未发表的关于模式的材料,汇编在一起提供给大家。

本书就是要达到这个目的。读者将在书中找到我前三年专栏写作生涯中的思考和想法,其中包括发表在C++ Report和Object Magazine中的所有文章,加上一些零碎的新见解。我按照逻辑的顺序来组织内容,而不是通过时间顺序来组织内容,其目的是为了使所有的内容能够像书本一样连贯。这样的组织比我想象的要容易一些,因为许多文章既是这个系列的一部分,又是那个系列的一部分,当然这仍然需要耗费大量的精力。我衷心地希望读者能够喜欢最终的结果。

一如既往,我要感谢许多人为我提供各种各样的帮助。首先最重要的是我的GoF成员——Erich Gamma、Richard Helm以及Ralph Johnson。他们每一个人都在不同的时刻为我提供了宝贵的反馈,这些反馈汇集在一起使本书成为了一本更加不同(当然是更好)的图书。我们几人互补性强,如同天成,遇见他们是我三生有幸,我由衷地感谢他们。

然而,同样的帮助也来自其他人。还有许多人花时间研读草稿,为的是找出不合逻辑的论述、不当的言辞,以及大家都再熟悉不过的笔误。他们是Bruce Anderson、Bard Bloom、Frank Buschmann、Jim Coplien、Rey Crisostomo、Wim De Pauw、Kirk Knoernschild、John Lakos、Doug Lea、Bob Martin、Dirk Riehle以及Doug Schmidt。特别感谢Jim,他是我在C++ Report的拍挡,不仅因为他为本书作序,更因为他是如此多才多艺,总是激励我奋进。

接下来要感谢的完全是一些陌生人,他们给我发电子邮件问我问题,提出意见,纠正我的错误,并给我以善意的责备。有许多这样的人,在这里我只列出了本书引用了他们的话的人,或者他们的意见与本书直接相关的人:Mark Betz、Laurion Burchall、Chris Clark、Richard Gyger、Michael Hittesdorf、Michael McCosker、Scott Meyers、Tim Peierls、Paul Pelletier、Ranjiv Sharma、David Van Camp、Gerolf Wendland和Barbara Zino。虽然很多人我没有提到名字,请相信我同样感谢你们的反馈。

最后,我要感谢两个家庭,一个是我自己的家人,另一个是我亲如一家的同事,你们对我的支持我无以言表。我欠你们的太多了。

J.V.

vlis@watson.ibm.com

1998年1月于纽约州霍索恩市


在阅读本书之前,如果读者还没有听说过一本名叫《设计模式》(Design Patterns: Elements of Reusable Object-Oriented Software [GoF95])的书,那么现在正好可以去找一本来读。如果读者听说过该书,甚或自己还有一本但却从来没有实际研读过,那么现在也正好应该好好研读一下。

如果你仍然在继续往下阅读,那么我会假设你不是上述两种人。这意味着你对模式有大致的了解,特别是对23个设计模式有一定的了解。你至少需要具备这样的条件才能够从本书受益,这是因为它对《设计模式》中的材料进行了扩充、更新和改进。如果不熟悉所谓的GoF模式——也就是刚才提到的那23个设计模式,那么读者将会很难理解书中的见解。事实上,在阅读这本书的时候,最好备一本《设计模式》在身边以便随时查阅。我还要假设你熟悉C++,这么假设应该很合理,因为我们在《设计模式》一书中也做了同样的假设。

现在让我们来实际检验一下,看你是否能用不到25个字来描述COMPOSITE模式的意图。你可以考虑一分钟。

※   ※   ※

在描述COMPOSITE模式的意图时,你是不是句斟字酌?如果确实如此,好,没问题。其实你大可不必把这个小测验看得太严重了,不妨放松一点。如果你知道该模式的意图,但只是无法准确描述出来,那么不必担心——本书同样适合你。

但如果你的头脑中完全是一片空白,那么很不幸,我的开场白显然没有起作用。我建议你放下本书,拿起一本《设计模式》,从第163页开始阅读,直到读完“实现”那一节。对该书第xv页中列出的其他模式,也采取同样步骤。如此一来,你就可以对背景知识有足够的了解,从而使得本书对你有用。

你可能会想,为什么本书会取名为Pattern Hatching?我最初选择这个名字,是因为计算机科学中有类似的概念。(此外,与模式有关的好书名都已经被用了。)但此后,我逐渐认识到它非常好地表达了我写作的意图。Hatching之意并非创造,而是在现有的基础上进行拓展。用于此处非常贴切:如果把《设计模式》看成一盒鸡蛋,那么许多新生命将会在这里破壳而出

请相信,我并不仅仅是效仿《设计模式》。我的目的是在它的基础上进行拓展,运用其中的概念,并使这些概念对读者更加有用。本书介绍了一些技巧,来帮助我们决定在不同的情况下,应该使用哪些模式以及不应该使用哪些模式。本书不仅对我们已有的一些模式提出了新的见解,还可以让读者见证我们开发新模式的整个过程。本书还提供了大量的设计示例,其中一些是久经考验的,另一些是试验性质的,或者说是“半成品”,还有一些则完全是主观推测——很可能是纸上谈兵的设计,根本经不起实践的检验,但它们也可能会蕴含未来健壮设计的种子。

我衷心地希望本书能够加深你对设计的感悟,提高你对模式的认识,并开阔你软件开发的视野。这些都是我在使用模式时曾有过的经历,希望它们也能成为你自己的宝贵经历。

这些日子,模式引起了大家强烈的兴趣,同时还伴随着一些迷惑、诧异和误解。这在一定程度上体现了主流软件开发人员认为这个领域有多么新,虽然从严格意义上说,它并不是一个新领域。这个领域的快速发展,也造成了一些空白。作为模式的倡导者,我们对此负有一定的责任:我们虽然一直努力让大家理解和接受模式([BMR+96、Coplien96、CS95、GoF95、MRB98和VCK96]),但是工作并不彻底。

为此,我感觉自己有义务来纠正那些对模式比较明显的误解,这些误解我常常耳闻,甚至可以自成模式了。我甚至还开玩笑地采用模式的形式来表述它们……直到那一刻我幡然醒悟:将任何事物都归纳为模式,这种行为本身就是对模式的一种误解!无论如何,请记住我并不是代表模式社区在发言。虽然我认为大多数模式专家都会同意这些是对模式最常见的误解,但就如何消除这些误解而言,他们的意见可能会与我的相左。

这些年人们对模式众说纷纭,令我反复思考,众多误解不过分为三类:一类有关模式什么,一类有关模式能够做什么,还有一类有关一直以来在推动模式的社区。我所列举的“十大”误解都可以被归到这三类中。因此,我会将它们分门别类。首先来看看关于模式什么的误解。

误解1:“模式就是在一种场合下对某个问题的一个解决方案。”

这是Christopher Alexander的定义[AIS+77],因此把它算作一种误解可能会显得有些离经叛道。但下面这个反例应该能够显露出它的不足。

问题: 如何在过期之前兑现中的彩票?

场合: 离最后期限只有一小时,一条狗把彩票吃了。

解决方案: 剖开狗的肚子,取出彩票,然后飞奔到最近的兑现点。

虽然这是在一种场合下对一个问题的解决方案,但它并不是一个模式。那它缺少了什么呢?至少需要三样东西。

(1)再现(recurrence),这使得该解决方案不仅与当前场合下的问题有关,而且与当前场合之外的问题也有关。

(2)教学(teaching),这将教会我们去理解怎样对解决方案加以完善,从而适应问题的变体。(对实际使用的模式来说,与教学有关的大部分内容都包含在对问题的描述、对解决方案的描述以及应用模式后得到的结果中。)

(3)一个用来指代模式的名字

诚然,一个令所有人都满意的定义是很难找到的,从“pattern-discussion”邮件列表(即patterns-discussion@cs.uiuc.edu)中持续的争论可略知一二。其中的困难在于,模式既是事物又是对相似事物的描述。区分两者的一种方法是,统一使用术语模式来表示描述,用模式实例来表示对模式的具体运用。

但是,定义术语可能只是徒劳无功,因为一个定义也许对一部分受众(如程序员)有用,但对另一部分受众(比如掌管公司财政大权的执行官)来说却毫无意义。当然,我也不会尝试在这里给出一个最终的定义。我只想说,任何一个规定模式的组成要素的定义,除了要讨论问题、解决方案和场合之外,还必须涉及再现、教学以及命名。

误解2:“模式只是行话、规则、编程技巧、数据结构……”

我称这种误解为“不以为然”。诚然,将不熟悉的事物归纳成已知的事物对我们来说是一件很自然的事情,尤其是在我们没有兴趣对不熟悉的事物进行深入研究的时候。再者,用新瓶装旧酒并号称创新的事情我们已经屡见不鲜了。保持警惕是应该的。

然而,“不以为然”并没有经验依据,很多时候它只是基于表面相似性的一种看法,还掺和些许轻视的态度。此外,从来没有什么东西是全新的,其实自从每个人出生起,各种模式就已经存在于他们的脑子里了。新的只是我们开始对模式进行命名并把它们记载下来。

来看看上面这句话。事实上的确存在一些模式行话,比如“模式”(pattern)本身、“推动力”(force)、Alexander的“无名的品质”(quality without a name),等等。但我们很难把模式简单归纳成行话。与计算机科学中的大多数领域相比,模式几乎没有引入什么新术语。事实上这是模式的特征,一个好的模式天生就很容易为它的受众所理解。虽然模式可能会用到它所面向的目标领域的行话,但我们几乎不需要为模式定义专门的术语。

模式不是可以盲目应用的规则(否则有悖于模式的教学特性)。模式也不仅仅是编程技巧,虽说“惯用法”关注的是与特定的编程语言有关的模式。“技巧”在我听起来有些贬义,它过分强调了解决方案,而忽略了问题、场合、教学以及命名。

毫无疑问,一项新事物要被接受会经历三个阶段:第一个阶段,它被当作垃圾,无人问津;然后它好像是不可行,无法推广;最后它变得显而易见,理所当然,人们会说:“我们一直以来都是这么做的。”模式目前还没有完全脱离第一阶段。

误解3:“看到了冰山的一角,就等于看到了冰山的全部。”

以偏概全不是一种正当的做法,如果用这种方式来看待模式,那就大错特错了。模式所涉及的领域、内容、范畴和风格非常广泛,而且它们的质量也千差万别。只要随便翻阅Pattern Languages of Program Design[CS95、MRB98、VCK96] 丛书中的一本,就可以感受到这一点。模式和编写模式的人一样多样,也许有过之而无不及。随便举几个例子,Alistair Cockburn、Jim Coplien、Neil Harrison以及Ralph Johnson等,虽然这些作者一开始也曾尝试用多种风格来为不同领域编写模式,但是现在他们早已经超越了这个阶段。因此,仅仅通过少数几个例子就对模式下一个笼统的结论是错误的。

误解4:“模式需要工具或方法的支持才能生效。”

在过去的5年中,我曾经编写过模式,使用过模式,并帮助过别人使用模式,也参与设计过至少一个基于模式的工具[BFY+96]。我可以很有把握地说,模式的优点来自于对模式本身的应用,也就是说不需要任何形式的支持。

当我在谈论这个话题的时候,我通常会指出模式的4个主要优点。

(1)它们提炼出专家的经验和智慧,为普通开发人员所用。

(2)它们的名字组成了一个词汇表,有助于开发人员更好地交流。

(3)系统的文档若记载了该系统所使用的模式,则有助于人们更快地理解系统。

(4)它们使得对系统进行改造变得更加容易,无论系统原来的设计是否采用了模式。

长久以来我原本认为大部分的优点来自第1点。现在我认识到第2点的重要性不亚于第1点。想一想:在软件开发的过程中,开发人员之间的口头及电子形式的交流的信息量有多少字节?我猜即使没有几吉字节,也有好几兆字节。(在我们编写《设计模式》一书的过程中,我保存下来的我们4人之间来往的电子邮件文件大小达数兆字节之多。我认为我们所付出的精力,差不多相当于开发一个小型到中等规模的软件项目。)交流如此多耗时自然多,任何有助于提高交流效率的东西都将为我们节省相当可观的时间。因此,模式使人与人之间的交流更顺畅高效。随着软件开发项目的规模变得越来越大,软件的寿命变得越来越长,我对第3点和第4点的重视程度也在不断提高。

简而言之,模式是供大脑消化吸收的食粮,而不是供工具加工的材料。方法论和自动化的支持对模式可能会有好处,但我相信这些都只是锦上添花的东西而已。

※   ※   ※

我们到目前为止讨论的误解都与模式什么有关。现在让我们来讨论一些关于模式能做什么的误解。这些误解有两类:夸大其词类和轻描淡写类。

误解5:“模式可以保证可复用的软件、更高的生产率、世界和平,等等。”

这个误解很容易批驳,因为模式并没有保证任何东西。它们甚至不能增加从中获取益处的可能性。模式并不能代替人来完成创造,它们只不过给那些缺乏经验但却具备才能和创造力的人带来了希望。

人们说到好的模式,会有恍然大悟之感。只有当模式能够触动心弦时,这种情况才会发生。如果模式无法触动心弦,那么它就像人迹罕至的森林中的一棵大树,纵使轰然倒下也没有人能听到它的声音。模式也是如此:即便它编写得再好,如果不能引起人们的共鸣,那么它好在哪里呢?

模式只不过是开发人员军火库中的另一件武器。将太多东西都归功于模式只能适得其反。要防止夸大其词的宣扬引发抵触情绪,最好的方法就是——少说多做。

误解6:“模式可以‘产生’整体架构。”

这种误解与上一种误解很相似,只不过夸张的程度要轻一些。

在模式的论坛里,定期会有一些关于模式的产生能力(generativity)的讨论。我认为,产生能力指的是模式能够创造新行为(ermergent behavior)的能力。这种表述听起来很酷,其意思是模式能够帮助读者解决模式没有明确解决的一些问题。就我所知,还有一些观点认为,真正的产生能力几乎能够自动实现这一点。

对我来说,产生能力的关键在于模式与教学相关的部分,例如,对问题的描述和对解决方案的描述,或对效果的讨论。在定义和提炼架构的时候,这些见解尤其有用。但模式本身并不能产生任何东西,能够产生东西的是人,只有当人具备足够的经验并且他们使用的模式足够好的时候,他们才能够这样做。而且,模式不可能涵盖架构的方方面面。给我看一个稍有规模的设计,我一定能发现既有模式尚未涉及的许多设计问题。也许这些问题不常见或不经常发生,或者只不过它们尚未被编写成模式的形式。但无论如何,我们需要运用自己的创造力来填补各种现有模式之间的空白地带。

误解7:“模式是用于(面向对象的)设计或实现的。”

误解的另一个极端是过分贬低模式的作用,就像现在讨论的这一种。竟然有人会相信这种说法,坦白地说,我对此感到很惊讶。然而有许多人曾经就这个问题问过我,多到足以让它能够在十大误解中占有一席之地。如果你觉得这种误解太过幼稚,那么可以直接跳到下一种误解。

如果模式不能把专家的经验记录下来,那么它们就一无是处。究竟记录哪些经验则由模式的编写者决定。在面向对象的软件设计中,当然有值得记录的经验,但在非面向对象的设计中,同样有值得记录的经验。不仅在设计领域有值得记录的经验,而且在分析、维护、测试、文档、组织结构等领域都有值得记录的经验。这些不同领域中的模式正在浮现。目前在分析模式领域,已经至少出版了两本书[Fowler97, Hay96],而且每一届的PLoP会议都会吸引一些新型的模式。(提交给1996年会议的一个模式特别有意思,它是关于音乐作曲的模式!)

与大多数的误解一样,这种误解也有一定的道理。如果看一看人们使用模式的形式,就会发现两种基本的风格:一种是《设计模式》一书所使用的高度结构化的GoF风格,另一种是Christopher Alexander的近乎纯文学的风格——叙述的文体,采用尽可能少的结构。在我涉足为面向对象设计以外的领域编写模式之后,才认识到GoF风格是多么地偏向面向对象领域。在我尝试过的其他领域,GoF风格根本不适用。对C++惯用法来说,它的结构图应该是什么样的?对音乐作曲的模式来说,它在实现上的取舍应该是什么样?对于用来撰写好的说明文的模式来说,它的协作部分应该是什么样?

显然,没有任何一种模式能够适用于所有领域。唯一能够适用于任何领域的是一个通用的概念——无论在什么领域,模式都是一种用来记录和传播专家经验的工具。

误解8:“没有证据表明模式对任何人有帮助。”

这种误解在过去还能站得住脚,但现在已经不是那么回事了。人们正在通过各种渠道报道模式所带来的好处,这些渠道包括Software—Practice and Experience [Kotula96]之类的期刊,以及OOPSLA [HJE95, Schmid95]和ICSE [BCC+]之类的会议。Doug Schmidt也曾经表示过,模式对大学生和研究生的计算机科学教学有诸多好处[PD96]。虽然这些大多是定性的分析,但就我所知,至少有一个团体正在进行受控的实验,以获取量化的结果。

随着时间的推移,我们会更加清楚使用模式所带来的好处和隐患。即使最初的反馈非常好,我们仍然需要积累更多的经验,这样才能得到一个更加全面的评估。但是,如果仅仅因为模式所带来的好处还没有被完全量化就拒绝马上开始使用模式,那绝对是很愚蠢的行为。

※   ※   ※

关于模式能够做什么的谬论就到此为止。下面最后两种误解与模式本身无关,而与拥护模式的社区有关。

误解9:“模式社区是一个由精英分子组成的小帮派。”

我很想知道这样的想法从何而来,这是因为如果模式社区确实有哪方面值得一提,那一定是它的多样性。这一点很容易判断,只要看一看PLoP的与会者就可以知道——人们来自世界各地,既有来自大公司也有来自小型创业公司,有分析师、设计师和实现者,有学生和教授,还有大名鼎鼎的作者和新手。而且令我感到惊讶的是,有几个经常参加该会议的与会者竟然不是搞计算机的!模式社区仍然处于不断变动的状态,每年与会者的流动率都相当高。

模式社区里常常有著作发表,但社区中有学术背景的人相对来说却并不多,对此有人可能会感到不解。事实上,PLoP的大多数与会者都是软件行业的从业人员,而且似乎一直以来都是这样。软件模式的早期拥护者们(包括Kent Beck、Peter Coad以及Ward Cunningham)没有一个是来自学术界的。GoF中只有一个(Ralph)来自学术界,而且他是我所见过的最讲究实用性的学者。模式社区的草根本质显然与那些所谓的同种论(homogeneity)和精英论是背道而驰的。

误解10:“模式社区是为自己服务的,甚至是不怀好意的。”

我曾经不止一次听到对模式的责难,说模式的主要用途是作为那些编著模式方面图书的人的收入来源。甚至还有一种说法是模式正朝着一个不可告人的方向发展。

完全是一派胡言!

作为GoF中的一员,我可以非常肯定地说,我们4人和其他任何人一样,对于人们对《设计模式》的反响感到惊讶。毫无疑问,当设计模式在1994年的OOPSLA会议上初次亮相时,我们4人对它所引起的轰动效应完全没有心理准备,读者的大量需求甚至让出版社都感到措手不及。在写书的整个过程中,我们最多的考虑就是尽我们所能来创造一本最高质量的图书。为了深入理解模式的内容,我们已经太忙了,根本无暇考虑销售问题。

当时的情况就是那样。现在模式 已经成为了一个重要的术语,因此有些人想利用它来谋取一些私利也在所难免。但是,如果仔细地阅读那些模式领军人物编写的作品,你就会感受到其中共同的宗旨:将来之不易的专家经验、最佳实践,甚至是竞争优势——多年亲身实践所积累的经验硕果——不仅展露出来而且传授给所有后来者。

正是这种要提升所有读者的软件设计能力的热情,激励着每一位真诚而富有成效的模式编写者。缺少任何一项因素,那只能是适得其反,并最终导致对模式的误解。

澄清了这些误解之后,人们对设计模式的反应不外乎有两种。下面我会通过一个类比来对它们进行描述。

设想有一个电子爱好者,虽然他没有经过正规的培训,但是却日积月累地设计并制造出了许多有用的电子设备:业余无线电、盖革计数器 、报警器等。有一天这个爱好者决定重新回到学校去攻读电子学学位,来让自己的才能得到正式的认可。随着课程的展开,这个爱好者突然发现课程内容都似曾相识。似曾相识的不是术语或表述的方式,而是背后的概念。这个爱好者不断学到一些名称和原理,虽然这些名称和原理原来他并不知道,但事实上他多年以来一直都在使用。整个过程只不过是一个接一个的顿悟。

现在让我们把镜头切换到一个大学新生,这个新生正在同一个班学习同样的课程。他没有电子学的背景,有的只是大量的轮滑经验,没错,但就是没有电子学的背景。对他而言,学习新课程极其吃力,这并不是因为他笨,而是因为这些内容对他来说完全是新的。这个新生需要花更多的时间来理解和领会所有的内容。通过努力学习再加上一点毅力,他最终完成了所有的课程。

如果你觉得自己像一个设计模式爱好者,那么你会更加有动力。如果你觉得自己更像一个新生,那么请振作起来:你在学习好的模式上的付出是不会白费的,只要将它们应用到自己的设计中就会得到回报。我保证。

但对有些人来说,电子学这个类比可能不太贴切,因为其中包含了“电子技师”的内涵。如果你也这样认为的话,那么不妨考虑一下Alfred North Whitehead在1943年说过的一句话,虽然是在不同的场合下说的,但它也许会更加贴切:

艺术就是将一种模式强加于经历,以及识别这种模式时所带来的审美享受。

指此书英文版原书页码。——编者注

我相信不会有比这个比喻更贴切的了。

如果想订阅这个邮件列表,请发邮件到patterns-discussion-request@cs.uiuc.edu,并用单词“subscribe”作为邮件的标题(没有引号)。

Geiger counter,一种用来探测放射能量的仪器。——译者注


如果想体验一下运用模式的感觉,那么最好的方法就是运用它们。对我来说,最大的挑战在于找到一个所有人都能够理解的示例。人们对自己的问题最感兴趣,如果某些人对某个示例越感兴趣,这个示例往往就越具体。问题在于,这样的示例所涉及的问题往往太过晦涩,对于没有相关领域背景的人来说难以理解。

层级文件系统(hierarchical file system)是每个计算机用户都熟悉的东西,就让我们来看看该如何设计它。我们不会关心诸如I/O缓冲和磁盘扇区管理之类的底层实现问题,我们要关心的是设计一个让应用程序开发人员使用的编程模型——文件系统的API。在大多数的操作系统中,这样的API通常包含大量的过程调用和一些数据结构,但对扩展性的支持却很少或根本没有。我们的设计将完全是面向对象的而且是可扩展的。

我们首先集中讨论这个设计最重要的两方面,以及用来对这两方面进行处理的模式。然后我会在这个示例的基础上展示其他模式是如何解决设计问题的。本章的目的并不是要为如何应用模式规定一个严格的流程,也不是要展示设计文件系统的最佳方法,而是要鼓励读者自己应用模式。用得越多,看得越多,你在处理模式的时候就会感到越轻松。最终,你将慢慢地学会应用模式所需的精湛技艺:属于你自己的技艺。

从用户的角度来看,无论文件有多大,目录结构有多复杂,文件系统都应该能够对它们进行处理。文件系统不应该对目录结构的广度或深度施加任何限制。从程序员的角度来看,文件结构的表示方法不仅应该容易处理,而且应该容易扩展。

假设我们正在实现一个用来列出一个目录中文件的命令。我们编写的用来得到一个目录的名字的代码与用来得到一个文件的名字的代码相比,应该没有区别,也就是说,同样的代码应该能够同时处理这两种情况。换句话说,在请求目录的名字和文件的名字时,应该能够以相同的方式处理。这样得到的代码将会更易于编写和维护。我们还想在不重新实现部分系统的前提下,加入新的文件类型(比如符号化链接)。

因此,一开始有两件事情非常清楚:一是文件和目录是这个问题域(problem domain)的关键元素,二是我们需要一种方式,能够让我们在完成设计之后再为这些元素引入特别的版本。一种显而易见的设计方法是用对象来表示这些元素。

我们如何实现图2-1所示的结构呢?有两种对象,这意味着我们需要两个类——一个用来表示文件,另一个用来表示目录。我们还想以同样的方式处理文件和目录,这意味着它们必须有一个共同的接口。更进一步说,这意味着这两个类必须派生自一个共同的(抽象)基类,我们称之为Node。最后,我们还知道目录中包含文件。

图2-1

所有这些约束基本上已经替我们把类的层次结构定义出来了。

Class Node {
public:
  // declare common interface here
protected:
  Node();
  Node(const Node&);
};
Class File : public Node {
public:
  File();
  // redeclare common interface here
};
Class Directory : public Node {
public:
  Directory();
  // redeclare common interface here
private:
  list<Node*> _nodes;
};

另一个需要斟酌的问题与共同接口的组成有关。哪些操作既能够适用于文件又能够适用于目录呢?

文件和目录有各种各样的共同属性,比如名字、大小、保护属性等。每个属性可以有相应的操作来访问和修改它的值。以相同的方式来处理那些对文件和目录都有明确意义的操作是很简单的事情。但是,想以相同的方式来处理那些不能明确适用于两者的操作时,问题就随之而来。

举个例子,用户经常执行的一项操作就是列出一个目录中的所有文件。这意味着Directory需要一个接口来枚举它的子节点。下面这个简单的接口用来返回第n个子节点。

virtual Node* getChild(int n);

由于一个目录既可能包含File对象,也可能包含Directory对象,因此getChild必须返回一个Node*。这个返回值的类型衍生出一个重要的结果:它强制我们不仅要在Directory类中定义getChild,而且还要在Node类中定义该接口。为什么?因为我们想要能够列出目录的子节点。实际上,用户经常想要访问文件系统结构的下一层。除非不用强制转换就能用getChild的返回值来调用getChild,否则是无法通过一种静态的、类型安全的方式来完成这个操作的。因此,和属性操作一样,getChild是我们想要同时用在文件和目录上的操作。

同时,getChild也是允许我们以递归的方式来定义Directory的操作的关键。假设Node声明了一个size操作,这个操作返回该目录树(及其子树)所占用的总字节数。Directory可以这样定义自己的这个操作:依次调用它所有子节点的size操作,将所有的返回值相加,得到的总和就是自己的返回值。

long Directory::size() {
  long total = 0;
  Node* child;

  for (int i = 0; child = getChild(i); ++i) {
    total += child->size();
  }

  return total;
}

目录和文件的例子说明了COMPOSITE模式最关键的几个方面:它产生的树结构可以支持任意的复杂度,它还规定了如何以统一的方式来处理这些树结构中的对象。COMPOSITE模式的意图部分对这些方面进行了描述:

将对象组织成一个树结构来表示“部分—整体”的层次结构,给客户一种统一的方式来处理这些对象,无论这些对象是内部节点(internal node)还是叶节点(leaf)。

适用性部分描述了我们应该在以下场合使用COMPOSITE模式。

该模式的结构部分用一个经过修改的OMT图的形式,描绘了典型的COMPOSITE类结构。之所以说它是典型,我的意思只是它代表了我们(GoF)所见过的类的最为常见的组织方式。它并不能代表最终得到的各个类及其关系,这是因为有时候受到某种设计或实现的影响,我们必须采取一些折中,这种情况下得到的接口可能会有所不同。(COMPOSITE模式同样对这些内容进行了阐述。)

图2-2展示了COMPOSITE模式涉及的各个类,以及这些类之间的静态关系。我们的Node类相当于Component,它是一个抽象基类。File类相当于子类Leaf,而Directory类则相当于子类Composite。从Composite指向Component的箭头线表明Composite包含了Component类型的实例。箭头前面的实心圆圈表示多于一个实例;如果没有实心圆圈,则表示有且仅有一个实例。箭头线尾部的菱形表示Composite聚合了它的子实例,这也意味着删除一个Composite会同样删除它的子实例。它还意味着所有的Component没有被共享,因此确保了树结构。COMPOSITE模式的参与者和协作部分对各个类之间的静态关系和动态关系分别进行了解释。

图2-2

COMPOSITE的效果部分总结了使用该模式的好处和坏处。好处是,COMPOSITE支持任意复杂度的树结构。这个特性产生的直接结果就是对客户代码隐藏了节点的复杂度:他们无法辨别出他们正在处理的Component到底是一个Leaf还是一个Composite,事实上他们也没有必要去辨别,这使得客户代码更加独立于Component的代码。客户代码也变得更加简单,因为它能够以统一的方式来处理Leaf和Composite。客户代码再也不需要根据Component的实际类型来决定要执行许多代码分支中的哪一个分支。最好的是,我们可以添加新的Component类型而无须修改已有的代码。

但是COMPOSITE的坏处在于它可能会产生这样的系统:系统中每个对象的类与其他对象的类看起来都差不多。由于显著的区别只有在运行的时候才会显现出来,因此这会使代码难以理解,即便我们知道类的具体实现也无济于事。此外,如果在一个比较低的层次运用该模式,或者运用该模式时的粒度太细,那么对象的数量可能会多得让系统负担不起。

正如读者可能已经猜到的那样,COMPOSITE模式的实现部分讨论了在实现该模式时会面临的许多问题。

在开发我们的文件系统时,我们将努力解决这些问题中的一部分,以及许多其他问题。

现在让我们深入研究一下在我们的文件系统中运用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

只要subdirAsubdirB已经存在而且是目录而不是文件,那么这条命令就应该能够正确执行。更概括地说,subdirAsubdirB应该是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;
    }
  }
}

这里headsubpath是字符串处理例程。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类都以统一的方式来处理adoptorphan

好了,好了。我知道你在想,“天啊!这些操作对File之类的叶节点来说毫无意义。”但这样的假设是不是切合实际呢?如果今后有人想定义一种新的类似垃圾箱(说得更准确一些,是回收站)的叶节点,它可以销毁它收养的所有子节点,那么这种情况该怎么处理?如果想在叶节点收养子节点时产生一条错误消息,那么这种情况又该怎么处理?我们很难证明adopt对叶节点来说绝无意义,orphan同样也是如此。

另一方面,有人可能会争辩说一开始就没有必要把File类和Directory类分开——所有的东西都应该是Directory。这样的论点是合理的,但是从实现的角度来说,它存在一些问题。一般来说,Directory对象中的许多内容对大多数文件来说是不必要的,比如用来存储子节点的数据结构、用来对子节点信息进行高速缓存以提高性能的数据结构,等等。经验表明,在许多应用程序中,叶节点的数量通常要比内部节点的数量多得多。这也是为什么COMPOSITE模式要把Leaf和Composite类分开的原因。

让我们来看一看,如果我们不仅仅只在Directory类中定义adoptorphan,而是在所有的Node类中定义adoptorphan,那将发生什么情况。我们让这些操作在默认的情况下产生错误消息。

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) {
  // ...
}

关键在于:虽然看起来我们不应该以统一的方式来处理adoptorphan操作,但这样做实际上是有好处的,至少在这个应用程序中如此。另一种最有可能的选择是引入某种形式的向下转型,让客户来确定节点的类型。

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<Directory*>(node);
      if (child) {
        mkdir(child, subpath);
      } else {
        cerr << getName() << " is not a directory."
           << endl;
      }
    } else {
      cerr << name << " nonexistent." << endl;
    }
  }
}

想必你已经注意到dynamic_cast引入了额外的检查和分支。为了能够处理用户在path中指定了无效目录名的情况,这样做是必需的。这个例子同时说明了不统一性会让客户代码变得更加复杂。

这并不是说不统一性在任何情况下都是不恰当的。某些应用程序可能不允许在叶节点上调用与子节点有关的操作,因此对这类应用程序来说,能够让编译器检测出这种情况是至关重要的。在这些情况下,不应该把adoptorphan以及类似的操作声明在基类中。但是,如果以统一的方式处理叶节点和内部节点并不会引起严重的后果,那么在这些情况下统一性通常会带来简单性和扩展性,这一点我们很快就会看到。

很高兴你能提出这个问题,因为我们现在正打算添加一个新的功能——符号化链接(symbolic link,它在Mac Finder中被称为别名,在Windows 95中被称为快捷方式)。符号化链接基本上是对文件系统中另一个节点的引用。它是该节点的“代用品”(surrogate),它不是节点本身。如果删除符号化链接,它会消失但不会影响到它所引用的节点。

符号化链接有自己的访问权限,这个访问权限与它引用的节点的访问权限可能是不同的。但是在大多数情况下,符号化链接都表现得和节点本身一样。如果一个符号化链接引用的是文件,那么客户代码可以将这个符号化链接当作该文件来处理。举个例子,客户代码可以编辑文件,或许还可以通过符号化链接来把对文件的改动保存起来。如果一个符号化链接引用的是目录,那么客户代码可以通过符号化链接来执行在目录中添加或者删除节点的操作,就好像这个符号化链接是目录本身一样。

符号化链接非常方便,它使我们无需移动或复制远在另一个地方的文件,就可以访问它们。对那些必须保存在一个地方但需要在另一个地方使用的节点来说,这很棒。如果我们的设计不支持符号化链接,那将是我们的失职。

因此,那些花钱买了《设计模式》的人应该问的第一个问题就是:有没有哪个模式可以帮助我们设计和实现符号化链接?事实上,还有一个更大的问题:我们如何找到正确的设计模式来解决手头的问题?

《设计模式》一书的1.7节给出了以下6个步骤。

(1)考虑设计模式如何解决设计问题。(换句话说,学习1.6节。但你我都知道在紧张的开发过程中这样做的可能性有多大。)

(2)快速浏览那些可能合适的模式的意图部分。(有点蛮干的意思。)

(3)研习模式之间是如何相互联系起来的。(这对我们来说仍然太过复杂,但我们已经很接近了。)

(4)看看哪些模式的目的(创建型、结构型和行为型)能与我们正在解决的问题对应起来,并查看这些模式。(嗯,给文件系统添加符号化链接看起来和结构有关。)

(5)审视引起重新设计的相关因素(在《设计模式》的第24页中列出),并运用那些能够帮助避免这些因素的模式。(因为我们现在根本还没有设计,所以重新设计看起来有些为时过早。)

(6)考虑一下设计中哪些部分应该是可变的。针对每一个设计模式,《设计模式》的第30页中的表1-2列出了该模式允许设计的哪些方面发生变化。

让我们沿着第6条的方向继续前进。如果看一看表1-2中的结构型模式,我们会发现如下内容。

也许我有一些偏见,但听起来PROXY像是我们要找的模式。翻到该模式,我们找到了它的意图:

为另一个对象提供一个代用品或占位符,以便控制对它的访问。

动机部分将该模式运用于延迟载入图像的问题(这与我们在Web浏览器中想要实现的效果不无相似之处)。

但让我们最终敲定PROXY模式的,是它的适用性部分。其中阐述了当我们需要对一个对象进行引用,但这个引用需要具备更多的功能或要比一个简单的指针更加复杂时,PROXY模式就适用。其中还列出了一些它适用的常见情形,其中包括一个用来控制对另一个对象的访问的“保护代理”(protection proxy)——这恰恰是我们需要的。

好了,现在我们如何将PROXY模式运用到文件系统的设计中去呢?看一下该模式的结构图(如图2-3所示),我们会看到三个关键的类:一个Subject抽象类,一个RealSubject具体子类,还有另一个Proxy具体子类。因此我们可以推断出Subject 、RealSubject和Proxy有兼容的接口。Proxy子类还包含一个对RealSubject的引用。

图2-3

该模式的参与者部分解释了Proxy类提供的接口与Subject的完全相同,这使得Proxy对象能够替代任何Subject对象。此外,RealSubject是Proxy所代表的对象。

把这些关系映射回我们的文件系统类,显然我们想要遵循的共同的接口是Node的接口。(毕竟那是COMPOSITE模式教给我们的。)这意味着Node类在Proxy模式中扮演的角色是Subject。

下面我们需要为Node定义一个子类来与Proxy模式中的Proxy类相对应。我称之为“Link”。

class Link : public Node {
public:
  Link(Node*);

  // redeclare common Node interface here
private:
  Node* _subject;
};

成员_subject用来引用实际对象。但是我们似乎有些偏离了该模式的结构图,结构图中引用的类型是RealSubject。在此例中,这相当于引用的类型是File或Directory,但我们仍然想让这两种类型的节点都可以使用符号化链接。我们应该怎么办?

如果看一看PROXY模式中对Proxy参与者的描述,我们会发现下面的语句:

[Proxy]用来维护一个引用,并让该代理访问实际对象。如果RealSubject和Subject具有相同的接口,那么Proxy也可以引用Subject。

根据前面的讨论,File和Directory共享了Node接口,这正是上面描述的情况。因此_subject是指向Node的指针。如果没有一个共同的接口,要定义一种能够同时用于文件和目录的符号化链接是非常困难的。事实上,我们最终可能会定义出两种符号化链接,除了一种用于文件而另一种用于目录之外,两者的工作方式完全相同。

我们要解决的最后一个主要问题是Link如何实现Node接口。基本上,只要把每个操作委托给_subject中与之对应的操作就可以了。因此getChild的实现可能是下面这样。

Node* Link::getChild (int n) {
  return _subject->getChild(n);
}

在某些情况下,Link所表现出来的行为可能并不依赖于它的subject。例如,Link可能会定义自己的保护操作,在这种情况下,它会像File那样来实现此类操作。

※   ※   ※

Laurion Burchall就PROXY模式的应用提出了他敏锐的见解[Burchall95]:

如果一个文件被删除了,那么指向它的代理将变成一个无关联指针(dangling pointer)。当一个文件被删除时,我们可以使用OBSERVER模式通知所有的代理,但这种方法不允许我们把新文件移动到旧文件的位置,从而让符号化链接继续工作。

在Unix和Mac中,符号化链接持有的是被引用文件的名字,而不是具体的对象。一个代理可以持有该文件的名字并引用文件系统的根目录。但由于每次都要查找名字,因此这会大大增加通过代理来访问文件的开销。

除了与OBSERVER有关的那部分之外,上面说的这些都没错。当代理指向的文件被替换掉的时候,我们完全可以通知代理并让它和文件重新关联起来。在这方面,替换和删除是相似的。

但Laurion的观点仍然是正确的:虽然只保持一个指向subject的指针是非常高效的,但如果不增加一些新的机制,那么这种做法很难让人满意。如果想把一个subject替换掉但又不把指向它的链接作废,就需要一个额外的间接层,而我们现在还没有。我们可以用存储文件名来代替存储对象指针,但是为了把文件名高效地映射到对象,这种方法可能需要某种类型的关联存储器(associatiue store)。即便如此,与只存储一个指针相比,这种方法仍然需要额外的开销。但是,除非指向文件的符号化链接太多,或者符号化链接的层次太多,否则这不应该是什么问题。当然,当文件被删除或者被替换的时候,关联存储器也必须要更新。

我倾向于不考虑这些不常用的情况,目的是为了让常用的情况能够快速运行。如果通过符号化链接来访问一个文件要比通过符号化链接来替换或删除一个文件更加常用——我认为事实的确如此,那么我更加倾向于采用基于OBSERVER的方法,而不是采用基于名字查找的方法。

※   ※   ※

在像这样的设计逐渐形成的过程中,需要注意的是不要把基类变成一个大杂烩:随着时间的推移,接口中的操作持续累积,接口不断膨胀。文件系统的每个新特性都会增加一两个操作。今天是为了支持可扩展属性,下个星期是为了计算一种新类型的文件大小统计数据,下个月是为了给图形用户界面返回图标。不用多久,Node就变成了一个巨型类——难以理解,难以维护,难以从中派生子类。

我们接下来就来解决这个问题。我们要寻找一种根本不需要对已有的类做任何改动就能在设计中添加新操作的方法。

到目前为止我们已经运用了两个设计模式:我们用COMPOSITE来定义文件系统的结构,用PROXY来帮我们支持符号化链接。把我们讨论到现在的改动和其他一些改进合并起来,得到了如图2-4所示的体现了COMPOSITE模式和PROXY模式的类层次结构。

图2-4

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);
}

在使用VISITOR模式之前,有两件事情需要考虑。

首先问一下自己,被访问的类层次结构是否稳定?拿我们的例子来说,我们是否会经常定义新的Node子类,还是说这种情况很少见?增加一种新的Node类型可能会迫使我们仅仅为了增加一个相应的visit操作而修改Visitor类层次结构中所有的类。

如果所有的visitor对新的子类不感兴趣,而且我们已经定义了一个与visitNode等价的操作来在默认情况下进行合理的处理,那么就不存在问题。但是,如果只有一种类型的visitor对新的子类感兴趣,那么我们至少必须对该visitor和Visitor基类进行修改。此外,在这样的情况下,进行多处修改很可能是不可避免的。如果我们没有使用VISITOR模式,而是把所有功能塞到了Node类层次结构中,那么可能我们最终也要对Node类层次结构进行多处修改。

我们要考虑到的第二点是,VISITOR模式在Visitor和Node类层次结构之间创建了一个循环依赖关系。因此,对任何一个基类的接口进行修改,很可能会促使编译器对这两个类层次结构都进行重新编译。当然,和修改一个大杂烩基类相比,这可能也差不到哪里去。但在一般情况下,我们希望避免这样的依赖关系。

※   ※   ※

下面是Kevlin Henney [Henney96]的一些相关见解:

C++重载机制并没有强迫我们必须重载visit的所有版本,或者必须放弃重载visit成员。

using声明不仅用来支持名字空间的概念,它还允许我们把基类中的名字注入到当前类中来帮助重载。

class NewVisitor : public Visitor {
public:
  using Visitor::visit;    // pull in all visit functions
                  // for overloading

  virtual void visit(Subject**); // override Subject**variant
};

这种方法不仅保持了重载所提供的规整性,而且还可以防止扩散。它不会强迫用户去记住[visit]系列函数用了哪些名字或用了什么命名约定。这种方法使我们能在新版本中对Visitor进行修改,同时不会对客户代码产生影响。

※   ※   ※

我们已经运用了两个模式(即COMPOSITE模式和PROXY模式)来定义文件系统结构,还运用了一个模式来以一种无扩散的方法(即添加代码而不是修改代码)来引入新功能。其中蕴含了一条很好的面向对象设计准则,也许属于老生常谈,但却值得一提:通过在不修改已有代码的前提下改变一个系统的行为,可以使系统达到最佳的灵活性和可维护性。如果在别人使用了你的软件之后,你仍然能够这样说,那么恭喜你——你已经兑现了对象技术的诸多承诺!

扯远了。我们的文件系统的另一个主要设计问题与安全性有关。它至少有两个相关的子问题。

(1)对文件系统进行保护,使之避免遭到无意或恶意的破坏。

(2)在面临硬件和软件故障时,依然能够维护文件系统的完整性。

这里我们将集中讨论第一个子问题,第二个子问题留给读者作为练习。(如果有谁愿意接受这个挑战,我将很乐意为他的解决方案评分。)

经常使用计算机的人大都有过丢失重要数据的惨痛经历,起因可能只是一个不巧的语法错误,也可能是鼠标点偏了,或者只是深夜脑子突然不好使。在正确的时间删除一个错误的文件是一种常见的灾难。另一种情况是无意的编辑——在不经意间修改了一个不应该修改的文件。虽然一个高级文件系统会具备撤销功能,可以从这些不幸的事件中恢复,但我们通常更希望防患于未然。可悲的是,大多数文件系统给我们另一种不同的选择:预防或后悔

目前我们将集中精力讨论对文件系统对象(即节点)的删除和修改操作进行保护。之所以考虑保护是因为它与编程接口有直接的联系,与用户界面并没有直接的联系。我们不需要为两者的区别担心,因为我们编程时使用的抽象与用户级的抽象是有紧密联系的。另外,我们假设所使用的文件系统是一个单用户文件系统,这和一台标准的不具备网络功能的个人计算机(与之相对的是多用户计算机系统,比如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应该失效。如果一个节点是不可写的,那么它应该禁止用户修改它的属性和结构,因此setNamestreamInadopt以及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通过定义doSomethingdoSomethingElse操作来实现默认的行为,子类可以对它们进行特化来执行不同的操作。在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基类的成员,因此它能够在不破坏封装的前提下调用受保护的操作,比如isWritabledelete

现在除了析构函数之外,destroy只用到了一个基本操作。为了避免把错误消息直接写在基类中,我们应该增加另一个基本操作来让子类修改错误消息。

void Node::destroy (Node* node) {
  if (node->isWritable()) {
    delete node;
  } else {
    node->doWarning(undeletableWarning);
  }
}

doWarning对警告操作进行了抽象,它允许节点就任何问题给用户以警告,而不仅仅是就无法删除节点这一个问题给用户以警告。它可以非常复杂,它可以执行任何操作,包括打印一行字符串到抛出一个异常。有了doWarning操作, 就无须为我们能想到的每种情况定义基本操作了(如doUndeletableWarningdoUnwritableWarningdoThisThatOrTheOtherWarning等)。

我们可以将TEMPLATE METHOD方法运用到Node的其他操作中,这些操作恰好不是静态的。为此,我们引入了新的基本操作。

void Node::streamOut (ostream& out) {
  if (isReadable()) {
    doStreamOut(out);

  } else {
    doWarning(unreadableWarning);
  }
}

streamOutdestroy这两个模板方法的主要区别在于,streamOut可以直接调用Node的各种操作。由于destroy不能引用this,因此它无法直接调用Node的操作。这也是为什么我们必须将待删除的节点传给destroy的原因,这样它就可以把待执行的操作委托给节点的基本操作了。另外要记住的是,我们在把streamOut升级为模板方法的同时,把它变成了虚函数。

※   ※   ※

TEMPLATE METHOD模式导致了一种被称为好莱坞原则的反向控制,或者说“不要调用我,我会调用你”。子类可以对算法中可变的部分进行扩展或重新实现,但它们不能改变模板方法的控制流和其余不变的部分。因此,当我们为Node类定义一个新的子类时,我们要考虑的不是控制流,而是责任——我们必须覆盖哪些操作,我们可以覆盖哪些操作,以及我们不能覆盖哪些操作。以模板方法的形式来组织我们的操作使得这些责任变得更加明确。

好莱坞原则非常有意思,因为它是理解框架的关键之一。它让框架将体系结构和实现细节中不变的部分记录下来,而将可变的部分交给与应用程序相关的子类。

有些人不太适应框架编程,反向控制就是其中原因之一。当我们以过程化的方式来编写代码时,我们会在极大程度上关注控制流。对于一个过程化的程序来说,即使它对函数的分解无可挑剔,但如果我们不了解其中的奥妙,那么很难想象我们能够理解整个程序。但一个好的框架会把控制流的细节抽象出来,这样我们最终要加以关注的是对象。相比之下,这种方式从一方面来看比控制流更容易理解,但从另一方面来看也比控制流更不容易理解。我们必须从对象的职责和协作方面来考虑。它从一个更高的层次来看整个系统,它的视角更加侧重于做什么而不是怎么做,它具备更大的潜在作用力和灵活性。与框架相比,TEMPLATE METHOD模式在一个较小的规模上——操作级别而不是对象级别——提供了这些好处。

我们已经讨论了如何给我们正在设计的文件系统添加简单的单用户保护。前面提到我们会将这个概念扩展到多用户环境,在这个环境中许多用户共享同一个文件系统。无论是配以中枢文件系统的传统分时系统,还是当代的网络文件系统,对多用户的支持都是必不可少的。即使那些为单用户环境所设计的个人计算机操作系统(如OS/2和Windows NT),现在也已经支持多用户。无论是什么情况,多用户支持都给文件系统保护这一问题增加了难度。

我们将再一次采用最简易的设计思路,效仿Unix系统的多用户保护机制。Unix文件系统中的一个节点是与一个“用户”相关联的。在默认的情况下,一个节点的用户就是创建该节点的人。从节点的角度来看,这种关联把所有用户分为两类:该节点的用户,以及其余所有的人。用标准的Unix术语来说,“其余所有的人”就是other

通过对一个节点的用户和其他人进行区分,我们可以给每种类型分别指定保护级别。例如,如果一个文件对它的用户是可读的,但对其他人是不可读的,那么我们说该文件是“用户可读的”但“其他人不可读的”。对写保护和可能会提供的其他保护模式(如扩展性、自动存档等)来说,它们的工作方式与此相似。

用户必须有一个登录名,无论是对系统还是对其他用户来说,这个登录名都唯一标识了该用户。虽然在现实中一个人可以有多个登录名,但对系统来说,“用户”和“登录名”是不可分割的。重要的是要保证一个人不能将自己与任何不属于他的登录名(假设他有一个登录名)相关联。这就是为什么我们在登录到Unix系统时,不仅需要提供登录名,而且还要提供密码来验证身份的原因。这个过程被称为身份验证。Unix不遗余力地对伪装加以防范,这是因为冒名顶替者能够访问合法用户能够访问的任何东西。

现在可以讨论具体的细节了。我们该如何对用户进行建模?作为面向对象的开发人员,答案很显然:使用对象。每个对象都有一个类,因此我们要定义一个User类。

我们现在需要考虑User类的接口。客户代码能够用User对象做什么?事实上,在目前的阶段更重要的是客户代码不能用User对象做什么。特别是,我们不应该允许客户代码随意创建User对象。

为了理解其中的原因,让我们假设User对象和登录名之间存在一对一的映射。(虽然我们可以允许一个登录名有多个User对象,但目前这样的需求尚不明确。)进一步假设一个User对象必须有一个合法的登录名与之相关联。这个假设是合理的,因为从系统的角度而言,没有登录名的用户是没有意义的。最后,如果客户没有同时提供登录名和密码,那么我们不能让他们创建User对象。否则,一个流氓程序只需要用相应的登录名创建User对象,就可以访问机密的文件和目录了。

一个User对象的存在代表了一次身份验证。于是很明显,我们必须对创建User对象实例的过程严加控制。如果应用程序提供了错误的登录名或密码,那么创建User对象实例的尝试应该失败,而且同时不会产生不完整的User对象,也就是那些由于在创建的时候缺乏必需的信息而导致无法正常使用的User对象。这几乎排除了我们用传统的C++构造函数来创建User实例的可能性。

我们需要一种安全的方法来创建User对象,在客户代码使用的接口中,这种方法不应该涉及构造函数。这里安全的意思是,客户代码应该无法通过任何不正当的方式来创建User对象的实例。那么我们如何用面向对象的术语来表述这样的安全性呢?

让我们考虑一下面向对象概念的三个基本要素:继承、封装和多态。其中与安全性最相关的非封装莫属。事实上,封装是安全性的一种形式。根据定义,客户代码是肯定无法访问封装后的代码和数据的。那么在我们的例子中我们想要封装什么?至少应该包括整个身份验证的过程,这个过程以用户的输入为开始,以User对象的创建为终止。

我们已经找到了问题。现在我们需要寻找一个解决方案,并用对象来把这个解决方案表达出来。也许现在是看一些模式的时候了。

此时此刻,我承认在模式的选择上,我们还没有什么指导方法。但我们知道对象的创建和封装都是非常重要的部分。为了缩小搜索的范围,《设计模式》根据每个模式的目的将它们分为三组:创建型、结构型以及行为型。其中创建型模式看起来和我们的问题联系最紧密:ABSTRACT FACTORY、BUILDER、FACTORY METHOD、PROTOTYPE以及SINGLETON。因为一共只有5个模式,所以我们可以先快速浏览每个模式,看是否能从中找到一个合适的模式。

ABSTRACT FACTORY关注的是创建一系列的对象而无需指定具体的类。这很好,但我们的设计问题并没有涉及一系列对象,而且我们也不反对创建具体的类(即User类)的实例。因此我们就排除了ABSTRACT FACTORY。下一个是BUILDER,它关心的是创建复杂的对象。它让我们使用相同的一些步骤来构造对象,而这些对象具有不同的表现形式,这和我们的问题没有太大的关系。除了没有强调对系列的支持,FACTORY METHOD的意图与ABSTRACT FACTORY相似,因此它和我们的问题也没有很紧密的联系。

PROTOTYPE怎么样?它把待创建对象实例的类型放到参数中。这样我们就可以用一个实例(这个实例在运行的时候是可以替换的)作为原型,并调用它的copy操作来创建新的实例,而无需编写代码用new操作符和类名(在运行的时候是无法改变的)来创建新的实例。如果要改变被实例化的对象的类,只需用一个不同的实例作为原型即可。

但这也不对。我们的兴趣并不在于改变要创建什么对象,而在于对客户代码如何创建User对象加以控制。由于任何人都可以对原型进行复制,因此和原始的构造函数相比,我们并没有获得更多的控制权。此外,在系统中保持一个User对象作为原型会对我们的身份验证模型产生危害。

剩下的只有SINGLETON了。它的意图是确保每个类只有一个实例,并提供一个全局访问点来访问该实例。该模式规定了一个名为Instance的静态成员函数,该函数不带任何参数,它会返回这个类的唯一实例。为了防止客户代码直接使用构造函数,所有的构造函数都是受保护的。

乍一看这似乎也不怎么适用——一个程序可能需要一个以上的User对象,不是吗?但即便我们不想把实例的数量限制为只有一个,我们确实想禁止每个用户有一个以上的实例。无论是哪种情况,我们都要对实例的数量加以限制。

因此,SINGLETON可能还是适用的。再仔细看一下SINGLETON的效果部分,我们发现了下面的描述:

[SINGLETON] 允许实例的数量是可变的。该模式让我们能够非常容易地改变想法来允许SINGLETON类有一个以上的实例。此外,我们还可以用相同的方法来对应用程序使用的实例数量加以控制。只有那个有权访问SINGLETON实例的[Instance]操作才需要修改。

就是它了!我们的情况正是SINGLETON模式的一个变体,我们可以将Instance操作重新命名为logIn并给它指定一些参数。

static const User* User::logIn(
  const string& loginName, const string& password
);

logIn确保只为每一个登录名创建一个实例。为了达到这个目的,User类可能会维持一个私有的静态散列表,该散列表以登录名为索引,用来保存User对象。logIn在这个散列表中查找loginName参数。如果找到了对应的User项,那么就返回该项,否则logIn就执行下面的操作。

(1)创建一个新的User对象,通过密码来进行身份验证。

(2)在散列表中登记该User对象,以便今后访问。

(3)返回该User对象。

下面对User::logIn操作的属性进行了总结。

必须承认的是,这是对SINGLETON模式的一种非正规应用。客户代码能够创建一个以上的User实例,这意味着我们并未严格遵循该模式的意图。但是,我们确实要对实例的数量进行控制,而该模式对此也提供了支持。我们都明白,模式并不代表唯一的解决方案。一个好的模式不仅仅只是对一个问题的解决方案的描述,它还给我们以洞察力和理解力,从而能够对解决方案进行修改,使之符合我们自己的情况。

即便如此,SINGLETON并没有告诉我们一切。例如,由于我们已经提供了logIn操作,因此如果客户代码期望我们提供一个对应的logOut操作来让用户登出系统,那将是个合理的要求。logOut将会引出一些重要的问题,这些问题与Singleton对象的内存管理有关。然而奇怪的是,SINGLETON模式对这些问题却只字未提。我们将在第3章就这些问题展开讨论。

※   ※   ※

下一个问题:客户代码如何使用User对象?为了找到答案,让我们先来看一些用例。

首先,考虑登录的过程。假设当用户想要访问系统(或者至少是系统中受保护的部分)时,系统会执行一个登录程序。登录程序会调用User::logIn来得到一个User对象。然后登录程序通过某种方式让其他应用程序能够访问该User对象,这样该用户就不必再次登录了。

其次,让我们考虑一个应用程序如何访问一个几天前创建的文件,该文件的创建者的登录名为“johnny”。假设这个应用程序的用户登录名为“mom”,并且该文件是用户可读的但其他人不可读的。因此,系统不应该允许“mom”访问该文件。在单用户系统中,应用程序通过调用streamOut操作并在参数中传入一个流,来要求得到文件的内容。

void streamOut(ostream&);

我们希望这个调用在多用户的情况下最好保持不变,但它没有在参数中引用正在访问该文件的用户。缺少了这个引用,系统将无法确保用户具有访问文件的权限。我们要么将这个引用在参数中显式地传入。

void streamout(ostream&, const User*);

要么通过登录过程隐式地得到这个引用。大部分的情况下,应用程序会在整个生命周期中代表唯一一个用户。在这种情况下,需要不停地在参数中提供User对象是很烦人的。但是,一个协作式的应用程序可供多个用户使用,这很容易想象而且很合理。在这种情况下,给每个操作指定User对象是必需的。

因此,我们需要给Node接口中的每个操作增加一个const User*参数——但同时不应该强迫客户代码必须提供该参数。默认参数所提供的灵活性让我们能够得体地处理这两种情况。

const string& getName(const User*= 0);
const Protection& getProtection(const User*= 0);

void setName(const string&, const User*= 0);
void setProtection(const Protection&, const User*= 0);

void streamIn(istream&, const User*= 0);
void streamOut(ostream&, const User*= 0);

Node* getChild(int, const User*= 0);
void adopt(Node*, const User*= 0);
void orphan(Node*, const User*= 0);

通常情况下,用户是隐式的,我们需要一个全局可访问的操作来得到这个唯一的User实例。这就是SINGLETON,但为了灵活性,我们还应该允许应用程序设置SINGLETON实例。因此,我们不使用唯一的User::Instance静态操作,而是使用下面的getset静态操作。

static const User*User::getUser();
static void User::setUser(const User*);

setUser让应用程序将隐式的用户设置为任何const User*,当然这个const User*应该是通过正当手段得到的。现在登录程序就可以调用setUser来设置全局User实例了,因为其他应用程序也应该使用该实例

extern const int maxTries;
// ...
const User* user = 0;

for (int i=0; i< maxTries; ++i) {
  if (user = User::logIn(loginName, password)) {
    break;
  } else {
    cerr << "Log-in invalid!" << endl;
  }
}

if (user) {
  User::setUser(user);

} else {
  // too many unsuccessful log-in attempts;
  // lock this login name out!
  // ...
}

到目前为止,一切都很容易理解。但我一直在回避一个简单的问题:所有这些是如何对streamOut和Node接口中的其他模板方法的实现产生影响的?或者更加直接一点,它们如何使用User对象?

与单用户的设计相比,关键的区别并不在于模板方法本身,而在于返回类型为布尔值的基本操作。例如,streamOut变成了如下所示。

void Node::streamOut (ostream& out, const User*u) {
  User* user = u ? u : User::getUser();

  if (isReadableBy(user)) {
    doStreamOut(out);

  } else {
    doWarning(unreadableWarning);
  }
}

在第二行我们可以看到一个明显的区别。如果参数中指定了用户,那么局部变量user会被初始化为指定的用户,如果参数中没有指定用户,那么user会被初始化为默认的User对象。但更为显著的区别是在第三行,其中isReadableBy取代了isReadableisReadableBy根据存储在节点中的信息,检查该节点是用户可读还是其他人可读。

bool Node::isReadableBy (const User*user) {
  bool isOwner = user->owns(this);

  return
    isOwner && isUserReadable() ||
    !isOwner && isOtherReadable();
}

isReadableBy揭示了对User::owns的需求——这个操作检查User对象中的登录名以及与节点相关联的登录名。如果两者的值相同,那么该用户拥有节点。owns操作需要一个接口来从节点获取登录名。

const string& Node::getLoginName();

节点也需要isUserReadableisOtherReadable之类的基本操作,这些操作就用户和其他人是否能够读写节点提供了更详细的信息。Node基类可以在实例变量中保存一些标志,并将这些操作简单地实现为对这些标志的访问,或者也可以将此类与存储有关的细节交给子类去处理。

※   ※   ※

我们已经讨论了足够多的细节,现在让我们重新回到设计层面。

读者应该还记得我们把世界一分为二——用户和其他人。但那样可能有些过于极端了。举个例子,如果我们正在和一些同事开发一个项目,那么我们很可能想要访问彼此的文件。我们可能还要对文件进行保护,使开发组之外的人员无法窥探它们。这正是UNIX提供了第三种类型(即group)的原因之一。一个group是经过命名的一组登录名。使节点成为组可读(group-readable)或组可写(group-writable)使我们能够对访问权限进行精细的控制,对需要相互协作的工作环境来说,这样的控制能够很好地满足要求。

要在设计中加入组的概念,我们需要了解哪些信息?我们知道两条信息。

(1)一个组有零个或多个用户。

(2)一个用户可以是零个或多个组的成员。

第二条意味着应该使用引用,而不应该使用聚合,因为删除一个组并不会删除其中包含的用户。

根据我们对组的理解,用对象来表示它们再合适不过了。问题是,我们是要定义一个新的类层次,还是只对已有的类层次进行扩展呢?

我肯定User类是唯一适合扩展的候选类。另一种选择是将Group类定义为一种类型的Node,这种选择既没有意义也没有用处。因此让我们来考虑一下Group和User之间的继承关系会给我们带来什么。

我们已经熟悉了COMPOSITE模式。它描述了叶节点(如File)和复合节点(如Directory)之间的递归关系。它给所有的节点以完全相同的接口,这样我们不仅能以统一的方式来处理它们,而且能以层级的形式来组织它们。也许我们想要的是用户和组之间的复合关系:User是Leaf类,而Group是Composite类。

让我们再回顾一下COMPOSITE模式的适用性部分,其中提到该模式适用于下面的情况。

根据这些标准,我们可以确信COMPOSITE模式并不适用。下面三条就是原因。

这三条原因反驳了User和Group之间的复合关系。但由于系统必须记录哪些用户隶属哪些组,因此我们仍然需要在用户和组之间建立某种关联。

事实上,为了得到最佳的性能,我们需要双向映射。系统中用户的数量很可能要比组的数量多得多,因此系统必须能够在不检查每个用户的前提下,确定一个组中的所有用户。查找一个用户隶属的所有的组也同样重要,因为这可以让系统更快地检查用户是否是某个组的成员。

实现双向映射的一个显而易见的方法是给Group和User类增加集合:在Group类中加入一个节点集合,在User类中加入一个组集合。但这种方法有两个严重的缺点。

(1)映射关系难以修改。我们至少必须修改一个基类,甚至可能要修改两个基类。

(2)所有对象都必须承担集合的开销。不包含任何用户的组需要承担集合的开销,不属于任何组的用户也需要承担集合的开销。即使在开销最小的情况下,每个对象仍然需要额外存储一个指针。

组和用户之间的映射不仅复杂,而且可能会发生变化。上面这个显而易见的方法将管理和维护映射的职责分散了,从而导致了刚才提到的缺点。有一个不太显而易见的方法可以避免这些缺点,那就是将职责集中起来

MEDIATOR模式将对象间的交互升格为完整的对象状态。通过不让对象显式地相互引用,它促进了松耦合,从而让我们能够单独地改变对象之间的交互,同时无需改变对象本身。

图2-5是在应用该模式之前的典型情况。有许多相互交互的对象(该模式将它们统称为colleague),每一个colleague都直接引用了(几乎所有的)其他colleague。

图2-5

图2-6所示是应用该模式后的结果。

图2-6

该模式的核心是一个Mediator对象,它对应于第二张图中的Grouping对象。各colleague之间没有显式地相互引用,而是引用了mediator。

在我们的文件系统中,Grouping对象定义了用户和组之间的双向映射。为了使映射易于修改,该模式为所有的Mediator对象定义了一个抽象基类,我们可以从这个基类派生与映射有关的子类。下面是Grouping(mediator)提供的一个简单接口,该接口让客户代码实现用户与组之间的注册和注销操作。

class Grouping {
public:
  virtual void ~Grouping();

  static const Grouping*getGrouping();
  static void setGrouping(const Grouping*, const User*= 0);

  virtual void register(
    const User*, const Group*, const User*= 0
  ) = 0;

  virtual void unregister(
    const User*, const Group*, const User*= 0
  ) = 0;
  virtual const Group*getGroup(
    const string& loginName, int index = 0
  ) = 0;
  virtual const string& getUser(
    const Group*, int index = 0
  ) = 0;

protected:
  Grouping();
  Grouping(const Grouping&);
};

这个接口中第一个要注意的地方是静态的getset操作,这两个操作类似于我们将SINGLETON模式应用于User类时定义的静态操作。我们在此应用SINGLETON模式也是出于相同的原因:映射需要是全局可访问和可设置的。

通过在运行的时候替换Grouping对象,我们能够一下子改变映射。例如,也许出于监管的目的,一个超级用户能够对映射进行重定义。我们必须对修改映射的操作进行严密保护,这也是为什么客户代码在调用setGrouping时必须传入一个经过身份验证的const User*的原因。与此类似,在建立或解除映射关系时,传给registerunregister操作的用户参数也必须经过身份验证。

最后两个操作getGroupgetUser用来得到相应的组和用户。可选的索引参数为客户代码提供了一种便捷的方式来遍历多个值。具体子类可以给这些操作定义不同的实现。注意,这些操作并没有直接用到User对象,而是用一个字符串来表示相应的登录名。这使得任何客户代码即使无权访问某个User对象,仍然可以知道该用户与哪些组相关联。

※   ※   ※

MEDIATOR模式的隐患之一是它有产生巨型Mediator类的趋向。由于Mediator类封装的交互可能非常复杂,因此它可能会成为一个难以维护和扩展的巨型类。运用其他一些模式有助于预防这样的可能性。例如,我们可以使用TEMPLATE METHOD来允许子类对mediator的部分行为进行修改。STRATEGY不仅能让我们完成同样的任务,而且还提供了更好的灵活性。COMPOSITE让我们能够以递归的形式把一些较小的部分组合成一个mediator类。

我们已经将模式应用于文件系统设计的各个方面。COMPOSITE模式的贡献在于定义了递归的树状结构,打造出了文件系统的主干。PROXY对主干进行了增强,使它支持符号化链接。VISITOR为我们提供了一种手段,使我们能够以一种得体的、非侵入性的方式来添加与类型相关的新功能。

TEMPLATE METHOD在基本层面(即单个操作层面)为文件系统的保护提供了支持。对于单用户保护来说,我们只需要该模式就足够了。但为了支持多用户,我们还需要更多的抽象来支持登录、用户以及组。SINGLETON在两个层面为我们提供了帮助:对机密的登录过程进行封装和控制,以及创建了一个可以为系统中任何对象所访问和替换的隐式用户。最后,MEDIATOR为我们提供了一种灵活和非侵入性的方式,来将用户和它们隶属的组关联起来。

图2-7对我们使用的模式以及体现了这些模式的类进行了总结。其中使用的表示方法是Erich多年前构想出来的,他称之为“pattern:role注解”。这种表示方法给每个类附加一个阴影框,其中包含了相关模式和参与者的名字。为了简洁,如果一个模式很显而易见而且没有歧义,那么图中只显示参与者的名字。通过避免增加连接线,以及使用具有醒目背景的阴影框,我们把混乱和干扰减到最少——类结构和对模式的注解看上去是位于两个不同的平面上。由于有些标注因为是模式本身的一部分而可以被省略掉,因此这种表示方法实际上减少了连接线的数量。例如,请注意图中省略了Directory和Node之间的聚合关系,因为它是COMPOSITE模式中内在的Component-Composite关系。

图2-7

我发现Erich的表示方法具有高度的可读性和伸缩性,而且提供了许多有用的信息。如果将图2-7和我自己的类维恩图表示方法(参见图2-4)进行比较,那么这一点就特别明显。它唯一的缺点是灰色的背景在传真时效果不太好。因此传真时请注意!

Object Modeling Technology,对象建模技术。——译者注

客户代码可以通过一种众所周知的方法(比如对Node类的一个静态操作)得到当前目录。访问众所周知的资源正是SINGLETON模式的职责。我们稍后就会用到该模式。

好吧,近乎完美地执行。我必须承认在这个例子中,我忽略了内存管理的问题。具体说来,当客户代码在叶节点上调用adopt时可能会有潜在的内存泄漏,这是因为客户代码把所有权转让给了一个不会接受所有权的节点。对adopt来说这是一个非常普遍的问题,因为即便是Directory对象该操作仍然有可能会失败(比如客户代码没有足够的权限)。如果对Node进行引用计数就不会有这个问题了,因为adopt会在失败的时候递减引用计数(或者不递增引用计数)。

这里指《设计模式》英文版原书的页码。后同。——编者注

getSubject()Link类特有的,只有Link类声明和实现了该操作。因此,如果我们把符号化链接当作节点来处理的话,是无法访问该操作的。但是,当使用Visitor的时候,这就不是什么问题了,因为符号化链接在访问节点的时候恢复了类型信息。

与高级文件系统的“预防或撤销”相对。——译者注

注意,我在其中为getNamegetProtection加入了相应的set…操作。它们执行的操作和我们的预期完全一样。

但把析构函数声明为private是不行的,因为那样子类就不能对析构函数进行扩展,来删除它们的子节点或它们包含的任何其他对象。

这个名字确实没有遵守前面提到的规则,但doIsWritable实在是不合适。

英文“Don’t call us, we’ll call you.”的意思是:“不要给我们打电话,我们会给你打电话。”此处的“call”一语双关,既指好莱坞原则中的打电话,又指软件中的函数调用。——译者注

Unix用户应该很快就能指出还存在第三种类型,即“group”。我们稍后会考虑它。

Doug Schmidt正确地指出,在C++中这样的定义是难以通过任何强制性的方法实现的[Schmidt96a]。例如,只要用一条简单的#define语句来把private定义为public,我们就可以让所有私有成员变成公有成员。避免这种篡改的一种方法是根本不在头文件中定义成员变量。相反,我们把成员变量和其他机密的实现细节定义在另一个单独的、不公开的头文件中。一个与此紧密相关的模式是BRIDGE,但这部分内容已经超出了这个脚注所允许的篇幅。

这意味着User对象被存放在共享内存中,或者可以在程序之间传送。诚然,这是一个非常重要的细节,但它的实现既不会影响我们已经定义的接口,也不会影响我们采用的方法。

Venn diagram,在集合论中用圆圈代表运算的图表,圆圈的位置和重叠表示了集与集之间的关系。——译者注


相关图书

有限元基础与COMSOL案例分析
有限元基础与COMSOL案例分析
程序员的README
程序员的README
现代控制系统(第14版)
现代控制系统(第14版)
现代软件工程:如何高效构建软件
现代软件工程:如何高效构建软件
GitLab CI/CD 从入门到实战
GitLab CI/CD 从入门到实战
科学知识图谱:工具、方法与应用
科学知识图谱:工具、方法与应用

相关文章

相关课程