面向对象设计原则

概述

对于面向对象软件系统的设计而言,在支持可维护性的同时,提高系统的可复用性是一个至关重要的问题,如何同时提高一个软件系统的可维护性和可复用性是面向对象设计需要解决的核心问题之一。在面向对象设计中,可维护性的复用是以设计原则为基础的。每一个原则都蕴含一些面向对象设计的思想,可以从不同的角度提升一个软件结构的设计水平。 面向对象设计原则为支持可维护性复用而诞生,这些原则蕴含在很多设计模式中,它们是从许多设计方案中总结出的指导性原则。面向对象设计原则也是我们用于评价一个设计模式的使用效果的重要指标之一,在设计模式的学习中,大家经常会看到诸如“XXX模式符合XXX原则”、“XXX模式违反了XXX原则”这样的语句。

最常见的7种面向对象设计原则如下表所示:

1.单一职责原则

单一职责定义

一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因

从定义中不难思考,一个类的所做的事情越多,也就越难以复用,因为一旦做的事情多了,职责的耦合度就变高了所以我们根据这个原则应该将不同职责封装在不同类中,不同的变化封装在不同类中。从我们平常的开发中不难发现,如果一个类或者方法接口等等只做一件事,那么可读性很高,并且复用性也很高,并且一旦需求变化,也容易维护,假如你一个类糅杂多个职责,那么很难维护。

单一职责举例分析

从实际业务来剥离一个例子:现在有这么一种情况,某租车平台个人模块类涉及多个方法,有如下登录、注册、支付宝押金支付、微信押金支付、支付宝套餐支付、微信套餐支付、整个结构如下:

/**
 * 个人模块
 */
 @Controller
 public class userController{
 /**
 * 登录
 */
 public void login(){
 }
 /**
 * 注册
 */
 public void register(){
 }
 /**
 * 押金支付(阿里)
 */
 public void payAliDeposit(){
 }
 /**
 * 押金支付(微信)
 */
 public void payWXDeposit(){
 }
 /**
 * 套餐支付(阿里)
 */
 public void payAliPackage(){
 }
 /**
 * 套餐支付(微信)
 */
 public void payWXPackage(){
 }
 }

我们可以看到很多功能都糅杂在一起,一个类做了那么多事情,很臃肿,别提维护,就连找代码都很困难,所以我们可以对这个UserController进行拆解,与此同时我们应该分包,比如这个应该在xxx.xxx.userMoudule下面,可能支付相关的有公共的方法,登录抑或也有公共的方法,那边抽成公共服务去调用。

public class LoginController(){}
public class registerController(){}
public class depositPayController(){
 // 支付宝支付
 // 微信支付
}
public class packagePayController(){
 // 支付宝支付
 // 微信支付
}

整个方案实现的目的就是为了解决高耦合,代码复用率低下的问题。单一职责理解起来不难,但是实际操作需要根据具体业务的糅杂度来切割,实际上很难运用。

2.开闭原则

开闭原则简介

开闭原则是面向对象的可复用设计的第一块基石,它是最重要的面向对象设计原则,定义如下:

一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。

软件实体包括以下几个部分:

  • 项目或软件产品中按照一定的逻辑规则划分的模块
  • 抽象和类
  • 方法

注意:开闭原则是指对扩展开放,对修改关闭,并不是说不做任何的修改。

开闭原则的优势

  • 可以使原来的测试代码依旧可以运行,只需要对扩展的代码进行测试即可
  • 可以提高代码的复用性
  • 可以提高系统的维护性

如何使用开闭原则

  • 抽象约束
  • 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
  • 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;(针对抽象编程)
  • 抽象层尽量保持稳定,一旦确定即不允许修改。
  • 元数据控制模块行为
  • 通俗来说就是通过配置文件来操作数据,spring的控制反转就是一个很典型的例子。
  • 约定优于配置
  • 封装变化
  • 将相同的变化封装到一个接口或者类中
  • 将不同的变化封装到不同的类或者接口中(单一职责的体现)

案例

某公司开发的租车系统有一个押金支付功能,支付方式有支付宝、阿里支付,后期可能还有银联支付、易支付等等,原始的设计方案如下:

// 客户端调用-押金支付选择支付手段
public class DepositPay {
 void pay(String type){
 if(type.equals("ali")){
 AliPay aliPay = new AliPay();
 aliPay.pay();
 }else if(type.equals("wx")){
 WXPay wxPay = new WXPay();
 wxPay.pay();
 }
 }
}
// 支付宝支付
public class AliPay {
 public void pay() {
 System.out.println("正在使用支付宝支付");
 }
}
// 微信支付
public class WXPay{
 public void pay() {
 System.out.println("正在使用微信支付");
 }
}

在以上代码中,如果需要增加银联支付,如YLPay,那么就必须要修改DepositPay中的pay方法的源代码,增加新的判断逻辑,违反了开闭原则(对修改关闭,对扩展开放,注意这边的银联支付相当于扩展,所以它没有违反规则),所以现在必须重构此代码,让其遵循开闭原则,做法如下:

  1. 增加一个接口,使得各种具体支付实现其接口
  2. DepositPay类针对接口编程,由客户端来决定具体使用哪种支付方式

重构后的图如下所示:

在上图中我们引入了接口Pay,定义了pay方法,并且DepositPay是针对接口编程,通过setPayMode()由客户端来实例化具体的支付方式,在DepositPay的pay()方法中调用payMode对象来支付。如果需要增加新的支付方式,比如银联支付,只需要让它也实现Pay接口,在配置文件中配置银联支付即可,依赖注入是实现此开闭原则的一种手段,在这里不赘述,源码如下:

public interface Pay {
 // 支付
 void pay();
}
public class AliPay implements Pay {
 @Override
 public void pay() {
 System.out.println("正在使用支付宝支付");
 }
}
public class WXPay implements Pay{
 @Override
 public void pay() {
 System.out.println("正在使用微信支付");
 }
}
// 客户端调用-押金支付选择支付手段
public class DepositPay {
 // 支付方式 (这边可以通过依赖注入的方式来注入)
 // 支付方式可以写在配置文件中
 // 现在不管你选用何种方式,我都不需要更改
 @Autowired
 Pay payMode;
 void pay(Pay payMode){
 payMode.pay();
 }
}

因为配置文件可以直接编辑,且不需要编译,所以一般不认为更改配置文件是更改源码。如果一个系统能做到只需要修改配置文件,无需修改源码,那么复合开闭原则。

3.里氏代换原则

里氏替换原则简介

Barbara Liskov提出:

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

上面的定义可能比较难以理解,简单理解就是所有引用基类(父类的)地方都可以用子类来替换,且程序不会有任何的异常。但是反过来就不行,所有使用子类的地方则不一定能用基类来替代,很简单的例子狗是动物,不能说动物是狗,因为可能还有猫。。。。

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

里氏替换原则约束

  • 子类必须实现父类的抽象方法,但不得重写(覆盖)父类的非抽象(已实现)方法。
  • 子类中可以添加特有方法(父类中不存在),此时则无法在以父类定义的对象中使用该方法,除非在使用的时候强转基类成子类进行调用。
  • 当子类覆盖或实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

所以我们在运用里氏替换原则的时候,尽量把父类设计为抽象类或者接口,让子类继承父类或者实现接口并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一。

里氏替换原则实战

某租车系统客户分为普通用户(customer)和VIP客户(VIPCustomer),系统需要提供一个根据邮箱重置密码的功能。原始设计图:

在编写重置密码的时候发现,业务逻辑是一样的,存在着大量的重复代码,而且还可能增加新的用户类型,为了减少代码重复性,使用里氏替换原则进行重构:

图上重置密码交由ResetPassword类去处理,只需要传入Customer类即可,不管任何类型的Customer类,只要继承自Customer,都可以使用里氏替换原则进行替换,假如有新的类型,我们只需要在配置文件中注入新的类型即可。代码如下(简单意会一下):

// 抽象基类
public abstract class Customer {
}
public class CommonCustomer extends Customer{
}
public class VIPCustomer extends Customer{
}
// 重置密码逻辑在这里实现,只需要传入对应的类型即可
public class ResetPassword {
 void resetPassword(Customer customer){
 }
}

里氏替换原则是实现开闭原则不可或缺的手段之一,在本例中,通过传递参数使用基类对象,针对抽象编程,从而满足开闭原则。

4.依赖倒转原则

依赖倒转原则简介

依赖倒转原则(Dependency Inversion Principle, DIP):抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。

可以通俗的定义为两种:

  1. 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
  2. 抽象不应该依赖于具体实现,具体实现应该依赖于抽象。

要求我们在设计程序的时候尽量使用层次高的抽象层类,即使用接口和抽象类进行变量的声明、参数类型声明、方法返回类型声明以及数据类型转换等等,同时要注意一个具体类应该只实现抽象类或者接口中存在的方法,不要给出多余的方法,这样抽象类将无法调用子类增加的方法.我们可以通过配置文件来写入具体类,这样一旦程序行为改变,可直接改变配置文件,而不需要更改程序,重新编译,通过依赖倒转原则来满足开闭原则。

在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入

依赖倒转原则实例

这部分可以参照上面开闭原则案例,可以从那例子中看出,开闭原则,依赖倒转原则,里氏替换原则同时出现了,可以说`开闭原则是我们要实现的目标,而里氏替换原则是实现手段之一,而同时里氏替换原则又是依赖倒转原则实现的基础,因为加入没有这个理论,依赖倒转原则是不成立的,无法针对抽象编程,要注意这3个原则基本都是同时出现的。Java后端学习交流圈:834962734 面向2-6年Java开发人员,进群可免费获取一份Java架构进阶技术精品视频。(高并发+Spring源码+JVM原理解析+分布式架构+微服务架构+多线程并发原理+BATJ面试宝典)以及架构思维导图。

5.接口隔离原则

接口隔离原则简介

接口隔离原则的两个定义:

1:使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口

2:类间的依赖关系应该建立在最小的接口上

接口的含义:

  1. 一个接口代表一个角色,不应该将不同的角色都交给一个接口,因为这样可能会形成一个臃肿的大接口;
  2. 特定语言的接口,表示接口仅仅是提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。

根据接口隔离原则,我们可明白,每个接口都应只承担一种相对独立的角色,不干不该干的事情.

实例演示

场景:模拟动物平时的动作,当然也包括人,最初的设计就是一个总接口IAnimal,里面定义动物会有的一些动作。

代码如下:

public interface IAnimal{
 /**
 * 吃饭
 */
 void eat();
 /**
 * 工作
 */
 void work();
 /**
 * 飞行
 */
 void fly();
 }
 public class Tony implements IAnimal{
 @Override
 public void eat() {
 System.out.println("tony吃");
 }
 @Override
 public void work() {
 System.out.println("tony工作");
 }
 @Override
 public void fly() {
 System.out.println("tony不会飞");
 }
 }
public class Bird implements IAnimal{
 @Override
 public void eat() {
 System.out.println("鸟吃");
 }
 @Override
 public void work() {
 System.out.println("鸟工作");
 }
 @Override
 public void fly() {
 System.out.println("鸟飞");
 }
 }

根据上面的写法发现Tony需要实现飞的接口,这很明显不仅仅是多余,而且不合理,因此需要通过接口隔离原则进行重构:

/**
 * 抽象动物的行为
 */
public interface IAnimal {
 /**
 * 吃饭
 */
 void eat();
 /**
 * 睡觉
 */
 void sleep();
}
/**
 * 高级动物人 的行为
 */
public interface IAdvancedAnimalBehavior {
 /**
 * 打牌
 */
 void playCard();
 /**
 * 骑车
 */
 void byBike();
}
/**
 * 低级动物的行为
 */
public interface IJuniorAnimalBehavior {
 /**
 * fly
 */
 void fly();
}
/**
 * 实现高级动物人的共通方法
 */
public class AbstractAdvancedAnimal implements IAnimal {
 @Override
 public void eat() {
 System.out.println("人吃");
 }
 @Override
 public void sleep() {
 System.out.println("人睡");
 }
}
/**
 * 实现低级动物人的共通方法
 */
public class AbstractJuniorAnimal implements IAnimal {
 @Override
 public void eat() {
 System.out.println("动物吃");
 }
 @Override
 public void sleep() {
 System.out.println("动物睡");
 }
}
// tony
public class Tony extends AbstractAdvancedAnimal implements IAdvancedAnimalBehavior {
 @Override
 public void playCard() {
 System.out.println("tony打牌");
 }
 @Override
 public void byBike() {
 System.out.println("tony骑车");
 }
}
// 鸟
public class Bird extends AbstractJuniorAnimal implements IJuniorAnimalBehavior{
 @Override
 public void fly() {
 System.out.println("鸟飞");
 }
}

重构之后,首先定义了一个总的动物接口的大类,然后分别使用了两个抽象类(一个是高级动物,一个是低级动物)分别去实现这些公共的方法,实现中可以抛出异常,表明继承此抽象类的类可以选择性的重写,可不重写。之后再定义了两个行为接口表明高级动物和低级动物所特有的,这样使得接口之间完全隔离,动物接口不再糅杂各种各样的角色,当然接口的大小尺度还是要靠经验来调整,不能太小,会造成接口泛滥,也不能太大,会背离接口隔离原则。

6.合成复用原则

合成复用原则简介

合成复用原则(Composite Reuse Principle, CRP):尽量使用对象组合,而不是继承来达到复用的目的。

通过合成复用原则来使一些已有的对象使之成为对象的一部分,一般通过组合/聚合关系来实现,而尽量不要使用继承。因为组合和聚合可以降低类之间的耦合度,而继承会让系统更加复杂,最重要的一点会破坏系统的封装性,因为继承会把基类的实现细节暴露给子类,同时如果基类变化,子类也必须跟着改变,而且耦合度会很高。

12-15 19:52