ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ pair 与 tuple:从 std::tie 到 C++17 结构化绑定

C++ pair 与 tuple:从 std::tie 到 C++17 结构化绑定 一个函数想返回两个值、一个结构体想当排序键、一次遍历想同时拿到 key 和 value这些需求都会走到std::pair和std::tuple上。C17 的结构化绑定structured binding让它们的用法舒服了很多但也埋了一个特别容易踩的坑auto [a, b] ...默认是拷贝。这篇先补齐pair/tuple/tie的基础工具再把结构化绑定的四种适用场景和拷贝陷阱一个个跑给你看。1. 引子函数想「返回两个值」怎么办C 的函数只能返回一个值。想同时吐出「是否成功」和「结果」常见三种绕法做法问题输出参数bool f(int in, Result out)调用点看不出哪个是输出还要先构造out返回结构体得为每个返回值组合定义一个新类型字段一多就泛滥返回std::pair/std::tuple标准库已经给好的通用「多返回值」载体第三种就是这一篇的主角。它们本质上是聚合了若干个字段的轻量结构体标准库顺手把比较、取值、展开都做好了。2. pair两个元素的组合std::pairT1, T2只有两个公开成员first和second。它自带字典序比较先比first相等再比second这一点在当排序键时非常好用。// pair_basic.cpp — 编译: g -stdc17 -Wall -O2 pair_basic.cpp -o demo #include cstdio #include string #include utility int main() { const std::pairstd::string, int a std::make_pair(std::string(miao), 30); // C17 起有推导指引deduction guide模板参数可以省掉 const std::pair b{std::string(tom), 25}; std::printf(a (%s, %d)\n, a.first.c_str(), a.second); std::printf(b (%s, %d)\n, b.first.c_str(), b.second); std::printf(a b ? %s\n, a b ? true : false); std::printf(b a ? %s\n, b a ? true : false); }a (miao, 30) b (tom, 25) a b ? true b a ? false两个要点std::make_pair会对参数做退化decay—— 数组退化成指针、const被剥掉、引用被去掉。想要保留引用得直接写std::pairT, U而std::tie正是这么干的第 4 节。pair的比较是「字典序 短路」先比first只有相等时才去看second。所以(miao,30) (tom,25)为真 ——miao tom已经决定了结果30和25根本没参与。上面第一个坑值得单独看一个片段。C17 的 CTAD 虽然能省模板参数但它推导的是「参数表达式本身的类型」字符串字面量推出来是数组再退化成指针// 坑直接传字符串字面量T1 会被推导成 const char*不是 std::string // auto c std::pair{tom, 25}; // c 的类型是 std::pairconst char*, int // auto d std::make_pair(tom, 25); // 同样退化成 std::pairconst char*, int // 想让 key 是 std::string就得显式构造std::pair{std::string{tom}, 25}官方文档std::pair、std::make_pair3. tuple任意个数的组合std::tupleTs...是pair的推广字段个数不限。取值不能用.first/.second只能靠编译期下标std::getN。// tuple_basic.cpp — 编译: g -stdc17 -Wall -O2 tuple_basic.cpp -o demo #include cstddef #include cstdio #include string #include tuple #include type_traits int main() { using Record std::tupleint, std::string, double; constexpr std::size_t kFieldCount std::tuple_size_vRecord; std::printf(字段个数: %zu\n, kFieldCount); // tuple_element_tN, T 在编译期给出第 N 个字段的类型下标从 0 开始 static_assert(std::is_same_vstd::tuple_element_t1, Record, std::string); const Record r{42, answer, 3.5}; std::printf(get0 %d\n, std::get0(r)); std::printf(get1 %s\n, std::get1(r).c_str()); std::printf(get2 %.1f\n, std::get2(r)); // 反例不要这么写get 的下标必须是编译期常量运行期变量编译不过 // std::size_t i 1; std::geti(r); }字段个数: 3 get0 42 get1 answer get2 3.5std::get的样子值得记住三点下标必须是常量表达式因为是模板参数。想按下标循环访问 tuple只能靠std::apply展开或模板递归不能用 for 循环。std::getT还能按类型取要求该类型在 tuple 里只出现一次std::getdouble(r)等价于std::get2(r)。std::getN的返回类型是引用对左值 tuple 返回T对const左值返回const T对右值返回T。所以它能直接改内容。官方文档std::tuple、std::get、std::tuple_size4. std::tie写进已有变量的「解包器」std::tie返回的是一个元素全是左值引用的 tuple也就是std::tupleT1, T2, ...。这个设计让它成为「把 tuple 拆进已有变量」的唯一顺手工具// tie_demo.cpp — 编译: g -stdc17 -Wall -O2 tie_demo.cpp -o demo #include cstdio #include string #include tuple namespace { std::tuplebool, int, std::string parseUser(int id) { if (id 0) { return {false, 0, {}}; } return {true, id, user- std::to_string(id)}; } } // namespace int main() { // 每个字段按位置赋给一个已经声明好的变量 bool ok false; int id 0; std::string name; std::tie(ok, id, name) parseUser(7); std::printf(ok%d id%d name%s\n, ok ? 1 : 0, id, name.c_str()); // std::ignore 占位跳过不关心的字段 int only_id 0; std::tie(std::ignore, only_id, std::ignore) parseUser(9); std::printf(只取 id: %d\n, only_id); // tie 的经典用法之二直接拿它当多字段排序键 const auto key1 std::tie(id, name); const auto key2 std::tie(only_id, name); std::printf(tie 造出的键也能比较: key1 key2 ? %s\n, key1 key2 ? true : false); }ok1 id7 nameuser-7 只取 id: 9 tie 造出的键也能比较: key1 key2 ? true为什么std::tie必须返回引用 tuple因为它的语义就是「把已有变量打包成一个引用集合好让赋值运算符逐个写回去」。如果它返回值 tuplestd::tie(a, b) ...就只是在改一个临时对象调用方看不到任何效果。两个必须记住的坑别用std::tie绑定临时变量。std::tie(x, y) f()里f()的返回值在整条语句结束时销毁而tie保存的是对已有变量的引用 —— 只要x/y生命周期够长就没问题但如果拿它去接收一个引用型 tuple比如std::tie自己造出来的就会挂上悬垂引用。std::ignore是std::tie的好搭档用来吃掉不关心的位置比「声明一个用不到的变量」干净。官方文档std::tie、std::ignore5. std::apply把 tuple 拆成函数实参C17有了 tuple早晚会遇到「这堆参数存在 tuple 里能不能直接喂给某个函数」的需求。C17 的std::apply就是干这个的它把 tuple 的每个元素按顺序展开成实参。// apply_demo.cpp — 编译: g -stdc17 -Wall -O2 apply_demo.cpp -o demo #include cstdio #include string #include tuple namespace { int add(int a, int b, int c) { return a b c; } struct Printer { void operator()(int id, const std::string name) const { std::printf(Printer: id%d name%s\n, id, name.c_str()); } }; } // namespace int main() { const std::tupleint, int, int args{1, 2, 3}; // 展开给普通函数 std::printf(apply(add) %d\n, std::apply(add, args)); // 展开给 lambda const int digits std::apply([](int a, int b, int c) { return a * 100 b * 10 c; }, args); std::printf(apply(lambda) %d\n, digits); // 展开给函数对象参数类型可以不同 const std::tupleint, std::string row{7, miao}; std::apply(Printer{}, row); }apply(add) 6 apply(lambda) 123 Printer: id7 namemiao实际用途一次性转发整包参数。比如用std::apply把记录元组喂给构造函数或用它把参数原封不动转给另一层调用把参数存成 tuple 再apply能绕开「参数个数不定」的模板展开手写麻烦。官方文档std::apply6. C17 结构化绑定能用在四种实体上结构化绑定structured binding一句话概括auto [a, b] expr;会把expr拆成若干个名字a、b直接可用不用写.first或std::get0。它能绑定的东西有四类绑定目标要求例子数组C 数组 /std::array长度编译期已知auto [x, y] raw;pair/tuple这类「tuple-like」类型特化了std::tuple_size且支持getNauto [k, v] *it;聚合类aggregate成员全 public、无非静态基类、无虚函数、无用户声明的构造函数auto [x, y] point;自己特化了std::tuple_sizeget的类型提供上述「元组协议」自定义Record// structured_binding_kinds.cpp — 编译: g -stdc17 -Wall -O2 structured_binding_kinds.cpp -o demo #include cstdio #include map #include string #include tuple namespace { struct Point { int x; int y; }; std::tupleint, std::string, double makeRecord() { return {1, hello, 2.5}; } } // namespace int main() { // 1) tuple-like const auto [id, name, score] makeRecord(); std::printf(tuple : id%d name%s score%.1f\n, id, name.c_str(), score); // 2) pairmap 的元素类型就是 pairconst Key, T const std::mapstd::string, int scores{{alice, 90}}; const auto [key, value] *scores.begin(); std::printf(pair : %s %d\n, key.c_str(), value); // 3) C 数组 const int raw[3] {10, 20, 30}; const auto [p, q, r] raw; std::printf(array : %d %d %d\n, p, q, r); // 4) 聚合类 const Point pt{3, 4}; const auto [px, py] pt; std::printf(struct : x%d y%d\n, px, py); }tuple : id1 namehello score2.5 pair : alice 90 array : 10 20 30 struct : x3 y4聚合类的绑定顺序就是成员声明顺序不是名字的字典序也不是内存布局的猜测。所以Point{x, y}绑出来的一定是(x, y)。这个特性让结构化绑定成了遍历map最舒服的写法for (const auto [key, value] : m)。官方文档结构化绑定声明 — cppreference7. 最关键的坑默认是拷贝不是引用这是结构化绑定最值得单独跑一遍的地方。auto [a, b] expr;里编译器会先造一个隐藏变量e默认就是expr的副本然后a、b只是e的成员的新名字。它们不是expr的成员的别名。auto [a, b] expr; 真正展开成什么 ├─ 隐藏变量 auto e expr; ← 默认是「拷贝」 └─ 名字映射 a ⇢ e 的第 0 个成员 b ⇢ e 的第 1 个成员 expr ┌───────────┬───────────┐ │ 成员 0 │ 成员 1 │ ← 原对象a/b 碰不到它 └───────────┴───────────┘ │ 拷贝 ▼ e ┌───────────┬───────────┐ │ 成员 0 │ 成员 1 │ └─────┬─────┴─────┬─────┘ │ │ a b ← 改 a 改的是 e 的成员 写 auto [a, b] expr; → 隐藏变量变成 auto e expr; 这时 a / b 才是 expr 成员的真正别名跑一下看差异// binding_copy_vs_ref.cpp — 编译: g -stdc17 -Wall -O2 binding_copy_vs_ref.cpp -o demo #include cstdio #include tuple int main() { std::tupleint, int t{1, 2}; //一默认拷贝改 a / b 只改副本原 tuple 不动 auto [a, b] t; a 100; b 200; std::printf(auto [a, b]: a%d b%d | 原 tuple (%d, %d)\n, a, b, std::get0(t), std::get1(t)); //二想要引用就显式写 auto这时改的就是原 tuple auto [c, d] t; c 300; d 400; std::printf(auto [c, d]: c%d d%d | 原 tuple (%d, %d)\n, c, d, std::get0(t), std::get1(t)); }auto [a, b]: a100 b200 | 原 tuple (1, 2) auto [c, d]: c300 d400 | 原 tuple (300, 400)两种写法一眼的差别auto那行原 tuple 还是(1, 2)auto那行变成了(300, 400)。这个坑在遍历容器时最要命。如果容器存的是std::string这种拷贝代价高的类型// 反例不要这么写每次迭代都拷贝一份 key 和 value for (auto [key, value] : heavy_map) { // key/value 是副本白拷贝一次 use(key, value); }正确写法是for (const auto [key, value] : heavy_map)。不拷贝同时限死只读。原写法的问题在于多了一次深拷贝std::string会分配堆内存n 次迭代就是 n 次 malloc/free纯粹的白给。官方文档C Core Guidelines — ES: Expressions and statementsES.11用auto避免重复写类型名 —— 但用auto时要注意拷贝语义8. 拷贝 vs 引用四种写法的选择表写法隐藏变量是什么能改原对象吗拷贝开销典型场景auto [a, b]原对象的副本不能改的是副本有可能很贵成员是int/ 指针这类小值对象auto [a, b]原对象的非 const 引用能无需要就地修改元素const auto [a, b]原对象的const 引用不能无只读遍历容器最常用auto [a, b]转发引用forwarding reference左右值都能绑绑左值时能改无绑定函数返回的临时 tuple泛型代码auto值得单独说一句函数返回 tuple 时返回的是一个临时对象auto绑定它会延长这个临时的生命周期和const auto一样而且还能改里面的内容// binding_forward_ref.cpp — 编译: g -stdc17 -Wall -O2 binding_forward_ref.cpp -o demo #include cstdio #include string #include tuple #include unordered_map namespace { std::tupleint, std::string makeRow() { return {7, seven}; } } // namespace int main() { // auto 绑定返回的临时 tuple生命周期被延长且可写 auto [id, name] makeRow(); std::printf(绑定临时 tuple: id%d name%s\n, id, name.c_str()); id 8; name !; std::printf(就地修改后 : id%d name%s\n, id, name.c_str()); // 只读遍历const auto 是最安全的默认选择 const std::unordered_mapstd::string, int counts{{a, 1}, {b, 2}}; int total 0; for (const auto [word, count] : counts) { total static_castint(word.size()) * count; // 不拷贝 key也不拷贝 value } std::printf(加权长度合计: %d\n, total); }绑定临时 tuple: id7 nameseven 就地修改后 : id8 nameseven! 加权长度合计: 39. 结构化绑定做不到的事这三条限制经常让人写出编译不过的代码提前记住能省不少时间// 反例 1结构化绑定只能用在变量声明里不能当类型用 // std::tupleint, int [lo, hi] std::make_tuple(1, 2); // 编译错误 // auto [lo, hi] std::make_tuple(1, 2); // 正确 // 反例 2不能用它标注函数的返回类型 // auto [lo, hi] f(); // 编译错误语法上就不允许 // 反例 3C17 的 lambda 不能捕获结构化绑定的名字需要 C20 // auto [lo, hi] std::make_tuple(1, 2); // auto f [lo, hi] { return lo hi; }; // C17 编译错误原因都指向同一个实现细节结构化绑定不引入「真正的变量」。标准规定名字绑定到一个隐藏对象上名字本身不是变量所以既不能当类型写、也不能被 lambda 捕获C17。C20 通过 P1091R3 放宽了 lambda 捕获这一条。还有一个更容易忽略的点decltype作用在绑定名上得到的是成员类型不是引用类型。所以decltype(name)是std::string不是std::string—— 在泛型代码里用decltype时要留心。10. 完整示例用 tie 造排序键把前几节的点串起来。给结构体排序时如果字段多手写比较链if (a.x ! b.x) return ...很容易漏字段或者写反用std::tie把要参与比较的字段打包成 tuple再借tuple自带的字典序比较一行就能表达「按 A 降序、再按 B 降序、再按 C 降序」。// employee_sort.cpp — 编译: g -stdc17 -Wall -O2 employee_sort.cpp -o demo #include algorithm #include cstdio #include string #include tuple #include vector namespace { struct Employee { std::string name; int age{}; int salary{}; }; // 排序键把参与比较的字段按优先级打包。字段顺序 比较优先级 auto sortKey(const Employee e) { return std::tie(e.salary, e.age, e.name); } } // namespace int main() { std::vectorEmployee staff{ {miao, 30, 20000}, {tom, 25, 20000}, {jerry, 25, 18000}, }; std::sort(staff.begin(), staff.end(), [](const Employee lhs, const Employee rhs) { return sortKey(lhs) sortKey(rhs); // 用 得到降序 }); std::printf(按 薪水降序 年龄降序 姓名降序:\n); for (const auto [name, age, salary] : staff) { std::printf( %-6s age%d salary%d\n, name.c_str(), age, salary); } }按 薪水降序 年龄降序 姓名降序: miao age30 salary20000 tom age25 salary20000 jerry age25 salary18000三点值得注意sortKey返回std::tupleconst int, const int, const std::stringtie造出来的是引用 tuple引用的都是入参对象的成员生命周期没问题。tuple的比较是逐字段的字典序所以「字段书写顺序 比较优先级」不用再维护一条if链。for (const auto [name, age, salary] : staff)这行同时用到了第 6 节的聚合类绑定和第 8 节的const auto—— 只读、零拷贝是遍历结构体容器最省事的写法。官方文档std::sort自定义比较器要求严格弱序tuple的operator天然满足11. 延伸阅读std::pair — cppreferencefirst/second、std::make_pair的退化规则、比较运算符的重载清单std::tuple — cppreference成员函数、tuple_cat、CTAD 推导指引写泛型代码时的第一手参考std::tie — cppreference明确写着「返回左值引用的 tuple」理解这句就理解了它的全部用途std::apply — cppreferenceC17 起可用展开 tuple 为实参结构化绑定声明 — cppreference四种绑定目标、隐藏变量的三种情形数组 / tuple-like / 聚合类是这一篇最该精读的一页本知识库内的相关篇目《map / set 完全指南红黑树与有序容器》 —— 讲透 std::map / std::set 背后那棵红黑树——有序和 O(log n) 是同一套结构的《C17 核心新特性总览一张速查表 8 个上手示例》 —— C17 是「写业务代码体感变化最大」的一个版本。顺带把新特性与迁移这条线也铺一下。《auto 类型推导到底推成了什么与 decltype 的差异》 —— auto 不是把右边类型原样抄过来顺带把基础语法的底子这条线也铺一下。12. 一句话总结pair/tuple是标准库给的「多返回值」载体std::tie负责把 tuple 拆进已有变量、顺带当零成本的多字段排序键C17 结构化绑定把取值写成了auto [a, b]但它默认拷贝——只读遍历一律用const auto [k, v]要就地修改才用auto绑临时对象用auto。
返回列表