他們在學校裡不會教你的程式設計原則

2023-05-26 12:02:00

前言

在大學的時候,學校一般只會教你你寫程式語言,比如C、C++、JAVA等程式語言。但是當你離開大學進入這個行業開始工作時,才知道程式設計不只是知道程式語言、語法等,要想寫好程式碼,必須還要了解一些程式設計原則才行。本文主要討論KISSDRYSOLID這些常見的程式設計原則,而且你會發現隨著工作時間越久,越能感受這些程式設計原則的精妙之處,歷久彌香。

KISS原則

Keep It Simple, Stupid!

你是不是有過接手同事的程式碼感到十分頭疼的經歷,明明可以有更加簡單、明白的寫法,非要繞來繞去,看不明白?

其實,我們在寫程式碼的時候應該要遵守KISS原則,核心思想就是儘量保持簡單。程式碼的可讀性和可維護性是衡量程式碼質量非常重要的兩個標準。而 KISS 原則就是保持程式碼可讀和可維護的重要手段。程式碼足夠簡單,也就意味著很容易讀懂,bug 比較難隱藏。即便出現 bug,修復起來也比較簡單。

我們寫程式碼的的時候要站在別人的角度出發,就像馬丁·福勒說的,我們寫的程式碼不是給機器看的,而是給人看的。

「任何傻瓜都可以編寫計算機可以理解的程式碼。優秀的程式設計師編寫出人類可以理解的程式碼。」 — 馬丁·福勒

那麼如何才能寫出滿足KISS原則的程式碼呢?

如何寫出KISS原則的程式碼?

我們直接上例子,下面的校驗IP是否合法的3種實現方式,大家覺得哪個最KISS?

  1. 寫法一

  1. 寫法二

  1. 寫法三

  • 寫法一程式碼量最少,正規表示式本身是比較複雜的,寫出完全沒有 bug 的正則表達本身就比較有挑戰;另一方面,並不是每個程式設計師都精通正規表示式。對於不怎麼懂正規表示式的同事來說,看懂並且維護這段正規表示式是比較困難的。這種實現方式會導致程式碼的可讀性和可維護性變差,所以,從 KISS 原則的設計初衷上來講,這種實現方式並不符合 KISS 原則。
  • 寫法二使用了 StringUtils 類、Integer 類提供的一些現成的工具函數,來處理 IP地址字串,邏輯清晰,可讀性好。
  • 寫法三不使用任何工具函數,而是通過逐一處理 IP 地址中的字元,來判斷是否合法,容易出bug,不好理解。

所以說,符合KISS原則的程式碼並不是程式碼越少越好,還要考慮程式碼是否邏輯清晰、是否容易理解、是否夠穩定。

總結以下如何寫出KISS原則的程式碼:

  1. 不要使用同事可能不懂的技術來實現程式碼。比如前面例子中的正規表示式,還有一些程式語言中過於高階的語法等。
  2. 不要重複造輪子,要善於使用已經有的工具類庫。經驗證明,自己去實現這些類庫,出bug 的概率會更高,維護的成本也比較高。
  3. 不要過度優化。不要過度使用一些奇技淫巧(比如,位運算代替算術運算、複雜的條件語句代替 if-else、使用一些過於底層的函數等)來優化程式碼,犧牲程式碼的可讀性。
  4. 主觀站在別人的角度上編寫程式碼。你在編寫程式碼的時候就要思考我這個同事看這段程式碼是不是很快就能夠明白理解。

DRY原則

Don't Repeat Yourself

你是不是有過這樣的經歷,專案中很多重複邏輯的程式碼,然後修改一個地方,另外一個地方忘記修改,導致測試給你提了很多bug?

DRY原則,英文全稱Don’t Repeat Yourself,直譯過來就是不要重複你自己。這裡的重複不僅僅是程式碼一模一樣,還包括實現邏輯重複、功能語意重複、程式碼執行重複等。我們不要偷懶,有責任把這些存在重複的地方識別出來,然後優化它們。

如何寫出DRY原則的程式碼呢?

我們直接上例子,程式碼重複的我就不講了,很好理解,關於實現邏輯或者功能語意重複的我覺個例子。

還是上面校驗IP的例子,團隊中兩個同事由於不知道就有了兩種寫法。

  • 同事A寫法

  • 同事B寫法

儘管兩段程式碼的實現邏輯不重複,但語意重複,也就是功能重複,我們認為它違反了 DRY 原則。我們應該在專案中,統一一種實現思路,所有用到判斷 IP 地址是否合法的地方,都統一呼叫同一個函數。不然哪天校驗規則變了,很容易只改了其中一個,另外一個漏改,就會出現莫名其妙的bug

其他的比如邏輯重複的意思是雖然功能是不一致的,但是裡面的邏輯都是一模一樣的。舉個例子,比如校驗使用者名稱和校驗密碼,雖然功能不一致,但是校驗邏輯都是相似,判空、字元長度等等,這種情況我們就需要把相似的邏輯抽取到一個方法中,不然也是不符合DRY原則。

那麼我們平時寫程式碼注意些什麼才是符合DRY原則呢?

  • 使用現成的輪子,不輕易造輪子

其實最關鍵的就是寫程式碼帶腦子,用到一個方法先看看有沒有現成的,不要看看不看,就動手在那裡造輪子。

  • 減少程式碼耦合

對於高度耦合的程式碼,當我們希望複用其中的一個功能,想把這個功能的程式碼抽取出來成為一個獨立的模組、類或者函數的時候,往往會發現牽一髮而動全身。移動一點程式碼,就要牽連到很多其他相關的程式碼。所以,高度耦合的程式碼會影響到程式碼的複用性,我們要儘量減少程式碼耦合。

  • 滿足單一職責原則

我們前面講過,如果職責不夠單一,模組、類設計得大而全,那依賴它的程式碼或者它依賴的程式碼就會比較多,進而增加了程式碼的耦合。根據上一點,也就會影響到程式碼的複用性。相反,越細粒度的程式碼,程式碼的通用性會越好,越容易被複用。

  • 模組化

這裡的「模組」,不單單指一組類構成的模組,還可以理解為單個類、函數。我們要善於將功能獨立的程式碼,封裝成模組。獨立的模組就像一塊一塊的積木,更加容易複用,可以直接拿來搭建更加複雜的系統。

  • 業務與非業務邏輯分離

越是跟業務無關的程式碼越是容易複用,越是針對特定業務的程式碼越難複用。所以,為了複用跟業務無關的程式碼,我們將業務和非業務邏輯程式碼分離,抽取成一些通用的框架、類庫、元件等。

  • 通用程式碼下沉

從分層的角度來看,越底層的程式碼越通用、會被越多的模組呼叫,越應該設計得足夠可複用。一般情況下,在程式碼分層之後,為了避免交叉呼叫導致呼叫關係混亂,我們只允許上層程式碼呼叫下層程式碼及同層程式碼之間的呼叫,杜絕下層程式碼呼叫上層程式碼。所以,通用的程式碼我們儘量下沉到更下層。

  • 繼承、多型、抽象、封裝

在講物件導向特性的時候,我們講到,利用繼承,可以將公共的程式碼抽取到父類別,子類複用父類別的屬性和方法。利用多型,我們可以動態地替換一段程式碼的部分邏輯,讓這段程式碼可複用。除此之外,抽象和封裝,從更加廣義的層面、而非狹義的物件導向特性的層面來理解的話,越抽象、越不依賴具體的實現,越容易複用。程式碼封裝成模組,隱藏可變的細節、暴露不變的介面,就越容易複用。

  • 應用模板等設計模式

一些設計模式,也能提高程式碼的複用性。比如,模板模式利用了多型來實現,可以靈活地替換其中的部分程式碼,整個流程模板程式碼可複用。

SOLID原則

SOLID原則不是一個單一的原則,而是對軟體開發至關重要的 5 條原則,遵循這些原則有助於我們寫出高內聚、低耦合、可延伸、可維護性好的程式碼。

S—單一職責原則

一個類應該有一個,而且只有一個改變它的理由。

單一職責原則在我看來是最容易理解也是最重要的一個原則。它的核心思想就是一個模組、類或者方法只做一件事,只有一個職責,千萬不要越俎代庖。它可以帶來下面的好處:

  • 可以讓程式碼耦合度更低
  • 使程式碼更容易理解和維護
  • 使程式碼更易於測試和維護,使軟體更易於實施,並有助於避免未來更改的意外副作用

舉個例子,我們有兩個類PersonAccount。 兩者都負有儲存其特定資訊的單一責任。 如果要更改Person的狀態,則無需修改類Account,反之亦然, 不要把賬戶的行為比如修改賬戶名changeAcctName寫在Person類中。

    public class Person {
    	private Long personId;
    	private String firstName;
    	private String lastName;
    	private String age;
    	private List<Account> accounts;

        // 錯誤做法
        public void changeAcctName(Account account, String acctName) {
            acccount.setAccountName(acctName);
            // 更新到資料庫
        }
    }

    public class Account {
    	private Long guid;
    	private String accountNumber;
    	private String accountName;
    	private String status;
    	private String type;

    }

所以大家在編寫程式碼的時候,一定要停頓思考下這個段程式碼真的寫在這裡嗎?另外很關鍵的一點是如果發現一個類或者一個方法十分龐大,那麼很有可能已經違背單一職責原則了,後續維護可想而知十分痛苦。

O—開閉原則

軟體實體(類、模組、函數等)應該對擴充套件開放,對修改關閉。

對擴充套件開放,對修改關閉,什麼意思?很簡單,其實就是我們要儘量通過新增類實現功能,而不是修改原有的類或者邏輯。因為修改已有程式碼很有可能對已有功能引入bug。

讓我們通過一個例子來理解這個原則,比如一個通知服務。

    public class NotificationService {
    	public void sendOTP(String medium) {
            if (medium.equals("email")) {
                //email 傳送
            } else if (medium.equals("mobile")) {
                // 手機傳送
        	} 
    }

現在需要新增微信的方式通知,你要怎麼做呢? 是在加一個if else嗎? 這樣就不符合開閉原則了,我們看下開閉原則該怎麼寫。

  • 定義一個通知服務介面

    public interface NotificationService {
    	public void sendOTP();
    }
  • E-mail方式通知類EmailNotification

    public class EmailNotification implements NotificationService{
    	public void sendOTP(){
    		// write Logic using JavaEmail api
    	}
    }
  • 手機方式通知類MobileNotification

    public class MobileNotification implements NotificationService{
        public void sendOTP(){
    		// write Logic using Twilio SMS API
    	}
    }
  • 同樣可以新增微信通知服務的實現WechatNotification

    public class WechatNotification implements NotificationService{
    	public void sendOTP(String medium){
    		// write Logic using wechat API
    	}
    }

這樣的方式就是遵循開閉原則的,你不用修改核心的業務邏輯,這樣可能帶來意向不到的後果,而是擴充套件實現方式,由呼叫方根據他們的實際情況呼叫。

是不是想到了設計模式中的策略模式,其實設計模式就是指導我們寫出高內聚、低耦合的程式碼。

L—里氏替換原則

派生類或子類必須可替代其基礎類別或父類別

這個原則稍微有點難以理解,它的核心思想是每個子類或派生類都應該可以替代/等效於它們的基礎類別或父類別。這樣有一個好處,就是無論子類是什麼型別,使用者端通過父類別呼叫都不會產生意外的後果。

理解不了?那我我們通過一個例子來理解一下。

讓我們考慮一下我有一個名為 SocialMedia 的抽象類,它支援所有社交媒體活動供使用者娛樂,如下所示:

    package com.alvin.solid.lsp;

    public abstract class SocialMedia {
        
        public abstract  void chatWithFriend();
        
        public abstract void publishPost(Object post);
        
        public abstract  void sendPhotosAndVideos();
        
        public abstract  void groupVideoCall(String... users);
    }

社交媒體可以有多個實現或可以有多個子類,如 FacebookWechatWeiboTwitter 等。

現在讓我們假設 Facebook 想要使用這個特性或功能。

    package com.alvin.solid.lsp;

    public class Wechat extends SocialMedia {

        public void chatWithFriend() {
            //logic  
        }

        public void publishPost(Object post) {
            //logic  
        }

        public void sendPhotosAndVideos() {
            //logic  
        }

        public void groupVideoCall(String... users) {
            //logic  
        }
    }

我們都知道Facebook都提供了所有上述的功能,所以這裡我們可以認為FacebookSocialMedia類的完全替代品,兩者都可以無中斷地替代。

現在讓我們討論 Weibo

    package com.alvin.solid.lsp;

    public class Weibo extends SocialMedia {
        public void chatWithFriend() {
            //logic
        }

        public void publishPost(Object post) {
          //logic
        }

        public void sendPhotosAndVideos() {
          //logic
        }

        public void groupVideoCall(String... users) {
            //不適用
        }
    }

我們都知道Weibo微博這個產品是沒有群視訊功能的,所以對於 groupVideoCall方法來說 Weibo 子類不能替代父類別 SocialMedia。所以我們認為它是不符合裡式替換原則。

如果強行這麼做的話,會導致使用者端用父類別SocialMedia呼叫,但是實現類注入的可能是個Weibo的實現,呼叫groupVideoCall行為,產生意想不到的後果。

那有什麼解決方案嗎?

那就把功能拆開唄。

    public interface SocialMedia {   
       public void chatWithFriend(); 
       public void sendPhotosAndVideos() 
    }

    public interface SocialPostAndMediaManager { 
        public void publishPost(Object post); 
    }


    public interface VideoCallManager{ 
       public void groupVideoCall(String... users); 
    }

現在,如果您觀察到我們將特定功能隔離到單獨的類以遵循LSP。

現在由實現類決定支援功能,根據他們所需的功能,他們可以使用各自的介面,例如 Weibo 不支援視訊通話功能,因此 Weibo 實現可以設計成這樣:

    public class Instagram implements SocialMedia,SocialPostAndMediaManager{
    	public void chatWithFriend(){
        //logic
        }
        public void sendPhotosAndVideos(){
        //logic
        }
        public void publishPost(Object post){
        //logic
        }
    }

這樣子就是符合裡式替換原則LSP。

I—介面隔離原則

介面不應該強迫他們的客戶依賴它不使用的方法。

大家可以看看自己的工程,是不是一個介面類中有很多很多的介面,每次呼叫API方法的時候IDE工具給你彈出一大堆,十分的"臃腫肥胖"。所以該原則的核心思想要將你的介面拆小,拆細,打破」胖介面「,不用強迫使用者端實現他們不需要的介面。是不是和單一職責原則有點像?

例如,假設有一個名為 UPIPayment 的介面,如下所示

    public interface UPIPayments {
        
        public void payMoney();
        
        public void getScratchCard();
        
        public void getCashBackAsCreditBalance();
    }

現在讓我們談談 UPIPayments 的一些實現,比如 Google PayAliPay

Google Pay 支援這些功能所以他可以直接實現這個 UPIPaymentsAliPay 不支援 getCashBackAsCreditBalance() 功能所以這裡我們不應該強制使用者端 AliPay 通過實現 UPIPayments 來覆蓋這個方法。

我們需要根據客戶需要分離介面,所以為了滿足介面隔離原則,我們可以如下設計:

  • 建立一個單獨的介面來處理現金返還。

    public interface CashbackManager{ 
    	public void getCashBackAsCreditBalance(); 
    }

現在我們可以從 UPIPayments 介面中刪除getCashBackAsCreditBalanceAliPay也不需要實現getCashBackAsCreditBalance()這個它沒有的方法了。

D—依賴倒置原則

高層模組不應該依賴低層模組,兩者都應該依賴於抽象(介面)。抽象不應該依賴於細節(具體實現),細節應該取決於抽象。

這個原則我覺得也不是很好理解,所謂高層模組和低層模組的劃分,簡單來說就是,在呼叫鏈上,呼叫者屬於高層,被呼叫者屬於低層。比如大家都知道的MVC模式,controller是呼叫service層介面這個抽象,而不是實現類。這也是我們經常說的要面向介面程式設計,而非細節或者具體實現,因為介面意味著契約,更加穩定。

我們通過一個例子加深一下理解。

  • 借記卡

    public class DebitCard { 
    	public void doTransaction(int amount){ 
            System.out.println("tx done with DebitCard"); 
        } 
    }
  • 信用卡

    public class CreditCard{ 
    	public void doTransaction(int amount){ 
            System.out.println("tx done with CreditCard"); 
        } 
    }

現在用這兩張卡你去購物中心購買了一些訂單並決定使用信用卡支付

    public class ShoppingMall {
    	private DebitCard debitCard;
    	public ShoppingMall(DebitCard debitCard) {
            this.debitCard = debitCard;
       	}
    	public void doPayment(Object order, int amount){              
            debitCard.doTransaction(amount); 
     	}
    	public static void main(String[] args) {
         	DebitCard debitCard=new DebitCard();
         	ShoppingMall shoppingMall=new ShoppingMall(debitCard);
         	shoppingMall.doPayment("some order",5000);
        }
    }

上面的做法是一個錯誤的方式,因為 ShoppingMall 類與 DebitCard 緊密耦合。

現在你的借記卡餘額不足,想使用信用卡,那麼這是不可能的,因為 ShoppingMall 與借記卡緊密結合。

當然你也可以這樣做,從建構函式中刪除借記卡並注入信用卡。但這不是一個好的方式,它不符合依賴倒置原則。

那該如何正確設計呢?

  • 定義依賴的抽象介面BankCard

    public interface BankCard { 
      public void doTransaction(int amount); 
    }
  • 現在 DebitCardCreditCard 都實現BankCard

    public class CreditCard implements BankCard{
    	public void doTransaction(int amount){            
            System.out.println("tx done with CreditCard");
        }
    }

    public class DebitCard implements BankCard { 
    	public void doTransaction(int amount){ 
    		System.out.println("tx done with DebitCard"); 
        } 
    }
  • 現在重新設計購物中心這個高階類,他也是去依賴這個抽象,而不是直接低階模組的實現類

    public class ShoppingMall {
    	private BankCard bankCard;
    	public ShoppingMall(BankCard bankCard) {
            this.bankCard = bankCard;
        }
    	public void doPayment(Object order, int amount){
            bankCard.doTransaction(amount);
        }
    	public static void main(String[] args) {
            BankCard bankCard=new CreditCard();
            ShoppingMall shoppingMall1=new ShoppingMall(bankCard);
            shoppingMall1.doPayment("do some order", 10000);
        }
    }

我們還可以拿 Tomcat這個 Servlet 容器作為例子來解釋一下。

Tomcat 是執行 Java Web 應用程式的容器。我們編寫的 Web 應用程式程式碼只需要部署在Tomcat 容器下,便可以被 Tomcat 容器呼叫執行。按照之前的劃分原則,Tomcat 就是高層模組,我們編寫的 Web 應用程式程式碼就是低層模組。Tomcat 和應用程式程式碼之間並沒有直接的依賴關係,兩者都依賴同一個「抽象」,也就是 Sevlet 規範。Servlet 規範不依賴具體的 Tomcat 容器和應用程式的實現細節,而 Tomcat 容器和應用程式依賴 Servlet規範。

總結

本文總結了軟體程式設計中的黃金原則,KISS原則,DRY原則,SOLID原則。這些原則不僅僅適用於程式設計,也可以指導我們在架構設計上。雖然其中有些原則很抽象,但是大家多多實踐和思考,會體會到這些原則的精妙。

歡迎關注個人公眾號【JAVA旭陽】交流學習