Try   HackMD

重構:改善既有的程式設計(第二版)

這是一篇讀書心得,主要分為兩部分:重構的核心概念,以及實際的重構solution。我會將重點著重在前者,後者若有時間的話會列出我覺得重要的部分。

寫在閱讀之前:目標

  • 了解什麼是重構。
  • 了解為什麼需要重構。
  • 找出程式碼中哪裡需要重構。
  • 如何開始重構的第一步。
  • 重構的實際案例

第一章:重構:第一個範例

(範例程式碼寫上去)

幾個重點:

  • 簡單來說,重構是改善已經寫好的設計。這聽起來有點怪,因為大多數軟體都是「先設計、後工程」,但是隨著時間過去,程式碼不停被修改,會使得程式碼從依循既有的設計,變為隨意更動的混亂。重構則是將不良/混亂的程式碼抽出來,重新修改為良好的設計。

  • 更精確地說,重構是指「對軟體內部結構進行變動,在不改動軟體可見行為(observable behavior)的前提下,提高它的可理解性,並降低修改他的成本」

  • 靈芝的好壞取決於多醣體。 程式的好壞取決於它多清楚:夠清楚,代表閱讀的『人』能更輕鬆地讀懂程式碼,找到哪個函式/元件是需要修改/新增的。糟糕的程式碼則難以閱讀、修改時需要重新閱讀的時間極高、且容易改壞掉。

    在我看來,好的程式碼有三個要件:

    • 易讀性(下一個接手的工程師可以看懂你每行code在幹嘛)
    • 分離性(一個複雜的code裡一定包含很多功能,而好的code能將這些功能拆成不同子函式/元件,每個子函式/元件僅肩負單一任務)
    • 複用性(子函式/元件可以被複用,而不用再複製同樣的程式碼去處理幾乎同樣的任務)
  • 如何有效地重構,關鍵在於採取小步驟:將重構的整個工程,拆解成數個單一任務,並盡可能在每個任務中加入測試。好處有二

    • 如果重構發生錯誤(ex程式碼不能動),能夠更快找到錯誤在哪。如果找不到,revert回上個版本的成本也較低。
    • 程式碼不會長時間維持無法運轉的情形:可以重構完一小部分,就暫停去做別的功能,而程式碼還是可以work。

    而且對工程師而言,專注於更小的改動,能夠有更即時的feedback,更能掌握重構的進度,有益身心健康(?。

重構的原理——什麼是重購、為什麼需要重構?

  • 重構是指「對軟體內部結構進行變動,在不改動軟體可見行為(observable behavior)的前提下,提高它的可理解性,並降低修改他的成本」。
  • 「可見行為」是指「程式碼執行後所呈現的結果」:照理來說,重構前後,程式碼做的事情不會改變,但有些重構方法會使改變執行內容(比如改變call stack順序),但如果執行的結果對使用者而言沒有任何變化,就是合格的重構。

為何需要重構?重構的目的

  1. 改善軟體的設計:減少重複造輪子,提升函式/元件的復用性。
  2. 讓軟體更容易理解:好的命名可以讓接手的工程師不會發瘋。
  3. 幫助找出bug:重構的過程能幫助工程師了解既有程式碼的架構、找出/改善潛在的bug。
  4. 提升新功能的開發速度:好的程式架構有幾個優點
    • 可以讓人快速找出如何與哪裡該新增feature
    • 良好的模組化可以提高函式/元件的復用性、加快feature的開發速度

重構的最終目標不是寫出「簡潔的程式碼」,而只是為了提升開發功能與修復bug的速度。

何時要進行重構?

我自己的感受:當我發現目前的程式碼難以閱讀、有重複的程式碼在做同一件事時、未來可能有部分元件/函式可以復用時,就是重構的好時機。

作者有提到一個重要的概念「營地原則」:重構不是要求程式碼能夠一次重構到位,而是至少讓它比原本更好一點。就像營地不可能一晚上從素亂不整變得整潔有序,但至少可以讓它稍微宜居一點。

  1. 預備性重構:加入新功能前,重構既有的函式
    在加入新功能或修復bug前,可以先花點時間進行重構:比如先檢視既有的函式能否復用,若發現有相似的函式,可對既有的函式參數化,並用於新功能中,減少重複造輪子。

    ​​​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. 理解性重構:重新命名,讓程式碼更容易被理解

  3. 打掃性重構:可以理解程式碼在幹嘛,但他用了很糟糕的方式在運用(比如多個重複的程式碼、邏輯無謂的重複,函式/元件的任務不清楚),這時就可以進行重構。

  4. 計畫性重構與長期重構:
    雖然大部分的重構都是在開發的過程中完成的,但不可避免的,還是會有大規模的重構需要執行。作者認為,當這種情況發生時,可以採「重構不會破壞程式碼運行」的前提下,小步驟的修改。

何時「不該」重構?

  1. 不需要修改的程式
  2. 整個重寫比重構更簡單時

重構與實作新功能

重構是對既有的程式碼進行修改,與其相對的是實作新功能,作者對兩者的關係有以下見解:

  1. 就程式碼而言,作者認為一個簡單的重構,可以加速實作新功能的速度。
  2. 就工程師的work process而言,可以把「重構」與「實作新功能」想像成「兩頂帽子」:當你寫一個新的功能時,可能先戴上「實作帽」,寫一段功能後,改戴「重構帽」,重構剛寫好的功能,反覆而之。重點在於,實作時不要做重構;重構時不要寫新功能,一次做好一件事。
  3. 就程式碼的commit與pull request而言,作者不建議把「重構」與「實作新功能」分成不同的commit或是PR,因爲重構大多與新功能有著上下文關係(eg.在實作前重構),如果拆分的話,會使得這種關係變得不清楚,更會移除重構背後的成因,讓重構變得單薄且沒有說服力。

其實不一定要把重構放在同個PR,因為如果重構所牽涉的範圍太大的話,反而會導致code review要花的時間過多,導致PR卡住,拖延開發進度。
但如果牽涉的範圍很小的話,是可以放在同個PR的,但仍建議不要放在同個commit。

  1. 就團隊協作而言,重構也可能會影響到其他成員的實作(比如說我把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: 如果暫時變數過多,可以將他提取變成函式,減少傳入的頻率,避免傳入過多變數給予提取出來的函式,閱讀性提升不高。
        example
        ​​​​​​​​​​​​ 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可以給其他的函式使用。
        ​​​​​​​​​​​​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
        example
        ​​​​​​​​​​​​function availableVacation(employee, grade){ ​​​​​​​​​​​​ ... ​​​​​​​​​​​​} ​​​​​​​​​​​​availableVacation(employee, employee.grade) ​​​​​​​​​​​​//變成 ​​​​​​​​​​​​function availableVacation(employee){ ​​​​​​​​​​​​ const grade = employee.grade ​​​​​​​​​​​​ ... ​​​​​​​​​​​​} ​​​​​​​​​​​​availableVacation(employee)
      • 如果不想要從同個物件中取出太多值作為參數,可以使用Preserve Whole Object
        example
        ​​​​​​​​​​​​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 結合成一個資料結構
        example
        ​​​​​​​​​​​​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
        example
        ​​​​​​​​​​​​//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
      example

      假設有一個全域變數長這樣:

      ​​​​​​​​//defaultOwner.js ​​​​​​​​let defaultOwnerData = { ​​​​​​​​ name: 'David', ​​​​​​​​ age: 26, ​​​​​​​​ gender: 'male' ​​​​​​​​}

      這個全域變數是可以被其他所有程式碼access的,所以可以

      ​​​​​​​​//讀取
      ​​​​​​​​const memberData = defaultOwnerData
      ​​​​​​​​//修改
      ​​​​​​​​defaultOwnerData.age = 27
      

      作者建議有兩種封裝的方式:

      1. 封裝方法,讓讀取、修改被轉為一個函式,如果要更動全域變數,透過這種函式執行,能被更容易的追蹤與修改。

        ​​​​​​​​​​​​//defaultOwner.js ​​​​​​​​​​​​let defaultOwnerData = { ​​​​​​​​​​​​ ... ​​​​​​​​​​​​} ​​​​​​​​​​​​export function defaultOwner(){return defaultOwnerData} ​​​​​​​​​​​​export function setDefaultOwner(arg){defaultOwnerData =arg}

        這樣的好處在於:
        - 讀取、修改由函式負責且同一個地方管理(defaultOwner.js),能更好監看資料的變化與使用方式。
        - 如果要修改讀取、修改的方式,只需要改一處的函式就好。

      2. 封裝值,讓修改時僅修改副本的值,甚至不可被修改

        ​​​​​​​​​​​​//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 (沒有被更動到)
        ​​​​​​​​​​​​//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:如果有變數被儲存成不同東西,可以將它分開。
        example

        我們常透過變數保存程式碼執行的結果,以方便我們引用。但如果一個變數被重新複值,代表這個變數所肩負的任務超過一個。而肩負多重任務的變數,容易讓其他工程師一頭霧水。
        假設有個程式碼長這樣:

        ​​​​​​​​​​​​ 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會不一樣。而取
        作者建議改成這樣:

        ​​​​​​​​​​​​ 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 StatementExtract Function:讓處理更新的函式只處理更新,其他的邏輯、函式都搬到外面或分開。
      • Seperate Query from Modifier:呼叫變數時不要執行具「修改變數」等副作用的函式。
        example

        函式可以分成「有副作用」與「無副作用」的函式:無副作用代表函式只會依據import回傳output,不會修改函式外部的任何程式碼。有副作用則在執行函式,除了回傳output,還會對函式外部造成影響。比如說叫出console.log,就是一個很常見的side-effect。作者認為有時候side-effect其實是一個函式的主要功能(main effec,但可以的話,盡量把無副作用與有副作用的函式分開。比如說

        ​​​​​​​​​​​​ 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:減少可變資料的作用域
        example

        假設有一個class叫做ProductionPlan,用於紀錄手機店的進貨數量與品項,並可以得出Derived Variable totalAmount,為所有進貨手機的數量

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

        作者建議可以改成這樣

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