「设计模式之美」笔记-原则

264 阅读14分钟

读《设计模式之美》笔记系列,以及对于其他一些已阅的内容简单总结

Summary of 7 principles 太长不看版

设计原则归纳目的
开闭原则 OCP对扩展开放,对修改封闭降低修改带来的风险
里氏替换 LSP不要破坏继承体系子类应该可以替换父类,但子类修改不应该影响父类方法含义防止继承泛滥
依赖倒置 DIP高层不应该依赖底层,面向接口编程避免底层依赖带来的变动风险
单一职责 SRP只做一件事,类的单一有助于提高代码可读性和可维护性
接口隔离 ISP一个类对另外一个类的依赖应该建立在一个最小的接口上功能解耦,高聚合、低耦合
最小知识 LKP如无必要,尽量不要直接引用减少代码臃肿
合成复用 CRP尽可能使用组合,而非通过继承降低代码耦合

其中, 单一职责(SRP), 开闭原则(OCP), 里氏替换(LSP), 接口隔离(ISP), 依赖倒置(DIP) 共同构成了大名鼎鼎的 SOLID 原则。

单一职责原则 Single Responsibility Principle, SRP

  • key point:

    • 一个类被引起变化的原因应该有且仅有一个,否则这个类应该被拆分为多个类
    • 一个对象如果承担了太多职责,会给其他代码带来非必要的其他功能的代码,带来代码上的引入冗余;一个职责的改变或许会牵连这个对象其他功能职责的内容修改
  • benefit:

    • 每个类只负责一个职能会简化这个类的功能复杂度
    • 提高类的可读性以及可维护性
    • 变更所引起的修改风险也会减低,当修改某个功能时,可以显著降低这个功能修改带来的其他影响
  • How:

    • 适当进行抽象建模,将相关性高的内容聚合在一块
  • 简单的判定依据:

    • 类的代码行数过多,或者是包含的属性、方法过多,可以考虑将其拆分

    • 依赖或者被依赖的其他类过多的时候,可以考虑将其拆分

    • 比较难给类起一个合适的名字,那可能这个类的职责不够清晰

    • 使用类的大量方法都是几种操作某个及属性值

开闭原则 Open Closed Principle, OCP

key point:

  • 软件实体对扩展开放,对修改关闭 (Software entities should be open for extension,but closed for modification) 对拓展开放是为了应对变化,对修改关闭是防止影响以往的代码和单元测试
  • 当需求修改时,应该扩展该模块的功能,以最小修改代价来满足新的需求;
  • 只要没有破坏原来的代码的正常运行、没有破坏原有的单元测试,则可以认为是符合开闭原则的

benefit:when 遵循开闭原则

  • 测试时只需要对拓展的代码进行测试,不影响原有代码
  • 颗粒度越小,被复用的概率越高,可以提高代码的复用性
  • 提交软件的可维护性,易于扩展和维护

How:

  • “抽象约束,封装变化”,通过接口或者抽象类为实体定义一个相对稳定的抽象层
  • 将可变的部分封装起来,隔离变换,提供抽象方法、接口给上层,当实际发生变化的时候,只需要扩展一个新的实现、并替换,尽可能避免上游代码的改动(约定好返回的内容、数据格式;调用方法和入参列表,减少改动)
  • 预先留好扩展点,以便未来需求变更时,以最小代码改动代价,来将代码灵活插入到新的扩展点上

具体方法 OCP 的方法

  • 多态
  • 依赖注入
  • 基于接口而非实现编程
  • 抽象意识

里氏替换原则 Liskov Substitution Principle, LSP

  • key point: When we need inheritance

    • 确保超类所拥有的性质在子类中依然成立(Inheritance should ensure that any property proved about supertype objects also holds for subtype objects)
    • 主要说明了什么时候该用继承,什么时候不行,里氏替换是对开闭原则的补充,反映子类和基类之间的作用
    • 原则是可以用子类去替换掉所使用的的父类,同时保证不改变原有程序逻辑以原有程序的正确性。按照协议来设计,遵循父类的对外承诺的协议来设计子类
  • benefit:

    • 保证类的拓展不会对已有的系统引入新的错误,保证兼容性前提下对父类进行拓展和调整
  • How:

    • 子类可以拓展父类,但是不能改变父类原有的功能,子类所继承父类原有的功能逻辑、输入输出以及异常约定都要保持与原来一致。
    • 子类不能去覆写父类已经实现的具体方法,防止引起其他子类的混乱
    • 用父类的单元测试去检测子类是否可以通过,如果不能通过,则说明子类未能很好遵从父类的约定,那么子类可能违反了 LSP
  • 违反 LSP 的例子

    • 子类继承了父类的排序方法,父类是按照时间排序,而子类是按照 id 排序,这则违反了原来父类的设计
    • 父类某个函数约定中,约定入参可以为任何数;而在子类中修改为只允许正整数,入参的约定父子类存在不一致,则违反了原来父类的设计

接口隔离原则 Interface Segregation Principle, ISP

  • key point:

    • 为各个类提供它们专供的接口,而非试图建立一个庞大的接口去供给所有依赖它的类进行调用,一个类对另外一个类的依赖应该建立在一个最小的接口上
    • 尽可能将庞大的接口拆分成更小或者更加具体的接口
  • Different with SRP 单一职责 两者均为提高内聚性,降低类之间的耦合性而提出,但二者各有不同

    单一职责接口隔离
    侧重点更加注重职责单一注重对于接口依赖的隔离
    约束对象约束类的实现和细节约束接口,针对抽象和程序框架的构建
  • benefit:

    • 将接口的颗粒度拆分得更小,可以使得系统更加灵活,防止外来变更扩散修改风险
    • 提高系统的内聚性,减少对外交互,降低系统的耦合性
    • 接口颗粒度大小必须适度,太小会使得接口数量过多,导致设计复杂;太大则灵活度降低,无法进行定制化服务,提高系统风险
    • 可以减少系统接口无用代码
  • How:

    • 一个接口应该只服务于一个子模块或者一个业务逻辑
    • 只提供给调用方所需要的接口方法
    • 拆分颗粒度时,要注重了解业务逻辑,不同的业务环境或者业务逻辑所采用的接口拆分标准或许也会不同
    • 提高内聚,减少对外交互,最大程度利用好接口,去完成更多的事情
  • Example:

image.png

依赖反转 Dependence Inversion Principle, DIP

  • key point:

    • 高层模块不应该依赖低层模块,两者都应该依赖于同一个抽象;抽象不应该依赖细节,细节应该依赖抽象 (High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions. )

      • 在调用链上,调用者属于上层,被调用者属于底层。这条原则实际上更多是指导框架,e.g. Tomcat, Tomcat 属于上层,WebApp 属于下层,两者都应该依赖于同一个抽象,Sevlet 规范,这个规范不依赖于具体 Tomcat 或者 WebApp 的实现细节,而 Tomcat 或者 WebApp 都应该依赖于 Sevlet 规范。
    • 只定义好接口或者抽象类去规范,而非具体实现细节,具体的细节交由具体实现类去进行实现

  • benefit:

    • 降低类间的耦合性,提高系统的稳定性
    • 减少并行开发引发风险,提高代码可读性和和维护性
  • How:

    • 每个类尽量提供接口或者抽象类
    • 任何类都不应该从具体类派生
    • 任何继承都要遵守 LSP
  • Compare with 控制反转 IOC、依赖反转 DIP、依赖注入 DI

    • 控制反转 IOC - demo/pattern/principle/IOC

      • 控制 指的是程序员对于程序执行的控制权,通过某种方式,使得程序的控制权转让给了框架或者其他,而非我们程序员撰写对应的实现过程,由此实现了 IOC
      • IOC 不是一种具体的实现技巧,而是一个设计思想,用以指导框架层面的设计
    • 依赖注入 DI

      • 注入 指的是不通过 new() 的方式在类内进行创建,而是在类外实例化所需要依赖的对象或者实例之后,通过构造函数或者函数的方式**传入(注入)**给类里面进行使用。

      • 这样的方式有利于提高类的拓展性,不需要改动类内的实现,通过外部传入即可,外部的对象只要满足一定接口约束,可以进行其他的拓展。

      • DI 是一种具体的编码技巧

        • with IOC: 类对象的创建和依赖注入工作和本身具体业务无关,可以抽象出来让框架自己来完成,这类框架就是依赖注入框架。简单配置类对象、类与类之间的关系,剩下由框架自己完成创建、管理对象生命周期、以及依赖注入等,例如: Spring 框架主要是通过依赖注入来实现控制反转。
    • 依赖反转 DIP

      • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象 (High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions. )

        • 在调用链上,调用者属于上层,被调用者属于底层。这条原则实际上更多是指导框架,e.g. Tomcat, Tomcat 属于上层,WebApp 属于下层,两者都应该依赖于同一个抽象,Sevlet 规范,这个规范不依赖于具体 Tomcat 或者 WebApp 的实现细节,而 Tomcat 或者 WebApp 都应该依赖于 Sevlet 规范。
  • 依赖注入 & 面向接口编程的区别?

    • 依赖注入是一种具体的编码技巧,所依赖的其他类,在类外实例化后传入,而非自己在类内进行实例化。强调对象创建和类之间的关系,可以进行灵活替换,提高代码扩展性。
    • 面向接口编程指的是约定一种接口规范,屏蔽掉其他的差异,依赖于接口,而非具体实现的类。强调上下游的稳定性,降低耦合度,提高扩展,关注抽象和实现。

KISS & YAGNI & DRY

Kiss - 如何做,尽量保持简单

  • Keep it Simple & Short
  • Keep it Short & Simple
  • Keep it Simple & straightforward

YAGNI - 要不要做,不要过度设计

  • You ain't gonna need it
  • 预留合理的拓展点,但是不要提前设计编码本不需要的代码

DRY - 重复代码

  • Don’t repeat yourself.

  • 典型的情况

    • 实现逻辑重复: 是否违反 DRY,要根据实际场景来分析,避免违反了单一职责和接口隔离。同时可以考虑在更小维度下进行拆分,拆分成细粒度的导函数来解决。e.g. 检查 username 和检查 password,虽然可能内容是一样的,但是本质是校验不同的内容。
    • 功能语义重复:如果最后达成的目标是一致的,那么就是违反了 DRY。e.g. 有两个不同方式实现用来检验 ip 地址是否合法,这个地方两个方法虽实现方式,但是最后都是用来校验 ip 地址,那么就是违反了 DRY。
    • 代码执行重复: 可能在一个串行执行的程序中,对相同的内容检验了两次,导致代码执行重复。这个时候可以考虑将其抽到顶层作为入参校验,避免两次重复校验。
  • 如何提高代码复用性

    • 减少耦合,避免牵一发而动全身的模块代码。
    • 满足单一职责,避免大而全。
    • 模块化
    • 业务和非业务的代码逻辑进行分离。
    • 通用代码下沉,杜绝下层代码调用上层代码。从分层的角度而言,越是底层的代码越是具有通用性。
    • 封装、抽象、继承、多态
    • 应用模板模式等设计模式
  • When?

    • 过早重构反而会有错误抽象的风险,当发生变更时,反而引入更多错误。Attempting premature refactoring risks selecting a wrong abstraction, which can result in worse code as new requirements emerge[2]  and will eventually need to be refactored again.
    • Rules of Three: 当一个方法重复了2次,即将重复第3次的时候,可以考虑将其重构。但是注意,这个 3 并非严格意义的3,而是当有复用需要的时候,即可以考虑对其进行重构。link: en.wikipedia.org/wiki/Rule_o…

迪米特法则, 最小知识法则 Least Knowledge Principle, LKP

  • key point:

    • 减低类间的耦合度,提高模块相对的独立性。实现高内聚,松耦合。
    • 目标是提高代码可读性和可维护性,缩小功能改动导致的代码改动范围。
    • Each unit should ONLY talk to its friends, DON’T talk to strangers. 不该有直接依赖的类,不要直接依赖;有依赖关系的类,尽量只依赖必要的接口。
  • benefit: 援引子空间聚类目标,类内高度聚集,类间松散连接

    • 松耦合:知道类与类之间的依赖关系,降低类间的耦合度,提高模块相对独立性
    • 高内聚:指导类内设计,将相近的内容聚合到类内,提高类的可复用性和系统拓展性
  • How: 从依赖者的角度来说,只依赖应该依赖的对象;从被依赖者的角度说,只暴露应该暴露的方法。

    • 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
    • 在类的结构设计上,尽量降低类成员的访问权限。
    • 在类的设计上,优先考虑将一个类设置成不变类
    • 在对其他类的引用上,将引用其他对象的次数降到最低。
    • 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
    • 谨慎使用序列化(Serializable)功能。

合成复用法则 Composite Reuse Principle, CRP

  • key point:

    • 尽量先使用组合或者聚合的关联方式来实现,之后再考虑继承关系来实现
    • 要使用继承关系,要严格遵循 里式替换原则 LSP 来实现
  • different with 复用类型:

    • 合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。
    继承复用组合复用
    封装性继承会将父类内容暴露给子类,父类对于子类来说是透明的,因此称之为“白箱复用”维持了类的封装性,成分对象内部对新对象而言不可见,所以又称之为“黑箱复用”
    耦合度父类的任何变更,都会导致子类发生变更,不利于类的拓展和维护新旧类耦合性低,新对象存取成分对象的方式就是通过成分对象的接口
    灵活性父类继承而来的实现是静态的,在编译时已经确定,所以在运行时不可能发生变化可以在运行时动态变化,新对象可以动态地引用与成分对象类型相同的对象

For more information:

设计模式文章 Index