14. pair与tuple:从双人到多人的数据打包术

说实话,pair和tuple这俩东西,在我刚学C++那会儿觉得挺鸡肋的。不就是把几个值捆在一起吗?我自己写个struct不香吗?后来真上了项目,才发现——嗯,有时候你就是要一个轻量级的“临时组合”,专门给std::mapstd::unordered_map或者某些算法返回值用的。这时候再专门写个struct,反而显得啰嗦。

好,咱们今天就把pair和tuple彻底聊透。从构造到使用,再到C++17的结构化绑定,一条线串下来。

14.1 pair:最简单的数据对

std::pair定义在<utility>头文件里。它就是把两个值打包成一个对象。两个值的类型可以不同,也可以相同。

14.1.1 pair的构造

构造pair的方式有好几种,我个人最常用的是std::make_pair,因为它省得你写模板参数。

#include <iostream>
#include <utility>  // pair
#include <string>

int main() {
    // 方式1:直接构造
    std::pair<int, std::string> p1(42, "hello");

    // 方式2:make_pair(推荐,类型自动推导)
    auto p2 = std::make_pair(3.14, "world");

    // 方式3:拷贝构造
    std::pair<int, std::string> p3(p1);

    // 方式4:C++11 列表初始化
    std::pair<int, std::string> p4{100, "C++"};

    std::cout << p1.first << ", " << p1.second << std::endl;
    return 0;
}

这里有个小细节:make_pair推导出来的类型有时候会带引用或const,如果你想要精确控制,还是显式写模板参数更稳妥。我在项目中遇到过一个问题——用make_pair传了一个字符串字面量,结果推导出const char*而不是std::string,后面赋值时出了点小麻烦。从那以后,我对于需要长期持有的pair,都会显式指定类型。

14.1.2 pair的访问与比较

访问pair的两个成员,用.first.second。比较操作符也是现成的:先比较first,再比较second。这个顺序很重要,你想想看,map的底层就是用pair的operator<来排序的。

std::pair<int, int> a(1, 2);
std::pair<int, int> b(1, 3);
std::pair<int, int> c(2, 1);

if (a < b)  // true,因为first相等,second: 2 < 3
    std::cout << "a < b" << std::endl;

if (a < c)  // true,因为first: 1 < 2
    std::cout << "a < c" << std::endl;

小提示:pair的operator==要求两个成员都支持==比较。如果你的类型没有定义==,那就没法直接用pair的相等比较了。

14.2 tuple:pair的升级版

pair只能装两个值。那三个、四个、五个呢?C++11引入了std::tuple,定义在<tuple>头文件里。说白了,tuple就是pair的泛化版本,可以装任意数量的值。

14.2.1 tuple的构造与访问

构造tuple的方式和pair类似。但访问成员就不能用.first.second了,得用std::get

#include <tuple>
#include <iostream>
#include <string>

int main() {
    // 方式1:直接构造
    std::tuple<int, double, std::string> t1(1, 2.5, "tuple");

    // 方式2:make_tuple(推荐)
    auto t2 = std::make_tuple(10, 3.14, "hello");

    // 方式3:std::tie 构造(绑定到已有变量)
    int x = 0;
    double y = 0.0;
    std::string z;
    std::tie(x, y, z) = std::make_tuple(100, 2.71, "tie");

    // 访问:按索引
    std::cout << std::get<0>(t1) << std::endl;  // 1
    std::cout << std::get<1>(t1) << std::endl;  // 2.5
    std::cout << std::get<2>(t1) << std::endl;  // "tuple"

    // 访问:按类型(要求类型唯一)
    std::cout << std::get<double>(t1) << std::endl;  // 2.5

    return 0;
}

注意:按类型访问时,如果tuple里有多个相同类型,编译会报错。比如tuple<int, int, double>,你用get<int>就歧义了。这时候只能用索引。

14.2.2 tuple的常用操作

操作说明示例
std::get<I>(t)获取第I个元素(I是编译期常量)get<0>(t)
std::tuple_size<T>::value获取tuple的元素个数tuple_size<decltype(t)>::value
std::tuple_element<I, T>::type获取第I个元素的类型tuple_element<0, decltype(t)>::type
std::tie(t1, t2, ...)创建左值引用的tuple,用于解包tie(a, b) = t
std::tuple_cat(t1, t2)拼接两个tupleauto t3 = tuple_cat(t1, t2)

我记得有一次写一个多返回值函数,返回了一个4元tuple。调用方用std::tie一口气解包到四个变量里,代码看起来特别清爽。不过后来代码维护的人说看不懂……所以我的建议是:tuple适合内部使用,如果是公开接口,还是用struct更清晰。

14.3 结构化绑定(C++17):告别冗长的解包

在C++17之前,从pair或tuple里取出值,要么用.first/.second,要么用std::get,要么用std::tie。都挺啰嗦的。C++17的结构化绑定(Structured Bindings)直接让这件事变得优雅。

14.3.1 基本语法

#include <tuple>
#include <map>
#include <iostream>
#include <string>

int main() {
    // 绑定pair
    std::pair<int, std::string> p{42, "answer"};
    auto [id, name] = p;  // id = 42, name = "answer"
    std::cout << id << ", " << name << std::endl;

    // 绑定tuple
    std::tuple<int, double, char> t{1, 2.5, 'A'};
    auto [a, b, c] = t;  // a=1, b=2.5, c='A'

    // 绑定数组
    int arr[3] = {10, 20, 30};
    auto [x, y, z] = arr;  // x=10, y=20, z=30

    // 绑定map迭代器(经典用法)
    std::map<int, std::string> m = {{1, "one"}, {2, "two"}};
    for (const auto& [key, value] : m) {
        std::cout << key << ": " << value << std::endl;
    }

    return 0;
}

你看,auto [key, value] 这一行,直接把pair的两个成员拆开命名了。不用再写it->firstit->second,代码可读性提升一大截。

14.3.2 修饰符与引用绑定

结构化绑定支持const&&&等修饰符。这个细节很重要——如果你想要修改原pair里的值,或者避免拷贝,一定要加引用。

std::pair<int, std::string> p{1, "hello"};

// 拷贝绑定(修改不影响原pair)
auto [a, b] = p;
a = 100;  // p.first 还是 1

// 引用绑定(修改影响原pair)
auto& [c, d] = p;
c = 200;  // p.first 变成 200

// const引用绑定(只读)
const auto& [e, f] = p;
// e = 300;  // 编译错误

注意:结构化绑定不能直接用于std::tuple的右值引用绑定(比如auto&& [a, b] = std::make_tuple(...)),因为右值tuple的成员是临时对象,引用绑定会导致悬垂引用。我见过有人踩过这个坑——绑定了一个临时tuple,然后访问时数据已经销毁了。

14.3.3 结构化绑定的底层原理

结构化绑定并不是创建了一个新的变量,而是编译器帮你生成了若干个“别名”。对于pair和tuple,它实际上调用了std::get<I>。对于数组,它直接访问下标。对于结构体,它访问公有成员。

这意味着:结构化绑定对性能没有额外开销。它就是个语法糖,编译后和你手写get<0>get<1>是一样的。

14.4 知识体系图

下面这张图把pair、tuple和结构化绑定的关系梳理清楚了。你可以看到,pair是tuple的特例(2元tuple),而结构化绑定是它们统一的解包方式。

pair 与 tuple 知识体系 std::pair 两个值的打包 构造:make_pair / 列表初始化 访问:.first / .second 比较:先first后second std::tuple 任意多个值的打包 构造:make_tuple / tie 访问:get<I> / get<Type> 操作:tuple_cat / tuple_size 特例 结构化绑定(C++17) auto [a, b, c] = pair/tuple/array/struct 支持 const / & / && 修饰符,零开销抽象 核心思想:用最少的代码,打包和解包多个值

14.5 避坑指南与个人经验

最后,我分享几个实际项目中遇到的坑,希望能帮你少走弯路。

  • pair的隐式转换:我曾经用make_pair("key", 123)往map里插数据,结果key的类型是const char*而不是std::string。虽然map的key是string,但pair构造时不会自动转换,导致多了一次拷贝。后来我改成std::pair<std::string, int>("key", 123),问题解决。
  • tuple的get索引必须是编译期常量:你不能写for(int i=0; i<3; ++i) get<i>(t)。i是运行期变量,编译不过。如果你需要遍历tuple,得用模板元编程或者C++17的std::apply
  • 结构化绑定与auto&&的陷阱:前面提过,绑定临时对象时用auto&&会导致悬垂引用。我的建议是:除非你确定绑定的对象是左值且生命周期足够长,否则一律用autoconst auto&
  • 不要滥用tuple:tuple虽然方便,但成员没有名字。代码里到处都是get<0>get<1>,三个月后你自己都看不懂哪个是哪个。我个人的原则是:超过3个成员,或者成员语义不明确,就写成struct。

核心总结:

  • pair装2个值,tuple装任意多个值
  • 结构化绑定是C++17最实用的语法糖之一,让pair/tuple的解包变得优雅
  • 引用绑定可以避免拷贝,但要小心生命周期
  • tuple适合内部临时使用,公开接口建议用struct

公众号:蓝海资料掘金营,微信deep3321