Typescript玩转设计模式 之 结构型模式(上)

avatar
数据可视化 @蚂蚁集团

作者简介 joey 蚂蚁金服·数据体验技术团队

继前文Typescript玩转设计模式 之 结构型模式(上)之后,本周带来的是系列文章之三,讲解的是4种结构性模式:

  • 适配器
  • 桥接
  • 组合
  • 装饰

结构性模式分为7种,本文先讲解其中四种,剩余3种下一篇文章再进行讨论~

适配器(Adapter)

定义

将一个类的接口转换成客户希望的另外一个接口。Adapter模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。

结构

适配器模式由以下角色构成:

  • Target(目标抽象类):目标抽象类定义客户所需接口,可以是一个抽象类或接口,也可以是具体类。
  • Adapter(适配器类):适配器可以调用另一个接口,作为一个转换器,对Adaptee和Target进行适配,适配器类是适配器模式的核心,在对象适配器中,它通过继承Target并关联一个Adaptee对象使二者产生联系。
  • Adaptee(适配者类):适配者即被适配的角色,它定义了一个已经存在的接口,这个接口需要适配,适配者类一般是一个具体类,包含了客户希望使用的业务方法,在某些情况下可能没有适配者类的源代码

对象适配器

类适配器

示例

电源插座是220V的,但手机充电时只需要5V,因此我们需要一个适配器让手机能在220V插座上充电。

// 适配器有2种实现模式,类模式和对象模式

// 目标接口
interface Voltage5vInterface {
  connect5v(): void;
}

// 被适配类
class Voltage220v {
  connect220v() {
    console.log('接通220V电源,');
  }
}

// 客户类,保存对适配器对象的引用关系,通过访问适配器对象来间接使用被适配对象
// 这里,手机充电时只需要知道适配器对象的5V接口就能调用被适配的220V插座来充电了
class Phone {
  private voltage5v: Voltage5vInterface;
  constructor(voltage5v: Voltage5vInterface) {
    this.voltage5v = voltage5v;
  }
  charge(): void {
    this.voltage5v.connect5v();
    console.log('已经接通电源,手机开始充电');
  }
}

// 类适配器
// Voltage220v是被适配的类,接口跟最终要求不一致
// Voltage5vInterface包含想要提供的接口
// 因此"继承"被适配的类,"实现"想要支持的接口
class ClassPowerAdapter extends Voltage220v implements Voltage5vInterface {
  connect5v(): void {
    this.connect220v();
    console.log('将220V电源转化为5v电源,');
  }
}

function classAdapterDemo() {
  const adapter = new ClassPowerAdapter();
  const phone = new Phone(adapter);
  phone.charge();
}
classAdapterDemo();

// 对象适配器
// 适配器中持有被适配类的对象的引用
class InstancePowerAdapter implements Voltage5vInterface {
  private voltage220v: Voltage220v;
  constructor(voltage220v: Voltage220v) {
    this.voltage220v = voltage220v;
  }
  connect5v(): void {
    this.voltage220v.connect220v();
    console.log('将220V电源转化为5v电源,');
  }
}

function instanceAdapterDemo() {
  const voltage220v = new Voltage220v();
  const adapter = new InstancePowerAdapter(voltage220v);
  const phone = new Phone(adapter);
  phone.charge();
}
instanceAdapterDemo();

同一个接口适配不同的类

电脑有个USB接口,可以插入华为手机或iphone的数据线

// 电脑的USB接口
interface ComputerInterface {
  usb(): void;
}

// 华为手机,有自己的数据接口
class HuaweiPhone {
  huaweiInterface(): void {
    console.log('华为手机的数据接口');
  }
}

// iphone,有自己的数据接口
class Iphone {
  iphoneInterface(): void {
    console.log('苹果手机的数据接口');
  }
}

// 华为手机数据线适配器
class HuaweiDataWireAdapter extends HuaweiPhone implements ComputerInterface {
  usb(): void {
    console.log('使用华为数据线连接');
    super.huaweiInterface();
  }
}

// iphone手机数据线适配器
class IphoneDataWireAdapter extends Iphone implements ComputerInterface {
  usb(): void {
    console.log('使用苹果数据线连接');
    super.iphoneInterface();
  }
}

function commonAdapterDemo() {
  const computer1 = new HuaweiDataWireAdapter();
  computer1.usb();
  const computer2 = new IphoneDataWireAdapter();
  computer2.usb();

}
commonAdapterDemo();

适用场景

  • 你想使用一个已经存在的类,而他的接口不符合你的需求;
  • 你想创建一个可以复用的类,该类可以与其他不相关的类或不可预见的类(即那些接口可能不一定兼容的类)协同工作;
  • (仅对于对象适配器)你想使用一些已经存在的子类,但是不可能对每一个都进行子类化以匹配他们的接口。对象适配器可以适配他的父类接口;

优点

  • 将目标类和被适配类解耦,通过引入一个适配器类来重用现有的被适配类,而无须修改原有代码;
  • 增加了类的透明性和复用性,将具体的实现封装在被适配类中,对于客户端类来说是透明的,而且提高了被适配类的复用性;
  • 类适配器:由于适配器类是被适配类的子类,因此可以在适配器类中置换一些被适配类的方法,使得适配器的灵活性更强;
  • 对象适配器:一个对象适配器可以把多个不同的被适配类适配到同一个目标,也就是说,同一个适配器可以把被适配类和它的子类都适配到目标接口;

缺点

  • 类适配器:单继承机制使得同时只能适配一个被适配类;
  • 对象适配器:与类适配器模式相比,要想置换被适配类的方法就不容易;

相关模式

  • 桥接(Bridge)模式的结构与对象适配器类似,但桥接模式的出发点不同:桥接目的是将接口部分和实现部分分离,从而使他们可以较为容易也相对独立的加以改变。而适配器则意味着改变一个已有对象的接口。
  • 装饰器(Decorator)模式增强了其他对象的功能而同时又不改变他的接口。因此装饰器对应用程序的透明性比适配器要好。装饰器支持递归组合,而纯粹使用适配器是不可能实现这一点的。
  • 代理(Proxy)模式在不改变他的接口的条件下,为另一个对象定义了一个代理。

桥接(Bridge)

定义

将抽象部分与他的实现部分分离,使他们都可以独立地变化。

结构

桥接模式包含以下角色:

  • Abstraction(抽象类):用于定义抽象类的接口,它一般是抽象类而不是接口,其中定义了一个Implementor(实现类接口)类型的对象并可以维护该对象,它与Implementor之间具有关联关系,它既可以包含抽象业务方法,也可以包含具体业务方法。
  • RefinedAbstraction(扩充抽象类):扩充由Abstraction定义的接口,通常情况下它不再是抽象类而是具体类,它实现了在Abstraction中声明的抽象业务方法,在RefinedAbstraction中可以调用在Implementor中定义的业务方法。
  • Implementor(实现类接口):定义实现类的接口,这个接口不一定要与Abstraction的接口完全一致,事实上这两个接口可以完全不同,一般而言,Implementor接口仅提供基本操作,而Abstraction定义的接口可能会做更多更复杂的操作。Implementor接口对这些基本操作进行了声明,而具体实现交给其子类。通过关联关系,在Abstraction中不仅拥有自己的方法,还可以调用到Implementor中定义的方法,使用关联关系来替代继承关系。
  • ConcreteImplementor(具体实现类):具体实现Implementor接口,在不同的ConcreteImplementor中提供基本操作的不同实现,在程序运行时,ConcreteImplementor对象将替换其父类对象,提供给抽象类具体的业务操作方法。

示例

// 汽车是一个维度,有多种不同的车型
abstract class AbstractCar {
  abstract run(): void;
}

// 路是一个维度,有多种不同的路
abstract class AbstractRoad {
  car: AbstractCar;
  abstract snapshot(): void;
}

/**
 * 汽车和路两个维度
 * 桥接就是一个维度的类中引用了另一个维度的对象,但只关心接口不关心是哪个具体的类
 * 从而实现两个维度独立变化
 */
class SpeedRoad extends AbstractRoad {
  constructor(car: AbstractCar) {
    super();
    this.car = car;
  }
  snapshot(): void {
    console.log('在高速公路上');
    this.car.run();
  }
}

class Street extends AbstractRoad {
  constructor(car: AbstractCar) {
    super();
    this.car = car;
  }
  snapshot(): void {
    console.log('在市区街道上');
    this.car.run();
  }
}

class Car extends AbstractCar {
  run(): void {
    console.log('开着小汽车');
  }
}

class Bus extends AbstractCar {
  run(): void {
    console.log('开着公共汽车');
  }
}

function carRunOnRoadDemo(): void {
  // 在高速公路上,开着小汽车
  const car = new Car();
  const speedRoad = new SpeedRoad(car);
  speedRoad.snapshot();

  // 在市区街道上,开着公共汽车
  const bus = new Bus();
  const street = new Street(bus);
  street.snapshot();
}
carRunOnRoadDemo();

/**
 * 人,汽车和路三个维度
 */
abstract class Person {
  road: AbstractRoad;
  abstract see(): void;
}

class Man extends Person {
  constructor(road: AbstractRoad) {
    super();
    this.road = road;
  }
  see(): void {
    console.log('男人看到');
    this.road.snapshot();
  }
}

class Woman extends Person {
  constructor(road: AbstractRoad) {
    super();
    this.road = road;
  }
  see(): void {
    console.log('女人看到');
    this.road.snapshot();
  }
}

function personSeeCarOnRoadDemo() {
  // 男人看到 在市区街道上 开着小汽车
  const car = new Car();
  const street = new Street(car);
  const man = new Man(street);
  man.see();
}
personSeeCarOnRoadDemo();

适用场景

  • 如果一个系统需要在抽象化和具体化之间增加更多的灵活性,避免在两个层次之间建立静态的继承关系,通过桥接模式可以使它们在抽象层建立一个关联关系;
  • “抽象部分”和“实现部分”可以以继承的方式独立扩展而互不影响,在程序运行时可以动态将一个抽象化子类的对象和一个实现化子类的对象进行组合,即系统需要对抽象化角色和实现化角色进行动态耦合;
  • 一个类存在两个(或多个)独立变化的维度,且这两个(或多个)维度都需要独立进行扩展;

优点

  • 分离接口及其实现部分;
  • 提高可扩充性;
  • 实现细节对客户透明;

缺点

  • 桥接模式的使用会增加系统的理解与设计难度,由于关联关系建立在抽象层,要求开发者一开始就针对抽象层进行设计与编程;
  • 桥接模式要求正确识别出系统中两个独立变化的维度,因此其使用范围具有一定的局限性,如何正确识别两个独立维度也需要一定的经验积累;

相关模式

  • 抽象工厂模式可以用来创建和配置一个特定的桥接模式。即一个维度的产品都由抽象工厂生成。
  • 和适配器模式的区别:适配器模式用来帮助无关的类协同工作,他通常在系统设计完成后才会被使用;桥接模式是在系统开始时就被使用,他使得抽象接口和实现部分可以独立进行改变。

组合(Composite)

意图

将对象组合成树形结构以表示“部分-整体”的层次结构。Composite使得用户对单个对象和组合对象的使用具有一致性。

结构

组合模式包含以下角色:

  • Component(抽象构件):它可以是接口或抽象类,为叶子构件和容器构件对象声明接口,在该角色中可以包含所有子类共有行为的声明和实现。在抽象构件中定义了访问及管理它的子构件的方法,如增加子构件、删除子构件、获取子构件等。
  • Leaf(叶子构件):它在组合结构中表示叶子节点对象,叶子节点没有子节点,它实现了在抽象构件中定义的行为。对于那些访问及管理子构件的方法,可以通过异常等方式进行处理。
  • Composite(容器构件):它在组合结构中表示容器节点对象,容器节点包含子节点,其子节点可以是叶子节点,也可以是容器节点,它提供一个集合用于存储子节点,实现了在抽象构件中定义的行为,包括那些访问及管理子构件的方法,在其业务方法中可以递归调用其子节点的业务方法。

示例

  // 抽象类 人,提供战斗接口
  abstract class Human {
    name: string;
    constructor(name: string) {
      this.name = name;
    }
    abstract fight(): void;
  }

  // 士兵类,战斗操作是自己加入战斗
  class Soldier extends Human {
    fight() {
      console.log(`${this.name} 准备加入战斗`);
    }
  }

  // 指挥官类,战斗操作是递归召集自己的下属,集合部队
  class Commander extends Human {
    soldiers: Set<Soldier>;
    constructor(name: string) {
      super(name);
      this.soldiers = new Set<Soldier>();
    }
    add(soldier: Soldier) {
      this.soldiers.add(soldier);
    }
    remove(soldier: Soldier) {
      this.soldiers.delete(soldier);
    }
    fight() {
      console.log(`${this.name} 开始召集属下`);
      this.soldiers.forEach(soldier => soldier.fight());
      console.log(`${this.name} 部队集结完毕`);
    }
  }

  // 在使用组合模式时,所有对象都有'fight'方法,因此不需要关心对象是士兵还是指挥官,即不需要关心是单个对象还是组合对象
  function battleDemo() {
    const soldier1 = new Soldier('soldier1');
    const soldier2 = new Soldier('soldier2');
    const soldier3 = new Soldier('soldier3');
    const soldier4 = new Soldier('soldier4');

    const subCommander1 = new Commander('subCommander1');
    subCommander1.add(soldier1);
    subCommander1.add(soldier2);

    const subCommander2 = new Commander('subCommander2');
    subCommander2.add(soldier3);
    subCommander2.add(soldier4);

    const chiefCommander = new Commander('chiefCommander');
    chiefCommander.add(subCommander1);
    chiefCommander.add(subCommander2);

    chiefCommander.fight();
  }
  battleDemo();

适用场景

  • 想表示对象的部分-整体层次结构;
  • 希望用户忽略组合对象与单个对象的不同,用户将统一地使用组合结构中的所有对象;

优点

  • 定义了包含基本对象和组合对象的类层次结构。基本对象可以被组合成更复杂的组合对象,而这个组合对象又可以被组合,不断递归下去。客户代码中,任何用到基本对象的地方都可以使用组合对象;
  • 简化客户代码。客户不需要关心处理的是一个叶节点还是枝节点;
  • 更容易增加新类型的组件;

缺点

  • 在增加新构件时很难对容器中的构件类型进行限制。有时候我们希望一个容器中只能有某些特定类型的对象,例如在某个文件夹中只能包含文本文件,使用组合模式时,不能依赖类型系统来施加这些约束,因为它们都来自于相同的抽象层,在这种情况下,必须通过在运行时进行类型检查来实现,这个实现过程较为复杂。

相关模式

  • 部件-父部件连接用于职责链模式。
  • 装饰器模式经常与组合模式一起使用。当装饰和组合一起使用时,他们通常有一个公共的父类。
  • 享元模式让你共享组件,但不能再引用他们的父部件。
  • 迭代器可用来遍历组合。
  • 访问者将本来应该分布在枝类和叶子类中的操作和行为局部化。

装饰(Decorator)

意图

动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator模式相比生成子类更为灵活。

结构

装饰模式包含以下角色:

  • Component(抽象构件):它是具体构件和抽象装饰类的共同父类,声明了在具体构件中实现的业务方法,它的引入可以使客户端以一致的方式处理未被装饰的对象以及装饰之后的对象,实现客户端的透明操作。
  • ConcreteComponent(具体构件):它是抽象构件类的子类,用于定义具体的构件对象,实现了在抽象构件中声明的方法,装饰器可以给它增加额外的职责(方法)。
  • Decorator(抽象装饰类):它也是抽象构件类的子类,用于给具体构件增加职责,但是具体职责在其子类中实现。它维护一个指向抽象构件对象的引用,通过该引用可以调用装饰之前构件对象的方法,并通过其子类扩展该方法,以达到装饰的目的。
  • ConcreteDecorator(具体装饰类):它是抽象装饰类的子类,负责向构件添加新的职责。每一个具体装饰类都定义了一些新的行为,它可以调用在抽象装饰类中定义的方法,并可以增加新的方法用以扩充对象的行为。

示例

  // 抽象构件——可视化组件
  class VisualComponent {
    draw(): void {
      console.log('绘制一个组件');
    }
  }

  // 装饰器基类,装饰可视化组件
  class Decorator extends VisualComponent {
    protected component: VisualComponent;
    constructor(component: VisualComponent) {
      super();
      this.component = component;
    }

    draw(): void {
      this.component.draw();
    }
  }

  // 带边框的装饰器
  class BorderDecorator extends Decorator {
    protected width: number;
    constructor(component: VisualComponent, borderWidth: number) {
      super(component);
      this.width = borderWidth;
    }
    private drawBorder(): void {
      console.log(`绘制宽度为${this.width}的边框`);
    }
    draw() {
      this.drawBorder();
      this.component.draw();
    }
  }

  // 带滚动条的装饰器
  class ScrollDecorator extends Decorator {
    private drawScrollBar(): void {
      console.log('绘制滚动栏');
    }
    draw(): void {
      this.drawScrollBar();
      this.component.draw();
    }
  }

  // 绘制一个带滚动条和边框的组件
  function decoratorDemo() {
    const component = new VisualComponent();
    const finalComponent = new BorderDecorator(new ScrollDecorator(component), 1);
    finalComponent.draw();
  }
  decoratorDemo();

适用场景

  • 在不影响其他对象的情况下,以动态、透明的方式给单个对象添加职责;
  • 处理那些可以撤销的职责;
  • 当不能采用生成子类的方法进行扩充时。一般情况是,可能有大量独立的扩展,为支持每一种组合将产生大量的子类,使得子类的书目呈爆炸性增长。另一种情况可能是因为类定义被隐藏,或类定义不能用于生成子类;

优点

  • 对于扩展一个对象的功能,装饰模式比继承更加灵活性,不会导致类的个数急剧增加;
  • 可以通过一种动态的方式来扩展一个对象的功能;
  • 可以对一个对象进行多次装饰,通过使用不同的具体装饰类以及这些装饰类的排列组合,可以创造出很多不同行为的组合,得到功能更为强大的对象;
  • 具体构件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体构件类和具体装饰类,原有类库代码无须改变,符合“开闭原则”;

缺点

  • 装饰模式提供了一种比继承更加灵活机动的解决方案,但同时也意味着比继承更加易于出错,排错也很困难,对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为繁琐。
  • 使用装饰时不应该依赖对象标识,typeof不能指向Component类

相关模式

  • 适配器模式:装饰器模式不同于适配器模式,因为装饰仅改变对象的职责而不改变他的接口。而适配器将给对象一个全新的接口。
  • 组合模式:可以将装饰器视为一个退化的,仅有一个组件的组合。然而,装饰仅给对象添加一些额外的职责——他的目的不在于对象聚集。
  • 策略模式:装饰器你可以改变对象的外表,而策略模式使得你可以改变对象的内核,这是改变对象的两种途径。装饰器是由外而内,策略是由内而外。当Component类很庞大时,使用装饰器代价太高,策略模式相对更好一些。在策略模式中,组件将他的一些行为转发给一个独立的策略对象,我们可以替换策略对象,从而改变或扩充组件的功能。装饰器模式,Component不需要知道外部的装饰,而策略模式Component需要知道进行了哪些扩充。

参考文档

本文介绍了前4种结构型模式,对后续模式感兴趣的同学可以关注专栏或者发送简历至'chaofeng.lcf####alibaba-inc.com'.replace('####', '@'),欢迎有志之士加入~

原文地址:github.com/ProtoTeam/b…