地球人都知道C++裡有一個typeid操作符可以用來獲取一個類型/表達式的名稱:
std::cout << typeid(int).name() << std::endl;
但是這個name()的返回值是取決於編譯器的,在vc和gcc中打印出來的結果如下:
int // vc i // gcc
一個稍微長一點的類型名稱,比如:
class Foo {}; std::cout << typeid(Foo*[10]).name() << std::endl;
打出來是這個效果:
class Foo * [10] // vc A10_P3Foo // gcc
(話說gcc您的返回結果真是。。)
當然了,想在gcc裡得到和微軟差不多顯示效果的方法也是有的,那就是使用__cxa_demangle:
char* name = abi::__cxa_demangle(typeid(Foo*[10]).name(), nullptr, nullptr, nullptr); std::cout << name << std::endl; free(name);
顯示效果:
Foo* [10]
先不說不同編譯器下的適配問題,來看看下面這個會打印出啥:
// vc std::cout << typeid(const int&).name() << std::endl; // gcc char* name = abi::__cxa_demangle(typeid(const int&).name(), nullptr, nullptr, nullptr); std::cout << name << std::endl; free(name);
顯示效果:
int // vc int // gcc
可愛的cv限定符和引用都被丟掉了=.=
如果直接在typeid的結果上加上被丟棄的信息,對於一些類型而言(如函數指針引用)得到的將不是一個正確的類型名稱。
想要獲得一個類型的完整名稱,並且獲得的名稱必須要是一個正確的類型名稱,應該怎樣做呢?
我們需要一個泛型類,用特化/偏特化機制靜態檢查出C++中的各種類型,並且不能忽略掉類型限定符(type-specifiers)和各種聲明符(declarators)。
先來考慮一個最簡單的類模板:
templatestruct check { // ... };
假如在它的基礎上特化,需要寫多少個版本呢?我們可以稍微實現下試試:
templatestruct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; template struct check ; // ......
這還遠遠沒有完。有同學可能會說了,我們不是有偉大的宏嘛,這些東西都像是一個模子刻出來的,弄一個宏批量生成下不就完了。
實際上當我們真的信心滿滿的動手去寫這些宏的時候,才發現適配上的細微差別會讓宏寫得非常痛苦(比如&和*的差別,[]和[N]的差別,還有函數類型、函數指針、函數指針引用、函數指針數組、類成員指針、……)。當我們一一羅列出需要特化的細節時,不由得感歎C++類型系統的復雜和糾結。
但是上面的理由並不是這個思路的致命傷。
不可行的地方在於:我們可以寫一個多維指針,或多維數組,類型是可以嵌套的。總不可能為每一個維度都特化一個模板吧。
不過正由於類型其實是嵌套的,我們可以用模板元編程的基本思路來搞定這個問題:
templatestruct check : check ; template struct check : check ; template struct check : check ; template struct check : check ; template struct check : check ; template struct check : check ; // ......
一個簡單的繼承,就讓特化變得simple很多。因為當我們萃取出一個類型,比如T *,之後的T其實是攜帶上了除*之外所有其他類型信息的一個類型。那麼把這個T再重復投入check中,就會繼續萃取它的下一個類型特征。
可以先用指針、引用的萃取來看看效果:
#includetemplate struct check { check(void) { std::cout << typeid(T).name(); } ~check(void) { std::cout << std::endl; } }; #define CHECK_TYPE__(OPT) \ template \ struct check : check \ { \ check(void) { std::cout << " "#OPT; } \ }; CHECK_TYPE__(const) CHECK_TYPE__(volatile) CHECK_TYPE__(const volatile) CHECK_TYPE__(&) CHECK_TYPE__(&&) CHECK_TYPE__(*) int main(void) { check (); system("pause"); return 0; }
輸出結果(vc):
void const volatile * const * &
很漂亮,是不是?當然,在gcc裡這樣輸出,void會變成v,所以gcc下面要這樣寫check模板:
templatestruct check { check(void) { char* real_name = abi::__cxa_demangle(typeid(T).name(), nullptr, nullptr, nullptr); std::cout << real_name; free(real_name); } ~check(void) { std::cout << std::endl; } };
我們可以簡單的這樣修改check讓它同時支持vc和gcc:
templatestruct check { check(void) { # if defined(__GNUC__) char* real_name = abi::__cxa_demangle(typeid(T).name(), nullptr, nullptr, nullptr); std::cout << real_name; free(real_name); # else std::cout << typeid(T).name(); # endif } ~check(void) { std::cout << std::endl; } };
但是到目前為止,check的輸出結果都是無法保存的。比較好的方式是可以像typeid(T).name()一樣返回一個字符串。這就要求check能夠把結果保存在一個std::string對象裡。
當然了,我們可以直接給check一個“std::string& out”類型的構造函數,但是這樣會把輸出的狀態管理、字符的打印邏輯等等都揉在一起。因此,比較好的設計方法是實現一個output類,負責輸出和維護狀態。我們到後面就會慢慢感覺到這樣做的好處在哪裡。
output類的實現可以是這樣:
class output { bool is_compact_ = true; templatebool check_empty(const T&) { return false; } bool check_empty(const char* val) { return (!val) || (val[0] == 0); } template void out(const T& val) { if (check_empty(val)) return; if (!is_compact_) sr_ += " "; using ss_t = std::ostringstream; sr_ += static_cast (ss_t() << val).str(); is_compact_ = false; } std::string& sr_; public: output(std::string& sr) : sr_(sr) {} output& operator()(void) { return (*this); } template output& operator()(const T1& val, const T&... args) { out(val); return operator()(args...); } output& compact(void) { is_compact_ = true; return (*this); } };
這個小巧的output類負責自動管理輸出狀態(是否增加空格)和輸出的類型轉換(使用std::ostringstream)。
上面的實現裡有兩個比較有意思的地方。
一是operator()的做法,采用了變參模板。這種做法讓我們可以這樣用output:
output out(str); out("Hello", "World", 123, "!");
這種寫法比cout的流操作符舒服多了。
二是operator()和compact的返回值。當然,這裡可以直接使用void,但是這會造成一些限制。
比如說,我們想在使用operator()之後馬上compact呢?若讓函數返回自身對象的引用,就可以讓output用起來非常順手:
output out(str); out.compact()("Hello", "World", 123, "!").compact()("?");
check的定義和CHECK_TYPE__宏只需要略作修改就可以使用output類:
templatestruct check { output out_; check(const output& out) : out_(out) { # if defined(__GNUC__) char* real_name = abi::__cxa_demangle(typeid(T).name(), nullptr, nullptr, nullptr); out_(real_name); free(real_name); # else out_(typeid(T).name()); # endif } }; #define CHECK_TYPE__(OPT) \ template \ struct check : check \ { \ using base_t = check ; \ using base_t::out_; \ check(const output& out) : base_t(out) { out_(#OPT); } \ };
為了讓外部的使用依舊簡潔,實現一個外敷函數模板是很自然的事情:
templateinline std::string check_type(void) { std::string str; check { str }; return std::move(str); } int main(void) { std::cout << check_type () << std::endl; system("pause"); return 0; }
如果我們想實現表達式的類型輸出,使用decltype包裹一下就行了。
不知道看到這裡的朋友有沒有注意到,check在gcc下的輸出可能會出現問題。原因是abi::__cxa_demangle並不能保證永遠返回一個有效的字符串。
我們來看看這個函數的返回值說明:
“Returns: A pointer to the start of the NUL-terminated demangled name, or NULL if the demangling fails. The caller is responsible for deallocating this memory using free.”
所以說比較好的做法應該是在abi::__cxa_demangle返回空的時候,直接使用typeid(T).name()的結果。
一種健壯的寫法可以像這樣:
templatestruct check { output out_; check(const output& out) : out_(out) { # if defined(__GNUC__) const char* typeid_name = typeid(T).name(); auto deleter = [](char* p) { if (p) free(p); }; std::unique_ptr real_name { abi::__cxa_demangle(typeid_name, nullptr, nullptr, nullptr), deleter }; out_(real_name ? real_name.get() : typeid_name); # else out_(typeid(T).name()); # endif } };
上面我們通過使用std::unique_ptr配合lambda的自定義deleter,實現了一個簡單的Scope Guard機制,來保證當abi::__cxa_demangle返回的非NULL指針一定會被free掉。
上面的特化解決了cv限定符、引用和指針,甚至對於未特化的數組、類成員指針等都有還不錯的顯示效果,不過卻無法保證輸出的類型名稱一定是一個有效的類型定義。比如說:
check_type(); // int [] *
原因是因為這個類型是一個指針,指向一個int[],所以會先匹配到指針的特化,因此*就被寫到了最後面。
對於數組、函數等類型來說,若它們處在一個復合類型(compound types)中“子類型”的位置上,它們就需要用括號把它們的“父類型”給括起來。
因此我們還需要預先完成下面這些工作:
上面的第1點,可以利用模板偏特化這種靜態的判斷來解決。比如說,給check添加一個默認的bool模板參數:
templatestruct check { // ... }; #define CHECK_TYPE__(OPT) \ template \ struct check : check \ { \ using base_t = check ; \ using base_t::out_; \ check(const output& out) : base_t(out) { out_(#OPT); } \ };
這個小小的修改就可以讓check在繼承的時候把父-子信息傳遞下去。
接下來先考慮圓括號的輸出邏輯。我們可以構建一個bracket類,在編譯期幫我們自動處理圓括號:
// () templatestruct bracket { output& out_; bracket(output& out, const char* = nullptr) : out_(out) { out_("(").compact(); } ~bracket(void) { out_.compact()(")"); } }; template <> struct bracket { bracket(output& out, const char* str = nullptr) { out(str); } };
在bracket裡,不僅實現了圓括號的輸出,其實還實現了一個編譯期if的小功能。當不輸出圓括號時,我們可以給bracket指定一個其它的輸出內容。
當然,不實現bracket,直接在check的類型特化裡處理括號邏輯也可以,但是這樣的話邏輯就被某個check特化綁死了。我們可以看到bracket的邏輯被剝離出來以後,後面所有需要輸出圓括號的部分都可以直接復用這個功能。
然後是[]的輸出邏輯。考慮到對於[N]類型的數組,還需要把N的具體數值輸出來,因此輸出邏輯可以這樣寫:
// [N] templatestruct bound { output& out_; bound(output& out) : out_(out) {} ~bound(void) { if (N == 0) out_("[]"); else out_("[").compact() ( N ).compact() ("]"); } };
輸出邏輯需要寫在bound類的析構,而不是構造裡。原因是對於一個數組類型,[N]總是寫在最後面的。
這裡在輸出的時候直接使用了運行時的if-else,而沒有再用特化來處理。是因為當N是一個編譯期數值時,對於現代的編譯器來說“if (N == 0) ; else ;”語句會被優化掉,只生成確定邏輯的匯編碼。
最後,是函數參數的輸出邏輯。函數參數列表需要使用變參模板適配,用編譯期遞歸的元編程手法輸出參數,最後在兩頭加上括號。
我們可以先寫出遞歸的結束條件:
templatestruct parameter; template struct parameter { output& out_; parameter(output& out) : out_(out) {} ~parameter(void) { bracket { out_ }; } };
輸出邏輯寫在析構裡的理由,和bound一致。結束條件是顯然的:當參數包為空時,parameter將只輸出一對括號。
注意到模板的bool類型參數,讓我們在使用的時候需要這樣寫:
parameter parameter_;
這是因為bool模板參數混在變參裡,指定默認值也是沒辦法省略true的。
稍微有點復雜的是參數列表的輸出。一個簡單的寫法是這樣:
templatestruct parameter { output& out_; parameter(output& out) : out_(out) {} ~parameter(void) { bracket bk { out_, "," }; (void)bk; check { out_ }; parameter { out_.compact() }; } };
parameter在析構的時候,析構函數的scope就是bracket的影響范圍,後面的其它顯示內容,都應該被包括在bracket之內,因此bracket需要顯式定義臨時變量bk;
check的調用理由很簡單,因為我們需要顯示出每個參數的具體類型;
最下面是parameter的遞歸調用。在把out_丟進去之前,我們需要思考下具體的顯示效果。是希望打印出(P1, P2, P3)呢,還是(P1 , P2 , P3)?
在這裡我們選擇了逗號之前沒有空格的第一個版本,因此給parameter傳遞的是out_.compact()。
對parameter的代碼來說,看起來不明顯的就是bracket的作用域了,check和parameter的調用其實是被bracket包圍住的。為了強調bracket的作用范圍,同時規避掉莫名其妙的“(void)bk;”手法,我們可以使用lambda表達式來凸顯邏輯:
templatestruct parameter { output& out_; parameter(output& out) : out_(out) {} ~parameter(void) { [this](bracket &&) { check { out_ }; parameter { out_.compact() }; } (bracket { out_, "," }); } };
這樣bracket的作用域一目了然,並且和check、parameter的定義方式保持一致,同時也更容易看出來out_.compact()的意圖。
好了,有了上面的這些准備工作,寫一個check的T[]特化是很簡單的:
templatestruct check : check { using base_t = check ; using base_t::out_; bound<> bound_; bracket bracket_; check(const output& out) : base_t(out) , bound_ (out_) , bracket_(out_) {} };
這時對於不指定數組長度的[]類型,輸出結果如下:
check_type(); // int (*) []
當我們開始興致勃勃的接著追加[N]的模板特化之前,需要先檢查下cv的檢查機制是否運作良好:
check_type();
嘗試編譯時,gcc會給我們吐出一堆類似這樣的compile error:
error: ambiguous class template instantiation for 'struct check' check { str }; ^
檢查了出錯信息後,我們會驚訝的發現對於const int[]類型,竟然可以同時匹配T const和T[]。
這是因為按照C++標准ISO/IEC-14882:2011,3.9.3 CV-qualifiers,第5款:
“Cv-qualifiers applied to an array type attach to the underlying element type, so the notation “cv T,” where T is an array type, refers to an array whose elements are so-qualified. Such array types can be said to be more (or less) cv-qualified than other types based on the cv-qualification of the underlying element types.”
可能描述有點晦澀,不過沒關系,在8.3.4 Arrays的第1款最下面還有一行批注如下:
“[ Note: An “array of N cv-qualifier-seq T” has cv-qualified type; see 3.9.3. —end note ]”
意思就是對於const int[]來說,const不僅屬於數組裡面的int元素所有,同時還會作用到數組本身上。
所以說,我們不得不多做點工作,把cv限定符也特化進來:
#define CHECK_TYPE_ARRAY__(CV_OPT) \ template\ struct check : check \ { \ using base_t = check ; \ using base_t::out_; \ \ bound<> bound_; \ bracket bracket_; \ \ check(const output& out) : base_t(out) \ , bound_ (out_) \ , bracket_(out_) \ {} \ }; #define CHECK_TYPE_PLACEHOLDER__ CHECK_TYPE_ARRAY__(CHECK_TYPE_PLACEHOLDER__) CHECK_TYPE_ARRAY__(const) CHECK_TYPE_ARRAY__(volatile) CHECK_TYPE_ARRAY__(const volatile)
這樣對於加了cv屬性的數組而言,編譯和顯示才是正常的。
接下來,考慮[N],我們需要稍微修改一下上面的CHECK_TYPE_ARRAY__宏,讓它可以同時處理[]和[N]:
#define CHECK_TYPE_ARRAY__(CV_OPT, BOUND_OPT, ...) \ template\ struct check : check \ { \ using base_t = check ; \ using base_t::out_; \ \ bound bound_; \ bracket bracket_; \ \ check(const output& out) : base_t(out) \ , bound_ (out_) \ , bracket_(out_) \ {} \ }; #define CHECK_TYPE_ARRAY_CV__(BOUND_OPT, ...) \ CHECK_TYPE_ARRAY__(, BOUND_OPT, ,##__VA_ARGS__) \ CHECK_TYPE_ARRAY__(const, BOUND_OPT, ,##__VA_ARGS__) \ CHECK_TYPE_ARRAY__(volatile, BOUND_OPT, ,##__VA_ARGS__) \ CHECK_TYPE_ARRAY__(const volatile, BOUND_OPT, ,##__VA_ARGS__)
這段代碼裡稍微用了點“preprocessor”式的技巧。gcc的__VA_ARGS__處理其實不那麼人性化。雖然我們可以通過“,##__VA_ARGS__”,在變參為空時消除掉前面的逗號,但這個機制卻只對第一層宏有效。當我們把__VA_ARGS__繼續向下傳遞時,變參為空逗號也不會消失。
因此,我們只有用上面這種略顯抽搐的寫法來干掉第二層宏裡的逗號。這個處理技巧也同樣適用於vc。
然後,實現各種特化模板的時候到了:
#define CHECK_TYPE_PLACEHOLDER__ CHECK_TYPE_ARRAY_CV__(CHECK_TYPE_PLACEHOLDER__) #if defined(__GNUC__) CHECK_TYPE_ARRAY_CV__(0) #endif CHECK_TYPE_ARRAY_CV__(N, size_t N)
這裡有個有意思的地方是:gcc裡可以定義0長數組[0],也叫“柔性數組”。這玩意在gcc裡不會適配到T[N]或T[]上,所以要單獨考慮。
現在,我們適配上了所有的引用、數組,以及普通指針:
check_type這裡看起來有點不一樣的是多維數組的輸出結果,每個維度都被括號限定了結合范圍。這種用括號明確標明數組每個維度的結合優先級的寫法,雖然看起來不那麼干脆,不過在C++中也是合法的。(); // void const volatile * (&) [10] check_type (); // int (([1]) [2]) [3]
#define CHECK_TYPE_ARRAY__(CV_OPT, BOUND_OPT, ...) \ template\ struct check : check ::value> \ { \ using base_t = check ::value>; \ using base_t::out_; \ \ bound bound_; \ bracket bracket_; \ \ check(const output& out) : base_t(out) \ , bound_ (out_) \ , bracket_(out_) \ {} \ };
這裡使用了std::is_array來判斷下一層類型是否仍舊是數組,如果是的話,則不輸出括號。
有了前面准備好的parameter,實現一個函數的特化處理非常輕松:
templatestruct check : check { using base_t = check ; using base_t::out_; parameter parameter_; bracket bracket_; check(const output& out) : base_t(out) , parameter_(out_) , bracket_ (out_) {} };
這裡有一個小注意點:函數和數組一樣,處於被繼承的位置時需要加括號;parameter的構造時機應該在bracket的前面,這樣可以保證它在bracket之後被析構,否則參數列表將被添加到錯誤位置上。
我們可以打印一個變態一點的類型來驗證下正確性:
std::cout << check_type() << std::endl; // 輸出:char (* (* const) (int const (&) [10])) [10] // 這是一個常函數指針,參數是一個常int數組的引用,返回值是一個char數組的指針
我們可以看到,函數指針已經被正確的處理掉了。這是因為一個函數指針會適配到指針上,之後去掉指針的類型將是一個正常的函數類型。
這裡我們沒有考慮stdcall、fastcall等調用約定的處理,如有需要的話,讀者可自行添加。
類成員指針的處理非常簡單:
templatestruct check : check { using base_t = check ; using base_t::out_; check(const output& out) : base_t(out) { check { out_ }; out_.compact()("::*"); } };
顯示效果:
class Foo {}; std::cout << check_type() << std::endl; // 輸出:int (Foo::* const) [3] // 這是一個常類成員指針,指向Foo裡的一個int[3]成員
其實我們不用做什麼特別的處理,通過T C::*已經可以適配無cv限定符的普通類成員函數指針了。只是在vc下,提取出來的T卻無法適配上T(P...)的特化。
這是因為vc中通過T C::*提取出來的函數類型帶上了一個隱藏的thiscall調用約定。在vc裡,我們無法聲明或定義一個thiscall的普通函數類型,於是T C::*的特化適配無法完美的達到我們想要的效果。
所以,我們還是需要處理無cv限定的類成員函數指針。通過一個和上面T C::*的特化很像的特化模板,就可以處理掉一般的類成員函數指針:
templatestruct check : check { using base_t = check ; using base_t::out_; check(const output& out) : base_t(out) { check { out_ }; out_.compact()("::*"); } };
下面考慮帶cv限定符的類成員函數指針。在開始書寫後面的代碼之前,我們需要先思考一下,cv限定符在類成員函數指針上的顯示位置是哪裡?答案當然是在函數的參數表後面。所以我們必須把cv限定符的輸出時機放在T(P...)顯示完畢之後。
因此想要正確的輸出cv限定符,我們必須調整T(P...)特化的調用時機:
// Do output at destruct struct at_destruct { output& out_; const char* str_; at_destruct(output& out, const char* str = nullptr) : out_(out) , str_(str) {} ~at_destruct(void) { out_(str_); } void set_str(const char* str = nullptr) { str_ = str; } }; #define CHECK_TYPE_MEM_FUNC__(...) \ template\ struct check \ { \ at_destruct cv_; \ check base_; \ output& out_ = base_.out_; \ \ check(const output& out) \ : cv_(base_.out_) \ , base_(out) \ { \ cv_.set_str(#__VA_ARGS__); \ check { out_ }; \ out_.compact()("::*"); \ } \ }; CHECK_TYPE_MEM_FUNC__() CHECK_TYPE_MEM_FUNC__(const) CHECK_TYPE_MEM_FUNC__(volatile) CHECK_TYPE_MEM_FUNC__(const volatile)
上面這段代碼先定義了一個at_destruct,用來在析構時執行“輸出cv限定符”的動作;同時把原本處在基類位置上的T(P...)特化放在了第二成員的位置上,這樣就保證了它將會在cv_之後才被析構。
這裡要注意的是,at_destruct的構造在base_和out_之前,所以如果直接給cv_傳遞out_時不行的,這個時候out_還沒有初始化呢。但是在這個時候,雖然base_同樣尚未初始化,但base_.out_的引用卻是有效的,因此我們可以給cv_傳遞一個base_.out_。
另外,at_destruct雖然定義了帶str參數的構造函數,CHECK_TYPE_MEM_FUNC__宏中卻沒有使用它。原因是若在宏中使用#__VA_ARGS__作為參數,那麼當變參為空時,#__VA_ARGS__前面的逗號在vc中不會被自動忽略掉(gcc會忽略)。
最後,來一起看看輸出效果吧:
class Foo {}; std::cout << check_type() << std::endl; // 輸出:int (Foo::* const) (int, Foo &&, int) volatile // 這是一個常類成員函數指針,指向Foo裡的一個volatile成員函數
折騰C++的類型系統是一個很有意思的事情。當鑽進去之後就會發現,一些原先比較晦澀的基本概念,在研究的過程中都清晰了不少。
check_type的實用價值在於,可以利用它清晰的看見C++中一些隱藏的類型變化。比如完美轉發時的引用折疊:
class Foo {}; templateauto func(T&&) -> T; std::cout << check_type )>() << std::endl; std::cout << check_type )>() << std::endl; std::cout << check_type )>() << std::endl;
在上面實現check_type的過程中,用到了不少泛型,甚至元編程的小技巧,充分運用了C++在預處理期、編譯期和運行期(RAII)的處理能力。雖然這些代碼僅是學習研究時的興趣之作,實際項目中往往typeid的返回結果就足夠了,但上面的不少技巧對一些現實中的項目開發也有一定的參考和學習價值。
順便說一下:上面的代碼裡使用了大量C++11的特征。若想在老C++中實現check_type,大部分的新特征也都可以找到替代的手法。只是適配函數類型時使用的變參模板,在C++98/03下實現起來實在抽搐。論代碼的表現力和舒適度,C++11強過C++98/03太多了。
完整代碼及測試下載請點擊:check_type.zip
更多內容請訪問:http://darkc.at