24種代碼壞味道和重構手法

語言: CN / TW / HK

24種代碼壞味道和重構手法

最近,小李感覺公司女生們看他的眼神不太對勁了,那種笑容好像是充滿慈愛的、姨母般的笑容。

image

作為一名老實本分的程序員,小李不太習慣這種被人過度關注的感覺,他不知道發生了什麼。

······

小李和小王的關係似乎過於親密,還經常擠在一個工位上辦公,一直到半夜。

這個流言直到某天他們的聊天內容被某個運營小姐姐聽到,他們之間的祕密才被大家發現。

小李和小王的故事

“這串代碼看起來不太好。” 小李指着屏幕,眉頭緊鎖。

image

“咋了,這段代碼是我寫的,因為這裏要實現客户的一個定製化需求,所以看起來有點複雜。” 小王湊了過來。

“我也説不出來哪裏不對勁,我現在要添加一個新特性,不知道從哪裏下手。” 小李撓了撓頭。

小王把凳子搬了過來:“來來,我給你講講,這段代碼,這段調用.....”

·····

中午 12 點,辦公室的空氣中瀰漫着飯菜的香氣,還有成羣結隊約飯的小夥伴,休閒區也成了乾飯的主戰場。

“噢?原來小李和小王這種叫做結對編程?” 運營小姐姐目光掃向還在座位上的小李小王。

image

“嗯...偶爾的結對編程是正常的,但是長期的結對編程説明出現了一些壞味道。”老工程師説完,端起飯盆,幹完了最後一口飯。

“oh...是那種...味道嗎?”運營小姐姐試探道。

在座一起吃飯的 HR、UI 小姐姐們被這句話又點燃了八卦之魂,發出了一陣愉快的笑聲。

“NoNoNo...老耿的意思,代碼裏出現了壞味道。”高級工程師大寶扶了扶眼鏡。

“什麼叫代碼裏出現了壞味道呀?”

“就是軟件架構要開始分崩離析的前兆,代碼裏面充斥着壞味道,這些味道都散發着噁心、腐爛的信號,提示你該重構了。”

小姐姐們眉頭一皺,不太滿意大寶在吃飯的時候講了個這麼倒胃口的例子。

老耿喝了口水,放下水杯:“阿寶説的沒錯。很顯然,小李和小王在寫程序的時候,並沒有發現代碼中存在的壞味道,導致他們現在欠下了一大批的技術債務,正在分期還債。”

“看來是時候給他們做個培訓了,教他們如何識別代碼中的壞味道。”

代碼中的壞味道

小李和小王頂着兩個大黑眼圈來到會議室,老耿早已經在會議室等着了。

小李看了一眼投影儀的內容 “24 種常見的壞味道及重構手法 —— 告別加班,可持續發展”。

image

“吶,給你們倆準備的咖啡,打起精神來哈。”大寶用身子推門進來,手上端着兩杯熱騰騰的咖啡,這是他剛從休息區親手煮的新鮮咖啡。

“謝謝寶哥” 小李和小王齊聲道。

“聽説你們倆最近老是一起加班到半夜。” 老耿把手從鍵盤上拿開,把視線從電腦屏幕上移到小李小王的身上。

“嘿嘿...” 小李不好意思的笑了笑 “最近需求比較難,加一些新功能比較花時間。”

“嗯,看來你們也發現了一些問題,為什麼現在加新功能花的時間會比以前多得多呢?”老耿把凳子往後挪了挪,看着兩人。

“因為產品越來越複雜了,加新功能也越來越花時間了。”小王接話道。

“對,這算是一個原因。還有嗎?”老耿接着問。

“因...因為我們之前寫的代碼有點亂,可能互相理解起來比較花時間...” 小李撓了撓頭。

“但其實我都寫了註釋的,我覺得還是因為產品太複雜了。”小王對小李這個説法有點不認可。

“好,其實小李説到了一個關鍵問題。”老耿覺得是時候了,接着説道:“Martin Fowler 曾經説過,當代碼庫看起來就像補丁摞補丁,需要細緻的考古工作才能弄明白整個系統是如何工作的。那這份負擔會不斷拖慢新增功能的速度,到最後程序員恨不得從頭開始重寫整個系統。”

image

“我想,你們應該有很多次想重寫系統的衝動吧?”老耿笑了笑,繼續説道:“而內部質量良好的軟件可以讓我在添加新功能時,很容易找到在哪裏修改、如何修改。”

老耿頓了頓,“所以,小王説的也對。產品複雜帶來了軟件的複雜,而複雜的軟件一不小心就容易變成了補丁摞補丁。軟件代碼中充斥着 “壞味道”,這些 “壞味道” 最終會讓軟件腐爛,然後失去對它的掌控。”

“對了,小王剛才提到的註釋,也是一種壞味道。”老耿笑了笑,看着小王,“所以,壞味道無處不在,有的壞味道比較好察覺,有的壞味道需要經過特殊訓練,才能識別。”

老耿又指了指投影儀大屏幕:“所以,我們今天是一期特殊訓練課,教會你們識別 24 種常見的壞味道及重構手法,這門課至少可以讓你們成為一個有着一些特別好的習慣的還不錯的程序員。”

“咳咳,你們倆賺大了”大寶補充道:“老耿這級別的大牛,在外邊上課可是要收費的喲~”

“等等,我去拿筆記本。”小李舉手示意,然後打開門走出去,小王見狀也跟着小李出去拿筆記本了。

24 種常見的壞味道及重構手法

老耿見大家已經都做好了學習的準備,站起身走到屏幕旁邊,對着大家説道:“那我們開始吧!”

”壞味道我們剛才已經説過了,我再講個小故事吧。我這麼多年以來,看過很多很多代碼,它們所屬的項目有大獲成功的,也有奄奄一息的。觀察這些代碼時,我和我的老搭檔學會了從中找出某些特定結構,這些結構指出了 重構 的可能性,這些結構也就是我剛才提到的 壞味道。”

“噢對,我剛才還提到了一個詞 —— 重構。這聽着像是個很可怕的詞,但是我可以和你們説,我口中的 重構 並不是你們想的那種推翻重來,有本書給了這個詞一個全新的定義 —— 對軟件內部結構的一種調整,目的是在不改變軟件可觀察行為的前提下,提高其可理解性,降低其修改成本。,你們也可以理解為是在 使用一系列重構手法,在不改變軟件可觀察行為的前提下,調整其結構。

image

“如果有人説他們的代碼在重構過程中有一兩天時間不可用,基本上可以確定,他們在做的事不是重構。”老耿開了個玩笑:“他們可能在對代碼施展某種治療魔法,這種魔法帶來的副作用就是會讓軟件短暫性休克。”

“在這裏,還有一點值得一提,那就是如何做到 不改變軟件可觀察行為,這需要一些外力的協助。這裏我不建議你們再把加班的人羣延伸到測試人員,我給出的方案是準備一套完備的、運行速度很快的測試套件。在絕大多數情況下,如果想要重構,就得先有一套可以自測試的代碼。”

“接下來,我會先審查你們商城系統的代碼,發現代碼中存在的壞味道,然後添加單元測試,進行重構後,最後通過測試完成重構。”

“我會在這個過程中,給你們演示並講解 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);

}); }); ```

老耿 運行了一下測試用例,顯示測試通過後,説:“我們有了單元測試後,就可以開始準備重構工作了。”

image

“我們先把 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); ```

“這一步看起來沒什麼問題,但是我們還是先 運行一下測試用例。”老耿按下了執行用例快捷鍵,用例跑了起來,並且很快就通過了測試。

image

“這一步説明我們的修改沒有問題,下一步我們修改測試用例,將測試用例調用的 countOrder 方法都修改為調用 getPrice 方法,再次 運行修改後的測試用例。”

image

老耿指着修改後的測試用例:“再次運行後,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 直接把這個函數刪除掉。”

image

“刪除後,我們的重構就完成了,新的代碼看起來像這樣。”

```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 記錄,繼續説道:“每一次重構完成都應該提交代碼,這樣就可以在下一次重構出現問題的時候,迅速回退到上一次正常工作時的狀態,這一點很有用!“

大寶補充到:“這樣的重構還有個好處,那就是可以保證代碼隨時都是可發佈的狀態,因為並沒有影響到整體功能的運行。”

“不過想一個合適的好名字確實不容易,耿哥的意思是讓我們要持續的小步的進行重構吧。”小王摸着下巴思考説道。

“阿寶和小王説的都對,在不改變軟件可觀察行為的前提下,持續小步的重構,保證軟件隨時都處於可發佈的狀態。這意味着我們隨時都可以進行重構,最簡單的重構,比如我剛才演示的那種用不了幾分鐘,而最長的重構也不該超過幾小時。”

“我再補充一點。”大寶説道:“我們絕對不能忽視自動化測試,只有自動化測試才能保證在重構的過程中不改變軟件可觀察行為,這一點看似不起眼,卻是最最重要的關鍵之處。”

“阿寶説的沒錯,我們至少要保證我們重構的地方有單元測試,且能通過單元測試,才能算作是重構完成。”

老耿稍作停頓後,等待大家理解自己剛才的那段話後,接着説:“看來大家都開始感受到了重構的魅力,我們最後看看這段代碼重構前後的對比。”

image

“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), '
'].join('\n'); }

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>`);

}); }); ```

“我們先運行測試一下我們的測試用例是否能通過吧。“老耿按下了執行快捷鍵。

image

“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'); }

“然後,我們把 renderPersonphotoDiv 內部調用的方法,都換成 emitPhotoDataNew 新方法,如果再穩妥一點的話,最好是換一個函數執行一次測試用例。”

``js function renderPerson(person) { const result = []; result.push(

${person.name}

`); result.push(emitPhotoDataNew(person.photo)); return result.join('\n'); }

function photoDiv(photo) { return ['

', emitPhotoDataNew(photo), '
'].join('\n'); }

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'); } ```

“替換完成後,執行測試用例,看看效果。”

image

“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 ['

', emitPhotoData(photo), '
'].join('\n'); }

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'); } ```

“修改完後,別忘了運行測試用例。”老耿每次修改完成後運行測試用例的動作,似乎已經形成了肌肉記憶。

image

“ok,測試通過。這次重構完成了,提交一個 Commit,再看一下修改前後的對比。”

image

“我們繼續看下一個壞味道。”

(三)過長函數(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
]);

}); }); ```

“測試用例寫完以後運行一下。“

image

“接下來的提取步驟就很簡單了,因為代碼本身是有註釋的,我們只需要參考註釋的節奏來進行提取就好了,依舊是小步慢跑,首先調整函數執行的順序,將函數分層。”

```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); } ```

image

“測試用例通過後,別忘了提交代碼。”

“然後我們再來審視一下這個重構後的 printOwing 函數,簡單的四行代碼,清晰的描述了函數所做的事情,這就是小函數的魅力!”

“説到底,讓小函數易於理解的關鍵還是在於良好的命名。如果你能給函數起個好名字,閲讀代碼的人就可以通過名字瞭解函數的作用,根本不必去看其中寫了些什麼。這可以節約大量的時間,也能減少你們結對編程的時間。”老耿面帶微笑看着小李和小王。

小李小王相視一笑,覺得有點不好意思。

“我們來看看重構前後的對比。”

image

“我們繼續。”老耿馬不停蹄。

(四)過長參數列表(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 字段和傳入的參數 minmax 之間的大小對比關係。如果 isOutSidetrue 的話,則過濾出價格區間之外的商品,否則過濾出價格區間之內的商品。”

“第一眼看過去,這個函數的參數實在是太多了,這會讓客户端調用方感到很疑惑。還好參數 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 }
]);

}); }); ```

“我把 priceRangeisOutSide 標記參數移除了,並且使用 priceOutsideRangepriceInsideRange 兩個方法來實現原有的功能。這時候還不能運行測試用例,因為我們的代碼還沒改呢。同樣的,把代碼調整成符合用例調用的方式。”

```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); } ```

“代碼調整完成後,我們來運行一下測試用例。好的,通過了!”

image

“嗯,我想到這裏以後,可以更進一步,把 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 ) ```

“這麼做以後,原來讓人疑惑的標記參數就被移除了,取而代之的是兩個語義更加清晰的函數。”

“接下來,我們要繼續做一件有價值的重構,那就是將數據組織成結構,因為這樣讓數據項之間的關係變得明晰。比如 rangeminmax 總是在調用中被一起使用,那這兩個參數就可以組織成結構。我先修改我的測試用例以適應最新的改動,就像這樣。”

```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

image

“這一步重構又精簡了一個參數,這是這項重構最直接的價值。而這項重構真正的意義在於,它會催生代碼中更深層次的改變。一旦識別出新的數據結構,我就可以重組程序的行為來使用這些結構。這句話實際應用起來是什麼意思呢?我還是拿這個案例來舉例。”

“我們會發現 priceOutSideRangepriceInsideRange 的函數命名已經足夠清晰,但是內部對 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,通過了。測試通過後再提交代碼。”

“這樣一來,讓 priceOutSideRangepriceInsideRange 函數內部也更加清晰了。同時,range 被組織成了一種新的數據結構,這種結構可以在任何計算區間的地方使用。”

“我們來看看重構前後的對比。”

image

“我們繼續。”

(五)全局數據(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 的方式,改了就會出問題。”

“但是我現在可以在代碼庫的任何一個角落都可以修改 platformtoken,而且沒有任何機制可以探測出到底哪段代碼做出了修改,這就是全局數據的問題。”

“每當我們看到可能被各處的代碼污染的數據,我們還是需要全局數據用一個函數包裝起來,至少你就能看見修改它的地方,並開始控制對它的訪問,這裏我做個簡單的封裝,然後再寫兩個測試用例。”

```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');
});

}); ```

image

“這樣一來,通過對象引用就無法修改源對象了,並且我這裏控制了對 platform 屬性的修改,只開放對 token 修改的接口。即便如此,我們還是要儘可能的避免全局數據,因為全局數據是最刺鼻的壞味道之一!”老耿語氣加重。

小李小王瘋狂點頭。

“我們來看一下重構前後的對比。”

image

“那我們繼續。”

(六)可變數據(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'
});

}); }); ```

“運行一下... 第二個用例報錯了。”

image

“報錯的原因就是因為對源對象進行了修改調整,從而影響了 baseConfig 的值。接下來我們調整一下 merge 函數就行了,現在 javascript 有很簡單的方法可以修改這個函數。”

js function merge(target, source) { return { ...target, ...source } }

“修改完成後,再次運行用例,就可以看到用例運行通過了。”

image

“我剛才的重構手法其實有一整個軟件開發流派 —— 函數式編程,就是完全建立在“數據永不改變”的概念基礎上:如果要更新一個數據結構,就返回一份新的數據副本,舊的數據仍保持不變,這樣可以避免很多因數據變化而引發的問題。”

“在剛才介紹全局數據時用到的封裝變量的方法,也是對可變數據這種壞味道常見的一種解決方案。還有,如果可變數據的值能在其他地方計算出來,這就是一個特別刺鼻的壞味道。它不僅會造成困擾、bug和加班,而且毫無必要。”

“這裏我就不做展開了,如果你們倆感興趣的話,可以去看看《重構:改善既有代碼的設計(第2版)》這本書,我剛才提到的壞味道,書裏面都有。”

小李小王奮筆疾書,把書名記了下來。

“我們來看一下重構前後的對比。”

image

“那我們繼續。”

(七)發散式變化(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); ```

“修改完成後,我們運行之前寫的測試用例... 測試通過了。”

image

“修改之後的三個函數只需要關心自己職責方向的變化就可以了,而不是一個函數關注多個方向的變化。並且,單元測試粒度還可以寫的更細一點,這樣對排查問題的效率也有很大的提升。”

大寶適時補充了一句:“其實這就是面向對象設計原則中的 單一職責原則。”

“阿寶説的沒錯,keep simple,每次只關心一個上下文 這一點一直很重要。”

“我們來看一下重構前後的對比。”

image

“我們繼續。”

(八)霰彈式修改(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 相關的邏輯都放在一起管理了,並且我把它組合成一個類以後還有一個好處。那就是類能明確地給這些函數提供一個共用的環境,在對象內部調用這些函數可以少傳許多參數,從而簡化函數調用,並且這樣一個對象也可以更方便地傳遞給系統的其他部分。”

“如果你們在編碼過程中,有遇到我剛才提到的那些問題,那就是一種壞味道。下次就可以用類似的重構手法進行重構了,當然,別忘了寫測試用例。”老耿對着小李二人説道。

小李小王瘋狂點頭。

“我們來看一下重構前後的對比。”

image

“那我們繼續。”

(九)依戀情節(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 訪問的都是自身模塊的數據,不再依戀其他模塊。我們運行一下測試用例。”

image

“ok,測試通過了,別忘了提交代碼。”老耿提交了一個 commit。

“我們來看一下重構前後的對比。”

image

“我們繼續下一個。”

(十)數據泥團(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); } } ```

“重構完成,我們運行測試代碼。”

image

“測試用例運行通過了,別忘了提交代碼。”

“在這裏我選擇新建一個類,而不是簡單的記錄結構,是因為一旦擁有新的類,你就有機會讓程序散發出一種芳香。得到新的類以後,你就可以着手尋找其他壞味道,例如“依戀情節”,這可以幫你指出能夠移至新類中的種種行為。這是一種強大的動力:有用的類被創建出來,大量的重複被消除,後續開發得以加速,原來的數據泥團終於在它們的小社會中充分發揮價值。”

“比如這裏,TelephoneNumber 類被提煉出來後,就可以去消滅那些使用到 telephoneNumber 的重複代碼,並且根據使用情況進一步優化,我就不做展開了。”

“我們來看一下重構前後的對比。”

image

“我們繼續講下一個壞味道。”

(十一)基本類型偏執(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(['美元', '元', '港幣', '美元']);

}); }); ```

“測試用例寫完以後運行一下,看看效果。”

image

“這個重構手法也比較簡單,先新建一個 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; } } ```

“重構完成後,運行測試用例。” 老耿按下運行鍵。

image

“測試用例運行通過了,別忘了提交代碼。”

“很多人對基本類型都有一種偏愛,他們普遍覺得基本類型要比類簡潔,但是,別讓這種偏愛演變成了 偏執。有些時候,我們需要走出傳統的洞窟,進入炙手可熱的對象世界。”

“這個案例演示了一種很常見的場景,相信你們以後也可以識別基本類型偏執這種壞味道了。”

小李小王瘋狂點頭。

“我們來看一下重構前後的對比。”

image

“那我們繼續吧。”

(十二)重複的 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'); } } } ```

“移除完成後,運行一下測試用例。”

image

“運行通過,接下來我們如法炮製,把 UsdPriceHkdPrice 也創建好,最後再將超類中的條件分支邏輯相關代碼都移除。” 老耿繼續寫代碼。

```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'); } } ```

“重構完成後,運行測試用例。”

image

“ok,運行通過,別忘了提交代碼。”

“這樣一來,修改對應的貨幣邏輯並不影響其他的貨幣邏輯,並且添加一種新的貨幣規則也不會影響到其他貨幣邏輯,修改和添加特性都變得簡單了。”

“複雜的條件邏輯是編程中最難理解的東西之一,最好可以將條件邏輯拆分到不同的場景,從而拆解複雜的條件邏輯。這種拆分有時用條件邏輯本身的結構就足以表達,但使用類和多態能把邏輯的拆分表述得更清晰。”

“就像我剛才演示的那樣。”

“我們來看一下重構前後的對比。“

image

“那我們繼續吧。”

(十三)循環語句(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,下一步是將 linesplit 切割,可以使用 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() })); }

“重構完成,運行測試用例。”

image

“測試通過,重構完成了,別忘了提交代碼。”

“重構完成後,再看這個函數,我們就可以發現,管道操作可以幫助我們更快地看清楚被處理的元素以及處理它們的動作。”

“可是。”小王舉手:“在性能上,循環要比管道的性能要好吧?”

“這是個好問題,但這個問題要從三個方面來解釋。”

“首先,這一部分時間會被用在兩個地方,一是用來做性能優化讓程序運行的更快,二是因為缺乏對程序的清楚認識而花費時間。”

“那我先説一下性能優化,如果你對大多數程序進行分析,就會發現它把大半時間都耗費在一小半代碼身上。如果你一視同仁地優化所有代碼,90 %的優化工作都是白費勁的,因為被你優化的代碼大多很少被執行。”

“第二個方面來説,雖然重構可能使軟件運行更慢,但它也使軟件的性能優化更容易,因為重構後的代碼讓人對程序能有更清楚的認識。”

“第三個方面來説,隨着現代電腦硬件發展和瀏覽器技術發展,很多以前會影響性能的重構手法,例如小函數,現在都不會造成性能的影響。以前所認知的性能影響觀點也需要與時俱進。”

“這裏需要引入一個更高的概念,那就是使用合適的性能度量工具,真正對系統進行性能分析。哪怕你完全瞭解系統,也請實際度量它的性能,不要臆測。臆測會讓你學到一些東西,但十有八九你是錯的。”

“所以,我給出的建議是:除了對性能有嚴格要求的實時系統,其他任何情況下“編寫快速軟件”的祕密就是:先寫出可調優的軟件,然後調優它以求獲得足夠的速度。短期看來,重構的確可能使軟件變慢,但它使優化階段的軟件性能調優更容易,最終還是會得到好的效果。”

“我們來看一下重構前後的對比。”

image

“那我們繼續下一個。”

(十四)宂贅的元素(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,很簡單,重構完成了,我們運行測試用例。”

image

“用例測試通過了。如果你想再精簡一點,可以再修改一下。”

js function reportLines(aCustomer) { return [ ['name', aCustomer.name], ['location', aCustomer.location] ]; }

“運行測試用例... 通過了,提交代碼。”

“在重構的過程中會發現越來越多可以重構的新結構,就像我剛才演示的那樣。”

“像這類的宂贅的元素存在並沒有太多的幫助,所以,讓它們慷慨赴義�去吧。”

“我們來看看重構前後的對比。”

image

“我們繼續。”

(十五)誇誇其談通用性(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 直接對物流信息進行管理。在重構完成後,運行測試用例。”

image

“用例運行通過了,這時候再把之前應用到 Shipment 的地方進行調整。當然,更穩妥的辦法是先使用 ShipmentNew 類進行替換後,再刪除原來的類。這裏我還是回退一下代碼,你們倆去評估一下影響點,再自己來重構吧。” 老耿回退了代碼。

小李小王瘋狂點頭。

“關於代碼通用性設計,如果所有裝置都會被用到,就值得那麼做;如果用不到,就不值得。用不上的裝置只會擋你的路,所以,把它搬開吧。”

“我們來看看重構前後的對比。”

image

“我們繼續吧。”

(十六)臨時字段(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 }; } }

“實現完成後,運行一下測試用例。”

image

“我引入了這個特例對象後,我只需要在初始化 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; } ```

“在這裏我就不對你們的代碼做實際修改了,你們下去以後自己調整一下吧。”

小李小王瘋狂點頭。

“我們來看一下重構前後的對比。”

image

“我們繼續下一個。”

(十七)過長的消息鏈(Message Chains)

js const result = a(b(c(1, d(f()))));

“這種壞味道我手寫代碼演示一下,比如向一個對象請求另一個對象,然後再向後者請求另一個對象,然後再請求另一個對象……這就是消息鏈。在實際代碼中,看到的可能就是一長串取值函數或一長串臨時變量。”

“這種一長串的取值函數,可以使用的重構手法就是 提煉函數,就像這樣。”

```js const result = goodNameFunc();

function goodNameFunc() { return a(b(c(1, d(f())))); } ```

“再給提煉出來的函數,取一個好名字就行了。”

“還有一種情況,就是委託關係,需要隱藏委託關係。我就不做展開了,你們有興趣的話去看一看重構那本書吧。“

“我們來看一下重構前後的對比。”

image

我們繼續下一個。”

(十八)中間人(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; } } ```

“調整完成後,直接運行測試用例。”

image

“測試用例通過了,別忘了把使用到 Product 的地方都檢查一遍。”

“很難説什麼程度的隱藏才是合適的。但是有隱藏委託關係和刪除中間人,就可以在系統運行過程中不斷進行調整。隨着代碼的變化,“合適的隱藏程度” 這個尺度也相應改變。”

“我們來看看重構前後的對比。”

image

“我們繼續下一個吧。”

(十九)內幕交易(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 類,而不會直接波及所有客户端。”

“我們運行一下測試代碼。”

image

“運行通過了,在所有代碼替換完成前,可以先保留對 department 的訪問,在所有代碼都修改完成後,再完全移除,提交代碼。”

“我們來看看重構前後的對比。”

image

“我們繼續下一個。”

(二十)過大的類(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 改造完成。接下來,我們先把 EmployeeDepartment 相同的字段和行為提煉出來,提煉成一個超類 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; } }

“這樣就完成了改造,運行一下測試用例。”

image

“測試通過了。記得把其他使用到這兩個類的地方改造完成後再提交代碼。”

“如果看見兩個異曲同工的類在做相似的事,可以利用基本的繼承機制把它們的相似之處提煉到超類。”

“有很多時候,合理的繼承關係是在程序演化的過程中才浮現出來的:我發現了一些共同元素,希望把它們抽取到一處,於是就有了繼承關係。所以,先嚐試用小而快的重構手法,重構後再發現新的可重構結構。”

“我們來看一下重構前後的對比。”

image

“我們繼續下一個。”

(二十二)純數據類(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(); } } ```

“然後我們運行一下測試用例。”

image

“用例運行成功了,別忘了提交代碼。” 老耿打了個 commit。

“我們需要為純數據賦予行為,或者使用純數據類來控制數據的讀寫。否則的話,純數據類並沒有太大存在的意義,應該作為宂贅元素被移除。”

“我們來看一下重構前後的對比。”

image

“那我們繼續下一個。”

(二十三)被拒絕的遺贈(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; } } ```

“關於這個壞味道,我想改造一下之前那個 EmployeeDepartment 的例子來進行講解。”

“這個例子可以看到,我把 staff 字段從 Department 上移到了 Party 類,但其實 Employee 類並不關心 staff 這個字段。這就是 被拒絕的遺贈 壞味道。”

“重構手法也很簡單,就是把 staff 字段下移到真正需要它的子類 Department 中就可以了,就像我剛完成提煉超類那時的樣子。”

“如果超類中的某個字段或函數只與一個或少數幾個子類有關,那麼最好將其從超類中挪走,放到真正關心它的子類中去。”

“十有八九這種壞味道很淡,需要對業務熟悉程度較高才能發現。”

“我們來看一下重構前後的對比。”

image

“那我們繼續下一個。”

(二十四)註釋(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 鼓勵一下吧!