子程式(Routine)是指為了單一目的而可被呼叫的獨立方法或程序,包括 C++ 中的函式、Java 中的方法、Visual Basic 中的函式或子程序等。 子程式是電腦科學中最重要的發明之一,它讓程式更容易閱讀、理解與維護。本章聚焦於區分好子程式與壞子程式的關鍵特徵。

7.1 建立子程式的正當理由#

建立子程式最重要的理由是降低複雜度(Reduce Complexity)。將細節封裝在子程式中,讓你在使用它時不必再思考內部運作方式。

建立子程式的完整理由清單
  • 降低複雜度:隱藏資訊,讓你不需要思考內部細節。深層巢狀的迴圈或條件判斷是拆分的明確信號。
  • 引入中間的、可理解的抽象層:用一個命名良好的子程式取代一段程式碼,等於為該段邏輯建立了更高層次的抽象。
  • 避免重複程式碼:將重複邏輯集中到一處,修改時只需改一個地方,也減少出錯機會。
  • 支援子類別覆寫(Subclassing):簡短且分解良好的子程式,比冗長的子程式更容易被覆寫。
  • 隱藏順序:當操作必須依特定順序執行時,將它們封裝在子程式中,避免讓順序假設散佈到系統各處。
  • 隱藏指標操作:指標操作容易出錯,將其隔離在子程式中可集中管理。
  • 提升可攜性(Portability):將不可攜的功能(如硬體依賴、作業系統依賴)隔離在子程式中。
  • 簡化複雜的布林判斷:將複雜的布林測試放入命名良好的函式中,讓主流程更清晰。
  • 提升效能:將程式碼集中在一處,只需在一處進行最佳化。

此外,許多建立類別的理由同樣適用於子程式:隔離複雜度、隱藏實作細節、限制變更影響範圍、隱藏全域資料、建立集中控制點、促進程式碼重用等。

看似太簡單的操作也值得建立子程式#

不要因為只有兩三行程式碼就不願建立子程式。小子程式有顯著好處:

  • 提升可讀性:一行 points = DeviceUnitsToPoints(deviceUnits) 遠比展開的計算公式容易理解。
  • 小操作往往會長大:日後可能需要加入錯誤處理、邊界檢查等邏輯,集中在一處管理遠比分散在十幾處更經濟。

如果某段程式碼被複製到十多個地方,日後發現需要加入三行錯誤處理,一個子程式只需改一處;沒有子程式的話,你就得在十多個地方各改三行。

7.2 在子程式層上設計#

在子程式層級,最核心的設計概念是內聚性(Cohesion),也就是子程式中各項操作之間的關聯程度。目標是讓每個子程式只做好一件事,不做其他事。

研究顯示,高內聚的子程式中有 50% 完全無缺陷,而低內聚的子程式只有 18% 無缺陷。此外,耦合度與內聚度比值最高的子程式,錯誤數量是最低者的 7 倍,修復成本是 20 倍。

內聚性的層級#

最理想的是功能內聚(Functional Cohesion),即子程式只做一件事。例如 sin()GetCustomerName()EraseFile()

以下幾種內聚雖不理想,但可接受:

  • 循序內聚(Sequential Cohesion):操作必須依特定順序執行,且步驟間共用資料,但合在一起不構成完整功能。可考慮拆成各自獨立的子程式。
  • 通訊內聚(Communicational Cohesion):操作使用相同資料但彼此無關。例如列印報告後重置摘要資料,應將兩者拆開。
  • 時序內聚(Temporal Cohesion):操作因為在同一時間執行而被放在一起,如 Startup()。此時應讓該子程式只負責調度其他子程式,而非直接執行各項操作。

以下幾種內聚通常不可接受,應重新設計:

  • 程序內聚(Procedural Cohesion):操作依指定順序執行,但彼此無功能上的關聯。
  • 邏輯內聚(Logical Cohesion):多個不相關操作被塞進同一個子程式,靠傳入的旗標來選擇執行哪一個。應拆成獨立的子程式。唯一的例外是純粹的事件處理器(Event Handler)
  • 巧合內聚(Coincidental Cohesion):操作之間完全沒有關聯,需要深度重新設計。
flowchart TD
    subgraph best["最佳"]
        A["功能內聚"]
    end
    subgraph acceptable["可接受"]
        B["循序內聚"]
        C["通訊內聚"]
        D["時序內聚"]
    end
    subgraph unacceptable["不可接受"]
        E["程序內聚"]
        F["邏輯內聚"]
        G["巧合內聚"]
    end
    best --> acceptable --> unacceptable

7.3 好的子程式名稱#

好的名稱能清楚描述子程式所做的一切。命名原則如下:

原則說明
描述所有行為名稱應涵蓋所有輸出和副作用。如果名稱需要寫成 ComputeReportTotalsAndOpenOutputFile() 才完整,那真正的解方是消除副作用,而非縮短名稱。
避免空洞模糊的動詞HandleCalculation()PerformServices()ProcessInput() 等,這些名稱幾乎沒有傳達任何資訊。
不要只靠數字區分OutputUser1OutputUser2,完全無法表達不同的抽象。
名稱長度不必設限清晰比簡短更重要。
函式用回傳值描述命名cos()printer.IsReady()pen.CurrentColor()
程序用強動詞加受詞命名PrintDocument()CalcMonthlyRevenues()。在物件導向語言中,物件本身已提供受詞,所以用 document.Print() 而非 document.PrintDocument()
成對使用反義詞add/removeopen/closecreate/destroystart/stopget/setfirst/lastinsert/deleteshow/hidenext/previous
為常見操作建立命名慣例避免同一團隊對相同操作使用 employee.id.Get()dependent.GetId()supervisor() 等不一致的命名。

7.4 子程式可以寫多長#

關於子程式長度,多項研究的綜合結論是:

  • 長度在 100-200 行(非註解、非空白行)的子程式,其錯誤率並不比更短的子程式高。
  • 超過 500 行的子程式錯誤率會明顯上升。
  • 在物件導向程式中,大量子程式會是很短的存取方法(Accessor),偶爾的複雜演算法才會導致較長的子程式。

不要以人為的長度限制來決定子程式長度。讓內聚性、巢狀深度、變數數量、決策點數量等複雜度相關因素,自然決定子程式應該有多長。但若超過 200 行,需格外謹慎。

7.5 如何使用子程式參數#

子程式之間的介面是程式中最容易出錯的區域之一。研究顯示 39% 的錯誤是內部介面錯誤。以下是減少介面問題的準則:

準則說明
按「輸入-修改-輸出」排列參數先列僅輸入的參數,再列同時輸入輸出的參數,最後列僅輸出的參數。狀態或錯誤變數放最後。
相似子程式的參數順序保持一致不一致的順序會增加記憶負擔。C 標準函式庫中 fprintf() 的 file 在第一個參數,而 fputs() 的 file 在最後一個參數,就是反面教材。
使用所有參數未使用的參數與更高的錯誤率相關。研究顯示,無未使用變數的子程式有 46% 無錯誤,而有多個未引用變數的子程式只有 17-29% 無錯誤。
不要把參數當作工作變數使用區域變數來存放中間結果。這能保留原始輸入值,也避免命名上的混淆。在 C++ 中可用 const 強制保護輸入參數。
記錄介面假設包括參數是輸入、修改還是輸出;數值參數的單位;預期的值範圍;不應出現的特定值等。使用斷言(Assertion)將假設寫入程式碼更佳。
參數數量限制在七個左右心理學研究顯示人類同時追蹤資訊的上限約為七個區塊。若經常傳遞超過七個參數,表示子程式之間的耦合太緊密,考慮將資料與子程式組織成類別。
傳遞物件還是個別欄位取決於子程式介面呈現的抽象。若抽象是「你需要這三個特定資料」,就傳個別欄位;若抽象是「你需要這個物件」,就傳整個物件。

若你發現自己為了呼叫某子程式而先建立物件、填入資料、呼叫後又取出資料,這表示你應該直接傳遞個別欄位。反之,若你頻繁更動參數列表且參數都來自同一物件,則應傳遞整個物件。

7.6 使用函式時要特別考慮的問題#

函式(Function) 回傳值,程序(Procedure) 不回傳值。使用上的關鍵區分:

  • 當子程式的主要目的是回傳由其名稱所描述的值時,使用函式。例如 sin()CustomerID()
  • 當子程式的主要操作是產生副作用(如格式化輸出)時,使用程序,並將狀態碼作為明確的輸出參數。
  • 避免在一行中同時結合子程式呼叫與狀態值測試,這會增加語句的複雜度。

設定函式回傳值的注意事項#

  • 檢查所有可能的執行路徑:確保函式在所有情況下都會回傳值。在函式開頭將回傳值初始化為預設值,可作為安全網。
  • 不要回傳區域資料的參考或指標:子程式結束後,區域資料會超出範圍。應改用類別成員資料與存取方法。

7.7 Macro 子程式和行內子程式#

Macro 子程式#

在 C++ 中使用前置處理器巨集時,需特別注意:

  • 完全括號化巨集表達式:如 #define Cube(a) ((a)*(a)*(a)),否則運算子優先序會導致非預期結果。
  • 多條陳述式的巨集用大括號包圍:避免在迴圈或條件判斷中只執行巨集的第一行。
  • 將可替換為子程式的巨集,以子程式命名慣例命名:這樣日後替換時不需更動呼叫端。

現代語言如 C++ 提供了 constinlinetemplateenumtypedef 等替代方案。如 Bjarne Stroustrup 所言:「幾乎每個巨集都反映出程式語言、程式或程式設計師的缺陷。」謹慎的程式設計師只在萬不得已時才使用巨集替代子程式。

行內子程式(Inline Routines)#

C++ 的 inline 關鍵字讓程式碼在撰寫時被視為子程式,但編譯器會在編譯時將其轉為行內程式碼。應謹慎使用,因為:

  • 行內子程式在 C++ 中必須將實作放在標頭檔中,這違反了封裝性
  • 較大的行內子程式會在每次呼叫時產生完整程式碼,增加程式碼大小。
  • 任何基於效能理由的行內化,都應先透過效能分析(Profiling)來驗證其改善效果。

要點#

  • 建立子程式最重要的理由是提升程式的智識可管理性,而非節省空間。改善可讀性、可靠性和可修改性才是更好的理由。
  • 有時候最值得獨立成子程式的操作,反而是很簡單的操作。
  • 內聚性有多種層級,但大多數子程式都可以做到功能內聚,這是最理想的狀態。
  • 子程式的名稱是其品質的指標。糟糕的名稱若是準確的,代表子程式設計不良;若是不準確的,代表名稱沒有告訴你程式在做什麼。無論哪種情況,都需要修改。
  • 函式應僅在主要目的是回傳其名稱所描述的特定值時使用。
  • 謹慎的程式設計師只在萬不得已時才使用巨集子程式。