State
January 5, 2022你有玩過生命遊戲嗎?沒玩過的話可以看一下〈康威生命遊戲〉,根據其中的描述,生命遊戲可簡化為三個規則:
- 某位置之細胞鄰居數為 0、1、4、5、6、7、8 時,該位置下次狀態必無細胞存活。
- 某位置之細胞鄰居數為 2 時,該位置下次狀態保持不變(有細胞就有細胞,無細胞就無細胞)。
- 某位置之細胞鄰居數為 3 時,該位置下次狀態必有細胞存活。
這三個規則可以命名為致命(Fatal)、穩定(Stable)、可復活(Revivable),在這邊使用列舉來代表:
enum Rule {
Fatal, Stable, Revivable;
private static Map<Integer, Rule> envs = Map.of(2, Stable, 3, Revivable);
public static Rule of(Cell[][] cells, Cell cell) {
// 計算鄰居數
int[][] dirs = {{-1, 0}, {-1, 1}, {0, 1}, {1, 1},
{1, 0}, {1, -1}, {0, -1}, {-1, -1}};
var count = 0;
for(var i = 0; i < 8 && count < 4; i++) {
var r = cell.i + dirs[i][0];
var c = cell.j + dirs[i][1];
if(r > -1 && r < cells.length && c > -1 && c <
cells[0].length && cells[r][c].isAlive != 0) {
count++;
}
}
return Rule.envs.getOrDefault(count, Fatal);
}
}
細胞會根據這三個規則,決定接下來是處於死亡或是存活狀態:
class Cell {
int i, j, isAlive; // 位置 i, j 與是否存活
Cell(int i, int j, int isAlive) {
this.i = i;
this.j = j;
this.isAlive = isAlive;
}
void nextGen(Cell[][] cells) {
switch(Rule.of(cells, this)) {
case Fatal:
this.isAlive = 0;
break;
case Revivable:
this.isAlive = 1;
break;
case Stable:
// nope
}
}
...
}
這沒什麼問題,生命遊戲最後歸結為三個規則,使用 switch
(或 if/else
)來判斷接下來要套用哪個規則,進行對應的狀態轉移,簡單而直覺;然而有時候在更複雜的遊戲裡,你可能會有更多的規則,並根據規則對角色進行對應的狀態轉移,若單純只用 switch
(或 if/else
)來處理,你可能會有…呃…很長很長…很長的判斷清單…
這或許還不打緊,如果日後你要變更規則對應的狀態轉移,你得在…很長很長…很長的清單裡進行維護與修改…XD
規則/狀態
可以考慮將狀態轉移的職責由代表規則的物件執行,例如,Java 的列舉可以有各自實作:
import java.util.function.Consumer;
enum Rule implements Consumer<Cell> {
Fatal {
@Override
public void accept(Cell cell) {
cell.isAlive = 0;
}
},
Stable {
@Override
public void accept(Cell cell) {
// nope
}
},
Revivable {
@Override
public void accept(Cell cell) {
cell.isAlive = 1;
}
};
private static Map<Integer, Rule> envs = Map.of(2, Stable, 3, Revivable);
public static Rule of(Cell[][] cells, Cell cell) {
...根據 cells 與 cell 來取得 Rule 實例
...詳細參考本文一開始的〈康威生命遊戲〉
}
}
然後,Cell
就不用管什麼哪個規則要做什麼樣的狀態轉移,只要呼叫 Rule
列舉實例的 accept
就可以了:
class Cell {
int i, j, isAlive;
Cell(int i, int j, int isAlive) {
this.i = i;
this.j = j;
this.isAlive = isAlive;
}
void nextGen(Cell[][] cells) {
Rule.of(cells, this).accept(this);
}
Cell copy() {
return new Cell(i, j, isAlive);
}
}
Gof 將這類實現稱為 State 模式,與其讓物件負責哪個規則要進行哪種狀態轉移,不如讓各個規則各自負責要進行的狀態轉移。
雖然這邊使用了 Java 的列舉,不過其實透過繼承關係,定義 Rule
作為 Fatal
、Stable
與 Revivable
的父類別,也可以實現以上的概念。
瀑布化的流程?
要不要這麼做,其實還是取決於需求,歸結後只有三個規則的生命遊戲,這麼做是稍微小題大作了;然而,若規則數量多而且對應的狀態轉移複雜(或者甚至日後有可能增長),最後你發現瀑布化的流程難以處理時,可以考慮 State 模式的實現方式。
在要求不可變動(immutable)的場合,若要朝著 State 模式的方向實現,物件狀態無法改變下,會是生成新物件來封裝新狀態,例如〈圖靈隨想〉的 Brainfuck 等機器,基本上就是狀態機,它們實現了 State 模式的概念,並採取了不可變動的特性。
現代一些前端程式庫或框架,為了處理前端複雜的規則與狀態,也常隱含了 State 模式的實現,而為了便於管理狀態,也常採用不可變動特性。
有時識別這個狀態不容易,就生命遊戲來說,提取規則用的狀態,其實是鄰居數量,而不是細胞本身的狀態,套用規則時改變了細胞狀態,然而最後其實也改變了整個盤面,也就是細胞鄰居數也被改變了,下一代就是根據新的鄰居數作為狀態,來取得對應的規則。
在實際的應用程式設計時,若規則與狀態的對應,因需求(增加)而(逐漸)構成瀑布化的流程,有時用來提取規則的狀態,可能與型態是對應的,而狀態轉移可能是某個流程,例如,你可能用了一串 instanceof
判斷物件的型態,然後做出對應的動作,在物件導向程式語言的入門文件中,應該會解釋這麼做不明智,或許你該考慮次型態多型(subtype polymorphism),將各個動作定義在子類別。
有時用來提取規則的狀態,不是那麼明顯,例如生命遊戲的鄰居數為 0、1、4、5、6、7、8 乍看是一組規則,其實鄰居數為 0、1、4、5、6、7、8 的狀態時,都用來提取同一個規則;有時提取規則用的狀態,會是一組條件的組合,像是投資組合、保險條款組合等,你可能使用了 &&
、||
等邏輯運算,將數個條件組合出某個成立狀況,這時可能就是隱含著這些條件組合,構成提取某個規則的狀態。
因為有時提取規則用的狀態不是那麼明顯,開發者就常採用直覺而簡單的做法,也就是不斷地增加新的 switch
、if/else
,然後流程變成小瀑布,久而久之就構成了大瀑布…你也許也親眼見過那壯闊的景像吧!
State 模式這時可能是解決瀑布化流程的一種思考方向,如果你不是要改變狀態,而是要執行某個動作,用物件封裝動作,以某值對應物件,這時採用字典之類的結構可以建立對應表,可能也是個解決的方式。例如:
actions.get(caseValue).execute(context); // actions 是個 `Map`
這時該怎麼封裝動作?案例值如何對應至動作?就是你要思考的…這其實也是 State 模式想表達的,面對瀑布化的流程,你應該想到的是…可以這樣繼續下去嗎?