跳至主要内容

[Book] 重構:改善既有程式的設計

Web Edition
Codebase

Chapter 6:第一組重構

Extract Function(提取函式)

時機

作者認為,提取函式的時間不是依照程式碼的長度、也不是只有在程式碼會被重複使用時,才來做提取函式的動作,而是「如果你必須費心查看一段程式碼才能了解它究竟在做什麼,你就應該把它拆出來,並且以它的目的來命名。」

"Separation between intention(what) and implementation."

做法

建立一個新函式,並且根據函式的目的來為它命名(根據 what 而不是 how 來命名)。

Inline Function(將函式內聯)

時機

「當程式碼本身的內容和函式名稱一樣清楚時」就不需要額外做內聯,因為多這一層反而增加認知負擔。

作法

將原本函式中的程式碼,直接放回執行它的地方,而不在包這一層函式。

Extract Variable(提取變數)

時機

當只看某個邏輯、運算式或判斷式沒辦法立即瞭解其意義時

做法

將比較複雜的邏輯命名,以讓其他開發者更容易理解

資訊

許多編輯器都有提供了 Refactor 的功能,方便開發者做到將程式提取成變數的動作。

VSCode 能將所選的程式碼抽取成變數:

extract-variable-vscode

Goland 能將所選的程式碼,以及相同的程式碼片段,同時抽取成變數:

extract-variable

Inline Variable(內聯變數)

時機

運算式或程式碼本身已經能清楚傳達訊息,不需要額外提取成變數,以避免額外負擔

做法

幫變數的程式碼貼回使用它的地方

Change Function Declaration(修改函式宣告式)

時機

發現函式的名稱不易理解時

做法

改善名稱的一個好方法是:「寫下註解來說明函式的用途,再把註解變成一個名稱」:

  • 修改函式名稱:讓名稱能夠更清楚該函式做了什麼
  • 修改函式參數:傳入整個物件或特定屬性
請善用 IDE/Editor 的 refactor 功能

修改函式或變數名稱時,請善用 Editor/IDE 提供的 refactor 的功能(預設是按 F2),它會自動幫你修改所有參照到這個變數的地方。

如果我們希望將這個函式的名稱從 circum 改為 circumference

使用遷移做法修改函式名稱(修改前)
func circum(radius float64) float64 {
return 2 * math.Pi * radius
}

但因為使用這個函式的地方非常多,可能沒辦法一次全部取代,所以我們暫時先同時保留兩個函式:

 func circum(radius float64) float64 {
+ return circum(radius)
+}
+
+func circumference(radius float64) float64 {
return 2 * math.Pi * radius
}

在有使用到這個函式的地方,陸續進行替換,知道全部替換完畢後,在移除舊有的函式。

思考

在傳遞函式參數的時候,經常會碰到要傳遞整個物件或只有使用到屬性。當傳遞整個物件時,會讓函式和這個物件的介面耦合,但卻可以輕鬆的讀取該物件中的其他屬性,當未來邏輯改變時,不需要修改這個函式的任何呼叫方,並可以增加函式的封裝性。

Rename Variable(更改變數名稱)

如果要修改「常數」名稱,和修改函式名稱一樣,一個好用的技巧是先同時新舊兩個變數名稱,逐步替換完畢後,再把舊的變數刪除。

假設我們希望把 cpyNm 改成 companyName

修改前
// 這是原本的變數名稱
const cpyNm = 'Acme Gooseberries';

我們先建立新的變數,並將舊的變數參照到新的變數:

修改後
// 這是希望修改後的變數名稱
const companyName = 'Acme Gooseberries';
const cpyNm = companyName;

Combine Functions into Transform(將函式組成轉換函式)

動機

我們經常需要在取得原始資料後,透過一些前處理來取得 derived data,而許多的 derived state 或 derived data 都是根據相同的邏輯而產生。作者喜歡把所有這類的計算邏輯放在一起,以便在固定的地方尋找與修改他們。

做法

使用「資料轉換函式」,用它接受資料,並計算所有的衍生值。

命名上,如果轉換邏輯產生的是相同東西,但是有額外的資訊時,作者習慣用 enrich,例如,enrichReading;但如果會產生的是不一樣的東西時,則習慣用 transform,例如,transformReading

針對還沒轉換前的資料,作者習慣加上 raw,例如 rawReading

Transform or Class

如果來源資料會被程式碼所更新,使用類別比較好,才能確保每次拿到的都是最新且正確的資料。

其他

  • Encapsulate Variable(封裝變數)
  • Introduce Parameter Object(使用參數物件)
  • Combine Functions into Class(將函式移入類別)
  • Split Phase(拆成不同階段):當遇到處理兩件不同事情的一段程式碼時

Chapter 08: 移動功能

  • Demo with Github Copilot

移動函式(Move Function)

有時你會很難決定函式最好的位置, 但是通常選擇越困難,這件事就越不重要。

拆開迴圈(Split Loop)

許多開發者對於這種重構感到不適,因為明明能在一個迴圈做完的事情,卻要分在多個迴圈做,但作者認為應該要把「效能優化和重構分開來看」,一旦程式清楚了,再來優化。一般來說迴圈不會是效能的瓶頸,將不同的邏輯從迴圈拆分開來後,往往更把效能優化做的更好。

將迴圈改成流程(Replace Loop with Pipeline)

移除死代碼(Remove Dead Code)

當程式碼再也用不到,我們就要刪除它。我們不在乎以後會不會用到它,就算這件事真的發生了,我也可以用版本控制系統把它挖出來。

Chapter 10:簡化條件邏輯

分解條件邏輯(Decompose Conditional)

修改前
function getPrice({ quantity, plan, date }: IGetPrice): number {
let charge: number | undefined;

// 原本的寫法
if (!date.isBefore(plan.summerStart) && !date.isAfter(plan.summerEnd)) {
charge = quantity * plan.summerRate * plan.regularServiceCharge;
} else {
charge = quantity * plan.regularServiceRate * plan.regularServiceCharge;
}

return charge;
}

情況:條件判斷式中,不易理解的 condition 和 statement

做法:將條件式及其分支抽成函式(Extract Function),並進行妥善的命名。

可以看到這裡的條件式相當不容易理解。透過分解條件邏輯,我們可以:

  • 將條件式中的判斷式(condition)抽成函式
  • 將條件式中的 statement 也抽成函式
將條件式中的判斷式(condition)抽成函式
+function isSummer({ plan, date }: IIsSummer): boolean {
+ return !date.isBefore(plan.summerStart) && !date.isAfter(plan.summerEnd);
+}
+
interface IGetPrice {
quantity: number;
plan: Plan;
@@ -24,7 +32,7 @@ interface IGetPrice {
function getPrice({ quantity, plan, date }: IGetPrice): number {
let charge: number;

- if (!date.isBefore(plan.summerStart) && !date.isAfter(plan.summerEnd)) {
+ if (isSummer({ plan, date })) {
charge = quantity * plan.summerRate * plan.regularServiceCharge;
} else {
charge = quantity * plan.regularServiceRate * plan.regularServiceCharge;
將條件式中的 statement 也抽成函式

+function getSummerCharge({ quantity, plan }: IGetCharge): number {
+ return quantity * plan.summerRate * plan.regularServiceCharge;
+}
+function getRegularCharge({ quantity, plan }: IGetCharge): number {
+ return quantity * plan.regularServiceRate * plan.regularServiceCharge;
+}
+

function getPrice({ quantity, plan, date }: IGetPrice): number {
let charge: number;

if (isSummer({ plan, date })) {
- charge = quantity * plan.summerRate * plan.regularServiceCharge;
+ charge = getSummerCharge({ quantity, plan });
} else {
- charge = quantity * plan.regularServiceRate * plan.regularServiceCharge;
+ charge = getRegularCharge({ quantity, plan });
}

return charge;
}
改用三元表達式
 function getPrice({ quantity, plan, date }: IGetPrice): number {
- let charge: number;
-
- if (isSummer({ plan, date })) {
- charge = getSummerCharge({ quantity, plan });
- } else {
- charge = getRegularCharge({ quantity, plan });
- }
-
- return charge;
+ return isSummer({ plan, date })
+ ? getSummerCharge({ quantity, plan })
+ : getRegularCharge({ quantity, plan });
}

Consolidate Conditional Expression(合併條件式)

修改前
func disabilityAmount(employee Employee) int {
if employee.seniority < 2 {
return 0
}
if employee.monthsDisabled > 12 {
return 0
}
if employee.isPartTime {
return 0
}

// calculate disability amount
return 999
}

情況:多個條件會有相同的回傳結果時

動機:

  • It replaces a statement of what I’m doing with why I’m doing it.
  • 如果彼此的檢查,在邏輯上是相互獨立時,就不會做這種重構,因為他們應該放在不同區塊
合併條件式(consolidate conditional expression)
func disabilityAmount(employee Employee) int {
- if employee.seniority < 2 {
- return 0
- }
- if employee.monthsDisabled > 12 {
- return 0
- }
- if employee.isPartTime {
+ if isNotEligibleForDisability(employee) {
return 0
}

return 999
}

+func isNotEligibleForDisability(employee Employee) bool {
+ return employee.seniority < 2 || employee.monthsDisabled > 12 || employee.isPartTime
+}
+
func main() {
result := disabilityAmount(employee)
fmt.Println(result)

Replace Nested Conditional with Guard Clauses(將內嵌的條件式換成防衛敘句)

範例一

修改前
func payAmount(p Employee) Amount {
var result Amount

if p.isSeparated {
result = Amount{
amount: 0,
reasonCode: "SEP",
}
} else {
if p.isRetired {
result = Amount{
amount: 0,
reasonCode: "RET",
}
} else {
// calculate amount
result = normalPayAmount()
}
}

return result
}

情況:當條件式中 ifelse 內的陳述句重要程度不同時

做法:提早讓函式中的條件式回傳(early return)

動機:當我們使用 if-then-else 時,表示的是 ifelse 中的陳述句同等重要。但如果我們用的是防衛敘句(guard clauses),意思則是指「這個情況不是這個功能的重點,如果發生了,就該做些什麼後離開」。

guard clause
func payAmount(p Employee) Amount {
if p.isSeparated {
return Amount{
amount: 0,
reasonCode: "SEP",
}
}
if p.isRetired {
return Amount{
amount: 0,
reasonCode: "RET",
}
}

// calculate amount
return normalPayAmount()
}

範例二:Reversing the Conditions

修改前
func adjustedCapital(instrument Instrument) int {
result := 0

if instrument.capital > 0 {
if instrument.interestRate > 0 && instrument.duration > 0 {
result = (instrument.income / instrument.duration) * instrument.adjustmentFactor
}
}
return result
}
reverse condition with guard clause
 func adjustedCapital(instrument Instrument) int {
result := 0

- if instrument.capital > 0 {
- if instrument.interestRate > 0 && instrument.duration > 0 {
- result = (instrument.income / instrument.duration) * instrument.adjustmentFactor
- }
+ if instrument.capital <= 0 {
+ return result
}
+
+ if instrument.interestRate <= 0 || instrument.duration <= 0 {
+ return result
+ }
+
+ result = (instrument.income / instrument.duration) * instrument.adjustmentFactor
return result
}

由於某些條件都回傳相同的結果,所以可以搭配 consolidate conditional expression:

搭配 consolidate conditional expression
 func adjustedCapital(instrument Instrument) int {
result := 0

- if instrument.capital <= 0 {
+ if instrument.capital <= 0 || instrument.interestRate <= 0 || instrument.duration <= 0 {
return result
}

- if instrument.interestRate <= 0 || instrument.duration <= 0 {
- return result
- }
-
- result = (instrument.income / instrument.duration) * instrument.adjustmentFactor
- return result
+ return (instrument.income / instrument.duration) * instrument.adjustmentFactor
}

Replace Conditional with Polymorphism(將條件式換成多型)

情況:根據不同的「類型」會回傳不同的結果

動機:根據不同的類型(class)/型別(type)來處理條件邏輯

修改前
type Bird = {
name: string;
type: 'EuropeanSwallow' | 'AfricanSwallow' | 'NorwegianBlueParrot';
numberOfCoconuts: number;
voltage: number;
isNailed: boolean;
};

function plumages(birds: Bird[]) {
return new Map(birds.map((b) => [b.name, plumage(b)]));
}

function speeds(birds: Bird[]) {
return new Map(birds.map((b) => [b.name, airSpeedVelocity(b)]));
}

function plumage(bird: Bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return 'average';
case 'AfricanSwallow':
return bird.numberOfCoconuts > 2 ? 'tired' : 'average';
case 'NorwegianBlueParrot':
return bird.voltage > 100 ? 'scorched' : 'beautiful';
default:
return 'unknown';
}
}

function airSpeedVelocity(bird: Bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return 35;
case 'AfricanSwallow':
return 40 - 2 * bird.numberOfCoconuts;
case 'NorwegianBlueParrot':
return bird.isNailed ? 0 : 10 + bird.voltage / 10;
default:
return null;
}
}
建立類別
-type Bird = {
+class Bird {
name: string;
type: 'EuropeanSwallow' | 'AfricanSwallow' | 'NorwegianBlueParrot';
numberOfCoconuts: number;
voltage: number;
isNailed: boolean;
-};
+
+ constructor({ name, type, numberOfCoconuts, voltage, isNailed }: Bird) {
+ this.name = name;
+ this.type = type;
+ this.numberOfCoconuts = numberOfCoconuts;
+ this.voltage = voltage;
+ this.isNailed = isNailed;
+ }
+
+ get plumage() {
+ switch (this.type) {
+ case 'EuropeanSwallow':
+ return 'average';
+ case 'AfricanSwallow':
+ return this.numberOfCoconuts > 2 ? 'tired' : 'average';
+ case 'NorwegianBlueParrot':
+ return this.voltage > 100 ? 'scorched' : 'beautiful';
+ default:
+ return 'unknown';
+ }
+ }
+
+ get airSpeedVelocity() {
+ switch (this.type) {
+ case 'EuropeanSwallow':
+ return 35;
+ case 'AfricanSwallow':
+ return 40 - 2 * this.numberOfCoconuts;
+ case 'NorwegianBlueParrot':
+ return this.isNailed ? 0 : 10 + this.voltage / 10;
+ default:
+ return null;
+ }
+ }
+}
+

function plumage(bird: Bird) {
- switch (bird.type) {
- case 'EuropeanSwallow':
- return 'average';
- case 'AfricanSwallow':
- return bird.numberOfCoconuts > 2 ? 'tired' : 'average';
- case 'NorwegianBlueParrot':
- return bird.voltage > 100 ? 'scorched' : 'beautiful';
- default:
- return 'unknown';
- }
+ return new Bird(bird).plumage;
}

function airSpeedVelocity(bird: Bird) {
- switch (bird.type) {
- case 'EuropeanSwallow':
- return 35;
- case 'AfricanSwallow':
- return 40 - 2 * bird.numberOfCoconuts;
- case 'NorwegianBlueParrot':
- return bird.isNailed ? 0 : 10 + bird.voltage / 10;
- default:
- return null;
- }
+ return new Bird(bird).airSpeedVelocity;
}

加入特例(Introduce Special Case)

動機:程式中針對特定值都採取了相同的動作時,把那個行為拿到單一地點。

範例

class Site {
private _customer: Customer | 'unknown' = 'unknown';

get customer() {
// !! 把原本判斷的地方統一改到這裡
return this._customer === 'unknown' ? new UnknownCustomer() : this._customer;
}
}

interface ICustomer {
readonly name: string;
readonly paymentHistory: IPaymentHistory;
billingPlan: any;

// 新添加的屬性
readonly isUnknown: boolean;
}
class Customer implements ICustomer {
/* ... */
}

// 實作一個 `UnknownCustomer` 的 class,它具有和原本 `Customer` 相同的屬性或方法,只是它回傳的結果都是當 customer 為 `unknown` 時的結果
class UnknownCustomer implements ICustomer {
/* ... */
}

// theCustomer 一開始就會根據是不是 "unknown" 而有不同的屬性
const theCustomer = new Site().customer;

// 拿到的 name 會是根據 customer 是不是 unknown 而有不同
const name = theCustomer.name;
  • 實作一個 UnknownCustomer 的 class,它具有和原本 Customer 相同的屬性或方法,只是它回傳的結果都是當 customer 為 unknown 時的結果

範例:使用物件常值

在前一個例子中,由於 Customer 能改被更新(billingPlan 是 setter),所以我們建立一個專門的類別(class)。

在沒有 setter 的情況下,我們可以建立一個參數的物件即可,例如:

// 因為會回傳的是「常數」,建議使用「Readonly」以避免被修改
function createUnknownCustomer(): Readonly<ICustomer> {
return {
isUnknown: true,
name: 'occupant',
paymentHistory: {
weeksDelinquentInLastYear: 0,
},
billingPlan: registry.billingPlans.basic,
};
}

class Site {
private _customer: Customer | 'unknown' = 'unknown';

get customer() {
// 改成呼叫 createUnknownCustomer
return this._customer === 'unknown' ? createUnknownCustomer() : this._customer;
}
}

加入斷言(Introduce Assertion)

  • An assertion is a conditional statement presumed to be always true.
  • 斷言失敗就代表程式有錯
  • 即時把這些斷言全部移除,程式也應該能正確執行
  • 斷言不應該影響系統的執行,有沒有斷言都不能改變系統的行為
  • 斷言是追蹤 bug 的最後一種手段

其他好用的方法

搭配 IIFE

Imgur

使用 Object Literal

使用 Object Literal 來省去 if elseswitch 的使用:

type Fruit = 'apple' | 'orange' | 'banana' | 'strawberry';
function logFruit(fruit: Fruit) {
return {
apple: () => console.log('🍎'),
orange: () => console.log('🍊'),
strawberry: () => console.log('🍓'),
banana: () => console.log('🍌'),
}[fruit]();
}

logFruit('apple');
('🍎');

透過 Object Literal,可以把原本長這樣的程式碼:

function getRate({ price, billingInterval }: IGetRate): number | null {
let rate: number | null = null;
switch (billingInterval) {
case BillingInterval.Month: {
rate = isCustom(price.monthly) ? null : price.monthly;
break;
}
case BillingInterval.Year: {
rate = isCustom(price.yearly) ? null : price.yearly;
break;
}
default:
}
return rate;
}

改成(精簡非常多):

function getRate({ price, billingInterval }: IGetRate): number | null {
const rate = {
[BillingInterval.Month]: price.monthly,
[BillingInterval.Year]: price.yearly,
}[billingInterval];

if (isCustom(rate)) {
return null;
}
return rate;
}