注:本篇参考了《Java设计模式》、《设计模式之禅师(第2版)》。
目录
这一篇是设计模式的第一篇,主要说明设计模式是什么、设计模式分类以、设计模式原则等,不描述具体的设计模式。
1. 设计模式介绍
1.1 设计模式简介
设计模式(Design Pattern)是对面向对象设计中反复出现的问题的解决方案。这个术语是在1990年代由Erich Gamma等人从建筑设计领域引入到计算机科学中来的。
情况是这个术语的含义还存有争议。算法不是设计模式,因为算法致力于解决问题而非设计问题。设计模式描述了一组相互紧密作用的类与对象。它还提供了一种讨论软件设计的公共语言,使得熟练设计者的设计经验可以被初学者和其他设计者掌握。它也为软件重构提供了目标。
--------以上内容摘《软件设计模式》自百度百科
1.2 设计模式历史
肯特贝克和沃德坎宁安在1987年利用克里斯托佛亚历山大在建筑领域里的思想开发了设计模式并把此思想应用在Smalltalk中的图形用户接口的生成中。一年后Erich Gamma在他的苏黎世大学博士毕业论文中开始尝试把这种思想改写为适用于软件开发。与此同时于此同时James Coplien 在1989年至1991 年也在利用相同的思想致力于C++的开发,而后于1991年发表了他的著作Advanced C++ Idioms。就在这一年Erich Gamma 得到了博士学位,然后去了美国,在那与Richard Helm, Ralph Johnson ,John Vlissides合作出版了Design Patterns - Elements of Reusable Object-Oriented Software 一书,在此书中共收录了23个设计模式。这四位作者在软件开发领域里也以他们的匿名著称Gang of Four(四人帮,简称GoF),并且是他们在此书中的协作导致了软件设计模式的突破。有时这个匿名GoF也会用于指代前面提到的那本书。
-------以上内容摘《软件设计模式》自百度百科
1.3 设计模式是什么
在面向对象或面向过程思想的编程中,最开始往往是做一些重复简单的工作,可能还不知道设计模式是什么。加上一些语言精练性和纯洁性,编程工作将变成一个让你时刻体验创造快感的激动人心的过程。
随着工作经验越来越丰富,可能会发现其中某些设计模式已经无意中使用过,也应该是看到过使用设计的代码,甚至自己也写过一些涉及设计模式的代码。
整个设计模式贯穿一个原理:面向接口编程,而不是面向实现。目标原则是:降低耦合,增强灵活性。
设计模式是一座桥梁,是编程语言基础知识与框架之间一座隐形的桥梁。表面上它看似是一具体的技术,实际上,它并不是一种具体技术,它讲述的一种思想。在Java这种面向对象的编程语言中,它不仅仅展示了接口或抽象类在实际安全中的灵活应用和智慧,让你能够真正掌握接口或抽象类的应用,从而在原来的Java语言基础上跃进了一步,让你的程序尽可能的可重用。
软件需求变幻无穷,计划没有变化快,但还是要寻找出不变的东西,并将它和变化的东西分离出来,这需要非常的智慧和经验。设计模式可以说是在这方面探索的一个重要内容。
像开始开发中用到的框架,就不同于编程语言的API,框架的内容不再被动的被使用、被调用,而是深刻的介入到一个领域中去,框架设计的目地就是将一个领域中不变的东西先定义好。比如整体结构和一些主要职责(如数据库操作、事务跟踪、安全等),剩余的就是变化的东西,针对这个领域中具体应用产生的具体不同的变化需求,而这些变化的东西就是程序员所要做的。
从上面来说,设计模式和编程语言的框架在思想和动机上是一脉相承的,只不过还有以下几点区别:
- 设计模式更抽象,在对每个应用时才会产生具体的代码;框架是具体的产品代码,可以接触到。
- 设计模式经框架的体系结构更小,许多框架都是应用设计模式来完成的。
- 设计模式几乎可以应用到任何应用中,而框架基本上只适用应用到某个领域。
总而言之,设计模式是一套可以被反复使用、经过分类编目的、代码设计经验的总结。而使用设计模式,是为了可重用代码、让代码更容易被他人理解、代码的可靠性、程序的重用性。
1.4 设计模式的格式
--------以下内容摘《软件设计模式》自百度百科
尽管名称和顺序在不同的资料中各有不同,描述模式的格式大致分为以下四个主要部分:
1.4.1 模式名称(Pattern Name)
每一个模式都有自己的名字,模式的名字使得我们可以讨论我们的设计。
它是一个助记名,它用一两个词来描述模式的问题、解决方案和效果。命名一个新的模式增加了我们的设计词汇。设计模式允许我们在较高的抽象层次上进行设计。基于一个模式词汇表,我们自己以及同事之间就可以讨论模式并在编写文档时使用它们。模式名可以帮助我们思考,便于我们与其他人交流设计思想及设计结果。找到恰当的模式名也是我们设计模式编目工作的难点之一。
1.4.2 问题(Problem)
在面向对象的系统设计过程中反复出现的特定场合,它导致我们采用某个模式。
描述问题存在的前因后果,它可能描述了特定的设计问题,如怎样用对象表示算法等。也可能描述了导致不灵活设计的类或对象结构。有时候,问题部分会包括使用模式必须满足的一系列先决条件。
1.4.3 解决方案(Solution)
上述问题的解决方案,其内容给出了设计的各个组成部分,它们之间的关系、职责划分和协作方式。
描述了设计的组成成分,它们之间的相互关系及各自的职责和协作方式。因为模式就像一个模板,可应用于多种不同场合,所以解决方案并不描述一个特定而具体的设计或实现,而是提供设计问题的抽象描述和怎样用一个具有一般意义的元素组合(类或对象组合)来解决这个问题。
1.4.4 效果(Consequence)
采用该模式对软件系统其他部分的影响,比如对系统的扩充性、可移植性的影响。影响也包括负面的影响。
描述了模式应用的效果及使用模式应权衡的问题。尽管我们描述设计决策时,并不总提到模式效果,但它们对于评价设计选择和理解使用模式的代价及好处具有重要意义。软件效果大多关注对时间和空间的衡量,它们也表述了语言和实现问题。因为复用是面向对象设计的要素之一,所以模式效果包括它对系统的灵活性、扩充性或可移植性的影响,显式地列出这些效果对理解和评价这些模式很有帮助。
别名(Also Known As):一个模式可以有超过一个以上的名称。这些名称应该要在这一节注明。
动机(Motivation):该模式应该利用在哪种情况下是本节提供的方案(包括问题与来龙去脉)的责任。
应用(Applicability)
结构(Structure):这部分常用类图与互动图阐述此模式。
参与者(Participants):这部分提供一份本模式用到的类与物件清单,与它们在设计下扮演的角色。
合作(Collaboration):描述在此模式下,类与物件间的互动。
结果(Consequences):这部分应描述使用本模式後的结果、副作用、与交换(trade-off)
实现(Implementaion):这部分应描述实现该模式、该模式的部分方案、实现该模式的可能技术、或者建议实现模式的方法。
例程(Sample Code):示范程式。
已知应用(Known Uses):业界已知的实做范例。
相关模式(Related Patterns):这部分包括其他相关模式,以及与其他类似模式的不同。
2. 设计模式分类
设计模式一共有23种,这些模式可以分为三大类:创建型模式(Creational Patterns)、结构型模式(Structural Patterns)、行为型模式(Behavioral Patterns)。也还有一些其它的设计模式,比如J2EE的设计模式。
序号 | 模式 & 描述 | 包括 |
---|---|---|
1 | 创建型模式 这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。 |
|
2 | 结构型模式 这些设计模式关注类和对象的组合。继承的概念被用来组合接口和定义组合对象获得新功能的方式。 |
|
3 | 行为型模式 这些设计模式特别关注对象之间的通信。 |
|
下面的表格是J2EE中的设计模式。除此以外,还有规格模式、对象池模式、雇工模式、黑板模式、空对象模式等。
J2EE 模式 这些设计模式特别关注表示层。这些模式是由 Sun Java Center 鉴定的。 |
|
下图是23种设计模式的关系图。
3. 设计模式六大原则
设计模式有六大原则,在使用设计模式时须要谨记。
3.1 单一职责原则
单一职责原则的英文名称是Single Responsibility Principle,简称SRP。其实这个原则有些争议,争议之处在于职责的定义,什么是类的职责,怎么划分类的职责。下面举例说明。
在实际开发项目的过程中,应该都会接触到用户、机构、角色这些功能模块,基本上使用的都是RBAC模型(Role - Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离),确实是一个很好的解决办法。这里要讲的用户管理、修改用户、增加机构(一个人属于多个机构)、增加角色等,用户有这么多的信息和行为要维护,就把这些写到一个接口中,都是用户管理的嘛,看看下面的类图。
类图其实是比较简单的,一个初级程序员也可以看出这个接口设计的有问题,用户的属性和行为没有分开,这是一个严重的错误!这个接口设计的确实一团糟,应该把用户的信息抽取成一个BO(Business Object,业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑),按照这个思路对类图进行修正,如下图所示:
重新拆分成两个接口,IUserBO负责用户的属性,简单地说,IUserBO的职责就是收集和反馈用户的属性信息;IUserBiz负责用户的行为,完成用户信息的维护和变更。有人可能要说了,这与实际工作中用到的User类还是有差别的啊。别急,先来看看分拆成两个接口怎么使用。OK,现在是面向接口编程,所以产生了这个UserInfo对象之后,当然可以把它当IUserBO接口来使用。也可以当IUserBiz接口使用,这要看你在什么地方使用了。要获得用户信息,就当是IUserBO的实现类;要是希望维护用户的信息,就把它当作IUserBiz的实现类就成了。如下代码清单所示:
确实可以这样,问题也解决了。下面来分析下刚才的动作,为什么要把一个接口拆分成两个呢?其实在实际使用中,更倾向于使用两个不同的类或接口:一个是IUserBO,一个是IUserBiz,类图如下图所示:
以上把一个接口拆分成两个接口的动作,就是依赖了单一职责原则,什么是单一职责原则?单一职责原则的定义就是:应该有且仅有一个原因引起的类的变更(ASD)。
在现实生活中,不少公司会有自己的产品,而不是来什么就做什么,其中一个原因就是做一个产品比几个不算产品的东西可以把一个产品做的更好。做一个产品不容易,但职责更单一一些,不少时候是一个更好的选择。比如软件公司也是,慢慢都是开始做产品,而不是能接到什么项目就做什么项目。产品可以提高公司的影响力和竞争力。
一个类若承担的职责过多,就等于把这些职责粘连在一起,一个职责的变化可能会削弱或抑制这个类完成其他职责的能力,这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意思不到的破坏(ASD)。软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离(ASD)。如果能够想到多于一个的动机去改变这个类,那这个类就具有多于一个的职责,就应该考虑类的职责分离。
3.2 里氏替换原则
3.2.1 里氏替换原则定义
在面向对象编程语言中,继承是必不可少的、非常优秀的语言机制,它如下面几个优点:
- 代码共享,减少类的创建,每个子类都拥有父类的方法和属性;
- 提高代码的重用性;
- 子类可以形似父类,但又异于父类;
- 提高代码的可扩展性,实现父类的方法就可以做自己想做的事了;
- 提高产品或项目的开放性。
但继承也有以下几方面的缺点:
继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
降低了代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了一些约束;
增强了耦合性。当父类是常量、变量或方法被修改时,需要考虑子类的修改,且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大段的代码需要重构。
Java使用extends关键字来实现继承,它采用了单一继承的规则,C++则采用了多重继承的规则,一个子类可以继承多个父类。从整体上看,利大于弊,怎么才能让利大于弊呢?解决方案就是引用里相氏替换原则(Liskov Substitution Principle, LSP)。里氏替换原则由Barbara Liskov提出。意思是若调用的是父类的话,那换成子类也完全可以运行。里氏替换原则有两种定义:
- 第一种定义,也是最正宗的定义:若对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。
- 第二种定义,所有引用基类的地方必须能透明地使用其子类的对象。
第二种定义更清晰明确,也更通俗易懂,只要父类能出现的地方子类就可以出现,且替换为子类也不会产生任何错误或异常,使用者可能根本不需要知道是父类还是子类。但是反过来就不行了,有子类出现的地方,父类未必就能适应。
3.2.2 里氏替换原则含义
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了4层含义。
1. 子类必须完全实现父类的方法
在实际开发项目时,经常会定义一个接口或抽象类,然后实现,调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。
需要注意的,若在类中调用其他类时务必要使用父类或接口,若不能使用父类或接口,则说明类的设计已经违背了LSP原则。若子类能不完整地实现父类的方法,或在父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承父亲,采用依赖、聚集、组合等关系代替继承。
2. 子类可以有自己的个性
子类当然可以有自己的属性和行为。从里低替换的原则来看,就是有子类出现的地方父类未必就可以出现。若向下转型(downcast)是不安全的。
3. 覆盖或实现父类的方法时输入参数可以被放大
方法中的输入参数称为前置条件。做过Web Service开发应该知道有一个“契约优先”的原则,也就是先定义出WSDL接口,制定好双方的开发协议,然后再各自实现。里氏替换原则也要求制定一个契约,就是父类或接口,这种设计方法也叫做Design by Contract(契约设计),与里氏替换原则有着异曲同工之妙。契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。
覆盖或实现父类的方法时输入参数可以被放大,就是说子类的方法的参数类型可以是宽于父类方法的参数类型。比如父类方法A的参数类型是HashMap,而子类方法A的参数类型可以是Map。子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或更为宽松,否则可能会出现一些问题。
4. 覆写或实现父亲的方法时输出结果可能被缩小
这句话的意思是,父类的一个方法的返回值是类型T,子类的相同方法(重载或覆盖)的返回类型是S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。为什么呢?
分两种情况,若是覆盖,子类覆盖父类的方法,子类方法的返回类型小于等于父类是必须的。若是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则的要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,这里可以参考上面描述的前置条件。
里氏替换原则的目的是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美。
在项目中,采用里氏替换原则时,应尽量避免子类的个性,一旦子类有个性,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的个性须被抹杀——委屈了点;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得扑朔迷离——缺乏替换的标准。
3.3 依赖倒置原则
3.3.1 概述
依赖倒置原则(Dependence Inversion Principle,DIP)。根据英文翻译过来,包含三层含义:
- 高层模块不应该依赖于低层模块,两者都应该依赖其抽象;
- 抽象不应该依赖于细节;
- 细节应该依赖于抽象。
高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。抽象在Java语言中,就是指接口或抽象类,两者都是不能直接被实现化的。细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化。依赖倒置原则在Java语言中的表现就是:
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
- 接口或抽象类不依赖于实现类;
- 实现类依赖接口或抽象类。
通俗来说,要针对接口编程,而不是对象实现编程。传递参数,或在组合聚合关系中,尽量引用层次高的类。主要是在构造对象时可以动态的创建各种具体对象,当然若一些具体类比较稳定,就不必再弄一个抽象类做它的父类,这样会有画蛇添足的感觉。
采用依赖原则可以减少类之间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。比如说司机驾驶奔驰车,其类图如下:
司机类的代码如下:
奔驰类的代码如下:
客户端测试时代码如下:
上面的代码很简单。那如果变更一下,司机张三不仅要开奔驰车,还要开宝马车,怎么处理呢?可以简单的加一个宝马车类。
宝马车产生了,但却没有办法让张三开动起来。因为张三没有开动宝马车的方法。正常来说开宝马车和开奔驰车需要驾照是一样的,但却开不了宝马车,显然是不合理的。出现这样的情况,问题就在于司机类和奔驰车类之间是紧耦合的,这样导致的结点就是系统的可维护性大大降低,可读性降低,两个相似的类需要阅读两个文件,有些麻烦。还有稳定性,固化的、健壮的才是稳定的,这里只增加了一个车类就需要修改司机类,这说明并不稳定,而是易变。被依赖者的变更竟然要让依赖者来承担修改的成本。这个关系是有毛病的。
设计是否具备稳定性,只要适当“松松土”,观察“设计的蓝图”是否可以茁壮成长就可以得出结论,稳定性较高的设计,在周围环境频繁变化的时候,依然可以做到“我自岿然不动”。一句话就是,变更更显真功夫!
再来说说减少并行开发引起的风险。并行开发最大的风险就是风险扩散,本来只是一段程序的错误或导演,逐步波及一个功能,甚至到最后毁坏了整个项目。为什么会这样呢?比如一个团队有20个开发人员,每个人负责不同的功能模块,甲负责汽车类的建造,乙负责司机类的建造,在甲没有完成的情况下,乙是不能完全的编写代码的,因为缺点汽车类,编译都无法通过。在这种不使用依赖倒置原则的环境中,所有的开发都是“单线程”的,甲做完,乙再做,然后是丙继续......这种“个人英雄主义”的编程模式已经不适合目前的情况了。一个项目是一个团队协作的结果,要协作就是并行开发,并行开发就要解决模块之间的项目依赖关系。这时依赖倒置原则要登场了。
综合以上的描述,若不使用依赖倒置原则会加重类之间的耦合性,降低系统的稳定性,增加并行开发引起的风险,降低代码的可读性和可维护性。再来说说上面司机和车的例子,引入依赖倒置原则后的类图如下。
建立两个接口:IDriver和ICar,分别定义了司机和汽车的各个职能,司机就是驾驶汽车,须实现driver()方法。如此一来,就好处理多了,也可以应对更多的变更。
3.3.2 依赖的三种写法
依赖是可以传递的,A对象依赖B对象,B对象又依赖C对象,C对象又依赖D对象......,只要做到抽象依赖,即使是多层的依赖传递也无所谓。对象的依赖关系有三种方式来传递。如下所示。
1. 构造函数传递依赖对象
在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方法叫做构造函数注入。
2. Setter方法传递依赖对象
在抽象中设置Setter方法声明依赖关系,依照依赖注入的说法,叫Setter依赖注入。
3. 接口传递依赖对象
在接口的方法中声明依赖对象,该方法也叫接口注入。
3.3.3 最佳实践
依赖倒置原则的本质是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合。在实际应用中,需要遵循以下几个规则:
- 每个类尽量都有接口或抽象类,或抽象类和接口两者都具备,但也要看实际情况;
- 变量的表面类型(表面类型指在定义时赋予的类型,实际类型是指对象的类型)尽量是接口或抽象类;
- 任何类都不应该从具体类派生;
- 尽量不要覆盖基类的方法;
- 结合里氏替换原则使用。
3.4 接口隔离原则
3.4.1 定义
隔离有两种定义:
- 第一种:客户端不应该依赖它不需要的接口;
- 第二种:类之间的依赖关系应该建立在最小的接口上。
客户端不应该依赖它不需要的接口,而应该依赖于它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性。类之间的依赖关系应该建立在最小的接口上,它要求是最小的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同描述。
简单点说,就是接口建立时,尽量单一,不要建立臃肿庞大的接口,就是接口尽量细化,同时接口中的方法尽量少。这样一看跟单一职责原则很像,其实接口隔离原则与单一职责原则审视的角度是不同的。单一职责原则要求类和接口职责单一,注重的职责,这是业务逻辑上的划分;而接口隔离原则要求接口的方法尽量少。
例如一个接口的职责可能包含10个方法,这10个方法都放在一个接口中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约束“不使用的方法不要访问”,按照单一职责原则是允许的,按照接口隔离原则是不允许的,因为它要求“尽量使用多个专门的接口”。专门的接口是指提供给每个模块的都应该是单一的接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。
3.4.2 保证接口的纯洁性
接口隔离原则是对接口进行规范约束,其包含以下四层含义:
1. 接口要尽量小
这是接口隔离原则的核心定义,不要出现臃肿的接口(Fat Interface),但是小也是有限度的。首先就是不能违反单一职责原则。根据接口隔离原则拆分接口时,首先必须满足单一职责原则!
2. 接口要高内聚
高内聚就是提高接口、类、模块的处理能力,减少对外的交互。对接口隔离原则来说就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越小,同时也有利于降低成本。
3. 定制服务
一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口(并不一定就是Java中定义的Interface,也可能是一个类或单纯的数据交换),设计时就需要为各个访问者(即客户端)定制服务。就是单独为一个个体提供优良的服务。在做系统时也需要考虑到对系统之间或模块之间的接口采用定制服务。采用定制服务就必然有一个要求:只提供访问者需要的方法。
4. 接口设计是有限度的
接口设计的粒度越小,系统越灵活,这是不争的事实。但灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个度一般是根据经验和常识来判断,没有一个固化或可测量的标准。
3.4.3 最佳实践
在实践中可以根据以下几个规则来衡量:
- 一个接口只服务于一个子模块或业务逻辑;
- 通过业务逻辑压缩接口中的public方法,接口时常回顾,尽量让接口达到“满身筋骨肉”,而不是“肥嘟嘟”的一大堆方法;
- 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理;
- 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,看到高手这样做的,千万别直接照抄。环境不同,接口拆分的标准就不同,深入了解业务逻辑,最好的接口设计就出自你的手中!
3.5 迪米特法则
迪米特法则(Law of Demeter,LoD)也称为最小知识原则(Least Knowledge Principle,LKP)。描述的是一个对象应该对其他对象有最少的了解。通俗地说,就是一个类应该对自己需要耦合或调用的类知道的最少,被耦合或调用的类的内部怎么复杂都跟我没关系,那不是我的事,我就知道你提供的这么多public方法,我就调用这么多,其它的一概不关心。
换句话说,就是一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他软件单位都只有最小的知识,而且局限于那些与本单位密切相关的软件单位。
比如在生活中,若是在外面见不到家人,通常是只和朋友通信。这里可以认为有朋友类,定义是:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。迪米特法则告诉我们一个类只和朋友类交流。
而朋友和朋友之间也是有距离的,关系好自然就距离近,关系不好就会距离远。一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也越大。因此为了保持朋友类之间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否还可以修改为private、packate-private(包类型,在类、方法、变量前不加访问权限,默认为包类型)、protected等访问权限,是还可以加上final关键字等。迪米特法则要求尽量不对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限。
另外,在实际应用中经常会出现一种情况,就是一个方法放在本类中也可以,放在其他类中也没有错,此时可以坚持这样一个原则:若一个方法放在本类中,既不增加类之间的关系,也对本类不产生负面影响,那就放置在本类中。
迪米特法则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。迪米特法则不希望类之间建立直接的联系。如果有真的需要建立联系的,也希望能通过他的友元类来转达。因此,应用迪米特法则有可能造成一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互关系,这在一定程度上增加了系统的复杂度。所以在使用迪米法法则时,需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。
有一个著名的“六度分隔理论”,是说任何两个不相识的人中间最多隔着6个人,即最多只需要通过6个人就可以将他们联系在一起。若将这个理论运用到项目中,就是我与我要调用的类之间最多有6次传递。而在实际运用中,若一个类跳转两次以上才能访问到另一个类,就需要想办法进行重构了。为什么是两次以上呢?因为一个系统的成功不仅仅是一个标准或是原则就能够决定的,还有非常多的外在因素决定,跳转的次数越多,系统就复杂,维护就越困难,所以只要跳转不超过两次都是可以忍受的,这需要具体问题具体分析。
迪米特法则要求类之间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际运用中,需要适度地考虑这个原则,别为了套用原则。需要反复试题,不遵循是不对的,严格执行就是“过犹不及”。
3.6 开闭原则
3.6.1 概述
开闭原则是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。
开闭原则由Bertrand Meyer提出。原文是:" Software entities should be open for extension, but closed for modification"。一个软件如类、模块和函数应该对扩展开放,对修改关闭。不过这并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行解耦,否则就是一个孤立无意义的代码片段。
可以把变化归纳为三类:
- 逻辑变化:只变化逻辑,而不涉及其他模块,比如原有的一个算法是a * b + c,现在改为a * b * c;可以通过修改原有类中方法的方式来完成,前提条件是所有依赖或关系此类都按照相同的逻辑处理。
- 子模块变化:一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必须引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的。
- 可见视图变化:可见视图变化是提供给客户使用的界面,如JSP程序等,该部分的变化一般会引起连锁反应。若仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化。比如列表展示的数据突然要增加列。
代码重构可以对原有的设计和代码进行修改,运维尽量减少对原有代码的修改,保持历史代码的纯洁性,提高系统的稳定性。
3.6.2 如何使用开闭原则
1. 抽象约束
通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含了三层含义:
- 第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
- 第二,参数类型、引用对象尽量使用接口或抽象类,而不是实现类;
- 第三,抽象层尽量保持稳定,一旦确定即不允许修改。
2. 元数据(metadata)控制模块行为
在编程过程中,尽量使用元数据来控制程序的行为,减少重复开发。元数据是用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
3. 制定项目章程
在一个团队中,章程指定了所有人员都必须遵守的约定。对项目来说,约定优于配置。
4. 封闭变化
对变化的封装包含两层含义:
- 第一,将相同的变化封装到一个接口或抽象类中;
- 第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在一个接口或抽象类中。
封装变化,也就是受保护的变化,找出预计有变化或不稳定的点,我们为这些变化创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到或“第六感”发觉有变化,就可以进行封装,23个设计模式都从各个不同的角度对变化进行封装的。
3.6.3 最佳实践
软件设计最大的难题就是应对需求的变化,而纷繁复杂的需求变化又是不可预料的,又还得为不可预料的事做好准备,这本身就是一件非常痛苦的事情。还好大量前辈们提出非常好的6大设计原则以及23个设计模式来封装未来的变化。
前面描述的5个设计原则如下:
- Single Responsibility Principle:单一职责原则
- Open Closed Principle:开闭原则
- Liskov Substitution Principle:里氏替换原则
- Law of Demeter:迪米特法则
- Interface Segregation Principle:接口隔离原则
- Dependence Inversion Principle:依赖倒置原则
把这6个原则的首字母(只取第一个单词的字母,同时不要重复的)联合起来就是SOLID(solid,稳定的),其代表的含义就是把这个6个原则结合起来使用的好处:建立稳定、灵活、健壮的设计,而开闭原则又是重中之重,是最基础的原则,是其他5个原则的精神领袖。
在使用开闭原则时,需要注意以下几个问题。
1. 开闭原则也只是一个原则
在项目中应尽量采用6大原则,适当时候可以进行扩充。例如通过类文件替换的方式完全可以解决系统中的一些缺陷。
2. 项目规章非常重要
应尽量让项目成员稳定,稳定后才能建立高效的团队文化。章程是一个团队所有成员共同的知识结晶,也是所有成员必须遵守的约定。优秀的章程能给项目带来非常多的好处,比如提高开发效率、降低缺陷率、提高团队士气、提高成员技术水平等。
3. 预知变化
架师或项目经理一旦发现有发生变化的可能,或变化曾经发生过,则需要考虑现有架构是否可以轻松地实现这一变化。架构师设计一套系统不仅要符合现有的需求,还要适应可能发生的变化,这才是一个优秀的架构师。
开闭原则是一个终极目标,任何人包括大量级人物都无法百分之百做到,但朝这个方向努力,可以非常显著的改善一个系统的架构,真正做到“拥抱变化”。