深入淺出設計模式(Design Pattern)-狀態模式(12-State Pattern)

Ben
19 min readApr 3, 2024

--

photo by Chris Lawton

這篇要來幫大家介紹將狀態封裝(行為封裝)起來並且靈活運用的模式,狀態模式(State Pattern)。

狀態模式(State Pattern)

定義:可讓物件在內部狀態改變時改變其行為。讓物件彷彿變成另一個類別。藉由封裝狀態物件的方式,在context中改變狀態物件就可以改變他的行為。

我們這次的情境是必須做一個糖果機,他的運作方式如下圖,而且在以後我們會加入更多的行為,所以我們要盡量維持設計的彈性和可維護性。

運作方式示意圖

首先我們先來了解一下我們需要的東西:

  1. 找出我們的狀態,總共有4個(有25美分、沒有25美分、糖果售完、售出糖果)。
  2. 建立變數來存放目前的狀態(SOLD_OUT = 0、NO_QUARTER = 1、HAS_QUARTER = 2、SOLD = 3)。
  3. 了解整個系統會有的操作行為(投入25美分、退回25美分、轉動旋鈕、投放)。

接下來我們試著實作它整個糖果機,他的結構大概會是這樣

class GumballMachine {
// 糖果機的所有狀態
SOLD_OUT: number = 0;
NO_QUARTER: number = 1;
HAS_QUARTER: number = 2;
SOLD: number = 3;

// 目前的狀態
state: number = SOLD_OUT;

// 糖果的數量
count: number = 0;

// 建構式會初始化機器擁有的糖果數量
constructor(count: number) {
this.count = count;
if (this.count > 0) {
this.state = this.NO_QUARTER;
}
}

// 投入25美分
public insertQuarter(): void {
if (state === this.HAS_QUARTER) {
// do something...
} else if (state === this.NO_QUARTER) {
// do something...
} else if (state === this.SOLD_OUT) {
// do something...
} else if (state === this.SOLD) {
// do something...
}
}

// 退還25美分
public ejectQuarter(): void {
if (state === this.HAS_QUARTER) {
// do something...
} else if (state === this.NO_QUARTER) {
// do something...
} else if (state === this.SOLD_OUT) {
// do something...
} else if (state === this.SOLD) {
// do something...
}
}

// 轉動旋鈕
public turnCrank(): void {
if (state === this.HAS_QUARTER) {
// do something...
} else if (state === this.NO_QUARTER) {
// do something...
} else if (state === this.SOLD_OUT) {
// do something...
} else if (state === this.SOLD) {
// do something...
}
}

// 處理轉動旋鈕後的動作
public dispense(): void {
if (state === this.HAS_QUARTER) {
// do something...
} else if (state === this.NO_QUARTER) {
// do something...
} else if (state === this.SOLD_OUT) {
// do something...
} else if (state === this.SOLD) {
// do something...
}
}
}

我們完成了我們的糖果機!!但是還記得嗎,軟體開發唯一不變的就是改變,該來的還是會來,現在我們碰到了新的需求,而我們這樣的設計方式是好的嗎?

混亂的狀態

我們雖然精心設計了這個糖果機,但這並不代表它很容易擴展。我們新的需求是要加入一個WINNER的狀態,讓整個糖果機更遊戲化,來看看如果是基於現有的設計我們要怎麼做。

class GumballMachine {
// 我們必須在這邊加入新的WINNER狀態
SOLD_OUT: number = 0;
NO_QUARTER: number = 1;
HAS_QUARTER: number = 2;
SOLD: number = 3;

// 而為了處理WINNER狀態,我們必須在每個地方加入新的條件式,這意味著必須修改很多地方
public insertQuarter(): void {}
public ejectQuarter(): void {}

// turnCrank將會特別混亂,因為我們必須檢查有沒有中獎,然後切換到WINNER或SOLD狀態
public turnCrank(): void {}
public dispense(): void {}
}

新的設計

在了解我們既有的設計對於擴展上會有非常多的隱憂之後,我們必須著手新的設計,也就是將狀態個別封裝成一個類別,在每一個動作發生的時候,將那一個動作委託給那個狀態的物件。

  1. 首先我們會定義一個State介面,裡面有糖果機器的每一個動作的方法。
  2. 接下來,為機器的每一個狀態實作一個State類別。當機器處於對應的狀態時,讓那些類別處理機器的行為。
  3. 最後,移除所有的條件程式,改成將動作委託給State類別。
新設計類別示意圖

接著我們來看看我們實作的內容,首先是State介面

// 所有的狀態參考指向的介面,每個狀態都必須實作
interface GumballMachineState {
insertQuarter(): void;
ejectQuarter(): void;
turnCrank(): void;
dispense(): void;
refill(): void;
}

再來是我們的糖果機器

// 糖果機接受一個糖果數量的建構式參數
class GumballMachine {
count: number = 0;
state: GumballMachineState;

hasQuarterState: HasQuarterState;
noQuarterState: NoQuarterState;
soldOutState: SoldOutState;
soldState: SoldState;
winnerState: WinnerState;

constructor(numberOfGumballs: number) {
// 建構式中初始化各種狀態的類別,並把機器類別(this)傳給各個狀態初始化
this.hasQuarterState = new HasQuarterState(this);
this.noQuarterState = new NoQuarterState(this);
this.soldOutState = new SoldOutState(this);
this.soldState = new SoldState(this);
this.winnerState = new WinnerState(this);

this.count = numberOfGumballs;
if (this.count > 0) {
this.state = this.noQuarterState;
} else {
this.state = this.soldOutState;
}
}

// 用來設定機器目前的狀態
public setState(state: GumballMachineState) {
this.state = state;
}

public getMachineState() {
console.log(`Inventory: ${this.count}`);
if (this.count > 0) {
console.log("Machine is waiting for quarter");
} else {
console.log("Machine is sold out");
}
}

public releaseBall() {
console.log("A gumball comes rolling out the slot...");
if (this.count > 0) {
this.count -= 1;
}
}

// 機器將會把目前觸發的行為委託給目前的狀態
public insertQuarter() {
this.state.insertQuarter();
}

public ejectQuarter() {
this.state.ejectQuarter();
}

public turnCrank() {
this.state.turnCrank();
this.state.dispense();
}

// 每個狀態類別建構式中接受了機器的類別,
// 因此可以調用機器的方法來取得機器擁有的所有狀態類別
public getHasQuarterState(): GumballMachineState {
return this.hasQuarterState;
}

public getNoQuarterState(): GumballMachineState {
return this.noQuarterState;
}

public getSoldOutState(): GumballMachineState {
return this.soldOutState;
}

public getSoldState(): GumballMachineState {
return this.soldState;
}

public getWinnerState(): GumballMachineState {
return this.winnerState;
}

public refill(count: number) {
this.count += count;
console.log(`The gumball machine was just refilled; its count is: ${this.count}`);
this.state.refill();
}
}

接著實作HasQuarter狀態

// 實作HasQuarter狀態
class HasQuarterState implements GumballMachineState {
randomWinner: number = Math.random();
gumballMachine: GumballMachine;

constructor(gumballMachine: GumballMachine) {
this.gumballMachine = gumballMachine;
}

public insertQuarter() {
console.log("You Can't insert another quarter");
}

public ejectQuarter() {
console.log("Quarter returned");
this.gumballMachine.setState(this.gumballMachine.getNoQuarterState())
}

public turnCrank() {
console.log("You turned...");
if (this.randomWinner > 0.4 && this.gumballMachine.count > 1) {
this.gumballMachine.setState(this.gumballMachine.getWinnerState());
} else {
this.gumballMachine.setState(this.gumballMachine.getSoldState());
}
}

public dispense() {
console.log("No gumball dispensed");
}

}

實作NoQuarter狀態

// 實作NoQuarter狀態
class NoQuarterState implements GumballMachineState {
gumballMachine: GumballMachine;

constructor(gumballMachine: GumballMachine) {
this.gumballMachine = gumballMachine;
}

public insertQuarter() {
console.log("You insert a quarter");
this.gumballMachine.setState(this.gumballMachine.getHasQuarterState());
}

public ejectQuarter() {
console.log("You haven't inserted a quarter");
}

public turnCrank() {
console.log("You turned, but there's are no quarter");
}

public dispense() {
console.log("You need to pay first");
}

public refill() {}
}

實作SoldOut狀態

// 實作SoldOut狀態
class SoldOutState implements GumballMachineState {
gumballMachine: GumballMachine;

constructor(gumballMachine: GumballMachine) {
this.gumballMachine = gumballMachine;
}

public insertQuarter() {
console.log("You Can't insert a quarter, the machine is sold out");
}

public ejectQuarter() {
console.log("You can't eject, you haven't inserted a quarter yet");
}

public turnCrank() {
console.log("You turned, but there are no gumballs");
}

public dispense() {
console.log("No gumball dispense");
}

public refill() {
this.gumballMachine.setState(this.gumballMachine.getNoQuarterState());
}
}

實作Sold狀態

// 實作Sold狀態
class SoldState implements GumballMachineState {
gumballMachine: GumballMachine;

constructor(gumballMachine: GumballMachine) {
this.gumballMachine = gumballMachine;
}

public insertQuarter() {
console.log("Please wait, we're already giving you a gumball");
}

public ejectQuarter() {
console.log("Sorry, you already turned the crank");
}

public turnCrank() {
console.log("Turning twice doesn't get you another gumball!");
}

public dispense() {
this.gumballMachine.releaseBall();
if (this.gumballMachine.count === 0) {
console.log("Oops, out of gumballs!");
this.gumballMachine.setState(this.gumballMachine.getSoldOutState());
} else {
this.gumballMachine.setState(this.gumballMachine.getNoQuarterState());
}
}

public refill() {}
}

實作Winner狀態

// 實作Winner狀態
class WinnerState implements GumballMachineState {
gumballMachine: GumballMachine;

constructor(gumballMachine: GumballMachine) {
this.gumballMachine = gumballMachine;
}

public insertQuarter() {
console.log("Please wait, we're already giving you a gumball");
}

public ejectQuarter() {
console.log("Sorry, you already turned the crank");
}

public turnCrank() {
console.log("Turning twice doesn't get you another gumball!");
}

public dispense() {
this.gumballMachine.releaseBall();
if (this.gumballMachine.count === 0) {
this.gumballMachine.setState(this.gumballMachine.getSoldOutState());
} else {
this.gumballMachine.releaseBall();
console.log("YOU'RE A WINNER! You got two gumballs for your quarter");
console.log("\n");

if (this.gumballMachine.count > 0) {
this.gumballMachine.setState(this.gumballMachine.getNoQuarterState());
} else {
console.log("Oops, out of gumballs!")
this.gumballMachine.setState(this.gumballMachine.getSoldOutState());
}
}
}

public refill() {}
}

接著來我們來說明它是如何達成這神奇運作方式的,我們可以在上方機器的類別的建構式中看到裡面初始化了每一個狀態類別,並且將機器類別本身傳給每一個狀態,這個動作讓我們可以在每一個狀態之間靈活的切換,同時也讓每一個狀態都是獨立的。

而機器只負責管理每一個狀態,並在觸發相應行為的時候將那個行為委託給該狀態去執行。在這樣的設計之下我們就能夠將機器和狀態之間的耦合解開,並且擁有擴充的彈性以及更好的維護性。

接著來測試我們的機器

// 測試類別
class GumballMachineTestDrive {
gumballMachine: GumballMachine;

constructor() {
// 初始化糖果數量
this.gumballMachine = new GumballMachine(5);

// 取得目前機器狀態
this.gumballMachine.getMachineState();

// 投入25美分
this.gumballMachine.insertQuarter();

// 轉動按鈕
this.gumballMachine.turnCrank();

// 取得狀態
this.gumballMachine.getMachineState();

// 更多操作...
this.gumballMachine.insertQuarter();
this.gumballMachine.turnCrank();
this.gumballMachine.insertQuarter();
this.gumballMachine.turnCrank();

this.gumballMachine.getMachineState();
}
}

const gumballMachineTestDrive = new GumballMachineTestDrive();

這樣我們就完成了我們整個的糖果機了,這次的範例內容就是將以上新設計的內容放在一起。

重點提示

  • 狀態模式可以讓物件根據他得內部狀態改變許多行為。
  • 狀態模式與程序狀態不一樣,他用完整的類別來表示每一個狀態。
  • 與狀態物件組合在一起的Context藉著委託給目前的狀態物件來取得他的行為。
  • 藉著將每一個狀態封裝在類別裡面,我們可以將以後的任何改變區域化。
  • 狀態模式與策略模式的類別圖相同,但是他們的目的不同。
  • 策略模式通常指定Context類別該表現出哪種行為或使用哪種演算法。
  • 狀態模式可讓Context隨著狀態的改變而改變行為。
  • 狀態的轉換可以用State類別或Context類別來控制。
  • 使用狀態模式通常會讓你的設計增加許多類別。
  • 許多不同的Context實例可以共用一組State類別。

結語

記得剛接觸到狀態模式的思維的時候真的是哇哇哇的充滿驚訝和讚嘆,自己從來沒有想過可以這樣去思考和設計(組合和封裝的搭配),在狀態模式的設計之下提供的靈活性、彈性、可擴充性、可維護性都是令我印象深刻的,也讓自己再次了解到物件導向思維的設計帶來的各種可能性。

下一篇要介紹:代理模式(Proxy Pattern)

這篇就到這邊瞜,如果有任何問題歡迎留言給我,喜歡的話也別忘了clap起來唷。

--

--