一個 C 接口設計的問題
C 語言在本質上,參數傳遞都是值傳遞。不像 Pascal 和 C++ 可以傳引用。這一點,使得 C 語言可以保持簡單的設計,但另一方面也頗為人詬病。
因為性能問題,指針不得不被引入。可以說,用 C 語言實現的軟件,其實現的 Bug 90% 以上都來至于指針,應該是沒有夸大了。當然設計引起的問題或許更為關鍵一些,那些于指針無關。
糾結于性能問題上,層次比較低。可 C 語言就是一個活躍在較低層次的語言,一旦你選擇用它,就不得不關心性能問題。反過來,把 C 模仿成更高級的語言,倒是有點畫蛇添足了。好了,讓我們來看個實際的涉及參數傳遞的相關問題,用 C 語言該如何設計。
最近同事在做一個類似 Protocol Buffers 的東西。這個東西做好并不容易,設計上尤為困難。其中的設計難點:設計一個合適的 DSL (領域專用語言) 我們討論過很久,也分析了好幾天,但今天不打算談了。揀個小東西說:當我們把一個二進制結構化數據塊解析出來,傳遞到 C 語言中,讓 C 語言可以方便的訪問數據結構時,接口如何設計?
?
這個問題在目標語言不是 C 而是更高級的語言(尤其是有 gc 機制的語言)時,都不是問題。可 C 語言本身是沒有對象概念的。
C 語言有結構,但是不具備描述動態長度的能力;沒有字符串,只有定長的字符數組;甚至沒有多維數組,只有一維數組的數組。
C 函數的參數及返回值可以是結構,但在接口設計中,或許是因為值傳遞,以及考慮 ABI 的簡潔性的關系,常常使用結構指針。返回結構指針往往有生命期管理的苦惱。即使到了 C++ 里,允許返回結果/對象了,可所謂返回值優化也是件相當讓人困擾的事情(如果你打算完全放棄了解語言的細節,無視細微處的性能問題。那么,為什么不考慮使用 Java 或是 Python ,無論什么都比選擇 C++ 強)。
對于返回一組復雜數據,通常的辦法有些什么?
最常用的方法是,調用者分配空間,傳遞給處理函數。由處理函數反向填寫結構內容。這樣的好處是,調用者可以選擇把空間分配在棧上還是堆上。一點小提示:在語法上,C 語言允許你把一個數組當成指針來傳遞。所以你可以定義一個長度為 1 的結構數組類型。用起來好看一些。具體見 標準庫中的 setjmp 的定義 。不過作為我個人的理念來說,不太主張在 C 語言設計的軟件中,為了減少幾次鍵盤輸入,而使用過多的語言特性。
這個方式的缺點是,你很難讓調用者定義不定的數據結構。尤其是在結構里還有對別的結構的引用。
跟這個相似的是接收字符串。最典型的例子是標準庫中的 fgets ,提供一個接收緩沖區的地址指針,和一個緩沖區大小。(注:gets 則是一個失敗的設計)同樣在 Windows 的 API 中,也隨處可見這樣的例子。
第二,就是由函數自己分配內存,交給調用者去釋放。大家只需要約定內存管理的接口即可。標準庫中的 strdup 就是這樣做的,同樣的還有 readline 庫中的 readline 。C 語言統一使用 malloc 管理內存,不像 C++ 提供了更靈活(更難控制,更容易出問題?)的 new 操作符重載。所以,給出這個約定并不會增添太多的麻煩。btw, 由于微軟 VC 的 CRT 對 malloc 等實現的過于糟糕,導致很多 Windows 的軟件自行實現內存管理器。或者在庫中開放自定義內存管理器注入的接口。這其實有點越俎代庖了。gcc 提供的 CRT 里, malloc 性能就相當不錯了。
缺點呢?內存只能從堆上分配;而且增加了內存泄露的隱患;設計角度上講,也不太干凈。對于復雜數據結構,這個方法也無能為力。C 語言里并沒有所謂析構函數的說法。
作為對第二點的一種補充方案,用的人就鳳毛麟角了。那就是給你的系統加入 gc 。實際上,就是約定另一種內存管理方法。我們的項目部分模塊在用,效果還不錯。 gc 庫已經開源,請參考這里 。如果信不過這套東西,可以考慮 COM 的機制:增減引用。COM 旨在建立一種對象模型,可惜 C 語言中沒有對象的概念,在 C 的層面使用 COM ,痛苦了一些。對于粒度比較小的東西,性能也將是問題。
第三種,用的人也比較多。就是在函數內部開一塊靜態空間,用于數據返回。返回的指針指向的數據的生命期可以保證到下次調用同一函數之前。靜態空間可以聲明在數據段里,也可以在程序初始化時從堆上分配出來,這樣利于在空間不夠的時候擴展。至于這塊靜態空間什么時候釋放的問題,不用太操心。即使不去釋放它們也不用內疚。操作系統會幫你回收的,還會比你干的更出色。C 是為了實現 UNIX 而誕生,而 UNIX 的哲學就是,編寫簡單的程序專心干好自己的事,讓更高層次的程序(通常是 shell 或動態語言)去組合它們,讓操作系統去管理它們。在 Windows 上,Unix 編程哲學未必有用,但大原則沒錯的。
這個方案有另外一個問題,就是函數不可重入,且有線程安全問題。重入問題可以想辦法避免。線程安全可以用 TLS 解決。老實說,我個人不看好在 C 語言中使用多線程解決問題。多線程也是違背 Unix 哲學的。如果你有幾件事情需要協調起來做,使用多進程;如果你有幾百件事情需要同時來做,考慮換個思路,玩玩 Erlang 啥的。
?
?
回到今天我們面臨的問題。用一種 DSL 來描述一個數據結構(比 C 的結構表達能力更強的),然后生成對應語言的解析庫。如果目標語言是 C 的話,我們生成的代碼如何返回對 C 程序員友好的結構化數據呢?
這讓我想到了 MySQL 的 C 語言接口。很多初學 C++ 的程序員,很喜歡把那些 C 接口“封裝”成“漂亮”的 C++ 接口。直接返回 vector 套 map 的多層模板實例。不知道有多少人干過?前幾年我帶實習生的時候反正見過不少。如果同學你現在醒悟了,明白這是件巨傻X 的事情,那么握握手,我們有共同理念;否則(C++ 封裝以后不是很“酷”嗎?),我們暫時沒有共同語言了。
我不是想說 MySQL 的 C 接口設計的很好,不過是中規中矩。只是 C++ 不是 C ,C 也不是 C++ 。(話說,上面提到的 C++ 封裝,我也不認為是正確的使用 C++ )反復提及 C++ ,是因為,我發現今天很大比例的 C 程序員其實是從 C++ 開始啟蒙的,而不是相反。把 C++ 當成 C 用的危害其實比不上把 C 當成 C++ 用。前者不過是把汽車開到自行車的速度,至少不怕摔跤了,跑起來還能安全點;而后者,非要把自行車踩到高速公路中間,遲早非撞死不可。
最方便 C 程序使用的莫過于傳入一個結構指針,讓庫去解析數據,填寫這個結構了。
但是,如果結構里有字符串、不定長數組(通常會根據前面解析出來的數據決定后面的長度,對于 C 的編程技巧來說,允許把結構體的最后一個數組的長度設為 0 ,假設成不定長的,從而減少一次間接的指針引用。但是對于結構中有多個不定長數組則無法使用這個技巧。)等等的話,就很難避免指針了。
數據中一旦出現指針(間接引用別的數據),就有內存管理問題。
最開始,考慮過一個很 C++ 的方案,傳入一個內存管理器。這種設計在 STL 里就有。所有 STL 的容器都可以指定一個 allocator ,供靈活的管理內存。前幾年我倒是認為這是個相當巧妙的東西。沒有細想,自定義分配器最終有多大的意義?自定義內存管理器,很大程度上是因為效率因素引起的。但性能問題永遠不是根本問題。制作軟件是為了達到特定的目的,而軟件開發的問題更多的是是解決復雜度問題。往往復雜度帶來的性能問題更加嚴重。然后為了解決復雜度帶來的性能問題去引入更高的復雜度,出現惡性循環的可能性非常之大。
即使我們傳入的內存管理器(或是直接使用 CRT 里的 malloc,但這樣就沒可能利用堆棧分配空間了),還會面臨新問題,如何回收結構中間接引用的數據。引入析構函數指針?OMG 。
后來,我們設想使用一個內部靜態空間,所有的解析結果都分配在內部,自我管理。這些空間還可以復用。大部分解析結果也就是臨時用用,這樣做很方便。而且調用者不用太關心數據的生命期。
但是,一旦調用者需要把結果(一個復雜結構)保存一段時間的話,他就遇到困難。
當然,也可能不是困難。當我們面對這個設計難點時,都應該向上考慮一層,究竟這是一個問題嗎?我們需要這么用嗎?
調用者可以自己遍歷這個數據結構,把他需要的數據,以自己的方式復制出來,組織起來。他們需要的是數據,而不是對數據結構完全的拷貝。
仔細考慮過以后,我們還是發現,保留完整的數據結構是有意義的。不像 C++ ,C 沒有對象賦值操作符重載這種語法糖,我也不喜歡用宏去模擬一個出來。增加一個拷貝函數指針其實和增添一個析構函數一樣,對 C 來說,不那么漂亮。(當然,同時增加了開發量,我們需要編寫更多的代碼自動生成器)
最終,我們采用了由調用者傳入緩沖區指針的方案。要求解析器生成的數據結構放在一塊連續的內存空間上。這樣,調用者就可以把指針直接定義成最終方便訪問的結構或聯合。但是提供更充裕的內存空間,存放那些內部引用的數據(比如字符串)。
因為結果數據區是由調用者提供,就不存在數據復制移動引起的指針調整問題(調用者可以自己先分配好)。
最后一個問題是,如何讓調用者估算數據接收區的大小呢?
很多 Windows API 可以通過兩次調用來完成,第一次空調用計算需要的緩沖區大小,第二次真的去填寫數據。根據實際需求分析過之后,我認為在我們這個模塊的應用上,這樣做是多余的。我們盡可以讓用戶隨便給一個估算大小去處理數據,一旦空間不夠,返回錯誤信息。讓用戶自己擴大緩沖區,重新調用一次即可。
btw, 不斷重試是我們最終認可的最 KISS 的方案。一開始,我們認為讓處理程序自己分配內存,并自己使用 realloc 更好。后來發現,完全是多余的設計。因為,解析二進制流是 O(1) 的操作,不比估算長度慢;而往往調用者都能正確估算接收區應有的長度,即使簡單的每次兩倍的方法擴展接收區大小,也不會浪費多少處理時間。即使他們需要精確分配結果需要的內存塊,盡可以用一個足夠大的公用緩沖區接收,然后得到長度信息,重新在特定內存上重來一次即可。
?
?
寫累了。想表達的也表達完了。今天到此為止。 :D
ps. 前幾天寫了一篇關于 一種對漢字更環保的 Unicode 編碼方案 ,我昨晚花了兩小時寫了個簡單的 C 實現。可以把 UTF-8 或 UTF-16 轉換到我自己定義的暫且命名為 UTF-C 的編碼上,也可以轉回來。代碼用的行數比預想的要多一些,因為我低估了 UTF-8 的處理復雜度(其實也不復雜啦)。
更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元

Comments
開年以來讀到的第一篇雄文,如飲烈酒,甘冽有勁。
Posted by:
寒流 | (24)
February 27, 2011 01:28 AM
strdup是posix的, 不是c庫.呵呵
Posted by:
cc | (23)
August 6, 2010 10:16 PM
哈哈,我們有共同點語言,我大膽地猜測,風云大哥一定更喜歡使用寫codegen來解決語言的不足,而不是去依賴某一語言的特性。
Posted by:
artizc | (22)
February 14, 2009 03:00 PM
方法三稍微修改,函數內部靜態空間每線程一個,與當前線程(ID)捆綁。實現上以線程ID為key,在數組或HashMap中存放緩存,實現頗為簡單,性能幾乎絲毫不損,也可解決文中大部分問題,唯是不可重入。不過正如云風所說,這個問題可能不是問題。
Posted by:
rich | (21)
January 30, 2009 11:04 AM
C 語言標準并沒有特別限制返回值不能是什么,所以 C 是允許返回struct 作為一種返回復雜數據的方案的。
不知道被以前看過的哪本書誤導,我覺得返回 struct 是 C++ 對 C 的一個擴展。(其實不是)
感謝指出這個錯誤的朋友,我今天仔細核對了 ISO C90 和 ISO C99 標準。:D
Posted by:
| (20)
Cloud
January 10, 2009 08:01 PM
您的語言表達能力有待提高啊。。。
Posted by:
Avin | (19)
January 9, 2009 01:40 PM
"這讓我想到了 MySQL 的 C 語言接口。很多初學 C++ 的程序員,很喜歡把那些 C 接口“封裝”成“漂亮”的 C++ 接口。直接返回 vector 套 map 的多層模板實例。不知道有多少人干過?前幾年我帶實習生的時候反正見過不少。如果同學你現在醒悟了,明白這是件巨傻X 的事情,那么握握手,我們有共同理念;否則(C++ 封裝以后不是很“酷”嗎?),我們暫時沒有共同語言了。"
呵呵,我也這樣干過。不過,沒辦法,誰叫咱,那時候剛學會
c++,不管什么東西,都要用我的c++這把小斧頭試試啊。
Posted by:
Anonymous | (18)
January 9, 2009 01:14 PM
windows api的調用歷來就是這么做的
Posted by:
Anonymous | (17)
January 8, 2009 05:05 PM
為什么說C函數不能返回結構?
Posted by:
Anonymous | (16)
January 8, 2009 03:42 PM
為什么不看看xdr? sun rpc中的數據序列化方式,如果嫌它在線程安全上問題太多,那么看看cdr?C++和java都在用它。corba的idl和sun rpc的xdr就是兩個很好的用DSL定義數據然后交給C語言去解析的例子。我覺得你所討論的這些,在它們的實現上全有答案。
至于allocator,我只在loki中看見它為小對象設計過,專用于小于64bits的小對象的分配。我覺得這方面是花力未必討好的事情。有時間可以做點別的優化。哦,可以借其做內存泄露的調試器。
至于數據接收區的大小,我覺得stl的stringstream以及string/vector這些都做的很好,根據需求自動增長,還可以讓程序員可以靈活的預留空間。但是可惜沒有realloc。不知道你的應用具體是什么情形,如果每次都是delete/new而沒有realloc,對于性能影響大不。
Posted by:
snnn | (15)
January 8, 2009 02:49 PM
感覺就是在說fgets嘛
Posted by:
zii | (14)
January 8, 2009 12:09 PM
這篇文章提了一個問題:如何用C語言接收被調用函數返回的數據塊?
答案是:調用者分配內存,然后傳指針給調用函數。被調用者往里面填數據。
簡單的說就是:去超市買東西得自備購物袋。
Posted by:
D.K | (13)
January 8, 2009 10:19 AM
當初我以為指針的數值等于指針指向的地址
后來才發現這兩是不等的。
我到現在還是沒搞清楚這兩者的關系。
只是 知道 指針的數值的地址包含有個指針頭數據才到指針真正的數據的地址。
有沒有語法或函數得到一個指針指向數據的長度?
Posted by:
dvaknheo | (12)
January 7, 2009 11:50 PM
感覺寫得很混亂,看得不是很明白,說的是對2進制數據的描述?
bioware的很多游戲文件都是2進制描述的,比如.tlk,從博德1開始發展到現在也沒淘汰,可以描述一段對話的文本(字符串),配音文件(字符串),時間軸(浮點),顏色什么的,實現也簡單,像元數據,先用一個數據結構描述數據結構的結構,然后就是按字節依次填充了
Posted by:
black | (11)
January 7, 2009 08:13 PM
我們公司的模塊間數據接口多數是這種類似fread的方案。不過看RADVISON的代碼就喜歡傳分配器了。
GLib就有點用C寫C++的感覺。
Posted by:
zelor | (10)
January 7, 2009 06:26 PM
傳分配器也可以"要求解析器生成的數據結構放在一塊連續的內存空間上"啊,只不過,真的有點怪,明明傳了個分配器,卻又約定只能調用一次-_-!或者傳一個類似于realloc的分配器?
Posted by:
天堂的隔壁 | (9)
January 7, 2009 05:25 PM
呵呵,最終的解決辦法 省心啊, 時間真的很寶貴,省出的時間可以泡泡妞,灌灌水, 這才叫生活
Posted by:
lbaby | (8)
January 7, 2009 02:35 PM
pool 是一個內部解決方案,能不暴露出來就不暴露出來。否則,就需要記住兩個東西:1. 結構的地址, 2. pool . 并且要記住兩者的關系。
如果要用的話,最好和別的部分正交化。比如,先創建后 pool 傳遞 buffer 指針進去。
至于 pool 的伸展能力,在這里不是必須的。文中已有講述。遵循能減則減的原則,就可以去掉了。
其實在堆上還是在棧上的問題,即使傳分配器,也是都可以兼顧到的 :) 只要愿意弄點奇技淫巧的話。
傳分配器的大問題是,復雜的數據結構是多次分配的,而最終需要一次釋放。這限制了分配器的設計(必須設計成 pool 那種,而不能是 C 標準的 malloc )
Posted by:
| (7)
Cloud
January 7, 2009 01:43 PM
雖然內存管理器等方案也不算壞,但從KISS上說,傳內存確實用起來最簡單,也靈活。(注意云風多次強調,內存可以在堆里分,也可以在棧上分,似乎要達到這個目地傳內存就是唯一的方法了)
Posted by:
天堂的隔壁 | (6)
January 7, 2009 12:45 PM
哈哈,新年好,特別謝謝上個星期云風大哥幫我抽中的豆漿機,豆漿很好喝,呵呵。
平時也一直在用c語言,說些我的看法吧:
1、c是用來做一些底層和性能要求較高的事情,如果不是很注重性能的話,沒必要用它。
2、c是可以傳結構體的。c中對于不定的結構體,可以用void指針。
4、對于像nginx這類代碼,可以借鑒一下它里面的pool的概念。也就是對于不同的生存期的對象定義不同的pool,在對象開始時,分配一定的內存,在對象存活時,可以從這個pool中分配內存,不夠的話,擴大這個pool。在對象消亡時,釋放這個對象的pool。pool底層的實現其實都是malloc來做的。不過它的特定環境是session這類有特定生存期的對象,與程序的原有設計關系很大,如果是通用的內存分配,像這類東西就沒法做到了。
Posted by:
yaoweibin | (5)
January 7, 2009 10:43 AM
GLib很多模塊都是傳構造和析構函數指針進去,或者至少傳析構指針
Posted by:
Anonymous | (4)
January 7, 2009 09:10 AM
至于說的引入析構函數指針的問題,一般情況下,內存都由內存分配器管理,不同生命期的對象使用不同的管理器,如果只牽涉的內存的話,我覺得要注冊析構的情況是很少的
Posted by:
exile | (3)
January 7, 2009 01:40 AM
我覺得傳入一個內存管理器的方式挺好的啊,比如像nginx這樣的內存管理,有點類似于Apache的apr_pool,但更適合一般的應用,除了性能的提升,更重要的是使用起來更簡單,最終統一釋放,也不容易產生內存泄露。
Posted by:
exile | (2)
January 7, 2009 01:28 AM
不懂?學習!
Posted by:
xinghang | (1)
January 7, 2009 12:23 AM