引入

想必大家都知道,在面向对象中,存在着三大特性和五大原则:

  • 三大原则
    • 封装
    • 继承
    • 多态
  • 五大原则
    • 单一职责原则(Single Responsibility Principle)
    • 开放封闭原则(Open-Close Principle)
    • 里氏替换原则(the Liskov Substitution Principle)
    • 依赖倒置原则(the Dependency Inversion Principle)
    • 接口分离原则(the Interface Segregation Principle)

今天,我们的主角不是面向对象,而是设计模式,在设计模式中同样也存在着六大原则,和上述的五大原有着重复:

  • 六大原则
    • 单一职责原则,实现类要职责单一;
    • 开闭原则,要对扩展开放,对修改关闭;
    • 里氏替换原则,所有引用父类的地方必须能透明地使用其子类的对象;
    • 依赖倒置原则,要面向接口编程,不要面向实现编程;
    • 接口隔离原则,使用多个专门的接口,而不使用单一的总接口,即调用方不应该依赖那些它不需要的接口;
    • 迪米特原则,只与你的直接朋友交谈,不跟“陌生人”说话,要降低耦合。

下面我们来一一解释。

上述五大原则、六大原则仅是作者个人认为,还有包括诸如合成复用等原则,也是很有用的原则,请读者去自行了解。

六大原则

单一职责原则

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

问题由来:当类T负责两个不同的职责时,当由于职责1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责2功能发生故障。

单一职责原则告诉我们:一个类不应该担负太多的责任,就像996的你一样!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。

单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。

单一职责原的优点有:

  1. 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;

  2. 提高类的可读性,提高系统的可维护性;

  3. 降低变更引起的风险,变更是不可避免的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。

开闭原则

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

问题由来:任何软件都需要面临一个很重要的问题,即它们的需求会随时间的推移而发生变化。因为变化,升级和维护等原因,如果需要对软件原有代码进行修改,可能会给旧代码引入错误,也有可能会使我们不得不对整个功能进行重构,并且需要对原有代码经过重新测试。所以当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现使我们需要的。

为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。在Java、C#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。

为什么使用开闭原则:

第一:开闭原则非常有名

只要是面向对象编程,在开发时都会强调开闭原则。

第二:开闭原则是最基础的设计原则

其它的五个设计原则都是开闭原则的具体形态,也就是说其它的五个设计原则是指导设计的工具和方法,而开闭原则才是其精神领袖。依照Java语言的称谓,开闭原则是抽象类,而其它的五个原则是具体的实现类。

第三:开闭原则可以提高复用性

在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来,而不是在一个类中独立实现一套业务逻辑。只有这样的代码才可以复用,逻辑粒度越小,被复用的可能性越大。为什么要复用呢?复用可以减少代码的重复,避免相同的逻辑分散在多个角落,减少维护人员的工作量以及系统变化时产生bug的机会。怎么才能提高复用率呢?设计者需要缩小逻辑粒度,直到一个逻辑不可以分为止。

第四:开闭原则可以提高维护性

一款软件量产后,维护人员的工作不仅仅对数据进行维护,还可能要对程序进行扩展,维护人员最乐意的事是扩展一个类,而不是修改一个类。让维护人员读懂原有代码,再进行修改,是一件非常痛苦的事情,不要让他在原有的代码海洋中游荡后再修改,那是对维护人员的折磨和摧残。

第五:面向对象开发的要求

万物皆对象,我们要把所有的事物抽象成对象,然后针对对象进行操作,但是万物皆发展变化,有变化就要有策略去应对,怎么快速应对呢?这就需要在设计之初考虑到尽可能多变化的因素,然后留下接口,等待“可能”转变为“现实”。

如何使用开闭原则

第一:抽象约束
抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:

1.通过接口或抽象类约束扩散,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法。

2.参数类型,引用对象尽量使用接口或抽象类,而不是实现类,这主要是实现里氏替换原则的一个要求。

3.抽象层尽量保持稳定,一旦确定就不要修改。

第二:元数据(metadata)控件模块行为

编程是一个很苦很累的活,那怎么才能减轻压力呢?答案是尽量使用元数据来控制程序的行为,减少重复开发。什么是元数据?用来描述环境和数据的数据,通俗的说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。

第三:制定项目章程
在一个团队中,建立项目章程是非常重要的,因为章程是所有开发人员都必须遵守的约定,对项目来说,约定优于配置。这比通过接口或抽象类进行约束效率更高,而扩展性一点也没有减少。

第四:封装变化

对变化封装包含两层含义:

(1)将相同的变化封装到一个接口或抽象类中。

(2)将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。 封装变化,也就是受保护的变化,找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口。

里氏替换原则

定义:里氏代换原则:所有引用基类(父类)的地方必须能透明地使用其子类的对象

继承优点

代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;提高代码的重用性;子类可以形似父类,但又异于父类;提高代码的可扩展性,实现父类的方法就可以“为所欲为”了;提高产品或项目的开放性。

继承缺点

继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;降低代码的灵活性。子类必须拥有父类的属性和方法;

增强了耦合性。当父类的常量、变量和方法被修改时,必需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果,大片的代码需要重构。

克服继承的缺点——里氏替换原则

从整体上来看,利大于弊。

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

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

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

(1)子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。

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

(3) Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏代换原则,这是一个与实现无关的、纯语法意义上的检查,但Java编译器的检查是有局限的。

依赖倒置原则

定义:

高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象,其核心思想是:要面向接口编程,不要面向实现编程

依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。

在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。

在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection, DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有三种,分别是:构造注入,设值注入(Setter注入)和接口注入。构造注入是指通过构造函数来传入具体类的对象,设值注入是指通过Setter方法来传入具体类的对象,而接口注入是指通过在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。

依赖倒置原则的作用

(1)依赖倒置原则可以降低类间的耦合性。

(2)依赖倒置原则可以提高系统的稳定性。

(3)依赖倒置原则可以减少并行开发引起的风险。

(4)依赖倒置原则可以提高代码的可读性和可维护性。

依赖倒置原则的实现方法

依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性,所以我们在实际编程中只要遵循以下4点,就能在项目中满足这个规则。

(1)每个类尽量提供接口或抽象类,或者两者都具备。

(2)变量的声明类型尽量是接口或者是抽象类。

(3)任何类都不应该从具体类派生。

(4)使用继承时尽量遵循里氏替换原则。

接口隔离原则

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

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

(1) 当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时,这个原则可以叫做“角色隔离原则”。

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

接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:

(1)单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。

(2)单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。

接口隔离原则的优点

接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点:

(1)将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

(2)接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。

(3)如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。

(4)使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。

(5)能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。

接口隔离原则的实现方法

在具体应用接口隔离原则时,应该根据以下几个规则来衡量:

(1)接口尽量小,但是要有限度。一个接口只服务于一个子模块或业务逻辑。

(2)为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。

(3)了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。

(4)提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

迪米特原则

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

迪米特法则(Law of Demeter,LoD)又叫作最少知识原则(Least Knowledge Principle,LKP),产生于 1987 年美国东北大学(Northeastern University)的一个名为迪米特(Demeter)的研究项目,由伊恩·荷兰(Ian Holland)提出,被 UML 创始者之一的布奇(Booch)普及,后来又因为在经典著作《程序员修炼之道》(The Pragmatic Programmer)提及而广为人知。

迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。

迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。

在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。

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

在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

迪米特法则的优点

迪米特法则要求限制软件实体之间通信的宽度和深度,正确使用迪米特法则将有以下两个优点。

降低了类之间的耦合度,提高了模块的相对独立性。

由于亲合度降低,从而提高了类的可复用率和系统的扩展性。

但是,过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。

迪米特法则的实现方法

从迪米特法则的定义和特点可知,它强调以下两点:

从依赖者的角度来说,只依赖应该依赖的对象。

从被依赖者的角度说,只暴露应该暴露的方法。

所以,在运用迪米特法则时要注意以下 6 点:

  1. 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。

  2. 在类的结构设计上,尽量降低类成员的访问权限。

  3. 在类的设计上,优先考虑将一个类设置成不变类。

  4. 在对其他类的引用上,将引用其他对象的次数降到最低。

  5. 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。

  6. 谨慎使用序列化(Serializable)功能。

六大原则总结

单一职责原则告诉我们实现类要职责单一;

里氏替换原则告诉我们不要破坏继承体系;

依赖倒置原则告诉我们要面向接口编程;

接口隔离原则告诉我们在设计接口的时候要精简单一;

迪米特原则告诉我们要降低耦合;

开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。

但是在实际项目中出现问题都是不可避免的,使用六大原则的前提是要保证系统的稳定性,不可照本宣科造成系统设计过度或者影响系统运行

二十三种设计模式

23种经典设计模式共分为3种类型,分别是创建型、结构型和行为型。

image-20210915170151978

具体的在之后的设计模式入门之中再继续讲解。

参考

设计模式六大原则是什么