--- ###### tags: `學習筆記`、`Coding Concepts` --- # 重構:改善既有的程式設計(第二版) > 這是一篇讀書心得,主要分為兩部分:重構的核心概念,以及實際的重構solution。我會將重點著重在前者,後者若有時間的話會列出我覺得重要的部分。 ## 寫在閱讀之前:目標 - 了解什麼是重構。 - 了解為什麼需要重構。 - 找出程式碼中哪裡需要重構。 - 如何開始重構的第一步。 - 重構的實際案例 ## 第一章:重構:第一個範例 (範例程式碼寫上去) 幾個重點: - 簡單來說,重構是改善已經寫好的設計。這聽起來有點怪,因為大多數軟體都是「先設計、後工程」,但是隨著時間過去,程式碼不停被修改,會使得程式碼從依循既有的設計,變為隨意更動的混亂。重構則是將不良/混亂的程式碼抽出來,重新修改為良好的設計。 - 更精確地說,重構是指「對軟體內部結構進行變動,在不改動軟體可見行為(observable behavior)的前提下,提高它的可理解性,並降低修改他的成本」 - ~~靈芝的好壞取決於多醣體。~~ 程式的好壞取決於它多清楚:夠清楚,代表閱讀的『人』能更輕鬆地讀懂程式碼,找到哪個函式/元件是需要修改/新增的。糟糕的程式碼則難以閱讀、修改時需要重新閱讀的時間極高、且容易改壞掉。 > 在我看來,好的程式碼有三個要件: > - 易讀性(下一個接手的工程師可以看懂你每行code在幹嘛) > - 分離性(一個複雜的code裡一定包含很多功能,而好的code能將這些功能拆成不同子函式/元件,每個子函式/元件僅肩負單一任務) > - 複用性(子函式/元件可以被複用,而不用再複製同樣的程式碼去處理幾乎同樣的任務) - 如何有效地重構,關鍵在於採取小步驟:將重構的整個工程,拆解成數個單一任務,並盡可能在每個任務中加入測試。好處有二 - 如果重構發生錯誤(ex程式碼不能動),能夠更快找到錯誤在哪。如果找不到,revert回上個版本的成本也較低。 - 程式碼不會長時間維持無法運轉的情形:可以重構完一小部分,就暫停去做別的功能,而程式碼還是可以work。 > 而且對工程師而言,專注於更小的改動,能夠有更即時的feedback,更能掌握重構的進度,有益身心健康(?。 ## 重構的原理——什麼是重購、為什麼需要重構? - 重構是指「對軟體內部結構進行變動,在不改動軟體可見行為(observable behavior)的前提下,提高它的可理解性,並降低修改他的成本」。 - 「可見行為」是指「程式碼執行後所呈現的結果」:照理來說,重構前後,程式碼做的事情不會改變,但有些重構方法會使改變執行內容(比如改變call stack順序),但如果執行的結果對使用者而言沒有任何變化,就是合格的重構。 ### 為何需要重構?重構的目的 1. 改善軟體的設計:減少重複造輪子,提升函式/元件的復用性。 3. 讓軟體更容易理解:好的命名可以讓接手的工程師不會發瘋。 4. 幫助找出bug:重構的過程能幫助工程師了解既有程式碼的架構、找出/改善潛在的bug。 6. 提升新功能的開發速度:好的程式架構有幾個優點 - 可以讓人快速找出如何與哪裡該新增feature - 良好的模組化可以提高函式/元件的復用性、加快feature的開發速度 :::info 重構的最終目標不是寫出「簡潔的程式碼」,而只是為了提升開發功能與修復bug的速度。 ::: ### 何時要進行重構? > 我自己的感受:當我發現目前的程式碼難以閱讀、有重複的程式碼在做同一件事時、未來可能有部分元件/函式可以復用時,就是重構的好時機。 > 作者有提到一個重要的概念「營地原則」:重構不是要求程式碼能夠一次重構到位,而是至少讓它比原本更好一點。就像營地不可能一晚上從素亂不整變得整潔有序,但至少可以讓它稍微宜居一點。 1. 預備性重構:加入新功能前,重構既有的函式 在加入新功能或修復bug前,可以先花點時間進行重構:比如先檢視既有的函式能否復用,若發現有相似的函式,可對既有的函式參數化,並用於新功能中,減少重複造輪子。 ```javascript= function tenPercentRaise(price){ return newPrice = price*1.1 } function fivePercentRaise(price){ return newPrice = price*1.05 } //變成 function priceRaise(price, raisePercent){ return newPrice = price*(1 + raisePercent) } ``` 2. 理解性重構:重新命名,讓程式碼更容易被理解 4. 打掃性重構:可以理解程式碼在幹嘛,但他用了很糟糕的方式在運用(比如多個重複的程式碼、邏輯無謂的重複,函式/元件的任務不清楚),這時就可以進行重構。 6. 計畫性重構與長期重構: 雖然大部分的重構都是在開發的過程中完成的,但不可避免的,還是會有大規模的重構需要執行。作者認為,當這種情況發生時,可以採「重構不會破壞程式碼運行」的前提下,小步驟的修改。 ### 何時「不該」重構? 1. 不需要修改的程式 2. 整個重寫比重構更簡單時 ### 重構與實作新功能 重構是對既有的程式碼進行修改,與其相對的是實作新功能,作者對兩者的關係有以下見解: 1. 就程式碼而言,作者認為一個簡單的重構,可以加速實作新功能的速度。 2. 就工程師的work process而言,可以把「重構」與「實作新功能」想像成「兩頂帽子」:當你寫一個新的功能時,可能先戴上「實作帽」,寫一段功能後,改戴「重構帽」,重構剛寫好的功能,反覆而之。**重點在於,實作時不要做重構;重構時不要寫新功能,一次做好一件事。** 3. 就程式碼的commit與pull request而言,作者不建議把「重構」與「實作新功能」分成不同的commit或是PR,因爲重構大多與新功能有著上下文關係(eg.在實作前重構),如果拆分的話,會使得這種關係變得不清楚,更會移除重構背後的成因,讓重構變得單薄且沒有說服力。 > 其實不一定要把重構放在同個PR,因為如果重構所牽涉的範圍太大的話,反而會導致code review要花的時間過多,導致PR卡住,拖延開發進度。 > 但如果牽涉的範圍很小的話,是可以放在同個PR的,但仍建議不要放在同個commit。 4. 就團隊協作而言,重構也可能會影響到其他成員的實作(比如說我把function A改名了,但其他人有import這個函式的話,就會有語意衝突)。比較好的辦法縮小單次重構的範圍與時間,並持續與主線整合。 ### 重構與架構 - 傳統上認為程式架構在撰寫程式碼前就要設計並確定了,但這種預設假定了架構的設計者要十分了解需求,而且這種需求不會變,但更常發生的是,但真正接觸到程式碼的使用者後,需求才確定或大幅改變。 - 要處理需求上的變化,其中一條路是「埋入大量的彈性機制」,比如說在函式裡面十幾個參數,以面對可能的使用情境。 - 但這樣的作法會使得函式變得過於複雜——試想若這些參數相互參照,刪除/新增其中一個參數都可以使得函式壞掉,維護起來會是一件多恐怖且耗時的事。簡單來說,**過多彈性機制其實會增加對「變化」反應的時間** - 作者認為,比較好的方式其實是「僅針對已知需求去設計程式架構」,而如果需求有變化的話,則重構程式架構。如此既可以保持程式架構的一致性,又能夠面對需求的變化。 > 僅因為「明確的需求改變」而調整程式碼架構。作者認為是在加快未來實作新功能的速度 ### 重構與性能 - 重構與性能優化關注的目標不同:雖然兩者皆不改變程式碼的功能,但**重構目標在「讓程式碼更好地被理解與修改」**,但可能會降低速度;性能優化則只在乎**程式碼運行的速度**,因此可能會需要寫出更難以修改的程式碼。 - 但作者也認為,重構與性能優化並不是水火不容:做性能優化時,可以先將程式調整成好閱讀、好調整的程式碼,著手執行性能優化的速度會較快、難度也較低。 ## 第三章:程式碼異味——什麼時候要開始重構 > 前面談的都是程式碼重構的原則與好處,現在則是開始介紹「遇到什麼樣的情境可以著手重構」。 作者認為,當程式碼出現這些徵兆時,就可以著手進行重構。作者使用了「異味」(Smell)形容這些 時間。 > 可以想成,待重構的程式碼其實都有一些hint在告訴我們「該做重構了」,「異味」就是指這個hint。 以下將介紹這些異味出現的情境,以及一些解決異味的方法 - Mysterious name - 情境:函式、模組、變數、類別的名稱難以理解。好的名稱可以使得程式碼清楚傳達它的功能與如何被使用。壞的名稱則容易讓我們猜半天。 - 解法:重新命名,包含`change function declaration`, `rename variable`, `rename field`等。重新命名不僅只牽涉到改變名稱而已,如果無法想出好名稱的話,通常代表著程式碼本身的設計不良,這時就需要搭配其他重構手法,比如簡化程式碼等。 - Deplicated Code - 情境:在一個以上的地方看到重複的程式碼。如何判斷重複:當你看到兩個相同/相似的程式碼時,都要仔細閱讀是否相同/相似;如果兩個都要修改,也要仔細抓出重複的地方。 - 解法:如果是同一個函式但被用在不同地方,可以先`extract function`後在不同地方呼叫它;如果是兩個相似的函式的話,則可以先透過`Slide statement`整理程式碼,並考慮參數化的可能性;如果是不同subclass中有同樣的method,則可以透過`pull up method`提取至basic class。 - Long Function - 情境:雖然叫做Long function,但其實函式的長度不是重點,能不能清楚表達其**功能**與**如何運作**才是重點。假設我將一個大函式中提取出好幾個小函式,或許整體的長度變長了,但韓式本身的結構變得更清楚了。 - 解法: - `extract function`:從大函式中,將一個或多個但重複的程式碼提取成一個函式,並在大函式中呼叫它。 - `replace Temp with Query`: 如果暫時變數過多,可以將他提取變成函式,減少傳入的頻率,避免傳入過多變數給予提取出來的函式,閱讀性提升不高。 :::spoiler example ```javascript= function getPrice(quantity, item){ const basePrice = quantity * item if (basePrice > 1000){ return basePrice * 0.95 }else { return basePrice * 0.98 } } // 如果只是單純的提取函式,會變成 function getPrice(quantity, item){ function basePrice(quantity, item){ return quantity * item } if (basePrice() > 1000){ return basePrice() * 0.95 }else { return basePrice() * 0.98 } } //作者建議可以將改為這樣 function getPrice(){ if (basePrice() > 1000){ return basePrice() * 0.95 }else { return basePrice() * 0.98 } } function basePrice(quantity, item){ return quantity * item } //這麼做有幾個好處: //1. getPrice不需傳入變數給basePrice了, // getPrice的功能變得更單純:計算要不要給折扣。 //2. basePrice可以給其他的函式使用。 ``` ```javascript= class Order { constructor(quantity, item) { this.item = item; this.quantity = quantity; } get price(){ var basePrice = this.quantity * this.item; var discountFactor = 0.98 if(basePrice >1000){discountFactor = 0.95} return basePrice * discountFactor } } //如果說basePrice在其他地方也要用,可以將它提取出來,讓其他getter也可以使用 class Order { constructor(quantity, item) { this.item = item; this.quantity = quantity; } get price(){ const basePrice =this.basePirce var discountFactor = 0.98 if(basePrice >1000){discountFactor = 0.95} return basePrice * discountFactor } get basePrice(){ return this.item * this.quantity } } ``` ::: - `Introduce Parameter Object` & `Preserve whole Object`:簡化一長串的參數,詳見`Long Parameter List` - `Replace Function with Command`(這個我真的看不懂) - Long Parameter List - 情境:函式所需的參數如果過多,會讓人難以理解 - 解法: - 如果可以透過查詢a參數取得其他參數,可使用`Replace Parameter with Query` :::spoiler example ```javascript= function availableVacation(employee, grade){ ... } availableVacation(employee, employee.grade) //變成 function availableVacation(employee){ const grade = employee.grade ... } availableVacation(employee) ``` ::: - 如果不想要從同個物件中取出太多值作為參數,可以使用`Preserve Whole Object` :::spoiler example ```javascript= const temperature = { low: 25, high: 30, average: 28, humidity: '20%', isRainyDay: false, ... } function withinRange (low,high) { return low > 15 || high < 35 } const low = temperature.low const high = temperature.high withinRange(low, high) //變成 function withinRange (todayTemperature) { return todayTemperature.low > 15 || todayTemperature.high < 35 } //傳入整個資料,並由函式自行取出需要的值。引用的參數從多個變成一個 //但作者有時候不會做這種重構,因為這會使函式與資料具有依賴性。 ``` ::: - 如果許多參數總是同時出現,代表他們是一種「資料泥團」(data clump),可以使用`introduce Parawmeter Object` 結合成一個資料結構 :::spoiler example ```javascript= const station = { name: 'stationA', records:[ {temp: 27, time:'2022/5/7/ 15:00:00'}, {temp: 25, time:'2022/5/7/ 15:05:00'}, {temp: 29, time:'2022/5/7/ 15:10:00'}, {temp: 31, time:'2022/5/7/ 15:15:00'}, ] } const operationPlan = { temperatureFloor: 20, temperatureCeiling: 30, } function recordsOutsideRange(station, min, max){ return station.records.filter( (r) => r.temp < min ||r.temp > max ) } const alert = recordsOutsideRange(station, operationPlan.temperatureFloor,operationPlan.temperatureCeiling) //變成 class NumberRange { constructor(min,max){ this._data = {min:min,max:max} } get min(){ return this._data.min} get max(){ return this._data.max} } const range = new NumberRange(operationPlan.temperatureFloor, operationPlan.temperatureCeiling) function recordsOutsideRange(station, range){ return station.records.filter( (r) => r.temp < range.min ||r.temp > range.max ) } const alert = recordsOutsideRange(station, range) //這種重構的好處在於,參數列會變短、清楚展示資料泥團之間的關係、 //程式碼的任務變得更清楚:由NumberRange()決定範圍在哪, //recordsOutsideRange()只判斷是否參數有超出範圍。 ``` ::: - 如果參數出現太多flag argument,可以使用`Remove Flag Argument` :::spoiler example ```javascript= //flag argumet是指呼叫方用來決定該執行函式內部哪個邏輯的argument。比如說 //another.js function fetchData(data, isPremium){ if(isPremium){...} else{...} } //main.js import fetchData from 'another.js' function getMemberData(memberData,isPremium){ fetchData(memberData,isPremium) } //這邊的isPremium就是一個flag argument。 //作者認為flag argument在呼叫的時候,難以辨識它的用途, //常常會需要翻function內部如何使用它,降低閱讀性。作者認為有幾個做法, //1.將flag argument放在呼叫方,而非被呼叫的函式裡面: //another.js function fetchPremiumData(){...} function fetchNormalData(){...} //main.js import {fetchPremiumData, fetchNormalData} from 'another.js' function getMemberData(isPremium){ if(isPremium){fetchPremiumData()} else {fetchNormalData(){...}} } //重構前:需要到another.js才能知道isPremium的用途, //重構後:在main.js就能知道isPremium是用於決定執行哪種邏輯。 //2.原始函式外面再包一層函式。 //有時候flag argument在原本函式中被大量使用,難以直接重構,比如說這樣 function fetchData(data, isPremium){ if(data.name ==='會員'){ //logic A } else if (data.name ==='非會員' && !isPremium){ //logic B } else if (!isPremium){ //logic C } } //或是更複雜,很難把flag argument抽出來,這時作者建議可以這樣做 function function fetchPremiumData(data){ return fetchData(data, true) } function function fetchNormalData(data){ return fetchData(data, false) } //這樣的好處是可以透過外面那層函式的名稱,知道fetchData的用途, //但又不需要重構複雜的fetchData。 ``` ::: - Global Data - 情境:全域資料是非常可怕的,原因在於它可以被所有地方讀取、修改、新增、刪減等,但我們卻很難trace有哪些程式碼使用或修改了它。 - 解法:`Encapsulate variable` :::spoiler example 假設有一個全域變數長這樣: ```javascript= //defaultOwner.js let defaultOwnerData = { name: 'David', age: 26, gender: 'male' } ``` 這個全域變數是可以被其他所有程式碼access的,所以可以 ```javascript //讀取 const memberData = defaultOwnerData //修改 defaultOwnerData.age = 27 ``` 作者建議有兩種封裝的方式: 1. 封裝方法,讓讀取、修改被轉為一個函式,如果要更動全域變數,透過這種函式執行,能被更容易的追蹤與修改。 ```javascript= //defaultOwner.js let defaultOwnerData = { ... } export function defaultOwner(){return defaultOwnerData} export function setDefaultOwner(arg){defaultOwnerData =arg} ``` 這樣的好處在於: - 讀取、修改由函式負責且同一個地方管理(`defaultOwner.js`),能更好監看資料的變化與使用方式。 - 如果要修改讀取、修改的方式,只需要改一處的函式就好。 2. 封裝值,讓修改時僅修改副本的值,甚至不可被修改 ```javascript= //defaultOwner.js let defaultOwnerData = { ... } export function defaultOwner() {return Object.assign({},defaultOwnerData)} export function setDefaultOwner(arg){defaultOwnerData =arg} //another.js import {defaultOwner, setDefaultOwner} from 'defaultOwner.js' let memberData = defaultOwner() memberData.age = 99 console.log(memberData.age) //99 console.log(defaultOwnerData.age) //26 (沒有被更動到) ``` ```javascript= //defaultOwner.js let defaultOwnerData = { name: 'David', //... } export function defaultOwner() {return new Person(defaultOwnerData)} export function setDefaultOwner(arg){defaultOwnerData = arg} class Person { constructor(data){ this._name = data.name; this._age = data.age; this._gender = data.gender; } get name(){return this._name}; get age(){return this._age}; //... } //another.js let memberData = defaultOwner() memberData.name = 'Tom' console.log(memberDta.name) // 'David' , NOT 'Tom' ``` ::: - Mutable Data - 情境:資料常常跟部分程式碼有耦合關係,一個資料變動,可能產生連鎖反應,甚至是bug:假如A變數被更新了,但B程式碼卻期望A是原本的值,這將導致一系列的bug,而且很難trace。 - 解法: - `Encapsulate variable`,控制修改的行為可被集中管理於一處。 - `Split Varibale`:如果有變數被儲存成不同東西,可以將它分開。 :::spoiler example 我們常透過變數保存程式碼執行的結果,以方便我們引用。但如果一個變數被重新複值,代表這個變數所肩負的任務超過一個。而肩負多重任務的變數,容易讓其他工程師一頭霧水。 假設有個程式碼長這樣: ```javascript= const quantity = 10 const item = 100 let price = quantity * item console.log(price) if(price >=500){price = quantity *item *0.9} console.log(price) // 1000 // 900 ``` 如果price被重新assign的過程,被包裹在很隱晦的地方,光看console.log,很難得知到為什麼兩次的temp會不一樣。而取 作者建議改成這樣: ```javascript= const quantity = 10 const item = 100 const basePrice = quantity * item console.log(basePrice) if(basePrice >=500){ const finalPrice = basePrice *0.9} console.log(finalPrice) // 1000 // 900 ``` 這麼改有兩個好處: 1. 原本一個變數要處理兩件,現在是兩個變數個別處理,兩個變數的功能變得更單純。 2. finalPrice不會修改到原本的basePrice,進而造成難以預測的bug。 但必須注意的是,如果變數為收集變數(比如說 `i = i + something`),這種就不要拆開,原因在於i本身的功能為收集總和、字串串接、將東西加入一個集合(比如說陣列)等,拆開反而毀掉了i原本的用途。 ::: - `Slide Statement`、`Extract Function`:讓處理更新的函式只處理更新,其他的邏輯、函式都搬到外面或分開。 - `Seperate Query from Modifier`:呼叫變數時不要執行具「修改變數」等副作用的函式。 :::spoiler example 函式可以分成「有副作用」與「無副作用」的函式:無副作用代表函式只會依據import回傳output,不會修改函式外部的任何程式碼。有副作用則在執行函式,除了回傳output,還會對函式外部造成影響。比如說叫出console.log,就是一個很常見的side-effect。作者認為有時候side-effect其實是一個函式的主要功能(main effec,但可以的話,盡量把無副作用與有副作用的函式分開。比如說 ```javascript= function getPrice(item, quantity){ const price = item * quantity axios.post('http://www.some-where',{ price:price}) return price } //變成 function getPrice(item, quantity){ return item * quantity } function sendPriceToSomeWhere(price){ axios.post('http://www.some-where',{price: price}) } // 這樣的好處在於,無副作用的查詢函式,裡面只做「查詢」的功能;修改、打api等這種具副作用的函式,則由另外一個函式執行。 ``` ::: - `Remove Setting Method`:移除set方法,減少變數可被直接修改的機會。 - `Replace Derived Variable with Query`:減少可變資料的作用域 :::spoiler example 假設有一個class叫做ProductionPlan,用於紀錄手機店的進貨數量與品項,並可以得出Derived Variable `totalAmount`,為所有進貨手機的數量 ```javascript class ProductionPlan{ constructor(adjustments){ this._adjustments = adjustments this._amount = adjustments[0].amount } get totalAmount(){ return this._amount} applyAdjustment(anAdjustment){ this._adjustments.push(anAdjustment) this._amount += anAdjustment.amount } } const productionPlan2022 = new ProductionPlan( [{amount:10,name:'iphone 13'}]) productionPlan2022.applyAdjustment( {amount:15,name:'samsung s22'}) console.log(productionPlan2022.totalAmount) //25 ``` 作者建議可以改成這樣 ```javascript= class ProductionPlan{ constructor(adjustments){ this._adjustments = adjustments this._amount = adjustments[0].amount } get totalAmount(){ return this._adjustments.reduce( (sum,a)=>sum+ a.amount ,0) } applyAdjustment(anAdjustment){ this._adjustments.push(anAdjustment) } } ``` 這麼做的好處在於: 1. totalAmount的計算來源更清楚:以前我只知道他是`this._amount`,但`this._amount`是什麼我不清楚,現在我知道是`this._adjustments`藉由reduce累加的結果。 2. 避免資料來源改變導致的故障:totalAmount原本是來自於`this._amount`,而`this._amount`又是來自於`anAdjustment.amount`。但如果`this._amount`又因為不知名的原因被修改了,可能會導致totalAmount的結果不如預期。改動後的totalAmount則是直接計算`this._adjustments`的數值,不受`this._amount`所影響,可變變數所造成的風險較小。 3. 當然如果你能確定資料來源(`this._amount`)是不會變的,那不用做這個改動。 ::: - 限制可變變數的作用域:Combine function into class, Combine Functions into Transform