木灵鱼儿

木灵鱼儿

阅读:511

最后更新:2022/05/23/ 1:33:07

设计模式的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类代替的,如果进行了替换,就得不到预期的结果,因此SquareRectangular之间的继承,违反了里氏替换原则,他们之间的继承关系不成立。

所以回到开头,如果你要覆写父类的方法,为什么不继承抽象或者接口呢。

以上面例子来讲,怎么解决这个问题,推荐是使用接口去规范。

比如他们都有相同的一些属性和方法,可以通过接口去约束他们的实现,这样他们之间就不存在继承关系,没有继承了,里氏替换原则就可以略过去,然后因为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"则不同,它表示某一个角色具有某一项责任。

总结

所有的设计原则,最终你会发现,它的目的:

  1. 找到需要变化的部分,把它独立出来,不要和那些不需要变化的代码混到一起
  2. 针对接口编程,而不是针对实现编程
  3. 都是为了实现弱耦合而努力

版权申明

本文系作者 @木灵鱼儿 原创发布在木灵鱼儿 - 有梦就能远航站点。未经许可,禁止转载。

关于作者

站点职位 博主
获得点赞 1
文章被阅读 511

相关文章