设计原则

在软件开发中,为了提供软件系统的:可维护性、可复用性、增加软件的可扩展性和灵活性,程序员尽量根据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. 都是为了实现弱耦合而努力
分类: 设计模式 标签: 设计模式设计原则开闭原则里氏替换原则依赖倒置原则单一职责原则接口隔离原则迪米特法则最少知道原则合成复用原则

评论

暂无评论数据

暂无评论数据

目录