重识 std::tuple:一个被低估的编译期异构容器

目录

[重新认识 tuple](#重新认识 tuple)

[1. 为什么要有 tuple?](#1. 为什么要有 tuple?)

[2. tuple 的本质](#2. tuple 的本质)

[手写精简版 MyTuple](#手写精简版 MyTuple)

[1. 空基类特化](#1. 空基类特化)

[2. 递归主模板](#2. 递归主模板)

[3. 实现 get()](#3. 实现 get())

内存布局与空基类优化

[1. tuple 的对齐与大小](#1. tuple 的对齐与大小)

[2. 布局分析](#2. 布局分析)

[操作 tuple 类型本身](#操作 tuple 类型本身)

[1. 类型查询与修改](#1. 类型查询与修改)

[2. 利用 index_sequence 实现万能访问](#2. 利用 index_sequence 实现万能访问)

应用与小巧思

[1. 多返回值与结构化绑定](#1. 多返回值与结构化绑定)

[2. 利用 std::apply 实现参数列表泛型编程](#2. 利用 std::apply 实现参数列表泛型编程)

[3. 类型列表与编译期算法](#3. 类型列表与编译期算法)

结尾


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

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

重新认识 tuple

1. 为什么要有 tuple?

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

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

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

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

cpp 复制代码
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>,编译器大致会给我们生出一个类似这样的玩意儿(简化版):

cpp 复制代码
// 递归终点
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 是多继承的递归,所以终点就是一个啥也没有的空壳子。

cpp 复制代码
template <typename... Types>
class MyTuple {};

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

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

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

2. 递归主模板

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

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

cpp 复制代码
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 函数模板不能偏特化,所以我们用一个静态工具类来做这个脏活。

cpp 复制代码
// 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)
        );
    }
};

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

cpp 复制代码
template <typename Head, typename... Tail>
class MyTuple<Head, Tail...> 
    : private MyTuple<Tail...> 
{
    ...
};

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

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

cpp 复制代码
template <typename Head, typename... Tail>
class MyTuple<Head, Tail...> 
    : private MyTuple<Tail...> 
{
    template <size_t, typename>
    friend struct TupleGetter;
    
    ...
};

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

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

cpp 复制代码
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);
}

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

cpp 复制代码
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 {} 那恶心的玩意就来了:

cpp 复制代码
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. 布局分析

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

cpp 复制代码
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 的类型底裤。

查询

cpp 复制代码
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 整点活:

cpp 复制代码
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,自己用参数包拼接:

cpp 复制代码
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> 创造一个。

cpp 复制代码
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>>>{}
    );
}

用起来:

cpp 复制代码
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. 多返回值与结构化绑定

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

cpp 复制代码
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,然后把它转成调用某个具体函数,这不就是一种参数列表的泛型编程吗?

实现一个通用的延迟调用

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

cpp 复制代码
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 里的位置

cpp 复制代码
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:

cpp 复制代码
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 适合那些临时的、泛型的、不需要名字的场合,或者模板深处我们实在没法提前知道类型的地方。

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

相关推荐
techdashen1 小时前
用 Rust 写生产级服务要踩多少坑——Cloudflare 把答案做成了一个开源库
开发语言·rust·开源
码界奇点2 小时前
基于Python的微信公众号爬虫系统设计与实现
开发语言·爬虫·python·毕业设计·web·源代码管理
瞎折腾啥啊2 小时前
VCPKG详细使用教程
linux·c++·cmake·cmakelists
落雪寒窗-2 小时前
Python开发个人日常记录
开发语言·python
启山智软2 小时前
【 商城系统源码:Java与PHP的区别】
java·开发语言·php
练习时长两年半的程序员小胡2 小时前
Java程序员转大模型应用开发专题(一):核心基础概念
java·开发语言·transformer·自注意力
源图客2 小时前
PHP开发环境搭建
开发语言·php
Evand J2 小时前
MATLAB绘图函数介绍:plotmatrix绘图,附MATLAB例子
开发语言·matlab·绘图
比特 GOK2 小时前
Qt项目ui文件中新添加的控件在代码中不识别的问题解决
开发语言·qt·ui