Skip to main content

Ch4: 讓型別碼能運作

VCR

Ch3 的 Refactor 完的最後留下一連串的 if-else,讓 Ch4 來教你怎樣優雅的 Refactor 這些 if-else if-else 吧!

function handleInputs() {
while (inputs.length > 0) {
let current = inputs.pop();
if (current === Input.RIGHT) moveHorizontal(1);
else if (current === Input.LEFT) moveHorizontal(-1);
else if (current === Input.DOWN) moveVertical(1);
else if (current === Input.UP) moveVertical(-1);
}
}

4.1 重構一個簡單的 if 陳述句

在 EXTRACT METHODE 的規則中,我們只能留下一大串 else if, 但也無法再使用 EXTRACT METHODE 而且也不符合 FIVE LINES,那該怎麼辦呢?

那就不要用!

4.1.1 NEVER USE if WITH else

  • 不要寫死決策
  • 把第三方資料類型映射到自己的資料類型
window.addEventListener("keydown", (e) => {
if (e.key === LEFT_KEY || e.key === "a") inputs.push(Input.LEFT);
else if (e.key === UP_KEY || e.key === "w") inputs.push(Input.UP);
else if (e.key === RIGHT_KEY || e.key === "d") inputs.push(Input.RIGHT);
else if (e.key === DOWN_KEY || e.key === "s") inputs.push(Input.DOWN);
});

異味

早期綁定(early-binding)會阻止新增變更,只能透過修改 if 來進行變更。 後期綁定(late-binding)可以在執行程式最後才確定行為,比較理想。

意圖

為了避免使用 if 來決定程式流程,盡量使用物件,使用物件可以根據實例化的類別來決定執行程式碼。

參考

  • 用類別替代型別碼 REPLACE TYPE CODE WITH CLASSES (4.1.3)
  • 引入策略模式 INTRODUCE STRATEGY PATTERN (5.4.2)

4.1.2 套用規則

使用 interface 來替換 enum

enum Input {
RIGHT,
LEFT,
UP,
DOWN,
}
interface Input2 {
isRight(): boolean;
isLeft(): boolean;
isUp(): boolean;
isDown(): boolean;
}

4.1.3 REPLACE TYPE CODE WITH CLASSES

描述

這個模式把 enum 轉換成 interface,把 enum 的值轉換成 class,這樣可以在每個值加入屬性,局部化該特定值得相關功能。 把 enum 轉換成 class 時,可以不需要考慮其他 enum 的值而把功能和資料結合在一起。 平常加入新的 enum 值,需要處理該 enum 所有地方的 if-else,或是 switch,而這個模式只需在 class 內實作新方法,不需要修改其他程式碼。

info

遇到需要檢查有關聯性的型別時 code,盡量都先轉換成 enum,再套用這個模式。

Before
const SMALL = 33;
const MEDIUM = 37;
const LARGE = 42;
After
enum TShirtSizes {
SMALL = 33,
MEDIUM = 37,
LARGE = 42,
}

處理步驟

  1. 請引入一個暫時的 interface,取個臨時的名字。這個介面應該要含有 enum 的每個值所對應的方法。
  2. 建立與 enum 的每個值對應的類別,除了與該類別相對應的方法返回 true 之外,介面中的所有方法都應該返回 false。
  3. 將 enum 重新命名。這樣會讓使用原本 enum 的所有地方回傳執錯誤訊息。
  4. 把型别的舊名稱改成臨時名稱,並用新的方法替換相等性檢查。
  5. 將剩下的參照到 enum 值的位置都替換為實例化新類別的處理方式。
  6. 最後當不再回報錯誤時,把介面的名稱全都修改為永久使用的名稱。

範例

enum TrafficLight {
RED,
YELLOW,
GREEN,
}
const CYCLE = [TrafficLight.RED, TrafficLight.GREEN, TrafficLight.YELLOW];
function updateCarForLight(current: TrafficLight) {
if (current === TrafficLight.RED) car.stop();
else car.drive();
}

這次的重構是為了為面的鋪陳,後面的章節會針對 isXXX()這類的 method 進行重構。

4.1.4 PUSHING CODE INTO CLASSES

interface Input {
isRight(): boolean;
isLeft(): boolean;
isUp(): boolean;
isDown(): boolean;
}

class Right implements Input {
isRight() { return true; }
isLeft() { return false; }
isUp() { return false; }
isDown() { return false; }
}
class Left implements Input { ... }
class Up implements Input { ... }
class Down implements Input { ... }

function handleInput(input: Input) {
if (input.isLeft()) moveHorizontal(-1);
else if (input.isRight()) moveHorizontal(1);
else if (input.isUp()) moveVertical(-1);
else if (input.isDown()) moveVertical(1);
}

4.1.5 重構模式: PUSH CODE INTO CLASSES

描述

  • REPLACE TYPE CODE WITH CLASSES 的延伸
  • 功能與資料會聚集到相應的 class
  • 意在消除 if-else

處理步驟

  1. 把原本的 function 複製到複製到相關的 class 裡。把複製好的 function 字眼移除。把 if 內的方法更換成 this, 因為使用 class 內的方法。
  2. 把該 method 新增到該 class 實作的 interface 中,並取稍微不一樣的名字,方便提示修改。
  3. 遍歷所有相關 class 中的新方法,移除多餘的 if-else
  4. 把 class 內新增的方法改成 interface 內相應的方法,代表我們在 class 內完成了實作
  5. 將原本 function 內的方法換成 interface 的方法
interface TrafficLight {
isRed(): boolean;
isYellow(): boolean;
isGreen(): boolean;
}
class Red implements TrafficLight {
isRed() {
return true;
}
isYellow() {
return false;
}
isGreen() {
return false;
}
}
class Yellow implements TrafficLight {
isRed() {
return false;
}
isYellow() {
return true;
}
isGreen() {
return false;
}
}
class Green implements TrafficLight {
isRed() {
return false;
}
isYellow() {
return false;
}
isGreen() {
return true;
}
}
function updateCarForLight(current: TrafficLight) {
if (current.isRed()) car.stop();
else car.drive();
}

4.1.6 移除多餘的 function

function handleInputs() {
while (inputs.length > 0) {
let current = inputs.pop();
handleInput(current);
}
}
function handleInput(input: Input) {
input.handle();
}

4.1.7 重構模式: INLINE METHOD

描述

在上個範例中,我們使用重構方法 PUSH CODE INTO CLASSES 將 function 複製並新增到 class 裡面來進祥 refactor, 被複製剩下的 function 就是多餘的 function,這時候就可以使用 INLINE METHOD 來移除多餘的 function。

處理步驟

  1. 把 function 名稱暫時換掉,這時候會有錯誤訊息在所有使用該 function 的地方。
  2. 複製這個 function 的內容,並留意他的參數。
  3. 在錯誤訊息的地方把 function 內容貼上,並把參數對應到正確的位置。
  4. 沒有錯誤訊息時,移除多餘的 function。

範例

function deposit(to: string, amount: number) {
let accountId = database.find(to);
database.updateOne(accountId, { $inc: { balance: amount } });
}
function transfer(from: string, to: string, amount: number) {
deposit(from, -amount);
deposit(to, amount);
}

4.2 重構大型的if陳述句

if ONLY AT START

function drawMap(g: CanvasRenderingContext2D) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x] === Tile.FLUX)
g.fillStyle = "#ccffcc";
else if (map[y][x] === Tile.UNBREAKABLE)
g.fillStyle = "#999999";
else if (map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
g.fillStyle = "#0000cc";
else if (map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
g.fillStyle = "#8b4513";
else if (map[y][x] === Tile.KEY1 || map[y][x] === Tile.LOCK1)
g.fillStyle = "#ffcc00";
else if (map[y][x] === Tile.KEY2 || map[y][x] === Tile.LOCK2)
g.fillStyle = "#00ccff";

if (map[y][x] !== Tile.AIR && map[y][x] !== Tile.PLAYER)
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}

NEVER USE if WITH else

interface Tile2 {
isFlux(): boolean;
isUnbreakable(): boolean;
isStone(): boolean;
//... <----------| enum的所有值都有其對應的方法
}
function colorOfTile(g: CanvasRenderingContext2D, x: number, y: number) {
if (map[y][x] === Tile.FLUX)
g.fillStyle = "#ccffcc";
else if (map[y][x] === Tile.UNBREAKABLE)
g.fillStyle = "#999999";
else if (map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
g.fillStyle = "#0000cc";
else if (map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
g.fillStyle = "#8b4513";
else if (map[y][x] === Tile.KEY1 || map[y][x] === Tile.LOCK1)
g.fillStyle = "#ffcc00";
else if (map[y][x] === Tile.KEY2 || map[y][x] === Tile.LOCK2)
g.fillStyle = "#00ccff";
}
let map: Tile[][] = [
[2, 2, 2, 2, 2, 2, 2, 2],
[2, 3, 0, 1, 1, 2, 0, 2],
[2, 4, 2, 6, 1, 2, 0, 2],
[2, 8, 4, 1, 1, 2, 0, 2],
[2, 4, 1, 1, 1, 9, 0, 2],
[2, 2, 2, 2, 2, 2, 2, 2]
];

function remove(tile: Tile) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
if (map[y][x] === tile) {
map[y][x] = new Air();
}
}
}
}

這兩個錯誤需要特殊的處置,因此我們將逐一進行處理。

4.2.1 去除泛化通用性

function remove(tile: Tile) {
for (let y = 0; y < map.length; y++) {
for let x = 0; x < map[y].length; x++) {
if (map[y][x] === tile) {
map[y][x] = new Air();
}
}
}
}
/// ...
remove(new Lock1());
/// ...
remove(new Lock2());
/// ...
remove(new Lock1());
/// ...
remove(new Lock2());
/// ...

4.2.2 重構模式:特定化方法(SPECIALIZE METHOD)

我們天生希望讓程式碼泛化通用並且能重複使用,但這樣做可能會有問題,因為它會模糊責任的範圍,也表示程式碼可以從各種地方呼叫取用。 愈專用特定化的方法,被呼叫取用的地方就愈少,這意味著很快就會變得不再使用,反而可以移除掉。

處理步驟

  1. 複製我們想要特定化的方法。
  2. 將其中一個方法重新命名為新的固定名稱,然後刪除(或替換)我們要特定化的參數。
  3. 根據需要修正方法,使其沒有錯誤。
  4. 將舊的呼叫改為使用新的呼叫。
function canMove(start: Tile, end: Tile, dx: number, dy: number)
{
return dx * abs(start.x - end.x)
=== dy * abs(start.y - end.y)
|| dy * abs(start.x - end.x)
=== dx * abs(start.y - end.y);
}
/// ...
if (canMove(start, end, 1, 0)) // Rook城堡
/// ...
if (canMove(start, end, 1, 1)) // Bishop主教
/// ...
if (canMove(start, end, 1, 2)) // Knight騎士
/// ...

4.2.3 只能用switch

本來使用enum索引來建立map,在資料庫或檔案中也會使用類似的方式來儲存資料。 在實務上,通常無法更改現有的外部資料來適應重構的處理。 不如建立一個新的函式,把我們從enum索引帶到新的類別。

let rawMap: RawTile[][] = [
[2, 2, 2, 2, 2, 2, 2, 2],
[2, 3, 0, 1, 1, 2, 0, 2],
[2, 4, 2, 6, 1, 2, 0, 2],
[2, 8, 4, 1, 1, 2, 0, 2],
[2, 4, 1, 1, 1, 9, 0, 2],
[2, 2, 2, 2, 2, 2, 2, 2]
];
let map: Tile2[][];
function assertExhausted(x: never): never {
throw new Error("Unexpected object: " + x);
}
function transformTile(tile: RawTile) {
switch (tile) {
case RawTile.AIR: return new Air();
case RawTile.PLAYER: return new Player();
case RawTile.UNBREAKABLE: return new Unbreakable();
case RawTile.STONE: return new Stone();
case RawTile.FALLING_STONE: return new FallingStone();
case RawTile.BOX: return new Box();
case RawTile.FALLING_BOX: return new FallingBox();
case RawTile.FLUX: return new Flux();
case RawTile.KEY1: return new Key1();
case RawTile.LOCK1: return new Lock1();
case RawTile.KEY2: return new Key2();
case RawTile.LOCK2: return new Lock2();
default: assertExhausted(tile);
}
}
function transformMap() {
map = new Array(rawMap.length);
for (let y = 0; y < rawMap.length; y++) {
map[y] = new Array(rawMap[y].length);
for (let x = 0; x < rawMap[y].length; x++) {
map[y][x] = transformTile(rawMap[y][x]);
}
}
}
window.onload = () => {
transformMap();
gameLoop();
}

tansformMap符合五行程式碼。但tansformTile違反了五行程式碼,也違反了另一條「NEVER USE switch」

info

在TypeScript中... enum(最舉)是代表數字的名稱,例如在C#中,它不像在Java中是個類別。因此,我們不需要在數字和enum之間進行任何轉換,可以直接使用enum索引,就像在之前程式碼中一樣的用法。

4.2.4 規則:不要使用switch(NEVER USE switch)

陳述

除非每種情況都有預設值和返回,否則千萬不要使用switch。

(Never use switch unless you have no default and return in every case)

解說

switch有兩個方便之處,但有副作用

  • switch支援default,可能忘記處理新的值,造成新的值落入了default
  • switch有「貫穿邏輯(fall-through logic)」的特性,程式會一直執行所有的case值到遇到break。容易忘記加上break或沒有注意到break。
info

在TypeScript中...

Switch陳述句還是很有用的,因為可以讓編譯器檢查我們是否都映射了所有的enum值。我們需要引入一個「魔法函式」才能實現這項功能,但它是專屬TypeScript的功能,其做法超出了本書的範圍。幸運的是,這個函式永遠不會改變,而這個模式在TypeScript中一直都是有效的。

異味

在Martin Fowler的Refactoring中,switch被稱為程式碼異味。 switch所關注的程式上下脈絡是如何在「這裡」處理值X。 相反地,把功能推入類別中則是把焦點放在資料,也就是依照這個值(物件)處理情況X。 只關注於程式上下脈絡所表示的是把「不變性」進一步從其資料中移開,從而讓「不變性」變成全域化。

意圖

這項規則(NEVER USE switch)有個優雅的附帶作用,可以把switch轉成一連串的else if之後,就能轉換成類別,最後削除if條件式。

4.2.5 削除if

使用INLINE METHOD

function colorOfTile(g: CanvasRenderingContext2D, x: number, y: number) {
if (map[y][x].isFlux())
g.fillStyle = "#ccffcc";
else if (map[y][x].isUnbreakable())
g.fillStyle = "#999999";
else if (map[y][x].isStone() || map[y][x].isFallingStone())
g.fillStyle = "#0000cc";
else if (map[y][x].isBox() || map[y][x].isFallingBox())
g.fillStyle = "#8b4513";
else if (map[y][x].isKey1() || map[y][x].isLocak1())
g.fillStyle = "#ffcc00";
else if (map[y][x].isKey2() || map[y][x].isLock2())
g.fillStyle = "#00ccff";
}

取代本來的colofOfTile

function drawMap(g: CanvasRenderingContext2D) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
colorOfTile(g, x, y);
if (!map[y][x].isAir() && !map[y][x].isPlayer())
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}
function colorOfTile(g: CanvasRenderingContext2D, x: number, y: number)
{
map[y][x].color(g);
}

4.3 處理程式碼重複的問題

info

如果想要冒一點險,可以跳過提取方法並將它內聯到下面的處理中,直接把程式碼移到類別中。但請確定已經提交過程式嗎。因為在出現問題時可以復原回到這個提交時間點。

function drawMap(g: CanvasRenderingContext2D) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
map[y][x].color(g);
if (!map[y][x].isAir() && !map[y][x].isPlayer())
g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}

最後整理如下

function drawMap(g: CanvasRenderingContext2D) {
for (let y = 0; y < map.length; y++) {
for (let x = 0; x < map[y].length; x++) {
drawTile(g, x, y)
}
}
}
function drawTile(g: CanvasRenderingContext2D, x: number, y: number)
{
map[y][x].draw(g);
}

4.3.1 不能使用抽象類別來代替介面嗎?

可以,這可以避免程式碼重複。 介面會強制我們為每個新的類別進行主動操作。不會意外忘記某個屬性或覆寫了不應該覆寫的東西。 在寫完程式六個月後最為麻煩,當忘掉了程式是怎麼運作時,可能需要回來新增一個tile型別

4.3.2 規則:只能從介面來繼承(ONLY INHERIT FROM INTERFACES)

陳述

只能從介面來繼承

解說

只能從介面繼承,而不能從類別或抽象類別來繼承。 如此可以減少程式碼的重複。 抽象類別提供的預設實作,程式碼共用可能會導致耦合。

異味

這項規則是從Design Pattern一書中的「優先使用物件組合而非繼承」演化而來。

意圖

應該透過參照其他物件來分享程式碼,而不是用繼承的方式。

4.3.3 重複的程式碼是怎麼一回事?

很多情況下,重複的程式碼是不好的。但是要思考一下為什麼會有這樣的情況發生?若修改到重複部份,則需要在整支程式中找出所有重複的內容來修,是很不好的。

如果程式中有重複的程式碼,而我們只在其中一處進行了更改,那就變成有了兩種不同的功用。程式碼的重複是不好的,因為這種做法會鼓勵程式碼產生分歧。

4.4 重構一對複雜的if陳述句

function moveHorizontal(dx: number) {
if (map[player][playerx + dx].isFlux()
|| map[playery][playerx + dx].isAir())
moveToTile(playerx + dx, playery);
} else if ((map[playery][playerx + dx].isStone()
|| map[playery][playerx + dx].isBox())
&& map[palyery][playerx + dx + dx].isAir()
&& !map[playery + 1][playerx + dx].isAir()) {
map[playery][playerx + dx + dx] = map[playerx][playerx + dx];
moveToTile(playerx + dx, playery);
} else if (map[playery][playerx + dx].isKey1()) {
removeLock1();
moveToTile(playerx + dx, playery);
} else if (map[playery][playerx + dx].isKey2()) {
removeLock2();
moveToTile(playerx + dx, playery);
}
}

4.5 移除無用的程式碼

由於介面都是公開的,沒有IDE能告知介面中的方法是否有被使用。 有可能是未來使用,或許也可能被外部範圍的某些程式使用。 一般情況下,無法輕易從介面中刪除方法。

4.5.1 重構模式:刪除後再編譯(TRY DELETE THEN COMPILE)

處理步驟

  1. 編譯。沒有錯誤。

  2. 從介面中刪除方法。

  3. 編譯。

    a. 如果編譯後出現錯誤,請復原,然後再繼續。

    b. 如果編譯後無錯誤,請逐一檢查每個類別,看看是否能繼續刪除相同的方法且編譯不出現錯誤。

範例

interface A {
m1(): void;
m2(): void;
}
class B implements A {
m1() { console.log("m1"); }
m2() { this.m3(); }
m3() { console.log("m3"); }
}
let a = new B();
a.m1();

總結

  • 「NEVER USE if WITH else」和「NEVER USE switch」這兩規則表示,應該只在程式的外圍使用else或switch。else和switch都屬於低層級的控制流程運算子。應該使用「REPLACE TYPE CODE WITH CLASSES」和「PUSH CODE INTO CLASSES」來替換。
  • 過於泛用化的方法會阻礙重構進行。使用「SPECIALIZED METHOD」
  • 「ONLY INHERIT FROM INTERFACES」禁止使用抽象類別和類別繼承來重用程式碼,因為會導致不必要的緊密耦合。
  • 「INLINE METHOD」及「TRY DELETE THEN COMPILE」可以在重構後清理程式碼。