設計模式-策略模式

astonishqft發表於2020-09-09

前言

作為一名合格的前端開發工程師,全面的掌握物件導向的設計思想非常重要,而“設計模式”是眾多軟體開發人員經過相當長的一段時間的試驗和錯誤總結出來的,代表了物件導向設計思想的最佳實踐。正如《HeadFirst設計模式》中說的一句話,非常好:

知道抽象、繼承、多型這些概念,並不會馬上讓你變成好的物件導向設計者。設計大師關心的是建立彈性的設計,可以維護,可以應付改變。

是的,很多時候我們會覺得自己已經清楚的掌握了物件導向的基本概念,封裝、繼承、多型都能熟練使用,但是系統一旦複雜了,就無法設計一個可維護、彈性的系統。本文將結合 《HeadFirst設計模式》書中的示例加上自己一丟丟的個人理解,帶大家認識下設計模式中的第一個模式——策略模式。

策略模式

策略模式:Strategy,是指,定義一組演算法,並把其封裝到一個物件中。然後在執行時,可以靈活的使用其中的一個演算法

模擬鴨子游戲

設計模擬鴨子游戲

一個遊戲公司開發了一款模擬鴨子的遊戲,所有的鴨子都會呱呱叫(quack)、游泳(swim) 和 顯示(dislay) 方法。

基於物件導向的設計思想,想到的是設計一個 Duck 基類,然後讓所有的鴨子都整合此基類。

class Duck {
  quack() {

  }
  swim() {

  }
  display() {

  }
}

綠頭鴨(MallardDuck)和紅頭鴨(RedheadDuck)分別繼承 Duck 類:

class MallardDuck extends Duck {
  quack() {
    console.log('gua gua');
  }
  display() {
    console.log('I am MallardDuck');
  }
}

class RedheadDuck extends Duck {
  display() {
    console.log('I am ReadheadDuck');
  }
  quack() {
    console.log('gua gua');
  }
}

讓所有的鴨子會飛

現在對所有鴨子提出了新的需求,要求所有鴨子都會飛。

設計者立馬想到的是給 Duck 類新增 fly 方法,這樣所有的鴨子都具備了飛行的能力。

class Duck {
  quack() {

  }
  fly() {
    
  }
  swim() {

  }
  display() {

  }
}

但是這個時候程式碼經過測試發現了一個問題,系統中新加的橡皮鴨(RubberDuck)也具備了飛行的能力了。這顯然是不科學的,橡皮鴨不會飛,而且也不會叫,只會發出“吱吱”聲。

於是,設計者立馬想到了覆寫 RubberDuck 類的 duckfly 方法,其中 fly 方法裡面什麼也不做。

class RubberDuck extends Duck {
  quack() {
    console.log('zhi zhi');
  }
  fly() {

  }
}

繼承可能並不是最優解

設計者仔細思考了上述設計,提出了一個問題:如果後續新增了更多型別的鴨子,有的鴨子既不會飛又不會叫怎麼辦呢?難道還是繼續覆寫 fly 或者 quack 方法嗎?

顯然,整合不是最優解。

經過一番思索,設計者想到了透過介面來最佳化設計。

設計兩個介面,分別是 FlableQuackable 介面。

interface Flyable {
  fly(): void;
}

interface Quackable {
  quack(): void;
}

這樣,只有實現了 Flyable 的鴨子才能飛行,實現了 Quackable 的鴨子才能說話。

class MallardDuck implements Flayable, Quackable {
  fly() {

  }
  quack() {

  }
}

透過介面雖然可以限制鴨子的行為,但是每個鴨子都要檢查一下是否需要實現對應的介面,鴨子型別多起來之後是非常容易出錯的,同時,透過介面的方式雖然限制了鴨子的行為,但是程式碼量卻沒有減少,每個鴨子內部都要重複實現fly和quack的程式碼邏輯。

分開變化和不變化的部分

下面開始介紹我們的第一個設計原則:

找出應用中可能需要變化之處,把它們獨立出來,不要和那些不需要變化的程式碼混在一起。

Duck 類中,quackfly 是會隨著鴨子的不同而改變的,而 swim 和 display 是每個鴨子都不變的。因此,這裡可以運用第一個設計原則,就是分開變化和不變化的部分。

面向介面程式設計,而不是針對實現程式設計

為了更好的設計我們的程式碼,現在介紹第二個設計原則:

針對介面程式設計,而不是針對實現程式設計。

針對介面程式設計的真正含義是針對超型別程式設計(抽象類或者介面),它利用了多型的特性。

在針對介面的程式設計中,一個變數宣告的型別應該是一個超型別,超型別強調的是它與它的所有派生類共有的“特性”。

針對實現程式設計。

比如:

interface Animal {
  void makeSound();
}

class Dog implements Animal {
  public void makeSound() {
    bark();
  }
  
  public void bark() {
    // 汪汪叫
  }
}

Dog d = new Dog();
d.bark();

因為 d 的型別是 Dog,是一個具體的類,而不是抽象類,並且 bark 方法是 Dog 上特有的,不是共性。

針對介面程式設計。

Animal a = new Dog();
a.makeSound();

變數 a 的型別是 Animal,是一個抽象型別,而不是一個具體型別。此時 a 呼叫 makeSound 方法,代表的是所有的 Animal 都能進行的一種操作。

現在我們接著之前的思路,將鴨子的 flyquack 兩個行為變為兩個介面 FlyBehaviorQuackBehavior。所有的鴨子不直接實現這兩個介面,而是有專門的行為類實現這兩個介面。

interface FlyBehavior {
  fly(): void;
}

interface QuackBehavior {
  quack(): void;
}

行為類來實現介面:

// 實現了所有可以飛行的鴨子的動作
class FlyWithWings implements FlyBehavior {
  fly(): void {
    console.log('I can fly with my wings !');
  }
}
// 實現了所有不會飛行的鴨子的動作
class FlyNoWay implements FlyBehavior {
  fly(): void {
    console.log('I can not fly !');
  }
}
// 實現了所有坐火箭飛行的鴨子的動作
class FlyRocketPowered implements FlyBehavior {
  fly(): void {
    console.log('I can fly with a rocket !');
  }
}
// 實現了橡皮鴨的吱吱叫聲
class Squeak implements QuackBehavior {
  quack(): void {
    console.log('zhi zhi !');
  }
}
// 實現了啞巴鴨的叫聲
class MuteQuack implements QuackBehavior {
  quack(): void {
    console.log();
  }
}

這樣做有個好處:

  1. 鴨子的行為可以被複用,因為這些行為已經與鴨子本身無關了。
  2. 我們可以新增一些行為,不會擔心影響到既有的行為類,也不會影響有使用到飛行行為的鴨子類。

整合鴨子的行為

現在鴨子的所有的行為需要被整合在一起,需要委託給別人處理。

繼續改造 Duck 類。

abstract class Duck {

  flyBehavior: FlyBehavior;
  quackBehavior: QuackBehavior;

  constructor(flyBehavior: FlyBehavior, quackBehavior: QuackBehavior) {
    this.flyBehavior = flyBehavior;
    this.quackBehavior = quackBehavior;
  }

  public performFly(): void {
    this.flyBehavior.fly();
  }

  public performQuack():void {
    this.quackBehavior.quack();
  }

  public setFlyBehavior(flyBehavior: FlyBehavior) {
    this.flyBehavior = flyBehavior;
  }
  
  public abstract display(): void;

  public swim() {
    console.log('all ducks can swim !');
  }
}

在鴨子類內部定義兩個變數,型別分別為 FlyBehaviorQuackBehavior 的介面型別,宣告為介面型別方便後續透過多型的方式設定鴨子的行為。移除鴨子類中的 flyquack 方法,因為這兩個方法已經被分離到 fly 行為類和 quack 行為類中了。

透過 performQuack 方法來呼叫鴨子的行為,setFlyBehavior 方法來動態修改鴨子的行為。

所有的鴨子整合 Duck 類:

// 綠頭鴨
class MallardDuck extends Duck {
  constructor() {
    super(new FlyWithWings(), new Quack());
  }

  display() {
    console.log('I am mallard duck !');
  }
}

// 模型鴨
class ModelDuck extends Duck {
  constructor() {
    super(new FlyNoWay(), new MuteQuack());
  }

  public display(): void {
    console.log('I am model duck !');
  }
}

在鴨子的建構函式中呼叫父類的建構函式,初始化鴨子的行為。

測試鴨子游戲

class Test {
  duck: Duck;

  constructor() {
    this.duck = new MallardDuck();
  }

  setPerformFly() {
    this.duck.setFlyBehavior(new FlyRocketPowered());
  }

  quack() {
    this.duck.performQuack();
  }

  fly() {
    this.duck.performFly();
  }
}

const test = new Test();

test.fly();
test.quack();

test.setPerformFly();

test.fly();

透過 setFlyBehavior 可以動態的改變鴨子的行為,是鴨子具備坐火箭飛行的能力。

多用組合,少用整合

從上面的例子就可以看出,每一個鴨子都有一個 FlyBehaviorQuackBehavior,讓鴨子(Duck 類)將飛行和呱呱叫委託它代為處理。

當你將兩個類結合起來使用,這就是組合(composition)。這種做法和繼承不同的地方在於,鴨子的行為不是繼承而來,而是和使用的行為物件組合而來的。也就是我們要介紹的第三個設計原則:

多用組合,少用繼承

策略模式的設計步驟

  1. 定義一個介面,介面中宣告各個演算法所共有的操作
interface Strategy {
  execute(): void;
} 
  1. 定義一系列的策略,並且在遵循 Strategy 介面的基礎上實現演算法
class StrategyA implements Strategy {
  execute() {
    console.log('I am StrategyA'); // 演算法 A
  }
}

class StrategyB implements Strategy {
  execute() {
    console.log('I am StrategyB'); // 演算法 B
  }
}
  1. 定義一個上下文 Context 類,在 Context 類中維護指向某個策略物件的引用,在建構函式中來接收策略類,同時還可以透過 setStrategy 在執行時動態的切換策略類,Context 類透過 setStrategy 來將具體的工作委派給策略物件。這裡的 Context 類也可以作為一個基類被具體的實現類所繼承,就相當於上文鴨子游戲中介紹的 Duck 類,可以被 MallardDuck、ReadheadDuck 等繼承。
class Context {
  private strategy: Strategy;

  constructor(stategy: Stategy) {
    this.stategy = Stategy;
  }

  executeStrategy() {
    this.stragegy.execute();
  }

  setStrategy(stategy: Stategy) {
    this.stategy = stategy;
  }
}
  1. 建立客戶端類,客戶端程式碼會根據條件來選擇具體的策略。
class Application {
  stragegy: Stragegy;
  constructor(stragegy: Stragegy) {
    this.stragegy = stragegy;
  }
  setCondition(condition) {
    if (condition === 'conditionA') {
      stragegy.setStrategy(new StrategyA());
    }
    if (condition === 'conditionB') {
      stragegy.setStrategy(new StrategyB());
    }
  }
  execute() {
    this.stragegy.execute();
  }
}

const app = new Application();

app.setCondition('conditionB');
app.execute();

策略模式結構圖:

策略模式的使用場景

當我們在設計程式碼的時候,想使用物件中各種不同的演算法變體,並希望能在執行時切換演算法時, 可以考慮使用策略模式。

參考

原始碼地址

  1. 針對介面程式設計,而不是針對實現程式設計
  2. 策略模式
  3. 《HeadFirst設計模式》

相關文章