第四章 设计
上个章节是为开始设计API打下基础和准备必要的开发背景知识。我分析了各种对API设计有益的品质和讲解了应用于可维护的API设计的标准设计模式。
本章将把这些信息全部整合到一起,涵盖高品质API设计的规范,从总体结构设计到类设计和单个函数调用。然而,如果API不能给用户他们需要的功能,再好的设计也是没有意义的。因此,我也会讲解如何定义功能需求来指定API的功能。我还会专门涵盖用例创建和用户实例的内容,从用户的视角来描述API的行为。这些不同的分析技术可以单独或者整合起来应用的,但是它们应该总是要在尝试API设计之前考虑:你不该设计连你自己都不理解的东西。
图4.1显示的是API设计的基本流程。开始是先分析问题,从设计方案到实现设计。这是一个持续和反复的过程:新的需求会在设计重新评估后出现,必须随其它资源一起修改,如一个主要错误的修复。本章关注这个过程的两个步骤。接下来的章节处理剩下的实现问题,如C++用法和测试。
在进入这些设计主题之前,我会花一些时间讲述下良好设计的重要性。这个开篇的内容来自于数百位工程师的大量代码的多年工作经验。这些经验学习来自于基础代码的改进或转交,经过多年的推动,设计良好的代码变得非常重要,高标准的维持就是从那时开始的。不这么做的话会导致高昂的开销。打个比方,良好的API设计是一段旅程,不是走出的第一步。
4.1 良好设计的例子
本章专注于优雅API设计的技术。不过,很可能你在工程代码中没有实现这些理想化的设计。你可能面对的是个较差内聚性的老旧系统,暴露内部细节,未经测试且缺乏文档和显现出非正交的行为。不管这些,这些系统的有些部分还是设计良好的,符合当初的构思。不过,经过一段时间后, 软件已经过时了,变得难以扩展和需要经常维护。
图4.1
API设计的步骤,从分析到设计,再到实现。
4.1.1 累积技术债务
所有的大型成功的公司都是从小不点开始的。一个经典的例子就是HewlettPackard(惠普),创始人是两个电气工程师,创立于1939年的Palo Alto(译者注:帕洛阿尔托(美国加利福尼亚州西部城市,靠近旧金山))并最终成长为世界一流的科技公司,收入超过1000亿美元。就对品质的要求的来说,让一个刚起步的公司获得成功和那些操持数十亿美元的上市公司是非常不同的。在公司成长过程中,往往要经历几个大的变革,同样的事情也发生在他们的软件实践上。
一个小型软件在刚起步时也需要让它的产品尽快出来,以免被市场上的竞争对手打败或耗尽资本。在这种环境下,软件工程师的主要压力是要尽快产出更多的软件。在这些条件下,用太长时间来设计和实现API常常被认为是奢侈到无法承受的。这个选择是公平的,到底是要尽快占领市场还是让公司倒闭。我曾经遇到过一个软件架构师在准备开发小型软件时,禁止编写任何注释、文档和测试,因为他认为这将大大减慢开发进度。
然而,一旦软件获得成功,主要的压力就转向提供一个稳固的、易于使用和文档丰富的产品。还会出现新的需求,迫使软件需要扩展,而这些原先是没打算过要支持的。但是这些都是构建在原先并没有打算设计成长久运行的产品核心上。结果就是,用Ward Cunningham的话说,这是欠下的技术债务(Cunninghman,
1992):
初次遇到有可能成为债务的代码。[P106 第4段]一点债务可以加速开发,直到后面需要重新编写。如果不重新的话,会发生危险。每次使用不大正确的代码都会增加债务的利息。整个工程项目有可能因为松散实现的债务负担而导致停滞。
Steve McConnell扩展了这个定义,提到有两种类型的债务:无心的和故意的。前者是在软件设计时意图是好的却变成容易出错的,当一个初级工程师编写的代码质量较低时,或公司购买其他公司的软件,后来却变得一团糟。后者是有意识的策略选择,因为时间比较赶、资金有限或资源限制,在截止日期到来前采用看似正确的解决方案。
这里还有一个重要的截止日期的问题,当察觉到的时候已经没有足够的时间返回并做出修复。因此,这种技术债务会逐渐累积:系统中存在短期粘合代码,慢慢变得更加深入地嵌入,最后改进技巧仍然存在于代码中,并成为用户依赖的特征,代码的便捷性和文档都被忽略了,最后那些当初的设计品质降低和模糊了。Robert C. Martin定义了四种警告符号,基础代码达到了这个点。(Martin,
2000)。下面罗列那些的指示版本,轻微修改了下:
q脆弱(Fragility):当软件存在不可预知的副作用或执行细节(依赖于系统的其它部分的内部)对系统非关联部分进行暴露时,软件就变得脆弱。这种结果会导致修改了系统的一部分会导致看起来无关的代码部分出现难以预期的错误。这样的话,工程师就会害怕去动那些代码,而成为维护的一种负担。
q死板(Rigidity):死板的软件是指拒绝做修改的软件。实际上,这种设计是相当脆弱的,即使是简单的修改,实现起来都要付出相当的代价,通常都会相当耗时,还面临重构的风险。结果就是粘性基础代码做出新的修改是那么的缓慢。
q固定(Immobility):一个优秀的工程师会观察代码,看看哪里可以重用代码,这样可以提高软件可维护性和稳定性。固定代码在软件中就是没有按照上面那么做,很难在其它地方重用代码。例如,实现处代码和周围的代码的关系比较混乱或者根据特定领域的知识而采用硬编码。
q不可转移(Non-transferability):如果在你的组织中,只有一个工程师会使用某部分的代码,那么这就称为不可转移。这个工程师通常是最早写那部分代码的开发人员或者是倒霉的要扫尾的人。对很多大型基础代码来说,不可能每个工程师都深入了解代码的每个部分,所以有些方面是其它工程师难以深入研究的,他们无法有效的处理项目中的这种情况。
这些问题的结果就是组件间的依赖增加了,会导致概念上无关的代码部分彼此依赖内部的实现细节。时间一长,这中情况在程序的绝大部分状态和逻辑中到达极致时,会变得冗余,也会影响全局。这也常常叫做“意大利面条式”(spaghetti)的代码或者叫“大泥丸”(big ball of mud)(Foote and Yoder, 1997)。
4.1.2 偿还债务
最后,公司会遇到这种情况,他们欠下了太多的技术债务,他们需要花费更多的时间去维护和容忍遗留下来的旧代码,而给用户添加新的特性却不会花那么多时间。这常常需要通过“下一代”项目去修复旧系统上的问题。例如,我遇到过前面几个段落提过的那个软件架构师,当他的公司成长并走向成功,他的团队就忙于重写原先设计好的所有代码。
图4.2
一个糟糕的紧耦合系统,变成一个“大泥丸”。
从策略上说,开发下一代项目有两点需要考虑:
(1).改进版:设计的系统组合所有新的需求并反复地重构现有的系统,直到符合要求。
(2).变革版:抛弃旧有的代码,从架构上完全重新设计和实现新的系统。
这两种选择都有各自的优缺点,也都是不容易的。我听说过这样的事情:有人改装过一辆车的引擎,当车速达到100英里每小时的时候,刹车就失灵了。
就改进版来说,你总是有一个可以发布的功能系统。不过,你仍要在旧的设计框架上开发,这可能不再是表达问题的最好方式。新的需求会导致关键用例从根本上改变最佳的流程。采用改进版的一个好方法就是通过新的设计良好的API(比如使用第三章讲过的封装模式,如外观模式)来隐藏老旧的代码,逐步更新让所有的用户检阅这些更简洁的API,并且把代码置于自动化测试之下。(Feathers,
2004; Fowler et al., 1999)。
就变革版来说,你不会受到旧技术的束缚,可以利用从旧版本(虽然这作为一个务实的步骤,但是你仍然可以利用从旧系统中得到一些关键类来保留关键功能行为)学习到所有知识进行设计。你也可以替换新的处理过程,比如给所有的新代码采用大量的单元测试,使用交换工具,如采用新的错误追踪系统或源控制管理系统。不过,这些选择都需要耗费很多时间和精力(也就是烧钱)来实现一个使用方便的系统,同时你也停止使用旧的工具进行所有的开发或继续在旧系统上实现,不断提升新的系统,以获得成功。你也必须留意第二个系统带来的“并发症”,新的系统容易因为目标太过庞大或太小众化设计(overengineered)而导致失败(*s,
1995)。
在这两种情况下,下一代项目的需要带来了团队动态问题和规划的复杂性。例如,你要让一个团队同时关注新的和旧的系统吗?这从个人观点出发是值得的。不过,出于短期策略需要倾向于胜过长期战略开发,因此可能会很难维持下一代项目遇到的致命错误的修复和维护旧的系统。或者,如果你把团队一分为二,会遇到一个关于“士气”的问题,在旧系统上开发的人员会觉得他们自己是二流的开发人员,留下来支持没有前途的代码库。
此外,为重新启动需要的技术往往会促使企业和公司发生重组。这将导致团队结构和关系进行重新评估和改造。它也会严重影响人们的生计,尤其是当公司决定缩小调整了部分业务。而这一切都是因为糟糕的API设计?或许,这有点戏剧性。重组是公司发展过程中很自然的过程,这有很多原因会导致重组:刚起步的10人公司的工作架构肯定不适合10000人的大型企业。然而,对软件失利做出反应的业务需求当然是一种导致重组的原因。例如,在2010年6月,Linden Lab裁员近30%,并进行了全公司的重组,主要是因为软件的发展速度不够快,不能满足该公司的收入目标。
4.1.3 长期化设计
投资一个大型的下一代项目来取代老旧的代码所消耗的成本可能高达数百万美元。例如,仅支付一个20人的团队(包括开发人员、测试人员、文书和管理人员)每人平均年薪10万美元,就要高达200万美元以上。不过,良好的设计原则可以帮助避免这种极端的后果。让我们先列举一些为什么会导致这种情况发生的原因:
(1).公司仅仅是起初不采用一个良好的软件设计,因为相信这会浪费宝贵的时间和金钱。
(2).负责项目的工程师缺乏良好的设计技术或者是认为不需要使用在他们的项目上。
(3).觉得编写的代码不需要用多久,例如,它仅仅是匆忙编写的一个演示或采用抛弃式原型(throw-away prototype)代码。
(4).软件项目的开发过程中并未注意到技术债务,以致随着时间的推移忽略或忘记了系统中所有需要修复的部分。(敏捷过程如Scrum在产品持续开发过程中,会保持债务是可见的。)
(5).起初的系统设计是良好的,但是随着时间的推移,由于不规范的增长,它的设计品质逐渐变差。例如,即使会危及到系统的设计,也要添加较差的修改到代码中。用Fred *s的话讲就是:系统失去了它的概念完整性(*s, 1995)。
(6).不断变化的需求往往也需要设计跟着演变,但该公司不断推迟这个重构工作,不管是有意还是无意的,更喜欢通过短期、技巧型和粘合代码(glue code)进行修复。
(7).允许代码错误长期存在。这通常是由于不断有新的功能添加进来,无法专注于最终产品的整体质量。
(8).代码没有进行过任何测试,所以系统退化导致工程师修改功能或某个部分的代码时变得提心吊胆,就害怕这些修改。
让我们处理这些问题的其中一部分。首先,良好的设计会严重拖慢项目的观念,它可能是最为昂贵的全局决策,编写杂乱结构的软件,让你可以更快地推向市场,然后要想在市场上维持生存就得完全重写一次代码。此外,编写好的软件在某种程度上也确实会更费时间,例如编写二代的代码应用于pimpl类或编写自动化测试来验证API的行为。然而,良好的设计开始时的作用并不明显,它总是要在长期运行后才能显示出它的好。保持一个接口和实现之间的良好分离可以大大降低维护的负担,即使在短期内,要编写自动化测试也可以让你有信心快速修改功能而不用破坏现有的代码行为。值得关注的是Michael Feathers把老旧代码定义成没有经过测试的,从这点上来说老旧的意思不是旧;所以,现在你编写的代码也可能是老旧的(Feathers, 2004)。
API之美在于其底层的实现可以根据你的需要,或者是平庸和混乱的,或者是全面和优雅的。良好的API设计就是给出一个稳定的逻辑接口来解决问题。不过,原先的API里面的代码可以是简单和没什么效率的。接着你可以添加更多的复杂实现,而不会打破其逻辑设计。与此相关的就是API允许你把问题隔离到指定的组件。通过管理组件之间的依赖关系,你可以限制问题的规模大小。相反,在“意大利式面条”代码中,每个组件都依赖其它组件的内部,行为变得非正交和一个组件的错误都会影响到其它组件,这是不易察觉的。因此,要花时间了解的信息是在开始就要花精力在高层次的设计之上:要专注于组件间的依赖和关系。这正是本章的要点所在。
问题的另一个方面是,如果不继续保持高质量的代码,如果在改进代码时逐渐降低设计质量。为了在最后期限赶工完成也是可以的,只要你稍后会重新修正就好。请记着欠下的技术债务要即使清理掉。代码倾向于比你想象的存活期更长。当你要削弱一个API时最好要想起这个事实,因为你可能要在很长时期维护这个API。因此,你要意识到对API的设计上出现的新需求和重构代码之间要维持一种一致和最新的设计。还有同样重要的是强制修改对API的控制,这样就不会演变成无人监督或混乱的情形。我会在第八章版本控制时讨论实现这些目标的方法。
4.2 收集功能需求
为软件给出一份良好设计的第一步就是要理解它到底需要做什么。令人奇怪的是工程师们浪费很多开发时间在构建错误的东西上。还要睁大眼睛看到这样的情况:两个工程师听取同一份非正式的任务描述,最后结束离开时的想法却是完全不同的。这不一定是坏事:在解决问题时有不同的想法是好事。问题是任务并没有描述的那么详尽,以便每个人能够有可共享的理解并一起实现相同的目标。这时需求就得登场了。在软件工业有很多不同类型的需求,包括下面所罗列的:
q业务需求:描述了软件在业务术语中的价值,也就是说:它是如何满足某个组织的需要的。
q功能需求:描述了的软件的行为,也就是说,软件应该支持完成什么。
q非功能需求:描述了软件必须实现的质量标准,也就是说:软件对用户来说的使用体验是如何的。
我将在下面的部分主要集中讲述功能和非功能需求。不过,它仍然是非常重要的,确保你的软件的功能与您的业务战略目标保持一致,否则你就得冒着API能够长期成功的风险。
4.2.1 什么是功能需求?
功能需求就是一种理解如何构建,以确保不会浪费时间和金钱构建错误的东西的简单方法。它也会给你一些前期的必要资料,以便实现一个符合需求的优雅设计。在我们的软件开发阶段图中(图4.1),功能需求是在分析阶段的方形中。
就API开发的来说,功能需求定义了API所预期的功能。这些应该和API的用户合作,让他们代表用户们给出需求和建议(Wiegers, 2003)。明确地获取需求也让你和目标用户可以就功能的范围达成一致。当然,API的用户也可以是开发人员,不会只因为你也是开发者,你就知道他们想要的。有时可能需要猜测或自己研究需求。尽管如此,你还是要确定好API的目标用户,在API领域的专家和从他们的输入获取到功能需求。例如,你可以做个采访、会议或者通过调查问卷来询问读者:
q他们希望API完成什么样的任务?
q从他们的角度上来看,什么样的流程是最佳的?
q所有可能的输入情况,包括输入的内容和有效值的范围。
q所有可能的输出情况,包括内容、格式和范围。
q必须支持什么样的文件格式或协议?
q他们对问题域有什么样的(如果有的话)心理模型(mental models)?
q他们使用什么样的域术语(domain terminology)?
如果你正在修订或重构现有的API,你也可以叫开发人员在他们使用API处写上代码注释。这样可以帮助识别麻烦的工作流程和API未使用的部分。你可以问他们使用API的理想方式(Stylos, 2008)。
提示
功能需求指定API是如何表现出行为的。
功能需求也可以用来支持非功能需求。这些要求,实际上不是如何判断API的行为,而是判断其操作上的限制。这些质量要求对用户来说是非常重要的,就像API提供的功能一样重要。非功能需求的例子包含如下几个方面:
q性能:是否有对某个操作有速度限制?
q平台兼容性:代码运行在哪个平台之上?
q安全性:是否有数据安全、访问或者隐私的要求?
q伸缩性:系统可以处理实际中的数据输入吗?
q灵活性:在发布后系统是否需要被扩展?
q可用性:用户容易理解、学习和使用API吗?
q并发性:系统需要利用多个处理器吗?
q费用:软件的费用是多少?
4.2.2 功能需求例子
功能需求通常是在需求文档中所管理一个需求,每个需求都给定一个唯一的标识符和描述。需求的原则是用来说明为什么它是必要的。这典型地表现为简明的要点清单,组织成不同主题的文档,使需求与系统的相同部分可以位于同一位置。
一份好的功能需求应该是简明、易于阅读、不含糊、经得起检验和不使用术语。还有重要的一点是它们不能过度详细地指定技术实现:功能需求是给出API要做什么的文档,而不是如何做。
为了说明这些要点,下面给出一个关于用户和自动取款机(ATM)交互的例子,功能需求如下所示:
(1).系统应该在没有现钞或无法连接到金融机构时停止进一步的交互。
(2).在ATM上进行金融交易时,系统应该验证插入的卡的有效性
(3).系统应该验证用户输入的PIN(个人识别码)号是否正确。
(4).系统需要分配用户取款的数额,如果是有效地,还要把该数额记入用户的账户。
(5).当交易没有完成时,系统应该通知用户。在这种情况下,不能从用户的账户中扣钱。
4.2.3 需求维护
你肯定不希望出现一直变化的需求。不过出现这种情况的原因有很多,但最常见的就是用户(还有你)在开始构建系统是如何运行时就有一个清晰的想法。因此,你应该查明版本和日期方面的要求,使你能够通过查阅文档的特定版本来了解它的新旧程度。
平均而言,在项目功能需求中有25%是会随着开发改变的,这会影响到70–85%代码需要返工(McConnell, 2004)。虽然和客户保持不断变化需求的同步是很好的,但是你也应该让每个人知道这些需求变化的成本。添加了新的要求会导致项目需要更长的时间才能交付。它也可能使设计发生重大变化,造成大量的代码需要重写。
你也应该要特别小心需求方面的陷阱。热河功能需求方面的重大修改都会导致时间表和项目成本的重新核算。一般来说,新增加的任何需求都应该评估其对商业价值带来的增量。应从务实的角度评估这个新的规定,这有助于权衡实现这些所带来的成本变化。
4.3 创建用例
用例描述了与用户或其它软件的进行交互的API的行为(Jacobson, 1992)。从本质上来说,用例是功能需求的一种形式,专门用来捕捉是谁在处理API和目的是什么,而不仅仅是简单地提供程序的特点、行为或实现注意点的列表。关注用例可以帮助你从用户的视角来设计一个API。
这并不罕见,生成一份功能需求文档,以及一套用例。例如,用例可以用来描述从用户的角度来看一个API,而功能需求可以用来描述一个功能或一个算法的细节。不过,仅仅专注于这些技术中的一种就足够了。在这种情况下,我建议创建用例,因为这些是用户最想用于系统交互的方式。当同时使用这两种方法,你可以从用例推导出功能需求,反之亦然。但更为典型的是,通过和用户合作,先生成用例,再从这些用例中推导出功能需求的列表。
提示
用例就是从用户的角度来描述API的需求。
Ken Arnold利用对驾驶汽车的分析来说明设计一个接口(基于它的用法,而不是其实现细节)的重要性。他指出,你更有可能这样问一个有丰富经验的司机:“驾驶员是如何控制汽车的?”,而不是这么问:“驾驶员是如何调整燃油进入活塞的速率的?”(Arnold, 2005)。
4.3.1 开发用例
每个用例都描述了一个目标,一个“演员”试图实现的。演员是启动交互系统的一个外部实体, 例如人、设备或其它软件。每个和系统交互的演员都扮演不同的角色。举个例子,某个数据库的演员可以是管理员来扮演这个角色、也可以是开发人员或者是数据库用户。一个创建用例过程的好方法就是:(1)识别系统的所有演员和他们各自扮演的角色。(2)识别每个角色需要完成的全部目标。(3)给每个目标创建用例。每个用例都应该利用问题域中的词汇来纯英文编写。它的名称用来描述演员的价值。每步用例应该使用角色名开头,接着用一个描述性的动词。例如,继续使用ATM机的例子,下面的步骤描述了如何验证用户的PIN号:
(1).用户插入ATM卡
(2).系统验证插入的卡对于本ATM机是否是有效的。
(3).系统提示用户输入PIN号。
(4).用户输入PIN号
(5).系统检验PIN号是否正确。
4.3.2 使用用例模板
一个好的用例表示一个行为单元的面向目标的叙述说明。它包括一个独特的序列步骤,描述工作流程来实现用例的目标。它也可以提供清晰的前和后置条件(pre-and postconditions)来指定使用案例前后的系统状态,也就是说,明确陈述用例间的依赖,以及促使用例初始化的触发事件。
用例可以用来记录不同程度的赘言。例如,它们可以是简单的几句或者是层次分明、相互参照的规范,符合特定的模板。它们甚至可以是直观地描述,如用UML用例图。(Cockburn, 2000)。
提示
用例可以是简短的面向目标的描述列表,也可以是更正式的遵循规定的模板的结构规范。
在更正式的实例中,也有许多不同的模板格式,用于表示用例文本上的样式。这些模板往往是针对特定项目的,可做为短期或广泛的适用于那个项目。不要沉溺于模板的细节:它更重要的作用是清楚地勾勒出需求,这显然比僵化的符号来得重要(Alexander, 2003)。然而,用例模板还是有一些共同之处的,如下所示:
名称:这是用例的唯一标识符,通常采用动词-名词的格式,如Withdraw Cash(取现金)或Buy Stamps(买邮票)。
版本:用来区别用例版本的数字。
描述:用一两句话来做个对用例的简短总结。
目标:描述用户要实现什么。
演员:演员扮演要完成目标的角色。
相关者:用例相关联的个人或者组织。例如ATM用户或银行。
基本过程:一系列描述典型过程的事件步骤。这应尽可能避免条件逻辑。
扩展:一系列导致可选步骤发生的条件。用来描述:如果目标失败了,采取什么行动。例如,当输入的PIN号无效时。
触发:导致用例初始化的事件。
先决条件:让触发成功执行所需要的一系列条件。
后置条件:描述用例成功执行后的系统状态。
注意:无法归入到其它类别的额外信息。
4.3.3 编写良好用例
编写用例应该是一个直观的过程。它们是用平实的语言,从用户的角度来看待是如何使用API的。然而,即使这是直观的任务也可以从下面的指导方针和建议中获得好处:
使用行业术语:用来描述用例的术语应该对用户来说觉得自然。用户应该熟悉这些来自目标领域的术语。实际上,用户应该能够阅读用例和轻松地理解这些情况。
q请勿过度指定用例:用来应该是描述系统的黑盒(black-box)功能,也就是说,你要避免指定实现细节。你也应该避免包含太多用例的细节。Alistair Cockburn使用了投入硬币到糖果机的例子。而不是试图指定投入正确数量的不同组合,如投入四分之三或15个镍币,或先10个镍币再5个,你只需要这样写:人们投币。
q用例不能定义全部需求:用例并不能收集需求的所有可能形式。例如,它们并不是代表系统设计、功能列表、算法细节或者系统任何不面向用户的其它部分。用例集中于如何与用户交互的API行为要求。你可能还希望往用例中添加用例的功能性和非功能性需求。
q用例不能定义设计:虽然你常常可以从用例中创建一个高层次的初步设计,但是你不该陷入这样的陷阱:用例直接定义出最佳的设计。事实上,它们无法定义全部的需求是一个原因。例如,它们没有定义API的性能、安全性或者网络方面的,而这些都会对设计产生很大的影响。而且,用例是从用户的角度编写的。因此,你可能需要根据冲突或不精确的目标重新诠释他们的反馈,而不是只根据字面的意思来对待。
q在用例中不要指定设计:人们普遍认为:应该避免在用例中描述拥护接口,因为UI是属于设计的部分,而不是需求,而且UI设计是更加多变的(Cockburn, 2000)。虽然这个原则并不直接适用于无UI的API设计,但是从中可以推知出:在我们的用例中,应该剥离出API的设计规范。用户可以尝试为你提出一个具体的解决方案,但是可能还存在更好的解决方案。因此,API的设计应该遵循用例中的分析。换句话说,用例定义的是用户想要实现的目标,而不顾实际的设计。
q用例可以直接测试:用例本身并不是什么测试计划,因为它们没有指定特定的输入和输出值。但是,它们指定了用户期望实现的主要流程。因此,它们是进行API直接自动化测试的重要出处。编写的一系列测试,验证这些关键的工作流程可以让你有信心满足用户的需要,而且在将来改进API时不会破坏这个功能。
q预料到返工:不要想在第一次就可以让用例变得十分完美。用例分析是一个发现的过程,它可以帮助你了解要构建的系统。因此,当你逐步了解整个系统,你也可以完善现有的用例,这是一个重复的过程。不过,众所周知:需求中的错误会对项目构成严重影响,会造成重新设计和重新实现。这就是为什么第一条建议中我要你避免把用例弄得太详细。
q不要坚持包罗万象:基于同样的原因,用例不要包含所有的需求形式,你不应该让用例表示API的所有方面。而且,你也不应让用例覆盖一切内容。系统的某些部分或许已经可以很好的理解,不需要再从用户角度看。还有一些需要注意的地方就是:你不可能有无限的时间和资源来编辑详尽的用例,你应该抓住最重要的面向用户的目标和工作流程。(Alexander, 2003)。
现在把上面所讲的内容整合起来,我将给出一个关于ATM例子的完整用例。用户输入一个PIN号,使用我们前面讲过的模板来格式化用例。
名称:输入PIN
版本:1.0.
描述:用户输入PIN号以验证她的银行账户信息。
目标:系统验证用户PIN号
相关者:
1. 用户需要使用AMT服务。
2. 银行需要验证用户的账户。
基本过程:
1.系统验证ATM卡在这个ATM机上是有效的。
2.系统提示用户输入PIN号。
3.用户输入PIN号。
4.系统检测API号是否是正确的。
扩展:
a.系统无法识别ATM卡:
a-1. 系统显示错误信息并中止操作。
b. 用户输入无效的PIN:
b-1. 系统显示错误信息并让用户重试。
触发:用户把卡出入ATM机。
后置条件:用户的PIN号在金融交易中通过验证。
4.4.4 需求和敏捷开发
敏捷开发这个术语是与敏捷宣言(Agile Manifesto)原理保持一致的软件开发的方法。例子包括极限编程(Extreme Programming,XP)、Scrum和DSDM。敏捷宣言(http://agilemanifesto.org/)写于2001年2月,有17个贡献者想要找到更加轻量和对传统的开发过程时间上的更灵活地选择。它指出在开发软件时,应重视下面的几个品质:
q开发者以及彼此的互动重于流程和工具。
q可以工作的软件重于面面俱到的文档。
q客户合作重于合同谈判。
q随时应对变化重于遵循计划。
因此,敏捷方法学淡化了以文件为中心的流程,取而代之的是偏向于迭代工作代码。不过,这并不意味着它们没有任何形式的要求。它的意思是,需求是轻量级的,易于修改的。维持一个具有大量文字的正式需求文档将认为是不敏捷的。不过,用例从通常概念上来说是非常符合敏捷过程的一部分,如Scrum和XP,特别强调了关于用户叙述(user stories)的制作。
用户叙述是一种高层次的需求,即只包含足够信息来让开发人员能够评估需要多大的努力才能完成。这和用例在概念上非常相似,除了从目标上要把它们弄得简短,通常就是用一句话。因此,一个简单的非正式用例比起正式的模板驱动或UML用例更像用户叙述。另一个重要区别是,用户叙述并不都是事先完成的。许多用户叙述是随着代码开发逐步添加的。也就是说,你开始给设计编写代码时,在你尝试实现它们后,应遵循规范,免得出问题。
提示
用户叙述是在一个敏捷开发过程中来自用户的捕捉最小化需求的一种方式。
用户叙述的另一个重要内容是:它们是由项目相关者编写的,不是开发人员,也就是说是用户、厂商、企业主或对要开发的产品感兴趣的支持人员。让用户叙述保持简短,这样就可以使相关者只需要几分钟就可以完成编写。Mike Cohn建议在描述用户叙述时采用一些简单的格式:
做为[角色],我要[某些],以便[做什么或有什么好处]
举个前面用过的ATM机的例子,给出的例子是关于五个不同的和发放现金的机器进行交互的用例:
q做为一个客户,我要取现金 ,以便用来买东西。
q做为一个客户,我要把钱从我的储蓄账户转到支票户头,以便我可以用支票。
q做为一个客户,我要把钱存进我的储蓄账户,以便增加我的账户余额。
q做为银行方,我要用户身份可以得到安全地验证,以便ATM可以防止欺诈活动。
q做为ATM的操作员,我要补充ATM机里的现金,以便用户可以顺利取到现钞。
给定一个精心编写的用户叙述,工程师就可以估算出开发的工作量有多大,以一个抽象的数量来说就是叙述点,接下来的工作就是实现这些叙述。相关者也常常会给出一个用户叙述的优先级别提示,来帮助你确定叙述的次序安排。接着,在快速审查过程中,相关者定期评估软件的状态,可提供用例用于下次开发中的迭代。换句话说,这意味着有用户的积极参与和迭代的开发风格来代替大量的前期需求文档。
Cohn还提供了一个易于记忆的缩写,帮助你创建良好的用户叙述。缩写词就是INVEST,每个字母都代表精心编写的用户叙述的品质(Cohn, 2004)。
独立的(Independent)
可协商的(Negotiable)
有价值的(Valuable)
可估计的(Estimable)
小的(Small)
可测试的(Testable)
此外,为编写良好用例所提供的所有建议也同样适用于用户叙述。例如,由于Scrum和XP等敏捷过程不会告诉你如何设计API,你一定不能忘记:一旦你让用户叙述积压在那边,你还是要通过一个单独的设计过程,制定出如何最好地实现那些叙述。这是本章的剩余部分要讲述的主题。
4.4 API设计的元素
最后,我们来谈谈设计!设计一个良好的API的秘诀是对问题域有个合适的抽象,接着设计适当的对象和类层级来表示抽象。
一个抽象只是对某物的简化型描述,可以不需要任何知识就可以理解它是如何通过程序实现的。它往往强调的是事物的重要特征和职责,而忽略并不重要的细节,只要理解其本质就足够了。此外,你还会经常发现复杂的问题,表现出来的就是众多的抽象层级或层。(Henning, 2009)。
例如,你可以描述汽车是如何工作在6个基础组件之上的:供油系统、引擎、变速器、驱动杆、车轴和*。燃油系统提供能量来转动引擎,这促使变速器旋转,驱动杆是连接变速杆和车轴的,传递力量给*让车辆不断地向前行驶。这一层抽象是用来理解汽车向前运动的基本原理。不过,你也可以给出另一层抽象,通过更多的这些组件来提供更多的细节。例如,一个内燃机可以描述为带有若干相互关联的组件,包括活塞、机轴、凸轮轴、分电器、调速轮和正时皮带。此外,引擎还可以归类为几种不同的类型,如内燃机引擎、电力引擎、燃气/电力混合型或氢燃料电池型。
同样地,绝大部分复杂软件系统的设计都显现出多个层次的细节结构,而那些层次也可以通过不同的方式来浏览。Grady Booch认为任何复杂的系统都有两种重要的层次视图(Booch等, 2007):
(1).对象层次结构:描述系统中的不同对象是如何合作的。这表示的结构分组是基于对象间的“谁是谁的一部分”(part of)的关系(比如,活塞是引擎的一部分,引擎是汽车的一部分)。
(2).类层次结构:描述相关对象之间共享的公共结构和行为。它用来处理对象属性的一般化和特殊化。这可以认定为对象间的“是一个”(is a)关系(比如,混合引擎是汽车引擎的一种类型)。
这两种视图在软件系统中都是同等重要的。图4.3就说明这两个概念,显示了相关对象的层次和继承了行为和属性的类层次。
与此相关的,大家普遍认为软件开发设计阶段的两大主要内容包括(Bourque等2004):
(1).架构设计:描述整个软件的顶层结构和组织。
(2).细节设计:描述设计里的单个组件到可以充分实现的级别。
因此,作为一般的做法,我建议定义一个对象层次来界定顶层概念结构的系统(或架构),然后通过类层次结构来改进,指定具体的C + +类来提供给你的用户使用。细节设计过程就是API定义的类也会考虑到它们提供的函数和参数。因此,本章的剩余部分将依次关注这些主题:
(1).架构设计。
(2).类设计。
(3).函数设计。
提示
API设计包括开发一个*的架构和一个详细的类层次。
4.5 架构设计
软件架构描述了整个系统的大致结构:API中*对象的集合和它们相互间的关系。通过开发一个架构,你将获得在系统中的不同组件的抽象认识,以及它们是如何通信和相互合作的。
图4.3
汽车设计显示的是一个“谁是谁的一部分”对象层次和“是一个”的类层次。箭头指向是从特殊化到更加一般化的类。
这里需要花时间考虑一下API的*架构,因为架构一旦出了问题将会对你的系统产生深远而广泛的影响。因此,本节详细介绍了为API制定架构的过程和如何把问题域分解成一组合适的抽象对象。
4.5.1 开发架构
对于任何给定的问题没有绝对好或坏的架构。如果你给同一份需求制定两个不同的架构,那么最终你会得到两份不同的解决方案。最重要的是要制定一个经过深思熟虑的有针对性的设计,提供一个框架,以实现系统和在各种带有冲突的需求和约束之间寻找到平衡点(Bass等2003)。从一个较高的视角来看,创建一个API的架构过程可以解析为四个基本步骤:
(1).分析能够影响到架构的功能需求。
(2).识别和说明架构上的约束。
(3).创造系统中的主要对象和它们的关系。
(4).沟通交流和架构文档化。
这些步骤的第一步是由早期的需求收集阶段完成的(翻回见图4.1),是基于正式的功能需求文档,一组面向对象的用例或一批非正式的用户叙述。第二个步骤包括捕捉和说明设计中架构上的约束的所有因素。第三个步骤包括给系统定义高层次的对象模型:关键对象和它们是如何相互联系的。最后,应该把架构和负责实现的工程师进行交流。图4.4说明了这些步骤。
需要特别强调的是:上述的步骤顺序不是一蹴而成的,你只执行一次,就可以奇迹般地得到完美的架构。前面已经讲到过,软件设计是一个迭代的过程。很难第一次就得到正确的结果。不过,第一次发布API后要挑剔些,因为之后的更改将是代价高昂的。因此,在发布给用户开始构建他们自己的程序之前,就应该测试好你的设计和逐步进行改进。
图4.4
开发API架构的步骤:(1)收集用户需求 (2)识别约束 (3)创建关键对象 (4)沟通和交流设计。
4.5.2 架构约束
API设计不是在真空环境(译者注:会受到外来条件的影响)下设计的。总是会有因素影响和制约架构。为了让设计过程可以继续下去,你必须认真识别和适应这些因素。Christine Hofmeister和她的合著者把这个阶段称为全局分析(global analysis)(Hofmeister等,2009)。这里全局的意思是可以影响整个系统的因素,它们常常构成一个群体,并相互依赖和相互矛盾。这些依赖可以分成3个基本类别。
1.组织因素,如:
a.预算
b.时间表
c.团队规模和经验
d.软件开发过程
e.决定在子系统上自行构建还是另行购买
f.管理侧重点(如:日期对比特性对比质量)。
2.环境因素,如:
a.硬件(如:机顶盒或移动设备)
b.平台(如:Windows、Mac和Linux)
c.软件约束(如:使用其它API)
d.客户端/服务器端约束(如:构建一个Web服务)
e.协议约束(如:用POP还是IMAP作为邮件客户端)
f.文件格式约束(如:必须支持GIF和JPEG图片)
g.数据库依赖(如:必须连接到远程数据库)
h.决定在子系统上是暴露还是封装
i.开发工具
3.操作因素,如:
a.性能
b.内存利用
c.可靠性
d.有效性
e.并发
f.可定制性
g.可扩展性
h.脚本性(Scriptability)
i.安全性
j.国际化
k.网络带宽
软件架构的职责就是优先考虑这些因素,把包含在功能需求的用户约束组合起来,并找出最好的折衷方案来生成灵活和效率高的设计。给预期的用户设计API时要格外谨慎,提高可用性,才能获得成功。然而,并没有什么完美的设计;需要权衡给定的组织、环境和操作限制。例如,如果你需要赶时间来完成,那么你就不得不依靠简化的设计,可以尽量使用第三方API并限制支持的平台数量。
提示
架构设计受制于许多独特的因素:组织、环境和操作。
一些约束是可以通过协商来解决的。例如,如果有个用户的需求是在系统中放置一个过分复杂的内容,那么用户很可能愿意接受已给可选的方案,这样可以省下一笔钱或者更快完成。
除了确定的因素会影响最初的架构,你也应该评估下哪些是在开发过程中容易发生更改的。例如,该软件的第一个版本的扩展性可能不好,不过你知道最终要移植一个插件模型,可以让用户添加他们自己的功能。另一个常见的例子是国际化。起初你并不关心多语言支持,不过稍后有了这个需求,这个对代码有很大影响。因此你的设计应该预期到这种有理由在未来发生变化的约束。您或许能够拿出一个可支持更改的设计方案,或者如果这是不可行的,你可能需要考虑应变计划了。这通常被称为“设计修改”(Parnas,1979)。
提示
总是需要对设计进行修改。修改是不可避免的。
这也是值得思考的:你如何把项目中依赖的任何API的设计和修改隔离起来。如果你使用的另一个API是完全隐藏于内部的,那么这就不会有任何问题。然而,如果你要在公共接口中对外暴露一个依赖的API,那么你就应该考虑是否可能限制它的访问级别尺度。在某些情况下,这根本不实用。例如,如果你使用boost::shared_ptr从API返回智能指针,那么你的用户也将需要依赖Boost的头文件。然而,在其它情况下,你或许可以提供相关的API包装,这样你就不需要强迫你的用户直接依赖该API。例如,KDE API是构建于Qt库之上。不过KDE是使用Qt
API上的瘦包装(this wrapper),这样就可以让用户不会直接依赖于Qt API之上。做为一个特列,KDE提供的类,如KApplication、KObject和KPushButton来替代Qt中直接暴露的QApplication、QObject和QPushButton类。用这种方式包装依赖API让你可以通过间接的额外层来免受一个依赖API的修改影响,用来解决错误或特定平台的限制。
4.5.3 识别主要抽象
一旦你分析完系统的需求和约束,你就准备开始构建高层次的对象模型。从本质上来说,这意味着识别问题域中的主要抽象和把这些分解成相互关联的层次结构。图4.5显示的就是这个过程的例子。它显示的是OpenSceneGraph API的*架构,这是一个用来可视化模拟程序的开源3D图像包(http://www.openscenegraph.org/)。
图4.5
OpenSceneGraph API*架构样例
立足于问题域中的实际概念架构,你的设计应该在将来需求发生变化时仍然是通用和健壮的。回想一下我在第二章罗列过的API品质的第一要点:一个良好的API应该构造出问题域模型。然而,把一个问题精确地分解成一堆抽象不是一件简单的任务。需要对问题有充分地理解,例如编写一个编译器或Web服务器,你可以利用搜集到的知识,这些知识是经过长期积累并由许多其他设计人员发布出来的。不过,对于新的问题,前人是极少或没有涉猎过的,那么这时候就要自己创造一个好的抽象分类。
分类这个问题并不是计算机科学中独有的。我们星球上的生物分类自从亚里士多德时代开始就一直存在着争论。在18世纪,Carolus Linnaeus提出把生命分成两大类:由植物和动物组成。到了19世纪,这个做了完善,包含微观生命形式。在电子显微镜时代又把类别增加到5个或6个。不过,到了21世纪的研究又对传统的分类提出了异议,提出了替代的“超群”(supergroup)模型。到底哪些特点可以用来创建分类,这个还存在很多争论。亚里士多德的动物分类是根据它们的繁殖方法,通过它们的形态学(相似的结构或外观)进行二项式系统的生物分组,而达尔文从中得到启发:支持动植物的分类学是通过共同的血统来分类的(生物是否有共同的祖先)。
4.5.4 创建关键对象
虽然在系统中对主要的抽象进行分类是有困难的,但是我仍然可以提供一些关于如何解决这些问题的建议。因此,这里有一些可以借鉴的技术,把一个系统分解成一组关键对象并确定它们相互间的关系(Booch,2007)。
q自然语言:用自然语言打个比方,名词倾向于表示对象,动词表示函数,形容词和名词所有格表示属性(Bourque等,2004)。我在第二章通过地址簿API的例子说明过这个。地址簿和人这两个现实世界中的概念都是名词,用来表示API中的关键对象,而一些动作如添加人到地址簿或给某个人添加电话号码都是动词,用来表示他们要修改的对象的函数调用。然而,一个人的名字是所有格名词,更像是一个Person对象的属性,而不是它本身的高级对象。
q属性:这个技术是把拥有相似的属性或性质的对象进行分组。这可以用不相关联的分类来实现,让每个对象要么是属于某个成员,要么不是,如红色对象对比蓝色对象,或者对象采用可能性分组,这取决于每个对象匹配某个模糊的标准或概念的紧密程度,如一部电影是动作片或是爱情片。
q行为:这个方法是通过对象所共享的动态行为来给对象进行分组的。这涉及到确定系统中行为的设置和把这些行为分配到系统中的不同部分。然后,你可以通过识别行为的发起方和这些行为的参与者来继承对象的设置。
q原型:在这种方法中,你会尝试发现对象的更通用的最初经鉴定的原型。例如,豆袋椅、酒吧凳和躺椅都属于椅子的类型,尽管它们有非常不同的形式和外观。但是,你可以基于椅子原型的匹配度,给它们中的每一个进行分类。
q域(Shlaer–Mellor):Shlaer–Mellor方法先把系统进行水平分区,创建类属的“域”(domains),接着通过单独解析域来进行垂直分区(Shlaer and Mellor, 1988)。这种分而治之方法的一个好处就是域倾向于形成可重用的概念,可适用于其他设计方面的问题。例如,使用我们前面的ATM例子,一个域可以是下列之一:
n有形的域:如ATM机或银行户头。
n角色域:如ATM用户或银行机构。
n事件域:如金融交易。
n安全域:如鉴定和加密。
n交互域:如输入PIN或取现。
n日志域:如系统记录日志信息。
q域(Neighbors):James Neighbors创造的这个术语域是揭露问题域中的所有程序所共享的类和对象。这是通过分析问题域相关系统的共性和独特之处,如识别所有的错误跟踪系统的通用元素或所有家族系列程序的一般特征。
q域(Evans):Neighbors相关的一个问题就是域分析属于域驱动(domain-driven)的设计。这是由Eric Evans介绍的并寻求使用核心业务概念的改进模型来为复杂的系统进行设计。
提示
识别API中的关键对象是困难的。试着从不同的角度来观察问题并通过多次迭代来完善你的模型。
当你有一个经过良好组织和结构化的用例时,这些技术的绝大部分都可以都可以应用得非常好。例如,用例通常都是构造成某物针对其它物件执行某个操作的句子。因此,你可以利用这些做为一种简单的自然语言的输入,通过对每个用例采取一些步骤和识别主题或对象名词,并通过这些创建一个初始的对象候选列表。0
每一个前面提到过的技术都涉及正式方法的不同程度。例如,用自然语言分析它并不是一种很严谨的技术,正式的设计方法学常常都不鼓励这种做法。这是因为自然语言常常容易引起二义性,且可能无法正确地表示问题域的重要概念或者会忽视重要的架构特征。因此,你要十分小心地把用例中的所有名词翻译成关键对象。你应该把这个分析结果作为一个初始候选列表,这样就可以在将来再做细致地分析和完善(Alexander,
2003)。这种完善包括识别模型中的任何空白,考虑是否可以从列表中抽取更通用的概念,并尝试对相似的概念进行分类。
相比之下,有几种正式的技术用来制作软件设计,包括文本的和图形标记。有个特别普遍的技术就是通用建模语言(Universal Modeling Language,UML)(Booch等,2005)。使用一组图形表格,UML可以用来可视化地指定和维护一个软件设计。例如,UML 2.3包含14种不同类型的图表来表示设计的各种结构和行为(见图4.6)。作为一个特殊的例子,UML序列图描绘的是对象之间的序列函数的调用。这些可用于分析阶段的用例图形化表示。接着,在设计阶段,架构师可以利用这些正式的图表来考察系统中对象的相互作用,并充实*的对象模型。
正式的设计标记也可以用来生成实际代码。这个变化是从类图的简单翻译到直接的源代码,等同于一个“可执行架构”(executable architecture),更广泛的标记。后者的架构是一个拥有充分细节描述的架构,可以翻译成可执行软件并在目标平台上运行。例如,Shlaer–Mellor标记最后演变成UML的图表表示,叫做可执行UML(Mellor and Balcer,
2002),它本身变成模型驱动架构(Model Driven Architecture)的奠基石。这种方法的基本原理模型编译器抽取几个可执行UML模型,其中的任一个定义了不同的横切关注点(crosscutting concern)或域,并通过组合这些来生成高级的可执行代码。支持可执行架构的都会注意到带来的双语言问题:有一个模型化语言(如UML),用来翻译成各自的编程语言(如C++、C#或Java)。因此,许多支持这些的都会假定需要有一个语言可以把这些关注点都连接起来。
图4.6
UML 2.3的14种图表类型
4.5.5 架构模式
第二章涵盖的各种设计模式都可以用来解决软件设计中反复出现的问题,如单态、工厂方法和观察者。这些解决方案倾向适用于组件级别的实现。然而,有叫做架构模式的这一级别的软件模式描述的是针对整个系统的更大尺寸的结构和组织。因此,这些解决方案的一部分可以用来帮助你构建API,能够很好的映射到某个特定的架构模式。下面的列表给出了很多流行的架构模式的分类(Bourque等,2004):
q结构化模式:层(Layers)、管道和过滤器(Pipes and Filters)和黑板(Blackboard)
q交互式系统:模型一视图一控制器(MVC)、模型一视图一表现层和外观层一抽象层一控制层
q分布式系统:客户端/服务器端、三层(Three Tier)、点对点(Peer to Peer)和经纪人(Broker)
q可适应系统:微内核(Micro-kernel)和反射(Reflection)
这些架构模式都属于优雅的设计,可以避免出现系统的不同部分的依赖问题,例如在第二章详细讨论过的MVC模式。此时,从系统架构的角度来看,有个值得注意的地方就是库文件的物理视图和它们的依赖性。我在图1.3中给出过一个这样的例子,我给出一个构成复杂最终用户程序的API层。即使在单个API中,你也会喜欢拥有不同的物理架构层,说明请见图4.7,如下说是:
(1).无关API(API-neutral)的底层例程,如字符串操作例程、数学函数或你的线程模型。
(2).实现API主要功能的核心业务逻辑。
(3).插件或脚本API允许用户扩展API的基本功能。
(4).便捷API是构建于核心API的功能之上的。
(5).表现层提供了API结果的可视化显示。
图4.7
API架构层例子:显示两个组件之间的循环依赖。
在这种情况下,给系统中的不同架构层施加严格的依赖层级是十分重要的,否则你会遇到层之间的循环依赖(请见图4.7)。在那些层中的独立组件中也存在同样的情况。通常来说,架构中的底层组件不应该依赖于更高层的组件之上。例如,你的核心业务逻辑不能依赖于便捷API之上,否则会在两者之间引入一个循环(可以想象便捷API也会调用核心业务逻辑)。再回顾一下MVC架构模式,你会注意到视图依赖于控制器,但是反之是不会的。David L. Parnas把这个概念称为免于循环的层级(loop-free hierarchies)(Parnas, 1979)。
MENVSHARED(译者注:发音 men-vee-shared作者项目中自创的词汇)
我在皮克斯的早年时期,我们遇到一个状况:在核心动画库套件间,存在这大量的循环依赖。这些依赖严重地拖慢了我们的系统,无法满足我们产品的截止时间。
为了允许系统得以继续编译,我们把所有的依赖代码都连接到单个大型共享库中,叫做libmenvshared.so(发音是men-vee-shared)。这意味着这个共享库中的任何其中之一发生修改都需要整个共享库进行重新编译,这个过程要消耗5-10分钟。
你可以想象到,这个成为了影响团队开发速度的瓶颈。事实上,当我们把代码移植到另一个平台时,menvshared库的大尺寸也导致了连接器(linker)的崩溃。
谢天谢地,几个勇敢的工程师最终追踪到了这个问题。经过几周的努力,我们逐渐剥离了这些依赖,从而避免了menvshared这个问题。
组件依赖有很多不好的地方。例如,你无法对每个组件进行独立地测试,而且在重用某个组件时必须同时带上其它的组件。基本上,为了理解其中任何一个组件,必须同时理解这两个组件。(Lakos,1996)。如果你得把几个组件组合成一个大型组件,这就会影响你的开发速度,在随附的边栏上有描述。第一章和第二章都有给出各种解除依赖的技术,例如回调、观察者和通知系统。从根本上来说,API应该是一个非循环的逻辑相关的组件层次结构。
提示
要避免API中组件间的循环依赖。
4.5.6 架构通信
一旦一个架构开发完毕,就可以用多种方式进行文档化。可以是简单的图画或维基网页构成的各种正式的方法,提供架构的模型标记,如UML或架构描述语言(Architecture Description Languages)设定(Medvidovic和Taylor,2000)。
无论你采用哪种方法,给架构编写文档都是十分重要的,可以让工程师了解设计。这么做可以让他们获取到更多的信息,根据你的期望来实现系统。这样还可以在未来有修改时保留架构的目标和完整性。对一个大型的或分布在不同地理位置上的开发团队来说,这是特别重要的。
在架构文档中你应该包含总设计的基本原理。也就是说,曾经考虑过的其它设计和最终的取舍,为什么最后选定的结构被认为是最好的。这种设计基本原理对于设计的长期维护是十分重要的,也可以帮助那些在未来也遇到相同的问题的设计师。事实上,Martin Robillard注意到API的用户在没有理解高层架构和设计目标时,他们会觉得很难学会如何使用这个API。
提示
在附随的用户文档中应该要为API描述高层架构和设计的基本原理。
通过沟通可以对设计进行审查,在API发布之前,还会收到反馈,并做出相应改进。事实上,在早期的设计过程中,通过设计审查,可以促进架构师、开发人员和用户之间的沟通,这样可以帮助你完成更全面和持久的设计。如果API的架构师也编写了一些代码,那么通过亲自动手将更好地阐明设计原理
尽管现代的敏捷开发方法重开发过程而淡化文档,因为设计文档常常会过时,但是提供系统架构文档还是有必要的。通过直接的沟通来充实文档是更加有成效的。这允许设计人员和制定人直接对话,可以避免当阅读文档规范时发生的误会。最后,架构师还要成为一个热情的解说者,能够回答工程师提出的问题,并确保关于架构的沟通渠道的畅通。(Faber,
2010)
讲个故事
当在皮克斯研发部工作的时候,我们软件工程师和经理的任务就是给创意策划开发强大且易于使用的程序。因此,我们得把我们的软件计划和设计与制片人进行沟通,通过他们熟悉的比喻和行话。
例如,我们的设计团队被称为“故事”部门,这在电影工作室中是指负责电影的初始计划和结构的。我们的软件时间表和系统设计都写在标准的书写板上,方便产品用户查看。这个书写板是一个木制的4X6英寸大小的,划有索引的表格。这些板采用“故事格”(story pitch)的格式显示给策划们看,填写的是制片术语,这样我们就可以通过让用户熟悉的方式,使用会议中的样式和结构把内容呈现给我们的用户。最后,我们还制作了几个“故事卷轴”(story
reels),是一些数字电影,由所提议的程序的手绘例子构成的动画版,通过旁白把工作流程展示给最终用户。
这种特殊的方式在我们的案例中可以很好的让我们的软件计划和没有专业技术的普通用户进行沟通。当然,这种格式不可能适用于其它所有的软件项目。不过,这里要告诉的核心原则是你要认真考虑如何采用某种自然和容易理解的方式和你的用户进行设计方面的沟通。
4.6 类设计
当设计好高级架构后,你就可以开始通过特定的C++类以及它们和其它类的关系来完善设计。对比顶层的架构设计来说,这是详细设计。它包括识别用户将会使用的实际类(actual classes),这些类之间的相互关系,及其它们的主要功能和属性。对于非常大的系统,这还可以包括描述这些类是如何组织成子系统的。
在API中过于注重对每个类的单独设计,容易使系统变得过于琐碎。你应该专注于定义了绝大部分重要功能的主要的类上。有个非常棒的规则叫做“80/20规则”,也就是说,你应该关注20%的类,它们定义了系统中80%的行为(McConnell, 2004)。
提示
关注20%的那些定义了80%的API功能的类的设计。
4.6.1 面向对象概念
在我介绍面向对象设计的详情之前,让我们先花点时间回顾一下面向对象的主要原则和它们在C++中的实现。你可能已经很熟悉这些概念了,不过我们还是简短地总结一下来确保我们的看法是一致的。
q类:类是一个对象的抽象描述或定义。它定义了对象上的数据成员和成员函数。
q对象:对象是一个带有状态、行为和唯一标识的实体(Booch等,2007)。它在C++中是使用new操作符在运行时通过一个具体类实例化后创建的一个实例。一个具体类是一个能够实例化的类,例如,它没有未定义的纯虚成员函数。
q封装:这个概念用来描述数据和方法被划分到一个单独的对象中,并遵循访问控制规范,如public、protected和private支持隐藏实现细节。
q继承:这允许对象能够继承父类的属性和行为,还可以引入它们自己的额外数据成员和方法。以这种方式定义的类叫做父类(也叫基类)的子类。子类可以重载基类的任何方法,只要把基类的方法声明成虚的就可以做到这一点。纯虚方法(通过在声明前添加“= 0”来指示)的子类必须添加一个实现,来让方法具体化(这样才允许创建它时能够实例化)。C++允许多重继承,意思就是一个子类可以继承自多个基类。两个对象之间通过public继承通常也叫做“is-a”关系。反之,private继承表示“was-a”关系(Lakos, 1996)。
q组合:关于继承还有一种技术是把一个或多个简单的对象组合起来,创建成一个更复杂的对象。做法很简单:只要在复杂对象中,把另一个对象声明为成员变量即可。这种关系是用“has-a”关系来描述的:一个类包含另一种类型的实例。“holds-a”关系描述的是类包含其它类型的指针或引用。
q多态:让一种类型看起来和用起来都像另一种类型。这使得不同类型的对象可以被交替使用,只要它们符合相同的接口。这是允许的,因为C++编译器可以推迟对象的类型检查,直到运行时,这个技术叫做延迟或动态绑定。在C++中使用模板也可以用来提供静态的(编译时)多态。
4.6.2 类设计选项
当要设计一个类时,需要考虑到很多因素。正如Scott Meyers说过的,创建一个新的类会涉及到定义一个新的类型。因此,你可以把类的设计看成是类型的设计,所以那些C++语言中的内建类型的设计方法和思路都可以应用在类的设计上面。(Meyers, 2005)。
当你着手设计一个新的类时,你要考虑好下面列表中的主要问题。还有要注意的是这并不是一个详尽的列表清单,不过它确实提供了一个好的起点,帮助你定义设计中的主要限制。
q使用继承:是否合适把某个类添加到现有的继承层次中去?该使用public还是private继承?应该支持多重继承吗?这将会影响到哪些成员函数应该是虚的。
q使用组合:是把相关的对象组合成成员函数,还是直接通过继承来解决?
q使用抽象接口:类要设计成抽象基类吗(子类要重写各种纯虚成员函数)?
q使用标准的设计模式:你可以使用已知的设计模式来应用到类的设计上吗?这么做可以通过周详的考虑和优雅的设计方法学来让你的设计易于被其他工程师所使用。
q初始化和销毁模型:用户是使用new和delete或你是使用工厂方法吗?你会为类重写new和delete来自定义内存分配行为吗?你会使用智能指针吗?
q定义一个拷贝构造函数和赋值操作符:如果类是分配动态内存,那么两者就都是需要的(当然,析构函数也是需要的)。这会对对象的赋值和传值产生影响。
q使用模板:你的类中是否定义了家族类型(family of types),而不是单一的类型。如果有,那么你可以考虑使用模板来进行泛型设计。
q使用const和explicit:在任何可行的地方,把参数、返回值和方法定义成const。使用explicit关键字来避免单参数构造函数中的意外类型转换。
q定义运算符:为需要的类定义运算符,如+、*=、[]、==或<<。
q使用友元:友元会破坏类的封装性,通常是一种糟糕设计的标志。不到万不得已,请勿使用。
q非功能约束:诸如性能和内存使用这方面的问题都会对类的设计造成约束。
4.6.3 使用继承
到目前为止,你将面临的最大设计决策是:你的类在何时和该如何使用继承。例如,你是应该使用public继承、private继承还是把相关联的类组合到API中?因为继承是很重要的话题,还有就是经常被滥用,我会在接下来的几个类设计的部分中关注这个。让我们先从几个一般的设计建议开始吧。
q继承设计或禁止继承:当你设计类时要做一个重要的决定,那就是类是否应该支持子类。如果是应该的话,你要认真考虑哪些方法应该声明成虚的并记录它们的行为。如果一个类不应该支持继承,那最好的方法就是声明一个非虚析构函数。
q只在恰当的地方使用继承:决定一个类是否继承于另一个类不是一件容易的任务。事实上,这或许是软件设计中最困难的部分。当我在下个部分讲述里氏替换原则(Liskov Substitution Principle,LSP)的时候,我会给出一些关于这个主题的指导。
q避免深层继承树(deep inheritance trees.):深层继承层级会增加设计的复杂度和难以预料的结果,这样会导致理解困难和使软件更容易出问题。层级深度的绝对限制是显然太主观了,但是任何超过两层或三层都已经会变得太复杂(McConnell,
2004)。
q使用纯虚成员函数来强制子类提供一个实现:虚成员函数可以用来定义包含一个可选实现的接口,而纯虚函数只是用来定义接口,并未包含实现(虽然实际上它可以为纯虚方法提供一个后备实现)。当然,非虚方法是用来提供一个无法被子类修改的方法。
q不要往现有的接口中添加新的纯虚函数:你应该使用纯虚成员函数设计合适的抽象接口。然而,要注意的是:在你把这个接口发布给用户后,如果你往这个接口中添加新的纯虚函数将会破坏用户的代码。这是因为用户的类是从抽象接口继承过来的,其实现是直到新的纯虚函数被定义后才具体化的。
q不要过度设计(overdesign):在第二章中,我讲过一个良好的API应该是符合最小完成度的。换句话说,你不要添加那些没有必要的额外抽象层。例如,如果在整个API中有个基类,只从一个单独的类中继承,这就标志着你对系统的当前需求的解决方案过度设计了。
提示
避免深层继承层级。
另一个要考虑的重要问题是:是否要利用多重继承,也就是说,设计的类继承自多个基类。Bjarne Stroustrup利用TemporarySecretary类(它是同时从Secretary和Temporary类继承过来的)的例子来反对C++中使用多重继承(Alexandrescu, 2001)。不过,在C++社区中讨论多重继承的意见分歧是件好事。在一方面,它提供了定义组合关系的灵活性,如TemporarySecretary例子。在另一方面,这也会带来不好捉摸的语义和模棱两可的开销,例如需要使用虚继承来处理“钻石问题”(diamond problem)(就是一个类含糊不清地继承自两个或多个基类,而这些基类又继承自一个共同的基类)。
绝大多数语言允许只从一个基类继承来支持多种、更多约束的类型。例如,Java允许继承自多个接口的类,Ruby允许继承自多个混合体(mixins)。这些类是让你可以继承自一个接口(在混合体中实现);不过,它们本身无法实例化。
如果使用恰当的话,多重继承可以是一个很强大的工具(STL的IO流类就是一个很好的例子)。然而,为了创建健壮和易于使用的接口,我赞同Steve McConnell的观点:你应该避免使用多重继承,除非使用一个抽象接口或者混合体类(McConnell, 2004)。
提示
避免使用多重继承,除了接口和混合体类。
有个令人感兴趣的一点是:新的C++0x规范包含了很多关于继承的改进。一个特别注意的就是:能够明确地指定是重写还是隐藏来自基类的一个虚方法。这是通过使用[[override]]和[[hiding]]属性实现的。这个新的功能能够很好地帮助你避免出现一些错误,如在派生类中错误地拼写了虚方法的名称。
4.6.4里氏替换原则(LSP)
这个原则是由Barbara Liskov在1987年引入的,提供了一个类是否应该设计成另一个类的子类的指导(Liskov, 1987)。LSP讲述了如果S是T的子类,那么T类型的对象可以完全被S类型的对象所取代而不需要在行为上做任何修改。
乍看之下,这和“is-a”继承关系没什么两样:S是T更特殊的类,类S可以看做是T的子类型。不过,LSP比“is-a”具有更多限制的定义。
让我们看一个经典的例子,一个椭圆(ellipse)形状类型:
[代码 P135 第一段]
接着你决定添加一个圆形(circle)类。从数学上的观点来看,圆形是椭圆的一个特例,两轴被限制为相等的。因此,可以把Circle类声明成Ellipse类的子类。例如:
[代码 P136 第一段]
SetRadius()方法用来设置椭圆的主径(major radii)和次径(minor radii),变成圆的话可以设置成相同的值。
[代码 P136 第二段]
不过,这也会带来很多问题。最明显的就是Circle也会继承和暴露Ellipse的SetMajorRadius()和SetMinorRadius()方法。这会破坏圆的自身一致性(self-consistency),让用户可以修改其中的一个半径却没有修改另一个。你可以通过重写SetMajorRadius() 和 SetMinorRadius()方法来同时设置主径和次径。不过,这还是存在几个问题。首先,你必须返回并把Ellipse::SetMajorRadius()和Ellipse::SetMinorRadius()声明成虚的,接着你才可以在Circle类中重写它们。这本身就是在说明你做错了一些东西。其次,现在你创建了一个非正交的API:修改某个属性会对另一个属性产生副作用。再次,你已经破坏了里氏替换原则,因为你无法在不破坏行为的情况下用Circle类来代替Ellipse类,如下面的代码所示:
[代码 P136 第三段]
这个问题表明了当类从基类继承后就改变了函数的行为。
这样的话,如果你不通过public继承(让模型圆成为椭圆的一个子类),那么你该换何种方式表示呢?有两种主流的方法来修正基于Ellipse类功能的Circle类:private继承和组合。
提示
LSP讲述的是当用派生类来替代基类时,应该尽可能的不会影响到任何行为。
私有继承
私有继承让你可以继承另一个类的功能,却不包括public接口。从本质上说,所有的基类public成员都会成为派生类的private成员。我把这称为“was-a”关系,与之形成对比的是public继承是“is-a”关系。例如,你可以使用private关键字来重新定义采用private继承的Circle类,如下所示:
[代码 P137 第一段]
在本例中,Circle并未暴露任何的Ellipse的成员函数,也就是说,没有public的Circle::SetMajorRadius()方法。采用这种方法就不会引起先前讨论的public继承带来的问题。事实上,Circle类型对象不能传递给可以接收Ellipse的代码,因为Ellipse基类型不是公开可访问的。
还要注意的是:如果你要在Circle中暴露Ellipse的public或protected方法,那么你可以按照下面的做法:
[代码 P137 第二段]
组合
私有继承是快速修复违背LSP的接口(如果该接口已经使用了public继承)。不过,更好的解决方法是使用组合。这只是意味着代替S类从T继承,而是采用S中把T声明成一个private数据成员(“has-a”)或者S中声明一个指向T的成员变量指针或引用(“holds-a”)。例如:
[代码 P138 第一段]
接着可以这么定义SetRadius() 和 GetRadius()方法:
[代码 P138 第二段]
在这种情况下,Ellipse的接口并没有在Circle的接口中暴露。不过,Circle仍然通过创建private的Ellipse实例来构建于Ellipse功能之上。因此,组合提供的功能和private继承是一样的。然而,面向设计的专家都喜欢使用组合来代替继承(Sutter and Alexandrescu, 2004)。
提示
比起继承,更偏爱使用组合。
偏向选择组合的主要原因是继承会造成更紧密的耦合设计。当一个类继承自另一个类型(public、protected或private),子类可以访问基类所有的public和protected成员,而组合,类只和其它类的public成员耦合。此外,如果你只包含指向其它对象的指针,那么接口就可以使用类的前置声明,而不需要#include它的所有定义。这样可以很好的在编译时隔离和提高代码的编译速度。最后,当不合适时,你不应该强行制造继承关系。上面的讨论告诉我们:从继承类型的用途来看,圆不应该被当成椭圆看待。最后要注意的是:还有一个很好的例子,就是通用的Shape类型,表示所有的形状,包括Circle和Ellipse都是继承自Shape。不过Circle不应该从Ellipse继承,因为它实际上拥有不同的行为。
4.6.5 开/关原则
Bertrand Meyer引入了开/关原则(Open/Closed Principle,OCP)的概念,用来描述类应该对扩展开放,而对修改关闭(Meyer, 1997)。从本质上说就是类的行为发生修改时不会影响到它的源代码。这是API设计中重要的基本原则,因为这是专注于创建可靠的接口,并可以长期稳定运行。
OCP背后的基本原则就是:一旦类创建完成并发布给用户后,对它的修改只能是修复错误。如果有添加新的属性或修改功能的话,就得创建一个新的类。这通常是通过继承或组合来扩展原先的类并得以实现的。不过,在本书的后面会讲到,你也可以提供一个插件系统来允许API用户扩展它的基础功能。
举个OCP的实际例子,第三章中的简单工厂方法并未关闭修改或开放扩展性。那是因为添加新类型到系统中需要修改工厂方法的实现。提示一下,这里的代码是简单的渲染工厂方法:
[代码 P139 第一段]
相比之下,第三章中可扩展的渲染工厂允许系统在不修改工厂方法的前提下进行扩展。这是通过用户在运行时注册新的类型实现的。因此,第二个实现演示了OCP:要扩展代码功能的话,原始的代码不需要做任何修改。
然而,如果坚持太严格的要求,OCP在实际软件项目中是很难实现的,甚至会和前面提过的良好API设计的原则相矛盾。特别是在大型的复杂系统中,当类发布后,限制其源码的修改是不切实际的。任何行为的修改,会导致约束触发新类的创建,这会削弱和破坏原先的整洁和最小化设计。在这些情况下,OCP更多的是考虑为可供尝试的方法,而不是严格的规定。而且,一个良好的API要有尽可能好的扩展性,本书中的OCP和特殊建议之间存在着紧张的关系,你应该采用谨慎和受约束的方式来把成员函数声明成虚的。
虽然如此,如果我重申OCP是让一个类的接口应该对修改关闭,而不是考虑不可修改的接口的精确实现,那么你就应该和本书关注的焦点适度地保持一致。也就是说,维持一个稳定的接口,让你可以灵活地修改底层的实现,而不会太影响用户的代码。此外,使用大量的回归测试(regression testing)将允许你修改内部代码,而不会对用户依赖的现有行为产生影响。还有,使用一个合适的插件架构(参见第十二章)能够给用户提供通用的扩展点。
提示
API应该关闭接口中不兼容的修改,开放功能上的扩展性。
4.6.6 迪米特法则
迪米特法则(Law of Demeter,LoD)也叫做最少知识原理(Principle of Least Knowledge),是关于松耦合设计的设计准则。这个规则是由Ian Holland在上世纪80年代末提出来的,是基于在美国东北大学开发的Demeter项目经验。它指出:每个组件应该对其它组件尽可能少的了解,即使是密切相关的组件。这可以比喻成只和朋友说话。
当应用到面向对象设计,LoD对函数的要求是:
q在相同的类中调用其它函数
q调用函数时的数据成员要在同一个类中
q调用函数时的以参数形式传入的对象
q调用函数时可以使用它创建的任何局部对象
q调用函数是使用全局对象(你不能拥有这些全局对象)
通过推导可以得出:你不应该调用对象中通过另一个函数调用得到的函数。例如,你应该避免下面的链式函数(chaining function)调用:
[代码 P140 第一段]
要避免这种方式的一种方法就是重构对象A,直接访问对象B中的功能,下面的代码是允许的:
[代码 P140 第二段]
或者,你可以重构调用代码,让对象B直接调用需要的函数。这可以通过把实例或引用存储在MyClass类中的对象B中实现,也可以把对象B传入到需要的函数中,例如:
[代码 P141 第二段]
这种技术的缺点就是会给类引入许多小的包装方法,增加函数的参数数量或是增加对象的尺寸。不过,好处就是不会再有那么多的依赖于其它对象的松耦合类。这样就可以让重构代码或在将来改进代码变得更容易。事实上,后面的那个解决方法是显式传递一个对象到函数中去,和现代软件实践中的依赖注入非常相似(在第三章中有讨论)。还有,另一个程序中,LoD包含在对象A中创建一个单独的方法,聚合调用对象B中的多个方法,和外观设计模式也相似。
提示
迪米特法则(LoD)描述的是:你应该只调用类中的函数或直接相关的对象。
4.6.7 类命名
在最近的这几个部分我着重关注面向对象的设计细节,一旦你已经写好合适的类集合,接下来同样重要的任务就是这些类的表述和一致的命名。下面给出类命名的一些指导准则:
简单的类名称应该是强健的、有描述性和不言自明的。此外,它们应该根据问题域的建模来命名,就是它们要构建的事物的名称,例如,Customer(客户)、Bookmark(书签)或Document(文档)。先前已经讲过,类的名称倾向于使用系统中的名词:设计中的主要对象。
qJoshua Bloch指出:好的命名可以促成好的设计。因此,一个类应该解决好一件事情,一个好的类名称应该能够立即传达它的目的(Bloch, 2008)。如果一个类命名起来有困难,这往往是你的设计有所欠缺。这里有个Kent Beck提供的例子:他原本在图形绘制系统里给对象应用通用的复合名称:DrawingObject,不过后来采用来自印刷领域的更具表现力的术语Figure(Beck,
2007)。
q有时需要采用一个复合名称来更加精准的传达特征,如TextStyle(文本样式)、SelectionManager(选择管理)或 LevelEditor(等级编辑器)。不过,如果你使用超过两个或三个单词就会让设计变得混乱和复杂。
q接口(抽象基类)倾向于用来形容对象模型。因此,它们可以用这样的方式来命名:Renderable(可渲染的)、Clonable(可克隆的)或Observable(可观察到的)。或者,通常给接口类一个大写的“I”作为前缀,例如:IRenderer 和IObserver。
q避免使用隐晦的缩写词。好的类名称应该是明显和一致的。不要强迫用户去记住你创造的缩写词。我将会在稍后的函数命名中再讲述这个注意点。
q你在*符号标志中应该包含某种形式的命名空间,例如类和*函数,这样你所设计的类就不会和用户可能使用的其它API相冲突。这可以通过C++的namespace关键字或使用简短的前缀来实现例如,所有的OpenGL函数调用都是用“gl”开始,所有的Qt类都使用“Q.”开始。
4.7 函数设计
API设计的最小尺度就是如何表示一个单独的函数调用。不过这或许看起来是比较明显的,不值得探讨太多的细节,不过实际上有很多函数级的问题会关系到设计一个良好的API。毕竟,函数调用在API中是最为频繁的:它们是用户如何访问API的行为。
4.7.1 函数设计选项
当设计一个函数调用时,你可以控制很多接口选项(Lakos, 1996)。首先,对于*函数你应该考虑下面的选项:
q静态VS非静态函数。
q参数是通过值、引用还是指针来传递。
q参数传递是const还是non-const。
q对于可选参数采用默认值。
q返回值是值类型、引用还是指针。
q返回值是const还是non-const。
q算子函数(Operator function)还是非算子函数。
q使用异常处理规范。
对于成员函数,你除了应该考虑上面的*函数的选项外,还有额外的如下选项:
q虚拟成员函数VS非虚拟成员函数。
q纯虚函数VS非纯虚成员函数。
qConst成员函数VS non-const成员函数。
qPublic、protected或private成员函数。
q为non-default构造函数使用explicit关键字。
除了控制函数的逻辑接口外的这些选项,还有一些可以应用在函数上组织属性,例如:
q友元函数VS非友元函数。
q内联函数VS非内联函数。
正确使用这些选项对设计高质量的API是相当重要的。例如,当不能修改对象时你就应该把成员函数声明成const(在第六章的C++用法中有更多这方面的细节)。通过const引用传递对象可以降低API导致的内存复制量(详见第七章关于性能方面的问题)。使用explicit关键字可以避免non-default构造函数的难以预期的副作用(详见第六章)。还有,有时使用内联函数可以提高性能(同时也会带来暴露执行细节的开销)并破坏二进制兼容性(详见第七章和第八章)。
4.7.2 函数命名
在系统中的函数命名倾向于使用动词,描述要执行的动作或用来返回值。下面给出一些关于*和成员函数的命名规范:
函数常常用来设置或返回值,使用标准的前缀,如Get和Set。例如,有个返回Web视图的缩放值的函数:GetZoomFactor(),或者使用描述性差点的ZoomFactor()。
函数还用来回答是或否的查询类操作,通过使用合适的前缀来表达这种行为,如:Is,Are或Has,并返回布尔型的结果。例如:IsEnabled()、ArePerpendicular()或HasChildren()。或者,在STL中倾向于不使用开始的动词,例如使用empty()函数来代替IsEmpty()。不过,这样虽然比较简洁,但是这种命名风格是含糊不清的,因为这也可以解释成清空容器的操作(除非你够精明,注意到方法使用了const来修饰)。因此,STL的设计忽略了设计品质中的发现性(不容易误用)。
函数也常常用来执行一些操作,应该采用动词来表示。例如,Enable()、Print()和Save()。如果你是在命名一个*函数,而不是某个类中的方法,那么你应该包含动作所施加的对象的名称,例如:FileOpen()、FormatString()和MakeVector3d()。
使用肯定的概念来命名你的函数,而不是用否定的。例如,使用IsConnected()(是否连接上了)名称来代替IsUnconnected()。这可以让用户在使用双重否定时不至于产生混淆,如:!IsUnconnected()。
函数名称应该描述例程处理的所有事情。例如,如果有个例程用来在图像处理库中执行图片的锐化过滤,并保存到磁盘中,那么这个方法应该叫做:SharpenAndSaveImage(),而不是SharpenImage()。如果这让函数名称太长的话,那么就意味着执行太多的任务了,应该分解开来(McConnell, 2004)。
你应该避免使用缩写。名称应该是自解释和好记的,但是如果使用缩写的话就容易导致混淆或出现费解的术语。例如,如果你是用GetCurrentValue()、GetCurrValue()、GetCurValue()或GetCurVal()的话,那么用户就不得不去记忆。一些软件项目会指定必须遵循的缩写列表,不过对用户来说,如果他们不需要去记这些列表的话,那么用户通常会觉得这会(使用API)更容易些。
函数不应使用下划线字符开头。C++标准提到全局符号使用保留的一个下划线给内部编译器使用。还有就是所有的符号以两个下划线开头,紧跟着首字母是大写的。虽然你可以找到这些下划线命名并符合规则的组合,不过通常在函数命名中避免使用这种方式(一些开发人员用这种协议来标明一个私有成员)。
构成自然对的函数应该使用互补性术语。例如,OpenWindow()应该和CloseWindow()结成对子,而不是使用DismissWindow()。应该采用精确的反义术语来让用户可以清晰的知道两个函数是执行相反的操作(McConnell, 2004)。下面的列表罗列出了一些常用的互补术语:
[图标 P144 第一段]
4.7.2 函数参数
使用好的参数名称也可以增加API的可发现性。例如,比较这两个标准C函数strstr()的签名,用来检索另一个字符串中是否包含某个子字符串:
[代码P144 第一段]
[代码 P144 第二段]
我想你也会觉得第二个签名在使用函数过程中,通过具有描述性的参数签名可以给出更多的指示,这样也更易于使用。
另一个要点是确保你给参数使用了正确的数据类型。例如,当你有个执行线性代数计算的方法,你应该使用双精度浮点型来避免出现单精度操作中丢失精度的错误。相似的,你千万不要再表示货币值的数据类型中采用浮点数,因为会出现潜在的取整错误(Beck, 2002)。
你还要在指定每个函数时寻求参数数量方面的平衡。太多的参数会导致调用时更加难以理解和操作。这也会暗中大大增加耦合度,建议这时候需要重构函数。因此,无论在什么情况下,你都要尽力让函数的参数数量达到最小化。在这方面,我们常常引用认知科学方面的研究,研究结果指出:我们短期记忆能记住的物品数量在7±2上下(Miller, 1956)。这就建议参数的数量不要超过5个到7个之间,否则用户就会觉得记住所有的选项会比较困难。事实上,Joshua Bloch认为参数大等于5个就太多了(Bloch, 2008)。
提示
避免很长的参数列表。
对于有很多可选参数的函数,你可以考虑采用一个struct(结构)或map容器来代替参数的传递。例如:
[代码 P144 第三段]
这个技术也是处理参数列表的好方法,可以改善API的设计。新版的API可以很方便地添加新的字段到结构的末尾,而无需修改OpenWindow()函数的签名。你也可以添加一个版本字段(由构造函数设置)来允许结构上的二进制兼容的修改:OpenWindow()函数接着可以检查版本字段来决定结构中应包含哪些信息。其它可以包含的选项是利用一个字段记录结构的字节大小,或者仅仅使用一个不同的结构。
缩小参数列表
早在20世纪80年代,Commodore Amiga平台就提供了大量的稳定且设计良好的API集,用来构建AmigaOS之下的程序。在起先的例程中在Amiga接收单个参数(这个结构包含指定一个屏幕所需要的所有信息)来打开新的屏幕。
[代码 P145 第二段]
NewScreen结构是这样的:
[代码 P145 第三段]
在AmigaOS的36版本中,往函数中添加了一个新的功能。这是通过引入标签列表概念实现的,本质上就是一个任意长的键值对列表。为了支持新的扩展模式,V36版本函数添加了这些标签列表的规范支持:
[代码 P145 第四段]
不过,为了维持向后兼容性,还是可以向OpenScreen()函数中传入新的ExtNewScreen结构。
[代码 P145 第五段]
这个扩展的结构如下所示:
[代码 P145 第六段]
当传递这个新的结构到OpenScreen()函数中,你就得设置Type字段的NS_EXTENDED位来表明该结构在最后包含一个Extension字段。用这种方式,你可以给AmigaOS传递新旧两种版本的参数,不过旧版本的amiga.lib会安全地忽略新的数据。
要注意到的是:这是一个纯C API,不支持函数重载。因此,OpenScreen()函数的两个版本并没有在同一个API版本中指定。新版本的API会指定ExtNewScreen签名,即使代码中传递的是较旧的NewScreen结构也可以通过C编译器的编译(可能出现警告)。在C++中,这种不匹配的类型会导致编译错误,但是在这种情况下,你只要提供OpenScreen()的两种重载版本。
我们再深入一点,你可以因此所有的public成员变量并只允许通过getter/setter方法来存取值。Qt API把这叫做基于属性的(property-based)API。例如:
[代码 P146 第二段]
这也可以帮助你减少函数需要的参数数量;在这种情况下,start()函数根本不需要任何参数。设置参数值的函数也可以带来下面的好处:
q值可以采用任意的顺序来指定,因为函数调用和顺序是无关的。
q每个值的目的更加明显,因为设置值的函数必须有个名称,例如:setInterval()。
q支持可选的参数,仅仅通过不调用相应的函数就可以实现。
q构造函数可以为所有的设置定义合适的默认值。
q添加新的参数是向后兼容的,因为现有的函数都不需要修改签名。只是添加了一个新的函数。
还有,我们可以给每个setter方法返回它的对象实例的引用((return *this;),这样你可以把这些函数链接起来。这个叫做命名参数用法(Named Parameter Idiom,NPI)。这还可以让用户少些代码。例如,你可以利用NPI重写QTimer的例子,如下所示:
[P146 第三段]
错误处理
程序开发人员编写的大量代码仅仅是用来进行处理错误的。实际中编写的大量错误处理代码非常依赖特定的程序。不过,据估计程序中的90%代码是用来处理异常或关于错误条件的(McConnell, 2004)。因此,API设计中的一个要点是用户会频繁使用它。事实上,这包含在Ken Pugh三大接口定律中(Three Laws of Interfaces Pugh, 2006):
(1).一个接口应该实现它的方法要它实现的。
(2).一个接口的实现应是无害的。
(3).如果一个接口无法完成它的职责,那么它应该通知它的调用者。
相应的,在API错误处理中也有三种主要的方式:
(1).返回错误代码。
(2).抛出异常。
(3).退出程序。
虽然有很多库的例子调用abort() 或 exit(),但是最后一种情况是应该极力避免的,事实上它违背了Pugh三大定律的第三条。在前面两种情况下,不同的工程师会根据技术采用不同的方法。对于异常和错误代码的争论我保持中立,不过我会对这两种情况给出公正的论证和缺点。无论你在API中选择哪种技术,最重要的问题应是你要采用一致的错误报告模式并给出良好的文档(well-documented)。
提示
使用一致的和良好文档的错误处理机制。
函数返回错误代码的方法是通过返回一个数值来表示成功或者失败。通常这个错误代码是作为函数的直接结果返回的。例如,许多Win32函数使用HRESULT数据类型来返回错误。这是一个32位值,表示故障的严重程度,子系统负责错误,是一个实际的错误代码。C标准库也提供了报告设计的非正交错误的例子,如函数read()、waitpid()和ioctl()用来设置errno全局变量的值。OpenGL也提供了一个相似的错误报告机制,通过一个叫做glGetError()的错误检测函数。
错误代码生成用户代码的用法如下所示:
[代码 P147 第一段]
作为一种替代方法,你可以使用C++的异常处理功能来在实现代码中的发出故障通知。这是通过抛出一个对象给用户实现的,并可以在他们的代码中捕捉到。例如,Boost库中支持在和用户发生通信错误时抛出异常,例如:boost::iostreams和boost::program_options库。在API中使用异常并作用于用户代码,如下所示:
[代码 P148 第一段]
错误代码技术提供了一个简单、明确和健壮的方式来给单独的函数调用报告错误。如果你正在开发API,可以从纯C程序进行访问,那么这也是唯一的选择。当你希望返回一个结果的同时又要返回错误代码,这时候就会觉得进退两难。解决这个问题的典型方法就是把错误代码作为函数的返回值并使用一个输出参数来填充返回值,例如:
[代码 P148 第二段]
动态脚本语言,如Python能够简单地通过一个数组返回多个值的更优雅方式来解决这个问题。这也是C++中的一种选择。例如,你可以使用boost::tuple来从函数中返回多个值,请看下面的例子:
[代码 P148 第三段]
相比之下,异常可以让用户从正常的控制流程中隔离他们的错误处理代码,让代码更具有可读性。它们提供了按照几个函数调用的序列来捕捉一个或多个错误的能力,而不需要检查每个单独的返回代码,而且它们让你能够处理调用堆栈中的上级错误,而不是在故障的发生点。一个异常还可以携带更多的信息,而不仅仅是错误代码。例如,STL异常包括故障的可读信息,可以通过what()方法来访问。而且,绝大多数的调式工具都可以提供一种方式在抛出异常时中断,让调试问题变得更加容易。最后,在构造函数中,异常是报告故障的唯一方式。
然而,这种灵活性是需要付出代价的。因为在运行时要解开堆栈,所以异常处理是一种昂贵的操作。而且,一个未捕捉到的异常会导致用户的程序退出,数据会丢失,严重伤害最终用户的感情。编写异常安全代码是不容易的,如果处理不当的话,可能会导致资源泄漏。通常来说,异常的使用是全有或全无的,意思就是:如果程序的任何一个部分有使用异常,那么整个程序都要准备恰当地处理异常。这意味着:在你的API种使用异常,也需要你的用户编写异常安全代码。值得注意的是:Google在他们的C++编码规范中强制使用异常,因为绝大部分他们的现有代码都对异常不宽容。
如果你在代码中选择使用异常来发布意外情况的通知,那么这里给出几个需要遵守的最佳实践:
q自定义的异常继承自std::exception,并定义一个what()方法来描述故障。
q考虑使用RAII技术来维持异常安全,也就是说,当抛出一个异常时,要确保资源被正确地清理。
q确保给所有抛出的异常(在函数注释中)编写了文档。
q你或许会尝试利用异常规范来给函数中抛出的异常编写文档。不过,要知道这些限制会受到编译器在运行时强制执行的,如果有的话,它们会影响到优化,如内联函数的功能。因此,绝大部分C++工程师会避开异常规范,如下所示:
[代码 P149 第一段]
q为遇到的逻辑错误设置创建异常,而不是给每个你引发的单独的物理错误创建唯一的异常。
q如果你在自己的代码中处理异常,那么你应该通过引用来捕捉这个异常,以避免为抛出的对象调用拷贝构造函数。还有,试着避免catch(...)语法,因为一些编译器也会在出现程序错误时抛出异常,如assert()或断错误(segmentation fault)。
q如果有一个异常是继承自多个异常基类,你应该使用虚继承来避免在用户的代码中捕捉异常的位置出现二义性和诡异的错误。
提示
自定义异常应继承自std::exception。
就错误报告的最佳实践而言,一旦发生错误,API应该尽可能的快速清理任何中间状态,如释放在错误发生前分配的资源。不过,你也应该试着避免返回一个不要的异常值,如NULL。这么做会让你的用户编写更多的代码来检查这些情况。例如,如果你有一个函数返回物件列表,在发生异常时,返回一个空的列表,而不是NULL。这就使用户可以编写更少的代码并可以减少用户取消引用NULL指针的机会。
此外,任何错误代码或异常描述都应该表示真实的故障。如果现有的异常不能准确地描述,又或捏造一个新的错误代码或异常,那么你会激怒你的用户,因为他们针对你报告的错误所进行的调式都是在浪费时间。你也应该尽可能给他们充分的信息来追踪错误。例如,如果一个文件无法打开,那么在错误描述中要包含文件名和故障原因,例如,没有权限、文件未找到或者磁盘空间不足。
提示
当有故障发生时,应该快速和整洁地给出准确和全面的诊断细节。