Fluent C

這本書的內容其實算基本的但很實用。它整理了一系列可以用在不同情況下的模式(pattern),並且詳細地描述了它們各自的優缺點、限制和要注意的事情。如果有時候不確定某個功能要用什麼方式寫的話,可以依據書中的分析來選擇最適合的寫法。

如果你已經寫 C 有一段時間的話,書中的寫法你應該多數都知道或用過,不過書中把它們更有系統地做了歸納,也有範例程式,且列出某個模式在哪些專案中有被使用過。

總之,如果是 C 的新手的話,滿推薦看這本書的,你可以非常快速且明確的知道某個情況下,可以確實解決問題的好寫法是什麼。

本文僅會大致整理一下重點,若想要知道詳細的內容的話,推薦還是買本原書來看。

底下的範例只是概念參考,並沒有現實意義。

錯誤處理

Function Split

簡單說就是關注點分離(SoC)和單一職責(SRP)。找出一個大型函式中可以獨立的職責,將其分離成獨立的部分。

書中提出其中一種是否分離的參考依據——如果在此函式的多處清理同個資源,就最好至少把它分為配置+清理資源的部分以及使用資源的部分,讓配置和清理資源的程式寫在一起,而使用該資源的程式也只專注在資源的使用上(這部分的程式可以有多個 return 也不必擔心資源的釋放)。

我認為這也和控制反轉(IoC)是相近的概念,即物件(資源)的創建和使用應該分離。負責創建資源的就專心管理資源本身,需要使用資源的就專注在程式邏輯上。

1
2
3
4
5
6
7
8
9
10
11
void do_something() {
int* data = malloc(SIZE *sizeof(int)); // 配置資源
if (data) {
parse(data); // 無論 parse() 內如何 return,最後都會清理資源
}
free(data); // 清理資源
}

void parse(int* data) {
// 使用資源的程式
}

Guard Clause

講 Early return 可能比較多人知道。就是當此函式有前置條件時,不要用巢狀 if-else 來處理,這樣很容易造成高層數的縮排,而且也會讓前置條件和關鍵邏輯的判斷混在一起,可讀性很差。

前置條件應該在開頭就獨立判斷,若不符就儘早 return 返回。

1
2
3
4
5
6
7
float cal_bmi(float mass, float height) {
if (mass <= 0 || height <= 0) { // 前置條件
return -1.0; // 條件不符,結束並回傳 Error code
}

return mass / (height * height); // 關鍵邏輯
}

Samurai Principle

簡單講就是速錯(Fail fast)。當錯誤發生時,直接讓整個程式當掉(如果這個程式不是高可用性或沒有很重要、關鍵的系統可以用)。可以使用 assert 達成,優點是當錯誤真的發生時,它會非常明顯以至於你難以有意或無意的忽視它(也就是錯誤不會被隱藏)。當然,runtime 容許的錯誤不適合這個方法。如果這是 Embedded 的話,要非常仔細地考量,通常可能不適合。

使用 assert 可以更方便地切換 debug 和 release 環境的不同行為。

1
assert(prt != NULL);  // prt 不該是 NULL

Goto Error Handling

有些時候你需要獲得和清理多個資源,你需要在函式結束前一一釋放所有已經取得的資源。

或許很多人和我剛開始學的一樣——不要用 goto。但它可能沒有像一些教科書寫的那麼糟糕,至少在 C 是這樣,因為 C 的 goto 只能在同個函式範圍內跳轉,不能跨函式。實際上像是 CPython 和 Linux kernel 等許多 C 程式裡都有用到 goto 來釋放資源,這甚至很常見。《21世紀C語言》中也有相同的觀點。

但是使用 goto 還是要小心並且想清楚,且避免多次跳躍,注意專案的規範裡允不允許使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void do_something() {
if (!alloc_a()) {
goto clean_a;
}
if (!alloc_b()) {
goto clean_b;
}

// 主邏輯

clean_b:
free_b();
clean_a:
free_a();
}

Cleanup Record

如果你還是不想或不行用 goto 但是想要處理多資源的獲取與清理,可以使用延遲求值(Lazy evaluation)特性。

因為 AND 邏輯是只要有一個 false ,其結果就一定是 false。因為 C 有短路求值(Short-circuit evaluation)的特性,使用 && 串聯的各個值,如果第一個值就是 false 的話,就不會再繼續評估後續的值了,因為結果一定是 false。 OR 運算子 || 也有相同的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void do_something() {
int res_a = 0;
int res_b = 0;

if ((res_a = alloc_a()) && (res_b = alloc_b())) {
// 主邏輯
}

if (res_a) { // A 資源有成功獲取的話要清理
free_a();
}
if (res_b) { // B 資源有成功獲取的話要清理
free_b();
}
}

不過我個人比較少看到這種寫法,在 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef enum {
ok = 0, // 只有 0 會解釋成 false,其它數值都視為 true
error_a,
error_b,
error_c,
} error_code;

error_code do_something() {
if (error_a_occurs) {
return error_a;
}

if (error_b_occurs) {
return error_b;
}

return ok;
}

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

適合:固定大小的資料、資料量大、生命週期需要跨函式。

使用靜態記憶體,無論是全域還是區域,靜態記憶體在編譯器就可以確認並分配好。但是多執行緒的情況要考慮同步機制。

1
2
3
4
5
6
int global[SIZE];          // 全域可調用的全域變數
static s_global[SIZE]; // 僅此檔案*內可調用的全域變數

void foo() {
static int local[SIZE]; // 僅此函式內可以調用的區域變數
}

一般我們說 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

1
2
3
4
5
6
7
8
9
10
11
void foo() {
int* ptr = NULL; // 分配前,使其明確地失效
ptr = malloc(SIZE * sizeof(int));

if (ptr != NULL) { // 檢查
// 主邏輯
}

free(ptr);
ptr = NULL; // 釋放後,使其明確地失效
}

Memory Pool

適合:大小大致相同,避免記憶體碎片化。

特別保留一大段連續的記憶體空間,使用該區域內的空間,而非直接配置 Heap。這段空間可以配置在靜態記憶體中,也可以在啓動時分配 Heap。你需要自己實作分配和釋放的函式。這種做法可以稍微降低記憶體碎片化的問題,但它只能降低,無法完全避免。

函式的回傳資料

Return Value

最基本的,使用 return 回傳資料。

Out-Parameters

因為 C 只資源回傳單一型別,若要回傳多個資訊時,最常見的做法就是使用指標,透過參數回傳。但是 C 不像 C# 有 inout 裝飾字,你會需要在某個地方明確標示此參數是輸入還是輸出(例如透過命名或 Doxygen 註解)。另外使用指標時也有注意多執行緒的情況,可能需要加鎖。

Aggregate Instance

如果函式的輸出很複雜,使用 Out-Parameters 會導致函式的可讀性降低,那可以將回傳資料打包成一個獨特的型別(通常使用 struct)。

Immutable Instance

傳遞大量的不可變資料內的資訊(它們可能位於靜態記憶體中),要確保使用者無法變更該資料,所以可以為回傳值加上 const

Caller-Owned Buffer

傳遞已知大小的複雜資料或大型資料,且資料在執行期可變。由呼叫方提供足夠的記憶體空間,被呼叫方將資料放入其中。

1
2
3
void do_something(buffer_t* buf) {
// 運用 buf
}

Callee Allocates

類似 Caller-Owned Buffer 的情況,但是呼叫方不知道大小。改為由被呼叫方配置記憶體空間,回傳時也要回傳大小。

1
2
3
4
void do_something(buffer_t* buf, int* size_out) {
*size_out = SIZE;
*buf = malloc(SIZE * sizeof(buffer_t));
}

生命週期與所有權

Stateless Software-Module

此程式不處理狀態。使用純函式(pure function),僅根據輸入的參數來回傳資料。

1
2
3
int do_something(int a, int b) {
return a + b;
}

Software-Module with Global State

此程式需要有共用的狀態。在一個全域範圍內儲存狀態,函式根據狀態和輸入參數來運作。由於用了全域變數(不同的函式都可以輕易存取),要注意維護此變數,否則依賴此變數的函式可能會產生錯誤的結果。

1
2
3
4
5
6
7
8
9
10
static int mode = 1;
int do_something(int value) {
if (mode == 0) {
return value;
} else if (mode == 1) {
return value * -1;
} else {
return mode;
}
}

Caller-Owned Instance

多個函式或執行緒想要共用一個狀態,呼叫方傳遞實體(instance)個函式,呼叫方可以決定生命週期。通常使用 struct 打包。

基本上可以把這裡的實體看作 OOP 中的物件,傳遞這些實體到不同的函式中處理,就像對一個物件呼叫不同的方法(method)處理。

1
2
3
4
5
6
7
8
typedef struct {
int a;
int b;
} instance_t;

void do_something(instance_t* inst) {
// 主邏輯
}

Shared Instance

多個函式或執行緒想要共用一個狀態,但是如果實體已被建立的話,就不會再重複建立新的實體。

這個做法就類似參照計數(reference counting)的垃圾回收(GC),每次要用到時都增加一個計數值,每關閉一個計數也減一,等計數為 0 就代表沒有任何呼叫方在使用它了,可以清理記憶體。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
typedef struct {
int a;
int b;
} instance_t;

typedef struct {
instance_t* inst;
int count;
} inst_list_t;

const int inst_id_a = 0;
const int inst_id_b = 1;
const int inst_id_c = 2;

static inst_list_t list[MAX];

instance_t* open(int id) {
if (list[id].count == 0) {
// 第一次使用要分配
list[id].inst = malloc(sizeof(instance_t));
}
list[id].count++;
return list[id].inst;
}

void close(int id) {
list[id].count--;
if (list[id].count == 0) {
// 沒有呼叫者了,要清理
free(list[id].inst);
}
}

void do_something(instance_t* inst) {
// 主邏輯
}

有彈性的 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

呼叫方需要稍有差異的實作,但不想要重複的程式碼。透過函式指標傳遞具體實作。

1
2
3
4
5
6
7
8
9
typedef int (*cal_fn)(int a, int b);

int add_fn(int a, int b) { return a + b; }
int sub_fn(int a, int b) { return a - b; }

int do_something(cal_fn fn, int a, int b) {
int res = fn(a, b);
return res;
}

Function Control

呼叫方需要稍有差異的實作,但不想要重複的程式碼,甚至連重複的邏輯和介面宣告也不想要。透過函式指標傳遞具體實作,並使用一個參數來選擇實行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int add_fn(int a, int b) { return a + b; }
int sub_fn(int a, int b) { return a - b; }

typedef enum {
add,
sub,
} fn_t;

int do_something(fn_t fn_type, int a, int b) {
int res;
switch (fn_type) {
case add:
res = add_fn(a, b);
break;
case sub:
res = sub_fn(a, b);
break;
}
return res;
}

有彈性的迭代器介面

Index Access

提供一個帶有索引參數的函式來取得基礎資料結構中的元素。也就是簡單地加個 Wrapper。

1
2
3
int get(int index) {
return data[index].id;
}

Cursor Iterator

提供一個 next() 函式,每次呼叫後,都會改指向下一個元素。基本上類似在 Python 中自己實作 __next__()

Callback Iterator

使用函式指標,對可迭代資料套用此函式進行處理。這在 OOP 和 FP(functional programming)中很常見(例如 Rust 的 map()、C# 的 ForEach()),只是 C 沒有匿名函式(anonymous Function),所以用起來可能會稍微麻煩一點。

1
2
typedef void(callback_fn)(void* element, void* arg);
void iterate(callback_fn func, void* arg);

模組化程式的檔案組織

檔案與資料夾管理也是很重要的一環。

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_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
  • module_b/
    • module_b.c
    • module_b.h

脫離 #ifdef 地獄

我們都不喜歡高層數的巢狀結構(以 Linux kernel 來說的話 3 層就是極限),而複雜的預處理器更加容易破壞的可讀性。

而且大量的 #ifdef 會增加程式本身的狀態,造成測試複雜。

Avoid Variants

想要處理在多個平台皆可用的程式。使用所有平台都有的標準化函式,也就是使用平台無關(platform Independent)的函式,若無就不考慮實作此功能。

Isolated Primitives

想要處理在多個平台皆可用的程式,但是沒有各平台通用的標準化函式。讓平台相關的程式獨立,其它部分保持平台無關。

1
2
3
4
5
6
7
8
9
10
11
void platform_variants() {
#if defined(_WIN32)
// WIN32 的專屬函式
#elif defined(__unix__)
// Unix 的專屬函式
#endif
}

void do_something() {
platform_variants();
}

Atomic Primitives

將基本單元原子化,每個函式只處理一種變體,進一步避免複雜的巢狀 #ifdef

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void feature_a() {
#if defined(_WIN32)
// WIN32 的專屬函式
#elif defined(__unix__)
// Unix 的專屬函式
#endif
}

void feature_b() {
#if defined(_WIN32)
// WIN32 的專屬函式
#elif defined(__unix__)
// Unix 的專屬函式
#endif
}

void do_something() {
#if defined(FEAT_A)
feature_a();
#elif defined(FEAT_B)
feature_b();
#endif
}

Abstraction Layer

將平台的專屬程式放在同一個實作裡。基本上就是前面的方法,但是再透過 .h 檔建立統一的 Interface,將其上升到檔案層級。

Split Variant Implementations

將各個平台專屬程式分別在不同的 .c 中實作,但是有共同的 .h 檔作為統一的 Interface。

1
2
// feature.h
void feature();
1
2
3
4
5
6
7
// feature_win32.c
#ifdef _WIN32

#include "feature.h"
void feature() { /* WIN32 的專屬實作 */ }

#endif
1
2
3
4
5
6
7
// feature_unix.c
#ifdef __unix__

#include "feature.h"
void feature() { /* Unix 的專屬實作 */ }

#endif

小結

這本書在最後還有提供 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 聯繫:)