设计模式(一)七大设计原则

  今天开始准备对设计模式进行整理和分享,之前有读过《大话设计模式》,和《HeadFirst设计模式》,个人觉得都是特别好的入门书籍。设计模式到底解决什么问题呢?个人觉得概括就两个字就是为了应付“变化”,其实就是为了处理因为需求不断变化导致代码不断变化从而引起的一系列问题,如何提高代码的“复用性”和“扩展性”,是设计模式解决的问题。
  同时,学习设计模式,也能让我们对面向对象思想理解的更加透彻。

下面是引用的一些专业的术语:

  设计模式(Design Pattern)是前辈们对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。
  1995 年,GoF(Gang of Four,四人组/四人帮)合作出版了《设计模式:可复用面向对象软件的基础》一书,共收录了 23 种设计模式,从此树立了软件设计模式领域的里程碑,人称「GoF设计模式」。

类与类之间的关系

  学习设计模式之前,我们需要了解UML中类与类之间的关系,因为每个设计模式都会有对应的类图,而类图都是用统一建模语言(Unified Modeling Language,UML)定义的,其中根据类与类之间的耦合度从弱到强依次定义了以下几种关系:依赖关系、关联关系、聚合关系、组合关系、泛化关系和实现关系。其中泛化和实现的耦合度相等,它们是最强的。

  1. 依赖关系
      依赖(Dependency)关系是一种使用关系,它是对象之间耦合度最弱的一种关联方式,是临时性的关联。在代码中,某个类的方法通过局部变量、方法的参数或者对静态方法的调用来访问另一个类(被依赖类)中的某些方法来完成一些职责。
      在 UML 类图中,依赖关系使用带箭头的虚线来表示,箭头从使用类指向被依赖的类。如图:
    在这里插入图片描述
      人与手机的关系图,人通过手机的语音传送方法打电话。

    PS:通常我们说的“依赖注入” ,其实是指通过依赖的方式,即临时性的关联进行注入,被注入对象和注入对象的关系其实是关联关系。

  2. 关联关系
      关联(Association)关系是对象之间的一种引用关系,用于表示一类对象与另一类对象之间的联系,如老师和学生、师傅和徒弟、丈夫和妻子等。关联关系是类与类之间最常用的一种关系,分为一般关联关系、聚合关系和组合关系。我们先介绍一般关联。
      关联可以是双向的,也可以是单向的。
      在 UML 类图中,双向的关联可以用带两个箭头或者没有箭头的实线来表示,单向的关联用带一个箭头的实线来表示,箭头从使用类指向被关联的类。也可以在关联线的两端标注角色名,代表两种不同的角色。
      在代码中通常将一个类的对象作为另一个类的成员变量来实现关联关系。
    在这里插入图片描述  如图所示是老师和学生的关系图,每个老师可以教多个学生,每个学生也可向多个老师学,他们是双向关联。

  3. 聚合关系
      聚合(Aggregation)关系是关联关系的一种,是强关联关系,是整体和部分之间的关系,是 has-a 的关系。
      聚合关系也是通过成员对象来实现的,其中成员对象是整体对象的一部分,但是成员对象可以脱离整体对象而独立存在(这点很重要,区分组合的关键)。例如,学校与老师的关系,学校包含老师,但如果学校停办了,老师依然存在。
    在 UML 类图中,聚合关系可以用带空心菱形的实线来表示,菱形指向整体。
    在这里插入图片描述
      如图所示是大学和教师的关系图。

  4. 组合关系
      组合(Composition)关系也是关联关系的一种,也表示类之间的整体与部分的关系,但它是一种更强烈的聚合关系,是 contains-a 关系。
      在组合关系中,整体对象可以控制部分对象的生命周期,一旦整体对象不存在,部分对象也将不存在,部分对象不能脱离整体对象而存在。例如,头和嘴的关系,没有了头,嘴也就不存在了。
      在 UML 类图中,组合关系用带实心菱形的实线来表示,菱形指向整体。
    在这里插入图片描述
      如图所示是头和嘴的关系图。

  5. 泛化关系
      泛化(Generalization)关系是对象之间耦合度最大的一种关系,表示一般与特殊的关系,是父类与子类之间的关系,是一种继承关系,是 is-a 的关系
      在 UML 类图中,泛化关系用带空心三角箭头的实线来表示,箭头从子类指向父类。在代码实现时,使用面向对象的继承机制来实现泛化关系。例如,Student 类和 Teacher 类都是 Person 类的子类,其类图如图所示。
    在这里插入图片描述

  6. 实现关系
      实现(Realization)关系是接口(或者抽象类)与实现类之间的关系。在这种关系中,类实现了接口,类中的操作实现了接口中所声明的所有的抽象操作。
      在 UML 类图中,实现关系使用带空心三角箭头的虚线来表示,箭头从实现类指向接口。例如,汽车和船实现了交通工具,其类图如图所示:
    在这里插入图片描述

面向对象七大设计原则

  在讲设计模式之前,我们需要了解什么是设计原则,就好比人做事情一样,设计模式就是我们做事的方法,而设计原则就是我们做事的底线,方法可以有很多种,从而达到目的,但是决不能违背原则。
  比较专业的话是这么说的:面向对象设计原则为支持可维护性复用而诞生,这些原则蕴含在很多设计模式中,它们是从许多设计方案中总结出的指导性原则。
  面向对象设计原则也是我们用于评价一个设计模式的使用效果的重要指标之一,在设计模式的学习中,大家经常会看到诸如“XXX模式符合XXX原则”、“XXX模式违反了XXX原则”这样的语句。所以并没有一个设计模式,能够符合所有的原则,都是在一定情况的下的最优选择。

1. 开闭原则

这个是七大设计原则中最根本的原则,可以说是其他六个原则的总纲,定义:

软件实体应对扩展开放,而对修改关闭。

  定义所说的软件实体,大到模块,小到方法。
  简单来说就是一旦有新的需求,我们在编写代码的时候不需要修改已有代码,只需要新增类和修改配置文件,这样就可以认为是符合开闭原则的。

  • 例如:
    在这里插入图片描述
    在ChartDisplay类的display()方法中存在如下代码片段:

    if (type.equals("pie")) {
    	PieChart chart = new PieChart();
    	chart.display();
    }else if (type.equals("bar")) {
    	BarChart chart = new BarChart();
    	chart.display();
    }
    

    在该代码中,如果需要增加一个新的图表类,如折线图LineChart,则需要修改ChartDisplay类的display()方法的源代码,增加新的判断逻辑,违反了开闭原则。

    现对该系统进行重构,使之符合开闭原则
      在本实例中,由于在ChartDisplay类的display()方法中针对每一个图表类编程,因此增加新的图表类不得不修改源代码。可以通过抽象化的方式对系统进行重构,使之增加新的图表类时无须修改源代码,满足开闭原则。具体做法如下:

    • 增加一个抽象图表类AbstractChart,将各种具体图表类作为其子类;
    • ChartDisplay类针对抽象图表类进行编程,由客户端来决定使用哪种具体图表

    重构后结构如图所示:

    在这里插入图片描述
      如图,我们引入了抽象图表类AbstractChart,且ChartDisplay针对抽象图表类进行编程,并通过setChart()方法由客户端来设置实例化的具体图表对象,在ChartDisplay的display()方法中调用chart对象的display()方法显示图表。如果需要增加一种新的图表,如折线图LineChart,只需要将LineChart也作为AbstractChart的子类,在客户端向ChartDisplay中注入一个LineChart对象即可,无须修改现有类库的源代码。
      因为xml和properties等格式的配置文件是纯文本文件,可以直接通过VI编辑器或记事本进行编辑,且无须编译,因此在软件开发中,一般不把对配置文件的修改认为是对系统源代码的修改。如果一个系统在扩展时只涉及到修改配置文件,而原有的Java代码或C#代码没有做任何修改,该系统即可认为是一个符合开闭原则的系统。

2. 里式替换

里氏代换原则由2008年图灵奖得主、美国第一位计算机科学女博士Barbara Liskov教授和卡内基·梅隆大学Jeannette Wing教授于1994年提出。其严格表述如下:

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

这个定义比较拗口且难以理解,因此我们一般使用它的另一个通俗版定义:

里氏代换原则(Liskov Substitution Principle, LSP):所有引用基类(父类)的地方必须能透明地使用其子类的对象。

  里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。

例如有两个类,一个类为BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个BaseClass类型的基类对象base的话,如:method1(base),那么它必然可以接受一个BaseClass类型的子类对象sub,method1(sub)能够正常运行。反过来的代换不成立,如一个方法method2接受BaseClass类型的子类对象sub为参数:method2(sub),那么一般而言不可以有method2(base),除非是重载方法。

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

在使用里氏代换原则时需要注意如下几个问题:

  • (1)子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
  • (2) 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一。
  • (3) Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏代换原则,这是一个与实现无关的、纯语法意义上的检查,但Java编译器的检查是有局限的。

示例:

  • 在Sunny软件公司开发的CRM系统中,客户(Customer)可以分为VIP客户(VIPCustomer)和普通客户(CommonCustomer)两类,系统需要提供一个发送Email的功能,原始设计方案如图所示:
    在这里插入图片描述
      在对系统进行进一步分析后发现,无论是普通客户还是VIP客户,发送邮件的过程都是相同的,也就是说两个send()方法中的代码重复,而且在本系统中还将增加新类型的客户。

    为了让系统具有更好的扩展性,同时减少代码重复,使用里氏代换原则对其进行重构。
      在本实例中,可以考虑增加一个新的抽象客户类Customer,而将CommonCustomer和VIPCustomer类作为其子类,邮件发送类EmailSender类针对抽象客户类Customer编程,根据里氏代换原则,能够接受基类对象的地方必然能够接受子类对象,因此将EmailSender中的send()方法的参数类型改为
    Customer,如果需要增加新类型的客户,只需将其作为Customer类的子类即可。重构后的结构如图所示:
    在这里插入图片描述
      里氏代换原则是实现开闭原则的重要方式之一。在本实例中,在传递参数时使用基类对象,除此以外,在定义成员变量、定义局部变量、确定方法返回类型时都可使用里氏代换原则。针对基类编程,在程序运行时再确定具体子类。

意义
继承,是面向对象语言非常优秀的语言机制。里氏替换原则的意义,就是规范继承的用法,让我们最大限度地发挥继承的优点。

3. 依赖倒置

  如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要实现机制之一,它是系统抽象化的具体实现。依赖倒转原则是Robert C. Martin在1996年为“C++Reporter”所写的专栏Engineering Notebook的第三篇,后来加入到他在2002年出版的经典著作“Agile Software Development, Principles, Patterns, and Practices”一书中。依赖倒转原则定义如下:

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

  依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。
  在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。
  在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入。构造注入是指通过构造函数来传入具体类的对象,设值注入是指通过Setter方法来传入具体类的对象,而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。
下面通过一个简单实例来加深对依赖倒转原则的理解:

  • Sunny软件公司开发人员在开发某CRM系统时发现:该系统经常需要将存储在TXT或Excel文件中的客户信息转存到数据库中,因此需要进行数据格式转换。在客户数据操作类中将调用数据格式转换类的方法实现格式转换和数据库插入操作,初始设计方案结构如图所示:
    在这里插入图片描述
      如图,Sunny软件公司开发人员发现该设计方案存在一个非常严重的问题,由于每次转换数据时数据来源不一定相同,因此需要更换数据转换类,如有时候需要将TXTDataConvertor改为ExcelDataConvertor,此时,需要修改CustomerDAO的源代码,而且在引入并使用新的数据转换类时也不得不修改CustomerDAO的源代码,系统扩展性较差,违反了开闭原则,现需要对该方案进行重构。
      在本实例中,由于CustomerDAO针对具体数据转换类编程,因此在增加新的数据转换类或者更换数据转换类时都不得不修改CustomerDAO的源代码。我们可以通过引入抽象数据转换类解决该问题,在引入抽象数据转换类DataConvertor之后,CustomerDAO针对抽象类DataConvertor编程,而将具体数据转换类名存储在配置文件中,符合依赖倒转原则。根据里氏代换原则,程序运行时,具体数据转换类对象将替换DataConvertor类型的对象,程序不会出现任何问题。更换具体数据转换类时无须修改源代码,只需要修改配置文件;如果需要增加新的具体数据转换类,只要将新增数据转换类作为DataConvertor的子类并修改配置文件即可,原有代码无须做任何修改,满足开闭原则。重构后的结构下图所示:
    在这里插入图片描述
      在上述重构过程中,我们使用了开闭原则、里氏代换原则和依赖倒转原则,在大多数情况下,这三个设计原则会同时出现,开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段,它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。

优点
通过上面的例子,总结一下依赖倒置原则的优点:

  • 减少类之间的耦合;
  • 降低并行开发引起的风险;
  • 提高代码的可读性和可维护性。

关于上面第2点需要解释一下,依赖倒置对于并行开发非常重要,可以说是必不可少。因为在多个人一起进行开发的时候,如果没有抽象类或者接口,你要等到别人完成具体的类才能进行开发,那么谈何并行开发?相反我们只需要先定好抽象类或者接口,大家就能开工了。所以在并行开发的时候一定要遵守依赖倒置原则。包括前和后端合作,优先定义好接口是一个道理。

实践
其实平常所说的面向接口编程就是依赖倒置原则的最佳实践

4. 单一职责

单一职责原则是最简单的面向对象设计原则,它用于控制类的粒度大小。单一职责原则定义如下:

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

  单一职责原则告诉我们:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
  单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。

  • 例如
    Sunny软件公司开发人员针对某CRM(Customer Relationship Management,客户关系管理)系统中客户信息图形统计模块提出了如图所示初始设计方案:
    在这里插入图片描述如图,CustomerDataChart类中的方法说明如下:

    • getConnection()方法用于连接数据库
    • findCustomers()用于查询所有的客户信息
    • createChart()用于创建图表
    • displayChart()用于显示图表。

      在图中,CustomerDataChart类承担了太多的职责,既包含与数据库相关的方法,又包含与图表生成和显示相关的方法。如果在其他类中也需要连接数据库或者使用findCustomers()方法查询客户信息,则难以实现代码的重用。无论是修改数据库连接方式还是修改图表显示方式都需要修改该类,它不止一个引起它变化的原因,违背了单一职责原则。因此需要对该类进行拆分,使其满足单一职责原则,类CustomerDataChart可拆分为如下三个类:

    • (1) DBUtil:负责连接数据库,包含数据库连接方法getConnection();
    • (2) CustomerDAO:负责操作数据库中的Customer表,包含对Customer表的增删改查等方法,如findCustomers();
    • (3) CustomerDataChart:负责图表的生成和显示,包含方法createChart()和displayChart()。

    使用单一职责原则重构后的结构如图所示:
    在这里插入图片描述

5. 接口隔离

接口隔离原则定义如下:

接口隔离原则(Interface Segregation Principle, ISP):使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。

  根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。这里的“接口”往往有两种不同的含义:一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象;另外一种是指某种语言具体的“接口”定义,有严格的定义和结构,比如Java语言中的interface。对于这两种不同的含义,ISP的表达方式以及含义都有所不同:

  • (1) 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时,这个原则可以叫做“角色隔离原则”。
  • (2) 如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。在面向对象编程语言中,实现一个接口就需要实现该接口中定义的所有方法,因此大的总接口使用起来不一定很方便,为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。接口应该尽量细化,同时接口中的方法应该尽量少,每个接口中只包含一个客户端(如子模块或业务逻辑类)所需的方法即可,这种机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接口。

好处:

  1. 避免接口污染;
    一个类如果要实现一个接口,那么就要实现这个接口要求的所有方法,如果这个接口里面包含这个类不需要的方法,那么就会造成接口污染,这是不好的设计,会对系统留下隐患。
  2. 提高灵活性;
    一个类是可以同时实现多个接口的,所以将一个臃肿的接口分割为若干个小接口,通过小接口的不同组合可以满足更多的需求。
  3. 提供定制服务;
    所谓的定制服务,就是通过细化接口,实现给不同的客户提供不同的接口的目的。定制服务可以有效避免因为给客户提供了多余的方法而造成的风险。
  4. 实现高内聚。
    什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。比如说,你告诉你的下属“一个小时之内去月球搬一块石头回来”,然后你就躺在海滩上晒着太阳喝着果汁,一个小时之后你的下属就搬着一块月亮上的石头回来给你了。这种不讲需要你关心任何细节,立即完成任务的行为就是高内聚的表现。具体到接口中,还是尽量细化你的接口。接口是对外界的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。

下面通过一个简单实例来加深对接口隔离原则的理解:

  • Sunny软件公司开发人员针对某CRM系统的客户数据显示模块设计了如下图所示接口,其中方法dataRead()用于从文件中读取数据,方法transformToXML()用于将数据转换成XML格式,方法createChart()用于创建图表,方法displayChart()用于显示图表,方法createReport()用于创建文字报表,方法displayReport()用于显示文字报表。
    在这里插入图片描述
      在实际使用过程中发现该接口很不灵活,例如如果一个具体的数据显示类无须进行数据转换(源文件本身就是XML格式),但由于实现了该接口,将不得不实现其中声明的transformToXML()方法(至少需要提供一个空实现);如果需要创建和显示图表,除了需实现与图表相关的方法外,还需要实现创建和显示文字报表的方法,否则程序编译时将报错。

    现使用接口隔离原则对其进行重构
      在上图中,由于在接口CustomerDataDisplay中定义了太多方法,即该接口承担了太多职责,一方面导致该接口的实现类很庞大,在不同的实现类中都不得不实现接口中定义的所有方法,灵活性较差,如果出现大量的空方法,将导致系统中产生大量的无用代码,影响代码质量;另一方面由于客户端针对大接口编程,将在一定程序上破坏程序的封装性,客户端看到了不应该看到的方法,没有为客户端定制接口。因此需要将该接口按照接口隔离原则和单一职责原则进行重构,将其中的一些方法封装在不同的小接口中,确保每一个接口使用起来都较为方便,并都承担某一单一角色,每个接口中只包含一个客户端(如模块或类)所需的方法即可。
      通过使用接口隔离原则,本实例重构后的结构如图所示:
    在这里插入图片描述
      在使用接口隔离原则时,我们需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可,不应该强迫客户依赖于那些它们不用的方
    法。

注意
  接口隔离原则和单一职责原则非常类似。单一职责原则要求接口的职责是单一的,而接口隔离原则要求接口尽量细化,它们有异曲同工之妙,都是要让我们的接口功能尽量单一,尽量小。
  但是,单一职责原则的着重点是在“职责”,而接口隔离原则只单纯地要求接口最小化。那么,如果已经满足单一职责原则的接口,在当前的需求下还可以继续细化,那么还需要细化吗?答案是不要再细化了。在实践中,接口设计的粒度越小,系统就越灵活,这是事实。但是灵活的同时也带来了系统的复杂化,导致开发难度增加。所以接口并不是越小越好,必须要有一个度。当单一职责原则和接口隔离原则存在矛盾时,以满足单一职责原则为底线。

6. 合成复用

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

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

  合成复用原则就是在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系),少用继承
  在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,但首先应该考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。
  通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称“白箱”复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。
  由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
  一般而言,如果两个类之间是“Has-A”的关系应使用组合或聚合,如果是“Is-A”关系可使用继承。“Is-A"是严格的分类学意义上的定义,意思是一个类是另一个类的"一种”;而"Has-A"则不同,它表示某一个角色具有某一项责任。
案例讲解:

  • Sunny软件公司开发人员在初期的CRM系统设计中,考虑到客户数量不多,系统采用MySQL作为数据库,与数据库操作有关的类如CustomerDAO类等都需要连接数据库,连接数据库的方法getConnection()封装在DBUtil类中,由于需要重用DBUtil类的getConnection()方法,设计人员将CustomerDAO作为DBUtil类的子类,初始设计方案结构如图所示:
    在这里插入图片描述
      随着客户数量的增加,系统决定升级为Oracle数据库,因此需要增加一个新的OracleDBUtil类来连接Oracle数据库,由于在初始设计方案中CustomerDAO和DBUtil之间是继承关系,因此在更换数据库连接方式时需要修改CustomerDAO类的源代码,将CustomerDAO作为OracleDBUtil的子类,这将违反开闭原则。【当然也可以修改DBUtil类的源代码,同样会违反开闭原则。】

    现使用合成复用原则对其进行重构。
      根据合成复用原则,我们在实现复用时应该多用关联,少用继承。因此在本实例中我们可以使用关联复用来取代继承复用,重构后的结构如图所示:
    在这里插入图片描述
      如图,CustomerDAO和DBUtil之间的关系由继承关系变为关联关系,采用依赖注入的方式将DBUtil对象注入到CustomerDAO中,可以使用构造注入,也可以使用Setter注入。如果需要对DBUtil的功能进行扩展,可以通过其子类来实现,如通过子类OracleDBUtil来连接Oracle数据库。由于CustomerDAO针对DBUtil编程,根据里氏代换原则,DBUtil子类的对象可以覆盖DBUtil对象,只需在CustomerDAO中注入子类对象即可使用子类所扩展的方法。例如在CustomerDAO中注入OracleDBUtil对象,即可实现Oracle数据库连接,原有代码无须进行修改,而且还可以很灵活地增加新的数据库连接方式。

7. 迪米特法则

迪米特法则来自于1987年美国东北大学(Northeastern University)一个名为“Demeter”的研究项目。其定义如下:

迪米特法则(Law of Demeter, LoD):一个软件实体应当尽可能少地与其他实体发生相互作用。

  它要求一个对象应该对其他对象有最少的了解,所以迪米特法则又叫做最少知识原则(Least Knowledge Principle, LKP)。
  如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易,这是对软件实体之间通信的限制,迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。

迪米特法则在类的设计上的体现
  迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
  在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

意义:
  迪米特法则的意义在于降低类之间的耦合。由于每个对象尽量减少对其他对象的了解,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。

  如何做到一个对象应该对其他对象有最少的了解呢?如果我们把一个对象看作是一个人,那么要实现“一个人应该对其他人有最少的了解”,做到两点就足够了:1.只和直接的朋友交流;2.减少对朋友的了解。

1.只和直接的朋友交流

  迪米特法则还有几种定义形式,包括:不要和“陌生人”说话、只与你的直接朋友通信等,在迪米特法则中,对于一个对象,其朋友包括以下几类:

  • (1) 当前对象本身(this);
  • (2) 以参数形式传入到当前对象方法中的对象;
  • (3) 当前对象的成员对象;
  • (4) 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  • (5) 当前对象所创建的对象。

只出现在方法体内部的类就不是直接的朋友,如果一个类和不是直接的朋友进行交流,就属于违反迪米特法则。

  任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

案例讲解:

  • 有一个学校,下属有各个学院和总部,现要求打印出学校总部员工ID和学院员工ID。代码如下:
//学校总部员工类
public class Employee{
	private String id;
	public String getId(){
		return id;
	}
	public void setId(String id){
		this.id = id;
	}
}
//学院的员工类
public class CollegeEmployee{
	private String id;
	public String getId(){
		return id;
	}
	public void setId(String id){
		this.id = id;
	}
}

//管理学院员工的管理类
public class CollegeManager{
	public List<CollegeEmployee> listCollegeEmployee(){
		public List<CollegeEmployee> list = new ArrayList<>();
		for(int = 0; i < 10; i++){
			CollegeEmployee e = new CollegeEmployee();
			e.setId("学院员工id:" + id);
			list.add(e);
		}
		return list;
	}
}

//学校管理类
public class SchoolManager{
	//返回总部所有员工
	public List<Employee> listEmployee(){
		List<Employee> list = new ArrayList<>();
		for(int i = 0; i< 5; i++){
			Employee emp = new Employee();
			emp.setId("学校总部员工id:" + i);
			list.add(emp);
		}
		return list;
	}
	//输出学校总部和学院员工信息
	public void printEmployee(CollegeManager sub){
		List<CollegeEmployee> collegeEmpList = sub.listCollegeEmployee();
		System.out.println("------------学院员工----------------");
		for(CollegeEmployee e collegeEmpList){
			System.out.println(e.getId);
		}
		List<Employee> empList = this.listEmployee();
		System.out.println("------------学校总部员工----------------");
		for(Employee e empList){
			System.out.println(e.getId);
		}
	}
}

分析:
  在类SchoolManager中,根据直接朋友的定义可知,类Employee和类CollegeManager是直接朋友,类Employee是以方法的返回值作为直接朋友,而类CollegeManager是以方法的参数作为直接朋友。
  类CollegeEmployee不是SchoolManager的直接朋友,因为CollegeEmployee是以局部变量方式出现在SchoolManager,违反了迪米特法则。
  按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合,对上面的SchoolManager、CollegeManager类进行改进,如下:

//管理学院员工的管理类
public class CollegeManager{
	public List<CollegeEmployee> listCollegeEmployee(){
		public List<CollegeEmployee> list = new ArrayList<>();
		for(int = 0; i < 10; i++){
			CollegeEmployee e = new CollegeEmployee();
			e.setId("学院员工id:" + id);
			list.add(e);
		}
		return list;
	}
	public void printCollegeEmp(){
		List<CollegeEmployee> collegeEmpList = this.listCollegeEmployee();
		System.out.println("------------学院员工----------------");
		for(CollegeEmployee e collegeEmpList){
			System.out.println(e.getId);
		}
	}
}

//学校管理类
public class SchoolManager{
	//返回总部所有员工
	public List<Employee> listEmployee(){
		List<Employee> list = new ArrayList<>();
		for(int i = 0; i< 5; i++){
			Employee emp = new Employee();
			emp.setId("学校总部员工id:" + i);
			list.add(emp);
		}
		return list;
	}
	//输出学校总部和学院员工信息
	public void printEmployee(CollegeManager sub){
		sub.printCollegeEmp();
		List<Employee> empList = this.listEmployee();
		System.out.println("------------学校总部员工----------------");
		for(Employee e empList){
			System.out.println(e.getId);
		}
	}
}

2. 减少对朋友的了解

  如何减少对朋友的了解?如果你的朋友是个话痨加大喇叭,那就算你不主动去问他,他也会在你面前叨叨叨,把他所有的经历都讲给你听。所以,要减少对朋友的了解,请换一个内敛一点的朋友吧~换作在一个类中,就是尽量减少一个类对外暴露的方法

  举一个简单的例子说明一个类暴露方法过多的情况。这个例子描述的是一个人用咖啡机煮咖啡的过程,例子中只有两个类,一个是人,一个是咖啡机:

首先是咖啡机类CoffeeMachine,咖啡机制作咖啡只需要三个方法:1.加咖啡豆;2.加水;3.制作咖啡:

public interface CoffeeMachine {
    // 加咖啡豆
    void addCoffeeBean();

    // 加水
    void addWater();

    // 制作咖啡
    void makeCoffee();
}

public class CoffeeMachineImpl implements CoffeeMachine {
    @Override
    public void addCoffeeBean() {
        System.out.printf("加咖啡豆");
    }

    @Override
    public void addWater() {
        System.out.printf("加水");
    }

    @Override
    public void makeCoffee() {
        System.out.printf("制作咖啡");
    }
}

然后就是人类Man,该类只有一个方法makeCoffee,在该方法中使用咖啡机制作咖啡:

public class Man {

    private CoffeeMachine coffeeMachine;

    public Man(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine;
    }

    public void makeCoffee() {
        coffeeMachine.addCoffeeBean();
        coffeeMachine.addWater();
        coffeeMachine.makeCoffee();
    }

    public static void main(String[] args) {
        CoffeeMachine coffeeMachine = new CoffeeMachineImpl();
        Man man = new Man(coffeeMachine);
        man.makeCoffee();
    }
}

在这个例子中,CoffeeMachine是Man的直接好友,但问题是Man对CoffeeMachine了解的太多了,其实人根本不关心咖啡机具体制作咖啡的过程。所以我们可以作如下优化:
  优化后的咖啡机类,只暴露一个work方法,把制作咖啡的三个具体的方法addCoffeeBean、addWater、makeCoffee设为私有:

public interface CoffeeMachine {
    // 加咖啡豆
    // void addCoffeeBean();

    // 加水
    // void addWater();

    // 制作咖啡
    // void makeCoffee();

    void work();
}

public class CoffeeMachineImpl implements CoffeeMachine {
    private void addCoffeeBean() {
        System.out.printf("加咖啡豆");
    }

    private void addWater() {
        System.out.printf("加水");
    }

    private void makeCoffee() {
        System.out.printf("制作咖啡");
    }

    @Override
    public void work() {
        addCoffeeBean();
        addWater();
        makeCoffee();
    }
}

现在Man对CoffeeMachine的了解只有一个work方法了,所以Man类应该修改为:

public class Man {

    private CoffeeMachine coffeeMachine;

    public Man(CoffeeMachine coffeeMachine) {
        this.coffeeMachine = coffeeMachine;
    }

    public void makeCoffee() {
        coffeeMachine.work();
    }

    public static void main(String[] args) {
        CoffeeMachine coffeeMachine = new CoffeeMachineImpl();
        Man man = new Man(coffeeMachine);
        man.makeCoffee();
    }
}

这样修改后,通过减少CoffeeMachine对外暴露的方法,减少Man对CoffeeMachine的了解,从而降低了它们之间的耦合。
  在实践中,只要做到只和直接的朋友交流和减少对朋友的了解,就能满足迪米特法则。因此我们不难想象,迪米特法则的目的,是把我们的类变成一个个“肥宅”。“肥”在于一个类对外暴露的方法可能很少,但是它内部的实现可能非常复杂(这个解释有点牵强~)。“宅”在于它只和直接的朋友交流。

注意
  迪米特法则的核心观念就是类间解耦,弱耦合。只有弱耦合了之后,类的复用才可以提高,类变更的风险才可以减低。但解耦是有限度的,除非是计算机的最小单元–二进制的0和1,否则都是存在耦合的。所以在实际项目中,需要适度地参考这个原则,避免过犹不及。

七大设计原则总结

设计原则名称 定 义
开闭原则(Open-Closed Principle,OCP) 软件实体应对扩展开放,而对修改关闭
里氏代换原则(Liskov Substitution Principle, LSP) 所有引用基类对象的地方能够透明地使用其子类的对象
依赖倒转原则(Dependence Inversion Principle, DIP) 抽象不应该依赖于细节,细节应该依赖于抽象
单一职责原则(Single Responsibility Principle, SRP) 一个类只负责一个功能领域中的相应职责
接口隔离原则(Interface Segregation Principle, ISP) 使用多个专门的接口,而不使用单一的总接口
合成复用原则(Composite Reuse Principle, CRP) 尽量使用对象组合,而不是继承来达到复用的目的
迪米特法则(Law of Demeter, LoD) 一个软件实体应当尽可能少地与其他实体发生相互作用

这 7 种设计原则是软件设计模式必须尽量遵循的原则,各种原则要求的侧重点不同。其中:

  • 【开闭原则】是总纲,它告诉我们要【对扩展开放,对修改关闭】;
  • 【里氏替换原则】告诉我们【不要破坏继承体系】;
  • 【依赖倒置原则】告诉我们要【面向接口编程】;
  • 【单一职责原则】告诉我们实现【类】要【职责单一】;
  • 【接口隔离原则】告诉我们在设计【接口】的时候要【精简单一】;
  • 【迪米特法则】告诉我们要【降低耦合度】;
  • 【合成复用原则】告诉我们要【优先使用组合或者聚合关系复用,少用继承关系复用】。

Refer

https://www.jianshu.com/p/4b244f132439

猜你喜欢

转载自blog.csdn.net/weixin_41947378/article/details/104844644