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) {
        ...
    }
}

然後,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 作為 FatalStableRevivable 的父類別,也可以實現以上的概念。

瀑布化的流程?

要不要這麼做,其實還是取決於需求,歸結後只有三個規則的生命遊戲,這麼做是稍微小題大作了;然而,若規則數量多而且對應的狀態轉移複雜(或者甚至日後有可能增長),最後你發現瀑布化的流程難以處理時,可以考慮 State 模式的實現方式。

在要求不可變動(immutable)的場合,若要朝著 State 模式的方向實現,物件狀態無法改變下,會是生成新物件來封裝新狀態,例如〈[圖靈隨想](/zh-tw/computation/automata/〉的 Brainfuck 等機器,基本上就是狀態機,它們實現了 State 模式的概念,並採取了不可變動的特性。

現代一些前端程式庫或框架,為了處理前端複雜的規則與狀態,也常隱含了 State 模式的實現,而為了便於管理狀態,也常採用不可變動特性。

有時識別這個狀態不容易,就生命遊戲來說,提取規則用的狀態,其實是鄰居數量,而不是細胞本身的狀態,套用規則時改變了細胞狀態,然而最後其實也改變了整個盤面,也就是細胞鄰居數也被改變了,下一代就是根據新的鄰居數作為狀態,來取得對應的規則。

在實際的應用程式設計時,若規則與狀態的對應,因需求(增加)而(逐漸)構成瀑布化的流程,有時用來提取規則的狀態,可能與型態是對應的,而狀態轉移可能是某個流程,例如,你可能用了一串 instanceof 判斷物件的型態,然後做出對應的動作,在物件導向程式語言的入門文件中,應該會解釋這麼做不明智,或許你該考慮次型態多型(subtype polymorphism),將各個動作定義在子類別。

有時用來提取規則的狀態,不是那麼明顯,例如生命遊戲的鄰居數為 0、1、4、5、6、7、8 乍看是一組規則,其實鄰居數為 0、1、4、5、6、7、8 的狀態時,都用來提取同一個規則;有時提取規則用的狀態,會是一組條件的組合,像是投資組合、保險條款組合等,你可能使用了 &&|| 等邏輯運算,將數個條件組合出某個成立狀況,這時可能就是隱含著這些條件組合,構成提取某個規則的狀態。

因為有時提取規則用的狀態不是那麼明顯,開發者就常採用直覺而簡單的做法,也就是不斷地增加新的 switchif/else,然後流程變成小瀑布,久而久之就構成了大瀑布…你也許也親眼見過那壯闊的景像吧!

State 模式這時可能是解決瀑布化流程的一種思考方向,如果你不是要改變狀態,而是要執行某個動作,用物件封裝動作,以某值對應物件,這時採用字典之類的結構可以建立對應表,可能也是個解決的方式。例如:

actions.get(caseValue).execute(context); // actions 是個 `Map`

這時該怎麼封裝動作?案例值如何對應至動作?就是你要思考的…這其實也是 State 模式想表達的,面對瀑布化的流程,你應該想到的是…可以這樣繼續下去嗎?