有關C語言模塊化實現的探討
本文節選自云風的博客上近日的兩篇文章:《好的設計》和《C 語言對模塊化支持的欠缺》。
由于最近幾年用的主要開發語言是 C 和 lua 。那么也打算以此為基礎寫。假定讀者至少有不錯的 C 語言基礎了。我真正想談的是,如何把一個軟件很好的構建起來。到底需要做些什么。(從實現層面看)怎樣才是好的軟件。
那么有一個重點問題,也是老問題,怎樣才是好的設計。
好的設計,必然是容易實現的。它可以很精巧,但不能難以理解。
太陽底下無新鮮事。軟件行業已經發展了這么多年,你想到的東西,肯定有人都想到過了。
每個軟件也都有它的生命期,我們只要在它的生命期內完成它的使命就行了。軟件往往需要盡快的投入使用,然后在使用中演化。這個演化最大可能并不依靠你一個人的力量去推動。隨著參與的人增加,人和人(指開發人員)的共性就會減少。每個人都看得懂可以充分接受,軟件才不容易向壞的方面演化。
我們常常談模塊化,談高內聚,低耦合。
本質上,就是如何管理復雜度。如何把一件很難的事情(開發一個軟件),分解成小問題,分而治之。
這些小問題之間的千絲萬縷的聯系,是設計人員面臨的最大難題。
有些原則聽起來不錯,但是堅持起來很難。
比如,讓模塊的輸入輸出沒有副作用。你能讓你的模塊每個輸入對對應著唯一輸出嗎?
又比如,讓模塊層次化。如果 A 模塊依賴 B 模塊,B 模塊依賴 C 模塊。一旦出現這個狀態,你能保證 A 模塊絕對和 C 模塊隔絕嗎?更有甚者,讓三個模塊循環依賴這種更糟糕的事情也并不鮮見。
抽象是個好東西。但借助不斷的抽象,問題不斷的包起來,演化成新的巨無霸,顯然會讓事情更糟。雖然最終可能真的能像搭積木一樣去組裝軟件了。或是雇傭更多的程序員填表單一樣的工作,相互不需要對方在做什么。但是,軟件性能卻下降到了不可以忍受的地步。bug 也隱藏的更久,更不可收拾。
好的設計,必須對問題有足夠清晰的理解。有如庖丁解牛一般,把整個問題劃開,在最薄弱的地方分離。其實,做到這點,也就夠了。
解決這些問題,其實跟語言無關。語言之爭是沒有多大意義的。如開頭所說,把設計做好,模塊之間的關系,用足夠簡單的方式就能描述清楚了,大部分流行的開發語言都能做到。
用 C 來實作,而沒有用它的近親 C++ ,也是為了避免狹隘的爭議:我們該用這個特性嗎?該用那個特性嗎?這個形式做是不是好點?那樣會不會有更好的性能?
所謂開發效率,對于個人來說,語言之不同,是會有很大差異。但是那是實現層面的差異。對于完成設計,這個過程,效率和所用語言無關。
實現的階段,程序員可不可以開心的放心的去完成那些接口,這就是衡量設計好不好的指標了。這個時候,一個高開發效率的語言有優勢(更少的代碼量),一個容易掌握的語言也有優勢(可以讓更多的人參于而少犯錯誤)。
#t#對于我的團隊,我會更樂于采用一種讓實現人員更輕松的方式。不用理會太多的語言細節,不用在投入開發前學習更多的概念(尤其是這個項目獨有的),不用特別嚴格的 code review 也可以允許大家提交新的代碼,切不至于輕易的引入 bug 。
我相信,軟件做到后面,設計人員不需要親自寫太多代碼。雖然我現在每天還是大量的寫,也并不覺得枯燥。
事必恭親是不好,但并不是說,你給實現人員足夠信任就可以放手的。真正讓你放手的只能是,你做出了好的設計,無論是誰,他也寫不壞它。這時,是你樂意自己寫,還是多找幾個同學幫忙寫,已經不重要了。
#p#
那么我們來討論一下怎樣構建一個(稍具規模的)軟件。我選擇用 C 為實現工具來做這件事情。就不得不談語言還沒有提供給我們的東西。
模塊化是最高原則之一(在 《Unix 編程藝術》一書中, Unix 哲學第一條即:模塊原則),我們就當考慮如何簡潔明快的使用 C 語言實現模塊化。
除開 C/C++ ,在其它現在流行的開發語言中,缺少標準化的模塊管理機制是很難想象的。但這也是 C 語言本身的設計哲學決定的:把盡可能多的可能性留給程序員。根據實際的系統,實際的需要去定制自己需要的東西。
對于巨型的系統(比如 Windows 這樣的操作系統),一般會考慮使用一種二進制級的模塊化方案。由模塊自己提供元信息,或是使用統一的管理方案(比如注冊表)。稍小一點的系統(我們通常開發接觸到的),則會考慮輕量一些的源碼級方案。
首先要考慮的往往是模塊的依賴關系和初始化過程。
依賴關系可以放由鏈接器或加載器來解決。尤其在使用 C 語言時,簡單的靜態庫或動態庫,都不太會引起大的麻煩。
C++ 則不然,C++ 的某些特性(比如模板類靜態成員的構造)必須對早期只供 C 語言使用的鏈接器做一些增強。即使是精心編寫的 C++ 庫,也有可能出現一些意外的 bug 。這些 bug 往往需要對編譯,鏈接,加載過程很深刻的理解,才能查出來。注:我并不想以此來反對使用 C++ 做開發。
我們需要著重管理的,是模塊的初始化過程。
對于打包在一起的一個庫(例如 glibc ,或是 msvcrt ),會在加載時有初始化入口,以及卸載時有結束代碼。我想說的不是這個,而是我們自己內部拆分的更小的模塊的相互依賴關系。
誰先初始化,誰后初始化,這是一個問題。
在 C++ 的語言級解決方案中,使用的是單件模塊。要么由鏈接器決定以怎樣的次序來生成初始化代碼,這,通常會因為依賴關系和實際構造次序不同而導致 bug (注:我在某幾本 C++ 書中都見過,待核實。自己好久不寫 C++ 也沒有實際的錯誤例子);要么使用惰性初始化方案。這個惰性初始化也不是萬能的,并且有些額外的開銷。(多線程環境中尤其需要注意)
我使用 C 語言做初期設計的時候,采用的是一種足夠簡單的方法。就是,以編碼規范來規定,每個模塊必須存在一個初始化函數,有規范的名字。比如 foo 模塊的初始化入口叫
- int foo_init()
#t#規定:凡使用特定模塊,必須調用模塊初始化函數。
為了避免模塊重復初始化,初始化函數并不直接調用,而是間接的。類似這樣: mod_using(foo_init);
mod_using 負責調用初始化函數,并保證不重復調用,也可以檢查循環依賴。
在這里,我們還約定了初始化成功于否的返回值。(在我們的系統中,返回 0 表示正確,1 表示失?。┤缓蠖x了一個宏來做這個使用。
- #define USING(m) if (mod_using(m##_init,#m)) { return 1; }
注:我個人反對濫用宏。也盡可能的避免它。這里使用宏,經過了慎重的考慮。我希望可以有一個代碼掃描器去判斷我是否漏掉了模塊初始化(可能我使用了一個模塊,但忘記初始化它)。宏可以幫助代碼掃描分析器更容易實現。而且,使用宏更像是對語言做的輕微且必要的擴展。
這樣,我的系統中模塊模塊的實現代碼最后,都有一個 init 函數,里面只是簡單的調用了 USING 來引用別的模塊。例如:
- #include "module.h"
- /*
- 我個人偏愛把 module.h 的引入放在源文件最后,初始化入口之前。
- 它里面之定義了 USING 宏,以及相關管理函數。
- 這樣做是為了避免在代碼的其它地方去引入別的模塊。
- */
- int
- foo_init()
- {
- USING(memory); // 引用內存管理模塊
- USING(log); // 引用 log 模塊
- return 0;
- }
至于模塊的卸載,大部分需求下是不需要的。今天在這里就不論證這一點了。