UML类图
- 用于描述系统中的类(对象)本身的组成和类(对象)之间的各种静态关系。
- 类之间的关系: 依赖、泛化(继承)、实现、关联、聚合与组合
猫是生活中很常见的,我们就拿猫来做个类图示例:
类图关系
面向对象是复合人类对现实世界的思维模式,利用面向对象设计,特别是采用各种设计模式来解决问题时,会设计多个类,然后创建多个对象,一个设计良好的类,应该兼顾信息和行为并且高内聚,而不同类之间,应该做到低耦合。
- 设计一个类中的信息和行为要高内聚
- 设计多个类,类之间要低耦合
当面对应用系统或者需要解决的问题经常是复杂的、高度抽象的,我们创建的多个对象往往是有联系的,通过对象之间的关系可以分为以下几类:
- 泛化(继承)关系
- 实现关系
- 依赖关系
- 关联关系
- 聚合关系
- 组合关系
对于泛化(继承)、实现这两种关系比较简单,他们体现的是一种类与类、或者类与接口之间的纵向关系。其他四种关系则体现的是类与类、类与接口之间的引用/横向关系。
这六种关系所表现的强弱程度,从强到弱依次为:泛化(继承)= 实现>组合>聚合>关联>依赖。
1. 泛化关系(generalization)
- 泛化关系就是继承关系:指的是一个类(子类、子接口)继承(extends)另一个类(父类、父接口)的功能,并可以增加自己额外的一些功能,继承是类与类或者接口与接口之间最常见的关系。
- 在UML类图中,继承通常使用 空心三角+实线 表示,箭头从子类指向父类。
2. 实现关系(realization)
- 实现关系:指的是一个class 类实现interface接口(可以实现多个接口)的功能;实现是类与接口之间最常见的关系。
- 在UML类图中,继承通常使用空心三角+虚线表示,箭头从实现类指向接口。
3.依赖关系(dependent)
- 依赖关系:指的是类与类之间的联接,依赖关系表示一个类依赖另一个类的定义。一般而言,依赖关系再java语言中体现为成员变量、局部变量、方法的形参、方法的返回值,或者对静态方法的调用。
- 依赖是类之间最基础的、也是最微弱的关系类型。 如果修改 一个类的定义可能会造成另一个类的变化, 那么这两个类之 间就存在依赖关系。 当你在代码中使用具体类的名称时, 通常意味着存在依赖关系。 例如在指定方法签名类型时, 或是通过调用构造函数对对象进行初始化时等。 通过让代码依赖接口或抽象类(而不是具体类), 你可以降低其依赖程度。
- 通常情况下, UML 图不会展示所有依赖——它们在真实代码 中的数量太多了。 为了不让依赖关系破坏 UML 图, 你必须 对其进行精心选择, 仅展示那些对于沟通你的想法来说重要 的依赖关系。
- 在UML类图中,依赖关系用虚线箭头表示,箭头从使用类指向被依赖的类。
4. 关联关系(association)
- 关联关系:指的是类与类之间的联接,它使一个类知道另一个类的属性和方法(实例变量体现)。A类以来与B类,并且把B类作为A类的一个成员变量,则A和B存在关联关系。
- 关联可以是双向的,也可以是单向的。两个类之前是一个层次的,不存在部分跟整体之间的关系。
- 在UML类图中,单向关联用实线箭头表示,箭头从使用类指向被关联的类,双向关联用带箭头或者没有箭头的实线来表示。
为了巩固对关联和依赖的之间区别的理解,下面我们来看一个两者结合的示例。假设有个一个Teacher
(老师)类:
class Teacher{
val student = Student()
//···
fun teach(c:Course){
//···
this.student.remember(c.getKnowledge())
}
}
teach()
(教授知识)方法接收一个来自Course
(课程)类的参数。如果有人修改了Course
类的getKnowledge()
(获取知识)方法(修改方法名或添加一些必须得参数等),代码将崩溃。这就是依赖关系。
再来看一下student
(学生)这个成员变量。我们可以肯定Student
(学生)类是Teacher
(老师)类的依赖:如果remember()
(记住)方法被修改,Teacher
的代码也将崩溃。但由于Teacher
的所有方法总能访问student
成员变量,所以Student
类就不仅是依赖,而也是关联。
5.聚合关系(aggregation)
- 聚合关系是关联关系的一种特例,他体现的是整体与部分,是一种“弱拥有”的关系,即has-a的关系。聚合是整体和个体之间的关系。例如学校和老师,汽车和轮子。
- 与关联关系一样,聚合关系也是通过实例变量实现。但是关联关系锁涉及的两个类是处在同一层次上的,而在聚合关系中,两个类是处在不平等的层次上的,一个代表整体,另一个代表部分。
- 聚合关系表示整体和个体的关系,整体和个体可以相互独立存在,一定是有两个模块分别管理整体和个体。
- 在UML类图中,聚合通常使用空心菱形+实线箭头表示,菱形指向整体。
6.组合关系
- 组合关系是关联关系的一种特例,它体现的是一个种contains-a(包含)的关系,这种关系比聚合更强,也称为强聚合。
- 它要求普通的聚合关系中代表整体的对象负责代表部分对象的生命周期,组合关系是不能共享的。代表整体的对象需要负责保持部分对象和存活,在一些情况下将负责代表部分的对象湮灭掉。代表整体的对象可以将代表部分的对象传递给另一个对象,由后者负责此对象的生命周期。换言之,代表部分的对象在每一个时刻只能与一个对象发生组合关系,由后者排他地负责生命周期。部分和整体的生命周期一样。
- 整体和个体不能独立存在,一定是在一个模块中同时管理整体和个体,生命周期必须相同(级联)。
- 在UML类图中,组合通常使用实心菱形+实线箭头表示,菱形指向整体。
总结
- 依赖:对类 B 进行修改会影响到类 A 。
- 关联:对象 A 知道对象 B。 类 A 依赖于类 B。
- 聚合:对象 A 知道对象 B 且由 B 构成。 类 A 依赖于类 B。
- 组合:对象 A 知道对象 B、由 B 构成而且管理着 B 的生命周 期。 类 A 依赖于类 B。
- 泛化(继承): 类 A 继承类 B 的接口和实现, 但是可以对其进行扩 展。 对象 A 可被视为对象 B。 类 A 依赖于类 B。
- 实现:类 A 定义的方法由接口 B 声明。 对象 A 可被视为对象 B。 类 A 依赖于类 B。
设计原则
- 封装变化的内容(方法、类)
- 面向接口进行开发,而不是面向实现
- 组合由于继承
这里就不展开说明,感兴趣可自行了解。
SOLID原则
- 单一职责原则(Single Responsibility Principle)
- 开闭原则(Open/close Principle)
- 里氏替换原则(Liskov Substitution Principle)
- 接口隔离原则(Interceface Segregation Principle)
- 依赖倒置原则(Dependency Inversion Principle)
S:单一职责原则(SRP)
修改一个类的原因只能有一个。
- 定义:对于一个类而言,应该仅有一个引起它变化的原因。其中变化的原因就表示了这个类的职责,它可能是某个特定领域的功能,可能是某个需求的解决方案。
- 这个原则表达的是不要让一个类承担过多的责任,一旦有了多个职责,那么它就越容易因为某个职责而被更改,这样的状态是不稳定的,不经意的修改很有可能影响到这个类的其他功能。因此,我们需要将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,不同类之间的变化互不影响。
实例说明
举一个具体的例子,有一个雇员类Employee
,我们有几个理由来对这个类进行修改,第一个 理由与该类的主要工作(管理雇员数据)有关。 但还有另一 个理由:时间表报告的格式可能会随着时间而改变, 从而使你需要对类中的代码进行修改。所以单一职责原则认为这两个变化的原因事实上是两个分离的功能,它们应该分离在不同的类中。
相关设计模式
面对违背单一职责原则的程序代码,我们可以利用外观模式,代理模式,桥接模式,适配器模式,命令模式对已有设计进行重构,实现多职责的分离。
小结
单一职责原则用于控制类的粒度大小,减少类中不相关功能的代码耦合,使得类更加的健壮;另外,单一职责原则也适用于模块之间解耦,对于模块的功能划分有很大的指导意义。
O:开闭原则(OCP)
对于扩展, 类应该是“开放”的;对于修改, 类则应 是“封闭”的。
- 定义:软件中的对象(类,模块,函数等)应该对于扩展是开放的,但是对于修改是封闭的。这里的对扩展开放表示这添加新的代码,就可以让程序行为扩展来满足需求的变化;对修改封闭表示在扩展程序行为时不要修改已有的代码,进而避免影响原有的功能。
- 要实现不改代码的情况下,仍要去改变系统行为的关键就是抽象和多态,通过接口或者抽象类定义系统的抽象层,再通过具体类来进行扩展。这样一来,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,达到开闭原则的要求。
实例说明
举一个具体的例子:你的电子商务程序中包含一个计算运输费用的订单 Order
类, 该类中所有运输方法都以硬编码的方式实现。 如果你需 要添加一个新的运输方式, 那就必须承担对 订单 类造成破 坏的可能风险来对其进行修改。
你可以通过应用策略模式来解决这个问题。 首先将运输方法 抽取到拥有同样接口的不同类中。
当需要实现一个新的运输方式时, 你可以通过扩 展 运输方式
Shipping
接口来新建一个类, 无需修改任 何 Order
类的代码。
相关设计模式
面对违背开闭原则的程序代码,可以用到的设计模式有很多,比如工厂模式,观察者模式,模板方法模式,策略模式,组合模式,使用相关设计模式的关键点就是识别出最有可能变化和扩展的部分,然后构造抽象来隔离这些变化。
小结
有了开闭原则,面向需求的变化就能进行快速的调整实现功能,这大大提高系统的灵活性,可重用性和可维护性,但会增加一定的复杂性。
L:里氏替换原则(LSP)
当你扩展一个类时, 记住你应该要能在不修改客户端 代码的情况下将子类的对象作为父类对象进行传递。
- 定义:在不影响程序正确性的基础上,所有使用基类的地方都能使用其子类的对象来替换。这里提到的基类和子类说的就是具有继承关系的两类对象,当我们传递一个子类型对象时,需要保证程序不会改变任何原基类的行为和状态,程序能正常运作。
实例说明
为了能理解里式替换原则,这里举一个经典的违反里式替换原则的例子:正方形/长方形问题。
上图为正方形/长方形问题的类层次结构,Square 类继承了 Rectangle 类,但是 Rectangle 类的宽高可以分别修改,但是 Suqare 类的宽高则必须一同修改。如果 User 类操作 Rectangle 类时,但实际对象是 Suqare 类型时,就会造成程序的出错,如下方代码:
Rectangle r = ...; // 返回具体类型对象
r.setWidth(5);
r.setHeight(2);
assert(r.area() == 10);
当返回具体类型对象为 Suqare 类型,面积为 10 的断言就是失败,这样明显是不符合里式替换原则的。
小结
要让程序代码符合里式替换原则,需要保证子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法,换句话就是子类可以扩展父类的功能,但不能改变父类原有的功能。
另一方面,里式替换原则也是对开闭原则的补充,不仅适用于继承关系,还适用于实现关系的设计,常提到的 IS-A 关系是针对行为方式来说的,如果两个类的行为方式是不相容,那么就不应该使用继承,更好的方式是提取公共部分的方法来代替继承。
I:接口隔离原则(ISP)
客户端不应被强迫依赖于其不使用的方法。
定义:客户端不应该依赖那些它不需要的接口。客户端应该只依赖它实际使用的方法,因为如果一个接口具备了若干个方法,那就意味着它的实现类都要实现所有接口方法,从代码结构上就十分臃肿。
实例说明
假如你创建了一个程序库, 它能让程序方便地与多种云计算 供应商进行整合。 尽管最初版本仅支持阿里云服务, 但它也 覆盖了一套完整的云服务和功能。
假设所有云服务供应商都与阿里云一样提供相同种类的功能。 但当你着手为其他供应商提供支持时, 程序库中绝大部分的 接口会显得过于宽泛。 其他云服务供应商没有提供部分方法 所描述的功能。
尽管你仍然可以去实现这些方法并放入一些桩代码, 但这绝 不是优良的解决方案。 更好的方法是将接口拆分为多个部分。 能够实现原始接口的类现在只需改为实现多个精细的接口即 可。 其他类则可仅实现对自己有意义的接口。
小结
基于接口隔离原则,我们需要做的就是减少定义大而全的接口,类所要实现的接口应该分解成多个接口,然后根据所需要的功能去实现,并且在使用到接口方法的地方,用对应的接口类型去声明,这样可以解除调用方与对象非相关方法的依赖关系。总结一下,接口隔离原则主要功能就是控制接口的粒度大小,防止暴露给客户端无相关的代码和方法,保证了接口的高内聚,降低与客户端的耦合。
D:依赖倒置原则(DIP)
- 高层模块不应该依赖低层模块,应该共同依赖抽象;
- 抽象不应该依赖细节,细节应该依赖抽象。
这里的抽象就是接口和抽象类,而细节就是实现接口或继承抽象类而产生的类。
实例说明
如何理解“高层模块不应该依赖低层模块,应该共同依赖抽象”呢?如果高层模块依赖于低层模块,那么低层模块的改动很有可能影响到高层模块,从而导致高层模块被迫改动,这样一来让高层模块的重用变得非常困难。
最佳的做法就如上图一样,在高层模块构建一个稳定的抽象层,并且只依赖这个抽象层;而由底层模块完成抽象层的实现细节。这样一来,高层类都通过该抽象接口使用下一层,移除了高层对底层实现细节的依赖。
依赖倒置原则通常和开闭原则共同发挥作用:你无需修改已 有类就能用不同的业务逻辑类扩展低层次的类。
小结
依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。同时依赖倒置原则也是框架设计的核心原则,善于创建可重用的框架和富有扩展性的代码,比如 Tomcat 容器的 Servlet 规范实现,Spring Ioc 容器实现。