為什麼要避免使用 libc

【CSDN 編者按】libc 是 Linux 下的標準 C 庫,也是初學者寫 hello world 包時含有的頭檔案 #include < stdio.h> 定義的地方,後來其逐漸被 glibc 給取代,本文作者列出了為什麼要避免使用 libc 的 20 個理由。

作者 |Chris Wellons 譯者 | 彎月

一般,在使用 C 語言時,我會盡可能避免使用標準庫 libc。如果有可能,我甚至不會連接這個庫。可能有些人對於這種工作及思考方式有點不太理解。這不是重新發明輪子嗎。但對於我來說,libc 就是一個不值得使用的輪子,其接口和實現上有太多的缺陷。幸運的是,如果你了解實際情況,製造一個更好、更簡單的輪子也非難事。

在本文中,我打算回顧一下 C 標準庫的函數和類似函數的宏,並討論我在實際中遇到的問題。

幸運的是,C 語言擁有很大的靈活性,這彌補了標準庫的不足。我已經擁有了工作中所需的一切工具,且不依賴任何運行時。

如何在不依賴 libc 的情況下編寫可移植軟體?首先,大部分程序的實現都需要做到與平臺無關,且程式碼不依賴 libc;然後,針對每個目標(平臺層),在各自的源檔案中編寫特定於平臺的程式碼。相比之下,平臺程式碼量很少,這部分程式碼大多是不可移植的,包括原始系統調用、圖形函數,甚至是彙程式設計式碼。在一些平臺上,我們必須連接 libc,有時是因為其中包含一些特定於平臺的功能,有時是因為連接libc是強制性的。

下面的討論只針對標準 C。有些平臺提供了特殊的解決方法,可以克服其標準函數的缺點,但這不是本文的討論重點。如果需要使用非標準函數,我會編寫特定於平臺的程式碼,也可以通過直接調用平臺的功能來完美地繞過原本的問題。

下面,我們按照 C18 草案中列出的順序,逐個瀏覽標準庫。

斷言和中止

斷言和中止

雖然 C 的斷言比其他我了解的任何語言都要好,但並不意味著就沒有問題。本質上 C 的斷言就是一個不需要展開棧的陷阱,但常見的實現並不會在宏內觸發陷阱,這就給使用造成了不便。更糟的是,有時完全不會觸發陷阱,而是直接以非零狀態碼結束進程。C 的斷言並沒有針對偵錯程式進行最佳化。

下面的這個小程序就是一個非常簡單的實現,如果有需要,我可以到後面再調整:

#define ASSERT(c) if (!(c)) __builtin_trap()

它不會輸出診斷資訊,但一般情況下也不需要。絕大多數時候,這個斷言會被偵錯程式捕捉,因此診斷資訊是不必要的。

我不反對 static_assert,但它也不是運行時的一部分。

數學函數

數學函數

我指的是 math.h、complex.h 等檔案中的所有函數。在實踐中,這些都是偽內建函數,也是 libc 中比較難替換掉的部分之一。這些函數對精度的重視超出了日常需要,但這是很合理的。

字符分類和對映

字符分類和對映

包括 isalnum、isalpha、isascii、isblank、iscntrl、isdigit、isgraph、islower、isprint、ispunct、isspace、isupper、isxdigit、tolower 和 toupper。這個接口具有誤導性,而且這些函數經常被錯誤使用。如果在源檔案中看到 #include ,那麼幾乎可以肯定程式碼有問題。我自己也為此感到內疚。在我的日常工作中,這些函數無一例外都禁止使用。

它們的原型大致如下:

int isXXXXX(int);

然而,輸入的取值範圍是 unsigned char 加上 EOF。除了 EOF 之外的任何負參數都是未定義的行為,顯然字串會產生錯誤結果。所以,如下寫法是不正確的:

char *s = ...;if (isdigit(s[0])) { // WRONG!...}

如果 char 是有符號的,就像在 x86 上一樣,那麼對於任意字串 s,isdigit的行為都是未定義的。某些實現在遇到此類輸入時甚至會崩潰。

如果參數是 unsigned char,那麼至少還能將參數擷取到定義域內,因此通常也能得出所需的結果。(但傳遞 Unicode 程式碼點會出現錯誤結果,真是一個奇怪的錯誤。)唯一的例外是它能夠處理 EOF。為什麼?因為這些函數是為 fgetc定義的,而不是字串!

如果想修復這個問題,你可以使用掩碼:

if (isdigit(s[0] & 255)) {...}

然而,你依然會遇到 locales 問題。locales 有點像全局狀態,它能改變一些 libc 函數的行為方式,包括字符分類。雖然locales有一些特別的用途,但大多數時候這種行為出人意料,也沒有人喜歡。這對性能也很不利。我的習慣就是利用LC_ALL=C運行所有GNU程序,這樣才能保證它們實現原本的行為。如果你需要解析的格式不適用於locales(絕大多數情況都是如此),那你肯定不希望字符分類按照locales的方式工作。

由於大多數時候這個接口和行為都不合適,因此你最好自己編寫範圍檢查或查找表。在命名的時候,請避免以 is 開頭,因為這些函數名是保留字。

_Bool xisdigit(char c){return c>='0' && c<='9';}

我使用了char,遇到簡單的 UTF-8 解析仍然有效。

errno

errno

如果沒有 libc,你就不必使用這個全局的、執行緒局部的、偽變數。甩掉包袱,返回自己定義的錯誤,並在必要時使用結構。

locales

locales

如上所述,locales 有一些特別的用途,比如格式化日期,但這些用途被困在 setlocale 設置的全局狀態後面,因此有時無法正確使用。

在 Windows 上,我改為使用 GetLocaleInfoW 來獲取「當前月份的本地名稱是什麼?」之類的資訊。

setjmp 和 longjmp

setjmp 和 longjmp

有時很難正確使用,尤其是將局部變數限定為 volatile 時。它可以與基於區域的分配相結合,自動且及時地釋放 set 和 jump 之間創建的所有對象。這些宏很好,但不要過度使用。

可變參數

可變參數

有時,可變參數函數有一定的幫助,這要感謝宏 va_start/va_end。不幸的是,這些函數非常複雜,因為調用慣例並沒有降低使用難度。它們需要編譯器的幫助,實際上它們是作為編譯器的一部分而實現的,不是 libc。這些函數雖好,但沒有它們我依然可以正常工作。

信號

信號

雖然在類 Unix 系統上信號很重要,但 C 標準庫中定義的信號其實並沒有什麼用。如果你正在處理信號,甚至是信號之類的東西,那麼肯定要編寫超出 C 標準庫、特定於平臺的程式碼。

原子

原子

我的一些示例中使用了 _Atomic 限定符,因為可以提高簡潔度,但我很少在實踐中使用它。部分原因是它對 API 和 ABI 有不利影響。與 volatile 一樣,C 使用類型系統來間接地實現某個目標。類型不是原子的,但載入和儲存是原子的。在標準化之前,C 實現一直使用行內函數、函數或宏來表達這些載入和儲存。

但是,我不認為原子函數需要 _Atomic 限定的參數,所以看似魚與熊掌我可以兼得。除了一個主要實現(MSVC)仍然不支持它們。在考慮使用 C 原子的地方,我都使用了更豐富的 GCC 內建函數集,Clang 也支持這些函數。如果需要編寫適用於 Windows 的程式碼,我會使用互鎖宏,因為這種方法適用於該平臺的所有編譯器。

標準輸入輸出

標準輸入輸出

標準輸入輸出庫 stdio 是我決定放棄 libc 的主要驅動因素。幾乎每個程序都需要某種輸入或輸出,但使用 stdio 會讓實現變得更加困難。

要想讀取或寫入檔案,首先必須打開它,即調用 fopen。然而,某個平臺的所有實現都不允許 fopen 訪問大部分檔案系統,因此使用 libc 會限制該平臺上程序的功能。

標準庫會區分「文字」和「二進位制」流。雖然在類 unix 平臺上這兩者沒有區別,但在其他需要對輸入和輸出進行轉換的平臺上卻有區別。除了資料會遭到破壞外,文字流的性能也很糟糕。以二進位制模式打開一切是一個非常簡單的解決方法,但是標準輸入、輸出和錯誤都是作為文字流打開的,而且沒有將它們改為二進位制流的標準函數。

使用 fread 時,某些實現會將整個緩衝區作為臨時工作空間,即便返回的長度遠小於整個緩衝區也是如此。所以以下程式碼無法正常運行:

char buf[N] = {0};fread(buf, N-1, 1, f);puts(buf);

這段程式碼除了會輸出預期的結果外,還會輸出垃圾,因為 fread 會覆蓋範圍之外的零。

流是緩衝的,並且沒有可靠地訪問未緩衝的輸入和輸出的方法,例如當應用程序已經緩衝時,或許這只是其工作方式帶來的必然結果。雖然我們有 setvbuf 和 _IONBF(「無緩衝」),但至少在某些情況下,這隻意味著「一次一個位元組」。使用 libc 的程序最終都會變成雙緩衝,這種現象很常見,因為我無法可靠地關閉 stdio 緩衝。

常見的實現假設流將被多個執行緒使用,因此每次訪問都需要使用互斥鎖。這會導致小型讀取和寫入的性能變差,而這些情況本應是緩衝最能發揮作用的地方。這不僅不正常,而且這樣的程序肯定會出問題,因此 stdio 實際上是犧牲了最常見的需求,對一些不太常見且極端的情況進行了最佳化。

我們沒有可靠的方式通過互動式輸入和顯示 Unicode 文字。C 標準中處理「寬字符」方面做出了一些妥協,但在實踐中毫無用處。我已經試過了。我最常見的需求是輸出標準錯誤的路徑,以便正確地顯示給使用者。

seek 函數的參數 offset 只能為 long 類型,一些實現甚至無法打開大於 2GiB 的檔案。

我不想處理這些問題,只是向平臺層添加了幾個無緩衝的 I/O 函數,然後在應用程序中放置了一個小的緩衝流實現,這個實現支持將緩衝刷新到平臺。文字的輸入和輸出使用了 UTF-8,如果平臺層檢測到它連接到了終端或控制檯,就會進行適當的轉換。獲得比標準輸入輸出更可靠的方法並不需要付出太多努力。

數值轉換

數值轉換

浮點數轉換是一個常見的難題,尤其是當你需要保證轉換後的字串與浮點數一一對應時。浮點數轉換是 libc 中最好用的一個部分。儘管使用 libc 仍然很難獲得最簡單或最短的一一對應的表示。此外,這也是修改 locales 可能會引發災難性後果的領域。

那麼,問題來了:在你的應用程序上下文中,浮點數轉換很關鍵嗎?也許,你只需要向使用者顯示一個四捨五入、低精度的浮點表示,比如在調試窗口中顯示玩家的位置等。或者你只需要解析一個中等精度的、格式非常簡單的、非整數輸入。這些都不難。

解析函數(atoi、strtol、strtod 等)需要以 null 結尾的字串,通常這很不方便。這些整數的來源可能並不像檔案一樣以空值結尾,所以我需要先附加一個空值終止符。我無法把對映到記憶體的檔案直接傳遞給它們。即使在使用 libc 時,我也經常編寫自己的整數解析器,因為 libc 解析器缺少合適的接口。

格式化整數很容易。解析範圍有限的整數(比如小於一百萬)很容易。但解析觸及數字類型極限的整數很棘手,因為無論有符號還是無符號,每個操作都必須防止溢出。幸運的是前兩個很常見,最後一個不太會遇到。

隨機數

隨機數

我們有 rand、srand 和 RAND_MAX函數。雖然我很熱衷於偽隨機數生成器,但我不推薦在任何情況下使用它。rand 函數是一個並不太優秀的偽隨機數生成器,性能不佳,並且持有全局狀態。我們無法提前預知 RAND_MAX,因此很難有效利用 rand。只需幾行程式碼,你就可以構建一個全方位碾壓的實現。

更糟糕的是,常見的實現希望可以利用多個執行緒併發訪問,因此它們將這些方法包裝在互斥鎖中。同樣,隨機數的最佳化針對的都是一些不太常見且極端的情況,各個執行緒會因為確定性的 偽隨機數生成器給出的非確定性結果而相互競爭,代價是犧牲最常見的需求。依賴這種互斥鎖的程序都會出問題。

記憶體分配

記憶體分配

相關函數包括 malloc、calloc、realloc、free 等。在實際的工作中,我們使用的函數過於細化,而且過多,以至於許多 C 程序的生命週期都糾結在一起。有時,我希望有一個標準的區域分配器,這樣許多獨立編寫的庫就可以使用一個通用、合理、可由調用者控制的分配接口。

此處,沒能實現標準化的一個主要原因是,分配器並不負責計算大小。calloc 是一個開始:你需要說明大小和數量,它計算出總分配量,檢查溢出。但需要的工作遠不止如此,哪怕只是不鼓勵獨立分配、鼓勵成組分配,情況也要好很多。

關於大小為零的記憶體分配,有一些邊緣情況,比如 malloc(0),標準對於行為的規定有點過於開放。但是,如果你的程序結構很糟糕,可能會將零傳遞給 malloc,那麼屆時你就會遇到更大的問題。

訪問環境

訪問環境

getenv 很簡單,但我更喜歡直接訪問環境塊,就像 main 的第三個非標準參數一樣。

exit 沒問題,但是 atexit 是垃圾。

system 在實踐中基本上沒有任何用處。

排序和搜尋

排序和搜尋

qsort 很差勁,因為它缺少上下文參數。質量參差不齊。如有必要,從頭開始實現也不難。我很少需要排序。

bsearch 的情況大致相同。儘管如果我需要對陣列進行二分搜尋,只用 bsearch 可能還不夠,因為通常我需要找到範圍的下限和上限。

多位元組編碼和寬字符

多位元組編碼和寬字符

mblen、mbtowc、mbtowc、wctomb、mbstowcs 和 wcstombs 與 locale 系統緊密關聯,而且並不會處理任何特別的編碼(如UTF-8),因此導致它們不可靠。所有其他寬字符函數的情況都很類似。幸運的是,我只需要在一個平臺上使用寬字符,可移植程式碼中不涉及這個問題。

最近還推出了 mbrtoc16、c16rtomb、mbrtoc32 和 c32rtomb,其中只規定了「寬」的方面(UTF-16、UTF-32),但沒有規定多位元組的方面。實現對於標準的支持很有限,所以沒有太大用處。

字串

字串

與 ctype.h 一樣,string.h 中的所有函數都很糟糕,有些函數總是被錯誤使用。

memcpy、memmove、memset 和 memcmp 都很好,但有一個問題:將空指針傳遞給這些函數,則行為是未定義的,即使大小為零。這很荒謬。空指針合法地指向大小為零的對象。如前所述,就連 malloc(0) 也允許以這種方式運行。如果沒有這個缺陷,這些函數還是可以使用的。

strcpy、strncpy、strcat 和 strncat 根本無法正常使用,每次使用都會引發混亂。因此,任何調用這些函數的程式碼都有問題,應該進一步審核。事實上,我沒有見過在實際程序中正確使用 strncpy 的例子。在我看來,這些函數無一例外都應被禁止。這些函數的非標準版本(如 strlcpy)亦是如此。

strlen 有合理的用途,但使用過於頻繁。它應該僅在接收未知大小的字串(例如 argv、getenv)時出現在系統邊界,而且永遠不應用於靜態字串。

在看到 strchr、strcmp 或 strncmp 時,我就在想為什麼使用者會不知道字串的長度。另一方面,strcspn、strpbrk、strrchr、strspn 和 strstr 沒有對應的 mem 系列函數,儘管空終止符的要求降低了它們的實用性。

strcoll 和 strxfrm 依賴於語言環境,因此用途很有限。而且不可預測,因此應避免。

memchr 很好,除了前面提到的空指針限制,儘管它出現的頻率較低。

strtok 具有隱藏的全局狀態。除此之外,返回的令牌有多長?strtok 在返回之前知道令牌的長度。為什麼不能直接告訴我?所以不用它。

strerror 有一個明顯、簡單、可靠的解決方案:返回一個指針,指向查找表中與錯誤編號相對應的靜態字串。沒有全局狀態,執行緒安全,可重入,而且返回的字串直到程序退出前都可以正常使用。有些實現就採用了這種方式,但不幸的是,有的實現並不這麼想,它會寫入一個共享的全局緩衝區。希望你沒有使用errno。

執行緒

執行緒

C11 引入了執行緒,但一直沒有太大水花。凡是可以使用 C 執行緒的地方都可以使用 pthreads,而且 pthreads 更好。

此外,執行緒的創建屬於平臺層的操作。

時間函數

時間函數

使用非常有限,除了使用 time 和 clock 生成隨機數種子之外,我不記得其他地方使用過這些函數。

總結

總結

我略過了一大堆寬字符函數,除此之外幾乎 C 標準庫的所有函數都提到了。在完全不使用這些函數時,唯一令我懷念的就是數學函數,偶爾還有 setjmp/longjmp。其餘的函數我都可以輕鬆構建出更好的實現。

上述所有提到的 C 實現都非常古老。它們很少發生變化,即便有變化,也不過是歲月的沉澱。這個領域沒有太多創新,這一點很好,因為我喜歡穩定的目標。

評論

評論1:我完全同意作者的看法,通常我也會這麼做。libc 是 C 語言中最薄弱的環節,過時、糟糕的命名規則,其 API 有時也有害。當然它也有好處:簡單、隨時可用,因此可以作為備選,但我認為,使用的框架最好能覆蓋所有 API,包括最簡單的 printf/malloc/fopen 等,而且應該在所有程式碼中維護相似的命名規則。

評論2:我最不敢相信的是 C 語言中的保留字規則。我甚至不知道有這條規則,我認為它比我想象得還要糟糕,它幾乎覆蓋了所有程式碼中能用到的常用單詞。但就像 C 語言中的其他陷阱一樣,早期的語言發明者並沒有考慮到名稱空間的概念,所以才導致了這個結果。至於 GNU tools 中的有關 locales 的行為不一致,也可以理解,畢竟 GNU 項目在歷史上是從 POSIX 標準發展而來的。

相關文章

震驚!C 語言字串處理有很多坑?

震驚!C 語言字串處理有很多坑?

【CSDN 編者按】毋庸置疑,在使用 C 字串時必須小心,否則你就會因為各種的未定義行為而感到頭疼。 原文連結:https://www.de...

吳峰光殺進 Linux 核心

吳峰光殺進 Linux 核心

【編者按】吳峰光,Linux 核心守護者,學生時代被同學戲稱為「老神仙」,兩耳不聞窗外事,一心只搞 Linux。吳峰光的 Linux 核心之...

最強 AI ChatGPT 真要取代程式設計師?

最強 AI ChatGPT 真要取代程式設計師?

【CSDN 編者按】ChatGPT 一出,「程式設計師要失業了」、「程式設計師要下崗了」之聲不絕於耳,引得程式設計師們不由得一陣驚慌,最強 ...