泛型编程编出来的代码,适用於任何「吻合某种条件限制」的资料型别。这已成为撰写可复用代码时的一个重要选择。然而,总有一些时候,泛型不够好 ─ 有时候是因为不同的型别差距过大,难以产生一致的泛化实作版本。这个时候 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 提供了一个 class has_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 对本文所提的意见。
叁考资料
- Nathan C. Myers, C++ Report, June 1995.
- 这个 type traits library 的完成,要感谢 Steve Cleary, Beman Dawes, Howard Hinnant 和 John Maddock。你可以在 www.boost.org 找到它。
- 所谓纯量型别(scalar type)就是算术型别(例如内建的整数或浮点数)、列举型别(enumeration)、指标、函式指标、或以上任何型别再加上 const- 或 volatile- 修饰词。
- 此句引自 Donald Knuth, ACM Computing Surveys, December 1974, pg 268.
- 这一份测试代码是 boost utility library 的一部份(见 algo_opt_examples.cpp),以 gcc 2.95 编译完成,所有最佳化选项都打开。我的测试结果是在 400MHz Pentium II + Microsoft Windows 98 上获得。
- John Maddock 和 Howard Hinnant 已经送出一个 "compressed_pair" library 给 Boost,其中使用的一个技术,和此处所描述的技术类似,也是用来持有 references。他们的 pair 也使用 type traits 来决定是否有任何型别是空的,并且采用 "derive" 而非 "contain" 的方式,用以保存空间 -- 这正是 "compressed" 的命名由来。
- 这其实是 C++ 核心语言工作小组的一个议题,由 Bjarne Stroustrup 提出。暂时的解决办法是,允许 "a reference to a reference to T" 的意义等同於 "a reference to T",但是只能存在於 template 具现实体中,或是存在於一个「具备多个 const-volatile 修饰词」的 method 中。
- 为什麽这里不该有 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);