设计模式学习(二):面向对象设计原则与UML类图
一、UML类图和面向对象设计原则简介
在学习设计模式之前,需要找我一些预备知识,主要包括UML类图和面向对象设计原则。
UML类图可用于描述每一个设计模式的结构以及对模式实例进行说明,而模式结构又是设计模式的核心组成部分,学习一个设计模式,如果不能绘制和理解其结构图,基本上等于没学。
面向对象设计原则是每一个设计模式效果评价的重要依据,每一个模式都符合一个或多个面向对象设计原则,这些原则都是从无数项目中提取出来的经验性原则,它们为消除软件设计和实现中的“臭味”而诞生,力图为当前系统提供最好的设计方案。
常用的面向对象设计原则包括7个,分别是单一职责原则、开放封闭原则、里氏替换原则、依赖倒转原则、接口隔离原则、合成复用原则和迪米特法则。
二、面向对象设计原则
面向对象设计的目标之一在于支持可维护性复用,一方面需要设计方案或者源代码的重用,另一方面要确保系统能够易于扩展和修改,具有较好的灵活性。
1.单一职责原则(Single Responsibility Principle,SRP)
单一职责原则:一个类只负责一个功能领域中的相应职责。或者可定义为:就一个类而言,应该只有一个引起它变化的原因。
单一职责原则的核心思想是:一个类不能太“累”!在软件系统中,一个类承担的职责越多,它被复用的可能性就越小,而且一个类承担的责任过多,就相当于将这些职责耦合在一起,当其中一个这则变化时,可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中。如果多个职责总是同时发生改变则可将它们封装在同一个类中。
单一职责原则是实现高内聚、低耦合的指导方针。
例:将连接数据库的方法和绘图的方法放在一个类中,就违反了单一职责原则。如果在其他类中也需要连接数据库或者绘图,则难以实现代码的重用。并且无论是修改数据库连接方式还是修改绘图方式,都需要修改该类,它拥有不止一个引起它变化的原因。
2.开放封闭原则(Open-Closed Principle,OCP)
开闭原则:一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
当软件系统需要面对新的需求时,应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地堆系统进行扩展,而且在扩展时无须修改现有代码,使得软件软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。
为了满足开闭原则,抽象化是开闭原则的关键。在Jaba、C#等编程语言中,可以为系统定义一个相对稳定的抽象层,而将不同的行为移至具体的实现层中完成。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
3.里氏替换原则(Liskov Substitution Principle,LSP)
里氏替换原则:所有引用基类(父类)的地方必须能透明地使用其子类对象。
在运用LSP时,应该将父类设计为抽象类或接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替代父类实例,可以很方便的扩展系统的功能,无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。
4.依赖倒转原则(Dependency Inversion Principle,DIP)
如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要实现机制之一,它是系统抽象化的具体实现。
依赖倒转原则:抽象不应该依赖于细节,细节应该依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
依赖倒转原则要求在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口或抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。
在引入抽象层后,系统将具有很好的灵活性。在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样一来,如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,就能扩展系统的功能,满足开闭原则的需求。
在实现一来倒转原则时,需要针对抽象编程,而将具体类的对象通过依赖注入(Dependency Injection,DI)的方式注入到其他对象中。
依赖注入是指一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。
常用的3中注入方式:构造注入、设值注入(Setter注入)和接口注入。
构造注入:通过构造函数来传入具体的对象。
设值注入:通过Setter方法来传入具体的对象。
接口注入:通过实现在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。
在大多数情况下,OCP、LSP和DIP这三个原则会同时出现,开闭原则是目标,里氏替换原则是基础,依赖倒转原则是手段,它们相辅相成,相互补充,目标一致,只是分析问题时所站角度不同而已。
5.接口隔离原则(Interface Segregation Principle,ISP)
接口隔离原则:使用多个专门的接口,而不是使用单一的接口,即客户端不应该依赖哪些它不需要的接口。
6.合成复用原则(Composite Reuse Principle,CRP)
合成复用原则:尽量使用对象组合,而不是使用继承来达到复用的目的。
合成复用原则就是在一个新的对象里通过关联关系(包括组合和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用的目的。
简而言之:复用时尽量使用组合/聚合关系,少用继承。
在面向对象的设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,但首先考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑使用集成,在使用继承时,需要严格遵守里氏替换原则。
通过继承来进行复用主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称”白箱“复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现时静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。
由于组合或聚合关系可以将已有的对象纳入到新的对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部细节对于新对象不可见,所以又称为”黑箱“复用,相对于继承而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性的调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
一般而言,如果两个类之间是”Has-A“的关系,应使用组合或聚合,如果是”Is-A“关系,可使用集继承。
7.迪米特法则(Law of Demeter,LoD)
迪米特法则:一个软件实体应当尽量少地与其他实体发生相互作用。
如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展也会相对容易。这是对软件实体之间通信的限制。迪米特法则要求降低软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类和雷之间保持松散的耦合关系。
迪米特法则还有几种定义形式:不要和”陌生人“说话,只与你的直接朋友通信等。
在迪米特法则中,对于一个对象,其”朋友“包括以下几类:
(1)当前对象本身(this)。
(2)以参数形式传入到当前对象方法中的对象。
(3)当前对象的成员对象。
(4)如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友。
(5)当前对象所创建的对象。
任何一个对象,如果满足以上条件之一,就是当前对象的”朋友“,否则就是”陌生人“。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与”陌生人“发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。
迪米特法则要求再设计系统时,应该尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用;如果其中一个对象需要调用拎一个对象的方法,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
运用迪米特法则时的注意事项:
(1)在类的划分上,应当尽量创建松耦合的类。
(2)在类的结构设计上,每一个类都应当尽量降低成员变量和成员函数的访问权限。
(3)在类的设计上,只要有可能,一个类型应当设计成不变的类。
(4)在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
三、UML(Unified Modeling Language,统一建模语言)类图
1.类的UML图示
在UML中,类使用包含类名、属性和操作且带有分割线的长方形来表示。如下图:
在UML类图中,类一般由3不部分组成:
(1)类名:每个类都必须有一个名字,类名是一个字符串。
(2)类的属性(Attributes):属性是指,类的性质,即类的成员变量。
UML规定属性的表示方式为:可见性 名称 : 类型[ = 默认值]
①“可见性”表示该属性对于类外的元素而言是否可见,包括public、private和protected3种,在类图中分别表示为+、-和#表示。
②“名称”表示属性名,用一个字符串表示。
③“类型”表示属性的数据类型,可以使基本数据类型,也可以是用户自定义数据类型。
④“默认值”是一个可选项,即属性的初始值。
(3)类的操作(Operations):操作是类的任意一个实例对象都可以使用的行为,是类的成员方法。
UML规定操作的表示方式为:可见性 名称(参数列表)[ :返回类型]
①“可见性”的定义与属性的可见性定义相同。
②“名称”即方法名,用一个字符串表示。
③“参数列表”表示方法的参数,其语法与属性的定义相似,参数个数是任意的,多个参数之间用“,”隔开。
④“返回类型”是一个可选项,表示方法的返回值类型,可以是基本数据类型,也可以是用户自定义的数据类型,还可以是空类型(void),如果是构造方法,则五返回类型。
2.类之间的关系
(1)关联关系:用实线连接有关联关系的对象所对应的类,在Java、C#和C++等变成语言实现关联关系时,通常将一个类的对象作为另一个类的成员变量。
如在一个登录界面类LoginForm中包含一个JButton类型的注册按钮LoginButton
在UML中,关联关系通常又包含如下几种形式:双向关联、单向关联、自关联、多重性关联、聚合关系和组合关系。
①双向关联
默认情况下,关联是双向的。例如,顾客购买商品并拥有商品,反之,卖出的商品总与某个顾客想关联。
②单向关联
类的关联关系也可以是单向的,在UML中单向关联用带箭头的实线表示。例如,顾客拥有地址,则Customer类与Address类具有单向关联关系。
③自关联
在系统中可能会存在一些类的属性对象类型为该类本身,这种特殊的关联关系成为子关联。例如,一个节点类的成员又是节点类型的对象。
public class Node { private Node subNode; }
④多重性关联
多重性关联又称为重数性关联关系,表示两个关联对象在数量上的对应关系。
⑤聚合关系(聚合关系用带空心菱形的直线表示)
聚合关系表示整体与部分的关系。在聚合关系中,成员对象时整体对象的一部分,但是成员对象可以脱离整体对象单独存在。
例如,汽车发动机是汽车的组成部分,但是汽车发动机可以独立存在。
1 public class Car 2 { 3 private Engine engine; 4 5 //构造注入 6 public Car(Engine engine) 7 { 8 this.engine = engine; 9 } 10 11 //设值注入 12 public void SetEngine(Engine engine) 13 { 14 this.engine = engine; 15 } 16 ... 17 } 18 19 public class Engine 20 { 21 }
在代码实现聚合关系时,成员对象通常作为构造方法、Setter方法或业务方法的参数注入到整体对象中。
⑥组合关系(组合关系用带实心菱形的直线表示)
组合关系也表示类之间整体和部分的关系,但是组合关系整体对象可以控制成员对象的生命周期,一旦整体对象不存在,成员对象也不存在,成员对象和整体对象之间有同生共死的关系。
例如,人的头和嘴巴,嘴巴是头的组成部分之一,如果头没了,嘴巴也就没了,因此头和嘴巴是组合关系。
在代码实现组合关系时,通常在整体类的构造方法中直接实例化成员类。
1 public class Head 2 { 3 private Mouth mouth; 4 5 public Head() 6 { 7 mouth = new Mouth(); 8 } 9 } 10 11 12 public class Mouth 13 { 14 }
(2)依赖关系(在UML中,依赖关系用带箭头的虚线表示,由依赖的一方指向被依赖的一方)
依赖关系是一种使用关系,特定事物的改变有可能会影响到使用该事物的其他事物。
在大多数情况下,依赖关系体现在某个类的方法使用另一个类的对象作为参数。
例如,在Driver类的Drive()方法中奖Car类型的对象car作为一个参数传递,以便在Drive()方法中能够调用Car类的Move()方法,且驾驶员的Drive()方法依赖车的Move()方法,因此Driver类依赖Car类。
1 public class Driver 2 { 3 public void Drive(Car car) 4 { 5 car.Move(); 6 } 7 } 8 9 public class Car 10 { 11 public void Move() 12 { 13 } 14 }
在系统的实施阶段,依赖关系通常通过3中方式来实现:
①最常用的一种方式如上图将一个类的对象作为另一个类中方法的参数。
②在一个类的方法中将另一个类的对象作为其局部变量。
③在一个类的方法中调用另一个类的静态方法。
(3)泛化关系(即集成关系,用带空心三角形的直线表示)
例如,Student类和Teacher类都是Person类的子类。
(4)接口与实现关系