【从0开始学设计模式-2| 面向对象设计原则】

前言

年过完了,调整调整继续学习!说完设计模式的简介以及UML图,这一篇主要是说一下面向对象设计原则

上一篇连接【从0开始学设计模式-1| 设计模式简介、UML图】

介绍

代码复用跟扩展性是优秀设计的特征,面向对象设计原则为支持可维护性复用而诞生,这些原则蕴含在很多设计模式中,它们是从许多设计方案中总结出的指导性原则。

主要分为这些原则:

面向对象设计原则也是后续设计模式学习的基础,每一个设计模式都符合一个或多个设计原则,是评价设计模式使用效果的重要指标之一。一定要搞清楚面向对象设计原则跟设计模式的关系

例如:

  • 适配器模式 ,遵循了开闭原则 (OCP) 跟 单一职责原则 (SRP)
  • 工厂方法模式,遵循了依赖倒置原则 (DIP) 跟 开闭原则 (OCP)
  • 等等

下面具体介绍每一个原则。

SOLID原则

单一职责原则(SRP)

介绍

单一原则 (Single Responsibility Principle, SRP)是最简单的设计模式,他主要用来控制类的颗粒度大小。

也就是说一个对象应该只包含单一的职责,且该职责被封装到一个类当中。

错误示例

下面看一个错误示例:

我们不难发现RoleDataOperation类 具有数据库连接、数据库数据操作、业务操作等多个职责,这不符合单一设计原则

修改

分成四个类:

  • RoleDataOperation 只负责调用DAO提供的功能来完成"存档"和"读档"的业务。
  • GameRoleDAO 只负责执行具体的增删改查业务
  • DbHelper 只负责 getConnection,数据库连接

每个类都只有单一的职责,这样就满足了单一职责原则

开闭原则(OCP)

介绍

开闭原则 (Open-Closed Principle , OCP)是面向对象的可复用设计的第一块基石,它是 最重要 的面向对象设计原则。

通俗的讲,也就是说你可以添加代码,但你不能修改代码

开闭原则是评价基于某个设计模式设计的系统是否具备灵活性和可扩展性的重要依据。

错误示例

下面来模拟一个刷怪塔:

看一下createMonster的伪代码:

java 复制代码
Monster monster = null; 

if (type == 1) {
    Boss boss = new Boss();
    // Boss 扩展处理
    monster = boss; 
} else if (type == 2) {
    Elite elite = new Elite();
    // 精英怪 拓展处理
    monster = elite;
} else if (type == 3) {
    Soldier soldier = new Soldier();
    // 普通怪 拓展处理 
    monster = soldier;
}

// 怪物对象通用处理
// ...

此时的问题就出现了,如果我们要新增一种怪物,那么就需要修改createMonster的业务代码,例如在后面再硬生生塞进一个 else if (type == 4),但这就违背了开闭原则

修改

将Monster定义成一个抽象类,作为基类,

java 复制代码
public abstract class Monster {
    ......
}

其他具体怪物类型继承这个类

java 复制代码
// Boss 类
public class Boss extends Monster {
    ......
}

// 精英怪类
public class Elite extends Monster {
   ......
}

// 普通小怪类
public class Soldier extends Monster {
  ......
}

将createMonster的参数变成基类类型,面向接口编程

java 复制代码
public class MonsterTower {
    
    /**
     * 参数类型为 Monster,这意味着你可以传入 Boss, Elite 或 Soldier 中的任何一个
     */
    public void createMonster(Monster monster) {
        //调用怪物自己的逻辑
        monster.对应方法;
       ......
    }
}

Java 在运行时能识别出传进来的是 Boss 还是 Elite,并调用正确的方法。

这样修改之后我们在添加一个怪物类的时候,完全就不需要修改MonsterTower类的代码,只需要创建出具体的类继承基类Monster即可,完全符合开闭原则

里氏替换原则(LSP)

介绍

里氏代换原则 (Liskov Substitution Principle, LSP),严格的描述如下:

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

通俗的来说:所有引用基类(父类)的地方都能透明地使用其子类的对象替换且不会出错

注意 :反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。

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

错误示例

伪代码如下:

java 复制代码
//某个业务流程...
void fun() {
   /*兼容LSP设计原则的execute方法*/
    Product audio = new Audio();
    audio.setFilePath("audio.mp3");

    Product video = new Video();
    video.setFilePath("video.mp4");

    download(audio);
    download(video);
  
		 /* 不兼容LSP设计原则 */
    Product chocolate = new Chocolate();
    chocolate.setFilePath("Chocolate can not be downloaded.");
    
   // 呼叫下载方法下载巧克力产品
  download(chocolate);
  // 显然这里就出现问题了 [!]
}

思考:现在的问题是谁应该对违反LSP负责?

  • 是download() 函数的创建者吗?
  • 是产品类的创造者吗?
  • 还是巧克力类的创造者?

简而言之,责任在于子类(Chocolate)的实现者

虽然基类定义了继承关系,但 LSP 强调的是契约 。既然基类的文档明确约定了"只处理可下载产品且不可下载时需抛出异常",那么子类实现者在明知巧克力不可下载的情况下,仍强行继承并提供了一个"静默失效"(即只设置路径而不符合下载预期)的实现,这便直接破坏了基类承诺的契约,导致调用者(download 函数)的假设失效,从而违反了 LSP。

TIP: 常见的LSP违规
  • 子类中的退化方法 : 如果基类有一个方法,但基类的子类不需要该方法 ,那么如果子类的作者再次退化该方法,这将是可替代的违规。
  • 从子类抛出异常 : LSP违规的另一种形式是向子类添加异常,而基类不希望这样。因为那时基类 不能不能 被子类替代

核心其实就是 "契约编程" ,如果业务规定基类能执行某个操作,子类就必须实质性地完成这个操作,而不是空实现(退化方法)或拒绝执行(抛出异常)。 子类必须全盘接受并履行基类定义的业务规则,不能挑挑拣拣!否则调用者在不知情的情况下把子类当成基类用,程序就会出现问题。

比如上面的例子中, Chocolate(巧克力)继承 Product(产品)就是一个典型的**"为了复用而继承,却破坏了契约"**的错误。

依赖倒置原则(DIP)

介绍

依赖倒置原则 (Dependence Inversion Principle,DIP)是面向对象设计的主要实现机制之一,它是系统抽象化的具体实现。

也就是说:针对接口编程,而不是针对实现编程

依赖倒置原则要求我们在程序代码中传递参数时或在关联关系中尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。

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

常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入

  • 构造注入是指通过构造函数来传入具体类的对象;

    java 复制代码
    public class DataBiz {
      private final DataConvert converter; // 声明为 final 增加稳定性
    
      // 通过构造函数传入具体类的对象
      public DataBiz(DataConvert converter) {
        this.converter = converter;
      }
    
      public void execute(String data) {
        converter.parse(data);
      }
    }

    设值注入是指通过Setter方法来传入具体类的对象;

    java 复制代码
    public class DataBiz {
      private DataConvert converter;
    
      // 通过 Setter 方法传入具体类的对象
      public void setConverter(DataConvert converter) {
        this.converter = converter;
      }
    
      public void execute(String data) {
        if (converter != null) {
          converter.parse(data);
        }
      }
    }
  • 接口注入是指通过在接口中声明的业务方法来传入具体类的对象。

    java 复制代码
    // 定义一个包含业务方法的接口,用于传入依赖对象
    public interface IDataService {
      void process(DataConvert converter, String data);
    }
    
    public class DataBiz implements IDataService {
      @Override
      public void process(DataConvert converter, String data) {
        // 在业务方法执行时动态传入具体类型的对象
        converter.parse(data);
      }
    }

这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。

错误示例

上面示例中DataBiz是典型的针对具体实现进行编程,因此在数据格式变更的时候就需要反复的修改代码。

伪代码如下:

java 复制代码
public class DataBiz {
//具体实现类
private JsonConvert jsonConverter = new JsonConvert();
private XMLConvert xmlConverter = new XMLConvert();
private TextConvert textConverter = new TextConvert();

public void saveData(String data, String type) {
 RequestData result;
 // 通过硬编码的 switch-case 判断类型,如果新增格式必须改这里
 if (type.equals("JSON")) {
   result = jsonConverter.parse(data);
 } else if (type.equals("XML")) {
   result = xmlConverter.parse(data);
 } else {
   result = textConverter.parse(data);
 }
 // 执行保存逻辑...
}
}
修改

基于依赖倒置原则,新增一个抽象的转换器DataConvertDataBiz针对DataConvert进行编程,然后根据里氏替换原则,程序运行时对父类进行替换,根据开闭原则,将运行对象指定设置到配置文件中。

在上述重构过程中,我们使用了开闭原则、里氏代换原则和依赖倒转原则,在大多数情况下,这三个设计原则会同时出现,开闭原则是目标,里氏代换原则是基础,依赖倒置原则是手段,它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。

核心伪代码:

java 复制代码
// 抽象转换器:面向接口编程,具体的类继承这个类实现这个方法
public abstract class DataConvert {
    public abstract RequestData parse(String data);
}
java 复制代码
public class DataBiz {
  private DataConvert converter;

  // 注入抽象:体现了依赖倒置
  public void setConverter(DataConvert converter) {
    this.converter = converter;
  }

  public void saveData(String data) {
    // 运行时替换:体现了里氏替换
    // 无论 converter 是 XML 还是 Json,业务流程都能跑通
    RequestData requestData = converter.parse(data);
    ......
  }
}

.properties 文件中指定具体的实现类,实现真正的"解耦"。

properties 复制代码
# 如果想切回 XML,只需修改这一行
converter.class=com.example.JsonConvert

接口隔离原则(ISP)

介绍

接口隔离原则(Interface Segregation Principle, ISP),定义为:

客户端不应该依赖哪些它不需要的接口。

也就是说,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。

这里要注意**"接口"两种不同的含义:**

  • 一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象。在ISP可以理解成一种角色,一个接口只能代表一种角色,此时也可以称之为 "角色隔离原则"
  • 一种是指某种语言具体的"接口"定义,有严格的定义和结构(如Java中的interface)。在ISP中表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口.
错误示例

简单说明:

Dropbox服务商不提供createServer、listServers、getCDNAddress这三个服务。

问题: 一个接口承担了太多任务,违反了接口隔离原则

修改

在使用接口隔离原则时,我们需要注意控制接口的粒度:

  • 接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;
  • 接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。

一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些它们不用的方法

组合复用原则(CRP)

介绍

组合复用原则 (Composite Reuse Principle, CRP)又称为组合/聚合复用原则 (Composition/AggregateReuse Principle, CARP),其定义如下:

优先使用对象的组合,而不是使用继承来达到复用的目的

也就是说,设计中可以通过两种方法 在不同的环境中复用 已有的设计和实现,即通过组合/聚合关系 或通过继承 ,但首先应该考虑使用组合/聚合

  • 组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;

  • 其次才考虑继承,在使用继承时,需要严格遵循里氏替换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用 继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用

一般而言,如果两个类之间是"Has-A"的关系应使用组合或聚合,如果是"Is-A"关系可使用继承。

错误示例

看一个案例就明白了

对于交通方式类, 被卡车小车继承

卡车小车又有各自的运行方式: 电动燃油

又往下有 导航类 细分...

如果像这样细分下去,如果要拓展一个交通方式类,比如飞机,那么需要多个维度上扩展类 (货物类型 × 发动机类型 × 导航类型) ,这样可能会导致子类的组合爆炸!!!

修改

组合实现的灵活组装,将变动的功能抽离成独立的零件。

  • 提取 Engine(引擎)接口,具体实现有 CombustionEngineElectricEngine
  • 提取 Driver(驾驶员)接口,具体实现有 RobotHuman

这样是显而易见的,这和日常生活中组装汽车一样,按部件组装。新增一个交通方式也只需要组装引擎、驾驶员等各个部分!

迪米特法则(LOD)

介绍

迪米特法则 (Law Of Demeter, LOD)又称为最少知识原则 (Least Knowledge Principle, LKP),也就是说,一个对象应当对其他对象尽可能少的了解。

其中定义之一如下:

每个单位应该只和它的朋友们通信,不与陌生人通信。

法则目标

如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

狭义的迪米特法则

迪米特法则要求应该尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。

  • 对于一个对象,其直接朋友包括以下几类:
    1. 当前对象本身(this);
    2. 参数形式传入到当前对象方法中的对象;(依赖关系)
    3. 当前对象的成员对象;(关联关系)
    4. 如果当前对象的成员对象是一个集合集合,那么集合中的元素也都是朋友;(关联关系)
    5. 当前对象所创建的对象;(组合关系)
  • 不是直接朋友的典型情况: 只出现在 方法体内部 的类对象

缺点: 遵循类之间的迪米特法则会是一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联。但是,这也会造成系统的不同模块之间的通信效率降低,也会使系统的不同模块之间不容易协调

广义的迪米特法则

  • 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;(降低访问权限)
  • 在类的设计上,只要有可能 ,一个类型应当设计成不变类;(设计成不变类)
  • 在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

错误案例:与直接朋友通信

这是狭义迪米特法则(Law of Demeter, LoD)经典的案例。 -> 直接切断非直接朋友的联系

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在Teacher中对非直接的朋友Student进行了通信,违背了迪米特法则,下面对其进行重构。

老师需要清点班上的人数, 从老师类方法中调用班长类的方法, 让班长进行人数清点, 但是老师又把学生数据报给班长了

如果老师只负责叫班长清点人数,学生数据班长自己知道,即可

错误案例:减少对朋友的了解

这是广义迪米特法则的经典案例 -> 封装细粒度动作,暴露粗粒度接口。

在一个类中,尽量减少一个类对外暴露的方法

Human需要调用许多WashingMachine提供的方法,同时还可以能导致调用顺序错误,违背了迪米特法则,下面来对其进行重构。

直接让洗衣机直接封装一个顺序已经规定好的接口(方法), 然后直接调用它就行了,跟自动洗衣机一样,提供了一个万能按钮

注意

  • 在类的划分上,应当创建弱耦合的类,类与类之间的耦合越弱,就越有利于实现可复用的目标。
  • 在类的结构设计上,每个类都应该降低成员的访问权限。
  • 在类的设计上,只要有可能,一个类应当设计成不变的类。
  • 在对其他类的引用上,一个对象对其他类的对象的引用应该降到最低。
  • 尽量限制局部变量的有效范围,降低类的访问权限。

总结

SOILD原则分成了五种准则:

  1. 单一职责

    • 单一职责保证的是类的颗粒度,对象职责单一,封装到类。这是实现高内聚的基础。

    • 这是实现高内聚的基础。一个类只负责一个功能,意味着引起它变化的原因只有一个。

      高内聚:指一个软件模块(如类、函数、包)内部的各个元素彼此关联紧密、共同完成一个单一、明确的任务

  2. 开闭原则

    • 最重要的面向对象的设计原则,源码不能修改,但是可以扩展
    • 它是所有原则的终极目标。我们通过依赖倒置、组合复用等机制,确保在业务变动时,通过增加新类而非修改旧代码来实现功能。
  3. 里氏替换原则

    • 父类对象使用子类对象替换,代码不会出现任何错误。但是反过来子类对象不一定能够使用基类对象替换
    • 它强调的是行为的一致性。如果子类改写了父类已实现的非抽象方法,或者违反了父类的文档契约(比如抛出意料之外的异常),就违反了 LSP
  4. 依赖倒置原则

    • 针对抽象(接口/抽象类)编程,不针对实现编程(具体类);通过依赖注入(构造、Setter、接口)传入对象。
    • 高层不依赖低层,细节依赖抽象,从而实现系统的解耦
  5. 接口隔离原则

    • 只依赖需要的接口,而不依赖不需要实现的接口
    • 接口应承担独立的角色,不要提供臃肿的总接口,每一个接口只代表一种角色,不干不该干的事。

下面是组合复用原则、迪米特法则

组合复用原则

  • 是说对于Has-A的关系使用组合的方式更合理,对于Is-A的方式可以使用继承。
  • 优先使用组合/聚合,因为它能避免继承带来的"类爆炸"和强耦合(白盒复用),将变动的逻辑作为零件组装,比死板的继承树更灵活。

迪米特法则

  • 最少知识原则 ;只跟"直接朋友"通信(this、参数、成员变量、创建的对象);通过引入"第三者"或最小化权限实现解耦。
    • 狭义的迪米特法则是通过引入"第三者",如果两个类不必直接通信,通过第三者转发调用。
    • 广义的迪米特法则是全方位的解耦:降低访问权限、优先使用不变类、将引用降到最低。
    • 注意:过度使用会产生大量中介类,降低通信效率。

思考

SOILD原则跟组合复用原则、迪米特法则三者关系是啥,为什么不起一个xxx原则,然后这个原则下面包含SOILD原则的五个原则以及组合复用原则、迪米特法则

首先是历史的原因:

这些原则是由不同的大师在不同年代提出的(如 Robert C. Martin 总结了 SOLID,而迪米特法则是 1987 年由东北大学提出的)。是不断完善的,而不是从头就全部有的。

再就是他们的侧重场景不同,可以这样概括:

单一职责(SRP)作为颗粒度的最小单位,保证了模块职责单一、关联紧密,有助于实现高内聚

依赖倒置(DIP)接口隔离(ISP)迪米特法则(LOD) ,从不同维度降低耦合度DIP 强调针对抽象编程而非实现,ISP 确保接口精简,避免不必要的依赖;LOD 则通过限制"朋友"间的通信宽度和深度,控制对象间的交互界限。

组合复用(CRP)与里氏替换(LSP)主要是解决了复用LSP 保证了子类替换父类时行为一致性,是多态的基础;CRP 则指明优先使用组合(Has-A)而非继承(Is-A),以避免"类爆炸"

他们的终极目标是开闭原则 (OCP) ,开闭原则是所有原则的灵魂和最终产物。通过其他原则的约束,确保系统在不修改源码的情况下实现灵活扩展

这样简单思考一下,把这样都串起来,可以加深一下理解!

如果这篇文章对你有帮助,欢迎点赞、评论、关注、收藏。你们的支持是我前进的动力!

相关推荐
杨充19 小时前
10.可测试性实战设计
设计模式·开源·代码规范
杨充19 小时前
9.重构十二式的实战
设计模式·开源·代码规范
杨充19 小时前
6.设计原则的全景图
设计模式·开源·全栈
杨充19 小时前
2.面向对象的特性
设计模式
杨充19 小时前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充19 小时前
8.反模式与坏味道
设计模式·开源·代码规范
杨充19 小时前
3.接口vs抽象类比较
设计模式
咖啡八杯1 天前
文法、BNF与AST
java·设计模式·解释器模式·ast·文法
咖啡八杯1 天前
GoF设计模式——解释器模式
java·后端·spring·设计模式