面向对象设计原则

面向对象设计原则是学习设计模式的基础,每一种设计模式都符合某一种或多种面向对象设计原则。通过在软件开发中使用这些原则可以提高软件的可维护性和可复用性,让我们可以设计出更加灵活也更容易扩展的软件系统,实现可维护性复用的目标。

面向对象设计原则综述

设计原则名称 设计原则简介 重要性
单一职责原则
Single Responsibility Principle, SRP
类的职责要单一,不能将太多的职责放在一个类中 ★★★★☆
开闭原则
Open-Closed Principle, OCP
软件实体对扩展是开放的,但对修改是关闭的,即在不修改一个软件实体的基础上去扩展其功能 ★★★★★
里氏代换原则
Liskov Substitution Principle, LSP
在软件系统中,一个可以接受基类对象的地方必然可以接受一个子类对象 ★★★★☆
依赖倒转原则
Dependence Inversion Principle, DIP
要针对抽象层编程,而不要针对具体类编程 ★★★★★
接口隔离原则
Interface Segregation Principle, ISP
使用多个专门的接口来取代一个统一的接口 ★★☆☆☆
合成复用原则
Composite Reuse Principle, CRP
在系统中应该尽量多使用组合和聚合关联关系,尽量少使用甚至不使用继承关系 ★★★★☆
迪米特法则
Law of Demeter, LoD
一个软件实体对其他实体的引用越少越好,或者说如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,而是通过引入一个第三者发生间接交互 ★★★☆☆

单一职责原则

定义

  • 一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
  • 就一个类而言,应该仅有一个引起它变化的原因

分析

  • 一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小。
  • 类的职责主要包括两个方面:数据职责和行为职责,数据职责通过其属性来体现,而行为职责通过其方法来体现。
  • 单一职责原则是实现高内聚、低耦合的指导方针

例子

电话通话的时候有四个过程发生:拨号、通话、回应、挂机。

1
2
3
4
5
6
7
8
public interface IPhone {
//拨通电话
public void dial(String phoneNumber);
//通话
public void chat(Object o);
//通话完毕,挂电话
public void hangup();
}

当前 IPhone 这个接口包含了两个职责:一个是协议管理,一个是数据传送。dial()hangup() 两个方法实现的是协议管理,分别负责拨通和挂机;chat() 实现的是数据传输,把我们说的话转换成模拟信号或数字信号传递到对方,然后再把对方传递过来的信号还原成我们听得懂的语言。而且这两个职责变化互不相关,那就考虑拆分成两个接口,类图如下:

这个类图完全满足了单一职责原则,但一个手机类要把 ConnectionManagerDataTransfer 组合在一起才能使用。组合是一种强耦关系,而且还增加了类的复杂性,多了两个类。

这样设计才是完美的,一个类实现了两个接口,把两个职责融合在一一个类中。虽然 Phone 中有两个原因引起变化,但我们是面向接口编程【后面依赖倒置原则会提到】,我们对外公布的是接口不是实现类。而且如果真的要实现类的单一职责,这就必须使用上面的组合模式了,这会引起类间耦合过重、类的数量增加等问题,人为增加设计的复杂性。

注意

  • 单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
  • 对于单一职责原则,建议是接口一定要做到单一职责原则,类的设计尽量做到只有一个原因引起变化。

里氏替换原则

定义

  • 如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有变化,那么类型S是类型T的子类型。
  • 所有引用基类(父类)的地方必须能透明地使用其子类的对象。

分析

  • 更通俗来讲子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
    • 子类必须完全实现父类的方法
    • 子类可以拥有自己的个性【尽量避免】
    • 重载(Overload)或实现父类的方法时输入参数【前置条件】可以被放大【相同或者更加宽松】
    • 覆写(Override)或实现父类的方法时输出结果【后置条件】可以被缩小【范围值相同或更小】

助解类图

例子

我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。

注意

  • 在类中调用其它类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
  • 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚合、组合等关系代替继承。
  • 在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调。把子类当做父类使用,子类的“个性”就会被抹杀;而把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离 —— 缺乏类替换的标准。

依赖倒转原则

定义

  • 高层模块不应该依赖低层模块,它们都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
  • 要针对接口编程,不要针对实现编程。

分析

  • 简单来说,依赖倒转原则就是指:代码要依赖于抽象的类,而不要依赖于具体的类;要针对接口或抽象类编程,而不是针对具体类编程
  • 在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中:
    • 构造注入(Constructor Injection):通过构造函数注入实例变量。
    • 设值注入(Setter Injection):通过Setter方法注入实例变量。
    • 接口注入(Interface Injection):通过接口方法注入实例变量。

例子

参考 单一职责原则 例子。

注意

  1. 每个类尽量有接口或抽象类,或者抽象类和接口两者具备。
    【尽量而已,如一些工具类 xxxUtils 不需要接口或抽象类】
  2. 变量的表面类型尽量是接口或抽象类。
    【表面类型是在定义的时候赋予的类型,实际类型是对象的类型】
  3. 任何类都不应该从具体类派生。
    【有时设计缺陷在所难免,因此只要不超过两层的继承都是可以忍受的】
  4. 尽量不要覆写基类的方法
    【如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写;类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响】
  5. 结合里氏替换原则使用
    【接口负责定义 public 属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现。实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化】
  6. 在项目中,只要记得“面向接口编程”就基本上掌握了依赖倒置原则的核心。
  7. 在现实中也存在必须依赖细节的事物,具体问题具体分析。

接口隔离原则

定义

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

分析

  • 使用接口隔离原则拆分接口时,首先必须满足单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。
  • 可以在进行系统设计时采用定制服务的方式,即为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。

例子

胖接口原始类图

胖接口细化后的系统类图

注意

  • 保证接口的纯洁性
    1. 接口要尽量小【“小”是有限度的,根据接口隔离原则拆分接口时,首先必须满足单一职责原则】
    2. 接口要高内聚【高内聚就是提高接口、类、模块的处理能力,减少对外的交互;具体到接口隔离原则就是,要求在接口中少公布 public 方法,接口是对外的承诺,承诺越少对系统开发越有利,变更的风险也就越少,同时利于降低成本】
    3. 定制服务【定制服务就是单独为一个个体提供优良服务:只提供访问者需要的方法】
    4. 接口的设计是有限度的【接口的设计粒度越小,系统越灵活但结构会出现复杂化,开发难度增加,可维护性降低】
  • 已经被污染的接口,尽量去修复;若变更的风险较大,则采用适配器模式进行转化处理。

迪米特法则

定义

  • 不要和“陌生人”说话。
  • 只与你的直接朋友通信。
  • 每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

分析

  • 在迪米特法则中,对于一个对象,其朋友包括以下几类:
    1. 当前对象本身(this);
    2. 以参数形式传入到当前对象方法中的对象;
    3. 当前对象的成员对象;
    4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
    5. 当前对象所创建的对象。
  • 任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。
  • 迪米特法则的主要用途在于控制信息的过载
    • 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;
    • 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;
    • 在类的设计上,只要有可能,一个类型应当设计成不变类;
    • 在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

例子

某系统界面类(如 Form1Form2 等类)与数据访问类(如 DAO1DAO2 等类)之间的调用关系较为复杂,如图所示:

使用迪米特法则之后:

注意

  • 类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象。
  • 朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。
  • 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。
  • 迪米特法则可分为狭义法则和广义法则。在狭义的迪米特法则中,如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
    1. 狭义的迪米特法则:可以降低类之间的耦合,但是会在系统中增加大量的小方法并散落在系统的各个角落,它可以使一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联,但是也会造成系统的不同模块之间的通信效率降低,使得系统的不同模块之间不容易协调。
    2. 广义的迪米特法则指对对象之间的信息流量、流向以及信息的影响的控制,主要是对信息隐藏的控制。信息的隐藏可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化、使用和修改,同时可以促进软件的复用,由于每一个模块都不依赖于其他模块而存在,因此每一个模块都可以独立地在其他的地方使用。一个系统的规模越大,信息的隐藏就越重要,而信息隐藏的重要性也就越明显。
  • 迪米特法则的核心观念就是类间解耦,弱耦合;只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。所以在使用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。

开闭原则

定义

  • 一个软件实体应当对扩展开放,对修改关闭。也就是说在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即实现在不修改源代码的情况下改变这个模块的行为。

分析

  • 在开闭原则的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
  • 抽象化是开闭原则的关键。
  • 开闭原则还可以通过一个更加具体的“对可变性封装原则”来描述,对可变性封装原则(Principle of Encapsulation of Variation, EVP)要求找到系统的可变因素并将其封装起来。

例子

某图形界面系统提供了各种不同形状的按钮,客户端代码可针对这些按钮进行编程,用户可能会改变需求要求使用不同的按钮,原始设计方案如图所示:

现对该系统进行重构,使之满足开闭原则的要求。

注意

  • 如何使用开闭原则:
    1. 抽象约束:第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不予许出现在接口或抽象类中不存在的public方法;第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。
    2. 元数据(metadata)控制模块行为【元数据用来描述环境和数据的数据,简单来说就是配置参数】
    3. 制定项目章程
    4. 封装变化:第一,将相同的变化封装到一个接口或一个抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。

合成复用原则

定义

  • 尽量使用对象组合,而不是继承来达到复用的目的。

分析

  • 在面向对象设计中,可以通过两种基本方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承
    • 继承复用:实现简单,易于扩展。破坏系统的封装性;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;只能在有限的环境中使用。(“白箱”复用 )
    • 组合/聚合复用:耦合度相对较低,选择性地调用成员对象的操作;可以在运行时动态进行。(“黑箱”复用 )
  • 组合/聚合可以使系统更加灵活,类与类之间的耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般首选使用组合/聚合来实现复用;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用

例子

某教学管理系统部分数据库访问类设计如图所示:

如果需要更换数据库连接方式,如原来采用JDBC连接数据库,现在采用数据库连接池连接,则需要修改 DBUtil 类源代码。如果 StudentDAO 采用 JDBC连接,但是 TeacherDAO 采用 连接池连接,则需要增加一个新的 DBUtil 类,并修改 StudentDAOTeacherDAO 的源代码,使之继承新的数据库连接类,这将违背开闭原则,系统扩展性较差。
现使用合成复用原则对其进行重构。

注意

  • 只有当以下的条件全部被满足时,才应当使用继承关系。
    1. 子类是超类的一个特殊种类,而不是超类的一个角色,也就是区分“Has-A”和“Is-A”.只有“Is-A”关系才符合继承关系,“Has-A”关系应当使用聚合来描述。
    2. 永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
    3. 子类具有扩展超类的责任,而不是具有置换掉或注销掉超类的责任。如果一个子类需要大量的置换掉超类的行为,那么这个类就不应该是这个超类的子类。

参考资料

文章目录
  1. 1. 面向对象设计原则综述
  2. 2. 单一职责原则
    1. 2.1. 定义
    2. 2.2. 分析
    3. 2.3. 例子
    4. 2.4. 注意
  3. 3. 里氏替换原则
    1. 3.1. 定义
    2. 3.2. 分析
    3. 3.3. 例子
    4. 3.4. 注意
  4. 4. 依赖倒转原则
    1. 4.1. 定义
    2. 4.2. 分析
    3. 4.3. 例子
    4. 4.4. 注意
  5. 5. 接口隔离原则
    1. 5.1. 定义
    2. 5.2. 分析
    3. 5.3. 例子
    4. 5.4. 注意
  6. 6. 迪米特法则
    1. 6.1. 定义
    2. 6.2. 分析
    3. 6.3. 例子
    4. 6.4. 注意
  7. 7. 开闭原则
    1. 7.1. 定义
    2. 7.2. 分析
    3. 7.3. 例子
    4. 7.4. 注意
  8. 8. 合成复用原则
    1. 8.1. 定义
    2. 8.2. 分析
    3. 8.3. 例子
    4. 8.4. 注意
  9. 9. 参考资料

20160502-DesignPattern-2/

本页二维码