以下是对 John Maddock 和 Steve Cleary 在 2000 年 10 月刊的 Dr Dobb's Journal 中发表的文章“C++ 类型特征”的更新版本。
泛型编程(编写适用于满足一组要求的任何数据类型的代码)已成为提供可重用代码的首选方法。然而,在泛型编程中,有时“泛型”并不足以满足需求——有时类型之间的差异太大,无法实现高效的泛型实现。这时,特征技术就变得很重要——通过将需要在类型基础上考虑的那些属性封装在特征类中,我们可以最大程度地减少代码因类型不同而需要变化的部分,并最大程度地增加泛型代码的数量。
考虑一个示例:在处理字符字符串时,一个常见的操作是确定以 null 结尾的字符串的长度。显然,可以编写能够执行此操作的泛型代码,但事实证明,存在更有效的方法:例如,C 库函数 strlen
和 wcslen
通常是用汇编语言编写的,并且在硬件支持的情况下,其速度可能比用 C++ 编写的泛型版本快得多。C++ 标准库的作者意识到了这一点,并将 char
和 wchar_t
的属性抽象到了 char_traits
类中。处理字符字符串的泛型代码只需使用 char_traits<>::length
来确定以 null 结尾的字符串的长度,确保 char_traits
的专门化将使用它们可用的最合适方法。
char_traits
类是将一组特定类型属性封装在一个类中的一个经典示例——Nathan Myers 将其称为 行李类[1]。在 Boost 类型特征库中,我们[2] 编写了一组非常具体的特征类,每个类都封装了 C++ 类型系统中的一个特征;例如,类型是指针还是引用类型?或者类型是否具有平凡的构造函数,或者具有 const 限定符?类型特征类共享一个统一的设计:如果类型具有指定的属性,则每个类都继承自类型 true_type,否则继承自 false_type。正如我们将展示的,这些类可以在泛型编程中用来确定给定类型的属性,并引入适合该情况的优化。
类型特征库还包含一组执行特定类型转换的类;例如,它们可以从类型中移除顶层 const 或 volatile 限定符。每个执行转换的类都定义了一个名为 type
的单 typedef 成员,它是转换的结果。所有类型特征类都在命名空间 boost
内定义;为简洁起见,大多数代码示例中都省略了命名空间限定符。
类型特征库包含的单独类太多,无法在此给出完整的实现——有关完整详细信息,请参阅 Boost 库中的源代码——但是,大多数实现相当重复,因此我们只介绍一些类的实现方式。从库中可能最简单的类 is_void<T>
开始,它仅当 T
为 void
时才继承自 true_type
。
template <typename T> struct is_void : public false_type{}; template <> struct is_void<void> : public true_type{};
在这里,我们定义了模板类 is_void
的主要版本,并在 T
为 void
时提供了一个完全专门化。虽然模板类的完全专门化是一项重要的技术,但有时我们需要一个介于完全泛型解决方案和完全专门化之间的解决方案。这正是标准委员会定义部分模板类专门化的场景。例如,考虑类 boost::is_pointer<T>
:在这里,我们需要一个处理 T 不是指针的所有情况的主要版本,以及一个处理 T 是指针的所有情况的部分专门化。
template <typename T> struct is_pointer : public false_type{}; template <typename T> struct is_pointer<T*> : public true_type{};
部分专门化的语法有点晦涩难懂,很容易占用一整篇文章来解释;与完全专门化一样,为了编写类的部分专门化,必须首先声明主模板。部分专门化在类名之后包含一个额外的 <<...>,其中包含部分专门化参数;这些参数定义了将绑定到该部分专门化而不是默认模板的类型。部分专门化中允许出现的内容的规则比较复杂,但作为经验法则,如果可以合法地编写两种形式的函数重载
void foo(T); void foo(U);
那么也可以编写形式为
template <typename T> class c{ /*details*/ }; template <typename T> class c<U>{ /*details*/ };
的partial specialization。这条规则并非万无一失,但它很容易记住,并且与实际规则足够接近,在日常使用中非常有用。
作为部分专门化的更复杂示例,考虑类 remove_extent<T>
。此类定义了一个名为 type
的单 typedef 成员,其类型与 T 相同,但删除了所有顶层数组边界;这是执行类型转换的特征类的示例。
template <typename T> struct remove_extent { typedef T type; }; template <typename T, std::size_t N> struct remove_extent<T[N]> { typedef T type; };
remove_extent
的目标是:想象一个泛型算法,它将数组类型作为模板参数传递,remove_extent
提供了一种确定数组的底层类型的方法。例如,remove_extent<int[4][5]>::type
将评估为类型 int[5]
。此示例还表明,部分专门化中的模板参数数量不必与默认模板中的数量匹配。但是,类名之后出现的参数数量必须与默认模板中的参数数量和类型匹配。
作为类型特征类如何使用的一个示例,考虑标准库算法 copy
template<typename Iter1, typename Iter2> Iter2 copy(Iter1 first, Iter1 last, Iter2 out);
显然,编写适用于所有迭代器类型 Iter1
和 Iter2
的泛型版本 copy 没有任何问题;但是,在某些情况下,复制操作可以通过调用 memcpy
最好地完成。为了用 memcpy
实现 copy,所有以下条件都必须满足。
Iter1
和 Iter2
都必须是指针。Iter1
和 Iter2
都必须指向相同的类型——排除 const 和 volatile 限定符。Iter1
指向的类型必须具有平凡的赋值运算符。所谓平凡的赋值运算符,是指类型要么是标量类型[3],要么是
如果满足所有这些条件,则可以使用 memcpy
来复制类型,而不是使用编译器生成的赋值运算符。类型特征库提供了一个类 has_trivial_assign
,使得 has_trivial_assign<T>::value
仅当 T 具有平凡的赋值运算符时才为真。此类对于标量类型“正常工作”,但必须针对也具有平凡赋值运算符的类/结构类型进行显式专门化。换句话说,如果 has_trivial_assign 给出了错误的答案,它将给出“安全”的错误答案——即不允许平凡赋值。
使用memcpy
对复制进行优化的代码版本在示例中给出。该代码首先定义一个模板函数do_copy
,它执行“缓慢但安全”的复制。传递给此函数的最后一个参数可以是true_type
或false_type
。之后是do_copy
的重载,它使用memcpy
:这次迭代器需要实际指向同一类型,最后一个参数必须是true_type
。最后,copy
的版本调用do_copy
,并将has_trivial_assign<value_type>()
作为最后一个参数传递:这将在适当的情况下分派到优化版本,否则将调用“缓慢但安全版本”。
在这些专栏中,人们经常重复“过早优化是万恶之源” [4]。因此,必须问一个问题:我们的优化是否过早?为了将这一点放在一个角度,我们在表 1 中展示了我们版本的复制与传统泛型复制 [5] 的计时结果。
显然,优化在这种情况下确实有所不同;但,公平地说,计时结果经过加载以排除缓存未命中效应——如果没有这一点,算法之间的准确比较会变得困难。但是,也许我们可以对过早优化规则添加一些注意事项
表 1.1. 使用 `copy<const T*, T*>` 复制 1000 个元素所花费的时间(以微秒计)
版本 |
T |
时间 |
---|---|---|
“优化”复制 |
char |
0.99 |
传统复制 |
char |
8.07 |
“优化”复制 |
int |
2.52 |
传统复制 |
int |
8.02 |
优化后的复制示例展示了如何使用类型特征在编译时执行优化决策。类型特征的另一个重要用途是允许代码编译,否则除非使用过度的部分特化,否则不会这样做。这是通过将部分特化委托给类型特征类来实现的。我们针对这种形式的用法的示例是一个可以保存引用的对 [6]。
首先,让我们检查std::pair
的定义,为简单起见,省略比较运算符、默认构造函数和模板复制构造函数
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) { } };
现在,这个“对”不能保存引用,因为它目前处于这种状态,因为构造函数将需要接受对引用的引用,而这在目前是非法的 [7]。让我们考虑一下构造函数的参数必须是什么,才能允许“对”保存非引用类型、引用和常量引用
对类型特征类有所了解后,我们可以构建一个单一映射,它允许我们根据所包含类的类型来确定参数的类型。类型特征类提供了转换 add_reference,它将引用添加到其类型,除非它已经是引用。
表 1.3. 使用 add_reference 合成正确的构造函数类型
|
|
|
---|---|---|
T |
const T |
const T & |
T & |
T & [8] |
T & |
const T & |
const T & |
const T & |
这允许我们为 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) { } };
添加回标准比较运算符、默认构造函数和模板复制构造函数(它们都相同),您就得到了一个可以保存引用类型的 std::pair
!
可以使用 pair
的部分模板特化来完成相同的扩展,但是为了以这种方式特化 pair
,需要三个部分特化,以及主模板。类型特征允许我们定义一个单一主模板,它会自动调整自身以适应任何这些部分特化,而不是采取强制性的部分特化方法。以这种方式使用类型特征允许程序员将部分特化委托给类型特征类,从而使代码更容易维护和理解。
我们希望在本文中,我们已经能够让您对类型特征有所了解。boost 文档中列出了更完整的可用类,以及使用类型特征的更多示例。模板使 C++ 用户能够利用泛型编程带来的代码重用优势;希望本文表明泛型编程不必降低到最低公分母,并且模板可以既是通用的,又是最优的。
作者要感谢 Beman Dawes 和 Howard Hinnant 在撰写本文时提供的宝贵意见。