面向对象设计原则概述
- 面向对象设计原则是学习设计模式的基础,每一种设计模式都符合某一种或多种面向对象设计原则。通过在软件开发中使用这些原则,可以提高软件的可维护性和可复用性,让我们可以设计出更加灵活也更容易扩展的软件系统,实现可维护性复用的目标。
一.软件的可维护性和可复用性
通常认为,一个易于维护的系统就是复用率高的系统,而一个复用性较好的系统就是一个易于维护的系统,但实际上软件的可维护性(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),除非是重载方法。
里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
在使用里氏替换原则时需要注意如下几个问题:
1.子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏替换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,父类中不提供相应的声明,则无法在父类对象中直接使用该方法。如果在父类 BaseClass 中声明了方法 method1() ,在子类SubClass中实现了方法 method1(),并增加了新的方法 method2() ,如果客户端针对父类编程,则无法使用子类中新增方法 method2() ,此时无法直接使用父类来定义,只能使用子类,则说明该设计违背了里氏替换原则,需要在设计父类时声明方法 method2(),以确保客户端可以透明地使用父类和子类对象。
2.在运用里氏替换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法。运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏替换原则是开闭原则的具体实现手段之一。
3.Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏替换原则,这是一个与实现无关的,纯语法意义上的检查,但Java编译器的检查是有局限的。