面向对象设计原则

1 概述

软件的可维护性(Maintainability)和可复用性(Reusability)是两个非常重要的用于衡量软件质量的属性,软件的可维护性是指软件能够被理解、改正、适应及扩展的难易程度,软件的可复用性是指软件能够被重复使用的难易程度。 – Java设计模式

2 SRP 单一职责原则

单一职责原则是最简单的面向对象设计原则,用于控制类的粒度大小

单一职责原则定义:一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。

单一职责模式的另一个定义:对于一个类来说,应该仅有一个引起它变化的原因(There should never be more than one reason for a class to change)

分析

  1. 一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小
  2. 当一个职责变化时,可能会影响其他职责的运作
  3. 将这些职责进行分离,将不同的职责封装在不同的类中
  4. 将不同的变化原因封装在不同的类中
  5. 单一职责原则是实现高内聚、低耦合的指导方针

2.1 例子

某软件公司开发人员针对CRM(Customer Relationship Management, 客户关系管理)系统中的客户信息图形统计模块提出了一种设计方案,如下图所示:

绿色代表方法是public的,冒号后面跟随的是返回值

CustomerDataChart类的方法中,getConnection()方法用于连接数据库,findCustomers()用于查询所有的客户信息,createChart()用于创建图标,displayChart()用户显示图表。可以看到,该类中聚集了与三个实体有关的功能,这对于我们复用其中的代码十分的不利,如果某一天另外一个类也需要连接数据库,如果它选择组合该类,则该类的其他三个方法对它来说都是冗余的,不符合我们面向对象的设计原则,我们可以用单一职责原则来进行重构,下面是我们设计的思路:

  1. DBUtil:该类负责连接数据库,包含数据库连接方法getConnection()
  2. CustomerDAO:该类负责操作数据库中的Customer表,包含对Customer表的增删改查的功能,比如说findCustomers()
  3. CustomerDataChart:该类负责图标的生成和现实,包含createChart()displayChart()方法。

重构后的类图如下图所示:

3 OCP 开放-封闭原则

开闭原则定义:软件实体应当对扩展开放对修改关闭

软件实体:软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。开闭原则就是指软件实体应尽量在不修改原有代码的情况下进行扩展。

开闭原则是面向对象的可复用设计的第一块基石,是最重要的面向对象设计原则。

分析

  1. 抽象化是开闭原则的关键
  2. 相对稳定的抽象层 + 灵活的具体层
  3. 对可变性封装原则:找到系统的可变因素并将其封装起来

4 LSP 里氏替换原则

里氏替换原则定义:如果对每一个类型为 SS 的对象 o1o_1 ,都有 TT 的对象 o2o_2,使得以 TT 定义的所有程序 PP 在所有的对象 o1o_1 都代换 o2o_2 时,程序 PP 的行为没有变化,那么类型 SS 是类型 TT 的子类型。

即:所有引用基类的地方必须能透明地使用其子类的对象

里氏代换原则表名,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立。如果一个软件实体使用的是一个子类对象,那么它不一定能够使用基类对象。举个例子,比如说我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能断定我喜欢动物。

里氏代换原则是实现开闭原则的重要方式之一,由于在使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

运用了运行时动态绑定的特性,这在面向对象语言中有着很大的体现,比如在Java中,我们一般会使用接口或者父类来定义某个变量,方便日后子类扩展而不用修改原代码。

使用里氏代换原则时应该将父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,在运行时子类实例替换成父类实例。

5 DIP 依赖倒置原则

依赖倒置原则定义:高层模块不应该依赖底层模块,它们都应该依赖抽象。抽象不应该依赖细节,细节应该依赖于抽象。

要针对接口编程,不要针对实现编程

Program to an interface, not an implementation

分析

  1. 在程序代码中传递参数或在关联关系中,尽量使用层次高的抽象类层,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等。

  2. 在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中。

  3. 在实现依赖倒置原则时需要针对抽象层编程,而将具体类的对象通过依赖注入(Dependence Injection, DI)的方式注入到其它对象中。依赖注入是指当一个对象要与其他对象发生依赖关系时采用抽象的形式来注入所依赖的对象。

    依赖注入是Spring中的关键特性。常用的注入方式有三种,分别是构造注入,设值注入(Setter注入)和接口注入。构造注入是指通过构造函数来传入具体类的对象,设值注入是指通过Setter方法来传入具体类的对象,而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。

Depend on abstractions – 依赖于抽象

不应该依赖于具体类 – 程序中所有的依赖关系都应该终止与抽象类或接口

  • 任何变量都不应该持有一个指向具体类的指针或引用
  • 任何类都不应该从具体类派生
  • 任何方法都不应该覆写它的任何基类中的已经实现了的方法
  • 例外:可以依赖稳定的具体类,比如String

好处

  • 依赖关系的倒置正是好的面向对象设计的标志所在。
  • 如果程序的依赖关系是倒置的,它就是面向对象的设计,否则就是过程化的设计。
  • DIP是实现许多OO技术所宣称的好处的基本底层机制。它的正确应用对于创建可重用的框架来说是必须的。

5.1 例子

某软件公司开发人员在开发CRM系统时发现该系统经常需要将存储在TXT或Excel文件中的客户信息转存到数据库中,因此需要进行数据格式转换,在客户数据操作类CustomerDAO中调用数据格式转换类的方法来实现格式转换,初始设计方案如图所示:

这种设计方法有一个问题,就是在每次转换数据的时候数据来源不一定相同,所以需要在源代码中更换数据转换类,比如说有时候需要将TXTDataConvertor改为ExcelDataConvertor,需要修改CustomerDAO的源代码,并且如果加入新的数据转换类时也需要修改源代码,扩展性很差,违反了开闭原则,所以我们可以对该方案进行重构,重构的方案如下:

然后通过xml配置文件来注入数据转换类。

6 ISP 接口隔离原则

接口隔离原则定义:客户端不应该依赖那些它不需要的接口

分析

  1. 当一个接口太大时,需要将它分割成一些更细小的接口
  2. 使用该接口的客户端仅需知道与之相关的方法即可
  3. 每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干
  4. 接口定义(1):一个类型所提供的所有方法特征的集合。一个接口代表一个角色,每个角色都有它特定的一个接口,“角色隔离原则
  5. 接口定义(2):狭义的特定语言的接口。接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口,每个接口中只包含一个客户端所需的方法,“定制服务

使用接口隔离原则时需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中的接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,在接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些他们不用的方法。

6.1 例子

某软件公司开发人员针对CRM系统的客户数据显示模块设计了下图的CustomerDataDisplay接口,其中方法readData()用于从文件中读取数据,方法transformToXML()用于将数据转换成XML格式,方法createChart()用于创建图表,方法displayChart()用于显示图标,方法createReport()用于创建文字报表,方法displayReport()用于显示文字报表。

在使用过程中发现该接口很不灵活,对实现类来说还需要实现很多自己并不需要的功能,可以用接口隔离原则来进行重构,方案如下图所示:

7 CARP 合成/聚合复用原则

合成复用原则定义:合成复用原则又称为组合/聚合复用原则。优先使用对象组合,而不是继承来达到复用的目的。

Composition(合成) vs Aggregation(聚合)

  • 聚合表示 “拥有” 关系或者整体与部分的关系
  • 组合是一种强得多的 “拥有” 关系 – 整体和部分的生命周期是一样的(两者在内存中在同一块空间里)
  • 换句话说:组合是值的聚合,而一般说的聚合是引用的聚合(被聚合的对象在另一块空间,不会随着该对象生命周期的结束而消失)

复用的基本种类

  1. 合成/聚合复用:将已有对象纳入新对象中,使之成为新对象的一部分
  2. 继承

继承的优点:

  • 新类易实现
  • 易修改或扩展

继承的缺点:

  • 继承复用破坏包装,白箱复用。
  • 超类发生变化,子类不得不改变
  • 继承的实现是静态的,不能在运行时改变

合成/聚合的优点

  • 新对象存取成分对象的唯一方法是通过成分对象的接口
  • 黑箱复用,因为成分对象的内部细节是新对象所看不见的。
  • 支持包装
  • 所需的依赖较少
  • 每一个新的类可以将焦点集中在一个任务上。
  • 这些复用可以在运行时间内动态进行,新对象可以动态的引用与成分对象类型相同的对象。
  • 作为复用手段可以应用到几乎任何环境中去

合成/聚合的缺点:系统中会有较多的对象需要管理

优先使用对象合成/聚合,而不是继承

利用合成/聚合可以在运行时动态配置组件的功能,并防止类层次规模的爆炸性增长。

区分 HAS-A 和 IS-A

7.1 Code法则:什么时候使用继承作为复用的工具

  • 子类是超类的一个特殊种类,而不是超类的一个角色,也就是区分 “Has-A” 和 “Is-A”。只有 “Is-A” 关系才符合继承关系, “Has-A” 关系应当用聚合来描述
  • 永远不会出现需要将子类换成另一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
  • 子类具有扩展超类的责任,而不是具有置换掉(override)或注销掉(nullify)超类的责任。如果一个子类需要大量的置换掉超类的行为,那么这个类就不是这个超类的子类。
  • 只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。

8 LoD 迪米特法则

迪米特法则又称为最少知识原则(Least Knowledge Principle, LKP)

迪米特法则的定义:每一个软件单位对其他的单位都只有最少的知识,并且局限于那些与本单位密切相关的软件单位。

LoD的实质是控制对象之间的信息流量,流向及信息的影响 – 信息隐藏

  • 在类的划分上,应当创建有弱耦合的类,类之间的耦合越弱,就越有利于复用。
  • 在类的结构设计上,每一个类都应当尽量降低成员的访问权限。一个类不应当public自己的属性,而应当提供取值和赋值的方法让外界间接访问自己的属性。
  • 在类的设计上,只要有可能,一个类应当设计成不变类。
  • 在对其它对象的引用上,一个类对其它对象的引用应该降到最低。
文章作者: ZY
文章链接: https://zyinnju.com/2022/03/17/Object-oriented-Design-Principle/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ZY in NJU