1. 程式人生 > >鍊金術(7): 何以解憂,唯有重構

鍊金術(7): 何以解憂,唯有重構

很多時候,把程式碼梳理一遍,把邏輯寫正確,把依賴關係理順,BUG就不見了。一個Bugly的遺留系統,只有徹底的重構,讓程式首先處於「良構」狀態,才可以正常的開發、維護和發版本。其中有一個本質的問題,就是讓程式碼實現「高內聚、低耦合」。下面是我的重構筆記。

我發現我原來習以為常的程式設計習慣,我一開始就不會寫出這種亂七八糟耦合的問題,所以有很長一段時間以來我都感覺不到寫程式碼要注意「高內聚、低耦合」問題了。可是這次重構,讓我又看到了那些義大利麵條程式碼是怎麼回事,而要拆開它們,一步步解除耦合,重新把這些程式碼寫到「正常」,我才又「感覺」到寫程式碼需要「高內聚、低耦合」這件事,對很多人來說是需要經過學習和練習的。

這次重構再一次證明了「全域性變數是萬惡之源」,這個人用JavaScript寫了很多類,但是呢,每個模組裡都返回了這個類的一個「假單例」,進一步又「向上」「向下」,在上下兩層都是用了這個虛假的單例,導致兩邊的內部都嚴重耦合這些「類的例項」,也就等價於直接使用了一堆的全域性變數。更惡劣的是,這些類的成員變數是直接暴露,到處賦值,把所有變數都暴露在「沒有任何封裝和保護」下的「任意修改」。

我這幾天簡直就是反覆在一層一層重構:

  1. 解除雙向耦合,層跟層之間只能是 A<----B<---C<----D 這種單向依賴,而不能互相依賴。程式裡的層跟層之間,要做到單向依賴,就能讓流程清晰,構架合理。
  2. 所有的變數修改「封裝」到類內部,全部通過方法來修改。在這個基礎上,內部變數的修改,在內部狀態機裡面做保護。
  3. 仔細、徹底清理幾個重要的有限狀態機(Finite State Machine),畫出狀態轉換的完整狀態轉換圖,內部必須有enterState轉換方法保護,任何錯誤轉換都直接報錯。我覺的這是直接體現「程式設計」是什麼的地方,不懂有限狀態機,就不是真正的程式設計。我看到很多定義了一堆狀態,但是狀態之間是可以隨意跳轉的程式碼,這種都是Bugly的根源。
  4. 收縮一個類狀態被修改的點。一個類定義了一組方法和屬性,只應該在某個場合下被使用,所有使用了這個類的地方,如果不是儘量控制在狹小的範圍,那麼狀態修改就在擴散,這些分散不但讓狀態的變化難以被理解,也不利於維護。一步步收縮範圍,根據「相關性」逐漸分析,哪些邏輯應該集中在某個地方管理。
  5. 函式裡的邏輯,不應該是一堆看不出幹什麼的程式碼構成。而應該儘量由一組一眼就看的清楚的函式呼叫構成,如果不是,那麼就需要重構這部分邏輯,讓它們在合適的地方組成一個合適的,功能明確的函式。
  6. 分離不同程序的類到不同的資料夾。每個程序只應該使用自己程序裡的類,否則,你會遇到諸如「這個變數我明明修改了,怎麼就是不對呢」的問題,因為你修改的和你讀取的根本就是兩個不同程序的變數,雖然看上去是「同一個類」,如果你有多執行緒程式碼,也是類似。明確每個類屬於哪個程序。用含義明確的資料夾物理分離它們。每個類只應該被一個程序使用,除非它是一個沒有狀態的工具類。這也進一步說明了不要使用全域性變數,一不小心,你就在兩個程序內使用了「同一個變數」的屬於兩個程序的副本。不要給自己製造這種混淆的機會。
  7. 如何解除 A<--->B 這種耦合呢?雖然我是在JavaScript裡寫程式碼,我還是會思考什麼時候使用「介面」,什麼時候使用「函式」來解除耦合的問題。許年年來,基於面向物件的設計模式,都在告訴你要面向介面來解除耦合,真的是這樣的嗎?

很久以來,我都已經 忘記了要寫一個介面了,因為動態語言裡並不需要什麼直接的介面。我認真思考了下,如果一個類確實有可能含有多種不同的相似的子型別,這個時候繼承是很自然的,例如,B1,B2,B3繼承B。此時AB的依賴,B可以是一個抽象類,也可以就是一個介面IB,這沒有什麼區別。反之,B也可以對IA依賴。由此設計模式一個系列基本上就是在說這件事。

但是,我可以不用介面實現解除耦合麼?合理設計回撥函式就可以做到。例如:

B.xxxxx(params, onXXXX, onYYYY)

只要B的函式引數裡定義好合適的回撥函式,那麼我並不需要B內部呼叫任何A的方法,A如果要把自己邏輯混進Bxxxxx方法的邏輯裡,只要使用B的時候,處理這些回撥就可以:

b.xxxxx(params,(...)=>{
    這裡加入A的邏輯
},(...)=>{
    這裡加入A的邏輯
});

這個時候,B如果要做到通用,就是儘量設計好合適的引數和回撥。

進一步,你可能會在A的內部使用B。這樣B雖然解除了對A的依賴,但是AB的依賴還是在,那麼,應該怎樣進一步解除這種耦合呢?一種抽象方法如是有效的,那就反覆使用它:

A.yyyyy(params, onXXXX, onYYYY);

這個時候,把A的邏輯和B的邏輯繫結在一起就是更外層的「責任」,AB負責「提供機制」,外層,例如C負責「使用策略」,從而做到「機制和策略的分離」

C:

a ,b;

a.yyyyy(params, (...)=>{
  // 其他邏輯,例如加入c的邏輯
  b.xxxxx(prams,(.....)=>{
      // 加入A的邏輯
  }, (...)=>{
      // 加入A的邏輯
  }
}, (...)=>{
  // 其他邏輯,例如加入c的邏輯
});

這當然可能引起「回撥巢狀地獄」,在許多情況下,可以使用語言層提供的async/await來讓程式碼更清晰一些。但是async/await並不是回撥的完備替代品,它只能讓單出口的非同步回撥變成「偽同步」程式碼。例如:

xxx((ret)=>{
    zzzz(ret)
});

變成:

let ret = await xxxx();
zzzz(ret);

但是這種能力它就比較囉嗦

xxxx((ret)=>{
   zzzz(ret);  
},(ret)=>{
   yyyy(ret);
}); 

要處理這種多出口的回撥,如果xxx內部要麼在第1個回撥結束,要麼在第2個回撥結束,那可以通過返回值判斷要怎麼處理:

let {err,ret} = await xxxxx();
if(err){
   zzzz(ret);
}else{
   yyyy(ret);
}

但是,如果xxxx內部在第1個回撥之後,也可能再次呼叫第2個回撥。或者任何一個回撥會呼叫多次。這個時候把xxxx函式變成不帶回調的async函式,邏輯會變的複雜,甚至不可能。

總之,這是題外話。我的核心要說明的是,通過在函式引數和回撥的設計,就可以解除A<---->B這種依賴關係。並且讓C在呼叫地方的程式碼「一眼就看出來AB之間如何協同工作完成任務」,這點是我考慮很多程式碼應該寫在哪裡的關鍵。

那就是,一個函式應該是:

run(); // 內部完成了神祕的任務

還是應該是:

if(a.init()){
   a.xxxx();
   a.zzzz();
};

更好呢?我認為,至少應該在xxxx函式的上一層呼叫地方,在那個粒度提供直觀的這個「程式在幹什麼」的直觀邏輯。

我認為介面的解藕,在於有同一個介面有多個不同的場景,但是相似子類的時候。而如果不是,那麼「高階函式」的組合就是更好的選擇。這個更好是類似「如無必要,務增實體」這類的思想,或者說「奧姆卡剃刀」原理。

以上就是重構的幾點感受,在重構專案中,也有助於我們理解構架是什麼,因為為了讓專案達到「良構」,我們必須理解很多「為什麼」。

--en