侯捷C++ Type traits(类型萃取

时间:2022-10-29 13:23:26

泛型編程編出來的代碼,適用於任何「吻合某種條件限制」的資料型別。這已成為撰寫可復用代碼時的一個重要選擇。然而,總有一些時候,泛型不夠好 — 有時候是因為不同的型別差距過大,難以產生一致的泛化實作版本。這個時候 traits(品质,特性意思) 技術就變得相當重要。這種技術可以將那些需要被納入考量的型別性質以一種 type by type 的原則,封裝於一個 traits class 內,於是可以將「由於型別之間的差異,必須撰寫出來以備用」的代碼體積降至最低,並使泛用代碼的體積提昇到最高。

考慮一個例子:當我們處理字元字串(character strings)時,常見的一個操作行為就是決定「以 null 為結束符號」的字串的長度。很明顯我們不可能寫出一份泛型代碼取代眾所周知原本就存在的極有效率的解法:是的,C 標準函式 strlen 和 wcslen 通常是以組合語言完成,如果再加上適量的硬體支援,就能夠比 C++ 泛型版本有明顯的速度優勢。C++ 標準程式庫的作者了解這一點,所以他們抽取出 char 和 wchar_t 的性質,置於 class char_traits 內。於是,泛型代碼一旦處理字元字串,便可以簡單地使用char_traits<>::length 來決定一個「以 null 為結束符號」的字串的長度,並且很安心地確知 char_traits 的特化版本將採用最適當的方法來完成任務。

Type traits(型別特性)

Class char_traits 是「把一系列與型別相關的性質包裹於單一 class 之內」的典型例子,那正是 Nathan Myers 所謂的 baggage class [參考資料1]。在 Boost type-traits library 中,我們 [參考資料2] 完成了一組非常特別的 traits classes,其中每一個 classes 都封裝了 C++ 型別系統中的一個(僅僅一個)特性。所謂特性(trait)指的是,舉個例子,某型別是否為一個 pointer,或是一個 reference?某型別是否擁有一個 trivial constructor,或是擁有一個 const 修飾詞? 這些 type-traits classes 共同享有一致性的設計:每一個 class 都有一個 member value,那是一個編譯期常數,如果某型別擁有某種特性,此一常數的值就是 true,否則就是 false。稍後我將為你展示,這些 classes 可以被使用於泛型編程之中,用來決定某個型別的特性,並導入對應的最佳化措施。

Boost type-traits library 也內含一組 classes,可以針對某個型別執行專屬的特定的轉換。例如它們可以從某個型別身上移除一個上層的 const 或 volatile。每一個用來執行轉換的 class 都定義有一個 typedef-member type,那便是轉換結果。所有這些 type-traits classes 都被定義於 namespace boost 之中。為求簡化,本文的範例代碼大多省略命名空間的設定。

實作(Implementation)

要在這裡顯示 type-traits library 的所有實作內容,是不可能的,那真是太多太多了。如果你有這個需求,請看 Boost library 的源碼。大部份實作方法都是重複的,所以這裡我只給你一種風貌,為你示範這些 classes 如何實作出來。讓我們從程式庫中最簡單的一個 class 開始。is_void<T> 有一個 member value,如果 T 是 void,它就是 true。

template <typename T>
struct is_void
{ static const bool value = false; }; template <>
struct is_void<void>
{ static const bool value = true; };

在這裡,我們定義了 template class is_void 的一個主版本,並針對「T 是 void」的情況提供了一個全特化( full-specialisation)版。雖然 template class 的全特化是一項重要技術,但有時候我們需要的解決方案介於「完全泛化」和「完全特化」之間。這正是標準委員會之所以定義出偏特化(partial template-class specialisation)的原因。舉個例子,讓我們考慮 class boost::is_pointer<T>,這裡我們需要一個主版本,用來處理「T 不為指標」的所有情況,以及一個偏特化版本,用來處理「T 是指標」的情況:

template <typename T>
struct is_pointer
{ static const bool value = false; }; template <typename T>
struct is_pointer<T*>
{ static const bool value = true; };

偏特化的語法帶了點不可思議的味道,而且一談到它很容易就耗掉一整篇文章。就像全特化的情形一樣,為了針對某個 class 寫出一個偏特化版本,你首先必須宣告 template 主版本。偏特化版本在 class 名稱之後多出一個 <…> ,其中內含偏特化參數;這些參數定義出「將被繫結於偏特化版」的某些型別。究竟什麼參數會(或說能夠)出現於偏特化版本之中,規則頗為曲折,以下是一個簡略的規則。如果你能夠以此型式合法寫出兩個多載化函式:

void foo(T);
void foo(U);

那麼你就能夠以此型式寫出一個偏特化版本:

template <typename T>
class c{ /*details*/ }; template <typename T>
class c<U>{ /*details*/ };

這個簡則並非絕對成立,但它非常簡單,足以讓你牢牢記住並足夠接近精確的規則。

至於比較複雜的偏特化例子,讓我們考慮 class remove_bounds<T>。這個 class 定義了唯一一個 typedef-member type,其型別與 T 相同,但移除任何上層(top level)的array 邊界;這是「traits class 對某個型別進行轉換」的例子:

template <typename T>
struct remove_bounds
{ typedef T type; }; template <typename T, std::size_t N>
struct remove_bounds<T[N]>
{ typedef T type; };

remove_bounds 的目的是:想像一個泛型演算法,接受一個 array 型別做為 template 參數,remove_bounds 會提供一個方法,讓你有辦法得知底部(underlying)的 array 型別。例如,remove_bounds<int[4][5]>::type 會被核定為型別 int[5]。這個例子也向你展示,在一個偏特化版本中,template 參數的個數並不需要吻合 default template 中的個數。然而,出現於 class 名稱之後的參數個數必須吻合 default template 的參數個數和參數型別。

copy 最佳化

現在我要舉一個例子,說明我們可以如何運用 type traits classes。考慮標準程式庫所提供的 copy 演算法:

template<typename Iter1, typename Iter2>
Iter2 copy(Iter1 first, Iter1 last, Iter2 out);

很明顯,寫一個泛型版本的 copy 絕無問題,它可以處理任何型別的迭代器 Iter1和 Iter2。然而在某種情況下,copy 動作可以透過 memcpy 完成。為了能夠以 memcpy 完成 copy,以下條件必須成立:

  • 兩個迭代器 Iter1 和 Iter2 的型別都必須是指標。
  • Iter1 和 Iter2 都必須指向相同的型別 - 但允許有不同的 const 和volatile 修飾詞。
  • Iter1 所指的型別必須有一個 trivial assignment operator。

所謂 trivial assignment operator,我的意思是這個型別如果不是一個純量型別(scalar types)[參考資料3],就是:

  • 這個型別沒有使用者自定的 assignment operator。
  • 這個型別沒有任何 data members 採用 reference 型式。
  • 所有的 base classes,以及所有的 data member objects 都有 trivial assignment operators。

如果上述所有條件都獲得滿足,那麼這個型別就可以被 memcpy 直接拷貝,而不使用一個由編譯器產生的 assignment operator。type-traits library 提供了一個 classhas_trivial_assign,使得當 T 有一個 trivial assignment operator 時,has_trivial_assign<T>::value 為 true。這個 class 只能對純量型別起作用,但你很輕易就可以將它特殊化,使它適用於那些也擁有 trivial assignment operator 的 class/struct。換一個角度說,如果 has_trivial_assign 給出錯誤的答案,它會導致安全性方面的錯誤。

列表一顯示一個最佳化(使用 memcpy)的 copy 代碼。代碼之中首先定義一個 template class copier,接受唯一一個 template 參數 Boolean,然後是一個 static template member function do_copy,執行 copy 的泛型版本(也就是比較慢但比較安全的版本)。接下來是一個 copier<true> 特化版本,其中也定義了一個 static template member function do_copy,這一次使用 memcpy 來執行最佳化拷貝動作。

為了完成整份實作代碼,現在我們需要一個 copy 版本;如果可以安全使用 memcpy,就讓它呼叫 copier<true>::do_copy 執行特化版本,否則就呼叫 copier<false>::do_copy 執行泛化版本。這正是列表一的代碼的所作所為。為了了解這些代碼如何運作,請看 copy 函式代碼,並首先注意最前面的兩個 typedefs v1_t 和 v2_t。它們使用std::iterator_traits<Iter1>::value_type 來得知兩個迭代器所指的是什麼型別,然後將其結果餵給另一個 type-traits class remove_cv,用以移除上層的 const- 或 volatile-修飾詞,這使 copy 得以比較兩個型別而不在乎 const- 或 volatile- 修飾詞。接下來,copy 宣告一個列舉元 can_opt,它將成為 copier 的 template 參數 - 在這裡,宣告為常數只是為了方便:數值可以被直接傳遞給 class copier(譯註:我無法理解這一段意思;代碼本身並未出現常數宣告)。can_opt 的值是根據「以下所有項目都驗證為真」而計算出來:

  • 首先,兩個迭代器必須指向相同型別 - 驗證方法是透過 type-traits class is_same
  • 其次,兩個迭代器都必須是真正的指標 - 驗證方法是透過先前描述過的 class is_pointer
  • 最後,被迭代器所指的型別必須有一個 trivial assignment operator - 驗證方法是透過 has_trivial_assign

最後,我們可以使用 can_opt 的值做為 template 引數,傳給 copier。這裡所呈現的 copy 版本會根據它所獲得的參數而調整,如果有可能使用 memcpy,它就會那麼做,否則就使用一個泛型的 copy。

值得如此嗎?

許多文章都會引用這句話:「貿然實施最佳化,是各種傷害的根源」("premature optimization is the root of all evil") [參考資料4]。所以你一定會問這樣的問題:我們的最佳化是否太過貿然?是否太過唐突?為了透視這一點,我把我們的  copy 版本拿來和一個傳統的泛型版本做比較 [參考資料5],結果顯示於表一。

很明顯,最佳化與否,造成兩個截然不同的結果。但我也要持平地說,時間的量測並不含括「快取裝置誤擊效應」(cache miss effects),因此這份結果並未能在兩個演算法之間展現精確的比較。然而,或許我們可以加上一些警告,放到「貿然最佳化」的規則裡頭:

  • 如果你一開始就使用正確的演算法,那麼最佳化就不再有必要。某些情況下,memcpy 是正確的演算法。
  • 如果某個組件即將在許多地方被許多人使用,那麼最佳化是值得的 - 即使對少數使用者而言,最佳化可能是小題大作。

表一:以 copy<const T*, T*> 拷貝1000 個元素,所花費的時間(微秒)

版本

型別 T

時間(微秒)

最佳化的 copy char 0.99
傳統的 copy char 8.07
最佳化的 copy int 2.52
傳統的 copy int 8.02

 

Pair of References

「copy 行為最佳化」這個實例告訴我們,type traits 如何被用來在編譯時期執行最佳化策略。type traits 的另一個重要用途是允許某些「除非運用極端的偏特化,否則無法通過編譯」的代碼得以被順利編譯。只要將偏特化行為授權(delegating)給type traits classes,便有可能做到。關於這種用法,我舉的例子是一個可以持有 references 的 pair [參考資料6]。

首先讓我們檢驗 "std::pair" 的定義,為了簡化,我略去其中的 comparision operators, default constructor, 和 template copy constructor:

template <typename T1, typename T2>
struct pair
{
typedef T1 first_type;
typedef T2 second_type; T1 first;
T2 second; pair(const T1 & nfirst, const T2 & nsecond)
:first(nfirst), second(nsecond) { }
};

此刻這個 "pair" 無法持有 references,因為如此一來其 constructor 將*接受一個 reference to reference,而這種語法目前並不存在 [參考資料7]。讓我們想想,為了允許 "pair" 得以持有 non-reference 型別、references 型別、constant references 型別,constructor 的參數必須是什麼樣子:

"T1" 的型別 constructor 的參數型別
T
const T &
T &
T &
const T &
const T &

一個和 type traits classes 類似的技術,允許我們建構單一的對應關係,使我們得以根據 contained class 的型別決定參數型別。type traits classes 提供了一個 "add_reference" 轉換,可以為自身型別加上一個 reference,除非它本身已經是一個 reference。

"T1" 的型別 "const T1" 的型別 "add_reference<const T1>::type" 的型別
T
const T
const T &
T &
T &  [註8]
T &
const T &
const T &
const T &

這使我們得以建立一個 template 主版本,定義一個可內含 non-reference 型別、 reference 型別、constant reference 型別的 "pair" :

template <typename T1, typename T2>
struct pair
{
typedef T1 first_type;
typedef T2 second_type; T1 first;
T2 second; pair(boost::add_reference<const T1>::type nfirst,
boost::add_reference<const T2>::type nsecond)
:first(nfirst), second(nsecond) { }
};

為它回添標準的 comparision operators, default constructor 和 template copy constructor 之後(它們都和原先版本相同),你就有了一個可以內含 reference 型別的 std::pair。

當然我們也可以使用偏特化技巧完成同樣的擴充,但果真如此,我們需要三個 "pair" 偏特化版本和一個主版本。Type traits 允許我們僅僅定義一個主版本,就可以自動而神奇地將自己調整為任何偏特化版,取代一一偏特化的所謂「暴力法」。以此方式使用 type traits,可允許程式員將偏特化授權(delegate)給 type traits classes,使得代碼比較容易維護,也比較容易被理解。

結論

希望這篇文章能夠給你一些想法,讓你大略知道 type-traits 是什麼。boost 說明文件中有更完整的 classes 列表,以及更進一步的使用範例。Templates 使 C++ 有能力實現泛型編程所帶來的復用性;這篇文章還告訴你,templates 可以和 generic 一樣地美好。這都有賴 type traits 帶來的價值。

致謝

感謝 Beman Dawes 和 Howard Hinnant 對本文所提的意見。

參考資料

  1. Nathan C. Myers, C++ Report, June 1995.
  2. 這個 type traits library 的完成,要感謝 Steve Cleary, Beman Dawes, Howard Hinnant 和 John Maddock。你可以在 www.boost.org 找到它。
  3. 所謂純量型別(scalar type)就是算術型別(例如內建的整數或浮點數)、列舉型別(enumeration)、指標、函式指標、或以上任何型別再加上 const- 或 volatile- 修飾詞。
  4. 此句引自 Donald Knuth, ACM Computing Surveys, December 1974, pg 268.
  5. 這一份測試代碼是 boost utility library 的一部份(見 algo_opt_examples.cpp),以 gcc 2.95 編譯完成,所有最佳化選項都打開。我的測試結果是在 400MHz Pentium II + Microsoft Windows 98 上獲得。
  6. John Maddock 和 Howard Hinnant 已經送出一個 "compressed_pair" library 給 Boost,其中使用的一個技術,和此處所描述的技術類似,也是用來持有 references。他們的 pair 也使用 type traits 來決定是否有任何型別是空的,並且採用 "derive" 而非 "contain" 的方式,用以保存空間 -- 這正是 "compressed" 的命名由來。
  7. 這其實是 C++ 核心語言工作小組的一個議題,由 Bjarne Stroustrup 提出。暫時的解決辦法是,允許 "a reference to a reference to T" 的意義等同於 "a reference to T",但是只能存在於 template 具現實體中,或是存在於一個「具備多個 const-volatile 修飾詞」的  method 中。
  8. 為什麼這裡不該有 const 修飾詞呢?對此感到驚訝的人,我要提醒你,請記住, references 永遠是個隱晦常數(舉個例子,你不能夠重新對一個 reference 賦值)。同時也請你記住,"const T &" 是完全不同的東西。因為這些理由,template 型別引數如果本身是個 references 的話,其「const-volatile 修飾詞」都被忽略。
namespace detail{

template <bool b>
struct copier
{
template<typename I1, typename I2>
static I2 do_copy(I1 first,
I1 last, I2 out);
}; template <bool b>
template<typename I1, typename I2>
I2 copier<b>::do_copy(I1 first,
I1 last,
I2 out)
{
while(first != last)
{
*out = *first;
++out;
++first;
}
return out;
} template <>
struct copier<true>
{
template<typename I1, typename I2>
static I2* do_copy(I1* first, I1* last, I2* out)
{
memcpy(out, first, (last-first)*sizeof(I2));
return out+(last-first); // 譯註:因為是 RandomAccessIterator
}
}; } template<typename I1, typename I2>
inline I2 copy(I1 first, I1 last, I2 out)
{
typedef typename
boost::remove_cv<
typename std::iterator_traits<I1>
::value_type>::type v1_t; typedef typename
boost::remove_cv<
typename std::iterator_traits<I2>
::value_type>::type v2_t; enum{ can_opt =
boost::is_same<v1_t, v2_t>::value
&& boost::is_pointer<I1>::value
&& boost::is_pointer<I2>::value
&& boost::has_trivial_assign<v1_t>::value
}; return detail::copier<can_opt>::do_copy(first, last, out);
} 转自侯捷:http://jjhou.boolan.com/programmer-6-type-traits-ddj.htm