面向对象设计原则(7大原则)

Posted Nick@Freya

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面向对象设计原则(7大原则)相关的知识,希望对你有一定的参考价值。

熟练掌握和应用面向对象设计(Object Oriented Design,OOD)7大原则,是初/中级Java工程师向高级/资深工程师进阶的一个必备技能,它可以大大提升程序的可复用性和可维护性,也是重构代码的一大利器。7大设计原则包括开闭原则、里氏替换原则、依赖倒转原则、单一职责原则、接口隔离原则、组合/聚合复用原则、迪米特法则。

1.开闭原则

面向对象可复用设计原则中最基础的原则便是开闭原则(Open-Closed Principle,OCP)。开闭原则指的是在设计一个对象(类、模块、函数)时,应遵循或做到对扩展开放、对修改关闭,其核心思想是面向接口/抽象进行编程。比如第四方支付平台,集成微信支付服务时,遵循开闭原则的话,正常的做法就是先抽象设计一个支付接口,然后微信支付类实现该支付接口。以后平台集成更多如支付宝、华为、苹果等支付服务时,只需要扩展一个对应支付方式的支付类即可,这样既扩展了新的支付服务,同时避免了修改现有的支付接口以及微信支付类,最大限度保证了新代码不会影响现有的业务代码。这就是对扩展(新对象)开放,对修改(现有对象)关闭。

2.里氏替换原则

里氏替换原则(Liskov Substitution Principle,LSP)要求设计一个对象时,代码中任何父类对象可以出现的地方,子类都可以出现,即可以使用子类来代替父类。实现开闭原则的核心思想是面向接口/抽象进行编程,其关键步骤是抽象化,而父类和子类的继承关系就是抽象化的具体体现,所以里氏替换原则是对实现抽象化的具体步骤的规范,它是对开闭原则的补充。比如教学平台中新增加了学生用户Student,和教师用户Teacher一样进入个人中心,都需要登录,在设计或重构用户登录服务类时,抽象的用户User类作为教师Teacher和学生Student的父类,此时需要遵循出现用户User的地方,都可以使用新扩展的学生Student代替,当然也必须也可以使用教师Teacher代替,这样设计出来的User类,对用户登录服务类来说,后续扩展更多的用户User的子类比如家长、辅导员时,可以保证符合开闭原则。

3.依赖倒转原则

依赖倒转原则(Dependency Inversion Principle,DIP)指程序中要依赖抽象(接口/抽象类)而不是具体的实现(实现类)进行编程,即面向接口/抽象进行编程,这样可以最大程度降低模块之间的耦合。比如订单服务中,会调用(依赖)支付服务以完成订单的支付。此时的订单服务实现类代码中,声明或持有的应该是一个支付服务PayService接口,而不是具体实现类比如Alipay或WeChatPay,所有需要用到支付服务的地方,调用的都是PayService接口变量,即代码中都是接口,等到在运行时才实例化或注入具体的实现类,编写代码时不需要关心具体实现类,也就是面向接口/抽象编程。这样,在应用程序扩展或集成新的支付服务时,原来的订单服务代码可以不需要进行修改任何代码,这样也就符合了开闭原则。

4.单一职责原则

单一职责原则(Single Function Principle,SFP)又称单一功能原则,它规范设计一个类应只实现单一或独立的职责即业务功能。职责分明的设计,可以提高程序的可维护性。如果多个独立的业务功能设计在同一类中,当一个或多个业务功能扩展或变动需要修改代码时,可能会带来冲突问题,降低可维护性。比如订单服务中,又包含了用户服务,重构用户服务的代码时,相当于重构订单服务的代码,这是不合理的,容易造成混淆。

5.接口隔离原则

接口隔离原则(Inteface Segregation Principle,ISP)指设计时不同的功能应定义在不同的接口上,避免实现类依赖不需要的接口,换个角度理解就是一个类应该依赖尽量少的接口或实现类,减少程序的冗余性和复杂性。

6.组合/聚合复用原则

组合/聚合复用原则(Composition/Aggregation Principle,CARP)也叫做合成复用原则(Composition Reuse Principle,CRP),它指在一个新的对象中引入/注入现有的对象以达到功能复用和扩展的目的。简单讲就是尽量使用组合/聚合而不要使用继承。

7.迪米特法则

迪米特法则(Low of Demeter,LoD)指设计一个对象时,尽可能少的与其它对象发生相互作用,即尽可能少的了解或依赖其它对象,降低耦合度。广义的迪米特法则,在类设计的具体操作上,主要通过以下三点来体现
优先考虑将一个类设置为final不变类
尽量降低一个类及其成员变量的访问权限
谨慎使用序列化

设计模式7大原则

面向对象设计原则

概述

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

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):尽量使用对象组合,而不是继承来达到复用的目的。

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

7.迪米特法则

参考:https://www.cnblogs.com/muzongyan/archive/2010/08/05/1793454.html
参考:https://blog.csdn.net/lovelion/article/details/7537584
参考:https://blog.csdn.net/qq_34966814/article/details/79475977





以上是关于面向对象设计原则(7大原则)的主要内容,如果未能解决你的问题,请参考以下文章

面向对象的设计的7大原则

设计模式7大原则

Java的开发—面向对象的7大原则之单一迪米特法则

面向对象可复用设计几大原则

面向对象设计的几大原则

面向对象设计原则