---
title: '《流暢的C》——適合C語言的各種 Pattern'
description: '這本書的內容其實算基本的但很實用。它整理了一系列可以用在不同情況下的模式（pattern），並且詳細地描述了它們各自的優缺點、限制和要注意的事情。如果有時候不確定某個功能要用什麼方式寫的話，可以依據書中的分析來選擇最適合的寫法。'
canonical: 'https://blog.ziteh.dev/posts/book-9786263245792'
created: 2024-06-03T13:11:00.000Z
tags: ['閱讀', '心得', '程式', 'C/C++']
author: 'ZiTe'
rights: 'CC BY-NC-SA'
---

![][cover]

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

<!--more-->

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

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

> 本文僅會大致整理一下重點，若想要知道詳細的內容的話，推薦還是買本原書來看。
>
> 底下的範例只是概念參考，並沒有現實意義。

## 錯誤處理

### Function Split

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

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

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

```c
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` 返回。

```c
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 環境的不同行為。

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

### Goto Error Handling

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

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

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

```c
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 運算子 `||` 也有相同的特性。

```c
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`。

```c
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

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

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

```c
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`。

```c
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# 有 `in`、`out` 裝飾字，你會需要在某個地方明確標示此參數是輸入還是輸出（例如透過命名或 Doxygen 註解）。另外使用指標時也有注意多執行緒的情況，可能需要加鎖。

### Aggregate Instance

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

### Immutable Instance

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

### Caller-Owned Buffer

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

```c
void do_something(buffer_t* buf) {
    // 運用 buf
}
```

### Callee Allocates

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

```c
void do_something(buffer_t* buf, int* size_out) {
    *size_out = SIZE;
    *buf = malloc(SIZE * sizeof(buffer_t));
}
```

## 生命週期與所有權

### Stateless Software-Module

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

```c
int do_something(int a, int b) {
    return a + b;
}
```

### Software-Module with Global State

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

```c
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）處理。

```c
typedef struct {
    int a;
    int b;
} instance_t;

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

### Shared Instance

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

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

```c
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

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

```c
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

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

```c
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。

```c
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），所以用起來可能會稍微麻煩一點。

```c
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

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

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

void do_something() {
    platform_variants();
}
```

### Atomic Primitives

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

```c
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。

```c
// feature.h
void feature();
```

```c
// feature_win32.c
#ifdef _WIN32

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

#endif
```

```c
// 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

[cover]: https://bucket.ziteh.dev/blog/book-9786263245792/8739cdd3.webp
