子程式(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 --> unacceptable7.3 好的子程式名稱#
好的名稱能清楚描述子程式所做的一切。命名原則如下:
| 原則 | 說明 |
|---|---|
| 描述所有行為 | 名稱應涵蓋所有輸出和副作用。如果名稱需要寫成 ComputeReportTotalsAndOpenOutputFile() 才完整,那真正的解方是消除副作用,而非縮短名稱。 |
| 避免空洞模糊的動詞 | 如 HandleCalculation()、PerformServices()、ProcessInput() 等,這些名稱幾乎沒有傳達任何資訊。 |
| 不要只靠數字區分 | 如 OutputUser1、OutputUser2,完全無法表達不同的抽象。 |
| 名稱長度不必設限 | 清晰比簡短更重要。 |
| 函式用回傳值描述命名 | 如 cos()、printer.IsReady()、pen.CurrentColor()。 |
| 程序用強動詞加受詞命名 | 如 PrintDocument()、CalcMonthlyRevenues()。在物件導向語言中,物件本身已提供受詞,所以用 document.Print() 而非 document.PrintDocument()。 |
| 成對使用反義詞 | 如 add/remove、open/close、create/destroy、start/stop、get/set、first/last、insert/delete、show/hide、next/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++ 提供了
const、inline、template、enum、typedef等替代方案。如 Bjarne Stroustrup 所言:「幾乎每個巨集都反映出程式語言、程式或程式設計師的缺陷。」謹慎的程式設計師只在萬不得已時才使用巨集替代子程式。
行內子程式(Inline Routines)#
C++ 的 inline 關鍵字讓程式碼在撰寫時被視為子程式,但編譯器會在編譯時將其轉為行內程式碼。應謹慎使用,因為:
- 行內子程式在 C++ 中必須將實作放在標頭檔中,這違反了封裝性。
- 較大的行內子程式會在每次呼叫時產生完整程式碼,增加程式碼大小。
- 任何基於效能理由的行內化,都應先透過效能分析(Profiling)來驗證其改善效果。
要點#
- 建立子程式最重要的理由是提升程式的智識可管理性,而非節省空間。改善可讀性、可靠性和可修改性才是更好的理由。
- 有時候最值得獨立成子程式的操作,反而是很簡單的操作。
- 內聚性有多種層級,但大多數子程式都可以做到功能內聚,這是最理想的狀態。
- 子程式的名稱是其品質的指標。糟糕的名稱若是準確的,代表子程式設計不良;若是不準確的,代表名稱沒有告訴你程式在做什麼。無論哪種情況,都需要修改。
- 函式應僅在主要目的是回傳其名稱所描述的特定值時使用。
- 謹慎的程式設計師只在萬不得已時才使用巨集子程式。