设计模式之禅|6大设计原则

曾经对“设计模式”(Design Pattern)很膜拜,以为是最高大上的东西。现在看来更多的是前人总结的经验吧,了解一下就可以了,关键是要用的时候“熟能生巧”。最开始看的时候也正在学习Java,配合着这本书中的Java代码,感觉各种神奇。其实,Java语言设计之初为了避免像C++般复杂却过于简化了语法,结果大大降低了本身的表达能力。很多本来可以很简洁的代码,必须绕来绕去得写出来,这个时候就很需要利用设计模式来整理代码逻辑。另一方面,设计模式也还是有些用途的,尤其是在开发大型软件系统,很注重系统架构的设计和功能模块的解耦的场合。

题外话,UML类图中类之间的关系有:
泛化 = 实现 > 组合 > 聚合 > 关联 > 依赖

  1. 泛化(Generalization): 继承关系,子类继承父类 继承用实线三角箭头指向父类
  2. 实现(Realization): 对接口的实现,类实现一个接口 继承接口用虚线三角箭头指向接口
  3. 组合(Composition): 整体与部分的关系,但部分不能离开整体而单独存在 实心菱形,菱形指向整体
  4. 聚合(Aggregation): 整体与部分的关系,且部分可以离开整体而单独存在 空心菱形,菱形指向整体
  5. 关联(Association): 拥有的关系,让一个类知道另一个类的属性方法 实线箭头,箭头指向被关联的类
  6. 依赖(Dependency): 一种使用的关系,即一个类的实现需要另一个类的协助,所以尽量不要使用双向的相互依赖,代码体现:局部变量、方法的参数或者调用类的静态方法 虚线箭头,指向被使用者

软件分析与设计是编码前的2个阶段,分析仅与业务有关,而与技术无关,设计以分析为基础,与具体技术有关。

紧耦合 类间耦合过重 接口封装过度 类间解耦,弱耦合

单一职责原则

单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。这个原则存在争议之处在对职责的定义,什么是类的职责,以及怎么划分类的职责。

RBAC模式(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离)。
把用户的信息抽取成一个BO(Business Object, 业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑)。

SRP的定义是:应该有且仅有一个原因引起类的变更。
SRP的原话解释:
There should never be more than one reason for a class to change.
就一个类而言,应该只有一个引起它变化的原因。

单一职责原则的优点:

  • 可以降低类的复杂度,实现什么职责都有清晰明确的定义;
  • 提高类的可读性;
  • 提高系统的可维护性;
  • 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,一个接口修改只对相应地实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。

单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事。一个职责就是一个接口。
对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了,可能不会满足单一职责原则。
单一职责适用于接口、类,同时也适用于方法。一个方法尽可能做一件事情。

里氏替换原则

在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美。
里氏替换原则(LSP:Liskov Substitution Principle)的定义:

  1. 第一种定义,也是最正宗的定义: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所在的对象o1都代换为o2时,程序P的行为没有发生变化,那么类型S是类型T的字类型。)
  2. 第二种定义:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基类的地方必须能透明地使用其子类的对象。)

继承的优点:

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

继承的缺点:

  • 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法。
  • 降低代码的灵活性。子类必须拥有父类的属性和方法。
  • 增强了耦合性。当父类的常量、变量和方法被修改时,必须要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果——大片代码需要重构。

在类中调用其他类时务必要使用父类或接口,如果不能使用负累或接口,则说明类的设计已经违背了LSP原则。
里氏替换原则包含了4层含义:

  1. 子类必须完全实现父类的方法
    如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生”畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
  2. 子类可以有自己的个性
    子类可以有自己的行为和外观,也就是方法和属性。
  3. 覆盖或实现父类的方法时输入参数可以被放大
    子类的方法可以重载(Overload)父类的方法,并把输入参数设置成为父类的方法的输入参数的父类(即把输入参数放大)。这时,通过父类的引用调用这个方法,实际调用的还是父类的方法,子类的方法由于只是重载而不是覆写(Override),会被隐藏掉。子类可以覆写(Override)父类的方法。
  4. 覆写或实现父类的方法时输出结果可以被缩小
    父类的方法返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类。

如果是覆写,父类和子类的同名方法的输入参数相同,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。

如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。

依赖倒置原则

依赖正置就是类间的依赖是实实在在的实现类间的依赖。

依赖倒置原则(Dependence Inversion Principle, DIP)的定义:
High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
翻译过来,有三重含义:

  1. 高层模块不应该依赖底层模块,两者都应该依赖其抽象。
  2. 抽象不应该依赖细节。
  3. 细节应该依赖抽象。

高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。在Java中,抽象是指接口或抽象类,两者都不是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化。依赖倒置原则在Java语言中的表现就是:

  1. 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。
  2. 接口或抽象类不依赖于实现类。
  3. 实现类依赖与接口或抽象类。

更加精髓的定义就是 “面向接口编程”——面向对象设计的精髓之一。

采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并发开发引起的风险,提高代码的可读性和可维护性。

依赖是可以传递的。只要做到抽象依赖,即使是多层的依赖传递也无所畏惧。

依赖倒置原则要求我们在程序代码中传递参数时或在关联关系中,尽量 引用层次高的抽象层类,即使用 接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。

最佳实践:

  • 每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备。接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的世界对父类进行细化。
  • 变量的表名类型尽量是接口或者抽象类。
  • 任何类都不应该从具体类派生。
  • 尽量不要覆写基类的方法。
  • 结合里氏替换原则使用。

对象的依赖关系又三种方式来传递:

  1. 构造函数传递依赖对象
  2. Setter方法传递依赖对象
  3. 接口声明依赖对象

接口隔离原则

接口的两种类型:

  1. 实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实例,它是对一个类型的事物的描述,就是一种接口。
  2. 类接口(Class Interface),Java中经常使用的interface关键字定义的接口。
    接口是我们设计时对外提供的契约。

接口隔离原则(ISP:Interface Segregation Principle)定义:
使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。

根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。

看到这里好像接口隔离原则与单一职责原则是相同的。其实接口隔离原则与单一职责原则的审视角度是不相同的,单一职责原则要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。

接口隔离原则是对接口进行规范约束,其包含的以下4层含义:

  1. 接口要尽量小
    这是接口隔离原则的核心定义。但是”小”是有限度的,首先就是不能违反单一职责原则,已经做到单一职责的接口不应该再分。即,根据接口隔离原则拆分接口时,首先必须满足单一职责原则。

  2. 接口要高内聚
    高内聚就是提高接口、类、模块的处理能力,减少对外的交互。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。

  3. 定制服务
    定制服务就是单独为一个个体提供优良的服务。要求就是:只提供访问者需要的方法。

  4. 接口设计是有限度的
    接口的设计粒度越小,系统越灵活。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低。所以接口设计一定要注意适度。

最佳实践:

  • 一个接口只服务于一个子模块或业务逻辑。
  • 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨肉”,而不是“肥嘟嘟”的一大堆方法。
  • 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理。
  • 了解环境,拒绝盲从。环境不同,接口拆分的标准就不同,深入了解业务逻辑,根据实际情况设计接口。

迪米特法则

最小知识原则(Least Knowledge Principle,LKP):
一个对象应该对其他对象有最少的了解。

通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少。
最小知识原则包含以下4层含义:

  1. 只和朋友交流
    Only talk to your immedate friends(只和直接朋友通信。)
    两个对象之间的耦合就成为朋友关系。

类和类之间的关系是建立在类间的,而不是方法间。
朋友类:出现在在成员变量、方法的输入输出参数中的类成为成员朋友类,而出现在方法内部的类不属于朋友类。

  1. 朋友间也是有距离的
    朋友类之间也不应该暴露太多方法。
    尽量不要对外公布太多的public和非静态的public变量,尽量内敛,多使用protected、package-private、protected等访问权限。

  2. 是自己的就是自己的
    如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。

  3. 谨慎使用Serializable
    可能会因为对类的更改未在服务器和客户端之间同步而引起序列化失败问题。

迪米特法则的核心观念就是类间解耦,弱耦合

开闭原则

开闭原则(OCP:Open-Closed Principle)的定义:
Software entities like classes, modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)
一个软件实体(模块、类、接口、方法)应该通过 扩展来实现变化,而不是通过修改已有的代码来实现变化。

开闭原则是最基本的原则,是其他原则和设计模式的精神。

开闭原则的重要性:

  • 开闭原则对测试的影响
    所有已经投产的代码都是有意义的,并且都不受系统规则的约束。如果通过修改而不是扩展代码来应对需求变化,需要重新测试已经修改的代码。

  • 开闭原则可以提高复用性
    在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。

  • 开闭原则可以提高可维护性

  • 面向对象开发的要求
    快速的应对变化,需要在设计之初就考虑所有可能变化的因素,然后留下接口。

需求变化的三种类型:

  1. 逻辑变化
    可以通过修改原有类中的方法的方式来完成

  2. 子模块变化
    通过扩展来应对

  3. 可见视图变化
    可见视图是提供给客户使用的界面,该部分的变化一般会引起连锁反应,但还是可以通过扩展来完成变化,这就要看原来的设计是否灵活。

应对需求变化的原则: 保持历史的纯洁性,不去修改历史。

应对需求变化的三种方法:

  1. 修改接口
    不可靠的,该方案应该被直接否定。

  2. 修改实现类
    该方法在项目有明确的章程或优良的架构设计时,是一个非常优秀的方法,但是仍有缺陷。

  3. 通过扩展实现变化
    好方法,修改少,风险小。

最佳实践:

  1. 抽象约束
    首先通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;第二,参数类型,引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定既不允许修改。

  2. 元数据(metadata)控制模块行为
    尽量使用元数据(用来描述环境和数据的数据,通俗的说就是配置参数)来控制程序的行为,减少重复开发。

  3. 制定项目章程
    对于项目来说,约定优于配置。

  4. 封装变化
    第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。


0%