常見的程式碼壞味道(Code Smell or Bad Smell)

常見的程式碼壞味道(Code Smell or Bad Smell)

圖片取自:https://www.slideshare.net/ElMahdiBenzekri/art-of-refactoring-code-smells-and-microservices-antipatterns

前言

究竟重複的程式碼要不要共用它?傳統的軟體開發上,重複的程式碼幾乎被視為罪惡的來源,但是它真的有多可怕?

常見的 Code Smells

  • The Dispensable (一些可有可無的)

    1. Lazy Class (冗員類別) 如果一個類很少使用它會增加代碼庫的複雜性,從而阻礙開發。這樣的類可能是不必要的,它的功能可以成為另一個類的一部分。

    2. Data Class (只有資料的類別) 一個類別只有包含『資料』未包含Methods,也就是說並未包含行為,不具行為模式的類別代表該『實例』容易被【其他類別所操控】。

    3. Duplicate Code (重複的程式碼) 如描述,這也是上次有分享在我的 FB 社團軟體開發之路[德國資深架構師給新手的建議]中有產生非常熱烈討論,這在社團朋友提醒下想起 Refactor 重構一書裡曾提過 rule of three 法則,這個法則的定義是這樣的,有時部分程式碼重複其實是允許的(不是說重複一定是是被允許的)而是在某些 Context 情境下,該程式碼有一段時間不會衍伸別的功能 或 需求一段時間不會異動、且如要將它 DRY 會產生的『複雜度』或『花費時間』遠高於預估的時間 或 不值得在目前這個專案的節骨眼上花費這個時間來做這件事,加上它 DRY 產生的複雜度偶後產生的維護成本【遠高於】不 DRY 的情況下的維護本,那麼,在這個時候,這個異動頻率【極低】的程式碼就不需要花費這個時間來 DRY 了!!因為重點在於,重構是需要『成本』的。(迷之音:DRY 是種抽象化阿~😎)

      這讓我想起之前,大約在 2016 年左右,在 101 的樓上某金融業客戶,曾經做過一個專案,這個專案就是因為客戶 DMZ 環境的因素,我讓內網的 Webside 與 DMZ 的網站保留了部分重複的程式碼,因為這段程式碼終年不需要異動,我也懶得花這個時間,而我居然也忘了這記件事情了.. 哈哈 Orz

      不過原歸正傳,我個人還是認為,其實適當的 DRY 也是種訓練,這對於 Junior 人員的『練功』是有某種程度的幫助的,當然了,除非,你的專案空閒的時間非常足夠,因為大部分還是以大局為重 (這也是為什麼我們會利用 Side Project 來練功/工程師絕對要經營 Side Project 是一樣的道理;你總不能在有限時間的專案裡試你想試的東西吧~ 萬一影響到專案無法如期驗收就不好了~ 😂)。

    4. Dead Code (從未使用過的程式碼) 方法、參數、甚至類別都算。這些從未使用的程式碼最終造成混亂、可讀性下降、從而減慢開發效能。要解決此代碼異味,應刪除此類代碼。使用大多數 IDE 可以很容易地找到此違規代碼。

    5. Speculative Generality (推測的普遍性) 這是指在撰寫程式時對未來可能發生的情況或是變化作過多的考量,雖然在撰寫程式碼時考慮可能的未來發展被認為是一種很好的做法,但推測普遍性是過度的時候,由於過多的判斷與考量,最終一定會有許多剩餘無用的程式碼,最終將導致維護變得困難。

  • The Couplers (不當的耦合關係)

    1. Feature Envy (你儂我儂的關係😏) 這是當一個 Method 對某一個類別有多次的叫用時,英文字義:Method has Feature Envy on another class; 這是一種類別與其他類別或方法的高度耦合現象。

      這通常是類別與方法的職責切割的不夠清楚所造成,如果某段方法內的行為會因為『其他類別』而影響,那麼你應該將此邏輯 Extract Method & Move 到該類別,審慎評估該行為是否應該有自身負責?或者根本不屬於自身該做的事情。

    2. Inappropriate Intimacy (不當親密關係) 這通常是有兩個類別有非常高度聯繫關係,比如A類別與B類別間、可能是方法調用過於頻繁、甚至到了行為相似之行為、這也是上方 Feature Envy 的更甚者,所以有可能 A 與 B 這兩個類別應該『合併』成一個類別。

    3. Message Chains (訊息鍊) 違反這個原則是表示,多個類別之間有不必要的『串聯或耦合』像是,A透過B類的方法、才能取得 D類中的資料,而形成一個訊息鍊..等等

    4. Middle Man (中間人) 在物件導向的世界中,A物件呼叫B物件的方法,通常A物件並不需要知道B物件如何實做,這是「封裝」的特性,隱藏B物件的內部實作。但如果B物件的很多方法實作方式都委託(delegate)給C物件(轉呼叫C物件的方法),則這種過度使用delegation的情況就產生了Middle Man這種壞味道。

  • The Bloaters (虛胖的程式碼)

    1. Long Method (過於攏長的方法) 一個方法若過於攏長通常表示該方法可能本身賦予太多的職責,也代表著這個方法不好維護 & 不好修改。解決方式

    2. Large Class (龐大的類別) 顧名思義,即是類別有過多的程式碼,過於龐大的類別不容易維護,容易產生多種會味道。

    3. Primitive Obsession (基本型別偏執) 很多程式設計師還是被教導為「為了執行效率應該使用基本型別」,間接導致許多設計原先該使用類別而變成使用基本型別,舉例來說:郵遞區號使用 int 而不是宣告 Zip)。這種壞味道會導致修改性變差,因為基本型別 int 除了能表示郵遞區號,也能表示年齡、身高等各種資訊,因使用基本型別會使當責任改變須修改程式碼時,難以附加或更改其職責,也讓其他開發人員閱讀程式碼變得困難。

    4. Long Parameter List (過長的參數列) 過長的參數列讓人難以理解,想想有一個 Method 擁有超過 8 個參數,某些程式語言又可改變輸入參數之『順序』,比如:有的參數可傳 MethodA(... DataSource: source, UserID: userId....) 有的呼叫方式又變成 MethodA(...UserId: userId, DataSource:source.....)) 這造成開發人員不容易理解、也可能造成不一致的情況。

      過長的參數列,若寫成 interface 若參數調整為 9 個參數,那麼影響範圍變成是所有有實作該 interface 的程式碼都得要修改。

      遇到這種情況,改變以類別作為參數可能比較恰當。

    5. Data Clumps (資料泥團) 有些資料的使用只會同時出現,不會單獨使用。比如說:Connection String 中的 Data Source=(local);Initial Catalog=MyDB;User Id=gelis;Password=; 這類資料只會同時使用,不會單獨使用,那麼就應該將這類資料放置在同一個 Class 裡。

  • The Object-Orientation Abusers (濫用物件導向設計)

    1. Switch Statements (switch 敘述) 這是大部分程式語言提供的語法,可針對處理方式作出不同的分類處理,但是有時可能需要思考,這些分類出來的 〔Category/Type/Kind〕是否內含著『龐大』又或者是『各處共用』商業邏輯?如果是,那可能應該切割為不同職責的 Class 來去處理這些邏輯,寫成 switch 可能會造成一個難以維護的壞味道)

    2. Temporary Field (暫時欄位) 這算是許多開發人員可能在無意間會犯的錯誤,試想你有一個 private 變數,會因為某個 public 方法而變更其值,但是這個值並不會在這個 public 方法執行後回傳這個 private 變數,而這個 private 變數又是 global 的,因此有可能在這個〔自身/this〕的 Instance 未釋放之前,若該 public 不小心又再被叫一次,那 private 就又會再被算一次,這時期內容已經不是當初預期的那個結果,如此一來,便造成偵錯的困難。

      解決方式也很容易,只要遵循一個原則,就是每一個【方法】都應該將『運算後的結果』回傳 & 所有的參數值都應該是藉由方法來拿取 & 方法也不保留運算後的結果。遵循以上這些原則,將程式碼重構後即可!)

      如下範例程式:

      (1). 修改前

      public class Employee
      {
      	private const decimal Percentage = 0.02m;
      	private decimal _earningForBonus;
      	
      	public Employee(decimal earningForBonus)
      	{
      		_earningForBonus = earningForBonus;
      	}
      	private void CalculateBonus()
      	{
      		_earningForBonus = _earningForBonus * Percentage;
      	}
      	public decimal CalculateForEarning()
      	{
      		return _earningForBonus * 2;
      	}
      }
      

      如上程式碼的方法 CalculateBonus() 會將 _earningForBonus 的計算結果又放置自身變數 _earningForBonus 裡,但 _earningForBonus 在此類別裡是 全域變數,極可能因為其他方法被呼叫後重複變更其值而產生一個壞味道。

      (2). 修改後

      public class Employee
      {
      	private const decimal Percentage = 0.02m;
      	private decimal _earningForBonus;
      
      	public Employee(decimal earningForBonus)
      	{
      		_earningForBonus = earningForBonus;
      	}
      	private decimal CalculateBonus()
      	{
      		return _earningForBonus * Percentage;
      	}
      	public decimal CalculateForEarning()
      	{
      		return (_earningForBonus * 2) + CalculateBonus();
      	}
      }
      

      依照我們前面所提及的,所有的參數值都應該是藉由方法『回傳』來拿取 & 方法也『不保留』運算後的結果。遵循以上這些原則,我們將程式碼重構成上方範例。

    3. Refused Bequest (拒絕遺贈) 這字面上叫做拒絕遺產,好比父親要將小吃店繼承給兒子,但是兒子想去上班賺錢,這就是一種拋棄繼承的例子。若從物件導向的角度來說,就是違反 SOLID 物件導向5大原則理面的 LSP,因此,當有個子類別未實作父類別方法,也代表該子類無法由父類利用多型來重用該程式碼,當有這樣的問題存在即是這種壞味道。

      我自己的觀點:若又這種壞味道的出現,可能要重新思考在設計上是否這兩個類別本來就毫無關係,本來該獨立存在?好比狗跟貓,你不會將狗繼承貓;或貓繼承狗吧?)

    4. Alternative Classes with Different interface (具有實作不同介面的類似替代類別) 這個中文名稱翻譯時在非常長,有可能表示這兩個類別也許本來就沒什麼關係,這有可能是在分析設計錯漏所引發的問題,或者實作時未考量到共通性;或者是團隊協作開發時未妥善溝通、或溝通不良而導致兩名工程師設計出功能類似但名稱不相同之類別,這種重複功能類別或方法的產生,就是這種壞味道。

  • The Change Preventers (修改意圖的阻礙者)

    1. Divergent Change (發散式的修改) 什麼叫做發散式的修改?字面上其實很難讓人瞭解其意思,其實是指【當一個類別的修改會因為『多個原因』而修改】這表示該列別的內聚力極差,一直與自己不相關的職責耦合,所以簡單的說,就是違反了物件導向五大設計原則裡的 SRP, Single Responsibility Principal 單一職責。

    2. Shotgun Surgery (霰彈槍手術;或者稱作霰彈式修改) 所謂的霰彈修改事實上是指,當一個【邏輯/需求內容】的修改時,得同時修改兩個 Business 的相關類別,這剛好與上一個 Divergent Change 相反。

    3. Parallel Inheritances Hierarchies (平行繼承架構) 筆者認為,的這算是所有壞味道中比較不好理解的,因為許多書本上寫的其實也很抽象,網路上查到的資料也不完整,仔細想想這不就是我們常用的 Abstract Factory 與 Factory Method 模式嗎?難道我用這兩種模式就是種壞味道嗎?其實不是的,因為所謂的平行繼承架構可算是霰彈式修改的特殊其況,指的是當你為某個類別添加子類別 subClass 的時候,你也必須同時為另一個類別相應增加一個新的子類別這就是個不好的味道,這其實是設計上的問題,但我們在使用 Factory Method 或 Abstract Factory 時,其實並不會發生如上情形,舉個例子:我建了一個提供速食店類別實作點餐的工廠介面 IOrderFactory,當實際訂餐時再根據定的餐點內容傳入餐點內容介面 IOrderProvider 實作,程式碼如下:

          
          //訂餐工廠介面
      public interface IOrderFactory
      {
        string CreateOrder();
      }
      //訂餐介面
      public interface IOrderProvider
      {
        string ProviderHamburger();
        string ProviderBeverage();
      }
      //McDonald.
      public class McDonald: IOrderProvider
      {
        public string ProviderHamburger() { return "提供 麥當勞 盡辣雞腿堡";}
        public string ProviderBeverage() { return "提供 麥當勞 可樂";}
      }
      
      public class Kentucky: IOrderProvider
      {
        public string ProviderHamburger() { return "提供 肯德基 卡拉雞腿堡";}
        public string ProviderBeverage() { return "提供 肯德基 可樂";}
      }
      //速食店類別
      public class FastFoodStores: IOrderFactory
      {
        private IOrderProvider _orderProvider;
        public FastFoodStores(IOrderProvider orderProvider)
        {
          _orderProvider = orderProvider;
        }
        public string CreateOrder()
        {
          return string.Format("我點了:{0}, 跟: {1}", _orderProvider.ProviderHamburger(), _orderProvider.ProviderBeverage());
        }
      }
      void Main()
      {
        FastFoodStores mc = new FastFoodStores(new McDonald());
        mc.CreateOrder().Dump();
        
        FastFoodStores ken = new FastFoodStores(new Kentucky());
        ken.CreateOrder().Dump();
      }
      
      


如上程式碼,當我需要增加另一種速食店時,我只需要再加一個 IOrderProvider 的實作即可,並不需要增加速食店類別,很明顯的並不會發生上述情況。

待續..

相關範例程式碼陸續補上。

 

留言

這個網誌中的熱門文章

什麼是 gRPC ?

什麼是 gRPC(二)- 來撰寫第一個 Hello World 吧!