目录

重新认识 tuple

1. 为什么要有 tuple?

2. tuple 的本质

手写精简版 MyTuple

1. 空基类特化

2. 递归主模板

3. 实现 get()

内存布局与空基类优化

1. tuple 的对齐与大小

2. 布局分析

操作 tuple 类型本身

1. 类型查询与修改

2. 利用 index_sequence 实现万能访问

应用与小巧思

1. 多返回值与结构化绑定

2. 利用 std::apply 实现参数列表泛型编程

3. 类型列表与编译期算法

结尾


每当我们想从一个函数里返回三个值,却不得不在文件开头写一个只活五秒钟的 struct,还给它起了个名字叫 TempResult。

后来 std::tuple 来了,青天就来了。它允许我们随手写个 std::make_tuple(111, 3.33, "hello"),编译器就默默地帮我们把类型全推了。

重新认识 tuple

1. 为什么要有 tuple?

对于这个问题,我总会有那种明明有刀叉,却非要用筷子吃牛排的感觉。

C++ 的强类型是它骄傲的资本,但也因此干过无数反人类的事。假如我们写了个函数,美滋滋地算出了一组结果,想一把全部返回去,然后我们愣住了。

返回多个值这个在其他语言里像呼吸一样自然的操作,在 C++ 里却逼着我们先定义一个 struct,起个名字,写上四五个成员,可能还得配个构造函数。就为了临时传一组数据,我们得在代码里造一个一辈子只用一次的类型,荒唐,太荒唐了。

这时候 std::tuple 踩着七彩祥云……不,它没踩祥云,它是黑着脸来的。它是那种“既然你们这么懒,那只能我勤快点咯”。我们可以这样写:

auto get_info()
{
    return std::make_tuple("老王", 18, 175.0, true); // 名字、年龄、身高、是否单身
}

欸,连返回值类型都懒得写,auto 一推了事。

这在泛型编程里使用起来还蛮方便的:模板函数需要打包一堆不知道什么类型的玩意儿时,总不能让我们事先定义十几种结构体吧?tuple 让临时性异构数据聚合起来,终于不用再整那一堆结构体了。

还有一种情况就是我们写了个模板,想把一堆模板参数存起来稍后再用,或者逐个展开操作。没有 tuple,我们就得跟 std::integer_sequence 和某种诡异的递归模板玩命;有了 tuple 后,它就是那个现成的编译期容器,我们想在里面放什么类型和值就全凭心情。

所以,为什么需要 tuple?因为 C++ 的类型系统太...自行脑补,需要一个专门的容器来帮忙擦屁股。它解决了“我不想为一次性数据组合命名一个新类型”的麻烦,同时给模板元编程提供了一个能装下任意类型组合的盒子。

2. tuple 的本质

好了,现在让我们来瞅瞅它是由什么东西做成的。

很多人以为 tuple 内部就是个“动态数组,每个元素 void*”之类的玩意,那可就太小看 C++ 模板的倔强了。

tuple 的本质就一句话:它是一棵用递归继承构建的匿名结构体树,每一个叶子节点都是对真实数据的编译期布局,不浪费一个字节,不搞一丝运行时开销。

我们写 std::tuple<int, double, std::string>,编译器大致会给我们生出一个类似这样的玩意儿(简化版):

// 递归终点
template<size_t I, typename... Types>
class TupleImpl {};

// 递归继承,剥洋葱一样
template<size_t I, typename Head, typename... Tail>
class TupleImpl<I, Head, Tail...> 
    : public TupleImpl<I+1, Tail...> 
{
    Head value; // 当前元素就嵌在这里
};

看到了吗?它通过一层层继承,把自己的身子串了起来,tuple<int, double, string> 最终会变成:

每个 TupleImpl 特化体里躺着它负责的那个元素(那个 Head value),因此内存布局上是紧凑的,没有额外指针,各个成员的内存位置通过 this 指针加上编译期计算的偏移自然可得。

这就是为什么 get<0>(t) 能零开销,它只是帮我们算好偏移,直接怼到我们想要的成员上,完全是一个被编译器藏起来的结构体成员访问

这解释了另一个现象:tuple 的成员顺序和我们的声明顺序严格一致,且它是值语义的容器。它不像 Java 的元组那样需要装箱,所有东西都在栈上,或者随 tuple 对象整体分配在堆上,内聚性极高。拷贝、移动、销毁也都是靠每个成员的贡献,编译器自动递归生成,快得一匹。

但同时,这个实现也带来它最讨人厌的一面:编译慢、错误信息像天书。我们一旦用错 get<> 索引,编译器会沿着继承链一层层找,最后甩给我们一篇关于 tuple_element 小作文,恨不得连我们祖上三代代码都给标出来。

手写精简版 MyTuple

这玩意儿一旦拆开,其实也就那么回事。

1. 空基类特化

任何递归都得有个头,不然编译器就跟我们没完没了直到爆栈。我们的 MyTuple 是多继承的递归,所以终点就是一个啥也没有的空壳子。

template <typename... Types>
class MyTuple {};

template <>
class MyTuple<> 
{
    // 空空如也,连个屁都没有
    // 但它的存在本身,是整个继承链的根
};

为什么要搞个空的?因为想想我们的继承链:MyTuple<int, double> 要继承自 MyTuple<double>,而 MyTuple<double> 要继承自 MyTuple<>。到了 MyTuple<> 这里,如果再往下继承就成无底洞了,所以它必须站出来说:“就到这儿,我是终点站,都给我停!”

这玩意儿就叫递归基类特化,或者更亲切地,叫它躺平基类。它不存数据,不占内存,但却是整个 tuple 大厦能够盖起来的法定地基。

2. 递归主模板

好了,现在写那个真正干活的。

我们的设计很简单:每一层只存当前元素,然后把剩下的类型扔给父类去处理。

template <typename Head, typename... Tail>
class MyTuple<Head, Tail...> 
    : private MyTuple<Tail...> 
{
    Head value; // 这一层只负责头部元素

public:
    // 构造函数
    MyTuple() = default;

    MyTuple(const Head& head, const Tail&... tail)
        : MyTuple<Tail...>(tail...), // 先初始基类
        value(head) // 然后是本层成员
    {}

};

晓得了波?MyTuple<int, double, std::string> 展开后是:

内存布局上,每个成员就嵌在自己那一层,顺序和我们声明的一模一样。我们也可以想象它就是编译器帮我们写了一个匿名的结构体,只不过这个结构体是通过继承关系串联起来的,而不是扁平地写在一层里。

3. 实现 get<N>()

现在有一个问题:我们怎么把元素取出来?

思路其实很简单:get<0> 就是要拿当前层(最顶层)的 value;get<1> 就往下剥一层父类,再拿那层的 value;get<2> 剥两层…… 翻译成代码,就是继续递归,直到下标为 0 时停手。

我们需要一个辅助模板,因为 get 函数模板不能偏特化,所以我们用一个静态工具类来做这个脏活。

// I 还没到 0,继续往下剥
template <size_t I, typename Tuple>
struct TupleGetter;

// I == 0,拿当前层的 value
template <typename Head, typename... Tail>
struct TupleGetter<0, MyTuple<Head, Tail...>> 
{
    static Head& get(MyTuple<Head, Tail...>& t) 
    {
        return t.value; // 直接拿这一层的成员
    }
    static const Head& get(const MyTuple<Head, Tail...>& t) 
    {
        return t.value;
    }
};

// I > 0,把当前层剥掉,转嫁给父类,下标减一
template <size_t I, typename Head, typename... Tail>
struct TupleGetter<I, MyTuple<Head, Tail...>> 
{
    // 父类类型
    using BaseType = MyTuple<Tail...>;

    static auto& get(MyTuple<Head, Tail...>& t) 
    {
        // 把当前对象强制转成父类引用,然后在父类上找 get<I-1>
        return TupleGetter<I - 1, BaseType>::get(
            static_cast<BaseType&>(t)
        );
    }

    static const auto& get(const MyTuple<Head, Tail...>& t) 
    {
        return TupleGetter<I - 1, BaseType>::get(
            static_cast<const BaseType&>(t)
        );
    }
};

现在还有一个小问题,回头看咱们写的递归主模板:

template <typename Head, typename... Tail>
class MyTuple<Head, Tail...> 
    : private MyTuple<Tail...> 
{
    ...
};

我们对于基类使用的是 private,但 TupleGetter 是一个外部工具类,它跟 MyTuple 没半毛钱血缘关系,没有权限去访问私有基类。

在我们这个精简版 MyTuple 里大可使用 public,但为了小小的封装可以使用友元把所有的特化都声明成朋友:

template <typename Head, typename... Tail>
class MyTuple<Head, Tail...> 
    : private MyTuple<Tail...> 
{
    template <size_t, typename>
    friend struct TupleGetter;
    
    ...
};

好吧,有时候封装性是好事,但封的太死连我们请的工具人都进不来,就不叫封装,改叫封闭得了。

最后我们再来个 get 函数整个漂亮的门面:

template <size_t I, typename... Types>
auto& get(MyTuple<Types...>& t)
{
    return TupleGetter<I, MyTuple<Types...>>::get(t);
}

template <size_t I, typename... Types>
const auto& get(const MyTuple<Types...>& t)
{
    return TupleGetter<I, MyTuple<Types...>>::get(t);
}

然后我们就可以愉快的使用了:

MyTuple<int, double, std::string> t(111, 6.66, "Hello");
std::cout << get<0>(t) << std::endl; // 111
std::cout << get<1>(t) << std::endl; // 6.66
std::cout << get<2>(t) << std::endl; // Hello

这里关键就在于那个 static_cast<BaseType&>(t),我们毫无心理负担地切掉当前层,把对象当成它父类去用,然后在父类上继续递归找。由于所有东西都在编译期算好,这个 static_cast 完全是零成本的指针偏移。

好了,现在我们手上有一个虽然简陋但五脏俱全的 MyTuple 了。它证明了 tuple 只是递归继承 + 编译期偏移计算这一套干净的组合。

内存布局与空基类优化

1. tuple 的对齐与大小

tuple 的大小,取决于它元素的类型、顺序,以及实现有多

我们那个精简版 MyTuple,是直接用递归继承把每个元素嵌在各自那一层的 Head value 里。对于普通类型这没毛病,可一旦塞一个空类进去,比如 struct Empty {} 那恶心的玩意就来了:

sizeof(MyTuple<int, Empty, double>); // 24

于是 MyTuple<int, Empty, double> 的大小大概会变成:int(4) + padding(4) + Empty(1) + padding(7) + double(8) = 24 字节。

对于这件事,还是‘有空类占一字节’这条规矩惹的祸,明明是个空壳子,就因为要有独立地址硬生生啃掉我们 8 字节的布局,这是赤果果的浪费!

但是真正的 std::tuple 没这么傻,标准库的实现者早就恨透了这点,所以他们掏出了 EBO(Empty Base Optimization,空基类优化)这件马甲。

原理其实很鸡贼:如果我们从空类继承,而不是把它当成员,编译器就可以让基类子对象不占空间(标准允许基类子对象和派生类共用地址,只要类型不同即可)。

所以真正厚重的 tuple 会把每个元素包装一下:遇到空类型,就让它当基类;遇到非空类型,就老老实实当成员。这样一来,空元素就彻底隐身了。

这也顺便点拨了对齐逻辑:tuple 的对齐值,通常等于各元素对齐要求的最大值,因为每个元素要么嵌在成员里,要么嵌在 EBO 基类里,都一样受对齐约束,整个对象的大小会在此基础上补齐到该对齐值的倍数。

2. 布局分析

现在我们拿这个具体的例子开刀:

struct Empty {}; // sizeof(Empty) == 1,但作为基类可以为 0
std::tuple<int, Empty, double> t;

在一些主流编译器下,这个小东西的大小极可能是 16 字节,而不是刚才那版的 24。省哪儿了?省的就是 Empty 被 EBO 压没的那些填充。

那偏移 4 到 8 之间那 4 个字节被 int 后的对齐填充 吃掉了,因为后面紧跟着要求 8 字节对齐的 double,所以 int 后面得塞 4 字节 padding,让 double 老实呆在 8 的倍数上。

那 Empty 呢?它就像个幽灵,贴在 int 后面,但由于 EBO 的存在它不占实实在在的字节,没有把布局撑大,填充仍然是 4 字节,整体大小 16。

如果顺序换一下呢?比如 std::tuple<Empty, int, double>,很多实现可能还是 16(Empty 在最前面完全不占空间,int 偏移 0,再加填充,double 偏移 8)。

不过这是实现细节,有些实现会把空基类永远塞在最前面,有些则可能调整。标准只保证元素顺序和构造/析构顺序一致,没规定物理布局,但有 EBO 后空类型通常就白送我们了。

操作 tuple 类型本身

1. 类型查询与修改

标准库其实已经给了我们两件趁手的小工具,专门用来偷窥 tuple 的类型底裤。

查询

using T = std::tuple<int, double, std::string>;

size_t sz = std::tuple_size_v<T>; // 3,有几个元素
using second_t = std::tuple_element_t<1, T>; // double,第二个元素类型

没有运行时开销,全是 sizeof... 和类型萃取那套东西。

tuple_size 就是一个个数包里的参数个数,tuple_element 就是递归剥到第 N 个拿出来。

修改

如果我们想在 tuple 类型前面加一个新类型,拼成新 tuple,标准里没有现成的 push_front,但我们可以用 std::tuple_cat 整点活:

template <typename T, typename Tuple>
struct push_front;

template <typename T, typename... Ts>
struct push_front<T, std::tuple<Ts...>>
{
    using type = decltype(std::tuple_cat(std::declval<std::tuple<T>>(), 
        std::declval<std::tuple<Ts...>>()));
};

using new_tuple = push_front<int, std::tuple<double, char>>::type;
// new_tuple 就是 std::tuple<int, double, char>

这本质上就是让编译器帮我们做一次类型推导,根本不运行,纯在编译期干活。我们也可以完全不依赖 tuple_cat,自己用参数包拼接:

template <typename T, typename Tuple>
struct push_front;

template <typename T, typename... Ts>
struct push_front<T, std::tuple<Ts...>> 
{
    using type = std::tuple<T, Ts...>;
};

有了这些,我们就掌握了在编译期对 tuple 类型做增删改查的基本功,后面写任何复杂的元函数,底子都是这玩意。

2. 利用 index_sequence 实现万能访问

一个很具体的问题:我们想对一个 tuple 的所有元素逐个执行一个操作,打印也好,传给函数也好,但 get<I> 的 I 必须是编译期常量,没法用运行时的 for 循环。

怎么办?我们得在编译期生成一串从 0 到 N-1 的索引,然后用一种语言支持的方式把它们展开。

std::index_sequence<0,1,2,...,N-1> 就是这个索引串,它是类型,承载着一包编译期整数。我们可以通过 std::make_index_sequence<N> 创造一个。

template <typename Tuple, typename Func, size_t... Is>
void for_each_impl(Tuple&& t, Func&& f, std::index_sequence<Is...>)
{
    // 把 f 作用于每个 get<Is> 上,逗号运算符保证顺序
    (f(std::get<Is>(std::forward<Tuple>(t))), ...);
}

template <typename Tuple, typename Func>
void for_each(Tuple&& t, Func&& f)
{
    for_each_impl(
        std::forward<Tuple>(t), 
        std::forward<Func>(f),
        std::make_index_sequence<std::tuple_size_v<std::decay_t<Tuple>>>{}
    );
}

用起来:

auto t = std::make_tuple(1, 3.14, "hello");
for_each(t, [](const auto& x) { std::cout << x << std::endl; });

那个 (f(get<Is>...), ...) 折叠表达式在编译期瞬间展开成一个用逗号串联的调用序列,编译器老老实实帮我们把循环展开了,零开销,每个元素都享受类型安全。

但是为了这么个展开,我们又得去理解一大堆东西,只能说不愧是 C++。

应用与小巧思

这些东西可能不是天天用,但需要用的时候能想到就行。

1. 多返回值与结构化绑定

多返回值这事,就不啰嗦了,直接上代码:

auto get_info()
{
    return std::make_tuple("老王", 18, 175.0);
}

auto [name, age, height] = get_info(); // C++17 结构化绑定

结构化绑定是 C++ 为了tuple 量身定做的语法糖,它让我们拆包的时候不用声明 std::tuple_element,直接靠 auto 推。

如果我们需要引用原 tuple 内部的东西,就用 auto& [...],不会产生意外的拷贝。这一点比 std::tie 安全,因为 tie 不小心就会引到临时对象上。

2. 利用 std::apply 实现参数列表泛型编程

std::apply 把 tuple 炸开喂给函数,瞬间就让函数拥有了运行时决定的参数列表。

但如果反过来想,我们可以写一个函数,接受任意参数的 tuple,然后把它转成调用某个具体函数,这不就是一种参数列表的泛型编程吗?

实现一个通用的延迟调用

比如我们有一堆不同类型的参数,想一会再用它们调用某个函数,但又不想写一堆重载。

template <typename Func, typename... Args>
class deferred_call
{
    Func f;
    std::tuple<Args...> args;
public:
    deferred_call(Func f, Args... args) : f(std::move(f)), args(std::move(args)...) {}
    
    auto execute()
    {
        return std::apply(f, args);
    }
};

这样我们就可以把参数打包,execute() 时再展开。

3. 类型列表与编译期算法

tuple 不仅能装值,它在元编程里的另一半灵魂,是类型列表

我们可以把 std::tuple<int, double, string> 完全当成一个编译期的类型容器,值只是附加的。标准库里有 tuple_element、tuple_size 这些原始工具,但我们完全可以基于 tuple 实现一套编译期算法。

查找类型在 tuple 里的位置

template <typename T, typename Tuple>
struct index_of;

template <typename T, typename... Ts>
struct index_of<T, std::tuple<T, Ts...>> 
    : std::integral_constant<size_t, 0> {};

template <typename T, typename U, typename... Ts>
struct index_of<T, std::tuple<U, Ts...>> 
    : std::integral_constant<size_t, 1 + index_of<T, std::tuple<Ts...>>::value> {};

如果找不到,别忘记兜底处理,比如给个 -1 啥的。

把每个类型套上一个外壳

假设我们要为 tuple 里的每个类型生成对应的 unique_ptr:

template <template <typename> class F, typename Tuple>
struct transform_tuple;

template <template <typename> class F, typename... Ts>
struct transform_tuple<F, std::tuple<Ts...>>
{
    using type = std::tuple<F<Ts>...>;
};

using ptr_tuple = transform_tuple<std::unique_ptr, std::tuple<int, double>>::type;

这种纯类型体操,和容器里实际的值一点关系没有,但是能帮我们生成对应的容器类型。

结尾

tuple 是好工具,但别把它当 struct 用。

如果我们的数据成员有清晰的名字和语义,别手懒,写个结构体比什么都强。tuple 适合那些临时的、泛型的、不需要名字的场合,或者模板深处我们实在没法提前知道类型的地方。

用对了,爽歪歪;用歪了,那就一起崩溃吧( ◜◡‾)。

更多推荐