面向对象设计原则

可维护性和可复用性

原因

两者某种程度上可以等同,我们一般希望达成的是可复用的可维护性
软件的可维护性较低,通常由下面四个原因构成

  • 僵硬:很难添加一个新的功能,造成代码的改动很大,所有的修改都要基于源代码的改动;与可拓展性相对
  • 脆弱:和过于僵硬同时存在;与灵活性相对
  • 低复用:组成部分很难在其他部分或者不同项目进行使用(黑盒)
  • 黏度过高:架构层面上,如果需要破坏原始的框架和结构;与可插入性相对

重构

调整程序代码改善软件的质量

单一职责原则

定义

  • 一个对象只包含单一的职责,职责被完整的封装在一个类中;就像链表的添加删除获取等职责
  • 就一个类而言,应该只有一个引起它变化的原因,也就是只对单一的职责负责

分析

  • 一个类承担的职责越多,被复用的可能性越小,当职责越来越多,就相当于职责之间的耦合越来越多,一个职责的变化就会影响其他职责的运作
  • 数据职责和行为职责,数据职责通过属性来体现,行为职责通过方法来体现
  • 高内聚,低耦合,就像 迭代器现在被独立出来成为一个类,专门负责迭代

举例

不要让login又负责连接又负责登陆校验

image.png image.png

开闭原则

定义

  • 一个软件实体应该对拓展开放,对修改关闭。软件实体可以指一个软件模块,一个由多个类组成的局部结构或一个独立的类
  • 也就是说,在设计一个模块的时候,这个模块可以在不被修改的前提下被扩展
  • 也就是,在增加一个功能的时候,可以在不修改源代码的情况下改变这个模块的行为,通过新的代码实现

分析

  • 抽象化,抽象类和具体类,具体类实现具体的功能
  • 对可变性封装原则,找到系统的可变因素并将其封装起来,当你预判可能会发生变化的时候,使用抽象

举例

image.png

当修改一个需求的时候,需要修改源代码,例如改变Button的形状

image.png

表驱动也是类似的思想

里式代换原则

定义

  • 如果对于每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2的时候,程序P的行为没有变化,那么类型S是类型T的子类型

  • 也就是说,所有引用基类的地方,都能透明的使用子类的对象

分析

  • 在软件中,如果能够使用基类对象,那么一定能够使用其子类对象。也就是说,把基类都变成它的子类,程序将不会产生任何错误和异常
  • 里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中,尽量使用基类类型来对对象进行定义,而在运行的时候,再确定其子类类型,用子类对象来替换父类对象
  • 不应该给子类添加新的公开方法(私有方法可以添加,被共有方法调用),子类应该是对父类功能的复用,新增父类的功能可以使用组合而不是继承
  • 增加新的功能,就只用增加新的子类
  • 子类可以被其他子类进行替换

举例

image.png

需要增加新的加密方法就需要增加DataOperator中的代码,违背了开闭原则,可以将其封装起来,并且保证抽象类和具体类之间满足里氏代换原则,接口需要一样(子类实现的方法,父类中都有声明)

image.png

也可以实现abstractCipher,增加新的功能就增加新的子类

依赖倒转原则

定义

Dependence Inversion Principle

  • 高层模块不应该依赖底层模块,都应该依赖抽象,抽象不应该依赖于细节,细节应该依赖于抽象
  • 要针对接口编程,不要针对实现编程
  • 高层和细节都依赖抽象,一种倒置关系

分析

  • 开闭原则是面向对象设计的目标,依赖倒转原则就是面向对象设计的主要手段
  • 也就是说,代码依赖于抽象的类,而不是具体的类;针对接口或者抽象类编程而不是针对实现编程
  • 只要发生了耦合关系的地方,就尽量使用抽象作为依赖

常用实现方式

  • 在代码中使用抽象类,将具体类放进配置文件中,“将抽象放进代码,将细节放进元数据”
  • 在程序中尽量不说明具体的实现,而在运行时通过文件才能体现,系统需要发生变化的时候,修改配置文件就可以满足需求

举例

image.png

以上都是面向具体编程的,当需要添加新的数据源和新的文件格式的时候,客户类和Main都需要修改源代码,以便使用新的类。(当然,下图也可以将抽象类换为接口,接口是对行为的封装)

image-20210624121413034

接口隔离原则

定义

  • 客户端不应该依赖那些它不需要的接口
  • 一旦一个接口太大,需要将它分割为一些更细小的接口,使用该接口的客户端仅需知道与它相关的方法

分析

  • 使用多个专门的接口,而不是用单一的总接口,每一个接口应该承担一种相对独立的角色,一个行为
  • 接口仅仅提供客户端需要的行为,将客户端不需要的行为隐藏起来,所以要尽量提供小的接口
  • 如果一个实现类只用一个功能,一个方法,一个行为,那么就提供只包含着一个行为的接口

举例

三角形+虚线代表实现
三角形+实现代表继承

image.png

由于是共有的方法,无法限制ClientA使用OperatorB和OperatorC

image.png

合成复用原则

定义

  • 组合/聚合复用原则

  • 在一个新的对象里通过关联关系来使用一些已有的对象,使之成为新对象的一部分,新对象通过调用已有对象的方法达到复用其已有功能的目的,即尽量使用对象组合/聚合has a而不是继承is a来达到复用的目的

分析

继承

  • 继承实现的复用是一种透明的复用,白箱复用(类似于代码的拷贝),继承的实现是静态的,不能实时发生改变
  • 一旦发生基类的改变,所有的派生类都会收到影响

组合

  • 内部细节对于新对象是不可见的,黑箱复用
  • 旧的成员的变化对于新对象是影响不大的,只用保证接口不变就行
  • 新对象可以动态引用,更有一定的灵活性

总结

  • 一般来说首选组合/聚合来实现复用,复用接口的实现,降低类之间的耦合度
  • 其次再考虑继承,在使用继承的时候,需要严格遵守里氏代换原则,复用接口的定义,父类确定不会有变化的部分才会使用子类进行继承

举例

  • StudentDAO和TeacherDAO复用getConnection这个方法
  • 但是如果更换了数据库的连接方式,需要更改DBUtil的源代码
  • 如果StudentDAO和TeacherDAO需要编程不同的数据库连接方式,需要增加新的DBUtil类别,并且修改子类的源代码,使他们分别继承不同的数据库类,违背了开闭原则
image.png image.png
  • 增加新的实现,就可以增加新的DBUtil的子类,甚至是放在配置文件中(不用改源代码),这里也有依赖倒转原则

迪米特法则(最小知识原则)

定义

  • 不要直接和陌生人说话
  • 只与你的直接朋友通信
  • 每一个软件单位都受限制的了解其他的单位,并且还仅仅局限于那些与本单位密切相关的软件单位

分析

  • 简单的说,就是一个软件实体应该尽可能少的与其他实体发生相互作用
  • 这样当一个模块修改的时候,就会尽量少的影响其他的模块,拓展相互容易

所谓的朋友

  • 对象本身
  • 以参数形式传入到当前对象方法中的对象
  • 成员对象,以及集合型成员对象中的元素
  • 当前对象创建的对象

狭义

image.png
  • 如果两个类不必彼此直接通信,那就不应当发生直接的相互作用,一个类需要调用另一个类的某一个方法的时候,可以通过第三者转发这个调用
  • 降低类之间的耦合,但是会增加大量的小方法,可以让一个系统的局部设计简化,因为每一个局部都不会与远距离的对象有直接的关联,但是会造成系统的不同模块的通信效率降低,不容易协调,(方法的调用是一个复杂的过程,付出了性能的代价)

广义

  • 对对象之间的信息流量、流向以及信息的影响的控制,主要是对于信息隐藏的控制,便于各个子系统之间脱耦,从而允许他们独立的开发,优化,使用和修改,同时可以促进软件的复用。
  • 由于每个模块不依赖其他模块而存在,因此每一个模块都可以独立地在其他的地方使用

用途

控制信息的过载

  • 创建低耦合的类,易于复用,并且便于修改
  • 每一个类应该尽量降低成员变量和成员函数的访问权限
  • 只要有可能,一个类应该设计成不变类;并且对于其他类的引用上,一个对象对于其他对象的引用应该降到最低

举例

调用关系较为复杂,找朋友,通过第三者转发

image.png image.png

小结

目标:开闭原则

指导:最小知识原则

基础:单一职责原则、可变性封装原则

实现:依赖倒转原则、合成复用原则、里式代换原则、接口隔离原则