Prototype

December 26, 2021

如果你的物件狀態是可變動(mutable), 若想將之作為引數傳遞,有時會顧慮到一個問題,你不希望該物件被函式改變,解決的方式之一,就是對物件進行複製,用複製品作為引數,函式要真改變了引數的狀態,對來源物件也不會有影響。

將一個物件作為來源進行複製,基本上是沒什麼大不了的問題,就取得來源物件的重要狀態,建立一個新物件啊!問題就在於「建立」這個動作誰來做,有時會是個考量點!

王氏磚

例如,來玩玩〈王氏磚〉,從文件中可以看到,基本的 2-edge 王氏磚會有 16 種類型,拼接時只是依照網格資料,建立對應類型的王氏磚並設定至相應的位置。

因為使用的王氏磚只有那 16 種類型,你可能會想建立一組王氏磚,設定好外觀後作為原型,這麼一來需要某塊磚時,只要複製原型的外觀資訊,並修改相應位置資訊就可以了。

class Tile01 {
    private int x;
    private int y;

	Tile01(一些建構資訊) {}
    
    void pos(int x, int y) {
        this.x = x;
        this.y = y;
    }
    ...
}

class Tile02 {
    private int x;
    private int y;

	Tile02(一些建構資訊) {}

    void pos(int x, int y) {
        this.x = x;
        this.y = y;
    }
    ...
}

class WangTiles {
	Object[] prototypes = new Object[16];
	
    // 註冊原型
	void tile(int type, Object tile) {
		prototypes[type] = tile;
	}
	
	void generate() {
        ...隨機產生 grids 代表王氏磚網格資訊
		
		for(Grid grid : grids) {
            // 取得對應原型
			var prototype = prototypes[grid.type];

			switch(grid.type) {
			case 0:
				var tile01 = (Tile01) prototype;
				var tile = new Tile01(從tile01取得建構資訊);
                tile.pos(grid.x, grid.y);
                ...繪圖或其他動作
				break;
			case 1:
				var tile02 = (Tile02) prototype;
				var tile = new Tile02(從tile02取得建構資訊);
                tile.pos(grid.x, grid.y);
                ...繪圖或其他動作
				break;
			case 2:
			...其他類型王氏磚的原型處理到 case 15...
			}
		}
	}
}

這樣的設計基本上也不是不行,不過呢!其實不只有 2-edge 王氏磚,如果你想設計一個更通用的 WangTiles,那麼以上逐一比對類型資訊、採用對應的建構的方式,顯然行不通;就算你只想針對 2-edge 王氏磚,萬一你設計其他風格的拼接塊,有不同的建構式,WangTiles 勢必得做出修改。

設計的原則

就方才的描述而言,需求變更,程式碼就得做出修改,顯然不符合開放關閉原則;另一方面,WangTiles 負責了不屬於它的職責,畢竟最知道該怎麼複製物件的,應該是物件本身,由 WangTiles 來處理物件複製,不符合單一職責(single responsibility)原則。

既然拼接塊本身要負責如何複製自身,那麼拼接塊的行為介面上,應該要規範複製的行為,例如,定義一個 Tile 類別,有個 copy 方法?

abstract class Tile {
    private int x;
    private int y;
    void pos(int x, int y) {
    	this.x = x;
    	this.y = y;
    }
    
    abstract Tile copy();
}

因為拼接塊的行為之一,是要能設定位置,以上的類別也就一定規範了,你可能會想到,Java 本身的話,Object 就定義了每個物件都會有個 clone 行為,是沒錯!如果你本身對 Java 的 clone 有足夠的認識,要直接利用也是可以的,只是這邊先假設 Java 沒這機制,讓範例單純一些,畢竟使用 Java 的 clone 機制,有其必須遵守的規範(可參考 API 文件)。

總之,有了以上的 Tile,來重構一下範例:

class Tile01 extends Tile {
	Tile01(一些建構資訊) {}
	
	@Override
	Tile copy() {
		return new Tile01(從自身取得建構資訊);
	}
}

class Tile02 {
	Tile02(一些建構資訊) {}
	
	Tile copy() {
		return new Tile02(從自身取得建構資訊);
	}
}

class WangTiles {
	Tile[] prototypes = new Tile[16];
	
	void tile(int type, Tile tile) {
		prototypes[type] = tile;
	}
	
	void generate() {
        ...隨機產生 grids 代表王氏磚網格資訊

		for(Grid grid : grids) {
			var tile = prototypes[grid.type].copy();
			tile.pos(grid.x, grid.y);
			...繪圖或其他動作
		}
	}
}

這樣感覺好多了呢!而且,在更複雜的情境下,例如作為原型的物件,可能內含其他物件,這些物件在複製時,要不要參與複製?必須淺層或深層複製?這些問題只有物件本身自己清楚,如果各物件各自實現了自己的 copy,那麼需要複製時就只要呼叫各物件的 copy,也能實現關切分離的概念。

JavaScript 原型鏈

像方才的範例,是用來處理一組原型的複製問題,Gof 稱這個模式為 Prototype 模式。

有的語言本身會有些支援物件複製的機制,例如 Java 的 clone、Python 的 copy 模組,考量使用這類特性時,會讓 Prototype 模式在實作上略有不同。

談到 Prototype,JavaScript 不是有原型鏈嗎?跟 Prototype 模式有什麼關係嗎?嗯…只是剛好都叫 prototype 啦!不過,如果配合 Object.create,加上 JavaScript 是動態定型,又支援物件個體化,實作 Prototype 模式有時是會簡單一些:

class WangTiles {
    constructor() {
        this.prototypes = [];
    }
	tile(type, tile) {
		prototypes[type] = tile;
	}
	
	generate() {
		const grids = [];
        grids.forEach(grid => {
            const tile = Object.create(prototypes[grid.type])
			tile.x = grid.x;
            tile.y = grid.y;
			...繪圖或其他動作
        });
	}
}

Object.create 會建立新物件,並以指物件被建立的新物件之 prototype 特性,如果你存取的特性不在傳回的新物件上,就會去 prototype 上找,如果存取 xy 特性,就會使用新物件的 xy 特性。

這個例子中,並不是將複製行為定義在物件上,而是透過 Object.create,要不要這麼用還是看需求,這只是用來說明語言特性如何實現 Prototype 模式的一個例子,模式就只是一個思考的方向,別死死板板地套用,也不是一定就得怎麼用,或者實作後應該長什麼樣!