设计模式七大原则(包含SOLID和迪米特法则、合成复用原则)

276 阅读6分钟

前言

设计模式七大原则是用来指导面向对象设计的。其中的前5大,大家应该都比较熟悉了 SOLID的是有关面向对象设计的5个原则的首字母缩略词。它包含

  1. Single-responsibility principle
  2. Open–closed principle
  3. Liskov substitution principle
  4. Interface segregation principle
  5. Dependency inversion principle 除了这些还有迪米特法则、合成复用原则。

遵循以上原则,有利于写出易懂,灵活,可维护性高的代码。下面来逐个介绍

Single-responsibility principle

单一职责原则:一个类只负责一件事,只有一个引起它变化的原因。 如果一个类型,负责多件事,那么它的每个职责在修改时,都会修改这个类。而修改,就有可能带来bug。 而且,如果其它的类只需要它的某个功能,但继承了它,就会连那些不需要的功能一起继承了,也可能带来bug。

Open–closed principle

开闭原则:软件实体(比如class)应该对扩展开放,对修改封闭 具体一点,它的意思是应该可以通过新增代码来给类实现新的功能,而且尽量避免对原有代码的修改。 这里有一个例子

class Screen {
  drawShapes(shapes: Shape[]): void {
    for (const shape of shapes) {
      if (shape instanceof Circle) {
        drawCircle(shape);
      } else if (shape instanceof Rectangle) {
        drawRectangle(shape);
      } else {
        throw new Error("Unknown shape!");
      }
    }
  }
}

如果想给drawShapes新增画三角形的功能,就得新增一个else if,违反了此原则。应该改成这样:

class Screen {
  drawShapes(shapes: Shape[]): void {
    for (const shape of shapes) {
      shape.draw();
    }
  }
}

其中,每种shape的draw方法都由它自己实现。这样避免了Screen和具体shape的耦合。新增shape时,不需要修改这个Screen::drawShapes里的代码,它就能支持新的shape的绘制。

下面再举一个例子 blinker.readthedocs.io/en/stable/ 这是python里的一个library。里面有个signal的使用,就遵循OCP。 开发者可以把每种事件,抽象成一个signal。然后给这个signal添加回调函数。

def log_on_event(event: Event):
    logger.info("Clicker at {}, {}".format(event.x, event.y))


def get_file(event: Event):
    requests.get(event.url)


on_click = signal("on_click")

on_click.connect(log_on_event)
on_click.connect(get_file)

on_click.send(Event(1, 2, "http://localhost:5000"))

如果想给on_click增加新的处理逻辑,只要新增on_click.connect(SOME_HANDLER)就行了。

Liskov substitution principle

里式替换原则:父类应当可以用子类替换。 也就是,父类能够出现的地方,子类一定能够出现。 这个原则的一个作用是来帮助开发者判断一个继承关系是否合理的。 这里有一个著名的问题 Circle-ellipse problem。理论上,圆是椭圆的一种,当椭圆的长轴和短轴相等时,这个椭圆就成了圆。 但其实在面向对象设计中,圆继承椭圆是不合适的。

class Ellipse {
  constructor(public x: number, public y: number) {
  }
  setX(x: number) {
    this.x = x;
  }
  setY(y: number) {
    this.y = y;
  }
}

如果一个Circle类继承了EllipsesetXsetY,无论怎么实现都是不合适的。原本接受Ellipse的代码,如果换成了Circle,那么代码就可能出错。

Interface segregation principle

接口隔离原则:不应强制客户端依赖它们不需要的接口 这个原则有点像单一职责原则的“接口版本”。举个例子

interface ParkingLot {
  parkCar();
  doPayment(car: Car);
}

这个接口表示停车场,其中有两个方法,一个停车,一个支付。 这里的问题,有个停车场免费,所以没有doPayment方法。 解决此问题的一个方法是拆成这两个接口。

interface ParkingLot {
  parkCar();
}

interface ParkingLotPayment {
  doPayment(car: Car);
}

免费停车场只需实现ParkingLot,付费停车场需要实现以上2个接口。

Dependency inversion principle

依赖倒置原则:类应该依赖接口或者抽象类,而不是具体类和函数。 再次考虑我在Open–closed principle中举的Screen例子。其实,在这个例子中,我们是按照DIP来改造了Screen类。同时也让这个类遵循了OCP。通过这个例子,我们可以发现,OCP和DIP有着紧密的联系。 DIP是一个实现OCP的方法,但不是唯一的方法。

Law of Demeteror (or principle of least knowledge)

迪米特法则,又称最小知道原则,它的概念是一个对象应该对其他对象保持最少的了解。它是一种让面向对象设计实现低耦合高内聚的方法。 举个例子

class Client {
  orderTakeout(foodService: FoodService, foodName: string) {
    const cook = foodService.getCook();
    const food = cook.cook(foodName);
    return food;
  }
}

这段代码是没有遵循LoD的。因为client不应该与cook交互。如果foodService获取事务的逻辑不再是以上这个逻辑,那client也得改代码。 不如改成以下形式

class Client {
  orderTakeout(foodService: FoodService, foodName: string) {
    const food = foodService.getFood();
    return food;
  }
}

这种形式中,Client类保持了对FoodSerivice的最小了解,职责变得简单了。foodSerice获取食物的逻辑无论怎样更改,都不需要修改client的代码。

Composite Reuse Principle (or composition over inheritance)

合成复用原则(又称为组合优于继承):使用组合来实现代码复用和多态,而不是继承。 继承虽然很常用,但是也有一些问题。最近几年流行的新语言,有些不支持继承,比如go, rust。 这里以继承和组合两种方式来实现同一个逻辑,来展示组合比继承好在哪里。 这个例子有点像我在接口隔离原则里提到的。

abstract class IBird {
  eat() {
    console.log("Eat food");
  };
  fly(): void {
    console.log("Flying.");
  }
}

class Ostrich extends IBird {
  fly() {
    throw new Error("Not implemented");
  }
}

这种方法的问题,子类Ostrich继承了它完全不需要的方法。这个问题,如果仍要使用继承来解决,也是能解决的,但是比较麻烦

classDiagram
direction BT
IFlyableBird <|-- IBird
IBird : +eat()
IFlyableBird: +fly()

Pigeon <|-- IFlyableBird
Pigeon : +sendMessage()

Ostrich <|-- IBird
Ostrich : +run()

如果把鸟会不会跑、会不会叫,等问题考虑进来,那继承关系更复杂了。假设鸟类拥有5种能力,这5种能力的组合高达2^5=32种,每一种都用1个类或者接口来表示就太麻烦了。

继承一个问题在于:继承层次过深、继承关系过于复杂时会影响到代码的可读性和可维护性。

下面,把以上的逻辑使用组合来表示。 把吃食物、飞翔的能力,作为对象,让各种鸟类引用这些对象,来表示它们拥有这些能力。

interface IFlyAbility {
  fly(): void;
}

interface IEatAbility {
  eat(): void;
}

class Pigeon {
  flyAbility: IFlyAbility;
  eatAbility: IEatAbility;
  fly() {
    this.flyAbility.fly();
  }
  eat() {
    this.eatAbility.eat();
  }
}

class Ostrich {
  eatAbility: IEatAbility;
  eat() {
    this.eatAbility.eat();
  }
}

以上代码有以下优势

  1. 继承关系简单。如果需要再增加一种能力,也只需要新增一个能力接口和一个能力实现即可。
  2. 灵活。类的继承关系在编译期就已经确定了,它的任意一种行为也是确定的。但组合关系中,一个对象可以随便改变它引用的对象,这样可以在运行时改变它的某个行为的实现。

总结

学习了以上几种原则,我对低耦合高内聚有了更深刻的认识。 SIP体现了高内聚。 OCP,DIP,LoD体现了降低高层模块与底层模块的耦合。 组合优于继承,体现了避免继承关系带来的耦合。

以上原则也不是完美的。例如在一个项目的实现方式(例如使用哪个数据库)很少改动的情况下,遵循DIP会使代码变得啰嗦。在明确一个类不会被继承的情况下,而且项目较小的情况下,遵守SRP也会使代码变得啰嗦。总之,应该灵活地去选择和使用。