這本書的內容其實算基本的但很實用。它整理了一系列可以用在不同情況下的模式(pattern),並且詳細地描述了它們各自的優缺點、限制和要注意的事情。如果有時候不確定某個功能要用什麼方式寫的話,可以依據書中的分析來選擇最適合的寫法。
如果你已經寫 C 有一段時間的話,書中的寫法你應該多數都知道或用過,不過書中把它們更有系統地做了歸納,也有範例程式,且列出某個模式在哪些專案中有被使用過。
總之,如果是 C 的新手的話,滿推薦看這本書的,你可以非常快速且明確的知道某個情況下,可以確實解決問題的好寫法是什麼。
本文僅會大致整理一下重點,若想要知道詳細的內容的話,推薦還是買本原書來看。
底下的範例只是概念參考,並沒有現實意義。
錯誤處理
Function Split
簡單說就是關注點分離(SoC)和單一職責(SRP)。找出一個大型函式中可以獨立的職責,將其分離成獨立的部分。
書中提出其中一種是否分離的參考依據——如果在此函式的多處清理同個資源,就最好至少把它分為配置+清理資源的部分以及使用資源的部分,讓配置和清理資源的程式寫在一起,而使用該資源的程式也只專注在資源的使用上(這部分的程式可以有多個 return
也不必擔心資源的釋放)。
我認為這也和控制反轉(IoC)是相近的概念,即物件(資源)的創建和使用應該分離。負責創建資源的就專心管理資源本身,需要使用資源的就專注在程式邏輯上。
Guard Clause
講 Early return 可能比較多人知道。就是當此函式有前置條件時,不要用巢狀 if-else 來處理,這樣很容易造成高層數的縮排,而且也會讓前置條件和關鍵邏輯的判斷混在一起,可讀性很差。
前置條件應該在開頭就獨立判斷,若不符就儘早 return
返回。
Samurai Principle
簡單講就是速錯(Fail fast)。當錯誤發生時,直接讓整個程式當掉(如果這個程式不是高可用性或沒有很重要、關鍵的系統可以用)。可以使用 assert
達成,優點是當錯誤真的發生時,它會非常明顯以至於你難以有意或無意的忽視它(也就是錯誤不會被隱藏)。當然,runtime 容許的錯誤不適合這個方法。如果這是 Embedded 的話,要非常仔細地考量,通常可能不適合。
使用 assert
可以更方便地切換 debug 和 release 環境的不同行為。
Goto Error Handling
有些時候你需要獲得和清理多個資源,你需要在函式結束前一一釋放所有已經取得的資源。
或許很多人和我剛開始學的一樣——不要用 goto
。但它可能沒有像一些教科書寫的那麼糟糕,至少在 C 是這樣,因為 C 的 goto
只能在同個函式範圍內跳轉,不能跨函式。實際上像是 CPython 和 Linux kernel 等許多 C 程式裡都有用到 goto
來釋放資源,這甚至很常見。《21世紀C語言》中也有相同的觀點。
但是使用 goto
還是要小心並且想清楚,且避免多次跳躍,注意專案的規範裡允不允許使用。
Cleanup Record
如果你還是不想或不行用 goto
但是想要處理多資源的獲取與清理,可以使用延遲求值(Lazy evaluation)特性。
因為 AND 邏輯是只要有一個 false
,其結果就一定是 false
。因為 C 有短路求值(Short-circuit evaluation)的特性,使用 &&
串聯的各個值,如果第一個值就是 false
的話,就不會再繼續評估後續的值了,因為結果一定是 false
。 OR 運算子 ||
也有相同的特性。
不過我個人比較少看到這種寫法,在 if
條件判斷內賦值的寫法可能會讓人感到困惑(可能會有人覺得它是 ==
而非 =
,但是這裡確實是要賦值)。但是如果資源很多的話,你的 if
條件會變得很長,難以閱讀。不然就是要將賦值的行為移出 if
。
Object-Based Error Handling
雖然 C 不是 OOP,但我們可以自己運用建構子(constructor)和解構子(destructor)的概念來初始化和清理資源。簡單說就是把獲取和釋放資源的函式再各別獨立。
回傳錯誤資訊
C 不像 C++ 或其它現代的語言有內建的例外處理(Exception)功能(雖然有一個 errno.h
),但是我們仍有處理錯誤訊息的需求。雖然確實有些 Exception library 可以用,但是還是有一些可以利用 C 本身風格的做法。
Return Status Codes
最基本的就是利用 return
回傳定義好的錯誤代碼。錯誤碼通常會使用 enum
或 #define
定義。呼叫方只要根據回傳值做判斷即可。
我個人的話傾向使用 enum
。
Return Relevant Errors
這個也是回傳錯誤碼,但僅考慮此錯誤資訊和呼叫方相關的情況,也就是呼叫方只處理它有辦法(及有責任)處理的。不然程式一大起來,光是回傳和解析錯誤碼的程式也會非常佔空間。
Special Return Values
如果這個函式本來就要一個回傳值,但是你同時又需要回傳錯誤碼時,可以指定一些特殊的數值作為錯誤碼,以避免與原始數值碰撞(除非原始數值的範圍就是全型別,那就要用 Out Parameter)。例如上面的 cal_bmi()
,正常計算的 BMI 數值一定是正數,因此可以使用負數來當做錯誤碼,呼叫方只要判斷回傳值是否大於 0 即可。
Log Errors
如果想要詳細的錯誤訊息,則需要有一套完整的 Log 系統。你可以利用這些特殊巨集來完善錯誤訊息:
__FILE__
:當前檔案__FUNC__
:當前函式__LINE__
:當前行
記憶體管理
記憶體和指標的管理一直都是寫 C 的重點。首先 C 中主要有幾種選項:
- Stack(堆疊):空間相當有限。
- Heap(堆積):可動態分配。
- Static(靜態記憶體):在編譯期確認。
Heap 通常被視為很有彈性,但配置 Heap 時有這些問題要注意:
- 配置完成的空間,如果沒有要再用的話,要釋放,不然此空間將無法再次被使用(即 memory leak)。
- 重複釋放記憶體可能會導致不可預期的結果,要絕對避免。
- 存取已釋放的記憶體可能導致不可預期的結果,要絕對避免。
- Heap 的配置較 Stack 和 Static 慢(因為要找尋適合的空間),且有可能失敗。
- 記憶體碎片化,連續的記憶體空間變得零碎,這點對 Embedded 和長時間運作的系統尤為重要(所以許多相關守則是避免 Embedded 使用 Heap)。
Stack First
適合:事先知道最大的資料量、該資料量並不大。
直接使用 Stack。一般預設在 Block 內宣告的變數就都是自動變數了,它們會在 Stack 中。當離開 Block 時會自動清理,不用擔心忘記或錯誤清理的情況。但是要注意使用指標時的懸空指標(Dangling pointer)。Stack 空間通常不大,存太大的資料會造成 Stack Overflow。
Eternal Memory
適合:固定大小的資料、資料量大、生命週期需要跨函式。
使用靜態記憶體,無論是全域還是區域,靜態記憶體在編譯器就可以確認並分配好。但是多執行緒的情況要考慮同步機制。
一般我們說
static
加在全域的變數或函式上時,可以限制其只能在此檔案中被調用(即其它地方無法extern
),但更精確說是此 Translation unit 內,而非此檔案。它們間還是有細微的差異。否則在標頭檔定義的static inline
函式就不能用了。
Lazy Cleanup
適合:無法事前知道資料量、資料量很大、整個生命週期都需要、此程式的執行時間不長。
使用 Heap,但不在程式內清理,而是等整個程式結束時有系統處理釋放,即刻意造成 memory leak。這個做法聽起來很瘋狂,但它確實也是一種方法,在某些特殊情況下可能很有效。當然,要使用此方法的話,你需要慎重考慮。
Dedicated Ownership
適合:無法事前知道資料量、資料量很大、需要配置各種大小的記憶體。
在每次分配記憶體時,明確定義負責清理記憶體的執行方。若可以的話,讓分配者同時負責釋放(例如 Function Split、Object-Based Error Handling 提到的那樣)。
Allocation Wrapper
適合:在程式多處配置 Heap,且要可以應對錯誤情況。
為配置和釋放記憶體的函式加包裝(Wrapper),在其中處理錯誤情況。要分配時只呼叫包裝。
Pointer Check
適合:在程式多處配置 Heap。
存取無效指標會導致無法預期的錯誤,要避免此情況。因此,讓未初始化及已釋放的指標明確地失效,方法是將其明確地賦值為 NULL
。
Memory Pool
適合:大小大致相同,避免記憶體碎片化。
特別保留一大段連續的記憶體空間,使用該區域內的空間,而非直接配置 Heap。這段空間可以配置在靜態記憶體中,也可以在啓動時分配 Heap。你需要自己實作分配和釋放的函式。這種做法可以稍微降低記憶體碎片化的問題,但它只能降低,無法完全避免。
函式的回傳資料
Return Value
最基本的,使用 return
回傳資料。
Out-Parameters
因為 C 只資源回傳單一型別,若要回傳多個資訊時,最常見的做法就是使用指標,透過參數回傳。但是 C 不像 C# 有 in
、out
裝飾字,你會需要在某個地方明確標示此參數是輸入還是輸出(例如透過命名或 Doxygen 註解)。另外使用指標時也有注意多執行緒的情況,可能需要加鎖。
Aggregate Instance
如果函式的輸出很複雜,使用 Out-Parameters 會導致函式的可讀性降低,那可以將回傳資料打包成一個獨特的型別(通常使用 struct
)。
Immutable Instance
傳遞大量的不可變資料內的資訊(它們可能位於靜態記憶體中),要確保使用者無法變更該資料,所以可以為回傳值加上 const
。
Caller-Owned Buffer
傳遞已知大小的複雜資料或大型資料,且資料在執行期可變。由呼叫方提供足夠的記憶體空間,被呼叫方將資料放入其中。
Callee Allocates
類似 Caller-Owned Buffer 的情況,但是呼叫方不知道大小。改為由被呼叫方配置記憶體空間,回傳時也要回傳大小。
生命週期與所有權
Stateless Software-Module
此程式不處理狀態。使用純函式(pure function),僅根據輸入的參數來回傳資料。
Software-Module with Global State
此程式需要有共用的狀態。在一個全域範圍內儲存狀態,函式根據狀態和輸入參數來運作。由於用了全域變數(不同的函式都可以輕易存取),要注意維護此變數,否則依賴此變數的函式可能會產生錯誤的結果。
Caller-Owned Instance
多個函式或執行緒想要共用一個狀態,呼叫方傳遞實體(instance)個函式,呼叫方可以決定生命週期。通常使用 struct
打包。
基本上可以把這裡的實體看作 OOP 中的物件,傳遞這些實體到不同的函式中處理,就像對一個物件呼叫不同的方法(method)處理。
Shared Instance
多個函式或執行緒想要共用一個狀態,但是如果實體已被建立的話,就不會再重複建立新的實體。
這個做法就類似參照計數(reference counting)的垃圾回收(GC),每次要用到時都增加一個計數值,每關閉一個計數也減一,等計數為 0 就代表沒有任何呼叫方在使用它了,可以清理記憶體。
有彈性的 API
雖然一般都說 SOLID 原則是 OOP,但是它闡述的概念其實在 C 也可以運用(更何況其實 C 已經有不少 OOP 的概念了)。
Header Files
對於熟悉 OOP(或 SOLID)的人一定都抽象化不陌生(無論是 Abstract 還是 Interface)。C 語言的使用者也一樣,雖然我們比較少用「抽象化」這個詞。
讓我們想想抽象是什麼?抽象就是無法實例化(instantiation)或則說沒有具體實作(implementations)。這不就是 C 的函式原型(function prototype)嗎?而我們最常「擺放」原型的地方就是標頭檔,所以將其看作 API 是非常直覺且常見的做法。
你應該把公開(public)的內容放在標頭檔中,而私有(private)內容或具體實作放在 .c
原始檔。
Handle
函式中共用狀態和資源,但不希望呼叫放存取所有的內容,即你想進行封裝(encapsulation)。
基本上和 Caller-Owned Instance 的概念類似,但是更強調封裝的概念。如果你有用過 STM32 HAL 的話,應該對於 xxx_TypeDef
這樣的名稱不陌生,就是類似的概念。
Dynamic Interface
呼叫方需要稍有差異的實作,但不想要重複的程式碼。透過函式指標傳遞具體實作。
Function Control
呼叫方需要稍有差異的實作,但不想要重複的程式碼,甚至連重複的邏輯和介面宣告也不想要。透過函式指標傳遞具體實作,並使用一個參數來選擇實行。
有彈性的迭代器介面
Index Access
提供一個帶有索引參數的函式來取得基礎資料結構中的元素。也就是簡單地加個 Wrapper。
Cursor Iterator
提供一個 next()
函式,每次呼叫後,都會改指向下一個元素。基本上類似在 Python 中自己實作 __next__()
。
Callback Iterator
使用函式指標,對可迭代資料套用此函式進行處理。這在 OOP 和 FP(functional programming)中很常見(例如 Rust 的 map()
、C# 的 ForEach()
),只是 C 沒有匿名函式(anonymous Function),所以用起來可能會稍微麻煩一點。
模組化程式的檔案組織
檔案與資料夾管理也是很重要的一環。
Include Guard
這個應該是基本中的基本。為了避免重複引入錯誤,你應該為所有的標頭檔加入 Include Guard,而要使用 #pragma once
還是傳統的 #ifndef
取決於你使用的編譯器和整個專案的環境。
Software-Module Directories
讓彼此相關的 .c
和 .h
檔放在同一個目錄下,且該資料夾的名稱要可以明示功能。
- module_a/
- module_a.c
- module_a.h
- module_b/
- module_b.c
- module_b.h
Global Include Directory
如果有些 .h
檔大量在各個程式中被使用,可以建立一個全域的引用路徑。
- include/
- module_a.h
- module_a/
- module_a.c
- module_b/
- module_b.c
- module_b.h
Self-Contained Component
若你的程式規模進一步擴大,你可以將相關的模組再集合成元件(component)。
- component_a/
- module_a1/
- module_a1.c
- module_a1.h
- module_a2/
- module_a2.c
- module_a2.h
- module_a1/
- module_b/
- module_b.c
- module_b.h
API Copy
你的程式已經變得超級大型,而且可能是由不同的團隊共同開發和使用,其中有些共用的 API 已經相當穩定,你可以複製它們。一般來說不太會使用這種方式,但這可能在某些特殊情況下會很有用。
- component_a/
- include_from_module_b/
- module_b.h
- module_a1/
- module_a1.c
- module_a1.h
- module_a2/
- module_a2.c
- module_a2.h
- include_from_module_b/
- module_b/
- module_b.c
- module_b.h
脫離 #ifdef 地獄
我們都不喜歡高層數的巢狀結構(以 Linux kernel 來說的話 3 層就是極限),而複雜的預處理器更加容易破壞的可讀性。
而且大量的 #ifdef
會增加程式本身的狀態,造成測試複雜。
Avoid Variants
想要處理在多個平台皆可用的程式。使用所有平台都有的標準化函式,也就是使用平台無關(platform Independent)的函式,若無就不考慮實作此功能。
Isolated Primitives
想要處理在多個平台皆可用的程式,但是沒有各平台通用的標準化函式。讓平台相關的程式獨立,其它部分保持平台無關。
Atomic Primitives
將基本單元原子化,每個函式只處理一種變體,進一步避免複雜的巢狀 #ifdef
。
Abstraction Layer
將平台的專屬程式放在同一個實作裡。基本上就是前面的方法,但是再透過 .h
檔建立統一的 Interface,將其上升到檔案層級。
Split Variant Implementations
將各個平台專屬程式分別在不同的 .c
中實作,但是有共同的 .h
檔作為統一的 Interface。
小結
這本書在最後還有提供 2 個滿完整的示範案例,以實際的程式來分享如何選擇並套用前面介紹的各種模式。
整體來說,我覺得這本書很適合 C 的新手。對於有經驗的人,也可以看看不同的寫法之間的詳細限制。
其中我最有印象的是 Lazy Cleanup,我從來沒想過這種記憶體管理方式也是一種方式(不過我應該用不太到它)。
書籍資訊
- 書名:流暢的C|設計原則、實踐和模式
- 原書:Fluent C: Principles, Practices, and Patterns
- 作者: Christopher Preschern
- 譯者: 陳仁和
- 版次:2023 年 07 月初版
- ISBN:978-626-324-579-2
延伸讀物
- 《無暇的程式碼》(Clean Code)Robert C. Martin。適合所有程式設計師的讀物,極度推薦。
- 《Test-Driven Development for Embedded C》James W. Grenning
- 《Expert C Programming》Peter van der Linden
- 《Patterns in C》Adam Tornhill
- 《SOLID Design for Embedded C》James Grenning
留言可能不會立即顯示。若過了幾天仍未出現,請 Email 聯繫:)