誰是真泛型


來自: SegmentFault

作者:garfileo

鏈接:https://segmentfault.com/a/1190000004171424


前兩天寫了篇表面上是批判 C++ 泛型但實際上只是自己的一點點反思的文章,目的只是說服自己以及那些像我一樣被 C++ 折磨的欲仙欲死的人,以後不要再在 C++ 這門複雜不堪的語言的太多細枝末節之處燃燒生命,只從中取出自己需要的一個子集慢慢消化即可,只不過我採用了粗暴的方式——砍掉我認爲不需要的,剩下的就是我需要的。可能那篇文章中對 C++ 譏誚之意過重,而且文章也過長甚至有些水,沒能突出我真正想表達的那些東西,結果導致與一位看我不順眼的哥們發生了一次不愉快的爭論。事後,我將那篇文章刪掉了。現在重新整理一下我的觀點,我會盡量嚴肅。但是依然要事先聲明一下,我不是任何一種語言的專家,在此只是表達一下個人的喜好……姑妄言之,姑妄聽之。


代碼膨脹


C++ 的泛型編程是基於模板實現的,而 C++ 的模板採用的是代碼膨脹技術。例如 std::list 容器,如果你將 int 類型的數據存進去,C++ 編譯器就爲你生成一個專門用來存 int 類型數據的列表數據結構。也就是說,你向 std::list 容器中存放什麼類型,C++ 編譯器就爲你生成相應的列表數據結構。理論上,數據的類型是無限的,因此 C++ 要生成的列表數據結構也是無限的。如果你的程序中有大量的數據類型要存到 std::list 容器,那麼代碼就會高度膨脹,這種膨脹是 C++ 編譯器在目標文件連接階段無法優化的。


現實中,可能你沒經歷過模板引起的代碼膨脹問題,所以對此不以爲然。我也沒經歷過,因爲我屬於幾乎不寫 C++ 代碼並且幾乎不關注 C++ 世界都發生了什麼的那種人。沒見過,不等於沒有。我看到的一本講 C++ 模板編程的書(擔心有人再認爲我將一本國產書視爲聖經,書名我就不提了)裏提到應用 boost::spirit 時很容易出現代碼極度膨脹的情況,類似的事在 [1] 中也提到了。


《Effective C++》的作者可能見過代碼膨脹的例子,所以他在條款 44 中建議『將與參數無關的代碼抽離 templates』。這個條款也許是 C++ 應對模板導致的代碼膨脹問題的唯一解決方案了,然而這個方案往往並不是那麼容易實現。你需要仔細審度你的代碼,認真的從模板類(或模板函數)中將那些不涉及模板參數的代碼抽離出來做成基類(或輔助函數)。即使你能很好的做到這一點,但是請認真想一想,這樣做真的有意義麼?


模板技術原本是爲了簡化編程任務而被提出來的,但是要消除模板帶來的代碼膨脹,你不得不對本來邏輯很清晰的代碼進行肢解再重新整合,這個過程或多或少的會破壞甚至扭曲原有的代碼邏輯,結果弄出來一個渾身插着電源線的怪獸般的模板類或模板函數。


C++ 模板代碼所導致的膨脹,主要帶來以下問題:


源代碼膨脹了,因爲程序猿要做『將與參數無關的代碼從模板中抽離』這件事。有人做過試驗,即使是一個不太大的 List 實現,將代碼從模板中抽離後,導致源代碼膨脹了 20%……其實開發效率也自然降低了很多。


編譯時間被拖長了,因爲編譯器在代碼編譯階段要對模板代碼進行『惰性計算』,要產生模板的實例代碼,在目標文件連接階段還要消除各個目標文件中重複的模板代碼。


目標文件膨脹了。有人說他用 boost::spirit 實現了一個很小的語法解析器,開了 GCC 的最大化優化選項,目標文件也要幾十 MB,而一個 Lua 或 Python 解釋器還不到 1 MB,Haskell 的解釋器 ghc 剛 1 MB 多一點……


模板代碼中如果存在錯誤,編譯器產生的錯誤信息也膨脹了,特別是模板類的嵌套嵌套再嵌套,或者模板實例非常多的時候,編譯出錯信息無法卒讀,甚至有人說編譯出錯信息甚至超出了他用的文本編輯器的緩存空間大小。


類型擦除


兩天前,我不知道類型擦除是個什麼東西,只是看了 Vala 語言 所實現的泛型之後才知道這個概念。因爲 Vala 語言是編譯到 C 的,所以很容易看到它的泛型是如何實現的。


下面是 Vala 模板類的示例:


public class Wrapper : GLib.Object {

 private G data;

 public void set_data(G data) {

 this.data = data;

 }

 public G get_data() {

 return this.data;

 }

 }

void main() {

 var wrapper_str = new Wrapper();

 wrapper_str.set_data("test");

 var s = wrapper_str.get_data();

var wrapper_int = new Wrapper();

 wrapper_int.set_data(100);

 var n = wrapper_int.get_data();

 }


泛型之處在於:


private G data;

 

wrapper_str.set_data("test");

var s = wrapper_str.get_data();

 

wrapper_int.set_data(100);

var n = wrapper_int.get_data();


上述代碼片段,會被 Vala 編譯器編譯爲下面的 C 代碼:


gpointer data;  

/* gpointer 類型就是 void * 類型 */

 

wrapper_set_data (wrapper_str, "test");

_tmp1_ = wrapper_get_data (wrapper_str);

s = (gchar*) _tmp1_;

 

wrapper_set_data (wrapper_int, (gpointer) ((gintptr) 100));

_tmp3_ = wrapper_get_data (wrapper_int);

n = (gint) ((gintptr) _tmp3_);


如果不打算看懂這些代碼也沒關係。簡單的說,Vala 的模板或泛型就是基於 void * 指針的強制類型轉換。 C 語言要模擬泛型編程,最自然的方式就是程序猿手動對 void * 進行類型轉換,GLib 庫中的所有數據容器都是這麼做出來的。由於 Vala 編譯器會對模板參數進行類型檢查,因此基本上不需要擔心 void * 的強制類型轉換會導致類型不安全的問題。後來,看了幾篇 Java 泛型的文檔,才知道原來 Vala 的這個做法叫『類型擦除』。


類型擦除的最大特點是沒有什麼東西會膨脹,因爲一個模板的全部實例會共享同一份代碼。


誰是真泛型?


很多人說 Java 的泛型是僞泛型,那麼 Vala 的泛型自然也是僞泛型了。也許我的世界觀有問題,我總覺得類型擦除纔是真的泛型,因爲它能真實的模擬現實中的『泛型』。


現實中,我們所謂的泛型,例如一個登山包,你可以用它來裝任何它能裝得下的東西。你去驢行時,登山包裏可以裝水杯、書籍、手機/平板、充電器、帳篷、睡袋、救生用品等等;如果你不是去旅遊,而是去逛超市,依然可以用這個登山包將所買的東西帶回家。你肯定不會揹着一大堆包去旅遊或者去逛超市,其中裝水杯包的叫水杯包,裝手機的包叫手機包,裝平板的包叫平板包,裝麪包的包叫麪包……而且這些包都跟登山包差不多大——在 C++ 中,你所生成的程度必須揹着這樣的一大堆包去驢行或逛超市。


從 C++ 11 開始,有右值引用了,模板變得比以前更好用了。在 C++ 14 中,連匿名函數也支持泛型了……我覺得 C++ 模板所帶來的代碼膨脹遲早會走進尋常百姓家的。


事實上,Boost 庫中的一些容器已經引入了類型擦除技術[2],例如 boost::any, boost::variant, boost::function 等等。雖然它們採用類型擦除技術的本意並非針對模板代碼膨脹問題,只是一種模擬,而且依然存在着模板代碼膨脹的問題。很久以前還看過一篇論文,名字忘記了,講的是如何在 C++ 中利用類型擦除技術來調和麪向對象編程與泛型編程之間的矛盾的。在 C++ 社區,類型擦除技術絕對是很高級的技術,之所以如此窮折騰,真的不是因爲 C++ 編譯器不支持類型擦除的緣故嗎?


C++ 中的類型擦除技術是基於模板模擬出來的,其基本原理就是將類模板轉化爲函數模板[3]。C++ 編譯器能夠自動推導出函數模板參數的實例,從而讓程序猿在寫代碼的時候無需設定模板參數,再借助運行時類型識別(RTTI)或函數模板取出被擦除了類型的數據。從本質上來說,這種類型擦除技術依然無法避免模板的膨脹,但是這個模擬過程已經將大部分與模板參數無關的代碼抽離了出來。


有趣的是,《C++ Primer》第四版的中文譯本在第 16 章『模板與泛型編程』中的導言部分很不嚴肅的將泛型編程定義爲『以獨立於任何特定類型的方式編寫代碼』。難道真的泛型不應該是以獨立於任何特定類型的方式去編寫獨立於特定類型的代碼麼?如果 C++ 模板真的適合做編寫獨立於特定類型的代碼這樣的事,那麼就不需要去將與參數無關的代碼從模板中抽離出來了,也不需要有運算符重載、Traits 類、模板特化與偏特化等補救機制了(一直都感覺 C++ 太擅長解決那些它自身製造出來的問題了)。《C++ Primer》第 5 版的『模板與泛型編程』章的導言部分已將這個不嚴肅的泛型編程定義去掉了。


泛型的敵人


Vala 語言除了 GNOME 開發者之外沒有多少人用,所以它是真泛型還是僞泛型,對這個世界幾乎沒有影響。


Java 的泛型引起的問題已經廣爲人知 [4-6],而且也因此獲得『僞泛型』的僞大稱號。但是,我覺得他們所說的 Java 泛型所引起的那些問題是面向對象編程範式引起的。因爲他們所指出的那些問題,往往是在面向對象編程範式中使用泛型編程範式的場景中出現的。如果類型擦除真的不行,那麼 Java 是如何實現了它的『STL』的?連 Vala 這種微不足道的小語言也實現了一些『STL』容器。


面向對象編程範式與泛型編程範式是矛盾的,熟悉 C++ STL 的人應該知道這個事實。


STL 之父 Alexander Stepanov 是反面向對象編程範式的。他在 1995 年的一次訪談[7]中說:『STL 不是面向對象的。我認爲面向對象和人工智能差不多,都是個騙局……我發現面向對象編程在技術上是錯誤的,它妄圖用基於單一類型的不同接口來分解世界,爲了處理不同的實際問題你需要不同種類的代數學——橫跨不同類型的接口族;我發現面向對象編程在哲學上是錯誤的,它聲稱一切都是一個對象。即使真的是這樣這也不是很有趣─說一切都是對象跟什麼都沒說一樣;我發現面向對象編程的方法論是錯誤的,它從類開始。就好像數學要從公理開始一樣。你不是從公理開始——你是從證明開始。直到你找到了一大堆相關證據你才能歸納出公理。你是以公理結束。編程上存在着同樣的事實:你要從有趣的算法開始。只有很好地理解了算法,你纔有可能提出接口以讓其工作。』


雖然 Alexander Stepanov 說的挺精彩,然而 STL 庫裏依然有一些類的繼承,例如五種迭代器之間的關係;應該將 Alexander Stepanov 的話理解爲他反對的是編程工作從類的設計開始。如果將很矛盾的兩種世界觀體混在在代碼中,出現了衝突,這難道不是很正常麼?爲何要將這種矛盾歸罪於類型擦除?C++ 模板之所以被大家視爲真泛型,無非是因爲 C++ 模板本來也是從面向對象編程範式中誕生的。用模板膨脹出一堆重複的代碼,這種方式與面向對象編程範式中的類的派生如出一轍,這也恰恰就是 STL 之父所反對的『數學要從公理開始』。


泛型的世界是平坦的,沒有繼承,沒有多態,例如你不能在自己的代碼中去繼承 STL 容器。我覺得 STL 的精華之處並不在與它提供了許多有用的數據容器,而在於容器、迭代器與算法這三者處於一個平坦的世界,並且被優美的組合了起來。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章