我希望告别冗长的前言,仅述说第二版的变更。写作第二版的我,疯狂地吸收了诸多大师的设计思想,这一点可以从参考文献的前后差别看到端倪。这两年以来,我又参与了几个项目的设计与开发工作,所谓“实践出真知”,在佐证大师观点的同时,自己对设计的认识更进了一步。或许,第二版不会比第一版优秀太多,但至少会减少诸多不足。囿于版本,我无法做出新的突破。我期待能创作一本全新的书,全面论述我对软件设计的认识。现在的我,还不足以写出梦想中的软件设计之道。
言归正传。
整体而言,我对第一版的所有章节都进行了一定程度的修订。或者更正了过去的错误,或者进一步完善了原有内容。本书的内容仍然是散漫而自由的,然而形散而神不散,大体遵循了设计的基本原则。
在第一篇《设计之要》中,我新增了《对象法则》一章,言简意赅地介绍了面向对象思想的核心要素与设计原则。这基于我的一贯理念,即设计模式的核心本质是面向对象设计思想的运用。只有掌握了面向对象设计思想,才能真正体会设计模式的精髓,并将其运用在实际的项目开发过程中。《对象法则》一章可以有机地与《封装变化》一章结合起来,再加上第23章《软件体系架构》的内容,基本上勾勒出软件设计的脉络,从面向对象思想到设计模式,再到软件体系架构。
在《封装变化》一章中,我不仅完善了项目实例,还增加了关于如何“解耦具体依赖”的几种技巧。对于软件设计而言,这是非常有益的指导。我整个儿删去了第一版的第5章《设计,由你掌握》,并将其中的部分内容转移到《封装变化》一章中。这使得第一篇的内容更为紧凑,虽然删去了讨论极限编程的相关内容,却可以使得我们能够更加关注于设计,而不是方法学。
第二篇《.NET框架与设计模式》增加了对.NET 3.X的源代码分析。我无法做到与时俱进,因为.NET 4.0即将走进.NET开发人员的程序生活。或许在本书出版之后的不久,还会有5.0,6.0……我只是希望我的书不要被时代抛弃得太远。好在设计模式本身属于经典,而经典总是能够经得起时间考验的。本书讲述经典,自然能讨得一定好处。
更新最明显的是迭代器模式在.NET中的实现。C# 2.0引入的yield return以及.NET 3.0引入的Lambda表达式都为迭代器模式在.NET中成为一种惯用法贡献了一份心力。我对此的分析,可以在一定程度上帮助读者更好地理解迭代器模式。在第二篇中,我新增了一章《.NET中的命令模式》,通过解析.NET 3.0引入的WF(Windows Workflow Foundation),展现命令模式的非凡价值。第二篇的内容虽然与.NET平台息息相关,但对于其他平台的开发人员而言,仍有可观之处。我在撰写本书第二版时,同样参考了Java平台的设计理念,以及Ruby中的设计模式。
从章节名称来看,我对第三篇《媒体播放器的设计之旅》进行了颠覆性的革新。事实不然,虽然内容仍有调整,但并未动摇其根本。在对本篇进行修订时,我扮演了一名重构者的角色,利用重命名和搬移内容的方法,极大地改善了既有章节的合理性。我抛开原有的以设计模式为核心的论述方式,转而从软件设计的角度看待问题。模拟真实的软件开发,我讨论了如何运用面向对象设计思想,如何对接口进行分离。当客户需要引入第三方软件时,我提出了接口适配的方案。当需求发生变化时,我则对接口行为进行了扩展或装饰。
第四篇《设计模式应用实践》仍然体现了本书的重要价值。我对第18章《命令模式应用》的实例进行了极大地完善,使得该实例在表现命令模式方面,更加丰富与完整。第19章《职责链模式应用》完全面貌一新,替换为最近完成的一个项目实例,并通过对领域进行建模,辅以用例图、时序图、通信图和类图推导详细设计,展现了“用例驱动开发”设计思想的冰山一角。
本书对软件架构着墨不多,主要的架构思想均放在第五篇《.NET体系架构》中。利用PetShop实例,对于指导读者初窥架构之美,仍有不可低估的作用。第二版对软件体系架构的内容有所补充与增强,更多地引入了企业应用架构模式和领域驱动设计的内容。第23章《软件体系架构》算得上是技术架构的入门读物,主要介绍了分层架构模式与相关设计要素。在第24章《数据访问层》中,我特别引入了.NET 4.0中的Entity Framework,算是一次有益的尝鲜。利用这样的ORM框架,还可以极度方便地实现资源库模式与工作单元模式,在诸多分层架构中,我们都可以看到它们的身影。在第28章《表现层》中,我设想了如何在PetShop中引入ASP.NET MVC框架。我本希望能有大量篇幅介绍Silverlight,以及MVP模式的运用,如此对于.NET的表现层设计方才显得完整,可惜我对Silverlight所知不多,心有余而力不足。
第二版还有诸多变化不能体现在目录中。例如我对本书的全部设计图进行了更新,更加准确、完善和美观,并保持了图形风格的一致性。第二版加入了诸多注解,大多数内容都是正文的补充与扩展,乃至思想点滴。阅读这些注解,可以帮助读者更好地理解我的设计意图,获得更多的模式知识。
本书面对哪些读者?读者又该如何阅读本书?第一版前言已经给出了答案。本书的再版并不打算彻底地改头换面。除了致谢,我不打算重复唠叨了。
钱锺书先生认为,献书仿佛魔术家玩的飞刀,放手而并没有脱手。随你怎样把作品奉献给人,作品总是作者自己的。可我还是希望把本书献给我的孩子——子瞻。当他宁静地呆在母亲肚子里时,本书的第二版同样也在孕育之中。现在,子瞻已经过了周岁生日,没有什么礼物可以比得上这本书更让我感到自豪。我还要把本书献给我亲爱的妻子。写作虽然痛苦,可哪里及得上你分娩痛楚的万分之一。抚养子瞻的辛劳,更让虚弱的你身心憔悴。本书献给你,可否给你一丝安慰?
感谢我的父母。尤其感谢我的母亲。这一年多以来,调皮的子瞻折磨得您腰酸背痛,您却没有任何怨言,反而甘之如饴。我能有时间写作本书,您功不可没。
1. Scrum敏捷框架
1.1 Scrum概述
Scrum是一种敏捷过程,它使用迭代和增量方式管理和控制复杂的软件与产品开发。Scrum的开发流程非常简单。首先,Product Owner根据客户的需求编写Product Backlog,然后召开计划会议,评估各项功能的相对工作量,并确定Sprint的愿景和目标,生成Sprint Backlog。然后,在Sprint(即迭代)的开发过程中,召开每日会议,Scrum Master通过它了解开发的进展以及出现的问题,检查团队成员的工作与进度。迭代结束后,团队会召开评审会议,向项目关系人展示可运行的增量版本,并检查是否达到了Sprint的目标。评审会议之后的回顾会议则会总结以往的实践经验,以提高团队生产力。
Scrum的核心在于迭代。团队首先浏览开发需求,考虑可用技术,并对自身技术及能力做出评估。然后共同确定构建功能的方案,并每日调整方法,以应对新的复杂问题、困难和出乎意料的情况。团队找出并选择最佳方案去完成任务。此创造性过程便是Scrum生产力的核心[1]。Scrum的所有实践就是围绕着一个迭代和增量的过程开展的。
1.2 Scrum的不足
与XP(eXtreme Programming,极限编程)不同,Scrum并没有提供核心的价值观与指导原则,也缺乏具体的实践方法,例如结队编程、测试驱动开发。Scrum仅仅规定了实施的基本流程与检查表,它是一个开放的管理框架,重心在于项目管理,而不是指导团队成员如何进行开发。这既是Scrum的优点,因为它很灵活,能够适应大多数场景,也可以兼容并包地引入其他方法学所提倡的实践;同时也是Scrum存在的固有缺陷,使得它难以被实践。如果没有一位优秀的Scrum Master,而团队成员又缺乏自我组织和管理的能力,就会让开发过程变得一团糟,团队成员将会无所适从。
在开发实践方面,Scrum可以借鉴XP提倡的结队编程以及测试驱动开发实现编码,通过重构对编码进行调整以适应需求的变化。但是,Scrum在建模方面却是一片空白。例如,Scrum对于如何创建Product Backlog,如何建立架构模型,以及如何在编码之前进行必要的模型设计,都没有给出具体的解决方案。缺乏正确的建模活动,就可能会对Scrum开发过程造成阻碍,影响团队达成Sprint的目标。
2. 敏捷建模与Scrum的契合
敏捷建模(Agile Modeling)是一个基于实践的建模方法,包括一系列以特定原则和价值为导向的,可被软件专业人员应用到日常工作中的实际做法[2]。敏捷建模有效地将建模敏捷化,利用敏捷方法的思想对传统的建模理念进行了重新梳理,使其更加适用于敏捷开发。敏捷建模描述了一种建模的风格。当它应用于敏捷的环境中时,能够提高开发的质量和速度,同时避免过度简化和不切实际的期望。
敏捷建模可以弥补Scrum在建模方面的不足。如果说Scrum是一个对开发过程的所有活动进行了规定的基本框架,则敏捷建模由于其对建模活动的核心关注,极大地丰富和增强了Scrum的软件过程。建模在所有的软件开发中都是不可缺少的一个重要环节。传统的建模活动,常常会重视对文档与工具的使用,要求创建的模型涵盖软件开发过程的方方面面。这种重量级的建模活动与敏捷开发方法在核心思想上是相悖的。敏捷方法需要敏捷的建模,Scrum自然也不例外。
敏捷建模定义了一组与轻量级的建模有关的价值观、原则和实践,并说明如何把它们付诸实施。本文将从敏捷建模的价值观、核心原则和核心实践三个方面讨论敏捷建模与Scrum的契合。
2.1 敏捷建模的价值观与Scrum的契合
敏捷建模的价值观包括交流、简单、反馈、勇气和谦虚。前面四条来自于XP的价值观,但完全可以说是敏捷开发的价值观。敏捷软件开发宣言强调与客户交流和团队的合作。宣言对可工作软件的重视甚于详尽的文档,凸现了简单的价值观。宣言对变更的重视体现了反馈的重要性,以及拥抱变化的勇气。Scrum同样体现了敏捷建模的第五条原则——谦虚。Scrum将整个团队定义为一种角色,作为一个整体负责将Sprint Backlog转化为可运行的产品。在开发过程中,团队成员需要管理自身的工作,同时对每次迭代和整个项目共同负责。如果没有谦虚的精神,Scrum的团队是无法运作的。
2.2 敏捷建模的核心原则与Scrum的契合
敏捷建模提出了十一条核心原则。Ambler认为,只有完全采纳这些原则,才能真正地宣称自己在进行敏捷建模。Scrum虽然没有提出具体的指导原则,但在Scrum框架和实施流程中,仍然体现了部分敏捷建模的核心原则。表1展现了在Scrum项目中敏捷建模核心原则的适用性。
表1 在Scrum项目中敏捷建模核心原则的适用性
| 敏捷建模的核心原则 | 与Scrum的契合 |
| 软件是你的首要目标 | Scrum坚持所有的Sprint都结束于演示,其目的就是要交付可工作的软件。 |
| 支持后续工作是你的第二目标 | Scrum认为,需求列表是推动迭代的主要力量,只要项目有资金,迭代就不会停止。项目的后续工作属于需求列表的内容。 |
| 轻装前进 | Scrum的最终产出物除了可工作的软件外,只包括Product Backlog和Sprint Backlog。 |
| 主张简单 | Scrum主张在一开始就要保持设计尽可能简单。 |
| 包容变化 | Scrum要求Product Owner根据不断变化的商业环境对产品作出调整。 |
| 递增的变化 | Scrum属于增量式开发,要求团队在每个Sprint周期内完成一部分产品功能增量。 |
| 有目的地建模 | 与建模相关的原则,Scrum并未要求 |
| 多种模型 | 与建模相关的原则,Scrum并未要求 |
| 你需要一个技术知识工具箱 | 团队的基本要求。 |
| 高质量的工作 | Scrum要求开发过程具有可视性,提倡对最后结果会产生影响的各个方面必须是清晰可见的,同时要求频繁的检查,以及对不合格的内容进行调整。 |
| 快速反馈 | Scrum每日会议、评审会议与回顾会议反映了这一原则。 |
2.3 敏捷建模的核心实践与Scrum的契合
敏捷建模的精华在于它的实践,但敏捷建模的实践是在价值观和原则指引下体现的。它的核心实践分为四类,即迭代和增量建模、团队协作、简单性和验证。实际上,敏捷建模的实践并没有超出敏捷开发的范围之外,只不过它的关注对象被界定为建模活动而已。因此,敏捷建模的实践完全可以应用在Scrum的开发过程中。
迭代和增量建模实践与Scrum完全吻合,因为Scrum本身就是一种迭代和增量开发。既然建模活动贯穿整个项目开发周期,因而建模采用迭代与增量的方式自然顺理成章。Scrum定义了团队角色,从而突出了团队成员的协作,成员作为一个整体参与到软件开发过程中。在Scrum中,每个成员都可能是建模人员,例如Product Owner对需求进行建模,对用户界面进行建模,团队成员对设计进行建模。简单性实践要求建模人员使用最简单的工具,创建简单的内容,简单地描述模型。归根结底,模型只需要传达它应该展现的内容,不管是需求分析,还是架构设计,都应该尽可能地保持简单,既不需要考虑格式,也不需要考虑完整,甚至可以丢弃那些已经实现了的模型。Scrum大量使用了白板、索引卡、即时贴等简单工具,创建的模型非常简单,甚至是临时的。Scrum同样重视对产品的验证,避免出现错误或与需求产生偏差。
3. 贯穿Scrum敏捷过程的敏捷建模
3.1 Scrum软件生命周期
Scrum并没有明确划分项目开发过程的阶段,而是将几种会议(计划会议、评审会议和回顾会议)定义为软件开发的里程碑。如果借用软件生命周期的概念,我们可以将Scrum划分为初始阶段、计划阶段、冲刺阶段与发布阶段。初始阶段的活动主要包括组建团队、准备资源和编写Product Backlog。计划阶段包括了Sprint的两次计划会议。冲刺阶段即一个完整的Sprint迭代,周期通常不超过六个星期。发布阶段包括评审会议与回顾会议。此阶段结束后,将发布一个达到Sprint目标的增量版本。至于产品的维护,则属于Product Backlog的一部分,列入每次迭代的范围。
3.2 初始阶段的敏捷建模
Product Backlog的编写与建模活动息息相关。Product Backlog是Scrum的核心,也是一切的起源。从根本上说,它就是一个需求、或故事、或特性等组成的列表,按照重要性的级别进行了排序。它里面包含的是客户想要的东西,并用客户的术语加以描述[3]。编写Product Backlog就是对需求进行建模。根据敏捷建模“主张简单”的原则,我们在描述Backlog的条目时,通常借鉴XP对用户故事的描述方式,而不是采用用例驱动的模式。可以使用Excel工具来创建一个Backlog表。敏捷建模认为“内容比形式更重要”,在表示Backlog时,我们甚至可以使用即时贴,将其展示在白板上,使每个人都能直接看到需求模型。
编写Product Backlog时,项目的利益相关人必须积极参与,和Product Owner一起确定Backlog的条目以及优先级。Product Backlog应该能够“包容变化”,Product Owner通过与项目关系人的讨论,可以增加新的功能,或者根据新的需求变化对其进行修改。根据敏捷建模的“多种模型”核心原则,我们也可以在Product Backlog基础上,使用用例模型或用户界面模型,帮助说明Backlog的业务流程,促进开发人员对需求的理解。
3.3 计划阶段的敏捷建模
计划阶段仅仅包括两次计划会议,每次计划会议大约持续四个小时。第一个计划会议主要确定Sprint的目标以及Sprint Backlog。第二个计划会议则需要确定Sprint Backlog中每个任务的承担人,并根据实际情况裁减Sprint Backlog,生成最终的Sprint Backlog。
敏捷建模认为,项目关系人应积极参与到需求建模活动中。Scrum负责需求建模的主要是Product Owner和客户。在计划会议中,Product Owner必须出席会议,以便对Backlog的需求条目进行解释,帮助团队理解需求。
敏捷建模的核心实践要求“与他人一起建模”。在计划会议中,团队常常会对功能需求进行拆分,其目的主要是为了更容易对工作量进行估算,但另一方面也是对需求的一种细化。一种最佳实践是将需求条目细分为任务。任务与需求条目的区别在于,需求条目属于可交付的内容,是Product Owner以及他所代表的利益相关人所关心的。而任务则不可交付,它常常代表了分析、建模、编码、测试等实现需求条目的各个环节。在拆分任务期间,并不会真正开展建模活动,但团队成员在了解到需求的具体细节时,实际上已经开始考虑需求的实现。
计划会议会对Sprint Backlog进行工作量估算。Scrum建议发挥集体的智慧。方法是利用计划纸牌。团队中的每个人都可以在深思熟虑之后,出示自己手里的纸牌,根据出示纸牌的数值取平均值,就可以大致获得该需求条目的工作量。这种方式符合敏捷建模“简单”的价值观。在讨论Sprint Backlog的需求细节时,则可以使用索引卡。根据每个需求的重要程度与优先级依次将索引卡张贴在墙上。索引卡属于敏捷建模的临时模型,在失去价值之后可以考虑丢弃,而只保留更新后的Sprint Backlog。
3.4 冲刺阶段的敏捷建模
整个冲刺阶段就是一个迭代周期,即一次Sprint。在 Sprint的开始阶段,我们可以根据Sprint Backlog建立一个任务列表模型,以及一个能够直观反映开发进度和开发效率的Burndown(燃尽)模型,并形成一个任务板。任务板要尽量简单,只需要保留必要的列。Scrum要求召开站立的每日会议,通常就在任务板前完成。团队成员一边描述昨天已经做的和今天要做的事情,一边移动任务板上对应的即时贴。每日会议结束,则任务模型也随之更新,最后由Scrum Master负责更新Sprint Backlog和Burndown模型。
在冲刺阶段引入敏捷建模非常必要,它有助于解决团队在开发过程中遇到的需求、设计以及开发方面的问题。一个方法是召集相关人员举行简短的设计会议,这在敏捷建模中称为专门或即兴建模会议。通常的流程是:首先与项目关系人探讨相关的需求,可能需要创建基本用户界面模型或者讨论业务规则的逻辑;随后继续前进讨论这些需求潜在的解决方案,这时常常会画一张白板草图来帮助讨论;再往后就是继续前进编码并测试这个解决方案[4]。
Scrum团队没有设计人员、建模人员和编码人员之间的区别,它是自组织、自管理的团队。团队的每一个成员都具有项目中所有方面的参与权力,不存在单一的团队成员对系统架构、需求或者测试负责的情况[5]。这正是敏捷建模“有效团队协作的实践”的运用。
在冲刺阶段,通过引入敏捷建模,我们可以在开发过程中创建架构模型、类结构模型和测试用例模型等内容。根据项目的实际情况,我们可以选择使用UML建模工具,或直接利用简单的白板工具创建架构模型,利用CRC卡展现类的结构模型。我们还可以借助一些需求模型以及用户界面模型,深入对需求的理解。
3.5 发布阶段的敏捷建模
Scrum评审会议实际上就是一次增量产品的演示和发布。在进行Sprint演示时,需要确保清晰阐述了Sprint目标,并让演示关注于业务层次,而不要考虑技术细节。如果我们在冲刺阶段严格地遵循了持续集成原则,就可以在每次Sprint之后发布一个增量版本,供用户使用。这实际上是“快速反馈”和“包容变化”核心原则的体现。通过每次迭代发布的版本,可以及时获得客户的反馈,验证实现是否与需求相符。如果出现偏差,或者客户提出新的建议和变化,就可以将其列入到下次Sprint的范围和目标中。
回顾会议在Scrum中是一项特殊的活动。审视和适应的能力是Scrum的基础,这也是开展回顾会议的目的。在回顾会议期间,项目团队会分析Sprint中的成功经验和遇到的障碍。Scrum回顾会议不会涉及建模活动,但它对于敏捷建模而言却具有促进作用,因为我们可以在回顾会议中总结敏捷建模应用的得与失。例如我们可以讨论建模活动是否过于复杂,是否需要引入其它的建模工具,哪些模型属于临时模型或契约模型。简而言之,在回顾会议中,我们可以检查团队的建模活动是否背离了敏捷建模的价值观、原则和实践。
4. 结束语
在Scrum项目中,建模活动仍然属于必不可少的一个环节,但却是很多Scrum实践容易忽视或轻视的一部分。Scrum敏捷框架的不足主要体现于此。如果将敏捷建模的价值观、原则与实践应用到Scrum的整个开发过程中,将有利于规范Scrum的建模活动。二者的关系是框架与细节的有机契合。Scrum提供了一个基础的框架,对敏捷开发过程中的所有活动进行了规定,而敏捷建模的重点则是全部软件过程的一部分,因而需要与另一个完整的过程结合,以增强这些过程。敏捷建模是Scrum的有效补充,在Scrum中实施敏捷建模,能够提高Scrum的可操作性,并在建模活动方面给与指导与规范。敏捷建模帮助Scrum团队找到建模的最佳点,保证我们既进行了足够的建模,以保证有效地研究和记录系统,但又没有过多地建模以致变成减慢项目进展的负担。
参考文献
[1] Ken Schwaber. Agile Project Management with Scrum[M]. 上海:世界图书出版公司,2007:6.
[2] 王毅嘉,张为群. 一种基于UML和敏捷建模的JEMM方法研究[J]. 西南师范大学学报(自然科学版),2005,30(3):426.
[3] Henrik Kniberg. Scrum and XP from the Trenches[M/OL].C4Media,2008, http://infoq.com/minibooks/scrum-xp-from-the-trenches
[4] Scott W Ambler. 敏捷建模——极限编程和统一过程的有效实践[M]. 张嘉路,朱鹏,程宾,译. 北京:机械工业出版社,2003:120.
[5] Robert C Martin. 敏捷软件开发——原则、模式与实践[M]. 邓辉,译.北京:清华大学出版社,2003:7.
一个外部具体对象的引入,必然会给一个模块带来与外部模块之间的依赖。而具体对象的创建始终是我们无法规避的。即使我们可以利用设计模式的工厂方法模式或抽象工厂封装具体对象创建的逻辑,但却又再次引入了具体工厂对象的创建依赖。虽然在设计上有所改进,但没有彻底解除具体依赖,仍让我心有戚戚焉。
以一个电子商务网站的设计为例。在该项目中要求对客户的订单进行管理,例如插入订单。考虑到访问量的关系,系统为订单管理提供了同步和异步的方式。显然,在实际应用中,我们需要根据具体的应用环境,决定使用这两种方式的其中一种。由于变化非常频繁,因而我们采取了“封装变化”的设计思想。譬如,考虑应用Strategy模式,因为插入订单的行为,实则就是一种插入订单的策略。我们可以为此策略建立抽象对象,如IOrderStrategy接口。
public interface IOrderStrategy
{
void Insert(OrderInfo order);
}
然后分别定义两个类OrderSynchronous和OrderAsynchronous实现IOrderStrategy接口。类结构如图1所示。
图1 订单策略的设计
当领域对象Order类需要插入订单时,将根据IOrderStrategy接口的运行期类型,执行相关的订单插入策略,如下代码所示。
public class Order
{
private IOrderStrategy m_orderStrategy;
public Order(IOrderStrategy orderStrategy)
{
m_orderStrategy = orderStrategy;
}
public void Insert(OrderInfo order)
{
m_orderStrategy.Insert(order);
}
}
由于用户随时都可能会改变插入订单的策略,因此对于业务层的订单领域对象而言,绝不能与具体的订单策略对象产生耦合关系。也就是说,在领域对象Order类中,不能new一个具体的订单策略对象,如下面的代码:
IOrderStrategy orderStrategy = new OrderSynchronous();
虽然在前面的实现中,我们通过领域对象的构造函数传递了IOrderStrategy接口对象。但这样的实现仅仅是将具体订单策略对象的创建推迟到了领域对象的调用者那里而已,调用者无法避免具体订单策略对象的创建。显然,这是一种“治标不治本”的做法。我们当然也期望能够有一种理想的状态,就是具体对象的创建永远都不要在代码中出现。事实上,模块与模块间之所以产生依赖关系,正是因为有具体对象的存在。一旦在一个模块中创建了另一个模块中的具体对象,依赖就产生了。现在,我们的目的就是要将这些依赖消除。
1、配置文件与反射技术
使用硬编码方式创建一个对象,必然会带来对象之间的具体依赖。一种最简单的方式是将反射技术与配置文件相结合,在具体对象拥有共同抽象的前提下,通过配置文件获得具体对象的类型信息,然后利用反射创建相应的对象。例如,在领域对象Order类中,可以如此实现:
public class Order
{
private static readonly IOrderStrategy orderInsertStrategy =
LoadInsertStrategy();
private static IOrderStrategy LoadInsertStrategy()
{
//通过配置文件找到具体的订单策略对象
string path = ConfigurationManager.AppSettings["OrderStrategyAssembly"];
string className = ConfigurationManager.AppSettings["OrderStrategyClass"];
//通过反射创建对象实例
return (IOrderStrategy)Assembly.Load(path).CreateInstance(className);
}
}
在配置文件web.config中,配置如下的节:
<add key="OrderStrategyAssembly" value="AgileDon.BLL"/>
<add key="OrderStrategyClass" value="BLL.OrderSynchronous"/>
通过引入泛型,我们可以对前面的逻辑进行有效的封装,例如定义如下的工厂辅助类。
public static class FactoryHelper<T>
where T:class
{
private static T instance = null;
public static T Create(string typeNameKey,
string nameSpace,
string assemblyPath)
{
if (instance == null)
{
string typeName = ConfigurationManager.AppSettings[typeNameKey];
string className = nameSpace + "." + typeName;
instance = (T)Assembly.Load(assemblyPath).
CreateInstance(className);
}
return instance;
}
}
注意, Create()辅助方法中的typeNameKey,是指向具体对象类型的键值。通常建议将其键值赋值为具体对象类型的抽象接口类型名,而对应的值则是目标创建对象的类型名。例如:
<add key="IOrderStrategy" value="OrderSynchronous"/>
然后,我们可以为属于相同命名空间的类统一定义工厂类,并在其中调用工厂辅助类FactoryHelper的Create()辅助方法。例如,为业务逻辑层的对象定义工厂类BLLFactory。
public static class BLLFactory<T>
where T:class
{
public static T Create(string typeNameKey)
{
string nameSpace = ConfigurationManager.AppSettings["BLLAssembly"];
string assemblyPath = ConfigurationManager.AppSettings["BLLPath"];
return BaseFactory<T>.CreateT(
typeNameKey, nameSpace, assemblyPath);
}
}
针对订单策略对象,对应的配置文件为:
<add key="BLLAssembly" value="AgileDon.BLL"/>
<add key="BLLPath" value="AgileDon.BLL"/>
<add key="IOrderStrategy" value="OrderSynchronous"/>
现在,我们就可以调用BLLFactory类的Create ()方法,传入类型名以获得具体的对象。例如:
IOrderStrategy orderInsertStrategy = BLLFactory<IOrderStrategy>.Create(
"IOrderStrategy");
如果需要将订单插入策略从同步修改为异步方式,只需将配置文件中IOrderStrategy键对应的值修改为"OrderAsynchronous"即可。
2、表驱动法
借鉴表驱动法【注:参见Steve McConnell著作《代码大全》第18章】的思想,我们可以利用一个Dictionary集合来维护目标对象与键值之间的映射关系。当我们需要获得对象时,可以利用键值对表进行查询,这样就可以有效地消除if语句。例如,可以在Strategy模式中使用表驱动法,将其作为模式的上下文对象,而不必执行对策略对象类型的逻辑判断。利用表驱动法,我们也可以解除对象之间的具体依赖。
仍然以订单的管理为例。我为订单的管理专门定义了一个OrderManager类,它负责初始化并维持对象表。
public static class OrderManager
{
private static IDictionary<string,IOrderStrategy> m_strategyTable;
static OrderManager()
{
Init();
}
private static void Init()
{
m_strategyTable = new Dictionary<string,IOrderStrategy>();
m_strategyTable.Add("sync",new OrderSynchronous());
m_strategyTable.Add("async",new OrderAsynchronous());
}
public static IOrderStrategy GetOrderStrategy(string strategyKey)
{
IOrderStrategy strategy;
if (m_strategyTable.TryGetValue(strategyKey, out strategy))
{
return strategy;
}
else
{
throw new Exception("无法找到正确的订单策略对象");
}
}
}
在调用OrderManager的GetOrderStrategy()方法时,为提供更好的灵活性,寻找策略对象的键值应该放在配置文件中,以避免修改源代码。
string strategyKey = ConfigurationManager.AppSettings["StrategyKey"];
IOrderStrategy strategy = OrderManager.GetOrderStrategy(strategyKey);
我们甚至可以提供一个注册方法RegisterStrategy(),用以应对未来可能的扩展。
public static class OrderManager
{
//其余实现略
public static void RegisterStrategy(
string strategyKey,
IOrderStrategy strategy)
{
if (String.IsNullOrEmpty(strategyKey))
{
throw new ArgumentNullException(strategyKey);
}
if (strategy == null)
{
throw new ArgumentNullException("策略对象不能为null");
}
if (m_strategyTable.ContainsKey(strategyKey))
{
throw new ArgumentException("已经存在键值" + strategyKey);
}
m_strategyTable.Add(strategyKey,strategy);
}
}
3、依赖注入
依赖注入(Dependency Injection)是一个漂亮的隐喻。依赖关系就像是被注入的液体,我们可以在任何时候将依赖关系注入到模块中,而不只限于在编译时绑定。既然这种依赖关系是通过注入的方式完成,就意味着我们可以随时更新,因为注入的液体与模块并无直接关联。实现依赖注入的前提是面向接口编程,而辅助的技术则是利用反射技术。
依赖注入是目前大多数轻量级IoC(控制反转,Inversion of Control)容器用于解除外部服务与容器服务之间依赖关系的一把利刃。首先,容器服务包含了外部服务接口的定义。然后,依赖注入通过使用一个专门的装配器对象,提供外部服务的具体实现,并其赋值给对应的容器服务对象。Martin Fowler将依赖注入的形式分为三种:构造函数注入(Constructor Injection)、设置方法注入(Setter Injection)和接口注入(Interface Injection)。其中,接口注入通过定义接口约束的方式实现依赖注入,会给容器带来设计的限制。而构造函数注入与设置方式注入则表现了产生依赖的两个连接点:构造函数与属性。如果构造函数参数或属性对象的类型为抽象的接口类型,则产生具体依赖的源头在于具体对象的创建。将创建具体对象的职责转移到IoC容器,就可以在运行时为构造函数参数或属性对象传递依赖。
目前,实现了依赖注入的轻量级容器已经应用在许多框架产品中,如Java平台下的Spring、PicoContainer等。在.NET平台下,常见的依赖注入框架包括AutoFac,Ninject,Spring.NET,StructureMap和Windsor等。
以Ninject框架为例,我们可以定义这样的Order类:
public class Order
{
private IOrderStrategy m_strategy;
public Order(IOrderStrategy strategy)
{
m_strategy = strategy;
}
public void Insert(OrderInfo order)
{
m_strategy.Insert(order);
}
}
然后,我们需要自定义一个OrderModule类,它派生自Ninject.Core.StandardModule类。这是Ninject实现依赖注入的一个特色,它抛弃了传统的xml映射文件,而是利用类型绑定的方式,并根据要创建的具体对象分组建立对应的Module类。注意,它不同于之前的解耦方法,因为它对业务逻辑代码没有造成任何侵略与污染。如上定义的Order类,保留了领域对象的本来面貌。使得领域层的开发人员可以专心致志着力于业务逻辑的实现。OrderModule类的定义如下所示:
using Ninject.Core;
public class OrderModule:StandardModule
{
public override void Load()
{
Bind<IOrderStrategy>().To<OrderSynchronous>();
}
}
客户端调用的代码可以通过Ninject提供的IKernel对象获得Order对象:
OrderModule module = new OrderModule();
IKernel kernal = new StandardKernel(module);
Order order = kernal.Get<Order>();
order.Insert(new OrderInfo());
4、惯例优于配置
使用配置文件固然可以解除与具体对象之间的依赖,然而,它带来的良好可扩展性,却是以牺牲系统的可维护性乃至于可靠性为代价的。配置文件很难管理,尤其是在配置信息相对较多的情况下。不管是集中管理还是分散管理,都存在一些与生俱来的缺陷。如果采用集中管理,则配置文件过大,既影响性能,也不能很好地展现配置信息的分类与层次。在.NET中,虽然可以利用<section></section>对配置文件进行分节,但终究不够直观。采用分散管理,则不同大小的配置文件千头万绪,既会给维护者带来管理的障碍,也不利于部署与使用。使用配置文件尤其不便于调试。开发环境提供的编译期检查,对于配置文件只能是“望洋兴叹”。所谓“差之毫厘,谬以千里”,小小的一个配置项错误,可能会造成难以弥补的巨大损失。为了弥补这些缺陷,许多产品或框架都提供了专门的配置或管理工具,使用直观的UI界面对配置文件进行操作,但繁杂的配置项仍然有可能让使用者望而却步。
惯例优于配置(Convention over Configuration)来源于Ruby On Rails框架的设计理念,也被认为是Rails大获成功的关键因素之一。这里所谓的惯例,可以理解为框架对编程的一些约束,我们可以根据实现制订的默认规则,通过反射技术完成对象的创建,对象的协作,甚至是应用程序的组装。例如在Rails中对MVC模式的实现中,就事先确立了Model、View和Controller的目录结构与命名规范。在这种情况下,我们不需要对元数据进行任何配置。ASP.NET MVC框架同样采纳了惯例优于配置的思想。采用惯例,虽然在一定程度上损失了系统的灵活性,带来的却是良好的可维护性。同时,它仍然可以解除系统与具体对象之间的强耦合关系。
惯例优于配置的技术并不是非常适合于本文中的订单策略示例。不过,在.NET框架中,有关WebRequest对象的创建,却可以改用惯例优于配置的思想来实现。图2是WebRequest对象的继承体系:
图2 WebRequest的类结构
在.NET框架中,创建一个WebRequest实例的方法是调用WebRequest的静态方法Create()
WebRequest myRequest = WebRequest.Create("http://www.agiledon.com");
由于,传入的Uri地址其前缀为"http",因此创建的myRequest对象应该为HttpWebRequest具体对象。如果需要根据不同的Request协议,扩展不同的WebRequest对象,就需要引入一些设计技巧,来解除与具体对象创建的依赖。.NET框架的实现能够达到这样的目的,但非常复杂,这里不提。我想要介绍的是如何利用惯例优于配置来实现WebRequest对象的扩展。利用“惯例优于配置”的思想有一个前提,就是我们要对WebRequest对象的命名规范进行惯例约束。例如,我们规定所有的WebRequest子类对象均由协议名加上“WebRequest”后缀构成。通过解析传入的Uri,可以获得传输协议的名称,之后将它与“WebRequest”连接起来,获得WebRequest子类对象的类名,再利用反射技术创建该对象。在WebRequest类中定义如下的Create()静态方法:
public static WebRequest Create(Uri requestUri)
{
if (requestUri == null)
{
throw new ArgumentNullException("requestUri");
}
string prefix = requestUri.Scheme.ToLower();
if (prefix == null)
{
throw new ArgumentNullException("requestUri");
}
if (prefix.Contains(""))
{
prefix = prefix.Replace(".","");
}
StringBuilder typeName = new StringBuilder();
typeName.Append("System.Net.");
typeName.Append(prefix.Substring(0,1).ToUpper());
typeName.Append(prefix.ToLower().Substring(1,prefix.Length - 1));
typeName.Append("WebRequest");
return (WebRequest)Activitor.CreateInstance(
System.Type.GetType(typeName));
}
只要WebRequest的子类对象能够遵循我们的惯例,即该类的类型名符合事先制订的规范,改进后的Create()方法就能够运行良好。以新增Tcp协议的WebRequest对象为例。该协议的Schema为“net.tcp”,因此其类名必须为“NettcpWebRequest”,并放在“System.Net”命名空间下。如果客户端调用WebRequest.Create()方法,并传入“net.tcp://www.agiledon.com”值,则Create()方法就会对该Uri地址进行解析,获得完整的类型名为“System.Net.NettcpWebRequest”,然后,利用反射技术创建该对象。采用“惯例优于配置”的方式,可以极大地简化工厂方法的实现代码,抛弃了繁琐的设计理念,具有非常灵活的扩展性以及良好的代码可读性。或许,唯一的遗憾是由于反射技术带来的性能损耗。
利用抽象的方式封装变化,固然是应对需求变化的王道,但它也仅仅能解除调用者与被调用者之间的耦合关系。只要还涉及具体对象的创建,即使引入了创建型模式,例如Factory Method模式,具体工厂对象的创建依然是必不可少的。不要小看这一点点麻烦,需知“千里之堤,溃于蚁穴”,牵一发而动全身,小麻烦可能会酿成大灾难。对于那些业已被封装变化的对象,我们还应该学会利用诸如“依赖注入”、“表驱动法”等技术,彻底解除两者之间的耦合;至于选择何种技术,则需要根据具体的应用场景做出判断。当然,模块或对象解耦的重要前提,则源于封装变化,要求我们针对接口编程,而不是实现。这也是GOF提出的面向对象设计原则。
《编程絮语》之二
没有对象协作的系统是不可想象的,因为此时的系统就是一个庞大的类,一个无所不知的“上帝类”。每个对象都有自己的自治领域,“各人自扫门前雪”,对象定义的法则就是这么自私。单一职责原则(SRP)[1]体现的正是这样的道理。对象的职责越少,则对象之间的依赖就越少。这一前提就是对象具有足够的高内聚与细粒度。这样的对象一方面有利于对象的重用,另一方面也保证了对象的稳定性。
对象的职责可以是自己承担,也可以委派给其他对象。因此,有对象就必然有依赖,正如有人就有江湖。那么,我们该如何降低对象之间的依赖?第一要则是依赖于抽象,如依赖倒置原则(DIP)[2]所云。如果无法依赖于抽象,则至少应该保证你所依赖的对象是足够稳定的。事实上,最稳定的对象就是抽象对象,所以万法归一,稳定才是降低依赖的基础。
依赖之殇的源头是“变化”。变化与稳定显然是矛盾的,软件设计的最大问题就是如何协调这两者之间的矛盾。我们需要像高明的杂技师,要学会掌握平衡,能够在钢丝绳上无碍的行走。那么,如何解决变化带来的影响呢?答案是利用封装来隔离变化。
封装的一种方式是抽象,因为相对于实现而言,接口总能保持一定的稳定性。例如税收策略。对于调用方而言,只是希望能够得到准确的税值,至于如何计算,则不是他关心的内容。抽象出计算税值的接口,就能够隔离调用方与可能变化的税收策略之间的依赖关系,如下图所示:
利用抽象还可以解除对特定实现环境例如外部资源、硬件或数据库的依赖。此时抽象隔离的变化可能是外部环境提供的API。例如,在考勤系统中,利用抽象隔离不同型号考勤机的变化。
利用抽象解除对象之间的依赖,还可以保证系统具有良好的可测试性。因为调用者依赖于抽象接口,就为我们引入Mock对象(当然也可以是Fake对象)执行单元测试提供了方便。尤其是当我们对领域对象进行测试时,如果领域对象需要对数据库操作,可以通过依赖抽象的持久对象(或仓储对象)实现职责的委派。此时,我们可以引入持久对象的Mock对象,模拟领域对象持久化的职责,既分离了领域对象与数据库资源的依赖关系,又能够提高单元测试的效率。
public interface IOrderRepository
{
void Add(OrderInfo order);
void Remove(OrderInfo order);
}
public class Order
{
private IOrderRepository m_repository;
public Order(IOrderRepository repository)
{
m_repository = repository;
}
public void Place(OrderInfo order)
{
if (order.Validate())
{
m_repository.Add(order);
}
}
}
利用封装隔离变化,并非必须依赖于抽象,根据不同的场景,降低要求,依赖于较为稳定的具体类对象也是可行的。这是一种降低复杂度的设计方式。例如,我们可以引入一个Helper类来封装第三方API的调用,从而实现调用方与第三方API的隔离。例如为SQL Server数据库操作定义一个Helper类:
public static class SQLHelper
{
public int ExecuteNonQuery() {}
public DataSet ExecuteQuery() {}
}
这样的设计类似于Gateway模式[3],利用一个Gateway对象来封装外部系统或资源访问。具体类对象显然不如抽象接口稳定,因此在设计时,我们需要遵循单一职责原则。这样的设计体现了DRY[4]原则,利用封装避免代码的重复,避免解决方案蔓延的坏味道[5]。合理的封装可以将变化点集中或限制到一处,以应对变化。一个常见的例子是利用简单工厂模式,将所有对象的创建集中在一个类中(当然也可以按模块创建不同的静态工厂)。即使创建的产品对象发生了变化,我们也可以只修改静态工厂类一处的实现。简单工厂模式常常可以应用在领域层中,通过工厂对象创建持久层对象(或所谓的数据访问对象)。
依赖源于对象的协作。传递依赖的方式可以通过属性,构造函数或方法的参数。若要保证对象间的松散耦合,构造函数或方法的参数以及属性的类型就应定义为抽象类型,如前面例子中的Order类。这是依赖解耦的关键方式,完全符合“面向接口设计”的编程思想,同时,它也有利于我们在后期实现“依赖注入”。
然而,产生依赖的方式绝不仅限于上述三种情形。例如,方法的返回值以及方法体中局部对象的创建,同样可能产生依赖。比较而言,这种依赖关系更难解除,因为它与具体的实现紧密相关。换句话说,因为这两种情形的依赖都涉及到具体对象的创建,且由实现者完成,而不能转交给调用方。例如,在如下的设计中,消息头会决定消息编码的方式。

MessageHeader的GetEncoder()方法需要返回一个IEncoder对象,这就要求在方法体中创建一个具体的IEncoder对象。要解除这样的依赖关系非常困难,如需彻底解除,一种可能是利用反射技术,通过具体类的类型来创建。还有一种可能是利用“惯例优于配置”实现解耦[6]。如果不需要彻底解除依赖,也可以利用“表驱动法”,或者直接将条件分支语句封装到方法中。
如果在方法实现中需要创建一个局部对象,我们可以考虑简单工厂模式或Registry模式[3]。例如,在Role对象的IsAuthorized()方法中,需要创建一个PriviledgeFinder对象,通过调用它的FindPriviledges()方法获得角色对应的权限集。此时,我们可以在Registry对象中提供PriviledgeFinder对象:
interface IPriviledgeFinder
{
IList<Priviledge> GetPriviledges(int roleID);
}
public class PriviledgeFinder:IPriviledgeFinder
{}
public class Registry
{
private Registry()
{ }
private static Registry Instance = new Registry();
protected virtual IPriviledgeFinder m_priviledge = new PriviledgeFinder();
public static IPriviledgeFinder PriviledgeFinder()
{
return Instance.m_priviledge;
}
}
public class Role
{
public bool IsAuthorized()
{
IList<Priviledge> priviledges =
Registry.PriviledgeFinder().GetPriviledges(this.ID);
}
}
上述实现实际上仍然利用了“将变化集中在一处”的设计原则。注意Registry类中的m_priviledge属性是virtual的受保护属性,它提供了一种变化的可能,可以交由子类去实现。
如何知道一个类是否过多的依赖其他类?一个办法就是创建这个类,并保证创建的对象能够正常使用。如果创建的过程非常复杂,就说明该类的依赖过多。此时,可以考虑分解该类的职责。如果这些依赖是必须的,则可以考虑利用封装,例如将对外部对象的调用修改为在内部创建(应用builder模式);也可以考虑使用Factory Method模式或者利用简单工厂。
依赖关系不仅仅只限于类与类之间,包(组件、模块、层)与包(组件、模块、层)之间同样存在依赖关系。良好的设计需要包之间保持松散耦合。大体上讲,包之间的依赖解除与类之间的依赖解除方式是一致的。即:要求一个包尽量依赖于一个稳定的包。注意,一个包依赖于另一个包,就代表着它依赖于这个包的每一个类。Robert C. Martin说:“我放入一个包中的所有类是不可分开的,仅仅依赖于其中一部分的情况是不可能的。”[7]因此,我们可以将一个包看做是一个类,它仍然要求职责的高内聚。在包中对类的分配,就相当于是对类进行一次分类。共同封闭原则[7]要求:“包中的所有类对于同一类性质的变化应该是共同封闭的。一个变化若对一个包产生影响,则将对该包中的所有类产生影响,而对于其他的包不造成任何影响。”简言之,我们在对包进行设计时,需要避免将不同的职责耦合在一个包中,它会造成变化点的扩散。
解除包之间依赖关系的一个重要方法仍然是抽象。使用Seperated Interface模式[3],在一个包中定义接口,而在另一个与这个包分离的包中实现这个接口。例如在分层架构模式中,我们常常对数据访问层进行抽象,使得业务逻辑层依赖于该抽象层,而不是它的低层模块。
上图的设计实际上是依赖倒置原则的体现。在项目开发中,这种将抽象与实现分别放在不同的包中,是系统设计中常见的方式。这样的设计也能够更好地应用在分布式开发场景中。
[1]单一职责原则(Single Responsibility Principle):就一个类而言,应该只专注于做一件事和仅有一个引起变化的原因;
[2]依赖倒置原则(Dependency Inversion Principle):高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象;
[3]Martin Fowler, Patterns of Enterprise Application Architecture;
[4]DRY原则,即“不要重复你自己(Don't Repeat Yourself)”它要求“系统中的每项知识只应该在一个地方描述。”
[5]Joshua Kerievsky, Refactoring to Patterns;
[6]文章《解除具体依赖的技术》
[7]Robert C. Martin Agile Software Development:Principles,Patterns and Practices
《编程絮语》之一
C#的语法脱胎于C++,因而保留了virtual关键字,可以定义一个虚方法(或虚属性)。一个类的成员被定义为virtual,就意味着它在告诉自己的子类:我准备了一笔遗产,你可以全盘接受,也可以完全拒绝或者修改我的遗嘱。显然,虚方法授予子类的权利甚至大于抽象方法。子类面对抽象方法只有重写(override)的权利,而对于虚方法,它还可以选择完全继承。
毫无疑问,虚方法破坏了对象的封装性。如果不加约束的使用,会对调用方造成破坏,至少它有可能破坏子类与父类之间在外在行为上的一致性。因此,当我们在重写虚方法时,务必要遵循Liskov替换原则。我们要保证对于调用方而言,子类对于父类是完全可以替换的。这里所谓的“替换”,是指子类不能破坏调用方对父类行为的期待。准确地说,子类在重写父类的虚方法时,必须遵循调用该方法的前置条件与后置条件。这也是“契约式设计”的思想。最理想的状态是让使用对象甚至无法知道是否存在派生类[1]。即类的继承体系对于调用者而言,必须体现外部接口的一致性,这样才能做到调用者对派生类无知。
如果确实需要重写父类的方法,最好的方式是扩展而不是修改。这实际上也是开放-封闭原则的体现。例如在Decorator模式中,我们重写父类方法的目的,是为了实现对该方法的装饰。Proxy模式的实现同样如此。Michael C. Feathers对此给出的忠告是[2]:
1)尽可能避免重写具体方法。
2)倘若真的重写了某个具体方法,那么看看能否在重写方法中调用被重写的那个方法。
Feathers的忠告是针对Java语言,因为在C#中我们无法重写具体方法,只能利用new关键字在子类中新建一个相同方法签名的具体方法,而这样的方法并不具备多态性。这里涉及到一个有趣的话题,是关于Java和C#的比较。在Java语言中,如果没有添加任何关键字,则方法默认就是虚方法,任何子类都可以重写它。C#则相反,它对虚方法给予了显式的定义。Java语言的缔造者显然是“性本善”论者,他认为所有子类的实现者均抱着善意的态度来对待父类的方法,因而他赋予了子类相当程度的自由,但却可能被别有用心者偷偷打开封装的后门。如果确有非常重要的隐私防止被篡改,则可以利用final关键字来强制保护。C#语言的发明者则持有“性本恶”的论调,他恶意地揣测子类总是会不怀好意,所以提供了一套默认的强权,来保护父类的隐私。如果需要对子类开放,则明确地声明为virtual,这就牢牢地把控制权攥紧在父类的手中。
C#保守的做法使得语言的特质更加安全(当然,Java会更加自由),我们可以使用virtual的自由性,搭配方法的访问限制,搭建一个安全合理的白盒框架。virtual关键字的含意本身就是面向子类的,所以,我们应该尽可能地将其放在protected方法中使用。如果该方法代表的行为确实需要公开给调用者,我们可以定义一个公开的具体方法,在其中调用一个受保护的虚方法。
在Template Method模式中,体现了C#这种划分具体方法和虚方法的好处。Template Method模式要求子类只能部分地替换父类的实现,整个骨架则必须保持固定不变。在父类中,我们将模板方法定义为具体方法,将基本方法定义为抽象方法。模板方法规定了基本方法的调用顺序,如果我们可以在子类中重写模板方法,就可能破坏基本方法的调用顺序,从而对整个策略造成影响。Strategy模式就不存在这个问题,因为它的策略是整体的。Template Method模式在模板方法中规定的骨架,实际上就是为调用者制订的前置条件和后置条件。
有一种说法是不要在虚方法中访问私有字段[3]。这存在一定的合理性。因为一旦我们在父类的虚方法中访问了私有字段,那么在子类重写该虚方法时,由于无法获得父类的私有字段值,就可能会导致该字段值的缺失。但这种说法并不完全准确。一方面,我们认为Liskov替换原则主要是为了约束Is-A关系在行为上的一致性[4],如果该字段对行为不会造成影响,则无大碍。另一方面,这也说明我们在重写虚方法时,最佳实践还是需要在重写的同时,调用父类的虚方法,如Decorator模式的实现方式。
[1] Alan Shalloway, James R. Trott Design Patterns Explained
[2] Michael C. Feathers Working Effectively with Legacy Code
[3] Dino Esposito, Andrea Saltarello Microsoft.NET Architecting Applications for the Enterprise
[4] Robert C. Martin Agile Software Development:Principles,Patterns and Practices