目录
25.1 基本的元组设计
25.1.1 存储(Storage)
25.1.2 构造
25.2 基础元组操作
25.2.1 比较
25.2.2 输出
25.3 元组的算法
25.3.1 将元组用作类型列表
25.3.2 添加以及删除元素
25.3.3 元组的反转
25.3.4 索引列表
25.4 元组的展开
25.5 元组的优化
25.5.1 元组和 EBCO
25.5.2 常数时间的 get()
25.6 元组下标
参考:GitHub - Walton1128/CPP-Templates-2nd--: 《C++ Templates 第二版》中文翻译,和原书排版一致,第一部分(1至11章)以及第18,19,20,21、22、23、24、25章已完成,其余内容逐步更新中。 个人爱好,发现错误请指正
C++ (以及 C)也有“异质”的组件:class 或者 struct。本章将会讨论 tuples,它采用了类似于 class 和 struct 的方式来组织数据。比如,一个包含 int,double 和 std::string 的 tuple,和一 个包含 int,double 以及 std::string 类型的成员的 struct 类似,只不过 tuple 中的元素是用位 置信息(比如 0,1,2)索引的,而不是通过名字。元组的位置接口,以及能够容易地从 typelist 构建 tuple 的特性,使得其相比于 struct 更适用于模板元编程技术。
另一种观点是将元组看作在可执行程序中,类型列表的一种表现。比如,类型列表Typelist,描述了一串包含了 int,double 和 std::string 的、可以在编译期间操作的 类型,而 Tuple则描述了可以在运行期间操作的、对 int,double 和 std::string 的存储。
通常会使用模板元编程和 typelist 来创建用于存储数据的 tuple。比如,虽然在上面的程序中 随意地选择了 int,double 和 std::string 作为元素类型,我们也可以用元程序创建一组可被 tuple 存储的类型。
比如,对于之前例子中的 t,get(t)会返回指向 int 17 的引用,而 get(t)返回的则是指向 double 3.14 的引用。
元组存储的递归实现是基于这样一个思路:一个包含了 N > 0 个元素的元组可以被存储为一 个单独的元素(元组的第一个元素,Head)和一个包含了剩余 N-1 个元素(Tail)的元组, 对于元素为空的元组,只需当作特例处理即可。
事实上,在 类型列表算法的泛型版本中也使用了相同的递归分解过程,而且实际递归元组的存储实现也 以类似的方式展开:
template
class Tuple;
// recursive case:
template
class Tuple
{
private:
Head head;
Tuple tail;
public:
// constructors:
Tuple() {
}
Tuple(Head const& head, Tuple const& tail): head(head),
tail(tail) {
}…
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple& getTail() { return tail; }
Tuple const& getTail() const { return tail; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};
在递归情况下,Tuple 的实例包含一个存储了列表首元素的 head,以及一个存储了列表剩余 元素的 tail。基本情况则是一个没有存储内容的简单的空元组。
而函数模板 get 则会通过遍历这个递归的结构来提取所需要的元素:
// recursive case:
template
struct TupleGet {
template
static auto apply(Tuple const& t) {
return TupleGet::apply(t.getTail());
}
};
// basis case:
template<>
struct TupleGet<0> {
template
static Head const& apply(Tuple const& t) {
return t.getHead();
}
};
template
auto get(Tuple const& t) {
return TupleGet::apply(t);
}
由于apply是一个static函数,所以模板参数要写在function前面,否则可以和struct的模板参数写在一起
注意,这里的函数模板 get 只是封装了一个简单的对 TupleGet 的静态成员函数调用。在不能 对函数模板进行部分特例化的情况下(参见 17.3 节),这是一个有效的变通方法,在这里 针对非类型模板参数 N 进行了特例化。在 N > 0 的递归情况下,静态成员函数 apply()会提取 出当前 tuple 的 tail,递减 N,然后继续递归地在 tail 中查找所需元素。对于 N=0 的基本情况, apply()会返回当前 tuple 的 head,并结束递归。
为了让元组的使用更方便,还应该允许用一组相互独立的值(每一个值对应元组中的一个元 素)或者另一个元组来构造一个新的元组。
从一组独立的值去拷贝构造一个元组,会用第一 个数值去初始化元组的 head,而将剩余的值传递给 tail:
用户可能会希望用移动构造(move-construct)来初始化元组 的一些(可能不是全部)元素,或者用一个类型不相同的数值来初始化元组的某个元素。因 此我们需要用完美转发(参见 15.6.3 节)来初始化元组:
template
Tuple(VHead&& vhead, VTail&&… vtail)
: head(std::forward(vhead)), tail(std::forward(vtail)…)
{
}
下面的这个实现则允许用一个元组去构建另一个元组:
template
Tuple(Tuple const& other)
: head(other.getHead()), tail(other.getTail())
{ }
但是这个构造函数不适用于类型转换:给定上文中的 t,试图用它去创建一个元素之间类型 兼容的元组会遇到错误:
// ERROR: no conversion from Tuple to long
Tuple t2(t)
这是因为上面这个调用,会更匹配用一组数值去初始化一个元组的构造函数模板,而不是用 一个元组去初始化另一个元组的构造函数模板。为了解决这一问题,就需要用到 6.3 节介绍 的 std::enable_if<>,在 tail 的长度与预期不同的时候就禁用相关模板:
template>
Tuple(VHead&& vhead, VTail&&… vtail)
: head(std::forward(vhead)), tail(std::forward(vtail)…)
{ }
template>
Tuple(Tuple const& other)
: head(other.getHead()), tail(other.getTail()) { }
函数模板 makeTuple()会通过类型推断来决定所生成元组中元素的类型,这使得用一组数值 创建一个元组变得更加简单:
template
auto makeTuple(Types&&… elems)
{
return Tuple…>(std::forward (elems)…);
}
元组是包含了其它数值的结构化类型。为了比较两个元组,就需要比较它们的元素。因此可 以像下面这样,定义一种能够逐个比较两个元组中元素的 operator==
// basis case:
bool operator==(Tuple<> const&, Tuple<> const&)
{
// empty tuples are always equivalentreturn true;
}
// recursive case:
template>
bool operator==(Tuple const& lhs, Tuple const& rhs)
{
return lhs.getHead() == rhs.getHead() &&
lhs.getTail() == rhs.getTail();
}
和其它适用于类型列表和元组的算法类似,逐元素的比较两个元组,会先比较首元素,然后 递归地比较剩余的元素,最终会调用 operator 的基本情况结束递归。运算符!=,,以及>= 的实现方式都与之类似。
贯穿本章始终,我们一直都在创建新的元组类型,因此最好能够在执行程序的时候看到这些 元组。下面的 operator<<运算符会打印那些元素类型可以被打印的元组:
#include
void printTuple(std::ostream& strm, Tuple<> const&, bool isFirst = true)
{
strm << ( isFirst ? ’(’ : ’)’ );
}
template
void printTuple(std::ostream& strm, Tuple const& t, bool isFirst =
true)
{
strm << ( isFirst ? "(" : ", " );
strm << t.getHead();
printTuple(strm, t.getTail(), false);
}
template
std::ostream& operator<<(std::ostream& strm, Tuple const& t)
{
printTuple(strm, t);
return strm;
}
元组是一种提供了以下各种功能的容器:可以访问并修改其元素的能力(通过 get<>),创 建新元组的能力(直接创建或者通过使用 makeTuple<>创建),以及将元组分割成 head 和 tail 的能力(通过使用 getHead()和 getTail())。使用这些功能足以创建各种各样的元组算法, 比如添加或者删除元组中的元素,重新排序元组中的元素,或者选取元组中元素的某些子集。
元组很有意思的一点是它既需要用到编译期计算也需要用到运行期计算。和第 24 章介绍的 类型列表算法类似,将某种算法作用与元组之后可能会得到一个类型迥异的元组,这就需要 用到编译期计算。比如反转元组 Tuple会得到 Tuple。但是和同质容器的算法类似(比如作用域 std::vector 的 std::reverse()),元组算法是需要在 运行期间执行代码的,因此我们需要留意被产生出来的代码的效率问题。
如果我们忽略掉 Tuple 模板在运行期间的相关部分,可以发现它在结构上和第 24 章介绍的 Typelist 完全一样:都接受任意数量的模板类型参数。事实上,通过使用一些部分特例化, 可以将 Tuple 变成一个功能完整的 Typelist:
如果我们忽略掉 Tuple 模板在运行期间的相关部分,可以发现它在结构上和第 24 章介绍的 Typelist 完全一样:都接受任意数量的模板类型参数。事实上,通过使用一些部分特例化, 可以将 Tuple 变成一个功能完整的 Typelist
// determine whether the tuple is empty:
template<>
struct IsEmpty> {
static constexpr bool value = true;
};
// extract front element:
template
class FrontT> {
public:
using Type = Head;
};
// remove front element:
template
class PopFrontT> {
public:
using Type = Tuple;
};
// add element to the front:
template
class PushFrontT, Element> {
public:
using Type = Tuple;
};
// add element to the back:
template
class PushBackT, Element> {
public:
using Type = Tuple;
};
和 typelist 的情况一样,向头部插入一个元素要远比向尾部插入一个元素要简单,因此我们从 pushFront 开始:
template
PushFront, V>
pushFront(Tuple const& tuple, V const& value)
{
return PushFront, V>(value, tuple);
}
将一个新元素添加到一个已有元组的末尾则会复杂得多,因为这需要遍历一个元组。注意下 面的代码中 pushBack()的实现方式,是如何参考了第 24.2.3 节中类型列表的 PushBack()的递 归实现方式的:
// basis case
template
Tuple pushBack(Tuple<> const&, V const& value)
{
return Tuple(value);
}
// recursive case
template
Tuple
pushBack(Tuple const& tuple, V const& value)
{
return Tuple(tuple.getHead(),
pushBack(tuple.getTail(), value));
}
// basis case
Tuple<> reverse(Tuple<> const& t)
{
return t;
}
// recursive case
template
Reverse> reverse(Tuple const& t)
{
return pushBack(reverse(t.getTail()), t.getHead());
}
在反转元组时,为了避免不必要的 copy,考虑一下我们该如何实现一个一次性的算法,来 反转一个简单的、长度已知的元组(比如包含 5 个元素)。可以像下面这样只是简单地使用 makeTuple()和 get():
auto reversed = makeTuple(get<4>(copies), get<3>(copies), get<2>(copies),
get<1>(copies), get<0>(copies));
这个程序会按照我们预期的那样进行,对每个元素只进行一次 copy
在需要将一组相关的数值存储到一个变量中时(不管这些相关数值的数量是多少、类型是什 么),元组会很有用。在某些情况下,可能会需要展开一个元组(比如在需要将其元素作为 独立参数传递给某个函数的时候)。作为一个简单的例子,可能需要将一个元组的元素传递 给在第 12.4节介绍的变参 print():
Tuple t("Pi", "is roughly", 3, ’\n’);
print(t…); //ERROR: cannot expand a tuple; it isn’t a parameter pack
我们可以使用索引列表实现这一功能。下面的函数模板 apply()接受一个函 数和一个元组作为参数,然后以展开后的元组元素为参数,去调用这个函数:
template
auto applyImpl(F f, Tuple const& t,
Valuelist) ->decltype(f(get(t)…))
{
return f(get(t)…);
}
template
auto apply(F f, Tuple const& t) ->decltype(applyImpl(f, t,
MakeIndexList()))
{
return applyImpl(f, t, MakeIndexList());
}
元组是一种基础的、潜在用途广泛的异质容器。因此有必要考虑下该怎么在运行期(存储和 执行时间)和编译期(实例化的数量)对其进行优化。
我们实现的元组,其存储方式所需要的存储空间,要比其严格意义上所需要的存储空间多。 其中一个问题是,tail 成员最终会是一个空的数值(因为所有非空的元组都会以一个空的元 组作为结束),而任意数据成员又总会至少占用一个字节的内存(参见 21.1 节)。
为了提高元组的存储效率,可以使用第 21.1 节介绍的空基类优化(EBCO,empty base class optimization),让元组继承自一个尾元组(tail tuple),而不是将尾元组作为一个成员。比 如:
// recursive case:
template
class Tuple : private Tuple
{
private:
Head head;
public:
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple& getTail() { return *this; }
Tuple const& getTail() const { return *this; }
};
这和第 21.1.2 节中的 BaseMemberPair 使用的优化方式一致。不幸的是,这种方式有其副作 用,就是颠倒了元组元素在构造函数中被初始化的顺序。在之前的实现中,head 成员在 tail 成员前面,因此 head 总是会先被初始化。在新的实现方式中,tail 则是以基类的形式存在, 因此它会在 head 成员之前被初始化。
能够实现一个即使用了 EBCO 优化,又能保持元素的初始化顺序,并支持 五车书馆 360 包含相同类型元素的元组:
template
class Tuple;
// recursive case:
template
class Tuple
: private TupleElt, private Tuple
{
using HeadElt = TupleElt;
public:
Head& getHead() {
return static_cast(this)->get();
}
Head const& getHead() const {
return static_cast(this)->get();
}
Tuple& getTail() { return *this; }
Tuple const& getTail() const { return *this; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};
基于这一实现,下面的程序:
#include
#include "tupleelt1.hpp"
#include "tuplestorage3.hpp"
#include
struct A {
A() {
std::cout << "A()" << ’\n’;
}
};
struct B {
B() {
std::cout << "B()" << ’\n’;
}
五车书馆
361
};
int main()
{
Tuple t1;
std::cout << sizeof(t1) << " bytes" << ’\n’;
}
会打印出:
A()
A()
B()
5 bytes
从中可以看出,EBCO 使得内存占用减少了一个字节(减少的内容是空元组 Tuple<>)。但是 请注意 A 和 B 都是空的类,这暗示了进一步用 EBCO 进行优化的可能。如果能够安全的从其 元素类型继承的话,那么就让 TupleElt 继承自其元素类型(这一优化不需要更改 Tuple 的定 义):
#include
template::value && !std::is_final::value>
class TupleElt;
template
class TupleElt
{
T value;
public:
TupleElt() = default;
template
TupleElt(U&& other) : value(std::forward(other)) { }
T& get() { return value; }
T const& get() const { return value; }
};
template
class TupleElt : private T
{
public:
TupleElt() = default;
template
TupleElt(U&& other) : T(std::forward(other)) { }
T& get() { return *this; }
T const& get() const { return *this; }
};
当提供给 TupleElt 的模板参数是一个可以被继承的类的时候,它会从该模板参数做 private 继承,从而也可以将 EBCO 用于被存储的值。有了这些变化,之前的程序会打印出:
A() A() B() 2 bytes
在使用元组的时候,get()操作的使用是非常常见的,但是其递归的实现方式需要用到线性次 数的模板实例化,这会影响编译所需要的时间。幸运的是,基于在之前章节中介绍的 EBCO, 可以实现一种更高效的 get,
理论上也可以通过定义 operator[]来访问元组中的元素,这和在 std::vector 中定义 operator[] 的情况类似。不过和 std::vector 不同的是,元组中元素的类型可以不同,因此元组的 operator[] 必须是一个模板,其返回类型也需要随着索引的不同而不同。这反过来也就要求每一个索引 都要有不同的类型,因为需要根据索引的类型来决定元素的类型。
使用在第 24.3 节介绍的类模板 CTValue,可以将数值索引编码进一个类型中。将其用于 Tuple 下标运算符定义的代码如下:
template
auto& operator[](CTValue) {
return get(*this);
}
为了让常量索引的使用变得更方便,我们可以用 constexpr 实现一种字面常量运算符,专门 用来直接从以_c 结尾的常规字面常量,计算出所需的编译期数值字面常量:
#include "ctvalue.hpp"
#include
#include
// convert single char to corresponding int value at compile time:
constexpr int toInt(char c) {
// hexadecimal letters:
if (c >= ’A’ && c <= ’F’) {
return static_cast(c) - static_cast(’A’) + 10;
}
if (c >= ’a’ && c <= ’f’) {
return static_cast(c) - static_cast(’a’) + 10;
}
// other (disable ’.’ for floating-point literals):
assert(c >= ’0’ && c <= ’9’);
return static_cast(c) - static_cast(’0’);
}
// parse array of chars to corresponding int value at compile time:
template
constexpr int parseInt(char const (&arr)[N]) {
int base = 10; // to handle base (default: decimal)
int offset = 0; // to skip prefixes like 0x
if (N > 2 && arr[0] == ’0’) {
switch (arr[1]) {
case ’x’: //prefix 0x or 0X, so hexadecimal
case ’X’:
base = 16;
offset = 2;
break;
case ’b’: //prefix 0b or 0B (since C++14), so binary
case ’B’:
base = 2;offset = 2;
break;
default: //prefix 0, so octal
base = 8;
offset = 1;
break;
}
}
// iterate over all digits and compute resulting value:
int value = 0;
int multiplier = 1;
for (std::size_t i = 0; i < N - offset; ++i) {
if (arr[N-1-i] != ’\’’) { //ignore separating single quotes (e.g. in 1’
000)
value += toInt(arr[N-1-i]) * multiplier;
multiplier *= base;
}
}
return value;
}
// literal operator: parse integral literals with suffix _c as sequence of chars:
template
constexpr auto operator"" _c() {
return CTValue({cs…})>{};
}