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

  • 2020 年 3 月 26 日
  • 筆記

很多時候,把代碼梳理一遍,把邏輯寫正確,把依賴關係理順,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函數的上一層調用地方,在那個粒度提供直觀的這個「程序在幹什麼」的直觀邏輯。

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

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

–end–