24種程式碼壞味道和重構手法
24種程式碼壞味道和重構手法
最近,小李感覺公司女生們看他的眼神不太對勁了,那種笑容好像是充滿慈愛的、姨母般的笑容。
作為一名老實本分的程式設計師,小李不太習慣這種被人過度關注的感覺,他不知道發生了什麼。
······
小李和小王的關係似乎過於親密,還經常擠在一個工位上辦公,一直到半夜。
這個流言直到某天他們的聊天內容被某個運營小姐姐聽到,他們之間的祕密才被大家發現。
小李和小王的故事
“這串程式碼看起來不太好。” 小李指著螢幕,眉頭緊鎖。
“咋了,這段程式碼是我寫的,因為這裡要實現客戶的一個定製化需求,所以看起來有點複雜。” 小王湊了過來。
“我也說不出來哪裡不對勁,我現在要新增一個新特性,不知道從哪裡下手。” 小李撓了撓頭。
小王把凳子搬了過來:“來來,我給你講講,這段程式碼,這段呼叫.....”
·····
中午 12 點,辦公室的空氣中瀰漫著飯菜的香氣,還有成群結隊約飯的小夥伴,休閒區也成了乾飯的主戰場。
“噢?原來小李和小王這種叫做結對程式設計?” 運營小姐姐目光掃向還在座位上的小李小王。
“嗯...偶爾的結對程式設計是正常的,但是長期的結對程式設計說明出現了一些壞味道。”老工程師說完,端起飯盆,幹完了最後一口飯。
“oh...是那種...味道嗎?”運營小姐姐試探道。
在座一起吃飯的 HR、UI 小姐姐們被這句話又點燃了八卦之魂,發出了一陣愉快的笑聲。
“NoNoNo...老耿的意思,程式碼裡出現了壞味道。”高階工程師大寶扶了扶眼鏡。
“什麼叫程式碼裡出現了壞味道呀?”
“就是軟體架構要開始分崩離析的前兆,程式碼裡面充斥著壞味道,這些味道都散發著噁心、腐爛的訊號,提示你該重構了。”
小姐姐們眉頭一皺,不太滿意大寶在吃飯的時候講了個這麼倒胃口的例子。
老耿喝了口水,放下水杯:“阿寶說的沒錯。很顯然,小李和小王在寫程式的時候,並沒有發現程式碼中存在的壞味道,導致他們現在欠下了一大批的技術債務,正在分期還債。”
“看來是時候給他們做個培訓了,教他們如何識別程式碼中的壞味道。”
程式碼中的壞味道
小李和小王頂著兩個大黑眼圈來到會議室,老耿早已經在會議室等著了。
小李看了一眼投影儀的內容 “24 種常見的壞味道及重構手法 —— 告別加班,可持續發展
”。
“吶,給你們倆準備的咖啡,打起精神來哈。”大寶用身子推門進來,手上端著兩杯熱騰騰的咖啡,這是他剛從休息區親手煮的新鮮咖啡。
“謝謝寶哥” 小李和小王齊聲道。
“聽說你們倆最近老是一起加班到半夜。” 老耿把手從鍵盤上拿開,把視線從電腦螢幕上移到小李小王的身上。
“嘿嘿...” 小李不好意思的笑了笑 “最近需求比較難,加一些新功能比較花時間。”
“嗯,看來你們也發現了一些問題,為什麼現在加新功能花的時間會比以前多得多呢?”老耿把凳子往後挪了挪,看著兩人。
“因為產品越來越複雜了,加新功能也越來越花時間了。”小王接話道。
“對,這算是一個原因。還有嗎?”老耿接著問。
“因...因為我們之前寫的程式碼有點亂,可能互相理解起來比較花時間...” 小李撓了撓頭。
“但其實我都寫了註釋的,我覺得還是因為產品太複雜了。”小王對小李這個說法有點不認可。
“好,其實小李說到了一個關鍵問題。”老耿覺得是時候了,接著說道:“Martin Fowler 曾經說過,當代碼庫看起來就像補丁摞補丁,需要細緻的考古工作才能弄明白整個系統是如何工作的。那這份負擔會不斷拖慢新增功能的速度,到最後程式設計師恨不得從頭開始重寫整個系統。”
“我想,你們應該有很多次想重寫系統的衝動吧?”老耿笑了笑,繼續說道:“而內部質量良好的軟體可以讓我在新增新功能時,很容易找到在哪裡修改、如何修改。”
老耿頓了頓,“所以,小王說的也對。產品複雜帶來了軟體的複雜,而複雜的軟體一不小心就容易變成了補丁摞補丁。軟體程式碼中充斥著 “壞味道”,這些 “壞味道” 最終會讓軟體腐爛,然後失去對它的掌控。”
“對了,小王剛才提到的註釋,也是一種壞味道。”老耿笑了笑,看著小王,“所以,壞味道無處不在,有的壞味道比較好察覺,有的壞味道需要經過特殊訓練,才能識別。”
老耿又指了指投影儀大螢幕:“所以,我們今天是一期特殊訓練課,教會你們識別 24 種常見的壞味道及重構手法,這門課至少可以讓你們成為一個有著一些特別好的習慣的還不錯的程式設計師。”
“咳咳,你們倆賺大了”大寶補充道:“老耿這級別的大牛,在外邊上課可是要收費的喲~”
“等等,我去拿筆記本。”小李舉手示意,然後開啟門走出去,小王見狀也跟著小李出去拿筆記本了。
24 種常見的壞味道及重構手法
老耿見大家已經都做好了學習的準備,站起身走到螢幕旁邊,對著大家說道:“那我們開始吧!”
”壞味道我們剛才已經說過了,我再講個小故事吧。我這麼多年以來,看過很多很多程式碼,它們所屬的專案有大獲成功的,也有奄奄一息的。觀察這些程式碼時,我和我的老搭檔學會了從中找出某些特定結構,這些結構指出了 重構
的可能性,這些結構也就是我剛才提到的 壞味道
。”
“噢對,我剛才還提到了一個詞 —— 重構
。這聽著像是個很可怕的詞,但是我可以和你們說,我口中的 重構
並不是你們想的那種推翻重來,有本書給了這個詞一個全新的定義 —— 對軟體內部結構的一種調整,目的是在不改變軟體可觀察行為的前提下,提高其可理解性,降低其修改成本。
,你們也可以理解為是在 使用一系列重構手法,在不改變軟體可觀察行為的前提下,調整其結構。
”
“如果有人說他們的程式碼在重構過程中有一兩天時間不可用,基本上可以確定,他們在做的事不是重構。”老耿開了個玩笑:“他們可能在對程式碼施展某種治療魔法,這種魔法帶來的副作用就是會讓軟體短暫性休克。”
“在這裡,還有一點值得一提,那就是如何做到 不改變軟體可觀察行為
,這需要一些外力的協助。這裡我不建議你們再把加班的人群延伸到測試人員,我給出的方案是準備一套完備的、執行速度很快的測試套件。在絕大多數情況下,如果想要重構,就得先有一套可以自測試的程式碼。”
“接下來,我會先審查你們商城系統的程式碼,發現程式碼中存在的壞味道,然後新增單元測試,進行重構後,最後通過測試完成重構。”
“我會在這個過程中,給你們演示並講解 24 種常見的壞味道及重構手法。”
“我們開始吧!”老耿重新回到座位。
(一)神祕命名(Mysterious Name)
```js function countOrder(order) { const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
const orderPrice = countOrder(order); ```
“上面的這段 countOrder
函式是做什麼的?看到函式名的第一感覺不太清晰,統計訂單?訂單商品數量嗎?還是統計什麼?但是,看到函式內部實現後,我明白了這是個統計訂單總價格的函式。這就是其中一個壞味道 —— 神祕命名
,當代碼中這樣的壞味道多了之後,花在猜謎上的時間就會越來越多了。”
“我們現在來對這段程式碼進行重構,我們需要先新增單元測試程式碼。這裡我只做演示,寫兩個測試用例。”
老耿很快針對這段程式碼,使用著名的 jest
框架寫出了下面兩個測試用例。
```js describe('test price', () => { test('countOrder should return normal price when input correct order quantity < 500', () => { const input = { quantity: 20, itemPrice: 10 };
const result = countOrder(input);
expect(result).toBe(220);
});
test('countOrder should return discount price when input correct order quantity > 500', () => { const input = { quantity: 1000, itemPrice: 10 };
const result = countOrder(input);
expect(result).toBe(9850);
}); }); ```
老耿 運行了一下測試用例
,顯示測試通過後,說:“我們有了單元測試後,就可以開始準備重構工作了。”
“我們先把 countOrder
內部的實現提煉成新函式,命名為 getPrice
,這個名字不一定是最合適的名字,但是會比之前的要好。”老耿使用 Ide
很容易就把這一步完成了。
```js function getPrice(order) { const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
function countOrder(order) { return getPrice(order); }
const orderPrice = countOrder(order); ```
“這一步看起來沒什麼問題,但是我們還是先 執行一下測試用例
。”老耿按下了執行用例快捷鍵,用例跑了起來,並且很快就通過了測試。
“這一步說明我們的修改沒有問題,下一步我們修改測試用例,將測試用例呼叫的 countOrder
方法都修改為呼叫 getPrice
方法,再次 執行修改後的測試用例
。”
老耿指著修改後的測試用例:“再次執行後,getPrice
也通過了測試,那接下來,我們就可以把呼叫 countOrder
方法的地方,都修改為呼叫 getPrice
方法,就像這樣。”
```js function getPrice(order) { const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
function countOrder(order) { return getPrice(order); }
const orderPrice = getPrice(order); ```
“這時候我們可以看到,編輯器已經提示我們,原來的 countOrder
方法沒有被使用到,我們可以藉助 Ide 直接把這個函式刪除掉。”
“刪除後,我們的重構就完成了,新的程式碼看起來像這樣。”
```js function getPrice(order) { const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
const orderPrice = getPrice(order); ```
“嗯,這個命名看起來更合適了,一眼看過去就可以知道這是個獲取訂單價格的函式。如果哪天又想到了更好的名字,用同樣的步驟把它替換掉就好了,有單元測試就可以保證你在操作的過程中並不會出錯,引發其他的問題。”
“對了,還有最重要的一步。”老耿加重了語氣:“記得提交程式碼!”
老耿用快捷鍵提交了一個 Commit
記錄,繼續說道:“每一次重構完成都應該提交程式碼,這樣就可以在下一次重構出現問題的時候,迅速回退到上一次正常工作時的狀態,這一點很有用!“
大寶補充到:“這樣的重構還有個好處,那就是可以保證程式碼隨時都是可釋出的狀態,因為並沒有影響到整體功能的執行。”
“不過想一個合適的好名字確實不容易,耿哥的意思是讓我們要持續的小步的進行重構吧。”小王摸著下巴思考說道。
“阿寶和小王說的都對,在不改變軟體可觀察行為的前提下,持續小步的重構,保證軟體隨時都處於可釋出的狀態。這意味著我們隨時都可以進行重構,最簡單的重構,比如我剛才演示的那種用不了幾分鐘,而最長的重構也不該超過幾小時。”
“我再補充一點。”大寶說道:“我們絕對不能忽視自動化測試,只有自動化測試才能保證在重構的過程中不改變軟體可觀察行為,這一點看似不起眼,卻是最最重要的關鍵之處。”
“阿寶說的沒錯,我們至少要保證我們重構的地方有單元測試,且能通過單元測試,才能算作是重構完成。”
老耿稍作停頓後,等待大家理解自己剛才的那段話後,接著說:“看來大家都開始感受到了重構的魅力,我們最後看看這段程式碼重構前後的對比。”
“ok,那我們接著說剩下的壞味道吧。”
(二)重複程式碼(Repeat Code)
``js
function renderPerson(person) {
const result = [];
result.push(
${person.name}
);
result.push(
title: ${person.photo.title}
`); result.push(emitPhotoData(person.photo)); return result.join('\n'); }function photoDiv(photo) { return ['
<p>title: ${photo.title}</p>
, emitPhotoData(photo), 'function emitPhotoData(aPhoto) {
const result = [];
result.push(<p>location: ${aPhoto.location}</p>
);
result.push(<p>date: ${aPhoto.date}</p>
);
return result.join('\n');
}
```
“嗯,這段代乍一看是沒有什麼問題的,應該是用來渲染個人資料介面的。但是我們仔細看的話,會發現 renderPerson
方法和 photoDiv
中有一個同樣的實現,那就是渲染 photo.title
的部分。這一部分的邏輯總是在執行 emitPhotoData
函式的前面,這是一段重複程式碼。”
“雖然這是一段看似無傷大雅的重複程式碼,但是要記住,一旦有重複程式碼存在,閱讀這些重複的程式碼時你就必須加倍仔細,留意其間細微的差異。如果要修改重複程式碼,你必須找出所有的副本來修改,這一點讓人在閱讀和修改程式碼時都很容易出現紕漏。”
“所以,我們就挑這一段程式碼來進行重構。按照慣例,我先寫兩個單元測試用例。”老耿開始寫用例。
```js describe('test render', () => { test('renderPerson should return correct struct when input correct struct', () => { const input = { name: 'jack', photo: { title: 'travel', location: 'tokyo', date: '2021-06-08' } };
const result = renderPerson(input);
expect(result).toBe(`<p>jack</p>\n<p>title: travel</p>\n<p>location: tokyo</p>\n<p>date: 2021-06-08</p>`);
});
test('photoDiv should return correct struct when input correct struct', () => { const input = { title: 'adventure', location: 'india', date: '2021-01-08' };
const result = photoDiv(input);
expect(result).toBe(`<div>\n<p>title: adventure</p>\n<p>location: india</p>\n<p>date: 2021-01-08</p>\n</div>`);
}); }); ```
“我們先執行測試一下我們的測試用例是否能通過吧。“老耿按下了執行快捷鍵。
“ok,測試通過,記得提交一個 Commit
,儲存我們的測試程式碼。接下來,我們準備開始重構,這個函式比較簡單,我們可以直接把那一行重複的程式碼移動到 emitPhotoData
函式中。但是這次我們還是要演示一下風險較低的一種重構手法,防止出錯。“老耿說完,把 emitPhotoDataNew
ctrl c + ctrl v,在複製的函式體內稍作修改,完成了組裝。
js
function emitPhotoDataNew(aPhoto) {
const result = [];
result.push(`<p>title: ${aPhoto.title}</p>`);
result.push(`<p>location: ${aPhoto.location}</p>`);
result.push(`<p>date: ${aPhoto.date}</p>`);
return result.join('\n');
}
“然後,我們把 renderPerson
和 photoDiv
內部呼叫的方法,都換成 emitPhotoDataNew
新方法,如果再穩妥一點的話,最好是換一個函式執行一次測試用例。”
``js
function renderPerson(person) {
const result = [];
result.push(
${person.name}
`); result.push(emitPhotoDataNew(person.photo)); return result.join('\n'); }function photoDiv(photo) { return ['
function emitPhotoData(aPhoto) {
const result = [];
result.push(<p>location: ${aPhoto.location}</p>
);
result.push(<p>date: ${aPhoto.date}</p>
);
return result.join('\n');
}
function emitPhotoDataNew(aPhoto) {
const result = [];
result.push(<p>title: ${aPhoto.title}</p>
);
result.push(<p>location: ${aPhoto.location}</p>
);
result.push(<p>date: ${aPhoto.date}</p>
);
return result.join('\n');
}
```
“替換完成後,執行測試用例,看看效果。”
“ok,測試通過,說明重構並沒有產生什麼問題,接下來把原來的 emitPhotoData
安全刪除,然後把 emitPhotoDataNew
重新命名為 emitPhotoData
,重構就完成了!”
``js
function renderPerson(person) {
const result = [];
result.push(
${person.name}
`); result.push(emitPhotoData(person.photo)); return result.join('\n'); }function photoDiv(photo) { return ['
function emitPhotoData(aPhoto) {
const result = [];
result.push(<p>title: ${aPhoto.title}</p>
);
result.push(<p>location: ${aPhoto.location}</p>
);
result.push(<p>date: ${aPhoto.date}</p>
);
return result.join('\n');
}
```
“修改完後,別忘了執行測試用例。”老耿每次修改完成後執行測試用例的動作,似乎已經形成了肌肉記憶。
“ok,測試通過。這次重構完成了,提交一個 Commit
,再看一下修改前後的對比。”
“我們繼續看下一個壞味道。”
(三)過長函式(Long Function)
js
function printOwing(invoice) {
let outstanding = 0;
console.log('***********************');
console.log('**** Customer Owes ****');
console.log('***********************');
// calculate outstanding
for (const o of invoice.orders) {
outstanding += o.amount;
}
// record due date
const today = new Date(Date.now());
invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
//print details
console.log(`name: ${invoice.customer}`);
console.log(`amount: ${outstanding}`);
console.log(`due: ${invoice.dueDate.toLocaleDateString()}`);
}
“嗯,這個函式看起來是用來列印使用者白條資訊的。這個函式實現細節和命名方面倒是沒有太多問題,但是這裡有一個壞味道,那就是 —— 過長的函式。”
“函式越長,就越難理解。而更好的闡釋力、更易於分享、更多的選擇——都是由小函式來支援的。”
“對於識別這種壞味道,有一個技巧。那就是,如果你需要花時間瀏覽一段程式碼才能弄清它到底在幹什麼,那麼就應該將其提煉到一個函式中,並根據它所做的事為其命名。”
“像這個函式就是那種需要花一定時間瀏覽才能弄清楚在做什麼的,不過好在這個函式還有些註釋。”
“還是老方法,我們先寫兩個測試用例。”老耿開始敲程式碼。
```js describe('test printOwing', () => { let collections = []; console.log = message => { collections.push(message); };
afterEach(() => { collections = []; });
test('printOwing should return correct struct when input correct struct', () => { const input = { customer: 'jack', orders: [{ amount: 102 }, { amount: 82 }, { amount: 87 }, { amount: 128 }] };
printOwing(input);
const today = new Date(Date.now());
const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30).toLocaleDateString();
expect(collections).toStrictEqual([
'***********************',
'**** Customer Owes ****',
'***********************',
'name: jack',
'amount: 399',
'due: ' + dueDate
]);
});
test('printOwing should return correct struct when input correct struct 2', () => { const input = { customer: 'dove', orders: [{ amount: 63 }, { amount: 234 }, { amount: 12 }, { amount: 1351 }] };
printOwing(input);
const today = new Date(Date.now());
const dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30).toLocaleDateString();
expect(collections).toStrictEqual([
'***********************',
'**** Customer Owes ****',
'***********************',
'name: dove',
'amount: 1660',
'due: ' + dueDate
]);
}); }); ```
“測試用例寫完以後執行一下。“
“接下來的提取步驟就很簡單了,因為程式碼本身是有註釋的,我們只需要參考註釋的節奏來進行提取就好了,依舊是小步慢跑,首先調整函式執行的順序,將函式分層。”
```js function printOwing(invoice) { // print banner console.log('***'); console.log(' Customer Owes '); console.log('***');
// calculate outstanding let outstanding = 0; for (const o of invoice.orders) { outstanding += o.amount; }
// record due date const today = new Date(Date.now()); invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
// print details
console.log(name: ${invoice.customer}
);
console.log(amount: ${outstanding}
);
console.log(due: ${invoice.dueDate.toLocaleDateString()}
);
}
```
“進行函式分層後,先執行一遍測試用例,防止調整順序的過程中,影響了函式功能... ok,測試通過了。”
“被調整順序後的函式就變得非常簡單了,接下來我們分四步提取就可以了”
“第一步,提煉 printBanner
函式,然後執行測試用例。”
```js function printBanner() { console.log('***'); console.log(' Customer Owes '); console.log('***'); }
function printOwing(invoice) { printBanner();
// calculate outstanding let outstanding = 0; for (const o of invoice.orders) { outstanding += o.amount; }
// record due date const today = new Date(Date.now()); invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
// print details
console.log(name: ${invoice.customer}
);
console.log(amount: ${outstanding}
);
console.log(due: ${invoice.dueDate.toLocaleDateString()}
);
}
```
“我們在提取的過程中,把註釋也去掉了,因為確實不需要了,函式名和註釋的內容一樣。”
“第二步,提煉 calOutstanding
函式 ,然後執行測試用例。”
```js function printBanner() { console.log('***'); console.log(' Customer Owes '); console.log('***'); }
function calOutstanding(invoice) { let outstanding = 0; for (const o of invoice.orders) { outstanding += o.amount; } return outstanding; }
function printOwing(invoice) { printBanner();
let outstanding = calOutstanding(invoice);
// record due date const today = new Date(Date.now()); invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);
// print details
console.log(name: ${invoice.customer}
);
console.log(amount: ${outstanding}
);
console.log(due: ${invoice.dueDate.toLocaleDateString()}
);
}
```
“第三步,提煉 recordDueDate
函式,然後執行測試用例。”
```js function printBanner() { console.log('***'); console.log(' Customer Owes '); console.log('***'); }
function calOutstanding(invoice) { let outstanding = 0; for (const o of invoice.orders) { outstanding += o.amount; } return outstanding; }
function recordDueDate(invoice) { const today = new Date(Date.now()); invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30); }
function printOwing(invoice) { printBanner();
let outstanding = calOutstanding(invoice);
recordDueDate(invoice);
// print details
console.log(name: ${invoice.customer}
);
console.log(amount: ${outstanding}
);
console.log(due: ${invoice.dueDate.toLocaleDateString()}
);
}
```
“第四步,提煉 printDetails
函式,然後執行測試用例。”
```js function printBanner() { console.log('***'); console.log(' Customer Owes '); console.log('***'); }
function calOutstanding(invoice) { let outstanding = 0; for (const o of invoice.orders) { outstanding += o.amount; } return outstanding; }
function recordDueDate(invoice) { const today = new Date(Date.now()); invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30); }
function printDetails(invoice, outstanding) {
console.log(name: ${invoice.customer}
);
console.log(amount: ${outstanding}
);
console.log(due: ${invoice.dueDate.toLocaleDateString()}
);
}
function printOwing(invoice) { printBanner(); let outstanding = calOutstanding(invoice); recordDueDate(invoice); printDetails(invoice, outstanding); } ```
“測試用例通過後,別忘了提交程式碼。”
“然後我們再來審視一下這個重構後的 printOwing
函式,簡單的四行程式碼,清晰的描述了函式所做的事情,這就是小函式的魅力!”
“說到底,讓小函式易於理解的關鍵還是在於良好的命名。如果你能給函式起個好名字,閱讀程式碼的人就可以通過名字瞭解函式的作用,根本不必去看其中寫了些什麼。這可以節約大量的時間,也能減少你們結對程式設計的時間。”老耿面帶微笑看著小李和小王。
小李小王相視一笑,覺得有點不好意思。
“我們來看看重構前後的對比。”
“我們繼續。”老耿馬不停蹄。
(四)過長引數列表(Long Parameter List)
```js // range.js function priceRange(products, min, max, isOutSide) { if (isOutSide) { return products .filter(r => r.price < min || r.price > max); } else { return products .filter(r => r.price > min && r.price < max); } }
// a.js const range = { min: 1, max: 10 } const outSidePriceProducts = priceRange( [ / ... / ], range.min, range.max, true )
// b.js const range = { min: 5, max: 8 } const insidePriceProducts = priceRange( [ / ... / ], range.min, range.max, false ) ```
“第一眼看過去,priceRange
是過濾商品的函式,仔細看的話會發現,主要比對的是 product.price
欄位和傳入的引數 min
與 max
之間的大小對比關係。如果 isOutSide
為 true
的話,則過濾出價格區間之外的商品,否則過濾出價格區間之內的商品。”
“第一眼看過去,這個函式的引數實在是太多了,這會讓客戶端呼叫方感到很疑惑。還好引數 isOutSide
的命名還算不錯,不然這個函式會看起來更深奧。”
小李忍不住插了句:“我每次呼叫 priceRange
函式的時候都要去看一眼這個函式的實現,我老是忘記最後一個引數的規則。”
“我和小李的看法是一樣的。”老耿點了點頭:“我也不喜歡標記引數,因為它們讓人難以理解到底有哪些函式可以呼叫、應該怎麼呼叫。使用這樣的函式,我還得弄清標記引數有哪些可用的值。”
“既然小李也已經發現這個問題了,那我們就從這個 isOutSide
引數下手,進行優化。老規矩,我們先針對現有的程式碼寫兩個測試用例。” 老耿開始寫程式碼。
```js describe('test priceRange', () => { test('priceRange should return correct result when input correct outside conditional', () => { const products = [ { name: 'apple', price: 6 }, { name: 'banana', price: 7 }, { name: 'orange', price: 15 }, { name: 'cookie', price: 0.5 } ]; const range = { min: 1, max: 10 }; const isOutSide = true;
const result = priceRange(products, range.min, range.max, isOutSide);
expect(result).toStrictEqual([
{ name: 'orange', price: 15 },
{ name: 'cookie', price: 0.5 }
]);
});
test('priceRange should return correct result when input correct inside conditional', () => { const products = [ { name: 'apple', price: 6 }, { name: 'banana', price: 7 }, { name: 'orange', price: 15 }, { name: 'cookie', price: 0.5 } ]; const range = { min: 5, max: 8 }; const isOutSide = false;
const result = priceRange(products, range.min, range.max, isOutSide);
expect(result).toStrictEqual([
{ name: 'apple', price: 6 },
{ name: 'banana', price: 7 }
]);
}); }); ```
“執行一下單元測試...嗯,是可以通過的。那接下來就可以進行引數精簡了,我們先把剛才小李提的那個問題解決,就是標記引數,我們針對 priceRange
再提煉兩個函式。”
“我們先修改我們的單元測試程式碼,按我們期望呼叫的方式修改。”
```js const priceRange = require('./long_parameter_list');
describe('test priceRange', () => { test('priceOutSideRange should return correct result when input correct outside conditional', () => { const products = [ { name: 'apple', price: 6 }, { name: 'banana', price: 7 }, { name: 'orange', price: 15 }, { name: 'cookie', price: 0.5 } ]; const range = { min: 1, max: 10 };
const result = priceOutSideRange(products, range.min, range.max);
expect(result).toStrictEqual([
{ name: 'orange', price: 15 },
{ name: 'cookie', price: 0.5 }
]);
});
test('priceInsideRange should return correct result when input correct inside conditional', () => { const products = [ { name: 'apple', price: 6 }, { name: 'banana', price: 7 }, { name: 'orange', price: 15 }, { name: 'cookie', price: 0.5 } ]; const range = { min: 5, max: 8 };
const result = priceInsideRange(products, range.min, range.max);
expect(result).toStrictEqual([
{ name: 'apple', price: 6 },
{ name: 'banana', price: 7 }
]);
}); }); ```
“我把 priceRange
的 isOutSide
標記引數移除了,並且使用 priceOutsideRange
和 priceInsideRange
兩個方法來實現原有的功能。這時候還不能執行測試用例,因為我們的程式碼還沒改呢。同樣的,把程式碼調整成符合用例呼叫的方式。”
```js function priceRange(products, min, max, isOutSide) { if (isOutSide) { return products.filter(r => r.price < min || r.price > max); } else { return products.filter(r => r.price > min && r.price < max); } }
function priceOutSideRange(products, min, max) { return priceRange(products, min, max, true); }
function priceInsideRange(products, min, max) { return priceRange(products, min, max, false); } ```
“程式碼調整完成後,我們來執行一下測試用例。好的,通過了!”
“嗯,我想到這裡以後,可以更進一步,把 priceRange
的函式進一步抽離,就像這樣。”
```js function priceRange(products, min, max, isOutSide) { if (isOutSide) { return products.filter(r => r.price < min || r.price > max); } else { return products.filter(r => r.price > min && r.price < max); } }
function priceOutSideRange(products, min, max) { return products.filter(r => r.price < min || r.price > max); }
function priceInsideRange(products, min, max) { return products.filter(r => r.price > min && r.price < max); } ```
“拆解完成後,記得執行一下測試用例... ok,通過了”
“在測試用例通過後,就可以開始準備遷移工作了。把原來呼叫 priceRange
的地方替換成新的呼叫,然後再把 priceRange
函式安全刪除,就像這樣。”
```js // range.js function priceOutSideRange(products, min, max) { return products.filter(r => r.price < min || r.price > max); }
function priceInsideRange(products, min, max) { return products.filter(r => r.price > min && r.price < max); }
// a.js const range = { min: 1, max: 10 } const outSidePriceProducts = priceOutSideRange( [ / ... / ], range.min, range.max )
// b.js const range = { min: 5, max: 8 } const insidePriceProducts = priceInsideRange( [ / ... / ], range.min, range.max ) ```
“這麼做以後,原來讓人疑惑的標記引數就被移除了,取而代之的是兩個語義更加清晰的函式。”
“接下來,我們要繼續做一件有價值的重構,那就是將資料組織成結構,因為這樣讓資料項之間的關係變得明晰。比如 range
的 min
和 max
總是在呼叫中被一起使用,那這兩個引數就可以組織成結構。我先修改我的測試用例以適應最新的改動,就像這樣。”
```js //... const range = { min: 1, max: 10 };
const result = priceOutSideRange(products, range);
expect(result).toStrictEqual([ { name: 'orange', price: 15 }, { name: 'cookie', price: 0.5 } ]);
//... const range = { min: 5, max: 8 };
const result = priceInsideRange(products, range);
expect(result).toStrictEqual([ { name: 'apple', price: 6 }, { name: 'banana', price: 7 } ]); ```
“測試用例修改完成後,來修改一下我們的函式。”
```js // range.js function priceOutSideRange(products, range) { return products.filter(r => r.price < range.min || r.price > range.max); }
function priceInsideRange(products, range) { return products.filter(r => r.price > range.min && r.price < range.max); }
// a.js const range = { min: 1, max: 10 } const outSidePriceProducts = priceOutSideRange( [ / ... / ], range )
// b.js const range = { min: 5, max: 8 } const insidePriceProducts = priceInsideRange( [ / ... / ], range ) ```
“修改完成後,執行我們的測試用例,順利通過,別忘了提交程式碼。”說完,老耿打了個 Commit
。
“這一步重構又精簡了一個引數,這是這項重構最直接的價值。而這項重構真正的意義在於,它會催生程式碼中更深層次的改變。一旦識別出新的資料結構,我就可以重組程式的行為來使用這些結構。這句話實際應用起來是什麼意思呢?我還是拿這個案例來舉例。”
“我們會發現 priceOutSideRange
和 priceInsideRange
的函式命名已經足夠清晰,但是內部對 range
範圍的判定還是需要花費一定時間理解,而 range
作為我們剛識別出來的一種結構,可以繼續進行重構,就像這樣。”
```js // range.js class Range { constructor(min, max) { this._min = min; this._max = max; }
outside(num) { return num < this._min || num > this._max; }
inside(num) { return num > this._min && num < this._max; } }
function priceOutSideRange(products, range) { return products.filter(r => range.outside(r.price)); }
function priceInsideRange(products, range) { return products.filter(r => range.inside(r.price)); }
// a.js const outSidePriceProducts = priceOutSideRange( [ / ... / ], new Range(1, 10) )
// b.js const insidePriceProducts = priceInsideRange( [ / ... / ], new Range(5, 8) ) ```
“修改測試用例也傳入 Range
物件,然後執行測試用例...ok,通過了。測試通過後再提交程式碼。”
“這樣一來,讓 priceOutSideRange
和 priceInsideRange
函式內部也更加清晰了。同時,range
被組織成了一種新的資料結構,這種結構可以在任何計算區間的地方使用。”
“我們來看看重構前後的對比。”
“我們繼續。”
(五)全域性資料(Global Data)
```js // global.js // ... let userAuthInfo = { platform: 'pc', token: '' }
export { userAuthInfo };
// main.js userAuthInfo.token = localStorage.token;
// request.js const reply = await login(); userAuthInfo.token = reply.data.token;
// business.js await request({ authInfo: userAuthInfo }); ```
“這個 global.js
似乎是用來提供全域性資料的,這是最刺鼻的壞味道之一了。”
“這個 platform
被全域性都使用到了,我可以把它修改為別的值嗎?會引發什麼問題嗎?”老耿問道
小李連忙說:“這個 platform
不能改,後端要靠這個欄位來選擇識別 token
的方式,改了就會出問題。”
“但是我現在可以在程式碼庫的任何一個角落都可以修改 platform
和 token
,而且沒有任何機制可以探測出到底哪段程式碼做出了修改,這就是全域性資料的問題。”
“每當我們看到可能被各處的程式碼汙染的資料,我們還是需要全域性資料用一個函式包裝起來,至少你就能看見修改它的地方,並開始控制對它的訪問,這裡我做個簡單的封裝,然後再寫兩個測試用例。”
```js let userAuthInfo = { platform: 'pc', token: '' };
function getUserAuthInfo() { return { ...userAuthInfo }; }
function setToken(token) { userAuthInfo.token = token; }
export { getUserAuthInfo, setToken }
// main.js setToken(localStorage.token);
// request.js const reply = await login(); setToken(reply.data.token);
// business.js await request({ authInfo: getUserAuthInfo() }); ```
“接下來執行一下測試用例。”
```js describe("test global data", () => { test('getUserAuthInfo.platform should return pc when modify reference', () => { const userAuthInfo = getUserAuthInfo(); userAuthInfo.platform = 'app';
const result = getUserAuthInfo().platform;
expect(result).toBe('pc');
});
test('getUserInfo.token should return test-token when setToken test-token', () => {
setToken('test-token');
const result = getUserAuthInfo().token;
expect(result).toBe('test-token');
});
}); ```
“這樣一來,通過物件引用就無法修改源物件了,並且我這裡控制了對 platform
屬性的修改,只開放對 token
修改的介面。即便如此,我們還是要儘可能的避免全域性資料,因為全域性資料是最刺鼻的壞味道之一!”老耿語氣加重。
小李小王瘋狂點頭。
“我們來看一下重構前後的對比。”
“那我們繼續。”
(六)可變資料(Mutable Data)
js
function merge(target, source) {
for (const key in source) {
target[key] = source[key];
}
return target;
}
“這個函式好像有點古老。”老耿有些疑惑。
“這個是我從之前的倉庫 copy 過來的一個工具函式,用來合成物件的,一直沒改過。”小王補充道。
“嗯,這個函式的問題是對 merge
物件的源物件 target
進行了修改,對資料的修改經常導致出乎意料的結果和難以發現的 bug。現在來看程式並沒有因為這個函數出現問題,但如果故障只在很罕見的情況下發生,要找出故障原因就會更加困難。”
“先寫兩個測試用例來進行驗證吧。”老耿開始寫程式碼。
```js describe('test merge', () => { test('test merge should return correct struct when merge', () => { const baseConfig = { url: 'https://api.com', code: 'mall' };
const testSpecialConfig = {
url: 'https://test-api.com',
code: 'test-mall'
};
const result = merge(baseConfig, testSpecialConfig);
expect(result).toStrictEqual({
url: 'https://test-api.com',
code: 'test-mall'
});
});
test('test merge should return original struct when merge', () => { const baseConfig = { url: 'https://api.com', code: 'mall' };
const testSpecialConfig = {
url: 'https://test-api.com',
code: 'test-mall'
};
merge(baseConfig, testSpecialConfig);
expect(baseConfig).toStrictEqual({
url: 'https://api.com',
code: 'mall'
});
}); }); ```
“執行一下... 第二個用例報錯了。”
“報錯的原因就是因為對源物件進行了修改調整,從而影響了 baseConfig
的值。接下來我們調整一下 merge
函式就行了,現在 javascript
有很簡單的方法可以修改這個函式。”
js
function merge(target, source) {
return {
...target,
...source
}
}
“修改完成後,再次執行用例,就可以看到用例執行通過了。”
“我剛才的重構手法其實有一整個軟體開發流派 —— 函數語言程式設計,就是完全建立在“資料永不改變”的概念基礎上:如果要更新一個數據結構,就返回一份新的資料副本,舊的資料仍保持不變,這樣可以避免很多因資料變化而引發的問題。”
“在剛才介紹全域性資料時用到的封裝變數的方法,也是對可變資料這種壞味道常見的一種解決方案。還有,如果可變資料的值能在其他地方計算出來,這就是一個特別刺鼻的壞味道。它不僅會造成困擾、bug和加班,而且毫無必要。”
“這裡我就不做展開了,如果你們倆感興趣的話,可以去看看《重構:改善既有程式碼的設計(第2版)》這本書,我剛才提到的壞味道,書裡面都有。”
小李小王奮筆疾書,把書名記了下來。
“我們來看一下重構前後的對比。”
“那我們繼續。”
(七)發散式變化(Divergent Change)
```js function getPrice(order) { const basePrice = order.quantity * order.itemPrice; const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; const shipping = Math.min(basePrice * 0.1, 100); return basePrice - quantityDiscount + shipping; }
const orderPrice = getPrice(order); ```
“這個函式是我們最早重構的函式,它的職責就是計算基礎價格 - 數量折扣 + 運費。我們再來看看這個函式,如果基礎價格計算規則改變,需要修改這個函式,如果折扣規則發生改變也需要修改這個函式,同理,運費計算規則也會引發它的改變。”
“如果某個模組經常因為不同的原因在不同的方向上發生變化,發散式變化就出現了。”
“測試用例已經有了,所以我們可以直接對函式進行重構。”老耿開始寫程式碼。
```js function calBasePrice(order) { return order.quantity * order.itemPrice; }
function calDiscount(order) { return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05; }
function calShipping(basePrice) { return Math.min(basePrice * 0.1, 100); }
function getPrice(order) { return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order)); }
const orderPrice = getPrice(order); ```
“修改完成後,我們執行之前寫的測試用例... 測試通過了。”
“修改之後的三個函式只需要關心自己職責方向的變化就可以了,而不是一個函式關注多個方向的變化。並且,單元測試粒度還可以寫的更細一點,這樣對排查問題的效率也有很大的提升。”
大寶適時補充了一句:“其實這就是面向物件設計原則中的 單一職責原則
。”
“阿寶說的沒錯,keep simple,每次只關心一個上下文
這一點一直很重要。”
“我們來看一下重構前後的對比。”
“我們繼續。”
(八)霰彈式修改(Shotgun Surgery)
```js // File Reading.js const reading = {customer: "ivan", quantity: 10, month: 5, year: 2017}; function acquireReading() { return reading }; function baseRate(month, year) { / / }
// File 1 const aReading = acquireReading(); const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;
// File 2 const aReading = acquireReading(); const base = (baseRate(aReading.month, aReading.year) * aReading.quantity); const taxableCharge = Math.max(0, base - taxThreshold(aReading.year)); function taxThreshold(year) { / / }
// File 3 const aReading = acquireReading(); const basicChargeAmount = calculateBaseCharge(aReading); function calculateBaseCharge(aReading) { return baseRate(aReading.month, aReading.year) * aReading.quantity; } ```
“接下來要說的就是 發散式變化
的反例 —— 霰彈式修改
。這個要找一個軟體裡的案例需要花點時間,這裡我直接拿《重構》原書的一個例子來作講解。”
“其實這個問題和重複程式碼有點像,重複程式碼經常會引起霰彈式修改的問題。”
“像上面的演示程式碼,如果 reading
的部分邏輯發生了改變,對這部分邏輯的修改需要跨越好幾個檔案調整。”
“如果每遇到某種變化,你都必須在許多不同的類或檔案內做出許多小修改,你所面臨的壞味道就是霰彈式修改。如果需要修改的程式碼散佈四處,你不但很難找到它們,也很容易錯過某個重要的修改。”
“這裡我來對這段程式碼進行重構,由於原始碼也不是很完整,這裡我只把修改的思路提一下,就不寫測試程式碼了。”老耿開始寫程式碼。
```js // File Reading.js class Reading { constructor(data) { this._customer = data.customer; this._quantity = data.quantity; this._month = data.month; this._year = data.year; }
get customer() { return this._customer; }
get quantity() { return this._quantity; }
get month() { return this._month; }
get year() { return this._year; }
get baseRate() { / ... / }
get baseCharge() { return baseRate(this.month, this.year) * this.quantity; }
get taxableCharge() { return Math.max(0, base - taxThreshold()); }
get taxThreshold() { / ... / } }
const reading = new Reading({ customer: 'ivan', quantity: 10, month: 5, year: 2017 }); ```
“在修改完成後,所有和 reading
相關的邏輯都放在一起管理了,並且我把它組合成一個類以後還有一個好處。那就是類能明確地給這些函式提供一個共用的環境,在物件內部呼叫這些函式可以少傳許多引數,從而簡化函式呼叫,並且這樣一個物件也可以更方便地傳遞給系統的其他部分。”
“如果你們在編碼過程中,有遇到我剛才提到的那些問題,那就是一種壞味道。下次就可以用類似的重構手法進行重構了,當然,別忘了寫測試用例。”老耿對著小李二人說道。
小李小王瘋狂點頭。
“我們來看一下重構前後的對比。”
“那我們繼續。”
(九)依戀情節(Feature Envy)
```js class Account { constructor(data) { this._name = data.name; this._type = data.type; }
get loanAmount() { if (this._type.type === 'vip') { return 20000; } else { return 10000; } } }
class AccountType { constructor(type) { this._type = type; }
get type() { return this._type; } } ```
“這段程式碼是賬戶 Account
和賬戶型別 AccountType
,如果賬戶的型別是 vip
,貸款額度 loanAmount
就有 20000,否則就只有 10000。”
“在獲取貸款額度時,Account
內部的 loanAmount
方法和另一個類 AccountType
的內部資料交流格外頻繁,遠勝於在自己所處模組內部的交流,這就是依戀情結的典型情況。”
“我們先寫兩個測試用例吧。”老耿開始寫程式碼。
```js describe('test Account', () => { test('Account should return 20000 when input vip type', () => { const input = { name: 'jack', type: new AccountType('vip') };
const result = new Account(input).loanAmount;
expect(result).toBe(20000);
});
test('Account should return 20000 when input normal type', () => { const input = { name: 'dove', type: new AccountType('normal') };
const result = new Account(input).loanAmount;
expect(result).toBe(10000);
}); }); ```
“測試用例可以直接執行... ok,通過了。”
“接下來,我們把 loanAmount
搬移到真正屬於它的地方。”
```js class Account { constructor(data) { this._name = data.name; this._type = data.type; }
get loanAmount() { return this._type.loanAmount; } }
class AccountType { constructor(type) { this._type = type; }
get type() { return this._type; }
get loanAmount() { if (this.type === 'vip') { return 20000; } else { return 10000; } } } ```
“在搬移完成後,loanAmount
訪問的都是自身模組的資料,不再依戀其他模組。我們執行一下測試用例。”
“ok,測試通過了,別忘了提交程式碼。”老耿提交了一個 commit。
“我們來看一下重構前後的對比。”
“我們繼續下一個。”
(十)資料泥團(Data Clumps)
```js class Person { constructor(name) { this._name = name; }
get name() { return this._name; }
set name(arg) { this._name = arg; }
get telephoneNumber() {
return (${this.officeAreaCode}) ${this.officeNumber}
;
}
get officeAreaCode() { return this._officeAreaCode; }
set officeAreaCode(arg) { this._officeAreaCode = arg; }
get officeNumber() { return this._officeNumber; }
set officeNumber(arg) { this._officeNumber = arg; } }
const person = new Person('jack');
person.officeAreaCode = '+86';
person.officeNumber = 18726182811;
console.log(person's name is ${person.name}, telephoneNumber is ${person.telephoneNumber}
);
// person's name is jack, telephoneNumber is (+86) 18726182811
```
“這個 Person 類記錄了使用者的名字(name),電話區號(officeAreaCode)和電話號碼(officeNumber),這裡有一個不是很刺鼻的壞味道。”
“如果我把 officeNumber
欄位刪除,那 officeAreaCode
就失去了意義。這說明這兩個欄位總是一起出現的,除了 Person
類,其他用到電話號碼的地方也是會出現這兩個欄位的組合。”
“這個壞味道叫做資料泥團,主要體現在資料項喜歡成群結隊地待在一塊兒。你常常可以在很多地方看到相同的三四項資料:兩個類中相同的欄位、許多函式簽名中相同的引數,這些總是綁在一起出現的資料真應該擁有屬於它們自己的物件。”
“老規矩,我們先寫兩個測試用例。”老耿開始寫程式碼
```js describe('test Person', () => { test('person.telephoneNumber should return (+86) 18726182811 when input correct struct', () => { const person = new Person('jack'); person.officeAreaCode = '+86'; person.officeNumber = 18726182811;
const result = person.telephoneNumber;
expect(person.officeAreaCode).toBe('+86');
expect(person.officeNumber).toBe(18726182811);
expect(result).toBe('(+86) 18726182811');
});
test('person.telephoneNumber should return (+51) 15471727172 when input correct struct', () => { const person = new Person('jack'); person.officeAreaCode = '+51'; person.officeNumber = 15471727172;
const result = person.telephoneNumber;
expect(person.officeAreaCode).toBe('+51');
expect(person.officeNumber).toBe(15471727172);
expect(result).toBe('(+51) 15471727172');
}); }); ```
“執行一下測試用例... ok,測試通過了,準備開始重構了。”
“我們先新建一個 TelephoneNumber
類,用於分解 Person
類所承擔的責任。”
```js class TelephoneNumber { constructor(areaCode, number) { this._areaCode = areaCode; this._number = number; }
get areaCode() { return this._areaCode; }
get number() { return this._number; }
toString() {
return (${this._areaCode}) ${this._number}
;
}
}
```
“這時候,我們再調整一下我們的 Person
類,使用新的資料結構。”
```js class Person { constructor(name) { this._name = name; this._telephoneNumber = new TelephoneNumber(); }
get name() { return this._name; }
set name(arg) { this._name = arg; }
get telephoneNumber() { return this._telephoneNumber.toString(); }
get officeAreaCode() { return this._telephoneNumber.areaCode; }
set officeAreaCode(arg) { this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber); }
get officeNumber() { return this._telephoneNumber.number; }
set officeNumber(arg) { this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg); } } ```
“重構完成,我們執行測試程式碼。”
“測試用例執行通過了,別忘了提交程式碼。”
“在這裡我選擇新建一個類,而不是簡單的記錄結構,是因為一旦擁有新的類,你就有機會讓程式散發出一種芳香。得到新的類以後,你就可以著手尋找其他壞味道,例如“依戀情節”,這可以幫你指出能夠移至新類中的種種行為。這是一種強大的動力:有用的類被創建出來,大量的重複被消除,後續開發得以加速,原來的資料泥團終於在它們的小社會中充分發揮價值。”
“比如這裡,TelephoneNumber
類被提煉出來後,就可以去消滅那些使用到 telephoneNumber
的重複程式碼,並且根據使用情況進一步優化,我就不做展開了。”
“我們來看一下重構前後的對比。”
“我們繼續講下一個壞味道。”
(十一)基本型別偏執(Primitive Obsession)
```js class Product { constructor(data) { this._name = data.name; this._price = data.price; / ... / }
get name() { return this.name; }
/ ... /
get price() {
return ${this.priceCount} ${this.priceSuffix}
;
}
get priceCount() { return parseFloat(this._price.slice(1)); }
get priceUnit() { switch (this._price.slice(0, 1)) { case '¥': return 'cny'; case '$': return 'usd'; case 'k': return 'hkd'; default: throw new Error('un support unit'); } }
get priceCnyCount() { switch (this.priceUnit) { case 'cny': return this.priceCount; case 'usd': return this.priceCount * 7; case 'hkd': return this.priceCount * 0.8; default: throw new Error('un support unit'); } }
get priceSuffix() { switch (this.priceUnit) { case 'cny': return '元'; case 'usd': return '美元'; case 'hkd': return '港幣'; default: throw new Error('un support unit'); } } } ```
“我們來看看這個 Product
(產品)類,大家應該也看出來了這個類的一些壞味道,price
欄位作為一個基本型別,在 Product
類中被各種轉換計算,然後輸出不同的格式,Product
類需要關心 price
的每一個細節。”
“在這裡,price
非常值得我們為它建立一個屬於它自己的基本型別 - Price
。”
“在重構之前,先把測試用例覆蓋完整。”老耿開始寫程式碼。
```js describe('test Product price', () => { const products = [ { name: 'apple', price: '$6' }, { name: 'banana', price: '¥7' }, { name: 'orange', price: 'k15' }, { name: 'cookie', price: '$0.5' } ];
test('Product.price should return correct price when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price);
expect(result).toStrictEqual(['6 美元', '7 元', '15 港幣', '0.5 美元']);
});
test('Product.price should return correct priceCount when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).priceCount);
expect(result).toStrictEqual([6, 7, 15, 0.5]);
});
test('Product.price should return correct priceUnit when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).priceUnit);
expect(result).toStrictEqual(['usd', 'cny', 'hkd', 'usd']);
});
test('Product.price should return correct priceCnyCount when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).priceCnyCount);
expect(result).toStrictEqual([42, 7, 12, 3.5]);
});
test('Product.price should return correct priceSuffix when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).priceSuffix);
expect(result).toStrictEqual(['美元', '元', '港幣', '美元']);
}); }); ```
“測試用例寫完以後執行一下,看看效果。”
“這個重構手法也比較簡單,先新建一個 Price
類,先把 price
和相關的行為搬移到 Price
類中,然後再委託給 Product
類即可。我們先來實現 Price
類。”
```js class Price { constructor(value) { this._value = value; }
toString() {
return ${this.count} ${this.suffix}
;
}
get count() { return parseFloat(this._value.slice(1)); }
get unit() { switch (this._value.slice(0, 1)) { case '¥': return 'cny'; case '$': return 'usd'; case 'k': return 'hkd'; default: throw new Error('un support unit'); } }
get cnyCount() { switch (this.unit) { case 'cny': return this.count; case 'usd': return this.count * 7; case 'hkd': return this.count * 0.8; default: throw new Error('un support unit'); } }
get suffix() { switch (this.unit) { case 'cny': return '元'; case 'usd': return '美元'; case 'hkd': return '港幣'; default: throw new Error('un support unit'); } } } ```
“此時,Product
類我還沒有修改,但是如果你覺得你搬移函式的過程中容易手抖不放心的話,可以執行一下測試用例。”
“接下來是重構 Product
類,將原有跟 price
相關的邏輯,使用中間人委託來呼叫。”
```js class Product { constructor(data) { this._name = data.name; this._price = new Price(data.price); / ... / }
get name() { return this.name; }
/ ... /
get price() { return this._price.toString(); }
get priceCount() { return this._price.count; }
get priceUnit() { return this._price.unit; }
get priceCnyCount() { return this._price.cnyCount; }
get priceSuffix() { return this._price.suffix; } } ```
“重構完成後,執行測試用例。” 老耿按下執行鍵。
“測試用例執行通過了,別忘了提交程式碼。”
“很多人對基本型別都有一種偏愛,他們普遍覺得基本型別要比類簡潔,但是,別讓這種偏愛演變成了 偏執
。有些時候,我們需要走出傳統的洞窟,進入炙手可熱的物件世界。”
“這個案例演示了一種很常見的場景,相信你們以後也可以識別基本型別偏執這種壞味道了。”
小李小王瘋狂點頭。
“我們來看一下重構前後的對比。”
“那我們繼續吧。”
(十二)重複的 switch(Repeated switch)
```js class Price { constructor(value) { this._value = value; }
toString() {
return ${this.count} ${this.suffix}
;
}
get count() { return parseFloat(this._value.slice(1)); }
get unit() { switch (this._value.slice(0, 1)) { case '¥': return 'cny'; case '$': return 'usd'; case 'k': return 'hkd'; default: throw new Error('un support unit'); } }
get cnyCount() { switch (this.unit) { case 'cny': return this.count; case 'usd': return this.count * 7; case 'hkd': return this.count * 0.8; default: throw new Error('un support unit'); } }
get suffix() { switch (this.unit) { case 'cny': return '元'; case 'usd': return '美元'; case 'hkd': return '港幣'; default: throw new Error('un support unit'); } } } ```
“剛才我們提煉了 Price
類後,現在發現 Price
類有個問題,你們看出來了嗎?” 老耿看著小李小王。
小李搖了搖頭,小王也沒說話。
“重複的 switch
語句,每當看到程式碼裡有 switch
語句時,就要提高警惕了。當看到重複的 switch
語句時,這種壞味道就冒出來了。” 老耿接著說道。
“重複的 switch 的問題在於:每當你想增加一個選擇分支時,必須找到所有的 switch,並逐一更新。”
“並且這種 switch
結構是非常脆弱的,頻繁的修改 switch
語句可能還可能會引發別的問題,相信你們也遇到過這種情況。”
小李此時似乎想起了什麼,補充道:“這裡的 switch
語句還好,有些地方的 switch
語句寫的太長了,每次理解起來也很困難,所以容易改出問題。”
“小李說的不錯,那我們現在來重構這個 Price
。這裡我偷個懶,測試用例接著用之前 Product
的測試用例,你們可以在實際專案中針對 Price
寫用例,測試用例的粒度越小,越容易定位問題。”
“我們先建立一個工廠函式,同時將 Product
類的例項方法也使用工廠函式建立。”老耿開始寫程式碼。
```js class Product { constructor(data) { this._name = data.name; this._price = createPrice(data.price); / ... / }
/ ... / }
function createPrice(value) { return new Price(value); } ```
“執行一下測試用例... ok,通過了。那我們下一步,把 Price
作為超類,建立一個子類 CnyPrice
,繼承於 Price
,同時修改工廠函式,在貨幣型別為 ¥
時,建立並返回 CnyPrice
類。”
```js class CnyPrice extends Price { constructor(props) { super(props); } }
function createPrice(value) { switch (value.slice(0, 1)) { case '¥': return new CnyPrice(value); default: return new Price(value); } } ```
“執行一下測試用例... ok,通過了。那我們下一步,把 Price
超類中,所有關於 cny
的條件邏輯的函式,在 CnyPrice
中進行重寫。”
```js class CnyPrice extends Price { constructor(props) { super(props); }
get unit() { return 'cny'; }
get cnyCount() { return this.count; }
get suffix() { return '元'; } } ```
“重寫完成後,執行一下測試用例... ok,通過了,下一步再把 Price
類中,所有關於 cny
的條件分支都移除。”
```js class Price { constructor(value) { this._value = value; }
toString() {
return ${this.count} ${this.suffix}
;
}
get count() { return parseFloat(this._value.slice(1)); }
get unit() { switch (this._value.slice(0, 1)) { case '$': return 'usd'; case 'k': return 'hkd'; default: throw new Error('un support unit'); } }
get cnyCount() { switch (this.unit) { case 'usd': return this.count * 7; case 'hkd': return this.count * 0.8; default: throw new Error('un support unit'); } }
get suffix() { switch (this.unit) { case 'usd': return '美元'; case 'hkd': return '港幣'; default: throw new Error('un support unit'); } } } ```
“移除完成後,執行一下測試用例。”
“執行通過,接下來我們如法炮製,把 UsdPrice
和 HkdPrice
也建立好,最後再將超類中的條件分支邏輯相關程式碼都移除。” 老耿繼續寫程式碼。
```js class Price { constructor(value) { this._value = value; }
toString() {
return ${this.count} ${this.suffix}
;
}
get count() { return parseFloat(this._value.slice(1)); }
get suffix() { throw new Error('un support unit'); } }
class CnyPrice extends Price { constructor(props) { super(props); }
get unit() { return 'cny'; }
get cnyCount() { return this.count; }
get suffix() { return '元'; } }
class UsdPrice extends Price { constructor(props) { super(props); }
get unit() { return 'usd'; }
get cnyCount() { return this.count * 7; }
get suffix() { return '美元'; } }
class HkdPrice extends Price { constructor(props) { super(props); }
get unit() { return 'hkd'; }
get cnyCount() { return this.count * 0.8; }
get suffix() { return '港幣'; } }
function createPrice(value) { switch (value.slice(0, 1)) { case '¥': return new CnyPrice(value); case '$': return new UsdPrice(value); case 'k': return new HkdPrice(value); default: throw new Error('un support unit'); } } ```
“重構完成後,執行測試用例。”
“ok,執行通過,別忘了提交程式碼。”
“這樣一來,修改對應的貨幣邏輯並不影響其他的貨幣邏輯,並且新增一種新的貨幣規則也不會影響到其他貨幣邏輯,修改和新增特性都變得簡單了。”
“複雜的條件邏輯是程式設計中最難理解的東西之一,最好可以將條件邏輯拆分到不同的場景,從而拆解複雜的條件邏輯。這種拆分有時用條件邏輯本身的結構就足以表達,但使用類和多型能把邏輯的拆分表述得更清晰。”
“就像我剛才演示的那樣。”
“我們來看一下重構前後的對比。“
“那我們繼續吧。”
(十三)迴圈語句(Loop)
js
function acquireCityAreaCodeData(input, country) {
const lines = input.split('\n');
let firstLine = true;
const result = [];
for (const line of lines) {
if (firstLine) {
firstLine = false;
continue;
}
if (line.trim() === '') continue;
const record = line.split(',');
if (record[1].trim() === country) {
result.push({ city: record[0].trim(), phone: record[2].trim() });
}
}
return result;
}
“嗯,讓我看看這個函式,看名字似乎是獲取城市區號資訊,我想了解一下這個函式的內部實現。嗯,它的實現,先是忽略了第一行,然後忽略了為空的字串,然後將字串以逗號切割,然後...”
“雖然有點繞,但花些時間還是能看出來實現邏輯的。”
“從最早的程式語言開始,迴圈就一直是程式設計的核心要素。但我感覺如今迴圈已經有點兒過時。”
“隨著時代在發展,如今越來越多的程式語言都提供了更好的語言結構來處理迭代過程,例如 Javascript
的陣列就有很多管道方法。”
“是啊,ES
都已經出到 ES12
了。”小王感慨,有點學不動了。
“哈哈,有些新特性還是給我們的重構工作提供了很多幫助的,我來演示一下這個案例。演示之前,還是先補充兩個測試用例。”老耿開始寫程式碼。
```js describe('test acquireCityData', () => { test('acquireCityData should return India city when input India', () => { const input = ',,+00\nMumbai,India,+91 22\n , , \nTianjing,China,+022\n , , \nKolkata,India,+91 33\nBeijing,China,+010\nHyderabad,India,+91 40';
const result = acquireCityData(input, 'India');
expect(result).toStrictEqual([
{
city: 'Mumbai',
phone: '+91 22'
},
{
city: 'Kolkata',
phone: '+91 33'
},
{
city: 'Hyderabad',
phone: '+91 40'
}
]);
});
test('acquireCityData should return China city when input China', () => { const input = ',,+00\nMumbai,India,+91 22\n , , \nTianjing,China,+022\n , , \nKolkata,India,+91 33\nBeijing,China,+010\nHyderabad,India,+91 40';
const result = acquireCityData(input, 'China');
expect(result).toStrictEqual([
{
city: 'Tianjing',
phone: '+022'
},
{
city: 'Beijing',
phone: '+010'
}
]);
}); }); ```
“寫完測試用例後,執行一下... ok,通過了。” 接下來準備重構工作。
“像這樣比較複雜的函式,我們選擇一步一步拆解。首先,把忽略第一行,直接用 slice
代替。”
js
function acquireCityData(input, country) {
let lines = input.split('\n');
const result = [];
lines = lines.slice(1);
for (const line of lines) {
if (line.trim() === '') continue;
const record = line.split(',');
if (record[1].trim() === country) {
result.push({ city: record[0].trim(), phone: record[2].trim() });
}
}
return result;
}
“修改完成後,執行測試用例... ok,下一步過濾為空的 line
,這裡可以用到 filter
。”
js
function acquireCityData(input, country) {
let lines = input.split('\n');
const result = [];
lines = lines.slice(1).filter(line => line.trim() !== '');
for (const line of lines) {
const record = line.split(',');
if (record[1].trim() === country) {
result.push({ city: record[0].trim(), phone: record[2].trim() });
}
}
return result;
}
“修改完成後,執行測試用例... ok,下一步是將 line
用 split
切割,可以使用 map
。”
js
function acquireCityData(input, country) {
let lines = input.split('\n');
const result = [];
lines = lines
.slice(1)
.filter(line => line.trim() !== '')
.map(line => line.split(','));
for (const line of lines) {
if (line[1].trim() === country) {
result.push({ city: line[0].trim(), phone: line[2].trim() });
}
}
return result;
}
“修改完成後,執行測試用例... ok,下一步是判斷國家,可以用 filter
。”
js
function acquireCityData(input, country) {
let lines = input.split('\n');
const result = [];
lines = lines
.slice(1)
.filter(line => line.trim() !== '')
.map(line => line.split(','))
.filter(record => record[1].trim() === country);
for (const line of lines) {
result.push({ city: line[0].trim(), phone: line[2].trim() });
}
return result;
}
“修改完成後,執行測試用例... ok,最後一步是資料組裝,可以使用 map
。”
js
function acquireCityData(input, country) {
let lines = input.split('\n');
return lines
.slice(1)
.filter(line => line.trim() !== '')
.map(line => line.split(','))
.filter(record => record[1].trim() === country)
.map(record => ({ city: record[0].trim(), phone: record[2].trim() }));
}
“重構完成,執行測試用例。”
“測試通過,重構完成了,別忘了提交程式碼。”
“重構完成後,再看這個函式,我們就可以發現,管道操作可以幫助我們更快地看清楚被處理的元素以及處理它們的動作。”
“可是。”小王舉手:“在效能上,迴圈要比管道的效能要好吧?”
“這是個好問題,但這個問題要從三個方面來解釋。”
“首先,這一部分時間會被用在兩個地方,一是用來做效能優化讓程式執行的更快,二是因為缺乏對程式的清楚認識而花費時間。”
“那我先說一下效能優化,如果你對大多數程式進行分析,就會發現它把大半時間都耗費在一小半程式碼身上。如果你一視同仁地優化所有程式碼,90 %的優化工作都是白費勁的,因為被你優化的程式碼大多很少被執行。”
“第二個方面來說,雖然重構可能使軟體執行更慢,但它也使軟體的效能優化更容易,因為重構後的程式碼讓人對程式能有更清楚的認識。”
“第三個方面來說,隨著現代電腦硬體發展和瀏覽器技術發展,很多以前會影響效能的重構手法,例如小函式,現在都不會造成效能的影響。以前所認知的效能影響觀點也需要與時俱進。”
“這裡需要引入一個更高的概念,那就是使用合適的效能度量工具,真正對系統進行效能分析。哪怕你完全瞭解系統,也請實際度量它的效能,不要臆測。臆測會讓你學到一些東西,但十有八九你是錯的。”
“所以,我給出的建議是:除了對效能有嚴格要求的實時系統,其他任何情況下“編寫快速軟體”的祕密就是:先寫出可調優的軟體,然後調優它以求獲得足夠的速度。短期看來,重構的確可能使軟體變慢,但它使優化階段的軟體效能調優更容易,最終還是會得到好的效果。”
“我們來看一下重構前後的對比。”
“那我們繼續下一個。”
(十四)冗贅的元素(Lazy Element)
```js function reportLines(aCustomer) { const lines = []; gatherCustomerData(lines, aCustomer); return lines; }
function gatherCustomerData(out, aCustomer) { out.push(["name", aCustomer.name]); out.push(["location", aCustomer.location]); } ```
“有些函式不能明確的說存在什麼問題,但是可以優化。比如這個函式,能給程式碼增加結構,設計之初可能是為了支援變化、促進複用或者哪怕只是提供更好的名字,但在這裡看來真的不需要這層額外的結構。因為,它的名字就跟實現程式碼看起來一模一樣。”
“有些時候也並不完全是因為過度設計,也可能是因為隨著重構的進行越變越小,最後只剩了一個函式。”
“這裡我直接用行內函數把它優化掉。先寫兩個測試用例。”老耿開始寫程式碼。
```js describe('test reportLines', () => { test('reportLines should return correct array struct when input aCustomer', () => { const input = { name: 'jack', location: 'tokyo' };
const result = reportLines(input);
expect(result).toStrictEqual([
['name', 'jack'],
['location', 'tokyo']
]);
});
test('reportLines should return correct array struct when input aCustomer', () => { const input = { name: 'jackli', location: 'us' };
const result = reportLines(input);
expect(result).toStrictEqual([
['name', 'jackli'],
['location', 'us']
]);
}); }); ```
“執行一下測試用例... ok,沒有問題,那我們開始重構吧。” 老耿開始寫程式碼。
js
function reportLines(aCustomer) {
const lines = [];
lines.push(["name", aCustomer.name]);
lines.push(["location", aCustomer.location]);
return lines;
}
“ok,很簡單,重構完成了,我們執行測試用例。”
“用例測試通過了。如果你想再精簡一點,可以再修改一下。”
js
function reportLines(aCustomer) {
return [
['name', aCustomer.name],
['location', aCustomer.location]
];
}
“執行測試用例... 通過了,提交程式碼。”
“在重構的過程中會發現越來越多可以重構的新結構,就像我剛才演示的那樣。”
“像這類的冗贅的元素存在並沒有太多的幫助,所以,讓它們慷慨赴義�去吧。”
“我們來看看重構前後的對比。”
“我們繼續。”
(十五)誇誇其談通用性(Speculative Generality)
``js
class TrackingInformation {
get shippingCompany() {return this._shippingCompany;}
set shippingCompany(arg) {this._shippingCompany = arg;}
get trackingNumber() {return this._trackingNumber;}
set trackingNumber(arg) {this._trackingNumber = arg;}
get display() {
return
${this.shippingCompany}: ${this.trackingNumber}`;
}
}
class Shipment { get trackingInfo() { return this._trackingInformation.display; } get trackingInformation() { return this._trackingInformation; } set trackingInformation(aTrackingInformation) { this._trackingInformation = aTrackingInformation; } } ```
“嗯... 來看看這個關於這兩個物流的類,而 TrackingInformation
記錄物流公司和物流單號,而 Shipment
只是使用 TrackingInformation
管理物流資訊,並沒有其他任何額外的工作。為什麼用一個額外的 TrackingInformation
來管理物流資訊,而不是直接用 Shipment
來管理呢?”
“因為 Shipment
可能還會有其他的職責。” 小王表示這是自己寫的程式碼。 “所以,我使用了一個額外的類來追蹤物流資訊。”
“很好,單一職責原則。”
“那這個 Shipment
存在多久了,我看看程式碼提交記錄...” 老耿看著 git 資訊說道:“嗯,已經存在兩年了,目前看來它還沒有出現其他的職責,我要再等它幾年嗎?”
“這個壞味道是十分敏感的。”老耿頓了頓,接著說道:“系統裡存在一些 誇誇其談通用性
的設計,常見語句就是 我們總有一天會用上的
,並因此企圖以各式各樣的鉤子和特殊情況來處理一些非必要的事情,這麼做的結果往往造成系統更難理解和維護。“
“在重構之前,我們先寫兩個測試用例吧。”老耿開始寫程式碼。
```js describe('test Shipment', () => { test('Shipment should return correct trackingInfo when input trackingInfo', () => { const input = { shippingCompany: '順豐', trackingNumber: '87349189841231' };
const result = new Shipment(input.shippingCompany, input.trackingNumber).trackingInfo;
expect(result).toBe('順豐: 87349189841231');
});
test('Shipment should return correct trackingInfo when input trackingInfo', () => {
const input = {
shippingCompany: '中通',
trackingNumber: '1281987291873'
};
const result = new Shipment(input.shippingCompany, input.trackingNumber).trackingInfo;
expect(result).toBe('中通: 1281987291873');
});
}); ```
“現在還不能執行測試用例,為什麼呀?” 老耿自問自答:“因為這個用例執行是肯定會報錯的,Shipment
目前的結構根本不支援這麼呼叫的,所以肯定會出錯。”
“這裡我要引入一個新的概念,那就是 TDD - 測試驅動開發。”
“測試驅動開發是戴兩頂帽子思考的開發方式:先戴上實現功能的帽子,在測試的輔助下,快速實現其功能;再戴上重構的帽子,在測試的保護下,通過去除冗餘的程式碼,提高程式碼質量。測試驅動著整個開發過程:首先,驅動程式碼的設計和功能的實現;其後,驅動程式碼的再設計和重構。”
“這裡,我們就是先寫出我們希望程式執行的方式,再通過測試用例去反推程式設計,在通過測試用例後,功能也算是開發完成了。”
“下面我們進行程式碼重構。”老耿開始寫程式碼。
```js class Shipment { constructor(shippingCompany, trackingNumber) { this._shippingCompany = shippingCompany; this._trackingNumber = trackingNumber; }
get shippingCompany() { return this._shippingCompany; }
set shippingCompany(arg) { this._shippingCompany = arg; }
get trackingNumber() { return this._trackingNumber; }
set trackingNumber(arg) { this._trackingNumber = arg; }
get trackingInfo() {
return ${this.shippingCompany}: ${this.trackingNumber}
;
}
}
```
“我把 TrackingInformation
類完全移除了,使用 Shipment
直接對物流資訊進行管理。在重構完成後,執行測試用例。”
“用例執行通過了,這時候再把之前應用到 Shipment
的地方進行調整。當然,更穩妥的辦法是先使用 ShipmentNew
類進行替換後,再刪除原來的類。這裡我還是回退一下程式碼,你們倆去評估一下影響點,再自己來重構吧。” 老耿回退了程式碼。
小李小王瘋狂點頭。
“關於程式碼通用性設計,如果所有裝置都會被用到,就值得那麼做;如果用不到,就不值得。用不上的裝置只會擋你的路,所以,把它搬開吧。”
“我們來看看重構前後的對比。”
“我們繼續吧。”
(十六)臨時欄位(Temporary Field)
```js class Site { constructor(customer) { this._customer = customer; }
get customer() { return this._customer; } }
class Customer { constructor(data) { this._name = data.name; this._billingPlan = data.billingPlan; this._paymentHistory = data.paymentHistory; }
get name() { return this._name; } get billingPlan() { return this._billingPlan; } set billingPlan(arg) { this._billingPlan = arg; } get paymentHistory() { return this._paymentHistory; } }
// Client 1 { const aCustomer = site.customer; // ... lots of intervening code ... let customerName; if (aCustomer === 'unknown') customerName = 'occupant'; else customerName = aCustomer.name; }
// Client 2 { const plan = aCustomer === 'unknown' ? registry.billingPlans.basic : aCustomer.billingPlan; }
// Client 3 { if (aCustomer !== 'unknown') aCustomer.billingPlan = newPlan; }
// Client 4 { const weeksDelinquent = aCustomer === 'unknown' ? 0 : aCustomer.paymentHistory.weeksDelinquentInLastYear; } ```
“這一段程式碼是,我們的線下商城服務點,在老客戶搬走新客戶還沒搬進來的時候,會出現暫時沒有客戶的情況。在每個查詢客戶資訊的地方,都需要判斷這個服務點有沒有客戶,然後再根據判斷來獲取有效資訊。”
“aCustomer === 'unknown'
這是個特例情況,在這個特例情況下,就會使用到很多臨時欄位,或者說是特殊值欄位。這種重複的判斷不僅會來重複程式碼的問題,也會非常影響核心邏輯的程式碼可讀性,造成理解的困難。”
“這裡,我要把所有的重複判斷邏輯都移除掉,保持核心邏輯程式碼的純粹性。然後,我要把這些臨時欄位收攏到一個地方,進行統一管理。我們先寫兩個測試用例。”
```js describe('test Site', () => { test('Site should return correct data when input Customer', () => { const input = { name: 'jack', billingPlan: { num: 100, offer: 50 }, paymentHistory: { weeksDelinquentInLastYear: 28 } };
const result = new Site(new Customer(input)).customer;
expect({
name: result.name,
billingPlan: result.billingPlan,
paymentHistory: result.paymentHistory
}).toStrictEqual(input);
});
test('Site should return empty data when input NullCustomer', () => { const input = { name: 'jack', billingPlan: { num: 100, offer: 50 }, paymentHistory: { weeksDelinquentInLastYear: 28 } };
const result = new Site(new NullCustomer(input)).customer;
expect({
name: result.name,
billingPlan: result.billingPlan,
paymentHistory: result.paymentHistory
}).toStrictEqual({
name: 'occupant',
billingPlan: { num: 0, offer: 0 },
paymentHistory: { weeksDelinquentInLastYear: 0 }
});
}); }); ```
“嗯,這次又是 TDD,第一個用例是可以執行的,執行是可以通過的。”
“接下來,我按這個思路去實現 NullCustomer
,這個實現起來其實很簡單。”
js
class NullCustomer extends Customer {
constructor(data) {
super(data);
this._name = 'occupant';
this._billingPlan = { num: 0, offer: 0 };
this._paymentHistory = {
weeksDelinquentInLastYear: 0
};
}
}
“實現完成後,執行一下測試用例。”
“我引入了這個特例物件後,我只需要在初始化 Site
的時候判斷老客戶搬出新客戶還沒有搬進來的情況,決定初始化哪一個 Customer
,而不用在每個呼叫的地方都判斷一次,還引入那麼多臨時欄位了。”
“如果寫出來的話,就像是這樣一段虛擬碼。”
```js // initial.js const site = customer === 'unknown' ? new Site(new NullCustomer()) : new Site(new Customer(customer));
// Client 1 { const aCustomer = site.customer; // ... lots of intervening code ... const customerName = aCustomer.name; }
// Client 2 { const plan = aCustomer.billingPlan; }
// Client 3 { }
// Client 4 { const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastYear; } ```
“在這裡我就不對你們的程式碼做實際修改了,你們下去以後自己調整一下吧。”
小李小王瘋狂點頭。
“我們來看一下重構前後的對比。”
“我們繼續下一個。”
(十七)過長的訊息鏈(Message Chains)
js
const result = a(b(c(1, d(f()))));
“這種壞味道我手寫程式碼演示一下,比如向一個物件請求另一個物件,然後再向後者請求另一個物件,然後再請求另一個物件……這就是訊息鏈。在實際程式碼中,看到的可能就是一長串取值函式或一長串臨時變數。”
“這種一長串的取值函式,可以使用的重構手法就是 提煉函式
,就像這樣。”
```js const result = goodNameFunc();
function goodNameFunc() { return a(b(c(1, d(f())))); } ```
“再給提煉出來的函式,取一個好名字就行了。”
“還有一種情況,就是委託關係,需要隱藏委託關係。我就不做展開了,你們有興趣的話去看一看重構那本書吧。“
“我們來看一下重構前後的對比。”
我們繼續下一個。”
(十八)中間人(Middle Man)
```js class Product { constructor(data) { this._name = data.name; this._price = createPrice(data.price); / ... / }
get name() { return this.name; }
/ ... /
get price() { return this._price.toString(); }
get priceCount() { return this._price.count; }
get priceUnit() { return this._price.unit; }
get priceCnyCount() { return this._price.cnyCount; }
get priceSuffix() { return this._price.suffix; } } ```
“嗯,這個 Product
+ Price
又被我翻出來了,因為經過了兩次重構後,它還是存在一些壞味道。”
“現在我要訪問 Product
價格相關的資訊,都是直接通過 Product
訪問,而 Product
負責提供 price
的很多介面。隨著 Price
類的新特性越來越多,更多的轉發函式就會使人煩躁,而現在已經有點讓人煩躁了。”
“這個 Product
類已經快完全變成一箇中間人了,那我現在希望呼叫方應該直接使用 Price
類。我們先來寫兩個測試用例。”老耿開始寫程式碼。
```js describe('test Product price', () => { const products = [ { name: 'apple', price: '$6' }, { name: 'banana', price: '¥7' }, { name: 'orange', price: 'k15' }, { name: 'cookie', price: '$0.5' } ];
test('Product.price should return correct price when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price.toString());
expect(result).toStrictEqual(['6 美元', '7 元', '15 港幣', '0.5 美元']);
});
test('Product.price should return correct priceCount when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price.count);
expect(result).toStrictEqual([6, 7, 15, 0.5]);
});
test('Product.price should return correct priceUnit when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price.unit);
expect(result).toStrictEqual(['usd', 'cny', 'hkd', 'usd']);
});
test('Product.price should return correct priceCnyCount when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price.cnyCount);
expect(result).toStrictEqual([42, 7, 12, 3.5]);
});
test('Product.price should return correct priceSuffix when input products', () => { const input = [...products];
const result = input.map(item => new Product(item).price.suffix);
expect(result).toStrictEqual(['美元', '元', '港幣', '美元']);
}); }); ```
“寫完的測試用例也是不能直接執行的,接下來我們調整 Product
類,把中間人移除。”
```js class Product { constructor(data) { this._name = data.name; this._price = createPrice(data.price); / ... / }
get name() { return this.name; }
/ ... /
get price() { return this._price; } } ```
“調整完成後,直接執行測試用例。”
“測試用例通過了,別忘了把使用到 Product
的地方都檢查一遍。”
“很難說什麼程度的隱藏才是合適的。但是有隱藏委託關係和刪除中間人,就可以在系統執行過程中不斷進行調整。隨著程式碼的變化,“合適的隱藏程度” 這個尺度也相應改變。”
“我們來看看重構前後的對比。”
“我們繼續下一個吧。”
(十九)內幕交易(Insider Trading)
```js class Person { constructor(name) { this._name = name; } get name() { return this._name; } get department() { return this._department; } set department(arg) { this._department = arg; } }
class Department { get code() { return this._code; } set code(arg) { this._code = arg; } get manager() { return this._manager; } set manager(arg) { this._manager = arg; } } ```
“在這個案例裡,如果要獲取 Person
的部門程式碼 code
和部門領導 manager
都需要先獲取 Person.department
。這樣一來,呼叫者需要額外瞭解 Department
的介面細節,如果 Department
類修改了介面,變化會波及通過 Person
物件使用它的所有客戶端。”
“我們都喜歡在模組之間建起高牆,極其反感在模組之間大量交換資料,因為這會增加模組間的耦合。在實際情況裡,一定的資料交換不可避免,但我必須儘量減少這種情況,並把這種交換都放到明面上來。”
“接下來,我們按照我們期望程式執行的方式,來編寫兩個測試用例。”
```js describe('test Person', () => { test('Person should return 88 when input Department code 88', () => { const inputName = 'jack' const inputDepartment = new Department(); inputDepartment.code = 88; inputDepartment.manager = 'Tom';
const result = new Person(inputName, inputDepartment).departmentCode;
expect(result).toBe(88);
});
test('Person should return Tom when input Department manager Tom', () => { const inputName = 'jack' const inputDepartment = new Department(); inputDepartment.code = 88; inputDepartment.manager = 'Tom';
const result = new Person(inputName, inputDepartment).manager;
expect(result).toBe('Tom');
}); }); ```
“在測試用例中,我們可以直接通過 Person
得到這個人的部門程式碼 departmentCode
和部門領導 manager
了,那接下來,我們把 Person
類進行重構。”
js
class Person {
constructor(name, department) {
this._name = name;
this._department = department;
}
get name() {
return this._name;
}
get departmentCode() {
return this._department.code;
}
set departmentCode(arg) {
this._department.code = arg;
}
get manager() {
return this._department._manager;
}
set manager(arg) {
this._department._manager = arg;
}
}
“這裡我直接將修改一步到位,但是你們練習的時候還是要一小步一小步進行重構,發現問題就可以直接回退程式碼。”老耿語重心長的說道。
小李小王瘋狂點頭。
“我們回來看程式碼,在程式碼裡,我把委託關係進行了隱藏,從而客戶端對 Department
類的依賴。這麼一來,即使將來委託關係發生變化,變化也只會影響服務物件 - Person
類,而不會直接波及所有客戶端。”
“我們執行一下測試程式碼。”
“執行通過了,在所有程式碼替換完成前,可以先保留對 department
的訪問,在所有程式碼都修改完成後,再完全移除,提交程式碼。”
“我們來看看重構前後的對比。”
“我們繼續下一個。”
(二十)過大的類(Large Class)
“還有一種壞味道叫做 過大的類
,這裡我不用舉新的例子了,最早的 Product
類其實就存在這樣的問題。”
“如果想利用單個類做太多事情,其內往往就會出現太多欄位。一旦如此,重複程式碼也就接踵而至了。二來,過大的類也會造成理解的困難。過大的類和過長的函式都有類似的問題。”
“我們在 Product
類中就發現了三個壞味道:基本型別偏執、重複的 switch、中間人。在解決這三個壞味道的過程中,也把 過大的類
這個問題給解決了。”
“重構是持續的小步的,你們可以對 Product
類除了 price
以外的方法再進行多次提煉,我這裡就不再演示了。”
小李小王瘋狂點頭。
“那我們繼續講下一個。”
(二十一)異曲同工的類(Alternative Classes with Different Interfaces)
```js class Employee { constructor(name, id, monthlyCost) { this._id = id; this._name = name; this._monthlyCost = monthlyCost; } get monthlyCost() { return this._monthlyCost; } get name() { return this._name; } get id() { return this._id; } get annualCost() { return this.monthlyCost * 12; } }
class Department { constructor(name, staff) { this._name = name; this._staff = staff; } get staff() { return this._staff.slice(); } get name() { return this._name; } get totalMonthlyCost() { return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost); } get headCount() { return this.staff.length; } get totalAnnualCost() { return this.totalMonthlyCost * 12; } } ```
“這裡有一個壞味道,和重複程式碼有異曲同工之妙,叫做異曲同工的類。這裡我以經典的 Employee
案例來講解一下。”
“在這個案例中,Employee
類和 Department
都有 name
欄位,也都有月度成本 monthlyCost
和年度成本 annualCost
的概念,可以說這兩個類其實在做類似的事情。”
“我們可以用提煉超類來組織這種異曲同工的類,來消除重複行為。”
“在此之前,根據我們最後想要實現的效果,我們先編寫兩個測試用例。”
```js describe('test Employee and Department', () => { test('Employee annualCost should return 600 when input monthlyCost 50', () => { const input = { name: 'Jack', id: 1, monthlyCost: 50 };
const result = new Employee(input.name, input.id, input.monthlyCost).annualCost;
expect(result).toBe(600);
});
test('Department annualCost should return 888 when input different staff', () => { const input = { name: 'Dove', staff: [{ monthlyCost: 12 }, { monthlyCost: 41 }, { monthlyCost: 24 }, { monthlyCost: 32 }, { monthlyCost: 19 }] };
const result = new Department(input.name, input.staff).annualCost;
expect(result).toBe(1536);
}); }); ```
“這個測試用例現在執行也是失敗的,因為我們還沒有把 Department
改造完成。接下來,我們先把 Employee
和 Department
相同的欄位和行為提煉出來,提煉成一個超類 Party
。”
```js class Party { constructor(name) { this._name = name; }
get name() { return this._name; }
get monthlyCost() { return 0; }
get annualCost() { return this.monthlyCost * 12; } } ```
“這兩個類相同的欄位有 name
,還有計算年度成本 annualCost
的方式,因為使用到了 monthlyCost
欄位,所以我把這個欄位也提煉出來,先返回個預設值 0。”
“接下來對 Employee
類進行精簡,將提煉到超類的部分進行繼承。”
js
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name);
this._id = id;
this._monthlyCost = monthlyCost;
}
get monthlyCost() {
return this._monthlyCost;
}
get id() {
return this._id;
}
}
“再接下來對 Department
類進行改造,繼承 Party
類,然後進行精簡。”
js
class Department extends Party {
constructor(name, staff) {
super(name);
this._staff = staff;
}
get staff() {
return this._staff.slice();
}
get monthlyCost() {
return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost);
}
get headCount() {
return this.staff.length;
}
}
“這樣就完成了改造,執行一下測試用例。”
“測試通過了。記得把其他使用到這兩個類的地方改造完成後再提交程式碼。”
“如果看見兩個異曲同工的類在做相似的事,可以利用基本的繼承機制把它們的相似之處提煉到超類。”
“有很多時候,合理的繼承關係是在程式演化的過程中才浮現出來的:我發現了一些共同元素,希望把它們抽取到一處,於是就有了繼承關係。所以,先嚐試用小而快的重構手法,重構後再發現新的可重構結構。”
“我們來看一下重構前後的對比。”
“我們繼續下一個。”
(二十二)純資料類(Data Class)
```js class Category { constructor(data) { this._name = data.name; this._level = data.level; }
get name() { return this._name; }
set name(arg) { this._name = arg; }
get level() { return this._level; }
set level(arg) { this._level = arg; } }
class Product { constructor(data) { this._name = data._name; this._category = data.category; }
get category() {
return ${this._category.level}.${this._category.name}
;
}
}
```
“Category
是個純資料類,像這樣的純資料類,直接使用字面量物件似乎也沒什麼問題。”
“但是,純資料類常常意味著行為被放在了錯誤的地方。比如在 Product
有一個應該屬於 Category
的行為,就是轉化為字串,如果把處理資料的行為從其他地方搬移到純資料類裡來,就能使這個純資料類有存在的意義。”
“我們先寫兩個簡單的測試用例。”老耿開始寫程式碼。
```js describe('test Category', () => { test('Product.category should return correct data when input category', () => { const input = { level: 1, name: '水果' };
const result = new Product({ name: '蘋果', category: new Category(input) }).category;
expect(result).toBe('1.水果');
});
test('Product.category should return correct data when input category', () => { const input = { level: 2, name: '熱季水果' };
const result = new Product({ name: '蘋果', category: new Category(input) }).category;
expect(result).toBe('2.熱季水果');
}); }); ```
“測試用例寫完以後,執行一下... ok,通過了。接下來,我們把本應該屬於 Category
的行為,挪進來。”
```js class Category { constructor(data) { this._name = data.name; this._level = data.level; }
get name() { return this._name; }
set name(arg) { this._name = arg; }
get level() { return this._level; }
set level(arg) { this._level = arg; }
toString() {
return ${this._level}.${this._name}
;
}
}
class Product { constructor(data) { this._name = data._name; this._category = data.category; }
get category() { return this._category.toString(); } } ```
“然後我們執行一下測試用例。”
“用例執行成功了,別忘了提交程式碼。” 老耿打了個 commit。
“我們需要為純資料賦予行為,或者使用純資料類來控制資料的讀寫。否則的話,純資料類並沒有太大存在的意義,應該作為冗贅元素被移除。”
“我們來看一下重構前後的對比。”
“那我們繼續下一個。”
(二十三)被拒絕的遺贈(Refuse Bequest)
```js class Party { constructor(name, staff) { this._name = name; this._staff = staff; }
get staff() { return this._staff.slice(); }
get name() { return this._name; }
get monthlyCost() { return 0; }
get annualCost() { return this.monthlyCost * 12; } }
class Employee extends Party { constructor(name, id, monthlyCost) { super(name); this._id = id; this._monthlyCost = monthlyCost; } get monthlyCost() { return this._monthlyCost; } get id() { return this._id; } }
class Department extends Party { constructor(name) { super(name); } get monthlyCost() { return this.staff.map(e => e.monthlyCost).reduce((sum, cost) => sum + cost); } get headCount() { return this.staff.length; } } ```
“關於這個壞味道,我想改造一下之前那個 Employee
和 Department
的例子來進行講解。”
“這個例子可以看到,我把 staff
欄位從 Department
上移到了 Party
類,但其實 Employee
類並不關心 staff
這個欄位。這就是 被拒絕的遺贈
壞味道。”
“重構手法也很簡單,就是把 staff
欄位下移到真正需要它的子類 Department
中就可以了,就像我剛完成提煉超類那時的樣子。”
“如果超類中的某個欄位或函式只與一個或少數幾個子類有關,那麼最好將其從超類中挪走,放到真正關心它的子類中去。”
“十有八九這種壞味道很淡,需要對業務熟悉程度較高才能發現。”
“我們來看一下重構前後的對比。”
“那我們繼續下一個。”
(二十四)註釋(Comments)
“最後,再提一點,關於 註釋
的壞味道。”
“我認為,註釋並不是壞味道,並且屬於一種好味道,但是註釋的問題在於很多人是經常把它當作“除臭劑”來使用。”
“你經常會看到,一段程式碼有著長長的註釋,然後發現,這些註釋之所以存在乃是因為程式碼很糟糕,創造它的程式設計師不想管它了。”
“當你感覺需要寫註釋時,請先嚐試重構,試著讓所有註釋都變得多餘。”
“如果你不知道該做什麼,這才是註釋的良好運用時機。除了用來記述將來的打算之外,註釋還可以用來標記你並無十足把握的區域。你可以在註釋裡寫下自己“為什麼做某某事”。這類資訊可以幫助將來的修改者,尤其是那些健忘的傢伙。”
小李小王瘋狂點頭。
“好了,那我們這次的特訓就到此結束了,你們倆下去以後一定要多多練習,培養識別壞味道的敏感度,然後做到對壞味道的零容忍才行。”
小結
雖然標題是 24 大技巧,但是文章卻介紹了 24 種程式碼裡常見的壞味道,還有每個壞味道對應的重構手法。
這其實有點類似於設計原則和設計模式的關係,設計原則是道,設計模式是術。
有道者術能長久,無道者術必落空,學術先需明道,方能大成,學術若不明道,終是小器。
這也是本文為什麼要介紹 24 種程式碼裡的壞味道,而不是直接介紹重構手法。因為只有識別了程式碼中的壞味道,才能儘量避免寫出壞味道的程式碼,真正做到盡善盡美,保持軟體健康長青。
如果發現了程式碼裡的 壞味道
,先把這片區域用 測試用例
圈起來,然後再利用 各種重構手法,在不改變軟體可觀察行為的前提下,調整其結構
,在 通過測試
後,第一時間 提交程式碼
,保證你的系統隨時都處於 可釋出
狀態。
文中的老耿原型其實就是《重構:改善既有程式碼的設計》的作者們,小王小李指的是團隊中那些經常容易把程式碼寫的像打補丁,然後過了一段時間老是想推翻重來的程式設計新人們(可能是老人),而大寶則像是一名手握屠龍術卻不敢直面惡龍的高階工程師。
我以為,重構也需要勇氣,開始嘗試的勇氣。
配套練習
我將文中所有的案例都整理到了 github
上,每個壞味道都有一個獨立的目錄,每個目錄的結構看起來就像是這樣。
xx.before.js
:重構前的程式碼xx.js
:重構後的程式碼xx.test.js
:配套的測試程式碼
強烈建議讀者們按照文章教程,自行完成一次重構練習,這樣可以更好的識別壞味道和掌握重構手法。
下面是對應的連結:
- (一)神祕命名(Mysterious Name)
- (二)重複程式碼(Repeat Code)
- (三)過長函式(Long Function)
- (四)過長引數列表(Long Parameter List)
- (五)全域性資料(Global Data)
- (六)可變資料(Mutable Data)
- (七)發散式變化(Divergent Change)
- (八)霰彈式修改(Shotgun Surgery)
- (九)依戀情節(Feature Envy)
- (十)資料泥團(Data Clumps)
- (十一)基本型別偏執(Primitive Obsession)
- (十二)重複的 switch(Repeated switch)
- (十三)迴圈語句(Loop)
- (十四)冗贅的元素(Lazy Element)
- (十五)誇誇其談通用性(Speculative Generality)
- (十六)臨時欄位(Temporary Field)
- (十七)過長的訊息鏈(Message Chains)
- (十八)中間人(Middle Man)
- (十九)內幕交易(Insider Trading)
- (二十)過大的類(Large Class
- (二十一)異曲同工的類(Alternative Classes with Different Interfaces)
- (二十二)純資料類(Data Class)
- (二十三)被拒絕的遺贈(Refuse Bequest)
- (二十四)註釋(Comments)
最後一件事
如果您已經看到這裡了,希望您還是點個贊再走吧~
您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!
如果覺得本文對您有幫助,請幫忙在 github 上點亮 star
鼓勵一下吧!