[C 的那些眉角]參數傳遞的眉角 — `const` 用對了嗎?

const 這個關鍵字,我用了很久都只會這樣寫:

const int MAX_SIZE = 256;

然後在函式參數上偶爾加一下,
覺得「這樣比較專業」,但其實不太確定為什麼要加。

直到有一次,同事在 code review 留言:

「這個參數應該加 const,你這樣寫呼叫端不知道你會不會改它。」

我才開始認真研究 const 在參數傳遞上到底是什麼意思。

結果發現,const 跟指標放在一起,
光是位置不同,意思就完全不一樣。


先從基本的說起

傳值(Pass by Value)

void foo(int x) {
    x = 100;  // 只改了 local copy,呼叫端的變數不受影響
}

int a = 5;
foo(a);
// a 還是 5

傳值的時候,函式拿到的是一份拷貝。
改了不影響呼叫端,所以這種情況加不加 const 對呼叫端沒差:

void foo(const int x) {
    x = 100;  // 編譯錯誤,但呼叫端本來就不受影響
}

傳值加 const,只是告訴編譯器「這個 local copy 我不會改」,
對呼叫端沒有任何保證的意義。

實務上,傳值的參數我通常不加 const,沒什麼必要。


傳指標(Pass by Pointer)

這裡才是 const 真正有意義的地方。

傳指標的時候,函式可以透過指標修改呼叫端的資料,
const 可以限制這件事。

但問題是,const 跟指標放在一起有四種寫法,
每種意思都不一樣。


const 與指標的四種組合

我用一個例子來說明,假設有個 uint8_t *p

組合一:const uint8_t *p(指向常數的指標)

void print_data(const uint8_t *p, size_t len) {
    for (size_t i = 0; i < len; i++) {
        printf("%02X ", p[i]);  // 可以讀
    }
    p[0] = 0xFF;  // ❌ 編譯錯誤,不能透過 p 修改資料
}

const* 左邊,代表指標指向的內容是唯讀的

函式承諾:「我只會讀這塊資料,不會改它。」

這是最常用的寫法,用在「只需要讀取資料」的參數上。


組合二:uint8_t * const p(常數指標)

void foo(uint8_t * const p, size_t len) {
    p[0] = 0xFF;   // ✅ 可以修改指標指向的內容
    p = other_buf; // ❌ 編譯錯誤,不能改變指標本身
}

const* 右邊,代表指標本身不能改變
但指向的內容可以改。

這個在函式參數上比較少用,
因為指標是傳值進來的,就算改了也不影響呼叫端。


組合三:const uint8_t * const p(常數指標指向常數)

void foo(const uint8_t * const p, size_t len) {
    p[0] = 0xFF;   // ❌ 不能改內容
    p = other_buf; // ❌ 不能改指標
}

兩個都不能改。

實務上比較少見,但如果你想表達「這個參數完全唯讀」,
這是最嚴格的寫法。


組合四:uint8_t *p(什麼都沒有)

void foo(uint8_t *p, size_t len) {
    p[0] = 0xFF;   // ✅ 可以改內容
    p = other_buf; // ✅ 可以改指標(但不影響呼叫端)
}

沒有任何限制,函式可以任意修改指向的內容。


用一張表整理

寫法 能改指標本身 能改指向的內容 常用場景
const uint8_t *p 唯讀參數(最常用)
uint8_t * const p 少用
const uint8_t * const p 完全唯讀
uint8_t *p 需要修改內容的參數

實際應用:什麼時候該加?

只讀資料,一定要加

// ❌ 沒加 const,呼叫端不知道你會不會改 buf
int uart_send(uint8_t *buf, size_t len);

// ✅ 加了 const,明確承諾不修改 buf
int uart_send(const uint8_t *buf, size_t len);

這個承諾很重要。

呼叫端可能傳進來一個 const 的資料:

const uint8_t greeting[] = "Hello";
uart_send(greeting, sizeof(greeting));

如果 uart_send 的參數沒有 const
這行會產生編譯警告(甚至錯誤),
因為你把 const 的東西傳給一個「可能會改它」的函式。


需要修改內容,不要加

// 這個函式要把資料寫進 buf,所以不能加 const
int uart_receive(uint8_t *buf, size_t buf_size, uint32_t timeout_ms);

這裡不加 const 是正確的,
代表「我會把資料寫進這個 buffer」。


字串參數幾乎都應該加

// ❌
void log_message(char *msg);

// ✅
void log_message(const char *msg);

Log 函式只需要讀字串,不需要改它,
const 讓呼叫端可以直接傳字串常數進來:

log_message("System initialized");  // 字串常數,需要 const 才不會警告

一個常見的誤解

很多人以為加了 const 就代表「這個變數不會被改」,
但其實 const 可以被 cast 掉:

void evil_function(const uint8_t *p) {
    uint8_t *writable = (uint8_t *)p;  // cast 掉 const
    writable[0] = 0xFF;                // 改了!
}

這樣做是 undefined behavior,
但編譯器不一定會報錯。

所以 const 是一個介面上的承諾
不是硬體層面的保護。

它的主要作用是:

  • 讓編譯器幫你檢查有沒有意外修改
  • 讓呼叫端知道這個函式的意圖
  • const 的資料可以安全地傳進去

嵌入式的特別情況:Flash 裡的常數

在嵌入式,const 還有一個實際的用途:
告訴編譯器這個資料可以放在 Flash(ROM)裡,不需要佔用 RAM。

// 這個陣列會放在 Flash,不佔 RAM
const uint8_t font_table[] = {
    0x3E, 0x51, 0x49, 0x45, 0x3E,  // '0'
    0x00, 0x42, 0x7F, 0x40, 0x00,  // '1'
    // ...
};

在 RAM 很珍貴的 MCU 上(幾 KB 的 SRAM),
這個差別非常重要。

如果沒有加 const,編譯器可能會把這個陣列複製到 RAM,
白白浪費記憶體。


說實話

const 這件事,我覺得最難的不是記住四種組合,
而是養成習慣,每次寫函式參數的時候都問自己:

「這個指標,我需要修改它指向的內容嗎?」

不需要修改 → 加 const uint8_t *
需要修改 → 不加

就這樣,其他兩種組合在函式參數上很少用到,
先把這個基本的搞清楚就夠了。

我現在的習慣是,寫完函式之後,
回頭看每個指標參數,問自己有沒有漏加 const

漏加不會讓程式跑不起來,
但會讓介面變得模糊,呼叫端不知道你的意圖。


這篇的 Checklist

  • [ ] 只讀取、不修改的指標參數有加 const
  • [ ] 字串參數(char *)有加 const
  • [ ] 需要修改內容的指標參數沒有誤加 const
  • [ ] 嵌入式的大型常數陣列有加 const(放 Flash 省 RAM)
  • [ ] 沒有用 cast 把 const 偷偷去掉
  • [ ] 函式介面的 const 使用一致,不是隨機加的
分類: 程式相關,標籤: , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *