zoukankan      html  css  js  c++  java
  • 读书笔记《A Philosophy of Software Design John Ousterhout 软件设计哲学》

    软件设计哲学这本书很薄,值得一读。这本书将大家平时碰到的很多软件问题从更深刻的层面进行了抽象分析,同时又给出了具体的解决方案。可以说既有理论高度,又能贴近实践。

    但针对软件问题,这本书并没有提出太多与众不同的解决方案,讲的绝大多数方案都是大家比较熟悉的。也就是说技术大家都有,没有做好只是缺少追求卓越的态度。当然造成这种后果是整个环境造成的,并不完全是开发人员的问题。

    这本书唯一跟大家平时理解有点偏差的地方就是本书认为模块应该是“深的”,即简单的接口,复杂的实现。这个思路从理论层面很显然是对的,但实际上很难做到。因为软件要想达到足够的灵活性,很难不出现“浅”的模块,比如框架、各种设计模式几乎都是“浅”的。而且模块复杂后,很难不通过拆分、解耦等方式分割模块,使模块没有原来那么“深”。这样降低了复杂模块的复杂性,但很有可能提升了整个系统的复杂性。

    总之,书中有些内容属于理想情况,实践中千万不能生搬硬套,需要根据具体情况进行权衡。以下是本书的主要内容。
     
     

    2 复杂性的本质

    2.1 复杂性的定义

    系统(C)的总体复杂性由每个部分的复杂性决定,p (cp)的权重是开发人员在该部分(tp)上花费的时间。

    2.2 复杂性的症状

    • 变更放大:看似简单的变更需要在许多不同的地方进行代码修改。
    • 认知负载:开发人员需要知道多少才能完成任务。较高的认知负荷意味着开发人员必须花费更多的时间学习所需的信息,并且由于遗漏了重要的东西,因此出现错误的风险更大。认知负载以多种方式出现,例如具有多种方法的API、全局变量、不一致性和模块之间的依赖性。
    • 未知的未知数:不清楚要完成一个任务,必须修改哪些代码,或者开发人员成功地执行该任务,必须包含哪些信息。未知的未知是最糟糕的。
    在复杂性的三种表现中,未知的未知是最糟糕的。未知的未知意味着你需要知道一些事情,但是你无法知道它是什么,甚至无法知道是否有问题。更改之后,直到出现错误,您才会发现它。变更放大是很烦人的,但是只要明确哪些代码需要修改,变更完成后系统就可以工作了。同样地,高认知负荷会增加改变的成本,但是如果清楚要阅读哪些信息,那么改变很可能是正确的。对于未知的未知数,我们不清楚该做什么,或者提出的解决方案是否可行。唯一确定的方法是读取系统中的每行代码,这对于任何规模的系统都是不可能做到的。即使这样也是不够的,因为变更可能依赖于一个从未被记录在案的微妙的设计决策。好的设计最重要的目标之一是使系统变得显而易见。这与高认知负荷和未知的相反。在一个显而易见的系统中,开发人员可以快速了解现有代码如何工作,以及需要做出什么更改。一个明显的系统是开发人员可以快速地猜测应该做什么,而不用非常费劲地思考,并且确信猜测是正确的。

    2.3 复杂性的产生原因

    复杂性是由两件事引起的:依赖性和模糊性。软件设计的目标之一就是减少依赖项的数量,并使保持的依赖项尽可能简单和明显。模糊性重要信息不明显时,容易产生混淆。不一致性也是造成模糊的一个主要原因。模糊性也是一个设计问题。如果一个系统有一个干净而明显的设计,那么它需要更少的文档。需要大量的文档通常是设计不太合适的标志。减少模糊的最好方法就是简化系统设计。
    依赖性导致变化放大和高认知负荷。模糊产生未知的未知,也有助于认知负荷。

    2.4 复杂性是递增的

    单一依赖性或模糊性本身不太可能对软件系统的可维护性产生重大影响。复杂性的产生是因为随着时间的推移,成百上千的依赖性和模糊性逐渐积累起来。最后,这些小问题太多,以致系统的任何可能变化都受到其中几个问题的影响。复杂性的递增性使其难以控制。很容易让你自己相信,由你当前的变化带来的一点点复杂性没什么大不了的。但是,如果每个开发人员都对每一个更改都采用这种方法,那么复杂性就会迅速积累起来。一旦复杂性积累起来,就很难消除,因为修复一个依赖性或模糊性本身不会产生很大影响。为了减缓复杂性的增长,你必须采用一种零容忍的哲学。

    3 战略与战术编程

    好的软件设计最重要的要素之一是你在处理编程任务时的思维方式。许多组织鼓励一种战术思维方式,关注于让特性尽快发挥作用。然而,如果你想要一个好的设计,你必须采取一个更具战略性的方法,你投入时间去产生干净的设计和修复问题。从长远来看比战术方法更便宜。
    最好的方法是持续地进行大量的小额投资。我建议你把总开发时间的10%-20%花在投资上。
    在开始阶段,战术性的编程方法比战略方法更快地取得进展。然而,在战术方法下,复杂性的积累速度更快,这降低了生产力。随着时间的推移,战略方法取得了更大的进展。
    好的设计不是免费的。它必须是你持续投资的东西,这样小问题就不会积累成大问题。

    4 模块应该是深的

    模块化设计的目标是最小化模块之间的依赖关系。

    为了管理依赖性,我们将每个模块分为两个部分:接口和实现。这个接口包含了开发人员在另一个模块中工作时必须知道的所有东西,以便使用给定的模块。通常,接口描述模块做什么,但不描述模块如何做。实现由实现接口所做承诺的代码组成。在特定模块中工作的开发人员必须理解该模块的接口和实现,以及给定模块调用的任何其他模块的接口。
    最好的模块是那些接口比它们的实现简单得多的模块。这种模块有两个优点。首先,一个简单的接口将一个模块强加给系统其余部分的复杂度降到了最低。其次,如果一个模块以不改变其接口的方式被修改,那么其他模块将不会受到修改的影响。
    模块接口,如果开发人员需要知道某个特定信息以便使用某个模块,那么该信息就是模块接口的一部分。包含两类信息:正式的和非正式的。正式部分在代码中显式地指定,其中的一些可以通过编程语言检查其正确性。非正式部分是一些约定和使用有限制等,只能用注释来描述,编程语言不能保证描述的完整性和准确性。没有明确指定会导致未知的未知问题。
    抽象是实体的简化视图,省略了不重要的细节。在模块化编程中,每个模块都以接口的形式提供抽象。该界面提供了模块功能的简化视图;从模块抽象的角度来看,实现的细节并不重要,因此从界面上省略了它们。
    抽象可以从两个方面出错:1、包含一些并不真正重要的细节,这时抽象变得比需要更复杂,增加了使用抽象的认知负担。2、省略了真正重要的细节,这会导致模糊性,即仅关注抽象,并不具备正确使用抽象所需的所有信息,这是虚假的抽象,可能看起来简单,但实际上并非如此。
    设计抽象的关键是理解什么是重要的,并寻找能够最小化重要信息量的设计。
    最好的模块是深度的:它们允许通过简单的接口访问很多功能。浅模块具有相对复杂的接口,但功能不多:它不隐藏太多的复杂性。
    模块深度是考虑成本与效益的一种方式。模块的好处是它的功能性。模块的成本(就系统复杂性而言)就是它的接口。模块接口表示模块对系统其余部分施加的复杂度:接口越小、越简单,它引入的复杂度就越低。最好的模块是那些收益最大、成本最低的模块。
    传统编程认为大量小类、小方法是好的,但未必,不符合深度模块的理念,极大的增加了复杂性。
    提供选择是好的,但接口的设计应使常见情况尽可能简单(见前面的公式)。几乎每个文件I/O用户都需要缓冲,因此应该默认提供。对于缓存不合适的少数情况,库可以提供禁用缓存的机制。
    如果一个接口有很多特性,但是大多数开发人员只需要知道其中的一些特性,那么该接口的有效复杂性就是常用特性的复杂性。

    5 信息隐藏

    基本思想是每个模块应该封装一些知识,这些知识代表设计决策。知识嵌入在模块实现中,但不会在模块界面中出现,因此对其他模块不可见。隐藏在模块中的信息通常包含有关如何实现某种机制的细节。
    信息泄露:当同一知识在多个地方被使用时,例如两个不同的类,它们都理解特定类型文件的格式时,就会发生信息泄露。在时间分解中,执行顺序反映在代码结构中:在不同时间发生的操作在不同的方法或类中。如果相同的知识在不同的执行点被使用,它就会在多个地方被编码,导致信息泄漏。

    6 通用模块是更深的

    最好的方法是以有点通用的方式实现新的模块。“有点通用性”意味着模块功能应该反映您当前的需要,但其接口不应该。相反,接口应该足够通用,以支持多种用途。这个界面应该很容易使用,满足今天的需要,但不应该专门与它们联系在一起。
    通用方法最重要的好处是,它比特殊目的方法产生更简单和更深层次的接口。
    软件设计最重要的元素之一就是确定谁需要知道什么以及什么时候需要知道。当细节很重要时,最好是尽可能明确和显而易见。
    什么是最简单的界面?如果您在不减少API总体功能的情况下减少API中的方法数量,那么您可能正在创建更多的通用方法。1、什么是最简单的界面,将涵盖我当前的所有需求?只要每个方法的API保持简单,减少方法的数量就有意义;如果为了减少方法的数量而不得不引入许多附加的参数,那么您可能不会真正简化事情。2、有多少情况会用到这个方法?如果一个方法被设计用于一个特定的用途,那么它可能太特殊了。3、这个API是否适合我当前的需要?这个问题可以帮助您确定在使API简单化和通用化方面何时做得太过火了。如果您必须编写大量附加代码才能将类用于当前目的,那么表明接口没有提供正确的功能。
    通用接口比专用接口有许多优点。它们往往更简单,方法更少,更深。它们还提供类之间的更干净的分离,而特殊用途的接口往往会在类之间泄漏信息。使您的模块具有某种通用性是减少系统复杂性的最好方法之一。

    7 不同层次,不同抽象

    直通方法:当相邻层有相似的抽象时,问题往往以直通方法的形式表现出来。传递方法是指除了调用另一个方法之外几乎不执行任何操作的方法,其签名与调用方法的签名类似或相同。

    8 把复杂性往下拉

    应该让模块的用户处理复杂性,还是应该在模块内部处理复杂性?如果复杂性与模块提供的功能有关,那么第二个答案通常是正确的。大多数模块拥有比开发人员更多的用户,模块开发人员应该努力使模块用户尽可能容易,即使这意味着额外的工作。表达这种想法的另一种方式是,对于一个模块来说,拥有简单的接口比简单的实现更为重要。
    配置参数是复杂度向上而不是向下移动的示例。配置参数使用户可以根据实际情况调整系统,但也为避免处理重要问题并将它们传递给其他人提供了一个容易的借口。在引入配置参数时,应该思考用户(或更高层级的模块)能否确定一个比我们能确定的更好的值?能否自动计算合理的默认值?
    在开发模块时,要寻找机会给自己带来一些额外的痛苦,以减少用户的痛苦。
    不能过分强调复杂性,在降低复杂性时要谨慎行事,如果(a)降低的复杂性与类的现有功能密切相关,(b)降低复杂性将导致应用程序中其他部分的许多简化,(c)降低复杂性简化类接口,则降低复杂性有意义。记住,目标是将系统的整体复杂性降到最低。

    9 合并还是分离

    在决定是否合并或分离时,目标是降低整个系统的复杂性,并改进其模块化程度。似乎实现这一目标的最好办法是将系统分成许多小部件,部件越小,每个部件可能就越简单。然而,细分行为产生了细分前所没有的额外复杂性,有些复杂性仅来自组件的数量,组件越多,就越难跟踪它们,也就越难在大型集合中找到所需的组件。
    • 细分通常会导致更多的接口,并且每个新接口都会增加复杂性。
    • 细分需要产生额外的代码来管理组件。
    • 细分产生分离:细分的组件将比细分前更远地分开。分离使得开发人员很难同时看到组件,甚至意识到它们的存在。如果组件是真正独立的,那么分离是好的:它允许开发人员一次只关注单个组件,而不会被其他组件分散注意力。另一方面,如果组件之间有依赖关系,那么分离就不好了:开发人员最终会在组件之间来回切换。更糟糕的是,他们可能不知道依赖关系,这可能导致错误。
    • 细分会导致重复:细分之前在单个实例中存在的代码可能需要存在于每个细分组件中。
    如果代码片段紧密相关,那么将它们组合在一起是最有益的。如果这些片断是无关的,那么它们分开可能更好。判断两段代码是否有关:
    • 它们共享信息
    • 它们一起使用,使用其中一段代码的任何人都可能使用另一段代码,注意指的是双向关系
    • 它们在概念上重叠,有一个包含两个代码段的简单高级类别
    • 不看另一段代码就很难理解其中一段代码
    1、如果共享信息应该合并;2、如果合并有更简单接口应该合并;3、如果可以消除重复应该合并;4、通用和特殊用途代码应该分离
    如果同一段代码(或几乎相同的代码)反复出现,那就是你还没有找到正确的抽象。
    在设计方法时,最重要的目标是提供干净和简单的抽象。每种方法都应该做一件事,并且完全做到。该方法应该有一个干净、简单的界面,这样用户就不需要脑子里有很多信息才能正确使用它。分离方法只有在导致更干净的抽象时才有意义。

    10 异常处理

    异常不成比例地增加复杂性,不应该简单的抛出异常给调用者,将复杂性转移出去,应该减少必须处理异常的地方的数量,在许多情况下,可以更改操作的语义,以便正常行为处理所有情况,并且没有例外条件可以报告。
    • 定义没有异常的API,重定义语义或将异常在API内部处理掉。比如一个unset命令,如果语义是删除一个变量时,如果这个变量不存在,那么必然应该产生异常,但如果语义是确保一个变量无效时,如果变量不存在直接返回即可。
    • 异常屏蔽,在底层检测和处理异常,上层就不用感知异常。比如TCP丢包重发。
    • 异常聚合,不是为许多单个异常编写不同的处理程序,而是使用单个处理程序在一个地方处理它们。比如更上层捕获异常,但要注意封装与信息隐藏,即上层的异常处理是通用设计,是用一个可以处理多种情况的单一通用机制来代替几个专门针对特定情况的机制。
    • 崩溃应用程序,针对一些不值得尝试处理的错误。
    • 定义没有特殊场景的API,非异常的一些特殊状态也跟异常类似,比如需要判断是否跳过某个状态,如果把这个状态转换成跟其它状态统一的逻辑(比如指定个无影响的值或变量),那么就不需要判断。
    • 注意上面的方案都是在异常可以不成为接口的前提下,如果上层必须知道是否发生了异常,那么即使增加了复杂性,也必须暴露异常。

    11 设计两次

    设计软件很难,所以你对如何构造一个模块或系统的最初想法不太可能产生最好的设计。这个原则的意思是多考虑几个设计方案,并对比分析优缺点,而不是直接使用想到的第一个方案。

    12 为什么写注释

    注释背后的总体思想是捕获设计者头脑中无法在代码中表示的信息。这些信息从底层的细节(如激发特别棘手的代码的硬件问题)到高级概念(如类的基本原理)都有。当其他开发人员稍后前来进行修改时,这些注释将允许他们更快、更准确地工作。
    没有注释,未来的开发人员将不得不重新编辑或猜测开发人员的原始知识,这将需要额外的时间,并且如果新的开发人员误解了最初的设计者的意图,就会有错误的风险。
    文档可以通过向开发人员提供他们进行更改所需的信息以及使开发人员容易忽略不相关的信息来减少认知负担。如果没有适当的文档,开发人员可能不得不阅读大量的代码来重建设计者头脑中的内容。文档还可以通过澄清系统的结构来减少未知的未知,从而清楚地知道哪些信息和代码与任何给定的更改相关。复杂性产生的主要原因是依赖性和模糊性。好的文档可以澄清依赖性,并填补空白以消除模糊性。

    13 注释应该描述从代码中看不出的东西

    注释的最重要原因之一是抽象,它包含许多从代码中看不出来的信息。抽象的概念是提供一种简单的思考方式,但是代码非常详细,仅仅通过阅读代码很难看到抽象。注释可以提供更简单、更高级别的视图。

    14 选择名称

    好的名称是文档的一种形式,使代码更容易理解,减少了对其他文档的需求,并且更容易发现错误。
    名称包括精确性和一致性两个方面。一致地使用名字,有三个要求:第一,始终使用通用名称用于给定的目的;第二,除给定的目的外,不要使用通用名称;第三,确保目的足够狭窄,使所有具有该名称的变量具有相同的行为。

    15 先写注释

    将注释作为设计过程的一部分,许多开发人员将文档的编写推迟到开发过程结束之后,这是生成低质量文档的最可靠的方法之一。编写注释的最佳时间是在流程开始,首先编写注释使文档成为设计过程的一部分,这不仅可以产生更好的文档,而且可以产生更好的设计,使编写文档的过程更加愉快。
    描述方法或变量的注释应该简单而完整。如果你发现很难写出这样的注释,那说明你所描述事物的设计可能有问题。

    16 修改现有代码

    系统设计是不断演变的,不可能在一开始就为系统设想出正确的设计。成熟系统的设计更多地取决于系统演化过程中所作的改变,而不是任何初始概念。防止复杂性随着系统发展而逐渐增加:
    • 保持战略性,见第三章,及时重构而不是快速修复。
    • 维护注释,让注释尽量靠近代码,这样修改代码时就容易看到注释并修改。
    • 注释属于代码而不是提交日志,修改代码时的一个常见错误是将有关更改的详细信息放在提交消息中提供给源代码存储库,而不是将其记录在代码中。
    • 避免注释重复,这样很难找到所有的注释全部修改。
    • 检查注释差异,提交前花几分钟扫描该提交的所有更改,确保每个更改都适当地反映在文档中。
    • 高级别的注释更易于维护。

    17 一致性

    一致性体现在名字、编程规范、抽象接口、设计模式等。
    难以维护,可能的解决方案有:1、文档,编程规范等;2、强制执行,工具检查,代码审查;3、开发人员在一个新文件中工作时,要环顾四周,看看现有代码的结构,并遵循,不要随意打破现有一致性,拥有一个更好的想法并不足以成为引入不一致的借口,你的新想法可能确实更好,但是一致性的价值几乎总是大于不一致性的价值。

    18 代码应该是显而易见的

    发现别人代码不明显比发现你自己的代码有问题要容易得多。因此,确定代码是否明显的最好方法是通过代码检查。如果有人读你的代码说它不明显,那么它就不明显,无论你看上去多么清楚。
    主要方法有:1、好的名字;2、一致性;3、清晰的格式;4、注释。
    使代码不显而易见:
    • 事件驱动,应该辅助额外的文档或注释,非显性代码如果通过快速阅读无法理解代码的含义和行为,这通常意味着,对于阅读代码的人来说,有一些重要的信息是不能立即清楚的;
    • 通用容器,封装成更有意义的专用容器,一般规则:软件的设计应该是为了便于阅读,而不是便于写作;
    • 声明和分配的类型不同;
    • 违反读者期望的代码,如果代码符合读者所期望的约定,那么代码就非常明显;如果代码不符合读者所期望的约定,那么记录这种行为就很重要,这样读者就不会感到困惑。

    19 软件趋势

    • 面向对象,不推荐实现继承,依赖严重
    • 敏捷开发,更倾向于战术性编程,应该更注重抽象和设计,即使推迟设计决策,但一旦需要抽象时,也应该花时间设计更合理的通用机制
    • 单元测试,对重构极为重要
    • 测试驱动开发,不推荐,战术性编程
    • 设计模式,一致性,通用解决方案,不要过度使用
    • Getter&Setter,不推荐,浅层函数
    20 为性能而设计
    简单不仅改进了系统设计,而且通常使系统更快。
     
     

    最重要的软件设计原则:

    1. 复杂性是递增的:你必须为小事出汗(见第11页)
    2. 仅能工作的代码是不够的(见第14页)
    3. 持续小投入,改善系统设计(见第15页)
    4. 模块应是深的(见第22页)
    5. 接口的设计应满足大部分的普通场景下应用更简单(见第27页)
    6. 模块拥有简单的接口比简单的实现更为重要(见第55页、第71页)
    7. 通用模块应该更深(见第39页)
    8. 分离通用和特殊用途代码(见第62页)
    9. 不同的层次应有不同的抽象(见第45页)
    10. 向下拉复杂性(见第55页)
    11. 定义不存在的错误(和特殊情况)(见第79页)
    12. 设计两次(见第91页)
    13. 注释应说明代码中不明显的内容(见第101页)
    14. 软件的设计应便于阅读,而不是便于书写(见第149页)
    15. 软件开发的增量应当是抽象的,而不是特性(见第154页)

    红旗摘要,系统出现任何这些症状都表明系统设计存在问题:

      1. 浅模块:类或方法的接口并不比它的实现简单多少(见第25页、第110页)
      2. 信息泄漏:一个设计决定反映在多个模块中(见第31页)
      3. 时间分解:代码结构基于执行操作的顺序,而不是基于信息隐藏(见第32页)
      4. 过度曝光:API强制调用者注意很少使用的特性,以便使用常用的特性(见第36页)
      5. 传递方法:一个方法除了将其参数传递给另一个具有类似签名的方法之外,几乎什么也不做(见第46页)
      6. 重复:一段代码被反复地重复(见第62页)
      7. 特殊通用混合码:特殊用途代码与通用代码没有完全分离(见第65页)
      8. 联合方法:两种方法有如此多的依赖性,以至于在不理解另一种方法的实现的情况下很难理解一种方法的实现(见第72页)
      9. 注释重复代码:注释的所有信息从紧挨着注释的代码中可以很容易的看出(见第104页)
      10. 实现文档污染接口:接口注释描述用户不需要知道的实现细节(见第114页)
      11. 模糊名称:变量或方法的名称非常不精确,不能传达很多有用的信息(见第123页)
      12. 难以选择名称:很难为实体提供一个精确而直观的名称(见第125页)
      13. 难以描述:为了完整,一个变量或方法的文档必须很长。(见第131页)
      14. 非显性代码:一段代码的行为或意义不容易理解。(见第148页)
  • 相关阅读:
    浅谈服务端渲染
    vuex数据持久化
    vuex中的命名空间
    如果在项目中使用阿里图标库
    vue中的插槽
    webpack相关以及搭建react环境
    数组的reduce方法
    再也不用等后端的接口就可以调试了Json-server
    react中如何使用swiper
    解决vue中组件库vant等ui组件库的移动端适配问题
  • 原文地址:https://www.cnblogs.com/gongxianjin/p/15632035.html
Copyright © 2011-2022 走看看