软件设计原则讲解,昭昭在目
一、UML 图
不要觉得奇怪为什么不讲软件设计原则而说到了 UML 图,因为软件设计原则和软件设计模式中你讲到最多的便是利用类图来表示 类与类之间的关系,因此我们需要 先会看,再会理,最后再来写!
1. 什么是 UML
统一建模语言(Unified Modeling Language,UML),是用来设计软件的可视化建模语言,它的特点是简单、统一、图形化、能表达软件设计中的动态与静态信息。
UML 从目标系统的不同角度出发,定义了用例图、类图、对象图、状态图、活动图、时序图、协作图、构件图、部署图等 9 种图。而我们来重点了解一下 类图。
2. 类图
1)类图的概念
类图(Class disgram)是显示了模型的静态结构,特别是模型中存在的类、类的内部结构以及它们与其他类的关系等。类图不显示暂时性的信息,类图是面向对象建模的主要组成部分。
2)类图的作用
类图是一种静态的结构图,描述了系统中类的集合、类的属性和类之间的关系,可以简化对系统的理解。
3)类图的表示
类图的表示是使用包含类名(className)、属性(field)和方法(method)且带有分割线的矩形来表示,格式如下:
示例如下:
我们可以看到 属性/方法 前面存在 + / - ,他们表示了这个属性或方法的可见性,表示可见性的符号有三种,如下:
- + : 表示 public
- - : 表示 private
- # : 表示 protected
从上面的示例中我们可以总结出:
- 属性的完整表示方式是:可见性 名称 : 类型 [ = 缺省值]
- 方法的完整表示方式是:可见性 名称(参数列表) [ : 返回类型]
中括号里的内容是可选的
也可以将类型放在变量名前面,返回值类型放在方法名前面
4)类图的关系
1. 关联关系
关联关系是对象之间的一种引用关系,用于表示一个类对象与另一个类对象之间的联系。比如老师和学生,学生与课程,校长与学校。关联关系是类与类之间最常用的一种关系,分为 一般关联关系,聚合关系和组合关系
一般关联关系又分为: 单向关联,双向关联,自关联
- 单向关联
从图中我们可以很直观的看出以下几点:
- 有两个类,分别是 Company(公司类) 和 Address(地址类)
- 每个**Company(公司类)**中都有一个 Address(地址类)
- 单向关联是用一个带箭头的实线表示
简而言之:每个公司都有一个地址,通过让 Company(公司类) 持有一个类型为Address(地址类) 的成员变量来实现。
- 双向关联
双向关联通俗的意思就是:你中有我,我中有你。从上图中我们可以看出:双方类中各自持有对方类型的成员变量。在 Company(公司类) 中持有一个 **Employee(员工类)**的集合,表示一个公司有可以有多个员工,而在 Employee(员工类) 中持有一个Company(公司类) 的成员变量,表示这个员工所属于哪个公司。
需要注意的是,双向关联的连线与单向关联有所不同。双向关联是用一个 不带箭头的直线表示。
- 自关联
自关联又是一种比较特殊的关联关系,通俗来说就是 我中有我,是使用一个 带有箭头且指向自身的线表示,这种关联关系我们一般在迭代器模式中比较常见。
2. 聚合关系
聚合关系其实也是关联关系的一种,只不过这种关联关系属于 强关联关系,是 整体和部分之前的关系。
聚合关系也是通过成员变量来实现的,其中成员对象是整体对象的一部分,但是成员对象可以脱离整体对象而独立存在。比如一个公司和员工的关系,公司中存在员工,但是如果公司倒闭了,这些员工依然存在,他们可以去别的公司。
注: 聚合关系是使用 带空心菱形的实线 表示的,特别需要注意菱形的方向,菱形是指向整体的
3. 组合关系
既然聚合关系是一种 "好聚好散"的关系,那有没有那种 "鱼死网破"的关系呢?肯定是有的,那就是聚合关系的加强版—组合关系。这是一种更加强烈的聚合关系。
在组合关系中,整体对象可以控制部分对象的生命周期,一旦整体对象不存在,部分对象也将不存在,部分对象不能脱离整体对象而存在,有点 "一荣俱荣,一损俱损" 的内味了。这就好像我们头和嘴的关系,如果头不存在了,那么嘴也就不存在了。
注: 组合关系是使用 带实心菱形的实线 表示的,同样我们需要注意菱形的方向,菱形是指向整体的
4. 依赖关系
依赖关系是一种使用关系,它是对象之间耦合度最弱的一种关联方式,是临时性的关联。在代码中,我们通常是某个类的方法通过局部变量、方法参数或者对静态方法的调用来访问另一个类(被依赖类)中的某些方法来实现一些职责。通俗的理解就是:我需要你的时候,咱们就存在了临时性的关系,我不需要你的时候,咱们之间就毫无关系。说着说着,越来越感觉像现实中 女神和癞皮狗 的关系。就比如以下图示:
注: 依赖关系是使用 带箭头的虚线来表示,箭头从使用类指向被依赖的类。
5. 继承关系
继承关系是对象之间耦合度最大的一种关系,表示一般与特殊的关系,是父类与子类之间的关系,是一种继承关系。比如 Student 类 和 Teacher 类 都是 Person 类的子类。
注: 继承关系是使用 空心三角箭头的实线 来表示,箭头从子类指向父类。
6. 实现关系
实现关系是接口与实现类之间的关系,在这种关系中,类实现了接口,类中的操作实现了接口中所声明的所有的抽象操作。
注: 实现关系是使用 带空心三角箭头的虚线来表示,箭头从实现类指向接口。
二、软件设计原则
上面铺垫了那么多知识,主角终于可以上场了。我们也不卖关子了,直接来看下我们将要了解到的软件设计原则:
从导图中我们了解到需要的设计原则总共有 6 种,不必感到害怕,接下来小菜带你一个一个去了解!
1. 开闭原则
开闭原则属于比较基础的设计原则,理解起来也比较简单,就一句话:对扩展开放,对修改关闭,但是往往就是这么简单的一句话,做起来却格外的困难。在程序需要进行扩展的时候,不能去修改原有的代码,实现一个热插拔的效果。
咱们先理一下思路,既然是对修改关闭,对扩展开放,那肯定是不能对原有类进行修改了,不能对原有类进行修改,又想要达到这种效果,那我们就得使用 接口和抽象类 了。
因为抽象出来的东西灵活性较好,适用性较广,只要抽象的合理,就可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象类中派生出新的实现类来进行扩展,当需求发生变化时候,我们只需要根据需求重新派生一个实现类来扩展就可以了。
示例: 我们玩游戏都有一个英雄角色, 这个时候为了氪金,就有了皮肤的需求,但是不同皮肤我们就不能对原有的英雄类进行修改,这是不合理的,所以我们就得从原有的英雄类中派生出新的英雄出来。
2. 里氏代换原则
里氏代换原则:任何基类可以出现的地方,子类一定可以出现。简单来说就是子类可以扩展父类的功能,但不能改变父类原有的功能,也就是子类继承父类时,除了添加新的方法完成新增功能外,尽量不要重写父类的方法。
里氏代换原则也是面向对象设计中最基本的原则之一,通俗意思就是父类总能被子类替代。如果我们通过重写父类的方法来完成新的功能,这样写起来虽然比较简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。
示例:正方形是一个特殊的长方形,只不过是正方形的长宽都一样,那我们就用反证法来试一下是否符合里氏代换原则
我们先理一下目前的所看到的东西,有三个类:Rectangle(长方形类)、Square(正方形类)、Client(客户端类),其中 正方形类 继承自 长方形类,因此这两个类属于继承关系。客户端类 其中有个方法需要依赖 长方形类,因此这两个类属于 依赖关系。一般来说长方形的长会大于长方形的宽,因此客户端类中的方法实现的功能就是当长方形的宽大于长时,长的值就会加1,直到长的值大于宽的值。
接下来我们用代码来实现一下:
Rectangle:
Square:
Client:
我们可以看到有个长为10,宽为15的长方形,我们也成功实现了功能,扩容后的长方形长为16,宽为15。根据里氏代换原则,我们如果传入的是 正方形类也是可以实现这个功能的,我们继续来试一下:
public static void main(String[] args) {
Square square = new Square();
square.setWidth(10);
System.out.println(square);
resize(square);
}
我们在控制台等了许久,发现并没有输出结果,直到栈溢出。这时长一直与宽相等,导致退出不了循环。那么这个时候我们得出结论,这种设计是不符合里氏代换原则的,因此这种设计是错误的。所以我们平时在设计系统的时候,就需要考虑我们设计的父子类是否符合里氏代换原则,那么根据以上例子我们可以作出改进,既然 长方形类 不适合做 正方形类 的子类,那我们是否应该考虑抽象出一个 四边形类 出来,来作为两个类的父类。
其中我们抽象出了 Quadrangle(四边形类) 其中定义了获取长和宽的两个方法,Square(正方形类) 和 **Rectangle(长方形类)**分别实现 四边形类,接下来我们用代码来实现:
Quadrangle:
public interface Quadrangle {
double getWidth();
double getLength();
}
Square:
Rectangle:
Client:
看完代码我们改变的只有增加了 四边形类 和修改了 正方形类,这样子我们使用扩容方法的时候需要传的是 长方形类,而 正方形类 不继承与 长方形类,扩容这个方法不适用,因此满足了里氏代换原则。
3. 依赖倒转原则
依赖倒转高层模块不应该依赖底层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
简单来说依赖倒转的核心就是对抽象进行编程,不要对实现进行编程,这样就会降低客户与实现模块之间的耦合。
示例:我们如果要组装一台电脑,组装电脑需要用到的配件有硬盘,内存和CPU,每个配件都有不同的品牌以供选择,我们这里选择希捷的硬盘,英特尔的CPU,金士顿的内存,图示如下:
代码实现如下:
XIJieHardDisk:
@Data
public class XIJieHardDisk {
private String capacity;
}
KingstonMemory:
@Data
public class KingstonMemory {
private String capacity;
}
IntelCpu:
public class IntelCpu {
public void run() {
System.out.println("英特尔处理器开始运行");
}
}
Computer:
@Data
public class Computer {
private XIJieHardDisk hardDisk;
private IntelCpu cpu;
private KingstonMemory memory;
public void run() {
System.out.println("计算机开始运行,参数如下:");
System.out.println("硬盘容量为 : " + hardDisk.getCapacity());
System.out.println("内存容量为 : " + memory.getCapacity());
cpu.run();
}
public static void main(String[] args) {
Computer computer = new Computer();
XIJieHardDisk hardDisk = new XIJieHardDisk();
hardDisk.setCapacity("1T");
IntelCpu cpu = new IntelCpu();
KingstonMemory memory = new KingstonMemory();
memory.setCapacity("16G");
computer.setHardDisk(hardDisk);
computer.setCpu(cpu);
computer.setMemory(memory);
computer.run();
}
}
/** OUTPUT:
计算机开始运行,参数如下:
硬盘容量为 : 1T
内存容量为 : 16G
英特尔处理器开始运行
**/
根据运行结果我们计算机也顺利组装成功,但是目前看起来好像是没问题,如果我们想要换个品牌的 CPU 或者 内存条,我们除了增加一个 对应品牌的类之外我们是不是还要修改Computer 类,刚看完上部分的小伙伴肯定马上意识到这不就违反了 开闭原则 吗,真是瞎胡闹。
那既然这种设计是错误的,我们就按照 依赖倒装原则 来改进一下:高层模块不应该依赖底层模块,两者都应该依赖其抽象,我们需要修改 Computer 类,让Computer类 依赖抽象(各个配件的接口),而不是依赖于各个组件具体的实现类。图示如下:
代码中我们只需要修改 Computer 类 和增加三个配件的主接口,让子品牌分别实现对应的接口即可:
HardDisk:
public interface HardDisk {
void setCapacity(String data);
String getCapacity();
}
Memory:
public interface Memory {
void setCapacity(String data);
String getCapacity();
}
Cpu:
public interface Cpu {
void run();
}
Computer:
@Data
public class Computer {
private HardDisk hardDisk;
private Cpu cpu;
private Memory memory;
public void run() {
System.out.println("计算机开始运行,参数如下:");
System.out.println("硬盘容量为 : " + hardDisk.getCapacity());
System.out.println("内存容量为 : " + memory.getCapacity());
cpu.run();
}
public static void main(String[] args) {
Computer computer = new Computer();
XIJieHardDisk hardDisk = new XIJieHardDisk();
hardDisk.setCapacity("1T");
IntelCpu cpu = new IntelCpu();
KingstonMemory memory = new KingstonMemory();
memory.setCapacity("16G");
computer.setHardDisk(hardDisk);
computer.setCpu(cpu);
computer.setMemory(memory);
computer.run();
}
}
/** OUTPUT:
计算机开始运行,参数如下:
硬盘容量为 : 1T
内存容量为 : 16G
英特尔处理器开始运行
**/
这样子让高层模块依赖抽象模块,就可以实现解耦了,更加方便扩展。
4. 接口隔离原则
客户端不应该被迫依赖于它不适用的方法,一个类对另一个类的依赖应该建立在最小的接口上。
简单来说就是强扭的瓜不甜,不适合自己的就不要强行加成。比如说一台手机可以打电话,发短信,上网,但是上网这个功能对于老人机就不适用了,我们就不应该把上网这个功能强行加给老人机。原本的设计应该是这样的:
这样子明显是不合理的,我们根据 接口隔离原则进行改进:一个类对另一个类的依赖应该建立在最小接口上,那我们应该把每个功能都抽取成各个接口,然后每种手机通过依赖的方式引入功能,图示如下:
代码实现如下:
InternetFun:
public interface InternetFun {
void internet();
}
MessageFun:
public interface MessageFun {
void message();
}
PhoneFun:
public interface PhoneFun {
void phone();
}
NewPhone:
*/
public class NewPhone implements InternetFun, MessageFun, PhoneFun {
@Override
public void internet() {
System.out.println("上网功能已具备");
}
@Override
public void message() {
System.out.println("发短信功能已具备");
}
@Override
public void phone() {
System.out.println("打电话功能已具备");
}
}
OldPhone:
public class OldPhone implements PhoneFun, MessageFun{
@Override
public void message() {
System.out.println("发短信功能已具备");
}
@Override
public void phone() {
System.out.println("打电话功能已具备");
}
}
通过接口隔离原则,我们可以需要什么功能,就实现什么接口,满足了最小依赖。
5. 迪米特法则
迪米特法则又成为最少知识原则。只和你的直接朋友交谈,不跟 "陌生人说话"。
简单来说就是如果两个软件实体无需直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用,目的就是为了降低类之间的耦合度,提高模块间的相对独立性。
示例: 对于明星来说,他不必和经纪公司还有粉丝直接打交道,这些事情只需要交给经纪人来处理就好了,经纪人就相当于是第三方,粉丝见面会和签约这种事都是通过经纪人在中间处理,而不用明星自己打交道。图示如下:
代码实现如下:
Star:
@Data
public class Star {
private String name;
public Star(String name) {
this.name = name;
}
}
Company:
@Data
public class Company {
private String name;
public Company(String name) {
this.name = name;
}
}
Fans:
@Data
public class Fans {
private String name;
public Fans(String name) {
this.name = name;
}
}
Agent:
@Data
public class Agent {
private Star star;
private Company company;
private Fans fans;
public Agent(Star star, Company company, Fans fans) {
this.star = star;
this.company = company;
this.fans = fans;
}
public void meeting() {
System.out.println("经纪人安排" + star.getName() + "与粉丝:" + fans.getName() + " 见面了");
}
public void bussiness() {
System.out.println("经纪人安排" + star.getName() + "与娱乐公司:" + company.getName() + " 签约了");
}
public static void main(String[] args) {
Star star = new Star("小菜");
Company company = new Company("Cbuc娱团");
Fans fans = new Fans("小菜菜");
Agent agent = new Agent(star, company, fans);
agent.meeting();
agent.bussiness();
}
}
/** OUTPUT:
经纪人安排小菜与粉丝:小菜菜 见面了
经纪人安排小菜与娱乐公司:Cbuc娱团 签约了
**/
迪米特法则就是通过中间人来实现双方及多方的交互,这样每方之间的关系就不会很杂乱。
6. 合成复用原则
合成复用原则是指尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现
通常类的复用分为 继承复用 和 合成复用 两种
继承复用 相对来说会比较简单易实现,但是也存在以下缺点:
- 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类。父类对子类是透明的,所以这种复用又称为 "白箱"复用
- 子类与父类的耦合度高。父类的任何改变都会导致子类实现发生变化,这不利于类的扩展与维护
- 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
- 它维护了类的封装性,因为成分对象的内部细节是新对象看不见的,所以这种复用又称为"黑箱"复用
- 对象间的耦合度低,可以在类的成员位置声明抽象
- 复用的灵活性高,这种复用可以在运行时动态进行,新对象可以动态地引用于成分对象类型相同的对象
我们来看下不同复用的图示:
继承复用:
首先有个汽车的抽象类,这个时候如果按照"动力源"划分的话,我们又可以扩展为 "汽油汽车" 和 "能源汽车",接着我们又可以针对颜色来划分,分为黑色和红色乃至更多其他颜色。这样子有个很明显的问题就是,通过继承复用就会产生很多子类。那我们接下来就用聚合复用 来实现一下:
我们将颜色单独抽取了出来,以聚合的方式来实现,可以看到整个设计也比较简单,这样就达到了实现合成复用原则