一個 8 位元的 \0,決定了 C 字串在哪裡結束——這個設計已經跟著 C 語言超過五十年。《C Strings: A 50-Year Mistake》的作者主張,這未必是最好的答案,很可能只是 1970 年代記憶體吃緊時的權宜之計。
C Strings:一個 50 年的錯誤
從 C 誕生那天起,字串就只是一串 char,靠最後一個 \0 位元組標記結尾——沒有長度欄位,沒有邊界紀錄,一切靠一個哨兵位元組撐著。這個設計撐了五十年,大多數寫 C 的人早把它當成物理定律,而不是一個可以被檢討的選擇。longtran2904 這篇長文把它當成一個可以檢討的選擇,一條一條把代價列出來,再回頭看看業界其實已經有人用不同的方式繳了這筆學費。
作者的論證分成六個部分——遺失的長度資訊、snprintf 的語意矛盾、重複改寫終止符的白工、兩種無效字串、二進位不相容、以及零拷貝子字串——最後才回頭承認 null-terminated 字串保留的兩個優點。這篇文章接下來,照著同樣的順序走一遍。這六個代價沒有哪一個是孤立的——遺失長度資訊是根,後面五個大多是這個根長出來的枝節。讀者不需要照單全收作者的立場,但至少該弄清楚,每一條代價具體指的是什麼。
遺失的那個整數
作者的第一個論點很直接:字串的長度是一項非常有價值的資訊,不儲存它,就等於逼所有用到這個字串的程式碼,要嘛重複呼叫 strlen,要嘛自己一個位元組一個位元組地數過去。這句話翻成白話:C 的慣例把「這個字串有多長」這個問題,從一次性的紀錄變成一個每次都要重新算的謎題。
這件事在迴圈裡特別容易被忽視。任何一段程式碼只要多次呼叫同一個字串的 strlen,就是在為同一個答案重複付錢——第一次呼叫花的時間,跟第一百次呼叫花的時間完全一樣,因為終止符本身不會被記住,也不會被快取。作者的論點不是說 strlen 很慢,而是說不必要地重複計算這件事,本來可以靠一個欄位就完全避免。
這個謎題還有一個更陰險的版本。作者舉 snprintf 當例子:同一個函式裡,輸入的那個整數(緩衝區容量)把結尾的 \0 算進去,回傳的那個整數(實際寫入的字元數)卻不含 \0。當你把程式碼從編譯期就固定的字面量字串,改寫成執行期才決定長度的緩衝區,這個不對稱就會咬人——你以為兩個整數說的是同一件事,其實不是。下面這個滑桿把兩種設計的差距具體畫出來:把字串長度從幾十 bytes 拉到幾千 bytes,null-terminated 版本的 strlen 掃描成本跟著線性增加,length-based 版本永遠只是讀一個固定大小的欄位。
這種不對稱之所以危險,是因為兩個整數在直覺上看起來應該是同一件事——都是「這個字串有多長」。一旦程式碼把其中一個當成另一個來用,例如拿 snprintf 的回傳值去配置下一段緩衝區的大小,卻忘了幫終止符留位置,就會在邊界上差一個位元組。這正是「長度靠一個位元組的慣例來表達」這件事,最容易在使用者沒注意的地方咬人的方式。
拖動滑桿看字串長度如何撐大兩種設計的成本差距 · 8 到 8192 bytes
// snprintf 的輸入輸出語意不對稱(作者原文):
// "the input integer includes the null terminator,
// while the output integer excludes it."
int cap = sizeof(buf); // cap 內含終止符的位置
int n = snprintf(buf, cap, "%s", s); // n 不含終止符
每次操作都要重寫一次的位元組
作者接著丟出一個反問:如果字串的長度要靠結尾的 \0 才能確定,那是不是代表每次替字串加內容,都要把上一個 \0 蓋掉、在新的結尾重新寫一個?換句話說,中間那些曾經存在過的終止位元組,是不是都是白工?他沒有在這段把答案寫死,而是留給讀者自己判斷——但這個提問本身,已經點出 null-terminated 設計裡一個容易被忽略的細節:長度不是一份紀錄,而是一個必須在每次寫入後重新推導的副產物。下面這段動畫把逐字附加的過程放慢,每按一次播放,終止位元組就往後跳一格。
按播放看逐字附加時終止位元組怎麼被反覆改寫 · 5 個字元
互動圖表
每次 append 一個字元,null-terminated 版本都要把結尾的 \0 往後移一格,作者以反問點出這其實是重複發生的動作。
作者沒有把這個問題定罪成明確的效能災難,只是提醒讀者:如果長度從來不是一份紀錄,而是每次都要用一次性的推導去重建,那麼「這個字串現在有多長」這個問題,其實從來沒有真正被回答過,只是被反覆重新問一次。
兩種「無效」的字串
第四個論點是關於「無效」這個概念本身。作者指出,因為字串的長度可以用兩種方式表達,null-terminated 設計連帶製造出兩種不同的無效字串:一個空字串,一個 null 字串。這兩者在多數情況下該被當成同一件事處理,但它們的位元表示完全不同——NULL 指標是「這個變數根本沒有指向任何記憶體」,空字串是「這個變數指向一塊記憶體,第一個位元組就是 \0」。呼叫端如果只寫 if (!p),會漏掉空字串那個分支;只判斷 p[0] == '\0',遇到 NULL 指標又會直接讀到不合法的記憶體。下面這組卡片把兩種狀態並排放著,它們看起來都「無效」,卻要用不同的檢查才能抓到。
狀態一 · NULL 指標
char* p = NULL;
if (!p) 抓得到——但這只是「指標本身不存在」,不是「字串內容是空的」。
狀態二 · 空字串
char* p = "";
if (!p) 抓不到,得改成 if (p[0] == '\0')——同樣是「無效」,卻要換一種寫法檢查。
這個分裂在寫防禦性程式碼時特別麻煩:函式簽章上只寫著 char*,呼叫端沒辦法從型別本身知道,這個參數是「保證非空的合法字串」,還是「可能是 NULL、也可能是空字串」的鬆散約定。兩種無效狀態各自需要一次判斷,程式碼裡也就多了一個容易漏掉的分支。
二進位資料不相容
第五個代價跟二進位資料有關。既然 null-terminated 字串靠 \0 判斷結尾,任何內含 \0 位元組的資料,用這種字串存,第一個 \0 出現的位置就會被誤認成結尾——後面的內容全部消失在 strlen 或任何依賴終止符的函式眼裡。作者的解法很直接:只要所有程式碼都改成依賴一個外顯的長度欄位,而不是仰賴終止符,這個型別就能安全存放任意的二進位資料,包括內含 \0 的內容。這一條在文字處理、網路封包解析、或任何需要把一段 bytes 當成字串傳遞的場景特別關鍵——你不會希望一份合法的資料,只因為裡面剛好有一個 \0,就被靜靜地截斷。
這一點跟前面幾個代價其實是同一枚硬幣的兩面:只要長度本身是外顯的欄位,而不是靠掃描才能推得的副產物,二進位安全就是附帶的結果,不需要另外設計一套機制。作者把這件事放在後段,但它其實是整篇論點裡,最直接指向「終止符是設計選擇,不是必然」的一條。
零拷貝子字串——作者眼中最大的好處
作者把這一條留到最後,稱它是「最重要的優點」,甚至用了「目前為止最大的好處」這樣的說法:因為 null-terminated 字串要求每個字串都必須以 \0 收尾,trim、slice、split、tokenize、search 這些最常見的字串操作,全部被迫配置新的字串來存放結果。原因很簡單——如果你想從一段字串裡截出中間一段,又要保留 null-terminated 的慣例,你就必須複製那一段內容,再自己補一個新的 \0,原本那塊記憶體沒辦法直接拿來用,因為它的結尾不在你要的位置上。
換成指標+長度的設計,子字串就只是同一塊記憶體上的一個窗口:指標往前移、長度縮小,就結束了,一次配置都不需要。作者提出的型別長這樣:
struct String
{
u8* data;
u64 size;
};
#define Str(s) (String){ (u8*)(s), sizeof(s) - 1 }
#define SArg(s) (int)((s).size), ((s).data)
data 指向原始緩衝區裡的某個位置,size 記錄這個窗口的長度——兩者都不擁有記憶體本身,只是對同一塊資料的一種觀察角度。下面這個 widget 把三種常見操作都跑一次:length-based 那一側配置次數永遠是 0,null-terminated 那一側每點一次就多配置一塊。
點選操作,看指標+長度只移動窗口,null-terminated 版本卻要重新配置一塊記憶體 · 3 種操作
這也解釋了為什麼作者把這一條放在最後、用最重的語氣講:前面五個代價,多半可以靠額外的程式碼繞過去;只有零拷貝子字串這件事,一旦決定要保留 null-terminated 的慣例,就沒有繞路可走——你要嘛複製,要嘛放棄終止符。
三種替代設計的取捨
如果拿掉終止符是對的方向,為什麼業界沒有全面轉向?作者用三種真實存在的替代設計,說明「拿掉終止符」背後其實有不只一種做法,各自付出不同的代價。
Redis 是最直接的案例。文章嵌入一則推文,說明 antirez 為 Redis 寫了一套叫 SDS(Simple Dynamic Strings)的自訂字串函式庫,後來獨立成一個函式庫——理由是「C strings had three problems Redis could not accept」,其中被明確點名的第一個問題是「strlen is O(n) because it scans until the null terminator」。換句話說,Redis 選擇自己維護一份長度欄位,理由跟這篇文章的論點完全一致:不想每次都花線性時間掃描字串。
推文本身沒有把三個問題都列完,至少在讀到的版本裡,只完整呈現了第一個——但光是這一個理由,已經足以說明為什麼 Redis 不打算等 C 標準委員會來解決這件事,而是直接動手寫一套自己的字串型別。
stretchy buffer 是另一種做法:把長度跟容量放在真正資料之前的一塊 header 裡,指標本身仍然指向資料的第一個位元組,只是往前偏移幾個位元組就能讀到 header。作者對這個設計有所保留,指出它會讓每個 char* 都背上一種幾乎看不見的隱藏語意,而且把容量欄位放進字串型別裡,即使對動態陣列來說合理,對字串來說仍然是一個不好的設計選擇。
Pascal 式的定長字串又是另一個極端:把字串放進一塊固定大小、通常 256 bytes 的字元陣列,長度可以用保留一個位元組來記,也可以照樣用終止符。作者認為這種做法在記憶體與硬體都吃緊的年代確實合理,但放到今天,除非是很特定的場合,否則沒有太大意義。下面這組卡片把五種設計放在一起,比較它們在標頭欄位跟能處理的字串大小上各自做了什麼取捨。
點任一張卡看它的欄位設計跟它換來什麼代價 · 5 種設計
五種字串設計的欄位取捨
C 標準字串,用結尾的 \0 判斷長度;沒有長度欄位,讀取前得整段掃過一次。
作者提出的 struct String { u8* data; u64 size; }:長度是外顯欄位,trim / slice 只移動窗口,不必配置新記憶體,也能安全存放二進位資料。
Redis 自己寫的 Simple Dynamic Strings,理由是推文裡點名的第一個問題:strlen 對 C 字串是 O(n),因為要掃到終止符才知道長度;SDS 自己存一份長度,避開這個代價。
長度與容量放在資料前面的隱藏 header,指標仍指向資料本身。作者認為這讓每個 char* 都背著一種幾乎看不見的隱藏語意,容量欄位對字串來說也不是好設計。
把字串塞進固定大小、通常 256 bytes 的字元陣列,長度用保留位元組或終止符表示。作者認為這在記憶體吃緊的年代合理,現在除非特定場合,意義不大。
三種設計攤開來看,其實是同一個問題的三種答案:把長度資訊放在哪裡、用什麼代價換取什麼保證。Redis 選擇完全外顯,另外做出一套獨立的字串型別;stretchy buffer 選擇半外顯,把 header 藏在指標前面;Pascal 式定長選擇犧牲彈性,換取固定大小帶來的簡單記憶體配置。沒有一種是免費的。
null-terminated 字串仍然保留的兩個優點
把六個代價列完,作者也承認 null-terminated 字串不是一無是處,還留著兩個站得住腳的優點。
第一個是 sentinel 帶來的前瞻掃描:因為結尾有一個保證存在的哨兵位元組,null-terminated 字串可以在迴圈裡的任何時候安全檢查目前的位元組,甚至可以做前瞻——一次比對好幾個位元組組成的序列,而且因為順序有保障,一旦其中一個位元組不符合,就能提早跳出,不用擔心讀到緩衝區外面。length-based 字串要做同樣的事,得先確認索引還在長度範圍內,才能安全地往後多看一格,多了一次比較。下面這個 widget 把兩種掃描並排:找一個兩個位元組的序列,null-terminated 版本每一步只需要一次比對,length-based 版本每一步要先做一次邊界檢查,才能做內容比對。
按下一步比較兩種掃描要多做幾次比較 · 找兩個位元組的序列「cc」
這兩個優點合起來說明了一件事:作者不是在主張 null-terminated 字串一無是處,而是在主張,多數場合下,長度資訊的價值,蓋過了 sentinel 帶來的那點前瞻掃描效率,也蓋過了跟既有 API 之間那道相容性門檻。真正的問題從來不是「要不要有終止符」,而是「終止符該不該是唯一的長度來源」。
第二個優點是相容性:因為 C 用的就是 null-terminated 字串,市面上大多數的函式庫跟作業系統 API 都要求這個格式。作者承認這是事實,但認為在實務上通常沒那麼要命,尤其在 Windows 上,你反正已經得把 UTF-8 字串轉成 UTF-16 才能呼叫系統 API,多一次轉換,並不會讓情況變得更糟。
把六個代價跟兩個優點放在天平上,作者給的答案很清楚:只要有機會重新設計,長度就該是型別的一部分,不是靠慣例去猜。對正在維護既有 C 或 C++ 程式碼的人來說,這不代表要把每一個 char* 都換掉——而是下次寫剖析器、寫文字處理、寫任何需要頻繁做子字串操作的模組時,值得先問一句:這裡真的需要 null-terminated 的相容性,還是只是因為大家一直都這樣寫?
作者的立場:拿掉終止符、把長度變成一個顯式欄位,換來的是零拷貝的子字串操作、乾淨的二進位相容性,以及不再需要為了知道這個字串有多長而重新掃描一次。付出的代價是要放棄 sentinel 前瞻掃描的簡潔,以及跟既有 C API 之間那道其實沒有想像中難跨的相容性門檻。Redis 自己動手寫了一套 SDS,也算是替這個立場投了一票。