Skip to main content

Ch5: 把相似的程式碼統合在一起

5.1 Unifying Similar classes

接續上一章,updateTile依然違反了許多規則,最顯著的就是"不要讓IF跟ELSE一起用"規則 引入isStony, isBoxy,可被理解為,像石頭一樣做動,像箱子一樣做動

Before
function updateTile(x: number, y: number) {
if ((map[y][x].isStone() || map[y][x].isFallingStone())
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingStone();
map[y][x] = new Air();
} else if ((map[y][x].isBox() || map[y][x].isFallingBox())
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingBox();
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x] = new Stone();
} else if (map[y][x].isFallingBox()) {
map[y][x] = new Box();
}
}
After
function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingStone();
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingBox();
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x] = new Stone();
} else if (map[y][x].isFallingBox()) {
map[y][x] = new Box();
}
}
interface Tile {
// ...
isStony(): boolean;
isBoxy(): boolean;
}
class Air implements Tile {
// ...
isStony() { return false; }
isBoxy() { return false; }
}

回傳一個常數的method我們叫constant method(常數方法)

我們可以合併兩個class是因為這兩個class共享了回傳不同常數的常數方法

Steps:

  1. 讓兩個class除了常數方法以外都相等

  2. 合併class (書上說很像分數加法,要先把分母變一樣再加起來)

先比較兩顆石頭類

Stone

class Stone implements Tile {
isAir() { return false; }
isFallingStone() { return false; }
isFallingBox() { return false; }
isLock1() { return false; }
isLock2() { return false; }
draw(g: CanvasRenderingContext2D,
x: number, y: number) {
// ...
}
moveVertical(dy: number) { }
isStony() { return true; }
isBoxy() { return false; }
moveHorizontal(dx: number) {
// ...
}
}
FallingStone
class FallingStone implements Tile {
isAir() { return false; }
isFallingStone() { return true; }
isFallingBox() { return false; }
isLock1() { return false; }
isLock2() { return false; }
draw(g: CanvasRenderingContext2D,
x: number, y: number) {
// ...
}
moveVertical(dy: number) { }
isStony() { return true; }
isBoxy() { return false; }
moveHorizontal(dx: number) {
}
}
class Stone implements Tile {
// ...
moveHorizontal(dx: number) {
if (map[playery][playerx + dx + dx].isAir()
&& !map[playery + 1][playerx + dx].isAir()) {
map[playery][playerx + dx + dx] = this;
moveToTile(playerx + dx, playery);
}
}
}
class FallingStone implements Tile {
// ...
moveHorizontal(dx: number) {
}
}
class Stone implements Tile {
// ...
isFallingStone() { return false; }
}
class FallingStone implements Tile {
// ...
isFallingStone() { return true; }
}
/// ...
new Stone();
/// ...

/// ...
new FallingStone(true);
/// ...

function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingStone();
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new FallingBox();
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x] = new Stone();
} else if (map[y][x].isFallingBox()) {
map[y][x] = new Box();
}
}
class Stone implements Tile {
// ...
isFallingStone() { return false; }
moveHorizontal(dx: number) {
if (map[playery][playerx + dx + dx].isAir()
&& !map[playery + 1][playerx + dx].isAir()) {
map[playery][playerx + dx + dx] = this;
moveToTile(playerx + dx, playery);
}
}
}
class FallingStone implements Tile {
// ...
isFallingStone() { return true; }
moveHorizontal(dx: number) { }
}
info

In TypeScript … Constructors behave a little differently than in most languages. First, we can have only one constructor, and it is always called constructor. Second, putting public or private in front of a parameter to the constructor automatically makes an instance variable and assigns it the value of the argument. So the following are equivalent.

class Stone implements Tile {
private falling: boolean;
constructor(falling: boolean) {
this.falling = falling;
}
}
class Stone implements Tile {
constructor(
private falling: boolean) { }
}

回頭看一下發現moveHorizontal有if搭配else,是不是又想起哪幾招...

當把falling變成type code,就可以使出連續技 -> into enum type -> Replace type code with classes -> push code into classes

/// ...
new Stone(true);
/// ...
new Stone(false);
/// ...
class Stone implements Tile {
constructor(private falling: boolean) { }
// ...
isFallingStone() {
return this.falling;
}
}

5.1.1 重構模式: Unify Similar Classes

描述:

當有兩個或多個class,只差在constant method,可以服用此藥方去整合他們

Unifying classes is great because having fewer classes usually means we uncover more structure.

程序

  1. The first phase is to make all the non-basis methods equal. For each of these methods, perform these steps:

    a. In the body of each version of the method, add an enclosing if (true) { } around the existing code.

    b. Replace true with an expression calling all the basis methods and comparing their result to their constant values.

    c. Copy the body of each version, and paste it with an else into all the other versions.

  2. Now that only the basis methods are different, the second phase begins by introducing a field for each method in the basis and assigning its constant in the constructor.

  3. Change the methods to return the new fields instead of the constants.

  4. Compile to ensure that we have not broken anything yet.

  5. For each class, one field at a time:

a Copy the default value of the field, and then make the default value a parameter.

b Go through the compiler errors, and insert the default value as an argument.

6 After all the classes are identical, delete all but one of the unified classes, and fix all the compile errors by switching to the remaining class.


5.2 整合簡單條件式

假設兩個分支做一樣的事情,用 OR (||) 整合起來

導入新方法drop(), rest()來設定新的falling field

Before

function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Stone(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Box(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x] = new Stone(new Resting());
} else if (map[y][x].isFallingBox()) {
map[y][x] = new Box(new Resting());
}
}

After
function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Stone(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Box(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x].rest();
} else if (map[y][x].isFallingBox()) {
map[y][x].rest();
}
}

大部分的method是空的


interface Tile {
// ...
drop(): void;
rest(): void;
}

class Stone implements Tile {
// ...
drop() { this.falling = new Falling(); }
rest() { this.falling = new Resting(); }
}

class Flux implements Tile {
// ...
drop() { }
rest() { }
}

整合相同程式碼到同一個分支

Before

function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Stone(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Box(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()) {
map[y][x].rest();
} else if (map[y][x].isFallingBox()) {
map[y][x].rest();
}
}

After

function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Stone(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Box(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isFallingStone()
|| map[y][x].isFallingBox()) {
map[y][x].rest();
}
}

5.2.1 Refactoring pattern: COMBINE IFS

Before
if (expression1) {
// body
} else if (expression2) {
// same body
}
After
if ((expression1) || (expression2)) {
// body
}

5.3 整合複雜條件式

進一步發現第一個if只是把一塊變成石頭,把另外一塊變成空氣,可以用上一節的方法簡化

Before
function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Stone(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y + 1][x] = new Box(new Falling());
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}
Before
function updateTile(x: number, y:
number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}

發現兩個body做的事情一樣,再一次整合

Before
function updateTile(x: number, y:
number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}

After
function updateTile(x: number, y:
number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()
|| map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}

5.3.1 Using arithmetic rules for conditions

用數學的加法乘法法則來記憶AND跟OR,OR(||)可以當成加(+), AND(&)可以當成乘(*) 圖像記憶法,因為OR(||)有兩條槓,可以組成(+),AND(&)感覺就像有一個x藏在裡面

Commutative Laws:
a + b = b + a
a × b = b × a
Associative Laws:
(a + b) + c = a + (b + c)
(a × b) × c = a × (b × c)
Distributive Law:
a × (b + c) = a × b + a × c
(a + b) × c = a × c + a × b

5.3.2 Rule: USE PURE CONDITIONS

STATEMENT: Conditions should always be pure.

Pure意思沒有side effect,像讀檔案的時候最好是先讀了,再移動cursor。 這個規則來自於常見的command query 分離法則,commands指的是任何會產生side effect的事情,queries代表是pure的事情。 一個簡單的遵循法則是,只允許回傳void的method做有side effect的事情。

作者的目的是為了把get data跟改動data兩個動作分開來,也更好命名。

Before
class Reader {
private data: string[];
private current: number;
readLine() {
this.current++;
return this.data[this.current] || null;
}
}
/// ...
let br = new Reader();
let line: string | null;
while ((line = br.readLine()) !== null) {
console.log(line);
}
After
class Reader {
private data: string[];
private current: number;
nextLine() {
this.current++;
}
readLine() {
return this.data[this.current] || null;
}
}
/// ...
let br = new Reader();
for (; br.readLine() !== null; br.nextLine()) {
let line = br.readLine();
console.log(line);
}

5.3.3 Applying condition arithmetic

Before
function updateTile(x: number, y: number) {
if (map[y][x].isStony()
&& map[y + 1][x].isAir()
|| map[y][x].isBoxy()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}
After
function updateTile(x: number, y: number) {
if ((map[y][x].isStony()
|| map[y][x].isBoxy())
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}

接著push || into classes

Before
function updateTile(x: number, y: number) {
if ((map[y][x].isStony()
|| map[y][x].isBoxy())
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}
After
function updateTile(x: number, y: number) {
if (map[y][x].canFall()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}

5.4 跨類別統合程式碼

Step1. 將行為放進Class

updateTile (Before)
// 在更新磚塊的動作中,如果磚塊可以掉落做「掉落」

function updateTile(x: number, y: number) {
if (map[y][x].canFall()
&& map[y + 1][x].isAir()) {
map[y][x].drop();
map[y + 1][x] = map[y][x];
map[y][x] = new Air();
} else if (map[y][x].isFalling()) {
map[y][x].rest();
}
}
updateTile (After)
// 在磚塊的「更新」動作中,如果能掉落執行「掉落」

function updateTile(x: number, y: number) {
map[y][x].update(x, y)
}

interface Tile {
update(x: number, y: number): void;
}

class Air implements Tile {
update(x: number, y: number): {
// 空氣磚不需要對「掉落」有反應
};
}

class Stone implements Tile {
update(x: number, y: number): {
if (map[y+1][x].isAir()) {
// 掉落中
this.falling = new Falling();
map[y+1][x] = this;
map[y][x] = new Air();
} else if (this.falling.isFalling()) {
// 到底了
this.falling = new Resting();
}
};
}
info

從本來在「環境」判斷所來「何人」對其進行對應動作;改成在「角色」裡,判斷所處環境決定其行為

  • Stone和Air都implement自Tile interface
    • 即:Stone和Air都是一種Tile

Step2. 抽取「策略」

Problems:

  • isOOO幾乎被移除了
  • Stone和Box有完全相同的程式碼: falling
  • 有可能有新的Tile需要implement

Solution:

  • 將falling視為一種「策略」
Before
class Stone implements Tile {
update(x: number, y: number) {
if (map[y+1][x].isAir()) {
this.falling = new Failing()
map[y+1][x] = this
map[y][x] = new Air()
} else if (this.falling.isFalling()) {
this.falling = new Resting()
}
}
}


class Box implements Tile {
update(x: number, y: number) {
if (map[y+1][x].isAir()) {
this.falling = new Failing()
map[y+1][x] = this
map[y][x] = new Air()
} else if (this.falling.isFalling()) {
this.falling = new Resting()
}
}
}
After
class FallingStrategy {
update(tile, x: number, y: number) {
if (map[y+1][x].isAir()) {
this.falling = new Failing();
map[y+1][x] = tile;
map[y][x] = new Air();
} else if (this.falling.isFalling()) {
this.falling = new Resting()
}
}
}

class Stone implements Tile {
constructor(strategy: FallingStrategy)
update(x: number, y: number) {
this.strategy.update(this, x, y)
}
}
class Box implements Tile {
constructor(strategy: FallingStrategy)
update(x: number, y: number) {
this.strategy.update(this, x, y)
}
}

Step3. 僅在開頭使用if

[Simplify] Before
class FallingStrategy {
update(x: number, y: number) {
if (map[y+1][x].isAir()) {
this.falling = new Failing()
map[y+1][x] = this
map[y][x] = new Air()
} else if (this.falling.isFalling()) {
this.falling = new Resting()
}
}
}

[Simplify] After
class FallingStrategy {
private drop(tile, x, y) {
map[y+1][x] = tile;
map[y][x] = new Air()
}
update(tile: Tile, x: number, y: number) {
this.falling = (map[y+1][x].isAir())
? new Falling()
: new Resting();
this.drop(tile, x, y)
}
}
info

以上這三個步驟,稱為「引入策略模式」

策略模式Strategy Pattern

  • 很多模式都是策略模式的變形
    • 若Strategy有attribute (variable而不是function)
      會被稱為狀態模式State Pattern
  • 變異(Variance)是策略模式的目的
  • 使用策略模式的情境
    1. 希望在程式碼中引入「變異」
    2. 希望在各個class間統合行為

規則:「不要讓介面只有一個實作」

  • 只有一個實作的介面並不會增加可讀性
  • 「介面」代表的是有變化;如果沒有變化,抽取介面會徒增心智的負擔
tip

抽象化增加了真正的複雜性,但減少了感知的複雜性

Abstraction trades an increase in real complexity for a decrease in perceived complexity

規則:「從實作提取介面」

  • 將「抽取介面」延遲到真正需要的時候才進行
    • 例如,想引入變異(Variance)的時候
Before
class ArraySum {
private processor: SumProcessor;
constructor(accumulator: number) {
processor = new SumProcessor(accumulator)
}
process(arr: number[]) {
for (let i=0; i< arr.length; i++) {
this.processor.processElement(arr[i])
}
return this.processor.getAccumulator();
}
}
class SumPorcessor {
constructor(private accumulator: number) {}
getAccumulator() {
return this.accumulator;
}
processElement(e: number) {
this.accumulator += e;
}
}
After
class BatchProcessor {
constructor(private processor: ElementProcessor) {}
process(arr: number[]) {
for (let i=0; i<arr.length; i++) {
this.processor.processElement(arr[i]);
}
return this.processor.getAccumulator();
}
}

interface ElementProcessor {
getAccumulator(): number;
processElement(e: number): void;
}
class MinimumProcessor implements ElementProcessor {
getAccumulator() { return this.accumulator; }
processElement(e: number) {
// The variance
if (this.accumulator > e) {
this.accumulator = e;
}
}
}
class SumProcessor implements ElementProcessor {
getAccumulator() { return this.accumulator; }
processElement(e: number) {
// The variance
this.accumulator += e;
}
}

5.5 統合相同的函式

Before
function removeLock1() {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x].isLock1()) { // 只有這裡有差
map[y][x] = new Air();
}
}
}
}
function removeLock2() {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x].isLock2()) { // 只有這裡有差
map[y][x] = new Air();
}
}
}
}
After
function remove(shouldRemove: RemoveStrategy) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (shouldRemove.check(map[y][x])) {
map[y][x] = new Air();
}
}
}
}

5.6 統合相似的程式碼

Before: Key1/Lock1
class Key1 implements Tile {
draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = "#ffcc00";
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
moveHorizontal(dx: number) {
removeLock1();
moveToTile(playerx + dx, playery);
}
}

class Lock1 implements Tile {
isLock1() { return true; }
isLock2() { return false; }
draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = "#ffcc00";
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
Before: Key2 & Lock2
class Key2 implements Tile {
draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = "#00ccff";
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
moveHorizontal(dx: number) {
removeLock2();
moveToTile(playerx + dx, playery);
}
}

class Lock2 implements Tile {
isLock1() { return false; }
isLock2() { return true; }
draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = "#00ccff";
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
Layer 1
After
class KeyConfiguration { // 封裝Key, Lock會使用到的參數們
constructor(
private color: string,
private _1: boolean, // 若未來需要引入第3或第4個key/lock的組合,可以透過把boolena改成number實現
private removeStrategy: RemoveStrategy
) { }

getColor() { return this.color; }
is1() { return this._1; }
getRemoveStrategy() { return this.removeStrategy; }
}

class Key implements Tile { // 整合Key1與Key2,改以keyConf注入做出差別
constructor(private keyConf: KeyConfiguration) { }
isLock1() { return false; }
isLock2() { return false; } // ??? 幹嘛不也從this.keyConf.is1()拿?

draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = this.keyConf.getColor();
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}

moveHorizontal(dx: number) {
remove(this.keyConf.getRemoveStrategy()); // 引入策略模式
moveToTile(playerx + dx, playery);
}
}

class Lock implements Tile { // 整合Lock1, Lock2
constructor(private keyConf: KeyConfiguration) { }

isLock1() { return this.keyConf.is1(); }
isLock2() { return !this.keyConf.is1(); } // 因為不是Lock1就一定是Lock2,省下一個參數

draw(g: CanvasRenderingContext2D, x: number, y: number) {
g.fillStyle = this.keyConf.getColor();
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}

moveHorizontal(dx: number) { }
}
info

5.6的重構,因為注意到key和lock這兩個class彼此是有關連的,刻意對他們做出耦合 (KeyConfiguration),下一章會繼續討論KeyConfiguration的整理

小結

  • 當有類似的程式碼需要合併時,有三種方式可以辦到
    • 統合相似的類別
    • 合併ifs
    • 引入策略模式
  • 規則:「使用純條件式」,指出條件式不該存有副作用(Side Effect)
    • 簡化條件式可以使用布林運算的算術簡化方式來處理
  • 規則:「不要讓介面只有一個實作」
    • 不要為了抽象化而抽象化
    • 應該從既有的實作提取介面,在真正需要的時候才進行抽象處理