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