软件设计

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

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

将出于相同原因而变化的事物聚集在一起, 将出于不同原因而变化的事物分开.

后一种定义更容易理解, 例如公司内的三个不同的部门不应该共用一个模块(三种职责被耦合在一个模块里).
因为三个部门对软件有各自的要求, 所以代码在未来被修改时很可能因这种耦合性出现问题.

在代码提交层面, 可以想象成三个部门都修改了这个模块的代码以使得模块更符合他们的需求,
但三个部门提交上来的代码都相互冲突, 根本无法合并成一个源文件, 这就是违反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原则的重复复述.

只有在测试完整的情况下, 才能保证重构结果的正确性.
如果没有配套的测试, 那只是"代码修改"而不是"重构".

重构的目的是让代码变得更"可用", 更易于修改, 更容易了解代码如何工作.

"先完成架构"通常会遇到问题, 这是因为除非软件架构被充分理解
(除非您是在抄袭一个现有的程序, 不然几乎无法做到), 否则一般无法一次性完成将软件以最好的形式编写.
在编码过程中, 在使用过程中, 开发人员会渐渐了解更好的架构应该是怎样的,
从而找到需要重构的部分和目标.

长的代码未必是差的代码, 只要新代码能比旧代码更易读, 更易于修改, 那么新代码就是好代码.

测试应该集中在容易出错的地方, 而不是覆盖所有的情况(工作量巨大, 且边际效益极低).

判断的基准是将"意图"与"实现"分开:
如果一段代码无法一眼就明白它的意图, 那么就应该把它放进单独的函数, 然后给这个函数一个可读的名称.

if-else暗示了代码分支具有相等的地位, 当条件分支都处于相等地位时, 就应该使用if-else语句.
guard模式用于分离guard模式之前的代码和之后的代码, 有时会降低代码的开销,
通常用于在罕见情况下(前后代码地位不相等)提前结束函数.

函数是一种代码单元, 写很多函数和写很短函数都是可以接受的,
而且在重构的意义上这通常是好的做法, 因为我们用函数名提供更好的代码可读性.

需要注意的是, 不应该将每个函数提取到单独的文件里.
文件数量的增长, 函数调用和定义的距离过远, 都会增加认知负担.

函数名如果脱离语境就变得难以理解, 就应该尽可能让它与调用者接近.

函数参数越多, 情况就越多, 情况越多, 测试难度就越大.
如果函数参数可以为零, 那么就让它为零.

一个类只应该负责一件事, 而不是负责多件事,
让一个类负责多件事带来混乱, 请让类保持其职责单一, 保持其高度内聚的特性.

类或模块应有且只有一个引起它变化的原因.
类只应有一个职责, 即只有一条修改的理由.

一个对象应该尽可能少地了解其他对象,
一个对象不应该具备对其他对象太过具体的认识(比如知道其内部是怎么运行的).
如果一个对象知道太多它不应该知道的, 就会造成代码的耦合性增加, 引起混乱.
如果严格遵守该原则, 那么每个对象都不应该直接使用其他对象,
而是由一个专职此事的中介者对象将两方协调起来.

模块应该使用简单的接口, 减少泄露内部信息.

原因:

  • 降低系统层面的复杂性, 使得功能的实现可以被简单理解.
  • 降低修改模块的阻力, 减少了版本锁定的风险, 让模块可以方便的升级.

不应该在SQL语句里运用DRY原则, 原因如下:

  • 容纳SQL语句的字符串已经是构成事物的最小单位, 不应该被重构.
  • 代码的直观性会由于SQL片段的插入变得很差
  • 引入SQL注入漏洞
  • 无意中导致意外修改(例如添加或删除字段后, 依赖这段SQL的某些代码不能正确运行)

在发生数据库模式变更时, 由于没有遵循DRY原则, 开发人员显然会发现有很多SQL语句需要修改:
由于数据库模式变更不是重构的一部分, 作为对项目有关键性修改的变更, 有很多代码需要修改是正常的.

一项功能应该只有一个被修改的理由, DRY原则很可能会破坏这一点:
应用了DRY原则的代码可能被很多文件, 乃至很多项目依赖, 以至于对它修改很可能破坏依赖它的代码.
使用语义化版本号无助于维护这种依赖, 因为现代软件对依赖的看法是"频繁更新, 保持最新".

视抽象的高明程度, 这种破坏性可大可小:
如果被抽象部分是一个合约/接口/数据结构, 则造成的破坏可能非常大.
因为编程语言对于数据的应用通常是硬编码的紧耦合代码.

一些代码的优点在于它的直观性, 而DRY无疑会破坏这些代码的优点, 教条般地遵守DRY原则可能导致项目变得难以开发.

以下是典型的间接化手法:

  • 将代码提取到独立的文件.
  • 将代码模块化.

变更一项功能需要在多处修改.

需要手动完成不具有连续性的动作, 例如打开文件和关闭文件, 申请内存和释放内存.

错误的代码关联使得一处代码不必要地对多处功能产生影响, 导致修改代码的阻力变大.

难以发现决定某项功能的代码所在的位置.

一种在游戏开发中使用的模式, 将OOP的对象按功能进一步提取成类,
这些按功能提取的类被称作组件, 常见的有输入组件, 物理组件, 图像组件等.
在使用组件模式时, 对象会按顺序调用自身具有的组件的方法.

本质上组件模式只是一种基于提取的重构手法.

一种在游戏开发中使用的模式, 源于组件模式, 但更进一步, ECS是OOP的对立面.

ECS由以下三种类型的元素组成:

  • 实体: 组件的集合, 对应OOP里的对象.
    每个实体有自己的唯一id, 可以添加或移除组件.
  • 组件: 不包含任何逻辑的纯数据.
    在较为底层的编程语言里, 由于ECS的组件数据在内存中是连续的(具有数据局部性), 内存性能会提升, 于是ECS也作为一种优化手段出现.
  • 系统: 查找实体, 对实体内的组件执行操作.
    一个游戏项目会有多个系统, 但每个实体只会被需要它的系统使用.
    区分系统的一个重要原因是如果不这么做, 则每个接触代码的程序员都需要了解该游戏在物理, 图像, 声音等方面的细节, 否则很容易破坏功能.

举例来说, 一个玩家角色是一个实体, 这个实体有几个组件, 分别描述与玩家角色相关的所有数据,
包括运动组件, 物理组件, 外观组件, 输入组件等.
系统则使用这些实体中的组件, 例如渲染系统使用玩家实体中的外观组件中的数据,
运动系统使用玩家实体中的运动组件中的数据.

local playerEntity = {
health = component.health(100)
, position = component.position(0, 0)
, sprite = component.sprite('player.png')
}
local drawSystem = {
-- 只查询所有包含position和image组件的实体
filter = requireAll('position', 'sprite')
, process = function (entity)
graphics.draw(entity.position.x, entity.position.y, entity.image)
end
}

结构体是一种内存数据结构, 结构体按照其成员的数据类型申请一片内存空间,
并且将成员将按顺序储存到内存位置.

结构体最常见于C和Rust这样严格控制内存的系统编程语言.

优点:

  • 实现了数据结构和处理函数的分离.
  • 扩展更多的处理函数时不需要修改已有的数据结构代码.
  • 容易测试

缺点:

  • 高耦合性, 处理函数必须对数据结构具有知识. 由于缺乏封装, 无法隐藏细节.
  • 数据结构和处理函数的代码在垂直距离上不够近, 处理函数分布在各个位置会带来认知负荷.
  • 添加新数据结构时, 处理函数也需要添加对应的处理代码.

什么时候使用:

  • 使用不可变数据类型, 且不隐藏信息时可读性会更好的情况下
class Rectangle {
public height: number
public width: number
}
class Circle {
public radius: number
}
type Shape = Rectangle | Circle
function area(rect: Rectangle): number
function area(circle: Circle): number
function area(shape: Shape): number {
if (shape instanceof Rectangle) {
const rect = shape
return rect.height - rect.width
} else if (shape instanceof Circle) {
const circle = shape
return Math.PI - (circle.radius--2)
}
}
const rect: Shape = new Rectangle()
rect.width = 10
rect.height = 20
console.log(area(rect))

对象是一种封装具体行为的机制.
对象的绝大部分价值来自于对特定概念的封装.
人们在使用对象时无需关注它是如何实现的, 降低了编程时的认知负荷.

模仿结构体的对象被称作贫血模型.

具有多态性的对象被称作充血模型.

优点:

  • 低耦合性, 只暴露必要成员, 外部调用者对内部逻辑保持无知的状态.
  • 相关代码的垂直距离足够近.
  • 符合开放封闭原则, 实现更多符合形状接口的类不需要修改已有的代码.

缺点:

  • 造成代码量增加.
  • 为Shape接口添加新的方法时, 需要给每一个实现Shape接口的对象添加方法.
  • 由于内部状态众多, 因此变得难以测试.

什么时候使用:

  • 有多个模型需要使用同一个接口, 且之后可能有更多实现此接口的新模型加入

注: 不使用JavaScript的getter/setter语言特性是为了不在代码中出现隐式调用,
getter和setter将自己伪装成了变量, 却有在运行时抛出异常的可能性,
代码的使用者会因其长得像变量而无法意识到这一点.

interface Shape {
area(): number
}
class Rectangle implements Shape {
private width: number
private height: number
constructor(width: number, height: number) {
this.width = width
this.height = height
}
setWidth(val: number) {
this.width = val
}
getWidth() {
return this.width
}
setHeight(val: number) {
this.height = val
}
getHeight() {
return this.height
}
area() {
return this.width - this.height
}
}
class Circle implements Shape {
private radius
constructor(radius: number) {
this.radius = radius
}
setRadius(val: number) {
this.radius = val
}
getRadius() {
return this.radius
}
area() {
return Math.PI - (this.radius--2)
}
}
const rect: Shape = new Reactangle(10, 20)
console.log(rect.area())

优点:

  • 实现了行为逻辑(事务脚本)与数据结构(状态)的分层.

缺点:

  • 本质上是面向过程的延续, 不符合最少知识原则.
  • 本质上是创建了一个数据持有器(data holder).
  • 造成类数量的增加.

什么时候使用:

  • 需要将事务脚本在面向对象编程抽象成多态的独立单元的时候.
class Rectangle {
private width: number
private height: number
constructor(width: number, height: number) {
this.width = width
this.height = height
}
setWidth(val: number) {
this.width = val
}
getWidth() {
return this.width
}
setHeight(val: number) {
this.height = val
}
getHeight() {
return this.height
}
}
class Circle {
private radius
constructor(radius: number) {
this.radius = radius
}
setRadius(val: number) {
this.radius = val
}
getRadius() {
return this.radius
}
}
class ShapeCalculator {
area(shape: Rectangle) {
return rect.width - rect.height
}
area(circle: Circle) {
return Math.PI - (circle.radius--2)
}
}
const rect: Shape = new Rectangle(10, 20)
const calculator = new ShapeCalculator()
calculator.area(rect)

目的: 分离数据结构和数据操作.

优点: 符合最少知识原则, 受访者只知道有访问者的存在, 不知道访问者的具体实现.

注: 编程语言最好能够提供根据类型的函数重载功能,
否则的话, 需要在访问者接口里定义多种不同的visit方法,
并在受访者的accept方法里调用与自身相关的visit方法.

public interface Acceptor {
public void accept(Visitor visitor);
}
public interface Visitor {
public void visit(Foo foo);
public void visit(Bar bar);
}
public class Foo implements Acceptor {
// ...
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
public class Bar implements Acceptor {
// ...
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
public class TheVisitor implements Visitor {
public void visit(Foo foo) {
// ...
}
public void visit(Bar bar) {
// ...
}
}
public class AnotherVisitor implements Visitor {
public void visit(Foo foo) {
// ...
}
public void visit(Bar bar) {
// ...
}
}
public interface Acceptor {
public void accept(Visitor visitor);
}
public interface Visitor {
public void visitFoo(Foo foo);
public void visitBar(Bar bar);
}
public class Foo implements Acceptor {
// ...
public void accept(Visitor visitor) {
visitor.visitFoo(this);
}
}
public class Bar implements Acceptor {
// ...
public void accept(Visitor visitor) {
visitor.visitBar(this);
}
}
public class TheVisitor implements Visitor {
public void visitFoo(Foo foo) {
// ...
}
public void visitBar(Bar bar) {
// ...
}
}
public class AnotherVisitor implements Visitor {
public void visitFoo(Foo foo) {
// ...
}
public void visitBar(Bar bar) {
// ...
}
}
Foo foo = new Foo();
foo.accept(new TheVisitor());
foo.accept(new AnotherVisitor());

在不对代码性能有严格要求的情况下, 都应该将性能让位于提升可读性, 这是因为:

  1. 1.
    有可读性的代码才是可维护的代码.
  2. 2.
    绝大多数降低可读性换取性能的代码优化对程序性能的整体影响微乎其微,
    大多数情况下I/O性能才是现代程序的瓶颈.
  3. 3.
    现代的编译器优化很强大, 甚至可能是反直觉的,
    开发者认为高性能的代码可能与不优化时效果相差无几甚至起反效果.
  4. 4.
    将时间用在更有用的部分.

使用准确, 专业的词汇.
使用具体的名称, 避免意义模糊, 指代宽泛的名称.

优先使用正向逻辑(没有逻辑非), 而不是反向逻辑(带有逻辑非).

// BAD
if (!debug)
// GOOD
if (debug)