C++ Type Erasure: 解決 C++ 模板 Code Bloat 問題
目錄
The Fundamental Software Engineering Rule: Any software engineering problems can be solved by adding another layer.
這篇文章是我學習 C++ type erasure 的筆記,其中主要權衡了「程式碼生成」和「型別擦除」這兩種泛型方式的優劣,但沒有具體給出怎樣在 C++ 中實現。這是因為實現方法已經在 C++ Templates 一書中詳細地給出了。
兩種泛型策略:「程式碼生成」和「型別擦除」
C++ 中常用的泛型(模板),其基本思想是在模板中把某些型別當作一個待填充的引數 [1],而在模板例項化的時候,把相應型別填充到模板型別引數被使用的位置上,從而生成一個獨特的具體的型別。換句話說,一個 C++ 模板並不是一個具體的型別,而是用來生成型別的「藍圖」。使用時,把模板引數填充進去,編譯器就按照這個藍圖為我們生成相應型別(注意每個函式和類都是獨立的型別)。從這一點上看,std::vector<int>
std::vector<std::string>
是兩個不同的型別。即使它們之間共用一些邏輯,C++ 也會分別生成兩套不相關的程式碼。在這篇文章中,我把這種策略簡稱為「程式碼生成」。
另一種實現泛型的方式是「型別擦除」(Type Erasure)。Java 語言的泛型使用的便是這種方法。它的基本思想是使用一個「通用」的基本型別實現通用邏輯,而遇到型別相關的操作 [2] 時再把這個「通用型別」轉換為具體的型別。Java 語言中幾乎所有物件都繼承自 Object
,所以天然地方便使用「型別擦除」來實現泛型。
如果你使用過 C 語言,或許會對 void*
有印象。為了在 C 語言中實現泛型,我們在通用的邏輯裡使用 void*
<stdlib.h>
裡的 qsort
。它接受 void*
型別的引數等實現通用邏輯,而使用者需要提供一個 cmp
函式,用來進行具體的「型別相關」的比較。
#include <string.h> #include <stdlib.h> // An example of comparing two C-style strings. int cmp(void const* lhs_, void const* rhs_) { char const* lhs = (char const*)lhs_; char const* rhs = (char const*)rhs_; return strcmp(lhs, rhs); }
當然 C 中這種泛型方式很粗糙,也很易錯(你能說清 qsort
二維陣列的原理嗎?)。而在如 Java 這樣的語言中,編譯器可以自動實現泛型型別轉換相關的邏輯。比如,對 ArrayList<String>
中的某個 String
呼叫 append
,第一步是 ArrayList
中的泛型程式碼(通用程式碼)把 Object
的引用轉成 String
的引用(這一步由 Java 編譯器為我們代勞了,不需要像 C 那樣手動);第二步是呼叫 String
的成員函式。可以用下面的示例來理解:
ArrayList primitiveArr = getStringArrayList();
ArrayList<String> autoConvertedArr = getStringArrayList();
// get the 2nd element in the ArrayList (It is de facto a reference to an Object, not String)
// and convert it to String.
String str = (String)primitiveArr.get(1);
// And then append a character
str.append('a');
// The above two steps can be written in this:
autoConvertedArr.get(1).append('a');
我不打算具體講 Java 中泛型特性如何實現和使用,只是為了說明型別擦除的大概原理。
[1]:C++ 模板引數也可以不是型別,這裡是為了說明方便。
[2]:型別相關的操作,指需要些型別滿足的特性。比如,看下面這個泛型函式:
template <typename T> T add(T a, T b) { puts("Calling .add(T rhs) in generic function"); // (1) return a.add(b); // (2) }
此處型別相關的操作就是 (2) 處的
.add(T rhs)
。型別T
必須有一個名為add
的成員函式,且接受一個T
型別的引數。在型別擦除的泛型策略中,此處就必須要把a
和b
轉換為具體型別T
,然後呼叫.add(T rhs)
。而 (1) 就是共用的型別無關的程式碼。在 C++ 中,這條語句在每個add<T>
中都會被生成一份,而在型別擦除的泛型策略中不會有這種 overhead。我在上面的例子中忽略了左右引用、
const volatile
等細節。
兩種策略的比較
如果語言沒有泛型支援,那麼我們可能需要為雷同的邏輯實現兩份程式碼:
void swap_int(int* __restrict__ lhs, int* __restrict__ rhs) {
if (lhs == rhs) { return; }
auto temp = *lhs;
*lhs = *rhs;
*rhs = temp;
}
// Let's ignore the member function swap of std::string
void swap_string(std::string* __restrict__ lhs, std::string* __restrict__ rhs) {
if (lhs == rhs) { return; }
auto temp = std::move(*lhs);
*lhs = std::move(*rhs);
*rhs = std::move(temp);
}
This is tedious。使用 C++ 模板可以只寫一份程式碼:
// Note that this is rather coarse
template <typename T>
void swap_string(T* __restrict__ lhs, T* __restrict__ rhs) {
if (lhs == rhs) { return; }
auto temp = std::move(*lhs);
*lhs = std::move(*rhs);
*rhs = std::move(temp);
}
為了方便寫模板程式碼,C++ 中對
int
等棧上原始型別也可以取右值引用等,雖然這沒什麼特殊效果,但免去了寫特例的麻煩
C++ 編譯器會為 int
和 std::string
分別生成對應的程式碼,最終得到的可執行檔案裡的程式碼與第一個塊中手寫的差別不會很大。
像這樣的「程式碼生成」的好處有:
- 最終生成的程式碼裡不會產生從引數
T
到具體型別的轉換——可以避免這個過程中出現的異常等。當然,對於內建型別,我們沒有這種擔憂。 - 型別資訊得到保留,在繼承等方面有一定優勢(可以保留子類向基類轉換的特性等;如果擦除了型別,一個
Array<Derived>
無法得知其中元素是否能轉為Base
)。但這也帶來一些問題,我將在下面進行討論。 - 方便型別過載。如果我們希望為
swap<int>
和swap<std::string>
提供不同的實現,只需要對模板特化(詳情可以查詢相關資料)即可。
程式碼生成的最明顯的壞處就是「程式碼膨脹」(Code Bloat) 了。即使是相同的邏輯,C++ 也會為其生成獨立的程式碼。對於 swap 這樣的小函式,問題不大(事實上基本沒有 overhead,因為此函式內很大一部分程式碼都是型別相關的)。但如果是較大的函式,且型別無關的程式碼較多,那麼將生成巨量的無用程式碼(overhead),便程式體積增大,甚至影響快取效能。
其它問題包括編譯速度問題和原始碼可見性的問題。如果使用 C++ 模板編寫庫,那麼庫的使用者必須能夠看到模板原始碼;因為模板只是一份藍圖,需要把使用處的具體型別(如 std::string
)填充進模板去編譯,所以模板程式碼不能封裝。C++ 嘗試過一些如 export
的機制,但最終不了了之。
而型別擦除只會為每一個泛型型別生成一套程式碼,而不是生成多份。這使得編譯過程變得簡單,也使得模板提前編譯成為可能。libc
庫中的 qsort
等就是一個例子(它們可能是以已編譯的形式提供,標頭檔案裡只有函式宣告)。它的靈魂在於:通用的邏輯中使用通用的型別,而涉及到型別相關的邏輯時又使用具體型別。
這種特性還使得模板可以應用在虛擬函式上。眾所周知,虛擬函式的呼叫是在「執行時」決定的。在編譯時,我們不能知道到底呼叫了哪個虛擬函式。然而,C++ 模板又必須在編譯時生成,所以 C++ 模板不能應用在虛擬函式上(不然就會在執行時呼叫不存在的函式)。使用型別擦除實現泛型的 Java 則沒有這個問題。
但是,型別擦除在效率上不如程式碼生成。這不僅僅是因為型別轉換(事實上這個開銷可能較小),更是因為我們無法在這種泛型程式碼中直接使用棧變數:
template <typename T>
void func(T a) {
T temp; // How much space does this occupy?
}
假設 C++ 泛型使用型別擦除的方法實現,那麼生成的唯一的通用程式碼中,temp
應該佔多大空間?更具體的,temp
應該如何構造?如何析構?這將成為不可能完成的任務。換句話說,型別擦除的泛型幾乎必然使用間接訪問方法和堆記憶體(指標、引用等)。所以,這種策略更適合用在經典的如 Java 那樣的 OOP 體系上。
Java 的函式引數只能是引用,於是根本不存在這個問題了
在 C++ 中,我們可以通過一些手段(加中間層),從而以型別擦除的方法實現泛型。這部分詳見 C++ Templates 相關章節。
使用型別擦除的場景一般如下:
- 程式碼膨脹問題很嚴重。我們不希望重量級的程式碼被反覆生成,這時可以使用型別擦除。舉個例子,
std::function
就是使用型別擦除的方法實現的。這是因為函式可能有很多種,甚至是lambda
和可呼叫物件(這種物件可能很重型)。為這些每種型別都生成一份程式碼的代價不可忽略。 - 支援動態語言特性。比如
std::any
等。