一文了解函數設計的最佳實踐

2023-06-23 09:00:33

1. 引言

良好設計的函數具有清晰的職責和邏輯結構,提供準確的命名和適當的引數控制。它們促程序式碼複用、支援團隊共同作業,降低維護成本,並提供可測試的程式碼基礎。通過遵循最佳實踐,我們能夠編寫出高質量、可讀性強的程式碼,從而提高開發效率和軟體質量。下面我們將一一描述函數設計時能夠遵循的最佳實踐。

2. 遵循單一職責原則

遵循單一職責原則是函數設計的重要原則之一。它要求一個函數只負責完成單一的任務或功能,而不應該承擔過多的責任。

通過遵循該原則,我們設計出來的函數將具有以下幾個優點:

  1. 程式碼可讀性的提高:函數只關注單一的任務或功能,使其邏輯更加清晰和簡潔。這樣的函數更易於閱讀和理解,能夠更快速地理解其作用和目的,提高程式碼的可讀性。
  2. 函數複雜度的降低:單一職責的函數具有較小的程式碼量和較少的依賴關係。這使得函數的邏輯更加集中和可控,減少了函數的複雜性。在維護和修改程式碼時,由於函數的功能單一,我們可以更容易地定位和修復問題,降低了維護成本。
  3. 程式碼可測試性的提高:遵循單一職責原則的函數更容易進行單元測試。因為函數的功能單一,我們可以更精確地定義輸入和期望輸出,編寫針對性的測試用例。這有助於提高程式碼的可測試性,確保函數的正確性和穩定性。

相對的,如果函數設計時沒有遵循單一職責原則,此時將帶來函數複雜性的增加,從而導致程式碼可讀性的降低以及程式碼可測試性的下降。

下面是一個沒有遵循單一職責原則的函數與一個遵循該原則的函數的對比。首先是一個未遵循該原則的程式碼範例:

func processData(data []int) {
    // 1. 驗證資料
    
    // 2. 清理資料
    
    // 3. 分析資料
    
    // 4. 儲存資料
    
    // 5. 記錄紀錄檔
}

在上述範例中,processData 函數負責整個資料處理流程,包括驗證資料、清理資料、分析資料、儲存資料和記錄紀錄檔。這個函數承擔了太多的職責,導致程式碼邏輯複雜,可讀性不高,同時如果某一個節點需要變更,此時需要考慮是否對其他部分是否有影響,程式碼的可維護性進一步降低。

下面我們將processData函數進行改造,使其遵循單一職責原則,從而凸顯出遵循單一職責原則的好處,程式碼範例如下:

func processData(data []int) {
    // 1. 驗證邏輯拆分到calidateData函數中
    validateData(data)
    // 2. 清理資料 拆分到cleanData函數中
    cleanedData := cleanData(data)
    // 3. 分析資料 拆分到 analyzeData 函數中
    result := analyzeData(cleanedData)
    //4. 儲存資料 拆分到 saveData 函數中
    saveData(result)
    //5. 記錄紀錄檔 拆分到 logData 函數中
    logData(result)
}

func validateData(data []int) {
    // 驗證資料的邏輯
    // ...
}

func cleanData(data []int) []int {
    // 清理資料的邏輯
    // ...
}

func analyzeData(data []int) string {
    // 分析資料的邏輯
    // ...
}

func saveData(result string) {
    // 儲存資料的邏輯
    // ...
}

func logData(result string) {
    // 記錄紀錄檔的邏輯
    // ...
}

改造後的processData函數中,我們將不同的任務拆分到不同的函數中,每個函數只負責其中一部分功能。由於每個函數只需要專注於其中一項任務,程式碼的可讀性更好,而且每個函數只負責其中一部分功能,故程式碼的複雜性也明顯降低了,而且程式碼也更容易測試了。

而且由於此時每個函數只負責其中一個任務,如果其存在變更,也不會擔心影響到其他部分的內容,程式碼的可維護性也更高了。

通過對比這兩個範例,我們可以很清楚得看到,遵循單一職責函數的函數,其程式碼可讀性更高,複雜度更低,程式碼可測試性更強,同時也提高了程式碼的可維護性。

3. 控制函數引數數量

函數在不斷進行迭代過程中,函數引數往往會不斷增多,此時我們在每次迭代過程中,都需要思考函數引數是否過多。通過避免函數引數過多,這能夠給我們一些好處:

  1. 首先是函數更加容易使用,過多的引數會增加函數的複雜性,使函數呼叫時的意圖不夠清晰。通過控制引數數量,可以使函數的呼叫更加簡潔和方便。
  2. 其次是函數的耦合度的降低: 過多的引數會增加函數與呼叫者之間的耦合度,使函數的可複用性和靈活性降低。通過封裝相關引數為物件或結構體,可以減少引數的數量,從而降低函數之間的依賴關係,提高程式碼的靈活性和可維護性。
  3. 同時也提高了函數的擴充套件性,當需要對函數進行功能擴充套件時,過多的引數會使函數的修改變得複雜,可能需要修改大量的呼叫程式碼。而通過封裝相關引數,只需修改封裝物件或結構體的定義,可以更方便地擴充套件函數的功能,同時對現有的呼叫程式碼影響較小。
  4. 能夠及時識別函數是否符合單一職責原則,當函數引數過多時,同時我們又無法將其抽取為一個結構體引數,這往往意味著函數的職責不單一。從另外一個方面,迫使我們在函數還沒有堆積更多功能前,及時將其拆分為多個函數,提高程式碼的可維護性。

下面,我們通過一個程式碼範例,展示一個函數引數數量過多的例子和優化後的範例,首先是優化前的函數程式碼範例:

func processOrder(orderID string, customerName string, customerEmail string, shippingAddress string, billingAddress string, paymentMethod string, items []string) {
    // 處理訂單的邏輯
    // ...
}

在這個範例中,函數 processOrder 的引數數量較多,包括訂單ID、顧客姓名、顧客郵箱、收貨地址、賬單地址、支付方式和商品列表等。呼叫該函數時,需要傳遞大量的引數,使函數呼叫變得冗長且難以閱讀。

下面,我們將processOrder的引數抽取成一個結構體,控制函數引數的數量,程式碼範例如下:

type Order struct {
    ID               string
    CustomerName     string
    CustomerEmail    string
    ShippingAddress  string
    BillingAddress   string
    PaymentMethod    string
    Items            []string
}

func processOrder(order Order) {
    // 處理訂單的邏輯
    // ...
}

在優化後的範例中,我們將相關的訂單資訊封裝為一個 Order 結構體。通過將引數封裝為結構體,函數的引數數量大大減少,只需傳遞一個結構體物件即可。

這樣的設計使函數呼叫更加簡潔和易於理解,同時也提高了程式碼的可讀性和可維護性。如果需要新增或修改訂單資訊的欄位,只需修改結構體定義,而不需要修改呼叫該函數的程式碼,提高了程式碼的擴充套件性和靈活性。

其次,在processOrder函數引數抽取的過程中,如果發現無法將函數引數抽取為結構體的話,也能幫助我們及時識別到函數職責不單一的問題,從而能夠及時將函數進行拆分,提高程式碼的可維護性。

因此,在函數設計迭代過程中,控制函數引數過多是非常有必要的,能夠提高函數的可用性和擴充套件性,其次也能夠幫助我們識別函數是否滿足符合單一職責原則,也間接提高了程式碼的可維護性。

4. 函數命名要準確

函數設計時,適當的函數命名是至關重要的,它能夠準確、清晰地描述函數的功能和作用。一個好的函數名能夠使程式碼易於理解和使用,提高程式碼的可讀性和可維護性。

相對準確的函數命名,能夠明確傳達函數的用途和功能,避免其他人對函數的誤用。同時,也提高了程式碼的可讀性,其他人閱讀程式碼時,能夠更加輕鬆得理解函數的含義和邏輯。因此,設計函數時,一個清晰準確的函數名也是至關重要的。

下面再通過一個程式碼的例子,展示準確清晰的函數命名,和一個含糊不清的函數命名之間的區別:

// 不合適的函數命名範例
func F(a, b int) int {
    // 函數體的邏輯
    // ...
}

// 適當的函數命名範例
func Add(a, b int) int {
    // 將兩個數相加
    return a + b
}

在上述範例中,第一個函數命名為 F,沒有提供足夠的資訊來描述函數的功能和用途。這樣的函數命名使其他人難以理解函數的目的和作用。

而在第二個函數中,我們將函數命名為 Add,清晰地描述了函數的功能,即將兩個數相加。這樣的命名使得程式碼更易於理解和使用。

因此,在函數設計中,我們需要定義一個清晰和準確的函數命名,這樣能夠提高程式碼的可讀性,讓其他人更容易理解我們的意圖。

5. 控制函數長度

在函數編寫和迭代過程中,一個超過1000行的函數,一般不是一開始實現便是如此,而是在不斷迭代過程中,不斷往其中迭代功能,才最終出現了這個大函數。由此造成的後果,各種業務邏輯在該函數中錯綜複雜,接手的同事往往難以快速理解其功能和行為。而且,在功能迭代過程中,由於各種邏輯穿插其中,此時函數將變得難以修改和維護,程式碼基本不具有可讀性和可維護性。

因此,在程式碼迭代過程中,時時考慮函數的長度是至關重要的。當在迭代過程中,發現函數已經過長了,此時應該儘快通過一些手段重構該函數,避免函數最終無法維護,下面是一些可能的手段:

  1. 確保函數只負責完成單一的任務或功能,避免函數承擔過多的責任。
  2. 當函數過長時,將其拆分為多個較小的函數,每個函數負責特定的功能或操作。
  3. 將長函數中的某些邏輯提取出來,形成獨立的輔助函數,以減少函數的長度和複雜度。

在需求迭代過程中,我們時時關注函數的長度,當長度過長時,便適當進行重構,保持程式碼的可讀性和可維護性。

6. 進行防禦式程式設計

在函數編寫過程中,儘量考慮各種可能的錯誤和異常情況,以及相應的處理策略。這能夠帶來一些好處:

  1. 增強程式的健壯性: 防禦式程式設計通過對可能的錯誤和異常情況進行處理,它可以幫助程式更好地處理無效的輸入、邊界條件和異常情況,從而提高程式的健壯性和可靠性。
  2. 減少程式的崩潰和故障: 通過合理的錯誤處理和例外處理機制,防禦式程式設計可以防止程式在出現錯誤時崩潰或產生不可預測的行為。它可以使程式在遇到問題時能夠適當地處理和恢復,從而減少系統的故障和崩潰。

下面是一個對比的範例程式碼,展示一個進行防禦式程式設計的程式碼和一個未進行防禦式程式設計的程式碼範例:

// 沒有防禦程式設計的函數範例
func Divide(a, b int) int {
    return a / b
}

// 有防禦程式設計的函數範例
func SafeDivide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

在上述範例中,第一個函數 Divide 沒有進行錯誤處理,如果除數 b 為零,會導致執行時發生除以零的錯誤,可能導致程式異常終止。而第二個函數 SafeDivide 在執行除法之前,先進行了錯誤檢查,如果除數 b 為零,則返回一個自定義的錯誤,避免了程式崩潰。

因此,我們在函數編寫過程中,儘量考慮各種可能的錯誤和異常情況,對其進行處理,保證函數的健壯性。

7. 總結

在這篇文章中,我們總結了幾個函數設計的最佳實踐,如遵循單一職責原則,控制函數引數數量,函數命名要清晰準確等,通過遵循這些原則,能夠讓我們設計出來高質量、可讀性強的程式碼,同時也具有更強的可維護性。

但是也需要注意的是,函數一開始設計時總是相對比較完美的,只是在不斷迭代中,不斷堆積程式碼,最終程式碼冗長,複雜,各種邏輯穿插其中,使得維護起來越發困難。因此,我們更多的應該是在迭代過程中,多考慮函數設計是否違反了我們這裡提出的原則,能在一開始就識別到程式碼的壞味道,從而避免最終演變成難以維護和迭代的函數。

基於此,我們完成了對函數設計最佳實踐的介紹,希望對你有所幫助。