鍊金術(7): 何以解憂,唯有重構
- 2020 年 3 月 26 日
- 筆記
很多時候,把代碼梳理一遍,把邏輯寫正確,把依賴關係理順,BUG就不見了。一個Bugly的遺留系統,只有徹底的重構,讓程序首先處於「良構」狀態,才可以正常的開發、維護和發版本。其中有一個本質的問題,就是讓代碼實現「高內聚、低耦合」。下面是我的重構筆記。
我發現我原來習以為常的編程習慣,我一開始就不會寫出這種亂七八糟耦合的問題,所以有很長一段時間以來我都感覺不到寫代碼要注意「高內聚、低耦合」問題了。可是這次重構,讓我又看到了那些意大利麵條代碼是怎麼回事,而要拆開它們,一步步接觸耦合,重新把這些代碼寫到「正常」,我才又「感覺」到寫代碼需要「高內聚、低耦合」這件事,對很多人來說是需要經過學習和練習的。
這次重構再一次證明了「全局變量是萬惡之源」,這個人用JavaScript寫了很多類,但是呢,每個模塊里都返回了這個類的一個「假單例」,進一步又「向上」「向下」,在上下兩層都是用了這個虛假的單例,導致兩邊的內部都嚴重耦合這些「類的實例」,也就等價於直接使用了一堆的全局變量。更惡劣的是,這些類的成員變量是直接暴露,到處賦值,把所有變量都暴露在「沒有任何封裝和保護」下的「任意修改」。
我這幾天簡直就是反覆在一層一層重構:
- 解除雙向耦合,層跟層之間只能是
A<----B<---C<----D
這種單向依賴,而不能互相依賴。程序里的層跟層之間,要做到單向依賴,就能讓流程清晰,構架合理。 - 所有的變量修改「封裝」到類內部,全部通過方法來修改。在這個基礎上,內部變量的修改,在內部狀態機裏面做保護。
- 仔細、徹底清理幾個重要的有限狀態機(Finite State Machine),畫出狀態轉換的完整狀態轉換圖,內部必須有enterState轉換方法保護,任何錯誤轉換都直接報錯。我覺的這是直接體現「編程」是什麼的地方,不懂有限狀態機,就不是真正的編程。我看到很多定義了一堆狀態,但是狀態之間是可以隨意跳轉的代碼,這種都是Bugly的根源。
- 收縮一個類狀態被修改的點。一個類定義了一組方法和屬性,只應該在某個場合下被使用,所有使用了這個類的地方,如果不是盡量控制在狹小的範圍,那麼狀態修改就在擴散,這些分散不但讓狀態的變化難以被理解,也不利於維護。一步步收縮範圍,根據「相關性」逐漸分析,哪些邏輯應該集中在某個地方管理。
- 函數里的邏輯,不應該是一堆看不出幹什麼的代碼構成。而應該盡量由一組一眼就看的清楚的函數調用構成,如果不是,那麼就需要重構這部分邏輯,讓它們在合適的地方組成一個合適的,功能明確的函數。
- 分離不同進程的類到不同的文件夾。每個進程只應該使用自己進程里的類,否則,你會遇到諸如「這個變量我明明修改了,怎麼就是不對呢」的問題,因為你修改的和你讀區的根本就是兩個不同進程的變量,雖然看上去是「同一個類」,如果你有多線程代碼,也是類似。明確每個類屬於哪個進程。用含義明確的文件夾物理分離它們。每個類只應該被一個進程使用,除非它是一個沒有狀態的工具類。這也進一步說明了不要使用全局變量,一不小心,你就在兩個進程內使用了「同一個變量」的屬於兩個進程的副本。不要給自己製造這種混淆的機會。
- 如何解除
A<--->B
這種耦合呢?雖然我是在JavaScript里寫代碼,我還是會思考什麼時候使用「接口」,什麼時候使用「函數」來解除耦合的問題。許年年來,基於面向對象的設計模式,都在告訴你要面向接口來解除耦合,真的是這樣的嗎?
很久以來,我都已經 忘記了要寫一個接口了,因為動態語言里並不需要什麼直接的接口。我認真思考了下,如果一個類確實有可能含有多種不同的相似的子類型,這個時候繼承是很自然的,例如,B1
,B2
,B3
繼承B
。此時A
對B
的依賴,B
可以是一個抽象類,也可以就是一個接口IB
,這沒有什麼區別。反之,B
也可以對IA
依賴。由此設計模式一個系列基本上就是在說這件事。
但是,我可以不用接口實現解除耦合么?合理設計回調函數就可以做到。例如:
B.xxxxx(params, onXXXX, onYYYY)
只要B
的函數參數里定義好合適的回調函數,那麼我並不需要B
內部調用任何A
的方法,A如果要把自己邏輯混進B
的xxxxx
方法的邏輯里,只要使用B的時候,處理這些回調就可以:
b.xxxxx(params,(...)=>{ 這裡加入A的邏輯 },(...)=>{ 這裡加入A的邏輯 });
這個時候,B
如果要做到通用,就是盡量設計好合適的參數和回調。
進一步,你可能會在A
的內部使用B
。這樣B
雖然解除了對A
的依賴,但是A
對B
的依賴還是在,那麼,應該怎樣進一步解除這種耦合呢?一種抽象方法如是有效的,那就反覆使用它:
A.yyyyy(params, onXXXX, onYYYY);
這個時候,把A
的邏輯和B
的邏輯綁定在一起就是更外層的「責任」,A
和B
負責「提供機制」,外層,例如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在調用地方的代碼「一眼就看出來A
和B
之間如何協同工作完成任務」,這點是我考慮很多代碼應該寫在哪裡的關鍵。
那就是,一個函數應該是:
run(); // 內部完成了神秘的任務
還是應該是:
if(a.init()){ a.xxxx(); a.zzzz(); };
更好呢?我認為,至少應該在xxxx
函數的上一層調用地方,在那個粒度提供直觀的這個「程序在幹什麼」的直觀邏輯。
我認為接口的解藕,在於有同一個接口有多個不同的場景,但是相似子類的時候。而如果不是,那麼「高階函數」的組合就是更好的選擇。這個更好是類似「如無必要,務增實體」這類的思想,或者說「奧姆卡剃刀」原理。
以上就是重構的幾點感受,在重構項目中,也有助於我們理解構架是什麼,因為為了讓項目達到「良構」,我們必須理解很多「為什麼」。
–end–