UML图与软件设计原则
# 1. 设计模式概述
# 1.1 软件设计模式的概念
软件设计模式(Software Design Pattern),又称设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。
- 它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。
- 它是解决特定问题的一系列套路,是前辈们的代码设计经验的总结,具有一定的普遍性,可以反复使用。
# 1.2 学习设计模式的必要性
设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性以及类的关联关系和组合关系的充分理解。
# 1.3 设计模式分类
创建型模式:用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。GoF 书中提供了单例、原型、工厂方法、抽象工厂、建造者等 5 种创建型模式。
结构型模式:用于描述如何将类或对象按某种布局组成更大的结构,GoF 书中提供了代理、适配器、桥接、装饰、外观、享元、组合等 7 种结构型模式。
行为型模式:用于描述类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责。GoF 书中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等 11 种行为型模式。
# 2. UML 图
统一建模语言(Unified Modeling Language,UML)是用来设计软件的可视化建模语言。
UML 从目标系统的不同角度出发,定义了用例图、类图、对象图、状态图、活动图、时序图、协作图、构件图、部署图等 9 种图。
# 2.1 类图概述
类图(Class diagram)是显示了模型的静态结构,特别是模型中存在的类、类的内部结构以及它们与其他类的关系等。类图不显示暂时性的信息。类图是面向对象建模的主要组成部分。
类图是系统分析和设计阶段的重要产物,是系统编码和测试的重要模型。
# 2.2 类的表示方法
在 UML 类图中,类使用包含类名、属性(field) 和方法(method) 且带有分割线的矩形来表示,比如下图表示一个 Employee 类,它包含 name, age 和 address 这3个属性,以及 work() 方法。
属性/方法名称前加的 + 和 - 表示了这个属性/方法的可见性,UML 类图中表示可见性的符号有三种:
+
:public-
:private#
:protected
属性的完整表示方式是:可见性 名称 :类型 [ = 缺省值]
方法的完整表示方式是:可见性 名称(参数列表) [ : 返回类型]
PS:也有将类型放在变量名前面,返回值类型放在方法名前面
# 2.3 类与类之间关系的表示方式
# 2.3.1 关联关系
关联关系是对象之间的一种引用关系,用于表示一类对象与另一类对象之间的联系,如老师和学生、师傅和徒弟、丈夫和妻子等。关联关系是类与类之间最常用的一种关系,分为一般关联关系、聚合关系和组合关系。我们先介绍一般关联。
一般关联又可以分为单向关联,双向关联,自关联。
- 单向关联
单向关联用一个带箭头的实线表示。上图表示每个顾客都有一个地址,这通过让 Customer 类持有一个类型为 Address 的成员变量类实现。
- 双向关联
双向关联就是双方各自持有对方类型的成员变量。双向关联用一个不带箭头的直线表示。上图中在 Customer 类中维护一个 List<Product>
,表示一个顾客可以购买多个商品;在 Product 类中维护一个 Customer 类型的成员变量表示这个产品被哪个顾客所购买。
- 自关联
自关联用一个带有箭头且指向自身的线表示。上图的意思就是 Node 类包含类型为 Node 的成员变量,也就是“自己包含自己”。
# 2.3.2 聚合关系
聚合关系是关联关系的一种,是强关联关系,是整体和部分之间的关系。
聚合关系也是通过成员对象来实现的,其中成员对象是整体对象的一部分,但是成员对象可以脱离整体对象而独立存在。例如,学校与老师的关系,学校包含老师,但如果学校停办了,老师依然存在。
聚合关系可以用带空心菱形的实线来表示,菱形指向整体。
# 2.3.3 组合关系
组合表示类之间的整体与部分的关系,但它是一种更强烈的聚合关系。
在组合关系中,整体对象可以控制部分对象的生命周期,一旦整体对象不存在,部分对象也将不存在,部分对象不能脱离整体对象而存在。例如,头和嘴的关系,没有了头,嘴也就不存在了。
组合关系用带实心菱形的实线来表示,菱形指向整体。
# 2.3.4 依赖关系
依赖关系是一种使用关系,它是对象之间耦合度最弱的一种关联方式,是临时性的关联。在代码中,某个类的方法通过局部变量、方法的参数或者对静态方法的调用来访问另一个类(被依赖类)中的某些方法来完成一些职责。
依赖关系使用带箭头的虚线来表示,箭头从使用类指向被依赖的类。
# 2.3.5 继承关系
继承关系是对象之间耦合度最大的一种关系,表示一般与特殊的关系,是父类与子类之间的关系,是一种继承关系。
泛化关系用带空心三角箭头的实线来表示,箭头从子类指向父类。在代码实现时,使用面向对象的继承机制来实现泛化关系。
# 2.3.6 实现关系
实现关系是接口与实现类之间的关系。在这种关系中,类实现了接口,类中的操作实现了接口中所声明的所有的抽象操作。
实现关系使用带空心三角箭头的虚线来表示,箭头从实现类指向接口。
# 3. 软件设计原则
在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,程序员要尽量根据 6 条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
# 3.1 开闭原则
对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,而是实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。
想要达到这样的效果,我们需要使用接口和抽象类。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
# 【例】搜狗输入法的皮肤设计
分析:搜狗输入法的皮肤是输入法背景图片、窗口颜色和声音等元素的组合。用户可以根据自己的喜爱更换自己的输入法的皮肤,也可以从网上下载新的皮肤。
- 这些皮肤有共同的特点,可以为其定义一个抽象类(
AbstractSkin
),而每个具体的皮肤(DefaultSpecificSkin
和StarWarsSpecificSkin
)是其子类,分别表示默认皮肤
和星战主题的皮肤
。 - 用户窗体可以根据需要选择或者增加新的主题,而不需要修改原代码,所以它是满足开闭原则的。
# 3.2 里式替换原则(LSP 原则)
里氏替换原则:任何基类可以出现的地方,子类一定可以出现。通俗理解:子类可以扩展父类的功能,但不能改变父类原有的功能。
子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。
# 【例】正方形不是长方形
在数学领域里,正方形毫无疑问是长方形,它是一个长宽相等的长方形。所以,我们开发的一个与几何图形相关的软件系统,就可以顺理成章的让正方形继承自长方形。
实现代码的核心部分:
/**
* 长方形类
*/
public class Rectangle {
public void setLength(double length) {
this.length = length;
}
public void setWidth(double width) {
this.width = width;
}
}
/**
* 正方形类
*/
public class Square extends Rectangle {
public void setWidth(double width) {
super.setLength(width);
super.setWidth(width);
}
public void setLength(double length) {
super.setLength(length);
super.setWidth(length);
}
}
public class RectangleDemo {
public static void resize(Rectangle rectangle) {
while (rectangle.getWidth() <= rectangle.getLength()) {
rectangle.setWidth(rectangle.getWidth() + 1);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
这其中 RectangleDemo
的 resize
方法调用 Rectangle
的 setter 方法来实现任意变化长方形的长和宽,但 Square
的长和宽必须相等,不能任意变换,其 setter 的实现会导致 setWidth
和 setLength
不断交替运行下去直至溢出错误,所以在此处 Rectangle 类型的参数是不能被 Square类型的参数所代替,因此,Square 类和 Rectangle 类之间的继承关系违反了里氏替换原则,它们之间的继承关系不成立,正方形不是长方形。
如何改进呢?此时我们需要重新设计他们之间的关系。抽象出来一个四边形接口(Quadrilateral
),让 Rectangle 类和 Square 类实现 Quadrilateral 接口,而不是让 Square 继承 Rectangle:
- 这样设计后,
resize()
方法就不能再传入正方形,避免了刚刚的问题。
# 3.3 依赖倒转原则
高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
比如 A 类是高层模块,B 类是低层模块,A 类不应该直接依赖 B 类,而是将 B 类抽象出其抽象父类或父接口,然后使 A 类依赖该抽象出来的抽象父类或父接口。
# 【例】组装电脑
现要组装一台电脑,需要配件 CPU,硬盘,内存条。只有这些配置都有了,计算机才能正常的运行。选择 CPU 有很多选择,如 Intel,AMD 等,硬盘可以选择希捷,西数等,内存条可以选择金士顿,海盗船等。
上面代码可以看到已经组装了一台电脑,但是似乎组装的电脑的 CPU 只能是 Intel 的,内存条只能是金士顿的,硬盘只能是希捷的,这对用户肯定是不友好的,用户有了机箱肯定是想按照自己的喜好,选择自己喜欢的配件。
根据依赖倒转原则进行改进:代码我们只需要修改 Computer 类,让 Computer 类依赖抽象(各个配件的接口),而不是依赖于各个组件具体的实现类。
public class Computer {
private HardDisk hardDisk;
private Cpu cpu;
private Memory memory;
public void run() {...}
}
2
3
4
5
6
7
面向对象的开发很好的解决了这个问题,一般情况下抽象的变化概率很小,让用户程序依赖于抽象,实现的细节也依赖于抽象。即使实现细节不断变动,只要抽象不变,客户程序就不需要变化。这大大降低了客户程序与实现细节的耦合度。
# 3.4 接口隔离原则
客户端不应该被迫依赖于它不使用的方法,一个类对另一个类的依赖应该建立在最小的接口上。
比如 A 类有方法 1 和方法 2,现在 B 类需要用到方法 1,这时我们将 B 类继承 A 类,这样的好处是 B 类拥有了方法 1 的功能,但问题是 B 类只需要方法 1,不需要方法 2 的功能,B 类被迫依赖于它不使用的方法。所以不应该使用继承。
改进方法:创建一个 C 接口,C 接口只有方法 1 的声明,然后通过 C 接口来将它们联系起来。
# 【例】安全门的设计
我们创建一个“黑马”品牌的安全门,该安全门具有防火、防水、防盗的功能。可以将防火,防水,防盗功能提取成一个接口,形成一套规范。类图如下:
现在如果我们还需要再创建一个“传智”品牌的安全门,而该安全门只具有防盗、防水功能呢?很显然如果实现 SafetyDoor 接口就违背了接口隔离原则,那么我们如何进行修改呢?看如下类图:
这样这两种安全门的类就写成:
/**
* “黑马”品牌的安全门
*/
public class HeiMaSafetyDoor implements AntiTheft,Fireproof,Waterproof {
...
}
/**
* “传智”品牌的安全门
*/
public class ItcastSafetyDoor implements AntiTheft,Fireproof {
...
}
2
3
4
5
6
7
8
9
10
11
12
13
# 3.5 迪米特法则
迪米特法则又叫最少知识原则。只和你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。
其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
比如一个传统公司想要一个做软件,而能做软件的是软件工程师,但这个传统公司不应该直接找软件工程师来通信,而是找软件公司,软件公司再把任务交给软件工程师。这里软件公司就相当于作为第三方来转发。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
# 【例】明星和经纪人
明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如和粉丝的见面会,和媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则。
# 3.6 合成复用原则
合成复用原则是指:尽量先使用组合或者聚合,其次才考虑使用继承。
通常类的复用分为继承复用和合成复用两种。
- 继承复用虽然有简单和易实现的优点,但它也存在以下缺点:
- 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为白箱复用。
- 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- 限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
- 组合或聚合复用可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
- 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为黑箱复用。
- 对象间的耦合度低。可以在类的成员位置声明抽象。
- 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象
一个典型例子是,
Stack
的实现不应该继承复用List
,而是应该组合复用,也就是将List
作为其一个私有成员变量,然后向外暴露push / pop
方法,其内部用 List 的 add / delete 来实现。
# 【例】汽车分类管理程序
汽车按“动力源”划分可分为汽油汽车、电动汽车等;按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。类图如下:
从上面类图我们可以看到使用继承复用产生了很多子类,如果现在又有新的动力源或者新的颜色的话,就需要再定义新的类。我们试着将继承复用改为聚合复用看一下。