设计模式的7个设计原则
设计原则
在软件开发中,为了提供软件系统的:可维护性、可复用性、增加软件的可扩展性和灵活性,程序员尽量根据7条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
设计原则最初只有5条,单一功能、开闭原则、里氏替换、接口隔离以及依赖反转,后来又进行了增强,目前详细版的有7个原则,很多书都只有6个原则。目前共有23种设计模式。
设计模式最初都是应用于强类型语言,所有很多例子都是java这种例子,所以,类,抽象,这些在后面的文章中会频繁出现。希望你有一些心理准备。
开闭原则
开闭原则是设计中最为重要的一个设计原则,它是所有原则的基础,所有的设计原则全部都是围绕着开闭思想延展的。
正所谓原则的尽头是开闭。
开闭原则讲的是:对扩展开放,对修改关闭。
举个例子:
当我们有一个已经写好的功能,已经投入使用时,这时又更新了新的需求,此时为了满足需求,原有的功能就需要进行改动,但是如果直接去修改,很容易造成原有功能出现问题(保证已上线的功能不会出问题)。
为了能保证代码的安全性,遵循开闭原则,对功能的直接修改是不提倡的,提倡的是增加新的扩展,比如原来只有a、b两个属性方法,我需要一个新的功能,可以增加一个c的方法。不影响原有的功能。
但是在面向对象的语言里,一般是会预先声明一个抽象,然后由一个类去继承抽象,去完成具体功能实现,当需要增加新的功能时,新增一个新的类去实现,不必改动原有的类。
此时,新的类可以很好的完成旧的类的功能,因为抽象定义了规则,且自身的扩展并不会影响旧的类。
面向对象语言里的做法
一般都会将会被改动到的内容,作为一个“热插拔”的功能,比如主题包,所有的主题都可以继承抽象主题,这个抽象定义了一个show
的使用接口。
系统需要更换主题时,只需要更换一个继承抽象的类,调用其show方法,就能完成主题切换,而不用每次改动都去修改更上级的代码,才能实现该功能。
那么此时,就可以满足对扩展开发,对修改关闭。
更深一步的思考
我们最开始举的例子,增加一个c方法,难道不是对功能的修改吗?
面向对象语言中,增加一个新的子类,必然要改变调用对象时的代码,这不算修改吗?
其实这种就是针对不同的维度下的看法,功能添加了c方法,这确实是对它的修改,但是新增的c方法并不会影响到功能原有的属性和方法,那么它又可以认为是扩展。
在面向对象语言中,我们可以把整个主题的功能代码作为一个整体来看,当我需要切换主题时,我只需要新增一个新的主题类,然后调用使用,且不会影响之前的主题代码和更上层的代码,那么它在添加新的功能时,完全满足开闭原则。
事实上修改是在所难免的,但是可以接受。
而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不 “修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
里氏替换原则
该原则可以看成是开闭原则中对扩展开放的延展。因为在强类型语言中,扩展一般都是继承类,然后扩展。但是这么弄就会涉及到一些继承的问题,这里就需要遵循里氏替换原则。
原则:任何基类可以出现的地方,子类一定可以出现
通俗点来说:父类的方法,子类继承后,除了自身新增的功能外,尽量不要重写父类的方法。
如果你要重写,那为什么要继承,为什么不直接继承抽象,甚至使用接口定义规则呢?
这条规则保证了,父类被替换成子类后,子类依旧保有原样的方法,不会对已存在的使用造成问题。
举个最经典的例子:正方形不是长方形
在数学的领域,正方形是属于长方形的(有一个角是直角的平行四边形叫做长方形。也定义为四个角都是直角的平行四边形。)
我们搬到代码上去实现。
//长方形
class Rectangular {
width: number;
height: number;
constructor() {
this.width = 0;
this.height = 0;
}
public setWidth(value: number) {
this.width = value;
}
public setHeight(value: number) {
this.height = value;
}
public getWidth(): number {
return this.width;
}
public getHeight(): number {
return this.height;
}
}
//依赖长方形的其他类
class Outh {
private rectangular: Rectangular;
constructor() {
this.rectangular = new Rectangular();
this.rectangular.setWidth(100);
this.rectangular.setHeight(80);
}
public resize() {
//如果高度小于或等于宽度
while (this.rectangular.getHeight() <= this.rectangular.getWidth()) {
this.rectangular.setHeight(this.rectangular.getWidth() + 1);
}
}
}
当我调用Outh
的resize的时候,他会判断,如果长方形高度小于或等于宽度,他会设置高度等于宽度+1;保持一个竖着的长方形。
此时我们可能会有一个新需求,一个正方形,开闭原则,我们扩展了一个新的子类。
//定义一个正方形
class Square extends Rectangular {
public setWidth(value: number) {
this.width = value;
this.height = value;
}
public setHeight(value: number) {
this.height = value;
this.width = value;
}
...其他需求功能
}
//依赖长方形的其他类
class Outh {
private rectangular: Rectangular;
constructor() {
this.rectangular = new Square();
this.rectangular.setWidth(100);
this.rectangular.setHeight(80);
}
public resize() {
//如果高度小于或等于宽度
while (this.rectangular.getHeight() <= this.rectangular.getWidth()) {
this.rectangular.setHeight(this.rectangular.getWidth() + 1);
}
}
}
子类覆写了赋值宽高的方法,但是此时我们再去调用resize,很明显,我们陷入死循环导致内存溢出。
在resize方法中,Rectangular
是无法被Square
类代替的,如果进行了替换,就得不到预期的结果,因此Square
与Rectangular
之间的继承,违反了里氏替换原则,他们之间的继承关系不成立。
所以回到开头,如果你要覆写父类的方法,为什么不继承抽象或者接口呢。
以上面例子来讲,怎么解决这个问题,推荐是使用接口去规范。
比如他们都有相同的一些属性和方法,可以通过接口去约束他们的实现,这样他们之间就不存在继承关系,没有继承了,里氏替换原则就可以略过去,然后因为Outh
中声明的是Rectangular
;所以如果使用了其他类,就会报错,又能在开发阶段避免代码问题。
//接口约束:四边形
interface Quadrilateral {
width: number;
height: number;
getWidth: () => number;
getHeight: () => number;
}
//长方形
class Rectangular implements Quadrilateral {
width: number;
height: number;
constructor() {
this.width = 0;
this.height = 0;
}
public setWidth(value: number) {
this.width = value;
}
public setHeight(value: number) {
this.height = value;
}
public getWidth(): number {
return this.width;
}
public getHeight(): number {
return this.height;
}
}
//定义一个正方形
class Square implements Quadrilateral {
width: number;
height: number;
constructor() {
this.width = 0;
this.height = 0;
}
public setLength(value: number) {
this.width = value;
this.height = value;
}
public getWidth(): number {
return this.width;
}
public getHeight(): number {
return this.height;
}
}
//依赖长方形的其他类
class Outh {
private rectangular: Rectangular;
constructor() {
this.rectangular = new Square();
this.rectangular.setWidth(100);
this.rectangular.setHeight(80);
}
public resize() {
//如果高度小于或等于宽度
while (this.rectangular.getHeight() <= this.rectangular.getWidth()) {
this.rectangular.setHeight(this.rectangular.getWidth() + 1);
}
}
}
注意:
typescript的类型校验,他不是全等的,它会是像鸭子定律那样(如果有一个东西 它长得像鸭子,叫声像鸭子,走路像鸭子,那它就是鸭子);所以,如果两个类的属性都一样,虽然实现不一样,那么他们就可以相等,校验就会通过。
依赖倒置原则
高层模块不应该依赖于低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
简单来说就是,不要针对具体的实现过程去编写代码,这样就会导致代码之间的耦合度非常高,如果某一个依赖出现错误,很难找到可以代替的依赖。即便找到了,也很难进行替换。
如果我们针对的是更高一层的抽象,比如所有的依赖都应该有一个show
方法(接口),那么我们就可以通过interface
或者abstract
抽象类去声明一个规则,依赖自身的实现遵循这个接口,调用方也声明需要的是符合这个规则的依赖,两方依赖的都是更高层次的抽象,这样耦合度就降低了,我更换一个依赖,只要遵循了规则,我调用方就可以正确的使用。
举个例子:组装电脑
一台电脑他需要:cpu、内存、硬盘
这是一个类图,可以看到电脑类需要三个子类,子类都有自己的操作方法,当电脑安装完硬件,run运行是,调用对应子类的方法。
我们来看下代码层的实现:
class Cpu {
run() {
console.log("cpu 进行了计算");
}
}
class Memory {
set() {
console.log("内存写入了缓存");
}
}
class HardDisk {
save() {
console.log("硬盘保存了数据");
}
}
class Computer {
cpu!: Cpu;
memory!: Memory;
hardDisk!: HardDisk;
constructor() {}
setCpu(cpu: Cpu) {
this.cpu = cpu;
}
setMemory(memory: Memory) {
this.memory = memory;
}
setHardDisk(hardDisk: HardDisk) {
this.hardDisk = hardDisk;
}
run() {
this.cpu.run();
this.memory.set();
this.hardDisk.save();
}
}
const computer = new Computer();
computer.setCpu(new Cpu());
computer.setMemory(new Memory());
computer.setHardDisk(new HardDisk());
computer.run();
此时符合我们的需要,电脑也能正常运行,但是如果有一天,cpu已经满足不了日常需要了,我们需要更换一个cpu,此时,你会发现,你需要改动非常多的地方,这显然不符合开闭原则。
我们可以遵循依赖倒置原则,让两方都依赖于抽象。
再看一个改动后的类图:
依赖于一个抽象,不是就可以避免上述问题,依赖随便换,只要满足接口要求即可。
interface Cpu {
run: () => void;
}
interface Memory {
set: () => void;
}
interface HardDisk {
save: () => void;
}
class MyCpu implements Cpu {
run() {
console.log("cpu 进行了计算");
}
}
class MyMemory implements Memory {
set() {
console.log("内存写入了缓存");
}
}
class MyHardDisk implements HardDisk {
save() {
console.log("硬盘保存了数据");
}
}
class Computer {
cpu!: Cpu;
memory!: Memory;
hardDisk!: HardDisk;
constructor() {}
setCpu(cpu: Cpu) {
this.cpu = cpu;
}
setMemory(memory: Memory) {
this.memory = memory;
}
setHardDisk(hardDisk: HardDisk) {
this.hardDisk = hardDisk;
}
run() {
this.cpu.run();
this.memory.set();
this.hardDisk.save();
}
}
const computer = new Computer();
computer.setCpu(new MyCpu());
computer.setMemory(new MyMemory());
computer.setHardDisk(new MyHardDisk());
computer.run();
此时,我们更换一个cpu是不是很简单,不符合要求的就会报错,上层模块不受具体的依赖实现影响,反正你要给我符合要求就行。
一般情况下抽象的变化概率很小,让用户程序依赖于抽象,实现的细节也依赖于抽象。即使实现细节不断变动,只要抽象不变,客户程序就不需要变化。这大大降低了客户程序与实现细节的耦合度。
单一职责原则
一个类或者模块只负责完成一个职责,简单点来说,一个模块,类、方法、不要承担过多的功能,比如一些不相干的功能。
而且更小粒度的设计,代码存在的bug就会越少。
当然也并不是所有的东西都一刀切,我们可以通过一个例子来思考下单一职责的真实应用。
class Message {
show(val: any) {
console.log(val);
}
}
我们有一个Message
的类,它用于打印内容,我们在控制台打印我们的内容。
const message = new Message();
message.show(111);
message.show("string");
message.show({ name: "string" });
//此时我希望能打印错误对象的属性
message.show(new Error("错误"));
log
是无法打印出错误对象的属性的,我们使用dir
方法才可以,所以怎么办?很简单嘛!直接在show里加个判断就好了。
class Message {
show(val: any) {
if (val instanceof Error) {
console.dir(val);
} else {
console.log(val);
}
}
}
但是我们可能过段时间又增加了一个需求,我希望能加一个抛出error
警告样式的打印输出。怎么办?很简单嘛,直接改show方法,加个参数就好了。
class Message {
show(val: any, isError = false) {
if (isError) {
console.error(val);
} else if (val instanceof Error) {
console.dir(val);
} else {
console.log(val);
}
}
}
然后每次新需求都改变show方法,这合理吗?这非常不合理,如果有一块代码写错了,就会影响到所有使用show的地方。
那我们可以单一职责;把他们拆分一下。
class Message {
show(val: any) {
console.log(val);
}
}
class ErrorMessage {
show(val: any) {
console.error(val);
}
}
class DirMessage {
show(val: any) {
console.dir(val);
}
}
我们改成三个不同的message类,需要使用调用什么,非常奈斯。
但是,我们有没有想过一个问题,就是代码的改动量,调用者和被调用者都需要大量的代码修改,最直观的就是调用者需要实例化好几个message的类才行。虽然这种方式遵循了单一职责原则。
仔细思考后,我们能否这么做呢?
class Message {
show(val: any) {
console.log(val);
}
dirShow(val: any) {
console.dir(val);
}
errorShow(val: any) {
console.error(val);
}
}
我们把不同的方法封装在一个类里面去,这样我们的调用者只需要实例化一个,调用时也更加明了,改动量也不会很大。
但是这种方法,第一眼看去,好像是违反了单一职责原则;因为一个类里面做了很多功能,但是,我们从另一个角度去看,从方法的角度去看,是不是做了单一性的区分,是不是也满足单一职责。
所以,单一职责虽然理解上很简单,但是在实际实现上时,还是需要一些思考的,不是无脑拆分,更多的是要考虑可维护性,灵活性这些东西。
接口隔离原则
客户端不应该被迫依赖于它不使用的方法;一个类对另一个类的依赖应该建立在最小的接口上。
简单来说就是对接口这种规则定义时的细化拆分,当然也不仅限于interface
接口的使用,他可以在其他地方使用,后面接口的例子也是方面我们理解。
例子:
比如我们有一个消息弹窗的接口,它定义了两个方法:show、hide
;show是显示,hide是关闭。
我们用一个类去实现它。
interface Message {
show: () => void;
hide: () => void;
}
class MessageBox implements Message {
name: string;
constructor() {
this.name = "MessageBox";
}
show() {
console.log(`show ${this.name}`);
}
hide() {
console.log(`hide ${this.name}`);
}
}
当我们使用一段时间后,发现这个功能非常好用,大家都习惯这种接口调用,此时,又有一个新的弹窗需求,它需要能够自动关闭,不能手动关闭。
那我们可能为了省事,也使用Message
接口来进行约束类。
class AutoHideMessage implements Message {
name: string;
constructor() {
this.name = "AutoHideMessage";
}
show() {
console.log(`show ${this.name}`);
setTimeout(() => {
console.log(`hide ${this.name}`);
}, 2000);
}
hide() {}
}
但其实这就不符合我们的接口隔离原则了,我们在看一下原则的第一句话:客户端不应该被迫依赖于它不使用的方法
我们的AutoHideMessage
很明显有一个它不需要的方法hide
;但是迫于Message
规则,我们不得不去对它进行实现。这显然不是我们想要的。
并且,如果Message
定义了20个接口,我们的AutoHideMessage
可能就用到其中10个,但是我们又不得不去实现另外10个用不到的方法。
怎么办呢?
我们在细读原则的第二段:一个类对另一个类的依赖应该建立在最小的接口上
怎么去理解呢,就是我AutoHideMessage
只用到10个方法,那么我的接口就应该只声明10个方法,要做到最小化接口。
那么我们可以对接口进行拆分!!!
interface MessageShow {
show: () => void;
}
interface MessageHide {
hide: () => void;
}
拆分后,我们根据情况implements
去指定多个接口
class MessageBox implements MessageShow, MessageHide {
name: string;
constructor() {
this.name = "MessageBox";
}
show() {
console.log(`show ${this.name}`);
}
hide() {
console.log(`hide ${this.name}`);
}
}
class AutoHideMessage implements MessageShow {
name: string;
constructor() {
this.name = "AutoHideMessage";
}
show() {
console.log(`show ${this.name}`);
setTimeout(() => {
console.log(`hide ${this.name}`);
}, 2000);
}
}
将接口拆分成独立的几个接口,实现类分别与它们需要的接口建立依赖关系,也就是采用依赖隔离原则
迪米特法则(最少知道原则)
一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和直接朋友通信,不和陌生人说话。
这个原则用于解耦操作,只有弱耦合才会有较高的复用率,但是解耦会带来一些负面影响,比如大量中转类或者说代理层出现。
简单点来说,我写了功能A,它负责了消息的弹出,关闭,此时我有一个功能B需要使用A,那么我只需要关心A公开的接口,我并不关心它的内部的实现,我只需要调用一两个接口,就能完成我的操作,最少知道原则。
也就是说,我们不要在一个功能里面,去写另一个功能的详细实现,这显然会导致代码搅在一起,很难维护,即便我们要去使用一些功能,也秉着最小知道原则,通过尽量少的调用就能完成需要的功能。
只和朋友通信,不和陌生人说话,其实也是在说解耦的问题,在此之前先说明下什么是直接朋友!
什么是直接朋友?
作为自己的成员变量,或者方法的参数,或者自己的某个方法抛出的对象,它就是我的直接朋友。
方法抛出,比如我有一个getItem方法,它接受一个下标,它最终会抛出一个从其他地方拿到的数组中的指定成员,那么这个成员,就是我的直接朋友
但是,有时候,不可能功能A一个方法就能满足我们的需要,可能会调用多个,当使用的越多,耦合度就越高,如何解耦,可以通过使用中转层,或者代理层的方式。
我们创建一个代理层,调用代理层的方法,代理层里面去对A功能进行强耦合。我们与代理层弱耦合。
合成复用原则
尽量使用对象组合(聚合),而不是继承来达到复用的目的。
当我们想复用一个对象的方法的时候,最先想到的就是就是继承,比如我有一个类,这个类有两个方法,我需要复用这两个方法,第一想到的就是extends
继承。
class A {
show() {}
hide() {}
}
//复用
class B extends A {
alert() {}
}
但是使用继承实际上会有代价的,首先就是B和A的耦合性增强,如果A的代码发生了变化,会直接影响到继承于它的子类,而且,如果父类A在后续增加了一些新的方法,但是这些方法在B中并不需要,那么显然子类B也会被添加到这些多余的方法。
所以,当我们在考虑代码复用的时候,首先就应该想到合成复用原则;
那么我们再回过头来看下原则的定义,组合和聚合!
什么是聚合?
聚合表示的是整体与部分的关系,整体和部分是可以分开的。比如:一个人,它有手有头,但是一个人可不可以没有手,没有手他也是一个活人。
手与人的关系它就是聚合关系,它可以分开。
从代码的角度可以这么表示:
class A {
show() {}
hide() {}
}
//复用
class B {
a: A;
setA(a: A) {
this.a = a;
}
}
或者:
class A {
show() {}
hide() {}
}
//复用
class B {
show(a: A) {
a.show();
}
}
A在B类中,它是一个可以与整体分离的状态,因为A并没有在B的实现过程(实例化)表现出不可分离。
什么是组合?
如果A和B是不可分离的,那么它就被升级为组合关系。比如人的头,一个活人肯定会有头的,头是不能进行分离的,分了就死了。
代码上可以这么表示:
class A {
show() {}
hide() {}
}
//复用
class B {
a = new A();
}
当我们搞清楚这两种区别后,应该也明白了合成复用原则,以及如何合成复用。
如果我们一定要使用继承,那么就要遵循里氏替换原则。
一般而言,如果两个类之间是“Has-A”的关系应使用组合或聚合,如果是“Is-A”关系可使用继承。"Is-A"是严格的分类学意义上的定义,意思是一个类是另一个类的"一种";而"Has-A"则不同,它表示某一个角色具有某一项责任。
总结
所有的设计原则,最终你会发现,它的目的:
- 找到需要变化的部分,把它独立出来,不要和那些不需要变化的代码混到一起
- 针对接口编程,而不是针对实现编程
- 都是为了实现弱耦合而努力
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据