Oasis's Cloud

一个人的首要责任,就是要有雄心。雄心是一种高尚的激情,它可以采取多种合理的形式。
—— 《一个数学家的辩白》

软件设计的哲学

作者:oasis


软件设计的重点是如何消减复杂性。

复杂性的定义

复杂性是指与软件系统结构有关的、使人难以理解和修改系统的所有因素。

指导降低复杂性的方向:

  • 编写更加简单的代码
  • 将复杂性进行封装(模块化设计)
  • 将复杂性隔离到一个永远不会看到的地方

如何识别复杂性:

  • 变更放大
    • 一个简单的变更需要在许多不同的地方进行代码的修改。
  • 认识负担
    • 开发者需要了解多少知识才能完成任务。
  • 不知道未知
    • 不清除必须修改哪些代码才能完成任务。
    • 开发者必须掌握哪些信息才能成功完成任务。例如在 Taro 工程中实现防止滚动穿透,开发者需要掌握 Taro 4 端的不同特性。

复杂性产生的原因:

  • 为什么会变更会放大
  • 为什么会有认知负担
  • 为什么会出现不知道未知的情况

造成上面问题的两大原因是:依赖关系和模糊性。

依赖关系是软件的基本组成部分,是一种隐式的无法完全消除的非实体。依赖关系管理不好就会导致变更放大,过多的依赖关系会增加认知负担。

模糊性主要在代码和设计层面表意不准确时出现。例如:

  • 一个无法准确指称的变量或方法名。
  • 变量或方法在多个地方的具有多种不同的使用情况。

复杂性是会在软件迭代更新中增量增加的。

解决复杂性从开发实践的角度来分析:

  • 战术性编程
    • 战术性编程就是一直叠加功能,不考虑设计是否合理。以尽快完成任务为目标。
  • 战略性编程
    • 战略性编程以系统的长期良好结构为目标。

在战略性编程中需要提前进行系统的设计思考,需要对比多种方案的优缺点。在精力分配上建议花费 10-20%。

模块化设计如何影响复杂性?

理想情况模块之间无互相影响,则最坏复杂性是最差模块的复杂性。

为什么进行模块化设计?

模块化设计的目标就是尽量减少模块之间的依赖。

模块的定义:

任何具有接口和实现的代码单元就可以被称作模块。 模块分为两部分:接口和实现。作者定义了一个计算复杂性的公式。矩形公式。顶边是接口。

模块接口的重要性

接口包括两种信息:正式部分和非正式部分。

  • 接口的正式部分是在代码中明确指定的,其中一些可以通过编程语言检查正确性。
  • 接口的非正式部分包括接口的高级行为以及隐含知识。非正式部分只能通过注释来描述。编程语言无法确保描述的完整性或准确性。

如何定义好的模块?

  • 接口比实现简单得多的模块可以认为是好的模块,它具有下面两个有点
    • 简单的接口可以最大限度降低模块对系统其他部分造成的复杂性(简单的接口清晰,通用性强,用起来更顺手)
    • 修改模块的方式不改变其接口(例如 unix 的文件操作接口,底层已经换过多次实现了)

抽象是进行模块设计的基石。抽象是对实体的简化,省略了不重要的细节。但是我们在进行抽象的时候经常出现两方面的错误:

  • 抽象中包含了不重要的细节。
  • 抽象中忽略了真正的细节。

深模块

模块深度是一种关于成本与收益的思考方式。模块的优势在于其功能,模块的成本在于其接口。为什么说接口是模块的成本?因为接口代表了该模块给系统其他部分带来的复杂性。接口越小越简单,带来的复杂性就越少。

浅模块是指:相对于所提供的功能而言,其接口过于复杂。

除了模块的依赖影响复杂性,进行过小的设计也会影响复杂性

“任何长于N行的方法都应该拆分成多个方法”,这种思想会导致过多的浅方法,从而增加系统整体的复杂性。

接口设计指导原则

接口设计应该使常见情况尽可能简单。

深模块可以减低复杂性,如何创建深模块?

信息隐藏

每个模块都应该封装一些代表设计决策的知识。这些知识嵌入模块的实现中,但不出现在模块的接口中,因此其他模块看不到这些知识。隐藏在模块中的信息通畅包括如何实现某种机制的细节。

如何识别信息泄露

  • 对设计决策的任何修改都需要对所有相关模块进行修改。比如两个不同的类都了解某种文件的格式,就会发生信息泄露。

如何解决信息泄露

  • 将多个地方的都知道的信息合并到其中的一个类。
  • 封装一个新的类用来表示泄露的信息。

信息泄露的原因

  • 时序分解设计方法会导致信息泄露。在时序分解设计中,开发者会进行面条式的设计,而不是根据制作面条需要多少不同的实体进行设计。

所以在进行模块设计时,应该关注执行每项任务所需的知识,而并不是任务发生的顺序。我们需要对知识进行模块化封装。

通用模块更深

通用模块之所以更深,是因为通用模块可以指导软件更好的分层。可以满足软件设计中的重要因素之一:确定谁需要知道什么,以及什么时候需要知道。

怎么设计更深的模块?

  • 什么是能满足我当前所有需求的最简单接口?
  • 这个方法会在多少种情况下使用?
  • 对于我当前的需求来说,这个 API 容易使用吗?

通用模块设计的时候会面临一些特例处理的情况,这时候我们可采用将特例上推或下推。例如将状态中涉及 UI 的部分上推到 UI 表示层。

除此外,代码实现的过程中会出现特例,对于特例的处理可以增加默认值,或借鉴数学中的换元法消除特例。

不同层,不同抽象

每个方法应该具有独特的功能,这样多个方法具有相同的签名也是可以的。调度器调用的方法就具有这种特性。

装饰器的动机是将类的特殊用途扩展与更通用的核心分离开。它们为少量的新功能引入了大量的重复引用。

装饰器一般可以通过下面策略进行替换。

  • 能否直接将新功能添加到底层类中,而不是创建一个装饰器类
  • 如果新功能是针对特定使用场景专门设计的,那么将它与使用场景合并,而不是创建一个单独的类,这样是否合理?
  • 能否将新功能与现有的装饰器合并,而不是创建一个新的装饰器?这样可以创建深的装饰器
  • 新功能是否可以不通过封装现有功能,而是作为独立于基类的单个类来实现?

不同层,不同抽象的另一个应用是,类的接口通常应不同于其实现:内部使用的表示法应不同于接口中出现的抽象。如果相同,那么这个类可能是一个浅类。

模块的拆分与合并策略

模块的拆分应该以降低整个系统的复杂性,提高其模块化为目标。

拆分的时机

当代码中出现下面情况可以考虑进行拆分

  • 它们共享信息
  • 它们被一起使用
  • 模块之间重叠,一个简单的高层类包含了两个部分的代码
  • 不看其中一段代码,就很难理解另外一个代码

合并的时机

  • 如果共享信息,则合并
  • 如果可简化接口,则合并
  • 消除重复,则合并
  • 区分通用代码和专用代码,一般良好的通用代码是可以通过很低成本的方式把专用代码的功能组合出来。

一些关于拆分的讨论:

  • 代码长度不应该成为拆分代码的指导意见。长的方法具有一下一些优势:
    • 代码块相对独立,那么该方法就是可读的、可理解的,只要一次读一个代码块即可。
    • 如果代码块之间有复杂的交互,那么将它们放在一起就更重要了,读者可以一次看到所有代码

如何判断拆分是否合理?

  • 每个方法应该只做一件事儿,并且把这件事儿做完整。
  • 阅读子方法的人不需要知道父方法的任何信息
  • 阅读父方法的人不需要了解字方法的实现

异常处理也会增加复杂性

减少异常处理造成的复杂性损害的最佳方法是减少需要处理异常的地方的数量。

减少异常情况的方法:

  • 将错误情况处理为默认情况
  • 屏蔽异常,不将异常抛给用户,因为在异常出现的时候用户往往不知道如何处理
  • 聚合异常的处理,这样可以降低对异常条件的处理,并在一处进行处理。
  • 让应用程序崩溃,其实这是在处理真实的再也无法处理的异常。

何时降低复杂性

什么时候降低复杂性是合理的?

  • 降低的复杂性与类的现有功能密切相关
  • 减低复杂性将导致应用程序其他部分简化
  • 降低复杂性将简化类的接口

满足上面任一条件,对代码的调整才具有一定的收益。