软件设计

每个软件模块都有且只有一种需要被(人)修改的理由.

任何软件模块都应该只对一类人负责.

将出于相同原因而变化的事物聚集在一起, 将出于不同原因而变化的事物分开.
后一种定义更容易理解, 例如公司内的三个不同的部门不应该共用一个模块(三种职责被耦合在一个模块里).
因为三个部门对软件有各自的要求, 所以代码在未来被修改时很可能因这种耦合性出现问题.
在代码提交层面, 可以想象成三个部门都修改了这个模块的代码以使得模块更符合他们的需求,
但三个部门提交上来的代码都相互冲突, 根本无法合并成一个源文件, 这就是违反SRP导致的.
正确的做法是尽早切分成三个不同的模块, 每个模块对应公司里的一个部门.
一个设计良好的系统应该在不需要修改已有代码的情况下就能够被扩展.
该原则的主要应用就是"接口(interface)", 新代码只需要满足旧代码的接口, 即可实现新的功能.
如果想用可替换的组件来构建软件系统, 那么这些组件就必须遵守同一个约定.
根据此原则, 父类不是为子类准备的用于共用代码的地基, 而是约定本身, 是为约束子类而存在的接口.
子类方法应该保持与其父类的行为兼容.
理解该原则的关键在于假设客户只知道父类的存在, 而不知道具体子类的存在.
该原则在OOP里意味着(以下内容十分类似于协变和逆变的概念):
  • 子类方法的参数应该比父类更抽象.
    例如, 父类方法 feed(Cat c) 的子类BengalCat至少应为 feed(Cat c), 最好是 feed(Animal c),
    而不应该是 feed(BengalCat c), 因为这破坏了从父类延续下来的约定,
    使用父类的客户可能不会知道存在BengalCat类.
  • 子类方法的返回值应该比子类更具体.
    例如, 父类方法 buyCat(): Cat 的子类BengalCat至少应为 buyCat(): Cat,
    最好是 buyCat(): BengalCat,
    而不应该是 buyCat(): Animal, 因为这破坏了从父类延续下来的约定,
    Animal可能不满足Cat的成员, 而客户却认为这是Cat.
  • 子类不应该抛出超出父类异常约定的异常(像Java这样的语言要求显示注明将会抛出的异常类型),
    这是因为父类的客户只知道这些约定的异常的存在.
    注: 在大多数编程语言里, 该要求都内建于编程语言的语法之中.
  • 子类方法参数不应该增加前置条件.
    例如一个接受任意整数的父类方法, 不应该在子类的方法里将该参数限制为只能接受正数.
    即使子类会对不接受的参数抛出相关的异常, 也不允许, 因为父类的约定不包含此内容.
  • 子类方法不应该增加后置条件.
    例如一个子类方法不应该创建一个额外的数据库连接(在方法执行完毕时不关闭此连接),
    这是因为父类的客户不会知道子类的这些副作用, 更无从解除这些副作用的影响.
  • 子类不应该覆盖超类的不变量成员.
  • 子类不应该修改超类的私有成员(仅在一些支持反射的动态类型语言里可能发生).
该原则衍生出另一条关于实现接口的原则:
如果需要处理特例, 不要在原来的实现里添加特例, 而是创建一个专门处理特例的新组件.
这条原则通过避免增加原有组件的复杂性, 避免了系统可维护性的降低.
正方形长方形问题是一个著名的违反LSP的案例:
  • Rectangle类可以分别修改长和宽.
  • Square类只能修改边长.
  • Square类是Rectangle的子类.
之所以违反LSP, 是因为"正方形是长方形的一种特例"这种现实中的直觉在OOP中不成立, 因为:
Reactangle的约定是"可以分别修改长和宽"的类, 而Square的约定是"只能修改边长"的类.
在OOP里, Square的实例可以被赋值给Rectangle类型的变量, 这会导致上述约定被打破.
正确的做法是让Rectangle类和Square类各自独立存在.
设计时应该避免不必要的依赖.
即"不要依赖不需要用到的东西".
简而言之, 对外部的依赖应该尽可能维持在最低限度.
典型的违反此原则的例子是使用云服务提供的功能, 结果在迁移时发现该项目已经无法脱离这些功能存在了.
如果确实需要使用云服务的功能, 则需要注意把这些功能拆分成更小的粒度,
以便提升代码的可移植性(只需修改和删除相关的部分, 而不用担心被整个锁定).
高层代码不应该依赖于底层代码, 而是应该依赖接口.
底层代码应该实现这些接口.
底层代码(经常处于基础设施层): 基础操作, 例如对磁盘进行写入, 读取等操作.
高层代码(经常处于应用层和领域层): 用于指导底层代码, 以实现业务逻辑.
由于接口比实现稳定, 因此代码应该依赖于接口而不是实现.
直接使用new进行类的实例化被认为是违反DIP的, 而使用工厂函数(函数的返回值有接口)则遵守了DIP.
软件复用的最小粒度应等同于其发布的最小粒度.
意思就是如果代码最小可以以模块的形式发布, 那么软件复用的最小粒度就是模块.
将由于相同原因而修改, 并且需要同时修改的东西放在一起.
将由于不同原因而修改, 并且不同时修改的东西分开.
不要强迫一个组件的用户依赖他们不需要的东西.
这实际上是ISP原则的重复复述.
不应该在SQL语句里运用DRY原则, 原因如下:
  • 容纳SQL语句的字符串已经是构成事物的最小单位, 不应该被重构.
  • 代码的直观性会由于SQL片段的插入变得很差
  • 引入SQL注入漏洞
  • 无意中导致意外修改(例如添加或删除字段后, 依赖这段SQL的某些代码不能正确运行)
在发生数据库模式变更时, 由于没有遵循DRY原则, 开发人员显然会发现有很多SQL语句需要修改:
由于数据库模式变更不是重构的一部分, 作为对项目有关键性修改的变更, 有很多代码需要修改是正常的.
一项功能应该只有一个被修改的理由, DRY原则很可能会破坏这一点:
应用了DRY原则的代码可能被很多文件, 乃至很多项目依赖, 以至于对它修改很可能破坏依赖它的代码.
使用语义化版本号无助于维护这种依赖, 因为现代软件对依赖的看法是"频繁更新, 保持最新".
视抽象的高明程度, 这种破坏性可大可小:
如果被抽象部分是一个合约/接口/数据结构, 则造成的破坏可能非常大.
因为编程语言对于数据的应用通常是硬编码的紧耦合代码.
一些代码的优点在于它的直观性, 而DRY无疑会破坏这些代码的优点, 教条般地遵守DRY原则可能导致项目变得难以开发.
以下是典型的间接化手法:
  • 将代码提取到独立的文件.
  • 将代码模块化.
一个对象应该尽可能少地了解其他对象,
一个对象不应该具备对其他对象太过具体的认识(比如知道其内部是怎么运行的).
如果一个对象知道太多它不应该知道的, 就会造成代码的耦合性增加, 引起混乱.
如果严格遵守该原则, 那么每个对象都不应该直接使用其他对象,
而是由一个专职此事的中介者对象将两方协调起来.
继承会导致以下问题:
  1. 1.
    为了满足多态性, 子类无法减少继承来的接口中的成员
  2. 2.
    为了避免出现令人意外的行为, 子类覆盖父类的方法时, 需要确保新行为与父类的行为兼容
  3. 3.
    由于子类在实现时需要了解父类的实现细节, 继承实际上是一种破坏父类封装的行为
  4. 4.
    由于子类与父类紧耦合, 父类的修改可能会破坏子类的行为
  5. 5.
    使用继承的解决方案可能会导致组合爆炸, 子类激增:
    卡车 -> 交通工具
    汽车 -> 交通工具
    电动卡车 -> 卡车
    混合动力卡车 -> 卡车
    电动汽车 -> 汽车
    混合动力汽车 -> 汽车
    自动驾驶电动卡车 -> 电动卡车
    自动驾驶混合动力卡车 -> 混合动力卡车
    自动驾驶电动汽车 -> 电动汽车
    自动驾驶混合动力汽车 -> 混合动力汽车
正确的(组合)做法是把"动力"和"驾驶方式"作为交通工具的两种维度(类的属性)拆分出去.