前言
人们为电脑编程超过80年了,但是很奇怪的是很少有人谈论怎么去设计这些程序或者什么样的程序是好程序。有讨论关于软件开发过程,如敏捷开发和开发工具诸如debuggers,版本控制系统,代码覆盖工具。同时还有软件扩展分析比如面向对象变成和功能变成,设计模式和算法。所有这些讨论是有价值的,但是软件设计的核心问题仍没有触及。David Parnas的分类论文“On the Criteria to be userd in Decomposing Systems into Modules” 发表于1971,但是软件设计在这之后的45年的进步仍没有超越这个论文。
计算机科学最基础问题是‘问题分解’:怎么把一个复杂的问题拆解成碎片,以至于可以独立去解决。问题拆解是编程者每天面临的主要问题。然而和这里说的不同的是,我没有在任何大学发现有问题拆解这门核心课程。我们重复地教面向对象编程,但是不教软件设计。
此外,这对编程者的产出量和产出质量有巨大变化,但是我们很少尝试去理解什么使编程者变地更好或者在课堂上教这些技巧。我和一些我认为伟大的程序员朋友交流过,但是大部分人很难说出给他们优势的关键技术。很多人认为软件编程技巧是一个先天的东西,很难教会。然而,有充足的科学证据表明理解表现在很多领域和高质量的练习更有关系而不是先天能力。(比如发表在Geoff Colvin
的‘天才被高估了’)
这些问题困惑我多年。我开始怀疑软件设计是否能后天学习,并且我假设软件设计技巧是区分一般开发者和伟大开发者的因素。我最后决定回答这些问题的唯一办法是尝试去开设移门软件设计的课程。结果就有了 CS190的斯坦福大学课程。在这门课上,我加强了软件设计原则的阐述。这门课有点类似传统英语写作课程。在英语课上,同学用写草稿,得到反馈,然后再写一遍来提升这样一个迭代过程来提高。在 CS190上,同学将从草稿中开发大量的碎片化的软件。然后我们通过广泛的代码审查来识别设计问题,然后同学修改他们的工程。这样同学可以看到软件设计原则是如何改进提升他们的代码质量的。
我现在已经开设这门软件设计课程3次了,这本书是根据这三次的课堂上提出的设计原则写的。原则高度抽象和哲学接壤(定义不存在的错误 Define errors out of existence),所以同学们很难理解也是正常的。通过写代码是同学最好的学习方式,通过犯错误,并用方法去修正错误可以看到他们进步的过程。
在这个阶段你可能会问:是什么使我认为我知道所有软件设计的问题呢?答案是我不知道,我学习编程时没有软件设计的课程,并且从没有一个导师教我软件设计原则。我学编程时,代码审查还不存在。我的软件设计方法来自我个人的写代码和看代码的经验。在我的职业生涯中我用多种语言写了大约250,000行代码。我参与了3个操作系统的设计,多文件和存储系统,基础工具例如debuggers,build systems和GUI工具集,一门脚本语言,写文本,画画,演讲和集成电路的交互式编辑器。经历这些使我有了处理大系统的经验和掌握了大量设计技术。此外,我读了很多别人写的代码,让我接触了各种方法,好的和坏的。
除这些经历外,我尝试去抽离共同特征,关于错误避免和技术使用。这本书是我经验的映射:每个描述的问题是我个人经历,每个建议和技巧是我在我写的代码中获得验证的。
我不希望这本书是软件设计的最终版:我很确定有很多技巧我忽略了,并且一些我的建议从长远看可能会是bad idea。然而,我希望这本书能使软件设计变成热门讨论的话题。把这本书中的经验和你的实际经验结合,去发现它是否能减少软件的复杂度。这本书代表我个人观点,所以有些人会不认同我的一些建议。如果你也是,尝试去理解为什么。我很乐意去听取你对于软件设计的其他观点。我希望这些交流可以使大学对于软件设计有更深的理解。我会持续修正这本书的内容。
与我交流关于这本书的内容可以用email:software-design-book@googlegroups.com
我很乐意听到关于这本书特别的反馈,比如bugs或者改善的建议,关于软件设计的一些想法和经验。最好的例子阐述了一个重要的设计原则并且用一两句就能解释清楚。你如果想看看其他人的建议,可以加入Google Group software-design-book。
如果因为一些原因Google Group software-design-book Google Group在未来停止了,搜索我的个人主页;你可以看到联系交流关于这本书的方式。请不要发送本书相关的内容到我个人email。
我建议你接收这本书的建议。整体目标是减少复杂性:这比其他原则和思想更重要。如果你尝试了一个方法但是没有减少复杂度,你就没必要保留他(但是,让我知道你的经历,我希望知道什么工作让你发生这种情况)。
很多人提供了批评或建议改善这本书的质量。以下人士对本书提供了帮助:。。。
第1章
引言
(都是关于复杂度)
软件开发是人类史上最纯洁的创造活动。编程者不会被诸如物理定律这些规则约束:我们可以创造令人激动的虚拟世界,是这个世界上不存在的世界。编程者不需要物理或协调技巧,比如棒球或者篮球。所有的编程者需要一个创造力的思想和组织你想法的能力。如果你能想象一个系统,你很可能用软件编程实现。
这意味着限制我们编写软件的能力在于理解我们创建的系统。随着一个软件需要越来越多的特征,它会变得复杂,有很多的依赖。同时,复杂度积累,使编程者在修改时越来越难关注到所有细节。这使得开发速度变慢并且导致bug,增加了开发成本。任何程序都避免不了复杂度的增加。程序越大,开发者越多,越难管理复杂度。
好的开发工具可以帮助我们处理复杂度,很多伟大的工具在过去的几十年被创造出来。但是仅仅使用工具有局限性。如果我们想让写程序更简单,以至于我们可以更廉价地创建更强大的系统,我们必须找到方法使写软件更简单。简单设计可以使我们在复杂度出现前构建强大的系统。
有两个通用方法解决复杂度,在本书后面会讨论。第一个方法是使代码更简单和明显。例如,复杂度可以通过使用特例或者稳定版本去减少。
另一个关于复杂度的方法是压缩它,这样编程者可以在一个不会一次性暴露所有复杂度系统工作。这个方法被称为模块化设计。在模块化设计中,一个软件系统被拆分为模块,比如在面向对象语言中的类。模块被设计成相互独立,这样编程者可以在不需要清楚了解模块内细节的情况下使用模块。
因为软件太有扩展性,软件设计是一个持续改进过程,这使得软件设计和物理系统设计不同,比如造房子,船,或者桥。然而,软件设计并不是一直这样。在大多数编程历史上,设计往往集中在项目的早起。这被称为瀑布模型,一个工程被拆分为离散的阶段,比如需求确定,设计,编码,测试和维护。在瀑布模型中,每个阶段在下个阶段开始前完成。在很多例子中,不同人负责不同阶段。整个系统设计一次,就在设计阶段。设计在这个阶段结束后被冻结,随之而来的阶段是去实现这个设计。
不幸的是,瀑布模型在软件设计中并没有很有效地实施。软件系统往往比物理系统更复杂。对于一个很大的软件系统,很难预测到所有的具体实现。随之而来的是起先的设计会出现很多问题。这些问题不容易发现直到实现阶段。瀑布模型很难相应这种情况(比如,设计中被调到另一个项目中)。这样,开发者开始对起先的设计打补丁,这使得问题越来越复杂。
第2章
复杂性的本质
这本书是关于怎么设计软件系统去减少复杂性。第一步是去理解敌人。具体来说就是“什么使复杂度”。你如何确定一个系统是不必要地复杂?什么导致了它复杂?这章会把问题提升一个高度,下一章会降低一个高度向你展示如何辨识复杂,用特定的特征。
辨识复杂度是设计的关键技巧。它让你在投入前发现问题,使你做出准确的判断。相比创造一个简单的设计,判断这个设计是否简单相对容易。一旦你发现系统太复杂,就可以指引你把它设计地简单。如果设计太复杂,尝试用不同方法使设计简单。长期以往,通过把复杂变简单,使你设计越来越顺手。
这章还展示一些框架的基础猜想。下一章会基于这章下一些定论。
2.1 复杂度定义
我用实践的方法定义复杂度。“复杂度是关于理解一个软件系统的结构容易与否和修改的难易程度。”复杂度有很多形式。例如,有可能很难理解一句代码的意思,实现一个小的改动很不容易,或者不清楚要修改哪部分来做出调整。如果软件系统很难被理解和修改,那么它很复杂。好理解,就简单。
你也可以认为复杂度是耗时和收益。在一个复杂的系统中,花费了很大时间去实现一个小改动。在简单系统中,大改动也可以很快地实现。
复杂度是开发者为了实现某个特定目标所要花费的精力。和整个大小,系统功能无关。人们往往用复杂去描述一个很大的系统,但在这本书中,如果这个系统很容易操作,那么它不复杂。当然,大部分大的系统很难学会,他们复合复杂的定义,但不是绝对。小的系统也可能复杂。
复杂度是由不同活动的共同部分定义的。如果一个系统只有一小部分复杂,但是那部分不需要修改,对于整体的复杂度影响就不大。用数学的方式来看可以这样描述:
C=求和(西格玛)CpTp
整个系统的复杂度C是由分部的复杂度p加成得到的。
复杂度对读者相比对作者更显而易见。如果你觉得代码简单但是其他人觉得复杂,那么它是复杂的。让其他人解释为什么他们觉得复杂很有必要。可能会有很有趣的事情遇到。你的工作是写不仅你自己觉得简单的代码,同时也让其他人工作更简单。
2.2 复杂度的特征
复杂度的表现通常有三个形式。下面会细说,每一个都会令开发复杂。
变化放大:
第一个是一个简单的改动需要改动很多不同地方的代码。例如,一个网页有很多页,每页都有一个banner展示背景色。在早期的很多网页,每个颜色都在每个页面单独声明。
Figure 2.1a
如果要修改,开发者需要改每一页。幸运的是,现在网页开发都会把这些属性声明一次。
Figure 2.1b
共享这个属性。一个好的设计有减少代码量的目标,集中处理使修改难度降低。
认知负荷:
第二个是认知负荷,意思是开发者需要额外学习多少去实现一个任务。高负荷意味着开发需要花大量时间学习,那么就有产生bug的高风险,因为他可能漏学一些重要的部分。例如,假如一个函数C分配了内存,返回一个指针指向那片内存,假设调用者会释放那片内存。这增加了认知负荷。如果开发者没有释放那片内存,就产生了内存泄漏。如果系统被设计成不需要关系释放内存,就减少了负荷。认知负荷在很多地方,例如API的方法,全局变量,模块间的依赖。
系统设计者有时假设复杂度可以被代码行数衡量。他们认为一个实现比另一个短,那么它就简单。很少的代码做出很大改变,那么变化就是容易的。然而,这些观点忽略了认知负荷的成本。我用过一些框架,调用代码非常简单,但是很难理解这些代码做了什么。
有时一个方法有更多行数的代码实际上更简单,因为它减少了认知负荷。
不知道不知道的情况:
第三个特征是需要哪些代码去实现一个任务,或者哪些必要信息必须携带去实现任务不明显。
Figure 2.1c
网站使用一个中间变量去实现横幅背景色,所以变得容易去更换。然而,一些网页用暗色阴影作为背景色来强调,在特定页有更深的颜色。如果背景色改了,强调的颜色也需要更改。不幸的是,开发者不喜欢实现这个,所以他们更新横幅背景而不更新强调色。即使开发者意识到这个问题,哪个页面用了强调色并不明显,所以开发者可能需要搜索每一页。
在复杂度的这三个表现中,最后一个是最严重的。有一个不知道的问题意味着有一些你需要去知道,但是你找不到它是什么,或者它是否有问题。直到出现bug前你都不知道怎么去改。修改应用很恼人,一个高认知负荷会增加修改成本,如果哪部分信息要阅读很明显,修改似乎是正确的。唯一了解全部的方法是读每一行代码,对于大系统来说几乎不可能。并且不高效,因为修改后的依赖问题并不会被声明。
一个重要的目标对于一个好的设计来说就是明显。这和高认知负荷和不知道不知道情况相反。在一个明显的系统中,开发者能够快速理解存在的代码是如何工作的,需要如何修改。一个明显的系统是开发者能够快速猜测需要做什么,不需要思考很久,并且对猜测很肯定。第18章讨论使代码更明显的技巧。
2.3 复杂度的认知
现在你明白了复杂度的高层特征和为什么复杂度使软件开发更复杂,下一步是理解什么导致复杂度的产生,这样就可以设计系统去避免问题。复杂度由两个事情产生:依赖和隐晦。
这部分讨论这些因素在一个高的层次,下一章在低的层次讨论。
出于这本书的目的,依赖存在于不能孤立和不能被理解的代码中,代码和其他代码有某种关联,如果代码修改了,依赖的代码也需要作出调整。例如Figure2.1a,背景色依赖存在于每一页。所有页需要相同背景色,所以一个页面的背景色改了,其他页也一定要改变。另一个例子是依赖存在于网络协议。具体来说有两部分独立代码控制发送和接收,但是它们都要遵守同一个协议,更改发送端的代码往往需要改动接受者的代码,反之亦然。方法的签名导致方法实现和代码之间的依赖。如果在一个方法增加参数,所有与这个方法有关的部分都需要调整来适应那个参数。
依赖是软件基础部分然而并不能完全消除。实际上,我们引入依赖作为软件开发的一部分。每次你写一个新类都会创建一个围绕这个类API的依赖。然而,软件设计的一个目标是减少依赖的数量,让依赖更简单和明显地表示出来。
考虑网页的例子。在早期网页每页的背景色都独立声明的情况下,每页都相互依赖。新的设计通过声明一个公用API去获得背景色。消除了每页的依赖,但是制造了新的依赖,即接收背景色的依赖。幸运的是,新依赖更明显。开发者可以通过搜索名称找到。更多的是,编译器帮助去管理API依赖。如果共享变量变了,引用老的名称的地方编译器会报错。新的网页设计将复杂依赖变成了简单明显的依赖。
第二个导致复杂性的因素是隐晦,隐晦发生在重要信息不明显。一个简单例子是一个变量名没有足够的描述信息,(如time)。获得它的唯一途径是搜索变量,看哪里用到。晦涩经常和依赖同时出现,依赖不明显。如果两个类中命名一样,没有其他信息,开发者很难分辨两者区别。
一般来说,依赖和晦涩导致了三大复杂度的因素。依赖导致修改更困难,增加了认知负荷。晦涩导致了不知道的不知道增多,同样增加了认知负荷。如果我们找到方法减少依赖和晦涩,我们就减少了软件开发的复杂度。
2.4 复杂度增加
复杂度并不是由单一的巨大错误产生的,它是由很多小错误积累而成的。一个简单的依赖和晦涩,它自己来说,并不足以影响整个系统。但是积少成多,数量足够多时会导致复杂度。最后,任何小的改动都会影响整个系统。
2.5 结论
复杂度来自依赖和晦涩的积累。复杂度增加了,导致修改难度增大,更大的认知负荷,随之而来更多的不确定。使后期增加一个新特性变得需要更多代码。此外,开发者消耗更多时间来保证改动是安全的,最差的是,他们并不能及时找到需要的信息。底线是复杂度导致修改存在的代码有很高的风险。
第3章
只写代码是不够的
(策略 vs 短程编程)
一个好软件设计最重要的元素之一是完成编程任务的思维。很多组织鼓励开发一个有预见性的系统。可以快速响应未来的变化。然而,你想要一个好的设计,你必须花大量时间用一个好的策略。这章会讨论为什么从长期看策略用于设计优于短程开发。
3.1 短程编程
大部分软件开发者用一种策略我称之为短程开发。在短程开发中,你主要关注实现一个功能或者修复一个bug。第一眼看很合理:有什么比写一段有效果的代码更重要呢?然而,短程编程不能提供一个好的系统设计。
短程开发的问题在于它很狭隘。你使用短程开发,为了快速完成一个任务。也许你有一个截止期限,所以计划一个好的未来并不是首选重要的事情。你没有花大量时间在寻找好的设计,你只是想快速解决问题。你想的是写一堆代码去实现功能,越快越好。
这就是导致系统变得复杂的原因。在早期章节的讨论中,复杂性是逐步递增的。不是一个特定的问题使系统变得复杂,而是整体的累加。如果使用短程编程,每一个编程任务都会增加这个复杂度。每个任务单独看似乎是解决问题的最短路径。然而,复杂性增加了,特别是每个人都用短程编程。
———
不久以后,部分复杂性会开始导致问题,你开始后悔早期的写法。但是你认为修改之前的思路比开发当前需求更麻烦。重构长期看有益,但是会影响你当前任务的完成。所以你还是使用补丁的方式去实现,绕过你遇到的问题。这使得系统更复杂,后期需要更多补丁。很快代码变得一团糟,需要花费数月去清理。没有一个项目可接收如此长的耗时,所以你继续使用短程编程策略。
如果你在一个大项目中工作很长时间,我相信你已经遇到过短程编程导致的问题。一旦你开始使用短程编程,就很难改变。
几乎每个软件开发组织有至少一人使用极端的短程编程:一个短程风暴。短程风暴是一个多产的编程者,写的代码比其他人快很多但是使用一些短程技巧。就轮实现任务来说,没人可以快过短程风暴。在一些组织,管理者把短程风暴视作英雄。然而,短程风暴会留下灾难性的破坏。他们很少考虑未来的扩展。并且其他开发者需要整理短程风暴留下的问题,让他们从表面看起来比短程风暴更慢了。
3.2 策略编程
变成一个好的软件设计者第一步是意识到只写代码是不够的。为了尽快实现你当前的需求而引入不必要的复杂性是没有必要的。重要的是系统长期的结构。大部分代码在一个系统中是为了扩展已经存在的代码,所以你重要的工作是促进这些扩展。因此,你不能把’写代码‘变成你的首要目标,尽管你的代码必须有用。你的首要目标必须是做一个好的设计,同时工作。这就是策略编程。
策略编程需要一个投资的观念模式,而不是快速实现当下需求,你必须花时间去设计系统。这些投资短期会减少你的进度,长期来看会加速整体的时间。
一些投资是积极主动的。例如,设计每个新的类是有必要花时间的。而不是实现出现在脑海的第一映像,尝试一组设计然后选择最干净的一个。尝试想象未来的扩展可以帮助你设计。写一个好的说明文档也是一个例子。
不管你考虑如何周到,避免不了遗漏的情况。过一段时间,这些错误会明显。当你发现一个设计问题,不要忽略它或者打不到。花一点时间去修正它。
3.3 花多少去投资?
所以正确的投资数量是多少?过度的投资,例如重新设计整个系统,是没有效的。设计思想是一点点出现的,从你的经验和以往的系统中获取。因此,最好的方法是花少量的投资在一个持续的项目中。我建议花10%-20%的时间在投资。这点程度不会影响你整个计划,但是足够多去设计好的模板。你早期的项目会多这10%-20%的开发时间。但是会使软件未来更好,未来每个项目可以节省10%-20%的时间。
3.4 开始行动和投资
在一些环境有很多因素阻止使用策略编程。早期有强大的外界压力要项目快速实现。在这些阶段,前面说的10%-20%时间是不能接受的。导致很多项目开始就是短程编程,花少量时间在设计,甚至一些问题也不改了。他们的理念是,如果项目成功了,会有足够的钱雇佣更多的人维护修复这些问题。
如果你在的公司是这样的发展方向,你要意识到一旦代码变得和意大利面一样,几乎不可能修复。你需要花费大量的开发时间去维护这个产品。随之而来的问题让后期版本更慢上线。
Facebook 是一个早期使用短程开发的例子。在早期的时候,Facebook的公司价值观是”Move fast and bread things“。 他们鼓励员工快速实现功能。当Facebook逐渐成为成功的公司时,他们发现代码越来越糟糕,变得不稳定和难以修复,他们意识到整个系统都已经不稳定。最后,Facebook更改了公司价值观”Move fast with solid infrastructure“,去鼓励员工做好的设计。
公正地说,Facebook早期的代码其实比平均早期公司的代码要好。短程编程在公司的起步阶段非常普遍。Facebook只是其中之一。
在硅谷的其他公司,也有使用策略设计的公司。谷歌和VMware和Facebook差不多时间开始发展,他们都遵从策略设计,非常强调高质量的代码和好的设计,他们的系统也十分可靠。
3.5 结论
好的设计不是平白无故产生的。一定是你长期投资,所以小的问题不会积累成大问题。幸运的是,好的设计最后会给你想不到的回报。
最有效的方法是所有人都为好的设计做一部分工作。
第4章
模型层次应该足够深
一个管理软件复杂度的技巧是模块设计,它的作用是让开发者只关注一小面从而降低开发难度。
4.1
模块设计
在模块设计中,软件系统被拆分成独立模块。模块可以以很多形式存在,例如类、子系统、服务。理想中,每个模块间相互独立:开发者可以在不知道其他模块的情况下开发一个慕课。实际上系统的复杂度在于最差的模块的复杂程度。
不幸的是,这个理想情况是不可实现的。模块必须通过方法或函数连接。随之而来的是,模块必须相互了解。这使得模块间产生依赖:如果一个模块变了,其他模块必须修改来适配。模块设计的目标是减少模块间的依赖。
为了管理依赖,我们把模块拆成两部分:一个接口和一个实现。接口说明如何使用但不会说明它是如何实现的。开发者需要知道如何使用它。
一个模块可能很复杂,但它的API很简单易懂,使用者不需要关心复杂的实现,只需要花时间知道如何使用它。
在这本书中,模块是任何代码的实现。任何函数,即使不是面向对象的也被称为模块。模块被设计成很多形式。高层次的子类和服务也是模块,他们的接口以多种形式呈现,例如内核调用或者HTTP请求。
最好的模块是那些接口比实现简单的。这些模块有两个优点。第一,简单的接口减少了指向其他系统的复杂度,第二,如果模块变化不会导致接口变化,那么模块改动就没有依赖英雄。如果接口足够简单,模块内部修改导致的影响越小。
4.2 什么是一个接口?
一个模块的接口包含两个信息:正式的和不正式的。
正式的是指明确在API中声明的,包含参数的类型和名称,是公开的变量。
不正式的是指那些高层的行为,例如一个函数会删除以一个参数命名的文件。在调用一个方法前也许需要调用另一个函数。这些隐含信息只能通过注释的行为添加到API中,这比正式的部分要复杂的多。
声明一个明确的接口是为了让开发者明确知道如何使用模块。可以帮助减少“不知道的不知道”的情况。
4.3 摘要
摘要是阐述一个模块的思想。摘要很重要,他是描述如何实现的。
在模块编程中,每个模块需要一个摘要来描述接口。它会简述一个模块的功能,细节是从接口描述的。摘要描述整个模块需要使用者知道的重点,不需要描述细节。
4.4 深模块
好的模块是提供强大功能但是简单的接口。我使用“深度”这个词秒速模块。从图中看,好的模块是很深的因为它把复杂性包含在模块内部,暴露尽可能简单的接口。
模块的深度是花费的时间和收益的比例。收益是指模块提供的功能。花费的时间是接口的复杂度。接口越小,模块越简单。
Unix和它的后代使用的文件I/O的机制,例如Linux,是深模块的例子。只有5个基础系统调用方法提供:
1 | int open; |
这些简单的方法背后是有成百上千行代码完成的。随着时间推移,内部实现进化了很多代,但接口没有变化。
另一个例子是垃圾回收机制,存在于Go或者Java,这些模块甚至没有接口,它默默地工作。
深度模块使用简单,但是隐藏了非常复杂的实现。
4.5 浅模型
从另一个角度说,浅模型是一个糟糕的接口,增加了功能的复杂性。描述比本身功能还要复杂的模型会增加复杂度。
4.6 过度分类
不幸的是,很多学生被灌输了过渡拆分的概念,他们认为一个类超过一定行数代码就要拆分,这使得类越来越多,增加了复杂度。很多错误发生在有“分类是好的,越多累越好”的理念。
4.7 例子,Java和Unix I/O 的对比
FileInputStream 只提供初级的 I/O 功能,既不能读也不能写。BufferedInputStream 添加了缓存,ObjectInputStream 添加了读和写的功能,但是FileInputStream和BufferedInputStream在文件打开后并没有其他作用了。后面的操作只用ObjectInputStream实现。
这是非常恼人的如果buffer被遗漏了,没有缓存I/O读写变得非常缓慢。也许java开发者会说不是所有人想要使用buffering对于文件读写,所以它不应该在基础机制里。他们可能认为使buffering分离,这样使用者可以选择是否使用它。提供选择是好的,但是接口应该设计的越简单越好。几乎所有人需要buffering,所以它应该默认被使用。在这个例子,库可以提供一个方法不使用它,这样大部分开发不需要知道有这个东西。
相反地,Unix系统设计地很简单。随机访问是很少用的,但使用也很简单,通过lseek系统调用,所以很多人都不需要知道这个机制。如果一个接口有很多特性,但是大部分开发只需要知道一部分,这样复杂性只针对使用这部分不常用API的人生效。
4.8 结论
通过分离接口和实现,我们可以隐藏系统复杂的实现。使用者只需从接口了解使用方法。重要的是设计一个深度足够深的类,使接口简单但是仍提供强大的功能。这样就把复杂度隐藏了。
第5章
信息隐藏(泄漏)
这章和后几章讲如何创建深度足够深的模块。
5.1 信息隐藏
实现深模块最重要的技巧是信息隐藏。这个技术是 David Parnas 第一次提出的。基础思想就是模块应该压缩到少量知识,能代表设计者的决策。这个知识点隐藏在模块的实现而不是接口,所以它对于其他模块是隐藏的。
这个隐藏的信息一般是如何实现一些机制。这里有一些可能不被隐藏在模块的信息例子:
怎么在一个 B-tree存储信息,并且高效地访问?
如何在一个文件内辨别物理磁盘的block对每个逻辑block的响应?
怎么实现TCP网络协议?
如何在一个多核的进程计划多线程?
如何解析json文件?
这些隐形信息包含数据结构和算法机制。还包含低层级的细节,例如一页的大小,还有高层的概念,他们更抽象,例如假定大部分文件很小。
信息隐藏从两方面减少复杂度。第一,它简化了模块的接口。接口反应了一个更简单,更抽象的模块功能视角,减少了认知负荷。第二,信息隐藏使得优化系统更简单。如果隐藏了部分信息,那么认为它对外没有依赖,外部的升级不会影响模块内部。例如,如果TCP协议改了,协议的实现需要修改,但是高层代码使用TCP发送和传输数据不需要修改。
在设计一个新模块的时候,你应该仔细考虑什么信息能够被隐藏在模块。如果你隐藏更多信息,你同样可以简化模块接口,使模块更深。
注意:将变量和方法在内部用private修饰并不是这里指的信息隐藏。私有声明确实可以帮助信息隐藏,它使外部不能访问。然而,私有item还是可以通过getter和setter访问。
最好的信息隐藏形式是当信息隐藏后,使它和使用者无关。
5.2 信息泄露
和信息隐藏相反的是信息泄露。信息泄露出现在一个设计决定反应在多个模块中。这使得模块间多了依赖:任何有关那部分设计的改动都会影响依赖的每个模块。然而,信息泄露还存在于即使没有暴露接口的模块中。例如两个模块都依赖一个文件格式,一个模块要读,一个模块要写,如果这个文件格式改了,两边的交互就有问题了。像这样的后门泄露比一般的信息泄露更可怕,因为它还是隐藏的。
信息泄露是一个红色标志,一个避免它的方法是提高对它的敏感程度。一旦你发现有可能出现的地方,问一下自己”如何修改这个类才能让这个知识点只影响一个类?“。另一个方法是找到所有相关的类,将这个方法独立出去。
5.3 暂时分解
一个常见的导致信息泄露的设计风格我称之为暂时分解。在暂时分解中,系统的结构和操作的顺序有关。考虑一个应用用一个特定格式读取一个文件,修改文件的内容,然后重写一遍。通过暂时分解,这个过程被分为三部分,一是读文件,二是修改,三是写。读和写的步骤都会导致信息泄露。解决方法是将读和写归到一个类中去。你很容易掉入暂时分解的陷阱,因为分解操作会在你编码时出现在你脑海。
在设计模块时,关注需要完成每个任务的知识点,而不是每个任务发生的次序。
5.4 例子:HTTP 服务
5.8 在一个类中信息隐藏
信息隐藏不仅在对外的API中,内部实现也可以用到。尝试在一个类中设计私有方法压缩信息。此外,尝试减少每个实例变量调用次数。一些变量也许要被整个类访问,但是有些只需要在很少地方用到。如果你能减少变量使用次数,那你可以减少依赖和复杂度。
5.9 不要做过头
如果是一些配置外部需要使用,不要过度隐藏它。作为软件设计,你的目标是减少模块使用的信息。例如,如果一个模块可以自己完成配置,那么比暴露配置接口要好。但是,重要的是你要分清哪个信息是被外界所需的然后暴露它。
5.10 结论
信息隐藏和深模块关联性很强。如果一个模块隐藏了大量信息,会增加模块提供的功能减少它的接口。这样就时模块更深。
当分解一个系统到模块时,不要被执行的次序影响。多考虑实现功能需要的不同知识点。然后压缩这些知识点。
第6章
通用目的的模块要更深
一个你常遇到的要做的决定是把一个新模型设计成通用模型还是特例模型。一些人认为你应该做成通用,这样可以用到很多地方,而不是只解决当下问题。在这个例子,新机制能够发现未来的扩展来节省时间。就像第三章讲的那样,用少量时间来节省未来的大量时间。
另一方面,我们知道很难预测未来的需求,所以通用设计可能会有一些实际不需要的多余特性。如果太通用,有时很难解决当下问题。随之而来的是,有人指出要优先解决当下问题。如果你用特例,然后在未来需要时改成通用也可以。
6.1 以通用为目的去封装类
在我的经验中,最佳方法是以通用为目的去写每个新模块。这里说的以通用为目的指的是模块功能应该反映你当下需求,但是接口不是。接口应该足够通用可以支持多方调用。接口应该足够简单,当下需求调用时并不需要特殊声明。不要为了通用设计的使实现当下需求变得难以理解。
最重要的是通用写法会让你未来更省力,如果你用这个类做扩展。然而,即使模块只是用于原始需求,以通用目的而写的方法也更好因为它足够简单。
6.2 例子:编辑器存储文本
假设我们要学生做一个GUI文本编辑器。编辑者可以点击,输入来编辑文件。编辑器要支持多端同时访问,要支持多层撤销和修改。
学生的例子:
void backspace(Cursor cursor);
void delete(Cursor cursor);
void deleteSelection(Selection selection);
6.3 一个更通用的API
void insert(Position position, String newText);
void delete(Position start, Position end);
Position changePostion(Position position, int numChars);
text.delete(cursor, text.changePostion(cursor, 1));
撤销可以这样实现:
text.delete(text.changePostion(cursor, -1), cursor);
扩展:
Position findNext(Position start, String string);
6.4 通用会帮助信息隐藏
通用目的的方法提供文本和接口类分离的方法,做了更好地信息隐藏。文本类不需要被使用者知道,比如撤销功能如何实现,这些细节被压缩到一个接口类中。新的功能很容易添加通过之前的接口类。开发者只需知道几个很少的函数去实现新的功能。
6.5 问你自己的问题
识别一个通用目的的类比创建一个要更容易。这里有一些你可以问的问题,可以帮助你找出使用通用目的还是特例来提供接口。
解决我当下的问题哪种接口更简单?
如果你要引进很多额外参数去降低方法数量,实际上并没有简化。
这个方法可以用在多少情况下?
如果可以用在很多地方,用通用的目的去设计。
这个API当下使用简单吗?
如要你需要额外写很多代码去用它,那还不如用特例解决。
6.6 结论
通用目的接口相比特殊目的接口更有优势。他们表现简单,但是深度很深。尝试让你的模块以通用目的去开发可以降低系统的复杂度。
第7章
不同的层,不同的抽象
软件系统是由很多层组成的,高层是由底层的服务为支撑的。一个好的设计系统,每层都可以为上层和下层提供支持。
如果一个系统的层有相似的抽象功能,是容易产生问题的。这章讨论这种情况发生的场景,发生导致的问题,和怎么消除这个问题。
7.1 ‘通过’方法
如果临近的层有相似的抽象功能,问题往往会由 通过方法 出现。‘通过’方法指的是方法接口和内部实现接口非常相似。例如:
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
这个就是‘通过’方法,它只是一个传递函数的作用。
通过方法增加了类的复杂度。实际上也没有为系统增加功能。
7.2 什么时候的接口重复是ok的?
有相同功能的方法并不总是不对的。重要的是提供新的功能,通过方法不好是因为它本身没有功能。
一个ok的例子是dispatcher调度方法。调度方法是指通过参数选择合适的方法给使用者。因为它有新的功能,就是帮助你选择一个合适的方法完成任务。
7.3 修饰
修饰对象是使存在的对象有扩展功能。在第4章中 Java I/O 的例子,bufferedInputStream 类是一个修饰。bufferedInputStream 提供了一个相似的API但是引进了buffering的概念。
修饰的目的是把特例目的的扩展和通用部分分离。然而,修饰类变的狭窄,它额外提供的功能小于带来的复杂度。它往往带来很多 ‘通过’方法。所以创建一个修饰类时,考虑以下问题:
你是否能提供一个直接的新功能,而不是创建一个修饰类?
这个新功能是否特殊到需要一个特别对象,是否可以把它和已有的类合并,而不是创建一个修饰类?
是否可以和已有的修饰类合并而不是创建一个新的?如果合并,反而可以增加模型深度。
最后,问你自己新功能是否真的有必要去包裹已存在的功能?
一些修饰可能会起到效果,但是往往还有别的更好地方案。
7.4 接口和实现
另一个影响不同层,不同深度原则的是类的接口应该和实现不同,即是具体的实现和抽象的接口不同。如果相同,说明这个模块不够深。例如上面编辑器的例子。
7.5 ‘通过’变量
API复制的另一个形式是‘通过’变量,指的是一个变量通过一长串方法传递。通过变量增加了复杂度因为每个中间方法都需要知道这个变量的存在。如果要修改这部分也会增大难度。
提供三个优化方法:
- 使用共享、单例的形式
- 使用全局变量
- 封装到一个上下文对象中
推荐使用3,如果要增加一个变量,增加到上下文对象中。控制器之间的传递代码就不用动。也更容易测试,修改上下文中的参数可以测试各种情况。
上下文是一个理想的解决方案。比全局变量更好。全局变量让你困惑在哪里要使用到。但是上下文对象也会导致线程安全问题,最好的方法是使上下文中的内容不可变,目前我也没找到更好的方法。
7.6 结论
每一部分加入到系统,比如接口、实现、参数、函数、类都会增加系统复杂度。消除复杂度可以通过设计消除部分表现元素。例如类通过压缩隐藏部分功能来隐藏使用者不需要知道的知识点。
不同层,不同抽象的原则是这个思想的应用。如果不同层有相似功能,例如‘通过’方法和修饰,它们可能没有提供足够的效益。还有‘通过’参数增加了中间层的负担。
第8章
降低复杂度
这章会从另一个角度考虑创建深模块。假设你在开发一个新模块,你发现了不可避免的复杂度。哪个更好?你是否应该让用户解决复杂度,还是在模块内部解决?如果复杂度关乎于功能,第二个回答往往是更好。大部分模块的用户比开发者多,所以让少数面临复杂度。尽管这会增加你开发世界。另一个支持的观点是有一个简单的接口比简单的实现要重要。
结论
开发一个模块时,从使用者的角度看问题而不是你自己的角度,去降低复杂度。
第9章
合起来好还是分开好?
一个常见的开发问题是:有两个功能,应该是把它们一起实现还是分开实现?
当决定要结合还是分开,目标是从整体来降低系统复杂度然后改善它。似乎最好的方法是把系统拆分成小组件。组件越小,个体部分也越简单。然而,分离会导致额外的问题:
复杂度会因为模块多而增加。模块多意味着更多接口,每个接口都会增加复杂度。
细分会导致更多代码去管理组件。
细分导致分离,分离使开发者很难从一个地方获取接口。如果组件真的是独立的,分离是对的。如果组件间还有依赖,那么就是坏的。
细分会导致复制,在一个实例的代码可能要复制到细分的组件中。
把相似的、相关的代码组合起来是有益的。这里有他们相关的因素:
他们共享信息,例如,都依赖一个具体类型文件。
他们是一起使用的。
他们概念上重叠,这样一个高层的分类可以覆盖他们。
如果不看另一个模块这个会看起来很费力。
9.1 如果信息共享,把它们合起来
9.2 如果能简化接口,把它们合起来
9.3 把它们合起来消除复制
9.4 分开通用目的和特殊目的的代码
如果代码重复出现,就是抽象没有做好
9.5 例子:插入光标和选择
不要混合使用,即有通用目的,又包含特殊
9.6 例子:拆分log
9.7 例子:编辑撤销机制
9.8 分裂和结合方法
不要出现要看另一个模块才能理解这个模块的情况
9.9 结论
是拆分还是合并代码应该基于复杂度。选择可以信息隐藏的方案,更少的依赖,更深的接口。
第10章
定义不存在的错误
10.1 为什么特例会增加复杂度
10.2 太多特例
10.3 定义不存在的错误
最好的方式消除例外的复杂度是定义你的API,不产生例外。
10.4 例子:windows的文件删除
10.5 例子:Java substring 方法
0.6 mask 特例
10.7 特例聚集
10.8 只是崩溃?
10.9 设计不存在的特例
如果你对不存在的错误定义,你也可以对其他特殊情况定义。
10.1 别做过头了
10.11 结论
任何形式的特例会使代码难以理解,增加bug。这章关注特例,是特例编程最重要的部分,然后讨论怎么减少特例要出现的地方。最好的方法是重新定义语义去消除错误情况。把特例封装成更通用的形式,这个技术可以降低系统复杂度。
第11章
设计两次
第12章
为什么写注释?4个借口不写
12.1 好的代码是自我注释的
一些人认为如果代码写的足够好,很明显不需要注释。这是一个很好的理由,就像冰淇淋有利你的健康一样好听。不幸的是,这是不对的。你可以通过好的变量名来减少注释。但是仍有很多情况下的信息不能代表现在代码。
12.2 我没有时间写注释
如果你认为注释是次要的,总有一些其他事情会优于写注释。
12.3 注释过时导致错误
如果你没有大量修改代码需要增加的注释也不多。代码审核可以帮助完善注释。
12.4 我见过的注释都是多余的
很多人见过没有用的注释,这是可以通过提高写注释能力来改善。
12.5 写好注释的收益
你从好的注释中可以获得。注释背后的关键在于获取设计者不能体现在代码中的思想。
第13章
注释应该描述代码说不清楚的事情
13.1 使用惯例
13.2 不要重复代码
不要用代码出现的词在描述里重复
13.3 低层次的注释可以增加精准度(和抽象相反)
精确很重要尤其在使用实例变量,方法参数和返回值时。命名和类型在一个变量声明时没有那么精确。注释可以补充丢失的细节例如:
- 这个变量的单元是什么?
- 边界有没有包括?
- 如果传入null,是如何工作的?
- 如果指向资源的变量要销毁,谁负责销毁?
- 有没有一定存在的变量,例如这个列表至少包含一个元素?
13.4 高层注释提供直觉
帮助使用者从结构和整体去理解。
13.5 接口注释
不要写对使用没有帮助的实现细节
13.6 写什么和为什么,不是怎么
帮助使用者理解这个代码在干什么。
13.7 跨模块设计决定
13.8 结论
注释的目标是让系统的结构和行为让读者容易理解,这样他们可以快速判断是否可以使用。一些信息可以通过代码呈现,一些重要但是不能通过代码提现的部分需要注释。
遵守注释是描述代码不能提现的部分的原则。在写注释时,把自己放在使用者的角度,看使用者需要什么。如果使用者说看不明白,不要和他争论。尝试去理解是哪部分让读者难以理解,然后写更好地注释。
13.9 13.5的问题的答案
第14章
命名
14.1 例子:差的命名导致bug
14.2 想象
14.3 命名应该准确
模糊的命名应该避免,会让开发者误会。
一个很短的命名很难描述具体的对象,所以出现短命名时是一个提示。
14.4
命名一致
14.5 不同的观点:统一风格
14.6 结论
我们使用可以帮助代码更清晰的命名,当有人遇到时能马上猜出它的行为,不经过考虑就能理解。这会降低你之后编程的难度。不太容易导致bug。起一个好命名也是投资,你会从中受益的。
第15章
先写注释
(使用注释作为设计的一部分)
15.1 延迟的注释是坏注释
15.2 先写注释
15.3 注释是设计的工具
描述一个方法或变量的注释应该完整。如果你发现很难写一个注释,你应该发现设计上是不是有问题了。
15.4 早期的注释是有趣的
15.5 早期注释是否昂贵?
15.6 结论
如果你没有尝试在开始就写注释,试一下。然后考虑它如何影响设计,然后享受软件开发的过程。你尝试过再来和我分享你的经验。
第16章
修改存在的代码
16.1 保持策略的
16.2 保留注释:使注释靠近代码
16.3 注释属于代码的一部分,不是提交日志
16.4 保留注释:避免复制
16.5 保留注释:检查不同
16.6 高层注释更容易保留
第17章
一致性
一致性是降低系统复杂度有利的工具。如果一个系统保持一致,意味着类似的事情用类似的方法去做,不同的事情用不同的方法去做。一致性降低了认知负荷。如果你知道了一处如何做,那么很快会理解其他相似的地方怎么做。
一致性减少了错误。如果两个类似的地方用不同方法,开发者会认为他们已经用通方法导致错误。
17.1 一致性的例子
一致性可以用多种形式体现。
命名:
14章已经详细阐述了。
代码风格:
除了编译器的规则外,现在大多数开发部门都会用一本指导手册限制编码风格的太多样化。手册里列举了一些问题,例如缩进问题,大括号位置,声明的位置,命名,注释,一些危险的代码。指导手册可以使代码更易读,并且减少错误。
接口:
接口的多种实现形式。一旦你理解了一种接口的实现形式,另一个实现变的容易理解,因为你已经了解了它是如何提供的。
设计模式:
设计模式用于解决特定问题,例如mvc用于ui设计。如果你使用存在的模式去解决问题,实现会进步地更快,你的代码更容易阅读。在19.5章讨论模式。
常量:
例如,一个存储结构需要一个标志来表明是一行的结束。常量减少特例的数量,更容易表现代码的行为。
17.2 追求一致性
一致性很难去维持,特别是当许多人长期工作时。一组人不清楚另一组人建立起的习惯。新来的不知道这些规则,所以他们并非故意违反规定,冲突就产生了。这里有一些推广一致性的建议:
文档:
创建一个文档,列出最重要的规则,例如代码风格指南。把文档放在容易查到的地方,例如显而易见的工程里。鼓励新来的加入小组阅读文档,鼓励老人时不时去审核一下。一些习惯的指南很多组织都放在网页上,尝试这样工作。
强制:
即使有了好的文档,很难要求开发者记住所有的规则。最好的方式是有一个工具可以帮助检查,保证不合规范的代码不能提交到仓库。
我最近的一个项目在行数特征方面遇到困难。一些开发者在Unix工作,行数被新的行终结,在windows上,行数被回车终结。如果开发者在一个系统做了小改动,在另一个系统会被认为每行都改了。这使得判断哪行真的改了发生问题。我们建立了一个换行的规则,但是很难保证每个工具都遵循这个规则。每次一个新开发者加入项目,我们总是会遇到换行问题。
最后我们写了一段脚本在提交前自动执行。脚本检查所有文件,保证没有回车符。这很有用,并且对新来的也有帮助。
代码审核提供另一个强制规则的机会,减少新来的规则问题。审核越频繁,代码越干净。
用谚语:When in Rome, do as the Romans do.
最重要的规则是大家遵循这个谚语,“如果你在罗马,照罗马人的习惯做”。在新建一个文件时,看一下已经有的代码是如何写的。所有公共变量和方法是否在私有变量前声明?是否所有方法以字母排序?是否变量都用了“驼峰式”还是“蛇式”?就如“firstServerName”还是“first_server_name”?如果你觉得一些看起来像规则的写法,照着它写。当做一个设计时,这是否符合这个软件已有的规则,如果和已存在的相似,模仿它。
不要更改已经存在的规则:
限制修改已存在的规则。我有一个更好的注意并不是这么做的理由。你的新注意可能更好,但是注重一致性比找到一个好一点的方法要重要。当引进新规则时,问你自己两个问题:1,你是否有重要的信息通过新规则声明,老规则并不能实现?第二,新规则是否适用于所有老规则用到的地方?如果两个回答都是yes,才去升级。完成后,老的规则使用的地方要没有影响,否则很可能后面又改回老规则。总的来说,改之前的规则并不是一个很好的注意。
17.3 别做过头
一致性不仅仅是说相似的事情用相似的方法做,而且要不相似的事情用不同的方法去做。如果你尝试把不同事情用已有的规则做,会增加复杂度。一致性只在确定的情况下才有用,就像“如果这个看起来像一个x,它确实是x”这种情况。
17.4 结论
一致性是投资观念模式的一个例子。它会让你花费额外时间,在谈话中做决定,创造自动检查,发现相似场景用代码模仿,代码审核来指导团队。开发者会更容易理解代码,工作地更快,产生更少的bug。
第18章
隐晦是增加复杂度的两个原因之一,在2.3章提到过。隐晦发生在当系统重要信息对新开发者不明显的情况。解决隐晦问题的方法是写明显的代码。这章讨论一些使代码更明显的因素。
如果代码明显,意味着一些人可以快速阅读代码,不花费太多实际,他们第一映像关于代码做什么的猜测也是正确的。如果代码明显,读者不需要花大量时间在写代码前阅读。代码不明显就需要读者花大时间去理解,这样不仅仅减少效率,增加误会会导致bug。明显的代码只需要少量注释。
“明显”是读者的看法,看别人的代码往往比看你自己的不明显。因此,最好的方法决定代码是否明显取决于其他人。如果其他人读你的代码说不明显,尝试去理解他为什么觉得不明显,这样你才可以做的更好。
18.1 使代码更明显的事情
两个最重要技术已经在前面讨论过。第一是用好的命名。第二是一致性,如果相同的事用相同的形式去做,读者更容易辨识。
这里有一些其他使代码更明显的例子:
明智地使用空格:
使用空格拆分参数可以使读者更容易理解。
注释:
有时很难避免代码不明显。因此,使用注释去补充就很重要。你要猜测读者可能在哪部分疑惑,用注释补充。
18.2 使代码不明显的事情
事件驱动编码。
如果快速读完代码很难理解,要注意了,说明重要的信息没有呈现。
通用的容器。
代码不是读者期望那样的。
18.3 结论
另一个考虑是否明显的形式是信息。如果代码不明显,说明重要的信息没有传递给读者。
为了让代码明显,你必须保证读者有足够的信息去理解它。你可以用三个方面去做。最好的方法是减少读者需要的信息,使用设计技巧例如抽离和消除特例。第二,你可以使用读者已经获得的上下文知识,这样读者可以少学习新的知识。第三,你可以通过代码把重要信息呈现,使用技巧例如好的命名和有策略的注释。
第19章
软件趋势
前面讲了软件设计原则,这章考虑一些软件发展趋势。对于每个趋势,我会描述他们和本书的关系,去评估这些趋势和复杂度的关系。
19.1 面向对象的编程和继承
面向对象编程已经留下30-40年了。它引进了类,继承,私有方法和实例变量。如果好好使用,这些机制可以帮助生产更好的软件。例如,私有方法和变量能够用于信息隐藏:没有代码可以通过接口访问私有变量,所以对外没有依赖。
面向对象的一个关键元素是继承。继承来自两种形式,对于软件复杂度有不同实现。第一个是接口继承,就是表面的接口可以继承但是实现方法补基础。每个子类必须实现父类的接口,但是不同子类可以用不同的实现。例如,接口定义I/O,在一个子类用于实现磁盘操作,另一个子类可能用于网络接口。
接口基础降低复杂度通过相同接口的不同实现。它允许获得解决问题的一个方法用于解决其他问题。另一个角度看,增加了深度,有更多的不同实现,接口更深。为了让接口有很多实现,必须捕获重要特征,并且清楚实现的区别,这个概念取决于抽象程度。
第二个继承的形式是实现继承。在这个形式中,一个表面的类决定不仅仅是签名或更多方法,还是默认的实现。子类能够选择继承父类的实现或者重写。没有实现继承,相同的实现方法可能要复杂到很多子类中,会增加类之间的依赖。因此,实现继承减少了代码。换句话说,它减少了变化放大问题,在第2章提到的那样。
然而,实现继承使父类和子类直接产生依赖。类实例变量在父类经常被子类和父类访问,这会导致信息类之间的泄露,使得不看另一个类很难改这个类。类继承使用实现继承会增加复杂度。
因此,要十分小心地使用实现继承。考虑是否可以使用帮助类去实现共享方法。而不是继承来自父类的继承方法。
如果没有可选变量去实现继承,尝试分离父类。可以使用父类去管理所有方法,子类只用只读防区去使用。
尽管面向对象变成的机制能够帮助实现简化设计,但他们不保证能产生好的设计。例如,如果类很窄,或者有复杂接口,还是会导致复杂性。
19.2 敏捷开发
19.3 单元测试
19.4 测试驱动开发
19.5 设计模式
19.6 getters和setters
19.7 结论
无论你遇到什么样的新模式的提议,通过复杂性去评估它。是否新提议真的可以帮助降低复杂度在一个大系统中。很多提议表面看起来很好,一旦你看的更深你会发现它使问题变得更复杂了。
第20章
为了表现设计
20.1 怎么评估表现
20.2 在修改前先评估
20.3 围绕关键点设计
20.4 一个例子:RAMCloud Buffers
20.5 结论
这章最重要的部分是见简洁的设计和高性能是匹配的。复杂性高的代码往往更慢因为它要做更多工作。另一方面来说,如果你写的简洁,你的系统会更快,你就不用担心性能问题。找到性能的关键点,越简单越好。
第21章
结论
这本书关于一个事情:复杂度。处理复杂度是软件设计最重要的部分。这是使系统难于构建和维护的关键。在这本书我尝试描述复杂性的关键原因,例如依赖和隐晦。我的红旗部分是帮助你避免复杂度的产生,例如信息泄露,不必要的错误,或者太通用的命名。我已经呈现了一些通用思想去构建简单的系统,例如编写更深的代码,定义不存在的错误,分离接口文档和实现文档。最后,我讨论了投资观念模式去实现简单设计。
这些所有的建议都会增加你早起的工作。然而一旦你开始考虑设计问题,会降低你的进度,但是掌握好的设计技巧。如果你只为了实现当下需求,似乎这些都是你的阻碍。
但换一面想,如果好的设计是你的目标,那么这本书的主意会使编码更有趣。设计是幻想的拼图:一个具体的问题如何通过简单的结构解决?寻找不同方法很有趣,并且如果找到简单和强大的解决方法是很棒的。一个简单,明显的设计是美好的东西。
进一步说,你很快会从好的设计的投资中得到回报。你定义的模块会降低之后开发的实际。如果你上手了,好的设计实际上并不会花费你太多时间。
变成一个好的设计者的回报是你花费大量时间在设计阶段,这非常有趣。差的设计者花费大量时间在修改bug。如果你改善你的设计技能,不仅可以开发出高质量软件,软件开发过程也会变得更有趣。
设计原则摘要
这里有最重要的软件设计原则:
- 复杂性是递增的,你必须从小事耕耘。
- 只写代码是不够的。
- 使用持续的小投资来改善你的系统。
- 模块应该足够深。
- 接口应该设计的使大部分通用调用足够简单。
- 模块有简单的接口比有简单的实现重要。
- 通用为目标开发的模块更深。
- 分离通用目标和特殊目标的代码。
- 不同层应该有不同职责。
- 降低复杂度。
- 定义不存在的错误(特殊错误)。
- 设计两遍。
- 注释应该描述代码不能展示的部分。
- 软件应该设计的更容易阅读,不是写。
- 软件开发的增长应该抽象,不是具体。
Red Flags 的摘要
这些特征出现都暗示系统设计有问题。
浅的模块:接口没有比它实现更简单。
信息泄露:一个设计决定会影响多个模块。
临时分解:代码结构基于操作的顺序,不是信息隐藏。
过渡暴露:为了使用通用的特征暴露一个不太用的API。
通过式方法:一个方法只是起到传递另一个方法的作用。
重复:一部分代码反复出现。
特例和通用混合:特例的代码没有从通用中分离。
方法结合:两个方法相互依赖,如果不理解其中一个实现就没法理解另一个。
注释中使用重复代码:注释中出现附近的代码。
实现文档污染接口:一个接口注释中出现具体实现的细节,这些细节对使用者是无用的。
模糊的命名:命名没用传递有效信息。
很难命名:你发现很难给这个整体命名。
很难描述:为了描述完整,你发现注释写的很长。
不明显的代码:一部分代码在干啥很难理解。
关于作者
John Ousterhout 是斯坦福大学教授。他开发了Tcl脚本语言并且在分布式操作系统和存储系统知名。耶鲁大学毕业,在卡梅隆大学获得博士。是国际学术会员,获得过ACM软件系统奖,ACM Grace Murray Hopper Award,国际科学基金,UC伯克利教学贡献奖。