|
前言 “杀死一个程序员不需要用枪,改三次需求就可以了”,虽然这是一句话笑话,但也从侧面描述出了软件的可维护性重要性
知名软件大师Robert C.Martin认为一个可维护性(Maintainability)较低的软件设计,通常由于以下4个原因造成
1)过于僵化(Rigidity):设计难以修改
2)过于脆弱(Fragility):设计易遭到破坏(需要修改的时候,容易牵一发而动全身,不该受到影响的代码也被迫的破坏掉)
3)牢固性(Immobility):复用率低(当想使用一些功能时会发现里面的某些代码不是他想要的,想把这些代码去掉时,发现没办法去掉,原因是代码耦合度太高了)
4)粘度过高(Viscosity):难以做正确事情(维护的过程中想进行修改某些代码,但是发现没有办法进行修改,原因就是粘度太高)
软件工程和建模大师PeterCoad认为,一个好的系统设计应该具备如下三个特性:
1)可扩展性(Extendibility)
2)灵活性(Flexibility)
3)可插入性(Pluggability)
面向对象设计原则和设计模式也是对系统进行合理重构,重构是在不改变软件现有功能的基础上,通过调整代码改善软件的质量、性能,使其程序的设计模式和架构更趋合理性,提高软件的扩展性和维护性。
七种原则并不是孤立存在的,他们相互依赖,相互补充。
1.单一职责原则(重要性4颗星)
概述: 类的职责要单一,不能将太多的职责放在一个类中
定义: 一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中(另一种定义是就一个类而言,应该仅有一个引起它变化的原因。)
原则分析: 1)一个类承担的职责越多,它被复用的可能性越小,而且如果一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作。
2)类的职责主要包括俩个方面:数据职责和行为职责,数据职责通过其属性来体现,而行为职责通过其方法来体现。(注重行为职责)
3)单一职责原则是实现高内聚,低耦合的指导方针,在很多代码重构手段中都能找到它的存在,它是最简单又最难运用的原则,需要实际人员发现类的不同职责将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验。
实例说明: 登录功能通过如下登录类(Login)实现:
init方法是对登录页面进行初始化的方法
display方法是显示登录界面
validate方法是对登录的账户和密码的合法性进行验证(页面上的语法验证)
getConnertion和findUser方法对账户和密码的合法性进行具体验证
main方法是程序的入口
设计分析: 前三个是页面的方法,后面俩个是业务上的方法,最后一个是程序的入口,几个方法都属于不同方面的,说明承担的职责有些多。一个类应该只有一个点变化点,应该各司其职,不能因为某个原因而导致类发生改变,反之就表明这个类承担的职责太多了。打个比方说如果页面发生改变,init方法就需要进行更改;再打个比方说验证规则发生变化,validate方法需要更改等;这些都是变化点。所以说Login是不符合单一职责原则的(所以说,我们可以通过变化点来判断是否符合单一职责原则)。
现使用单一职责原则对其进行重构。
设计分析: 将Login进行分解为MainClass(负责程序的入口)LoginForm(界面视图),UserDAO(和用户对象打交道的类),DBUtil(负责和数据库进行连接的类)这样的四个类。我们可以看出这四个是不同的变化点,它们之间都是无关的,大打个比方说,如果界面需要改变,我们只需要将LoginForm类里面的方法,不需要更改其他类的方法,因此这样的分解是真正符合单一职责原则。
总结: 如果遵循单一职责原则的思想的话,我们就可以将一个复杂(或承担过多职责)的类按照业务逻辑这种思想把它进行分解,拆分成一个个高内聚低耦合的这样的一个类,最终目的是提高软件(或者这个类)的可维护性(或者可复用性),所以说这个思想是非常简单,但是我们在做的时候却是很难的。从这里我们应该也能看出来结构是非常重要的。所以我们在平时的练习中有意识的往这方面靠近,一定要认真的遵循这个原则。
2.开闭原则(重要性5颗星)
概述: 软件实现对扩展是开放的,但对修改是关闭的,即在不修改一个软件实体的基础上去扩展其功能
定义: 也就是说在设计一个模块的时候,应对使这个模块可以在不被修改的前提下被扩展,即实现在不修改源代码的情况下改变这个模块的行为。
原则分析: 1)开闭原则由Bertand Meyer于1988年提出的,它是面向对象设计中最重要的原则之一。
2)在开闭原则的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
3)抽象化是开闭原则的关键。
注: 抽象是指一个类或者多个类里面共性的东西抽取出来。
抽象的最大好处就是稳定的、可靠的‘不容易发生改变的。
4)开闭原则还可以通过一个更加具体的“对可变性封装原则“来描述,对可变性原则要求找到系统的可变因素并将其封装起来。
实例说明: 某图形界面系统提供了各种不同形状的按钮,客户端代码可针对这些按钮进行编程,用户可能会改变需求要求使用不同的按钮,原始设计方案如图所示:
设计分析: 现在客户想做一个登陆界面,想做一个圆形的按钮,在LoginForm界面里包含一个CircleButton类,在类里面提供一个display方法。但是过了一段时间,客户想把圆形按钮编程矩形按钮,只能把button接口改变成圆形接口。如果按照我们平时的思想来说是合情合理的,但是如果按照开闭原则来说,它是违背了开闭原则的设计,并不是好的设计。
使用开闭原则对其重构:
设计分析: 这里增加了一个AbstactButton的抽象类,下面进行俩个类的实现。LoginFrom类中的button类型发生改变,有之前的具体类型变为AbstarctButton类。可以通过多态进行转型。印证了抽象化是实现开闭原则的关键。
总结: 开闭原则思想就是在不稳定的类中间添加一个稳定的抽象类,然后把自己需要的点从具体的类中提取出来放到第三方类中,增强代码的稳定性。所以说抽象方法时开闭原则的关键。
开闭原则是衡量一个代码好坏的标准。
3.里氏替换原则(重要性4颗星)
概述: 在软件系统中,一个可以接受基类对象的地方必然可以接受一个子类对象
定义: 如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所以程序P在所有的对象o1都替换成o2时, 程序P的行为都没有变化,那么类型S是类型T的子类型。
(所有用于基类的地方必须能 透明地使用其子类的对象)
原则分析: 1)里氏替换原则由2008年图灵奖得主、美国第一位计算机科学女博士、麻省理工学院教授Barbara Liskov和卡内基。梅隆大学Jeannette Wing教授于1994年提出。
2)里氏替换原则可以通俗表述为:在 软件中如果能够使用基类对象,那么一定能够使用其子类对象。(注)把基类都替换成它的子类,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类的话,那么它不一定能够使用基类。
3)里氏替换原则是实时开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此 在程序中尽量使用基类类型来对对象进行定义,而在运行时在确定其子类类型,用子类对象来替换父类对象。
实例说明: 某系统需要实现对重要数据(如用户密码)的加密处理,在数据操作类(DataOperator)中需要调用加密类中定义的加密算法,系统提供了俩个不同的加密类,CipherA和CipherB,他们实现不同的加密方法,在DataOperator中可以选择其中的一个实现加密操作。如图所示:
Client是客户端(main方法)
DataOperator数据操作类
CipherA和CipherB俩个加密类,以不同的方式加密
设计分析: 如果需要更换一个加密算法类或者增加并使用一个新的加密算法类,如将CipherA改为CiherB,则需要修改客户类Client和数据操作类DataOperator的源代码,违背了开闭原则。
原因是DataOperator里面的方法直指俩个加密类,关系太紧密,耦合度太高。
使用里氏替换原则对其重构:
设计分析: 1.将原先无关系的CipherA和CipherB俩个类变成继承起来(因为都是给明文,得到密文。作用是一样的,方法不同而已)将CipherA当做父类,把CipherB当做子类。
2.将DataOperator类里面的变量变成CipherA这个基类。
3.把多余的set方法去掉,只要父类的。里面传的变量也是基类。
我们把客户端想使用的算法放置到客户端外面的xml文件里,如果想换就改配置文件就可以了,如果扩展直接添加子类。
总结: 如果代码中有大量的if-else或者case这种选择结构的话,说明代码需要使用里氏替换原则进行代码优化,将各种不同的情况进行抽象化,提取公共的东西创建一个父类,让子类继承,继承完后需要实现不同的功能时,就需要使用@Override重写方法(多态的形式实现)
如果程序遵循里氏替换原则,继承就能成为降低复杂的一个强大工具,因为它能让程序员关注于对象的一般特性而不必担心细节。如果程序员必须要不断地思考不同派生类的实现在语义上的差距,继承就只会增加复杂度了。
4.依赖倒置原则(重要性5颗星)
概述: 要针对抽象层编程,而不是针对具体类编程
定义: 高层模块不应该依赖底层模块,它们都应该 依赖抽象。
抽象不应该依赖于细节,细节应该依赖抽象。
注意: 要针对接口编程,不要针对实现编程。
原则分析: 1)结构良好的面向对象架构都具有清晰的层次定义,每个层次通过一个定义良好的,受控的接口向外提供一组内聚的服务。
- 高层Policy Layer->低层Mechanism Layer->低层UtilityLayer,看起来似乎正常(如果底层里面的属性或方法的名字发生改变,是不是高层的也需要发生改变,所以说是有问题的。代码脆弱)
- Policy Layer对于依赖的2个layer的改动都是敏感的。
2)依赖倒置原则是Robert C.Martin在1996年为《C++Reporter》所写的专栏Engineering Notebook的第三篇,后来加入到他在2002年出版的经典《Agile Software Development,Princiles,Patterns,and Practices》中
3)简单来说,依赖倒置原则就是指:代码要依赖于抽象的类,而不要依赖于具体的类;要针对接口或抽象类编程,而不是针对具体类编程。
依赖(耦合)关系:(1.打个比方说我现在有一个A这个类,然后A调用B里面的方法,那么这个时候就是A依赖B。2.A继承了B,这个时候A也是依赖了B)所有A和B发生关系的地方都是依赖关系。
更好的描述:不要依赖那些容易变化的具体类。如果你要继承一个类,从一个抽象类继承。如果要持有一个类的引用,从一个抽象的类引用。如果要调用一个函数,从一个抽象的函数调用。
4)实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说 开闭原则是面向对象设计的目标的话,那么 依赖倒置原则就是面向对象设计的主要手段。
5)依赖倒置于原则的常用实现方式之一是 在代码中使用抽象类,而将具体类放到配置文件中。
- “ 将抽象放进代码,将细节放进元数据”(抽象是稳定的,不可改变的。元数据是配置文件,可以修改)
- Put Abstractions in Code,Details in Metadata(《程序员修炼之道:从小工到专家》书中所说)
6)依赖倒置原则是用来解耦合的,使代码之间的耦合性越来越低,就达到了解耦的目的。
- 类之间的耦合
(高内聚是指一个软件模块是由相关性很强的代码组成,只负责一项任务,也就是常说的单一责任原则。
低耦合是指让每个模块尽可能的独立完成某个特定的子功能。)
俩个类没有耦合关系就是零耦合关系,但是这只是我们心目中希望的,很难实现。
发生在俩个具体类(可实例化的)之间,经有一个类对另一个类直接应用造成的耦合关系就是具体耦合关系。
发生在一个具体类和一个抽象类(或接口)之间,使俩个必修发生关系的类之间存在最大的灵活性的耦合就是抽象耦合关系。(比如说A依赖于B,B是个接口。这就是抽象耦合关系)
7)依赖倒置原则要求客户端依赖于抽象耦合, 以抽象方式耦合是依赖倒置原则的关键。
? 客户端经常调用服务器端的东西,所以说客户端依赖服务器端。但是依据依赖倒置原则,就建议使用抽象耦合,依赖抽象类或抽象方法。
在俩个具体的实现了加一层抽象的接口,使其稳定。(最上层依赖的是接口,下面的每层都实现接口,这样的话不管高层还是下层都是依赖于接口)
8)依赖注入:
A依赖抽象的C,B也依赖抽象的C。这个时候需要具体的代码实现B(这个时候就是依赖注入)。
- 构造注入:通过构造函数注入实例变量。
- 设值注入:通过Setter方法注入实例变量。
- 接口注入: 通过接口方法注入实例变量。
实例说明: 某系统提供一个数据转换模块,可以将来自不同数据源的数据转换成多种格式,如可以转换来自数据库的数据、也可以转换来自文本文件的数据,转换后的格式也是XML文件、也可以是XLS文件等。根据需求的设计如下:
由于需求的改变,该系统可能需要 增加新的数据源或者新的文件格式,每增加一个新的类型的数据源或者新的类型的文件格式,客户类MainClass都需要修改源代码,以便使用新的类,但违背了开闭原则。
现使用依赖倒置原则对其进行重构
将俩个具体的方法进行抽象化,同时将俩个文本转换的方法也进行抽象。然后让MainClass依赖接口,把变化的东西放到配置文件上。这样就符合依赖倒置原则了,关于扩展问题也随之解决了。
5.接口隔离原则(重要性2颗星)
概述: 使用多个专门的接口来取代一个统一的接口
定义:- 客户端不应该依赖那些它不需要的接口。
- 或者说是一旦一个接口太大,则需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。
原则分析: 1)接口隔离原则是指使用多个专门的接口,而不使用单一的总接口。每一个接口应该承担一种相对独立的角色,不多不少,不干不该干的事,该干的事都要干。
- 一个接口就只代表一个角色,每个角色都有它独特的一个接口,此时这个原则可以叫做“角色隔离原则”。
- 接口仅仅提供客户端需要的行为,即所需的方法,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不是提供大的总结课。
2)使用接口隔离原则拆分接口时,首先必须满足 单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。
3)可以在进行系统设计时采用 定制服务的方式,即 为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。
实例说明: 开发人员针对某个系统的客户数据显示模块设计了如图所示的接口
dataRead()用于从文件中读取数据
transfromToXML()用于将数据转换成XML格式
createChart()用于创建图表,
displayChart()用于显示图表
createReport()用于创建文字报表
dislayReport()用于显示文字报表
client界面,界面调用接口中的方法,方法再进行具体的实现。这样的设计虽然也实现了需要的功能,并且也满足了依赖倒置原则,但并不是很完美。
- 如果数据格式为CML,无需转换,怎么办
- 如果仅需要创建和显示图像,该怎么办
- 存在的问题:接口承担了太多职责:导致该接口的实现类很庞大,实现类中都需要实现接口所有办法,灵活性较差,如果出现大量的空方法,将导致系统中产生大量的无用代码,影响代码质量;
- 由于客户端针对大接口编程,将在 一定程度上破坏程序的封装性,客户端看到了不应该看到的方法,没有为客户端定制接口。
- 因此需要 接口隔离原则和单一职责原则进行重构。将其中的一些方法封装在不同的小接口中,确保每一个接口使用起来都比较方便,并都承担某一单一角色,每个接口中只包含一个客户端(如模块或类)所需的方法即可。(遇到这种问题就需要我们学会拆分,把大接口拆分成一个个小接口,这个使我们需要考虑的。)
现使用接口隔离原则对其进行重构:
将每个功能都进行拆分,每个类中都是单独一个实现的方法。这样就将代码优化了。
6.合成复用原则(重要性4颗星)
概述: 在系统中应该尽量多使用组合和聚合类关联关系,尽量少使用甚至不使用继承关系
合成复用原则又称为组合/聚合复用原则。
定义: 尽量使用对象组合,而不是继承来达到复用的目的。(面向对象有一个很重要的特点就是尽量复用一些代码)
原则分析: 1)合成复用原则就是指在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用其已有功能的目的。简言之:要尽量使用组合/聚合关系,少用继承。
2)在面向对象设计中,可以通过俩种基本方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承。
- 继承复用:实现简单,易于扩展。破坏系统的封装性(子类继承父类可以继承的方法或属性,那么就相当于子类知道了父类的结构或内部实现功能,所以破坏了封装性);从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性(只能改变源代码进行改变功能,不能在运行的时候进行改变。);只能在有限的环境中使用。(“白箱”复用)
- 组合/聚合复用:耦合度相对较低,选择性地调用成员对象的操作;可以在运行时动态进行。(“黑箱”复用)(A调用B类里面的方法都不知道B类里面的结构就是黑箱复用)
3)组合/聚合可以 使系统更加灵活,类与类之间的 耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般 首选使用组合/聚合来实现复用;其次才考虑继承,在使用继承时, 需要严格遵循里氏替换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难道以及系统的复杂度,因此需要 慎重使用继承复用。
实例说明: 某教学管理系统部分数据库访问类设计如图:
- 如果需要更换数据库连接方式,如原先采用JDBC连接数据库,现在采用数据库连接池连接,则需要修改DBUtil类源代码。
- 如果StudentDAO采用JDBC连接,但是TeacherDAO采用连接池连接,则需要增加一个新的DBUtil类,并修改StudentDAO或TeacherDAO的源代码,使之继承新的数据库连接类,这将违背开闭原则,系统扩展性较差。
现使用合成复用原则对其进行重构。
7.迪米特法则(重要性3颗星)
概述: 一个软件实体对其他实体的引用越少越好,或者说如果俩个类不必彼此直接通信,那么这俩个类就不应当发生直接的相互作用,而是通过引入一个第三者发生间接交互
定义: 1)不要和“陌生人”说话
2)只与你的直接朋友通信
3)每一个软件单位对其他的单位都只有最少的知识,而且局限于那些于本类单位密切相关的软件单位
原则分析: 1)迪米特法则又称为最少知识法则。
2)迪米特法则来自1987年秋美国东北大学一个名为“Demeter”的研究项目
3)简单地说,迪米特法则就是 一个软件实体应对尽可能少的与其他实体发生相互作用。这样,当一个模块修改是,就会尽量少的影响其他的模块,扩展会相对容易,这是对软件实体之间 通信的限制,他要求限制软件实体之间通信的 宽度和深度。(宽度指涉及到面积,深度是指涉及到的子类数量)
4)在迪米特法则中,对于一个对象,其朋友包括以下几类:
- 当前对象本身(this)
- 以参数形式传入到当前对象方法中的对象
- 当前对象的成员对象
- 如果当前对象的成员是一个集合,那么集合中的元素也都是朋友
- 当前对象所创建的对象
5)任何一个对象,如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人"
6)地面米特法则可分为狭义法则和广义法则。在狭义的迪米特法则中,如果俩个类之间不必彼此直接通信,那么这俩个类就不应该发生直接的相互作用,如果其中的一个类需要调用另一个类的某一个方法的话,可以通过第三者转发这个调用。
上图中Object A和Object B是相互依赖的关系A里面的某个方法里面传的参数类型是Object B类型,然后可以通讯,现在A想调用C里面的方法,第一种方法是A和C直接发生关系(但是不提倡,提倡间接发生关系)第二种方法是A调用B,B调用C,这样就是间接发生关系。
- 狭义的迪米特法则:可以 降低类之间的耦合,但是会在系统中增加大量的小方法并散落在系统的各个角落,他可以使一盒系统的局部设计简化,因为每一个就不都不会和远距离的对象有直接的关联,但是也会 造成系统的不同模块之间的特性效率降低,使得系统的不同模块之间不容易协调。
- 广义的迪米特法则: 指对对象之间的信息流量、流向以及信息的影响的控制,主要是对 信息隐藏的控制。 信息的隐藏可以使各个子系统之间脱耦,从而允许他们独立地被开发、优化、使用和修改,同时可以促进软件的复用,由于每一个模块 都不依赖于其他模块而存在,因此每一个模块都可以独立地在其他的地方使用。一个系统的规模越大,信息的隐藏就越重要,而信息隐藏的重要性也就越明显。
7)迪米特法则的主要用途在于 控制信息的过载:
- 在类的划分上,应当尽量 创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合的类一旦被修改,不会关联的类造成太大波及;
- 在类的结构设计上,每一个类都应当 尽量降低成员变量和成员函数的访问权限;
- 在类的设计上,只要有可能, 一个类型应当设计成不变类(final修饰);
- 在对其他类的引用上, 一个对象对其他对象的引用应当降低最低(能不发生关系就不发生关系)。
实例说明: 某系统界面类(如Form1、Form2等类)与数据访问类(如DAO1、DAO2等类)之间的调用关系较为复杂,如图所示:
分析: 如果数据范文类DAO1发生变化,Form1和Form2都需要发生改变。所以这种耦合关系需要进行解耦。
现使用迪米特法则对其进行重构。
分析: 在系统界面层和数据访问层之间添加一个中间层,使俩者之间不进行直接通信,这样就大幅度的解耦了。不至于牵一发而动全身。打个比方说(如果DAO1发生改变,不至于Form1进行改变,有可能只需要改变Controller1里面的一些方法)。
设计思考与总结 1)对于面向对象的软件系统来说,在支持 可维护性的同时,需要提高系统的 可复用性。
2)软件的复用可以提高软件的开发效率,提高软件质量,节约开发成本,恰当的复用还可以改善系统的可维护性。
3)单一职责原则要求在软件系统中,一个类只负责一个功能领域中的相应职责。
4)开闭原则要求一个软件实体应当对扩展开放,对修改关闭,即在不修改源代码的基础上扩展一个系统的行为。
5)里氏替换原则可以通俗表述为在软件中如果能够使用基类对象,那么一定能够使用其子类对象。
6)依赖倒置原则要求抽象不应该依赖细节,细节应该依赖抽象;要针对接口编程,不要针对实现编程。
7)接口隔离原则要求客户端不应该依赖那些它不需要的接口,即将一些大的接口细化成一些小的接口供客户端使用。
8)合成复用原则要求复用时尽量使用对象组合,而不是使用继承。
9)迪米特法则要求一个软件实体应当尽可能少的与其他实体发生相互作用。
面向对象设计干了俩件事,一个是类的设计,另一个是类与类之间的关系设计。主要目的是为了使代码达到高内聚,低耦合的目的。
结束: 本篇基本将面向对象的七大原则说完了,这里多说俩句,面向对象不止七个原则,我们只是挑里面比较重要的七个说一说。
----------------------------
原文链接:https://blog.51cto.com/14954398/2572041
程序猿的技术大观园:www.javathinker.net
[这个贴子最后由 flybird 在 2021-01-12 10:06:17 重新编辑]
|
|