11年前有幸閱讀了《重構——改善既有代碼的設計》第一版,當時是一口氣讀完的,書中的內容直接驚艷到我了。
今年讀了該書的第二版,再次震撼到我了,並且這次的示例代碼用的JavaScript,讓我更有親切感。
全書共有12章,前面5章是在講解重構的原則、測試、代碼的壞味道等內容,后面7章是各種經驗和實踐,全書的精髓所在。
在這些年的編程生涯中,或多或少地使用着一些重構手法,得益於這些手法,讓我在編程時能更加的游刃有余。
通篇讀下來后,個人總結了重構的秘訣,8個字:消除重復,清晰意圖。
書中會從各種角度,全方位的來闡述作者的重構心得,我會將平時常用的幾個手法摘錄到本文中,可快速應用於實際項目中。
一、第一組重構
1)提煉函數
有的觀點從復用的角度考慮,認為只要被用過不止一次的代碼,就應該單獨放進一個函數;只用過一次的代碼則保持內聯(inline)的狀態。
但作者認為最合理的觀點 是“將意圖與實現分開”:如果你需要花時間瀏覽一段代碼才能弄清它到底在干什么,那么就應該將其提煉到一個函數中,並根據它所做的事為其命名。
function printOwing(invoice) { printBanner(); let outstanding = calculateOutstanding(); //print details console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); } // 重構后 function printOwing(invoice) { printBanner(); let outstanding = calculateOutstanding(); printDetails(outstanding); function printDetails(outstanding) { console.log(`name: ${invoice.customer}`); console.log(`amount: ${outstanding}`); } }
2)提煉變量
表達式有可能非常復雜而難以閱讀。這種情況下,局部變量可以幫助我們將 表達式分解為比較容易管理的形式。
在面對一塊復雜邏輯時,局部變量使我能給其中的一部分命名,這樣我就能更好地理解這部分邏輯是要干什么。
return order.quantity * order.itemPrice Math.max(0, order.quantity - 500) * order.itemPrice * 0.05 + Math.min(order.quantity * order.itemPrice * 0.1, 100); //重構后 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;
3)變量改名
好的命名是整潔編程的核心。變量可以很好地解釋一段程序在干什么——如 果變量名起得好的話。
只在一行的lambda表達式中使用的變量,跟蹤起來很容易——像這樣的變量,作者經常只用一個字母命名,因為變量的用途在這個上下文中很清晰。
對於作用域超出一次函數調用的字段,則需要更用心命名。
let a = height * width; //重構后 let area = height * width;
4)引入參數對象
一組數據項總是結伴同行,出沒於一個又一個函數。這樣一組數據就是所謂的數據泥團,作者喜歡代之以一個數據結構。
將數據組織成結構是一件有價值的事,因為這讓數據項之間的關系變得明晰。
使用新的數據結構,參數的參數列表也能縮短。並且經過重構之后,所有使用該數據結構的函數都會通過同樣的名字來訪問其中的元素,從而提升代碼的一致性。
function amountInvoiced(startDate, endDate) {...} function amountReceived(startDate, endDate) {...} function amountOverdue(startDate, endDate) {...} //重構后 function amountInvoiced(aDateRange) {...} function amountReceived(aDateRange) {...} function amountOverdue(aDateRange) {...}
5)拆分階段
每當看見一段代碼在同時處理兩件不同的事,作者就想把它拆分成各自獨立的模塊。
因為這樣到了需要修改的時候,就可以單獨處理每個主題,而不必同時在腦子里考慮兩個不同的主題。
最簡潔的拆分方法之一,就是把一大段行為分成順序執行的兩個階段。舉個簡單的例子:
編譯器的任務可拆分成一系列階段:首先對文本做詞法分析,然后把token解析成語法樹,再對語法樹做幾步轉換(如優化),最后生成目標碼。
每一步都有邊界明確的范圍,讓人可以聚焦思考其中一步,而不用理解其他步驟的細節。
const orderData = orderString.split(/\s+/); const productPrice = priceList[orderData[0].split("-")[1]]; const orderPrice = parseInt(orderData[1]) * productPrice; //重構后 const orderRecord = parseOrder(order); const orderPrice = price(orderRecord, priceList); function parseOrder(aString) { const values = aString.split(/\s+/); return { productID: values[0].split("-")[1], quantity: parseInt(values[1]) }; } function price(order, priceList) { return order.quantity * priceList[order.productID]; }
6)替換算法
如果發現做一件事可以有更清晰的方式,那么就會用比較清晰的方式取代復雜的方式。
“重構”可以把一些復雜的東西分解為較簡單的小塊,但有時你就必須壯士斷腕,刪掉整個算法,代之以較簡單的算法。
可以先把原先的算法替換為一個較易修改的算法,這樣后續的修改會輕松許多。
使用這項重構手法之前,得確定自己已經盡可能分解了原先的函數。
替換一個巨大且復雜的算法是非常困難的,只有先將它分解為較簡單的小型函數,才能很有把握地進行算法替換工作。
function foundPerson(people) { for (let i = 0; i < people.length; i++) { if (people[i] === "Don") { return "Don"; } if (people[i] === "John") { return "John"; } if (people[i] === "Kent") { return "Kent"; } } return ""; } //重構后 function foundPerson(people) { const candidates = ["Don", "John", "Kent"]; return people.find((p) => candidates.includes(p)) || ""; }
7)移除標記參數
“標記參數”是這樣的一種參數:調用者用它來指示被調函數應該執行哪一部分邏輯。
標記參數會隱藏函數調用中存在的差異性。使用這樣的函數,還得弄清標記參數有哪些可用的值。
布爾型的標記尤其糟糕,因為它們不能清晰地傳達其含義。在調用一個函數時,很難弄清true到底是什么意思。
如果明確用一個函數來完成一項單獨的任務,其含義會清晰得多。並非所有類似這樣的參數都是標記參數。
如果調用者傳入的是程序中流動的數據,這樣的參數不算標記參數;只有調用者直接傳入字面量值,這才是標記參數。
另外,在函數實現內部,如果參數值只是作為數據傳給其他函數,這就不是標記參數;只有參數值影響了函數內部的控制流,這才是標記參數。
去掉標記參數后,代碼分析工具能更容易地體現出“高級”和“普通”兩種預訂邏輯在 使用時的區別。
function setDimension(name, value) { if (name === "height") { this._height = value; return; } if (name === "width") { this._width = value; return; } } //重構后 function setHeight(value) { this._height = value; } function setWidth(value) { this._width = value; }
二、搬移特性
1)搬移字段
每當調用某個函數時,除了傳入一個記錄參數,還總是需要同時傳入另一條記錄的某個字段一起作為參數。
總是一同出現、一同作為函數參數傳遞的數據,最好是規整到同一條記錄中,以體現它們之間的聯系。
如果修改一條記錄時, 總是需要同時改動另一條記錄,那么說明很可能有字段放錯了位置。
此外,如果更新一個字段時,需要同時在多個結構中做出修改,那也是一個征兆,表明該字段需要被搬移到一個集中的地點,這樣每次只需修改一處地方。
class Customer { get plan() { return this._plan; } get discountRate() { return this._discountRate; } } //重構后 class Customer { get plan() { return this._plan; } get discountRate() { return this.plan.discountRate; } }
2)搬移語句到函數
要維護代碼庫的健康發展,需要遵守幾條黃金守則,其中最重要的一條當屬“消除重復”。
如果發現調用某個函數時,總有一些相同的代碼也需要每次執行,那么可以考慮將此段代碼合並到函數里頭。
這樣,日后對這段代碼的修改只需改一處地方,還能對所有調用者同時生效。
如果某些語句與一個函數放在一起更像一個整體,並且更有助於理解,那就會毫不猶豫地將語句搬移到函數里去。
如果它們與函數不像一個整體,但仍應與函數一起執行,那可以用提煉函數將語句和函數一並提煉出去。
result.push(`<p>title: ${person.photo.title}</p>`); result.concat(photoData(person.photo)); function photoData(aPhoto) { return [ `<p>location: ${aPhoto.location}</p>`, `<p>date: ${aPhoto.date.toDateString()}</p>` ]; } //重構后 result.concat(photoData(person.photo)); function photoData(aPhoto) { return [ `<p>title: ${aPhoto.title}</p>`, `<p>location: ${aPhoto.location}</p>`, `<p>date: ${aPhoto.date.toDateString()}</p>` ]; }
3)搬移語句到調用者
作為程序員,我們的職責就是設計出結構一致、抽象合宜的程序,而程序抽象能力的源泉正是來自函數。
與其他抽象機制的設計一樣,我們並非總能平衡好抽象的邊界。
隨着系統能力發生演進(通常只要是有用的系統,功能都會演 進),原先設定的抽象邊界總會悄無聲息地發生偏移。
對於函數來說,這樣的邊界偏移意味着曾經視為一個整體、一個單元的行為,如今可能已經分化出兩個甚至是多個不同的關注點。
函數邊界發生偏移的一個征兆是,以往在多個地方共用的行為,如今需要在某些調用點面前表現出不同的行為。於是,得把表現不同的行為從函數里挪出,並搬移到其調用處。
emitPhotoData(outStream, person.photo); function emitPhotoData(outStream, photo) { outStream.write(`<p>title: ${photo.title}</p>\n`); outStream.write(`<p>location: ${photo.location}</p>\n`); } //重構后 emitPhotoData(outStream, person.photo); outStream.write(`<p>location: ${person.photo.location}</p>\n`); function emitPhotoData(outStream, photo) { outStream.write(`<p>title: ${photo.title}</p>\n`); }
4)移動語句
讓存在關聯的東西一起出現,可以使代碼更容易理解。
如果有幾行代碼取用了同一個數據結構,那么最好是讓它們在一起出現,而不是夾雜在取用其他數據結構的代碼中間。
最簡單的情況下,只需使用移動語句就可以讓它們聚集起來。
此外還有一種常見的“關聯”,就是關於變量的聲明和使用。有人喜歡在函數頂部一口氣聲明函數用到的所有變量,作者則喜歡在第一次需要使用變量的地 方再聲明它。
通常來說,把相關代碼搜集到一處,往往是另一項重構(通常是在提煉函數)開始之前的准備工作。
const pricingPlan = retrievePricingPlan(); const order = retreiveOrder(); let charge; const chargePerUnit = pricingPlan.unit; //重構后 const pricingPlan = retrievePricingPlan(); const chargePerUnit = pricingPlan.unit; const order = retreiveOrder(); let charge;
5)拆分循環
如果你在一次循環中做了兩件不同的事,那么每當需要修改循環時,你都得同時理解這兩件事情。
如果能夠將循環拆分,讓一個循環只做一件事情,那就能確保每次修改時你只需要理解要修改的那塊代碼的行為就可以了。
拆分循環還能讓每個循環更容易使用。如果一個循環只計算一個值,那么它直接返回該值即可;但如果循環做了太多件事,那就只得返回結構型數據或者通過局部變量傳值了。
如果重構之后該循環成了性能的瓶頸,屆時再把拆開的循環合到一起也很容易。
let averageAge = 0; let totalSalary = 0; for (const p of people) { averageAge += p.age; totalSalary += p.salary; } averageAge = averageAge / people.length; //重構后 let totalSalary = 0; for (const p of people) { totalSalary += p.salary; } let averageAge = 0; for (const p of people) { averageAge += p.age; } averageAge = averageAge / people.length;
6)以管道取代循環
時代在發展,如今越來越多的編程語言都提供了更好的語言結構來處理迭代過程,這種結構就叫作集合管道。
集合管道是這樣一種技術,它允許使用一組運算來描述集合的迭代過程,其中每種運算 接收的入參和返回值都是一個集合。
這類運算有很多種,最常見的則非map和 filter莫屬。運算得到的集合可以供管道的后續流程使用。
作者發現一些邏輯如果采用集合管道來編寫,代碼的可讀性會更強——只需從頭到尾閱讀一遍代碼,就能弄清對象在管道中間的變換過程。
const names = []; for (const i of input) { if (i.job === "programmer") names.push(i.name); } //重構后 const names = input.filter((i) => i.job === "programmer").map((i) => i.name);
7)移除死代碼
事實上,我們部署到生產環境甚至是用戶設備上的代碼,從來未因代碼量太大而產生額外費用。
就算有幾行用不上的代碼,似乎也不會因此拖慢系統速度,或者占用過多的內存,大多數現代的編譯器還會自動將無用的代碼移除。
但當你嘗試閱讀代碼、理解軟件的運作原理時,無用代碼確實會帶來很多額外的思維負擔。
它們周圍沒有任何警示或標記能告訴程序員,讓他們能夠放心忽略這段函數,因為已經沒有任何地方使用它了。
當程序員花費了許多時間,嘗試理解它的工作原理時,卻發現無論怎么修改這段代碼都無法得到期望的輸出。
一旦代碼不再被使用,我們就該立馬刪除它。有可能以后又會需要這段代碼,可以從版本控制系統里再次將它翻找出來。
三、簡化條件邏輯
1)分解條件表達式
程序之中,復雜的條件邏輯是最常導致復雜度上升的地點之一。
必須編寫代碼來檢查不同的條件分支,根據不同的條件做不同的事,然后很快就會得到一個相當長的函數。
大型函數本身就會使代碼的可讀性下降,而條件邏輯則會使代碼更難閱讀。
在帶有復雜條件邏輯的函數中,代碼(包括檢查條件分支的代碼和真正實現功能的代碼)會告訴我發生的事,但常常讓我弄不清楚為什么會發生這樣的事,這就說明代碼的可讀性的確大大降低了。
可以將它分解為多個獨立的函數,根據每個小塊代碼的用途,為分解而得的新函數命名,並將原函數中對應的代碼改為調用新函 數,從而更清楚地表達自己的意圖。
對於條件邏輯,將每個分支條件分解成新函數還可以帶來更多好處:可以突出條件邏輯,更清楚地表明每個分支的作用,並且突出每個分支的原因。
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) charge = quantity * plan.summerRate; else charge = quantity * plan.regularRate + plan.regularServiceCharge; //重構后 if (summer()) charge = summerCharge(); else charge = regularCharge();
2)合並條件表達式
當檢查條件各不相同,但最終行為卻一致。如果發現這種情況,就應該使用“邏輯或”和“邏輯與”將它們合並為一個條件表達式。
之所以要合並條件代碼,有兩個重要原因。首先,合並后的條件代碼會表述“實際上只有一次條件檢查,只不過有多個並列條件需要檢查而已”,從而使這一次檢查的用意更清晰。
當然,合並前和合並后的代碼有着相同的效果,但原先代碼傳達出的信息卻是“這里有一些各自獨立的條件測試,它們只是恰好同時發生”。
其次,這項重構往往可以為使用提煉函數做好准備。將檢查條件提煉成一個獨立的函數對於厘清代碼意義非常有用,因為它把描述“做什么”的語句換成了“為什么這樣做”。
if (anEmployee.seniority < 2) return 0; if (anEmployee.monthsDisabled > 12) return 0; if (anEmployee.isPartTime) return 0; //重構后 if (isNotEligibleForDisability()) return 0; function isNotEligibleForDisability() { return ( anEmployee.seniority < 2 || anEmployee.monthsDisabled > 12 || anEmployee.isPartTime ); }
3)以衛語句取代嵌套條件表達式
條件表達式通常有兩種風格。第一種風格是:兩個條件分支都屬於正常行為。第二種風格則是:只有一個條件分支是正常行為,另一個分支則是異常的情況。
如果兩條分支都是正常行為,就應該使用形如if...else...的條件表達式;如果某個條件極其罕見,就應該單獨檢查該條件,並在該條件為真時立刻從函數中返回。
這樣的單獨檢查常常被稱為“衛語句”。以衛語句取代嵌套條件表達式的精髓就是:給某一條分支以特別的重視。如果使用if-then-else結構,你對if分支和else分支的重視是同等的。
這樣的代碼結構傳遞給閱讀者的消息就是:各個分支有同樣的重要性。
衛語句就不同了,它告訴閱讀者:“這種情況不是本函數的核心邏輯所關心的,如果它真發生了,請做一些必要的整理工作,然后退出。”
function getPayAmount() { let result; if (isDead) result = deadAmount(); else { if (isSeparated) result = separatedAmount(); else { if (isRetired) result = retiredAmount(); else result = normalPayAmount(); } } return result; } //重構后 function getPayAmount() { if (isDead) return deadAmount(); if (isSeparated) return separatedAmount(); if (isRetired) return retiredAmount(); return normalPayAmount(); }