我来讲两句
- 观看更多精彩文章访问主页:https://blog.csdn.net/weixin_45692705?spm=1011.2124.3001.5343
- 获取文章资料教程请关注下方
公众号 。 - 想要看懂源码写出好的代码,学习设计模式是必要的,在这给大家推荐一本好书(只是推荐因为我也在看,不是打广告大家买不买跟我也没有一毛钱关系 ●’?’●)。
- 大家踊跃的点赞评论一下,最近因为工作原因更新也很慢了,希望发布的作品都能上热榜吧。
好了下面我们开始正文 😊
面向对象设计原则概述
- 面向对象设计原则是学习设计模式的基础,每一种设计模式都符合某一种或多种面向对象设计原则。通过在软件开发中使用这些原则,可以提高软件的可维护性和可复用性,让我们可以设计出更加灵活也更容易扩展的软件系统,实现可维护性复用的目标。
一.软件的可维护性和可复用性
- 通常认为,一个易于维护的系统就是复用率高的系统,而一个复用性较好的系统就是一个易于维护的系统,但实际上软件的
可维护性(Maintainability) 和可复用性(Reusability) 是两个独立的目标。对于面向对象的软件系统设计来说,在支持可维护性的同时提高系统的可复用性是一个核心问题,面向对象设计原则正是为解决这个问题而诞生的。
一个可维护性较低的软件设计通常由如下几个原因造成的
过于僵硬
- 很难在一个软件系统中添加一个新的功能,增加一个新的功能将涉及很多模块,造成系统改动较大。如在源代码中存在大量的硬编码,使得代码的灵活性很差,几乎所有的修改都要面向程序源代码进行。
过于脆弱
- 与过于僵硬同时存在,修改已有系统时代码过于脆弱﹐对一个地方的修改会导致看上去没有关系的另一个地方发生故障。
复用率低
- 复用是指一个软件的组成部分可以在同一个项目的不同地方甚至在不同的项目中重复使用。而复用率低表示很难重用这些现有的软件组成部分,如类、方法、子系统等﹐即使是重用也只停留在简单的复制粘贴上,甚至根本没有办法重用,程序员宁愿不断重复编写一些已有的程序代码。
黏度过高
- 对系统进行改动时,有时候可以保存系统的原始设计意图和原始设计框架,有时候可以破坏原始意图和框架。前者对系统的扩展更有利,应该尽量按照前者来进行改动。如果采用后者比前者更容易,则称为系统的黏度过高﹐黏度过高将导致程序员采用错误的代码维护方案。
了解完 “可维护性较低的软件设计” 我们再来看看一款好的系统设计应该具备哪些性质
可扩展性
- 容易将新的功能添加到现有系统中,与“过于僵硬”相对应。
灵活性
- 代码修改时不会波及很多其他模块。与“过于脆弱"相对应。
可插入性
- 可以很方便地将一个类抽取出去,同时将另一个有相同接口的类添加进来,与“黏度过高”相对应。
这有个疑问如何使得系统满足上述的三个性质呢?
- 其关键在于恰当提高系统的可维护性和可复用性。软件的复用(Reuse)或重用拥有众多优点,如可以提高软件的开发效率,提高软件质量,节约开发成本,恰当的复用还可以改善系统的可维护性。
下面我们来举例说明 👇
-
传统的软件复用技术包括代码的复用、算法的复用和数据结构的复用等,但这些复用有时候会破坏系统的可维护性,因为可维护性和可复用性是有共性的两个独立质量属性。如A和B两个模块都需要使用另一个模块C,如果A需要C增加一个新的行为,但B不需要甚至不允许C增加该行为。如果坚持使用复用,就不得不以系统的可维护性为代价,如修改B的代码,这将破坏系统的灵活性。而如果从保持系统的可维护性出发,就只好放弃复用。而面向对象设计复用在一定程度上可以解决这两个质量属性之间发生冲突的问题。 -
面向对象设计复用的目标在于实现支持可维护性的复用,如在Java这样的语言中,可以通过面向对象技术中的抽象,继承,封装和多态等特性来实现更高层次的可复用性。通过抽象和继承使得类的定义可以复用,通过多态使得类的实现可以复用,通过抽象和封装可以保持和促进系统的可维护性。在面向对象的设计里面,可维护性复用都是以面向对象设计原则为基础的,这些设计原则首先都是复用的原则,遵循这些设计原则可以有效地提高系统的复用性,同时提高系统的可维护性。 -
面向对象设计原则和设计模式也是对系统进行合理重构的指南针。重构(Refactoring)是在不改变软件现有功能的基础上,通过调整程序代码改善软件的质量﹑性能,使其程序的设计模式和架构更趋合理,提高软件的扩展性和维护性。
二.面向对象设计原则
- 常用的面向对象设计原则包括7个,这些原则并不是孤立存在的,它们相互依赖、相互补充。
名称 | 介绍 |
---|
单一职责原则 | 类的职责要单一,不能将太多的职责放在一个类中 | 开闭原则 | 软件实体对扩展是开放的,但对修改是关闭的,即在不修改一个软件实体的基础上去扩展其功能 | 里氏替换原则 | 在软件系统中,一个可以接受基类对象的地方必然可以接受一个子类对象 | 依赖倒转原则 | 要针对抽象层编程,而不要针对具体类编程 | 接口隔离原则 | 使用多个专门的接口来取代一个统一的接口 | 合成复用原则 | 在复用功能时,应该尽量多使用组合和聚合关联关系,尽量少使用甚至不使用继承关系 | 迪米特法则 | 一个软件实体对其他实体的引用越少越好,或者说如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,而是通过引人一个第三者发生间接交互 |
- 下面我们将带大家逐个了解面向对象中用到的七大原则 🌹
单一职责原则
- 单一职责原则是最简单的面向对象设计原则,它用于控制类的粒度大小。
单一职责原则定义
- 一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。另一种定义就一个类而言,应该仅有一个引起它变化的原因。
单一职责原则分析
- 一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小,而且如果一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作。
- 类的职责主要包括两个方面:数据职责和行为职责。数据职责通过其属性来体现,而行为职责通过其方法来体现。如果职责太多,将导致系统非常脆弱,一个职责可能会影响其他职责,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中。如果多个职责总是同时发生改变,则可将它们封装在同一类中。
- 单一职责原则是实现高内聚﹑低耦合的指导方针,在很多代码重构手法中都能找到它的存在。它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验。
单一职责原则实例
实例说明
- 基于Java的C/S系统的“登录功能"通过如下登录类(Login)实现,如下图所示
在上方类图中省略了类的属性,Login类的方法说明如下:
init() 方法用于初始化按钮、文本框等界面控件;display() 方法用于向界面容器中增加界面控件并显示窗口;validate() 方法供登录按钮的事件处理方法调用,用于调用与数据库相关的方法完成登录处理,如果登录成功则进入主界面﹐否则提示错误信息;getConnection() 方法用于获取数据库连接对象Connection来连接数据库;findUser()方法用于根据用户名和密码查询数据库中是否存在该用户,如果存在则返回true,否则返回false,该方法需要调用getConnection()方法连接数据库,并供 validate()方法调用;main()函数是系统的主函数,即系统的入口。
现在使用单一职责原则对其进行重构
实例解析
- 在本实例中,类Login承担了多重职责,它既包含了与界面有关的方法。又包含了与数据库操作有关的方法,甚至还包含了系统的入口函数 main()方法。无论是对界面的修改还是对数据库访问的修改都需要修改该类,类的职责过重。如果另一个系统(如B/S系统)也需要使用该类中的数据访问代码进行登录,无法直接重用这些数据访问代码,只能复制粘贴部分代码,无法实现高层次的复用。
根据单一职责原则,可以对上述代码进行重构,按照功能将其拆分为如下4个类(还可以进一步拆分):
- 类LoginForm负责界面显示,因此它只包含与界面有关的方法和事件处理方法;
- 类 UserDAO负责用户表的增删改查操作,它封装了对用户表的全部操作代码,登录本质上是一个查询用户表的操作;
- 类DBUtil负责数据库的连接,该类可以供多个数据库操作类重用,所有操作数据库的类都可以调用该类中的getConnection()方法来获取数据库连接对象;
- 类MainClass负责启动系统,在该类中定义了main()函数。
重构后的类图
- 通过单一职责原则重构后将使得系统中类的个数增加,但是类的复用性很好。如上图中, DBUtil类可供多个DAO类使用,而UserDAO类也可供多个界面类使用,一个类的修改不会对其他类产生影响,系统的可维护性也将增强。
开闭原则
- 开闭原则是面向对象的可复用设计的第一块基石,它是最重要的面向对象设计原则。
开闭原则定义
- 一个软件实体应当对扩展开放,对修改关闭。也就是说在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即实现在不修改源代码的情况下改变这个模块的行为。
开闭原则分析
- 开闭原则由Bertrand Meyer于 1988年提出,它是面向对象设计中最重要的原则之一。在开闭原则的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
- 任何软件都需要面临一个很重要的问题,即对它们的需求会随时间的推移而发生变化。当软件系统需要面对新的需求时,我们应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。
- 为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。在类似Java,C#的面向对象编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为在具体的实现层中完成。在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
- 开闭原则还可以通过一个更加具体的 “对可变性封装原则” 来描述,对可变性封装原则(Principle of Encapsulation of Variation,EVP)要求找到系统的可变因素并将其封装起来。如将抽象层的不同实现封装到不同的具体类中,而且EVP要求尽量不要将一种可变性和另一种可变性混合在一起,这将导致系统中类的个数急剧增长,增加系统的复杂度。
- 百分之百的开闭原则很难达到,但是要尽可能使系统设计符合开闭原则,后面所学的里氏代换原则、依赖倒转原则等都是开闭原则的实现方法。在即将学习的24种设计模式中,绝大部分的设计模式都符合开闭原则,在对每一个模式进行优缺点评价时都会以开闭原则作为一个重要的评价依据,以判断基于该模式设计的系统是否具备良好的灵活性和可扩展性。
开闭原则实例
实例说明
- 某图形界面系统提供了各种不同形状的按钮﹐客户端代码可针对这些按钮进行编程,用户可能会改变需求,要求使用不同的按钮,原始设计方案如下图所示。
- 如果界面类LoginForm需要将圆形按钮(CircleButton)改为矩形按钮(RectangleButton),则需要修改LoginForm类的源代码,修改按钮类的类名,由于圆形按钮和矩形按钮的显示方法不相同,因此还需要修改LoginForm类的display(方法实现代码。
现对该系统进行重构,使之满足开闭原则的要求。
实例解析
-
分析上述实例,由于LoginForm类面向具体类进行编程,因此每次更换具体类时不得不修改源代码,而且在这些具体类中方法没有统一的接口,相似功能的方法名称不一致。如果希望系统能够满足开闭原则,需要对按钮类进行抽象化,提取一个抽象按钮类AbstractButton,LoginForm类针对抽象按钮类AbstractButton进行编程。在Java语言中,可以通过配置文件、DOM解析技术和反射机制将具体类类名存储在配置文件中,再在运行时生成其实例对象。 -
使用开闭原则对本实例进行重构后,LoginForm类将面向抽象进行编程,如果需要增加新的按钮类如菱形按钮(Diamond Button),只需要增加一个新的类继承抽象类AbstractButton并修改配置文件(如 config. xml)即可,无须修改已有类的源代码,包括抽象层类AbstractButton,具体按钮类CircleButton和 RectangleButton,以及使用按钮的界面类LoginForm 的源代码,在不修改源代码的前提下扩展系统功能的要求,完全符合开闭原则。在Java 中,配置文件一般使用XML格式的文件或properties格式的属性文件,如下图所示。
注意 :因为XML 和 properties等格式的配置文件是纯文本文件,可以直接通过VI编辑器或记事本进行编辑,且无须编译,因此在软件开发中,一般不把对配置文件的修改认为是对系统源代码的修改。如果一个系统在扩展时只涉及修改配置文件,而原有的Java代码或C#代码没有做任何修改,该系统即可认为是一个符合开闭原则的系统。
里氏替换原则
- 开闭原则的核心是对系统进行抽象化,并且从抽象化导出具体化。从抽象化到具体化的过程需要使用继承关系以及本节将要学习的里氏代换原则。
里氏替换原则定义
里氏代换原则(Liskov Substitution Principle,LSP)有两种定义方式
-
第一种定义方式相对严格:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以丁定义的所有程序Р在所有的对象o1都代换o2时,程序Р的行为没有变化,那么类型S是类型T的子类型。 -
第二种是更容易理解的定义方式:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
里氏替换原则分析
- 里氏代换原则可以通俗表述为:在软件中如果能够使用基类对象,那么一定能够使用其子类对象。把基类都替换成它的子类,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类的话,那么它不一定能够使用基类。
- 例如有两个类,一个类为 BaseClass,另一个是SubClass类,并且SubClass类是BaseClass类的子类,那么一个方法如果可以接受一个 BaseClass类型的基类对象base的话,如 method1 ( base),那么它必然可以接受一个 BaseClass类型的子类对象sub,即method1 (sub)能够正常运行。反过来的代换不成立,如方法 method2接受BaseClass类型的子类对象sub为参数(即 method2(sub))后,则一般情况下不可以有 method2(base),除非是重载方法。
- 里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
在使用里氏替换原则时需要注意如下几个问题:
- 子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏替换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,父类中不提供相应的声明,则无法在父类对象中直接使用该方法。如果在父类 BaseClass 中声明了方法 method1() ,在子类SubClass中实现了方法 method1(),并增加了新的方法 method2() ,如果客户端针对父类编程,则无法使用子类中新增方法 method2() ,此时无法直接使用父类来定义,只能使用子类,则说明该设计违背了里氏替换原则,需要在设计父类时声明方法 method2(),以确保客户端可以透明地使用父类和子类对象。
- 在运用里氏替换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法。运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏替换原则是开闭原则的具体实现手段之一。
- Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏替换原则,这是一个与实现无关的,纯语法意义上的检查,但Java编译器的检查是有局限的。
里氏替换原则实例
实例说明
- 某系统需要实现对重要数据(如用户密码)的加密处理,在数据操作类(DataOperator)中需要调用加密类中定义的加密算法,系统提供了两个不同的加密类CipherA和CipherB,它们实现不同的加密方法,在 DataOperator中可以选择其中的一个实现加密操作,如图下图所示。
- 在DataOperator类的encrypt(O)方法中,将调用加密类CipherA 或CipherB的加密方法encrypt()。如在客户类Client的 main()函数中可能存在如下代码片段:
CipherA cipherA = new CipherA();
DataOperator do = new DataOperator();
do.setCipherA(cipherA);
...
- 与之对应,在:DataOperator类的encryptO)方法中可能存在如下代码片段:
...
return cipherA.encrypt(plainText)
- 如果需要更换一个加密算法类或者增加并使用一个新的加密算法类,如将上述CipherA改为CipherB,则需要修改客户类Client和数据操作类DataOperator的源代码,违背了开闭原则。
现使用里氏替换原则对其进行重构,使得系统可以灵活扩展,符合开闭原则。
实例解析
- 在本实例中,导致系统灵活性和可扩展性差的本质原因是Client类和 DataOperator类都针对每一个具体类进行编程,每增加一个具体类都将修改源代码,此时,可以将CipherB作为CipherA的子类,Client类和 DataOperator类都针对CipherA进行编程,根据里氏代换原则,所有能够接受CipherA类对象的地方都可以接受CipherB类的对象,因此可以简化DataOperator类和Client类的代码,而且将CipherA类对象替换成CipherB类对象很方便,无须修改任何源代码。如果需要增加一个新的加密算法类,如CipherC,只须将CipherC类作为CipherA类或CipherB类的子类即可。重构后的类图如下图所示。
-
在上图中,由于CipherB是CipherA的子类,因此所有能够使用CipherA对象的地方都可以使用CipherB对象来替换,且可以将具体类的类名存储至配置文件中,如果需要使用CipherA 的encrypt()方法﹐则配置文件中存储的类名为CipherA,如果需要使用CipherB的encrypt()方法,则配置文件中存储的类名为CipherB。 -
如果需要增加一个新的加密类﹐如CipherC,则可将CipherC继承CipherA或CipherB,并覆盖其中定义的encrypt()方法,并将配置文件中存储的类名改为CipherC,所有现有类的代码无须做任何改变,完全符合开闭原则。
依赖倒转原则
- 如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是实现面向对象设计的主要机制。依赖倒转原则是系统抽象化的具体实现。
依赖倒转原则定义
依赖倒转原则分析
-
简单来说,依赖倒转原则就是指:代码要依赖于抽象的类,而不要依赖于具体的类﹔要针对接口或抽象类编程,而不是针对具体类编程。也就是说,在程序代码中传递参数时或在组合聚合关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明,参数类型声明,方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口和抽象类中声明过的方法,而不要给出多余的方法。否则将无法调用到在子类中增加的新方法。 -
实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要手段。有了抽象层,可以使得系统具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要扩展抽象层,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,满足开闭原则的要求。依赖倒转原则是COM,CORBA,EJB、Spring 等技术和框架背后的基本原则之一。
下面简单介绍一下依赖倒转原则中经常提到的两个概念,类之间的耦合 和依赖注入 。
1.类之间的耦合
在面向对象系统中,两个类之间通常可以发生三种不同的耦合关系(依赖关系)。
-
零耦合关系:如果两个类之间没有任何耦合关系,称为零耦合。 -
具体耦合关系:具体耦合发生在两个具体类(可实例化的类)之间,由一个类对另一个具体类实例的直接引用产生。 -
抽象耦合关系:抽象耦合关系发生在一个具体类和一个抽象类之间,也可以发生在两个抽象类之间,使两个发生关系的类之间存有最大的灵活性。由于在抽象耦合中至少有一端是抽象的,因此可以通过不同的具体实现来进行扩展。
依赖倒转原则要求客户端依赖于抽象耦合,以抽象方式耦合是依赖倒转原则的关键。 由于一个抽象耦合关系总要涉及具体类从抽象类继承,并且需要保证在任何引用到基类的地方都可以替换成其子类,因此,里氏代换原则是依赖倒转原则的基础。
2.依赖注入
- 对象与对象之间的依赖关系是可以传递的,通过传递依赖,在一个对象中可以调用另一个对象的方法,在传递时要做好抽象依赖,针对抽象层编程。简单来说,依赖注入就是将一个类的对象传入另一个类,注入时应该尽量注入父类对象,而在程序运行时再通过子类对象来覆盖父类对象。依赖注入有以下三种方式。
1.构造注入
public interface AbstractBook
{
public void view();
}
public interface AbstractReader
{
public void read();
}
public class ConcreteBook implements AbstractBook
{
public void view(){
...
}
}
public class ConcreteReader implements AbstractReader
{
private AbstractBook book;
public ConcreteReader(AbstractBook book){
this.book = book;
}
public void read(){
book.view();
}
}
2.设值注入
- 设值注入是通过Setter方法注入实例变量,代码如下:
public interface AbstractBook
{
public void view();
}
public interface AbstractReader
{
public void setBook(AbstractBook book);
public void read();
}
public class ConcreteBoak implements AbstractBook
{
public void view(){
...
}
}
public class ConcreteReader implements AbstractReader
{
private AbstractBook book;
public void setBook(AbstractBook book){
this.book = book;
}
public void read(){
book.view();
}
}
2.接口注入
public interface AbstractBook
{
public void view();
}
public interface AbstractReader
{
public void view(){
...
}
}
public class ConcretReader implements AbstractReader
{
public void read(AbstractBook book){
book.view();
}
}
依赖倒转实例
实例说明
- 某系统提供一个数据转换模块,可以将来自不同数据源的数据转换成多种格式,如可以转换来自数据库的数据(DatabaseSource),也可以转换来自文本文件的数据(TextSource),转换后的格式可以是XMI文件(XMI.Transformer),也可以是XL.S文件( XLSTransformer)等。
某设计人员设计如下原始类图,用于实现该数据转换模块,如下图所示。
- 由于需求的变化,该系统可能需要增加新的数据源或者新的文件格式,每增加一个新的类型的数据源或者新的类型的文件格式,客户类 MainClass都需要修改源代码,以便使用新的类,违背了开闭原则。现使用依赖倒转原则对其进行重构。
实例解析
- 在本实例中,MainClass类针对具体类编程,如果增加新的具体类必须修改 MainClass类的源代码,系统的可扩展性和灵活性受到局限﹐因此可以对这些具体类进行抽象化,使得 MainClass类针对抽象层进行编程,而将具体类放在配置文件中,重构后的系统类图如下图所示。
- 在上图中,引入了两个抽象类(或接口)AbstractSource和AbstractTransformer,MainClass依赖于这两个抽象类,针对抽象类进行编程,而将具体类类名存储在配置文件config.xml中,通过XML解析技术和Java反射机制生成具体类的实例,代换 MainClass类中的抽象对象,实现真正的业务处理。在这个过程中使用了里氏代换原则,依赖倒转原则必须以里氏代换原则为基础。增加新的数据源或文件格式时,只需要增加一个AbstractSource或 AbstractTransformer类的子类,同时修改config.xml 配置文件,更换具体类类名,无须对原有类的代码进行任何修改,满足开闭原则的要求。
接口隔离原则
- 接口隔离原则要求我们将一些较大的接口进行细化,使用多个专门的接口来替换单一的总接口。
接口隔离原则的定义
- 客户端不应该依赖那些它不需要的接口。
注意,在该定义中的接口指的是所定义的方法。 - 另一种定义,一旦一个接口太大,则需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。
接口隔离原则分析
-
实质上,接口隔离原则是指使用多个专门的接口,而不使用单一的总接口。每一个接口应该承担一种相对独立的角色,不多不少,不干不该干的事,该干的事都要干。这里的“接口”往往有两种不同的含义:一种是指一个类型所具有的方法特征的集合,仅仅是一种逻辑上的抽象﹔另外一种是指某种语言具体的“接口”定义,有严格的定义和结构,如Java语言里面的interface。对于这两种不同的含﹐ISP的表达方式以及含义都有所不同。 -
当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,这就是一种逻辑上的概念,接口的划分将直接带来类型的划分。此时,可以把接口理解成角色,一个接口就只代表一个角色,每个角色都有它特定的一个接口,此时这个原则可以叫做“角色隔离原则”。 -
如果把“接口”理解成狭义的特定语言的接口,那么ISP表达的意思是指接口仅仅提供客户端需要的行为,即所需的方法,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。在面向对象编程语言中,如果需要实现一个接口,就需要实现该接口中定义的所有方法,因此大的总接口使用起来不一定很方便。为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。接口应该尽量细化,同时接口中的方法应该尽量少,每个接口中只包含一个客户端(如子模块或业务逻辑类)所需的方法即可。 -
使用接口隔离原则拆分接口时,首先必须满足单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下﹐接口中的方法越少越好。可以在进行系统设计时采用定制服务的方式,即为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。
接口隔离原则实例
- 下面通过一个简单的实例来加深对接口隔离原则的理解。
实例说明
- 下图展示了一个拥有多个客户类的系统,在系统中定义了一个巨大的接口AbstractService来服务所有的客户类。
- 如果客户类ClientA只须针对方法 operatorA()进行编程,但由于提供的是一个胖接口,AbstractService的实现类ConcreteService必须实现在AbstractService中声明的所有三个方法,而且在ClientA中除了能够看到方法 operatorA() ,还能够看到与之不相关的方法operatorB()和 operatorC() ,在一定程度上影响系统的封装性。因此,可以使用接口隔离原则对其进行重构。
实例解析
- 由于在接口AbstractService中三个不同的方法分别对应三类不同的客户端,因此需要将该接口进行细化,以确保每一类用户都具有与之对应的专门的接口,可以将该接口分割成三个小接口,如图下图所示。
- 通过对AbstractService接口的细化,我们可以将其分割为三个专门的接口;AbstractServiceA ,A bstractServiceB和AbstractServiceC,在每个接口中只包含一个方法,用于对应一个客户端。在实际使用过程中,如果一个客户端对应多个方法,可以将这几个方法封装在同一个小接口中。接口实现类ConcreteService可以一次性实现这三个接口,也可以提供三个接口实现类分别实现这三个接口。无论是使用一个实现类还是使用三个实现类,对于ClientA等客户端类而言没有任何区别,因为它们是针对抽象的接口编程,只能看到与自己相关的业务方法,不能访问其他方法,因此保证系统具有良好的封装性。同时,无须关心一个业务方法的改变会给一些不相关的类造成影响,因为这些类根本无法访问该方法。
- 在使用接口隔离原则时需要注意接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护,接口也不能太大,太大的接口将违背接口隔离原则,灵活性较差,使用起来很不方便。一般而言,接口中仅包含为某一类用户定制的方法即可。
合成复用原则
- 合成复用原则是面向对象设计中非常重要的一条原则。为了降低系统中类之间的耦合度,该原则倡导在复用功能时多用关联关系,少用继承关系。
合成复用原则定义
- 合成复用原则(Composite Reuse Principle,CRP)又称为组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP),其定义为:
尽量使用对象组合,而不是继承来达到复用的目的。
合成复用原则分析
- GoF提倡在实现复用时更多考虑用对象组合机制,而不是用类继承机制。通俗地说,合成复用原则就是指在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分﹔新对象通过委派调用已有对象的方法达到复用其已有功能的目的。简言之,要尽量使用组合/聚合关系,少用继承。
在面向对象设计中,可以通过两种基本方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,这两种复用机制的特点如下:
- 通过继承来实现复用很简单,而且子类可以覆盖父类的方法,易于扩展。但其主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的某些内部细节对子类来说是可见的,所以这种复用又称为“白箱”复用。如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(例如类不能被声明为final类)。
- 通过组合/聚合来复用是将一个类的对象作为另一个类的对象的一部分,或者说一个对象是由另一个或几个对象组合而成。由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳人到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象是不可见的,所以这种复用又称为“黑箱”复用。相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作﹔合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
- 组合/聚合可以使系统更加灵活,类与类之间的耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般首选使用组合/聚合来实现复用,其次才考虑继承。在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。
合成复用原则实例
实例说明
- 在该类图中, DBUtil类用于连接数据库,它提供了一个 getConnection()方法,用于返回一个Connection类型的数据库连接对象。由于在StudentDAO、TeacherDAO等类中都需要连接数据库,因此需要复用getConnection()方法,在本设计方案中, StudentDAO、TeacherDAO等数据访问类直接继承 DBUtil 类,复用其中定义的方法。
- 如果需要更换数据库连接方式,如原来采用JDBC连接数据库,现在采用数据库连接池连接,则需要修改 DBUtil类源代码。如果StudentDAO采用JDEC连接,但是TeacherDAO采用连接池连接,则需要增加一个新的 DBUtil类,并修改StudentDAO或TeacherDAO的源代码,使之继承新的数据库连接类,这将违背开闭原则,系统扩展性较差。
下面使用合成复用原则对其进行重构
实例解析
- 根据合成复用原则,我们可以使用组合/聚合复用来取代继承复用,如下图所示。
- StudentDAO和TeacherDAO类与DBUril 类不再是继承关系,而改为聚合关联关系,并增加一个setDBOperator()方法来给DBUtil类型的成员变量dBOperator赋值。如果需要改为另一种数据库连接方式,只需要给DBUtil 增加一个子类,如NewDBUtil,在该子类中覆盖getConnection()方法,再在客户类中调用setDBOperator()方法时注入子类对象即可。如果希望系统更加灵活一点,可以在客户类中针对 DBUtil编程,而将具体类类名存储在配置文件中,DBUtil类及其子类都可以直接应用于该系统。用户无须修改任何源代码,只需修改配置文件即可完成新的数据库连接方式的使用,完全符合开闭原则。
迪米特法则
- 迪米特法则用于降低系统的耦合度,使类与类之间保持松散的耦合关系。
迪米特法则定义
迪米特法则(Law of Demeter,LoD)又称为最少知识原则(Least Knowledge Principle,LKP),它有多种定义方法,其中几种典型定义如下:
- 不要和 “陌生人” 说话。
- 只与你的直接朋友通信。
- 每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
迪米特法则分析
- 简单地说,迪米特法则就是指一个软件实体应当尽可能少地与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少地影响其他的模块,扩展会相对容易,这是对软件实体之间通信的限制,它要求限制软件实体之间通信的宽度和深度。
在迪米特法则中,对于一个对象,其朋友包括以下几类:
- 当前对象本身
this ; - 以参数形式传入到当前对象方法中的对象;
- 当前对象的成员对象;
- 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
- 当前对象所创建的对象。
任何一个对象如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。迪米特法则可分为狭义法则和广义法则。在狭义的迪米特法则中,如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用,如图下图所示。
-
在上图中,Object A 与Object B存在依赖关系,ObjectC是Object B的成员对象,根据迪米特法则,Object A只能调用ObjectB中的方法,而不允许调用Object C中的方法,因为它们之间不存在直接引用关系。根据迪米特法则,不允许出现a.method1(). method2()或者a. b. method()这样的调用方式,只允许出现 a. method(),也就是在方法调用时只能够出现一个“.”(点号)。 -
狭义的迪米特法则 可以降低类之间的耦合,但是会在系统中增加大量的小方法并散落在系统的各个角落,它可以使一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联,但是也会造成系统的不同模块之间的通信效率降低,使得系统的不同模块之间不容易协调。 -
广义的迪米特法则 就是指对对象之间的信息流量﹑流向以及信息的影响的控制,主要是对信息隐藏的控制。信息的隐藏可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化,使用和修改,同时可以促进软件的复用,由于每一个模块都不依赖于其他模块而存在,因此每一个模块都可以独立地在其他的地方使用。一个系统的规模越大,信息的隐藏就越重要,而信息隐藏的重要性也就越明显。
迪米特法则的主要用途在于控制信息的过载。在将迪米特法则运用到系统设计中时,要注意下面的几点:
- 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低﹐就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及。
- 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限。
- 在类的设计上,只要有可能,一个类型应当设计成不变类。
- 在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
迪米特法则实例
实例说明
- 某系统界面类(如Form1 ,Form2等类)与数据访问类(如DAO1,DAO2等类)之间的调用关系较为复杂,如下图所示。
- 由于存在复杂的调用关系,将导致系统的耦合度非常大,重用现有类比较困难,增加新的界面类或数据访问类也比较麻烦。现需要降低界面类和业务逻辑类之间的耦合度,可使用迪米特法则对系统进行重构。
实例解析
- 为了降低界面类与数据访问类之间的耦合度,可以在它们之间引人一系列控制类(如Controller1,Controller2等类),由控制类来负责控制界面类对业务逻辑类的访问,重构之后的类图如下图所示。
- 在重构过的上图中,由于控制类的引入,界面类与数据访问类之间不存在直接引用关系。如果增加一个新的界面类如Form6,需要引用DAO2,DAO3和DAO4,原来需要建立三个引用关系,而有了控制类后,只需要直接引用控制类Controller2即可。如果需要增加新的数据访问类,可以对应增加新的控制类或者修改现有控制类,无须修改原有界面类。系统具有较好的灵活性,且可以很方便地重用现有的界面类和数据访问类。
总结
- 对于面向对象的软件系统设计来说,在支持可维护性的同时,需要提高系统的可复用性。
- 软件的复用可以提高软件的开发效率,提高软件质量,节约开发成本,恰当的复用还可以改善系统的可维护性。
- 单一职责原则要求在软件系统中,一个类只负责一个功能领域中的相应职责。
- 开闭原则要求一个软件实体应当对扩展开放,对修改关闭,即在不修改源代码的基础上扩展一个系统的行为。
- 里氏替换原则可以通俗表述为在软件中如果能够使用基类对象,那么一定能够使用其子类对象。
- 依赖倒转原则要求抽象不应该依赖于细节,细节应该依赖于抽象,要针对接口编程,不要针对实现编程。
- 接口隔离原则要求客户端不应该依赖那些它不需要的接口,即将一些大的接口细化成一些小的接口供客户端使用。
- 合成复用原则要求复用时尽量使用对象组合,而不使用继承。
- 迪米特法则要求一个软件实体应当尽可能少地与其他实体发生相互作用。
这也不是广告,给大家推荐的工具罢了,本文中的图片纯手绘如有错别字请见谅,评论区指出我会及时更改的。
- 好了,到此Java面向对象设计原则就总结完毕了,大家看完之后别忘了一键三连关注下方公众号哦 ?
|