底层实现深度解析)
面向底层机制 | 从语法糖到编译器等价展开 | C17 核心特性一、前言一个拆包的烦恼假设你要写一段代码从函数里拿回一个 std::pairint, double然后分别使用它的两个成员// 版本 1没有结构化绑定的痛苦岁月C17 之前 std::pairint, double result compute(); int id result.first; // 要记得 .first 是第一个 double score result.second; // 要记得 .second 是第二个 // 更痛苦的是result 这个中间变量还得一直活着直到你用完全部字段写多了你会发现我只是想拿两个数为什么还要记住 .first / .secondC17 给出了答案——结构化绑定Structured Bindings// 版本 2C17 结构化绑定 auto [id, score] compute(); // 一行搞定名字自己取 // 接下来直接用 id 和 score 即可通俗类比快递拆箱旧写法就像你把快递箱搬到家里每次想拿里面的东西都要喊箱子里的第一格箱子里的第二格结构化绑定就像你把箱子拆开直接给里面每样东西贴上一个名字标签然后把箱子扔掉——从此你只跟名字打交道再也不关心它原来放在哪个格子。本文目标不只教你怎么用而是带你钻进编译器内部看它究竟把 auto [id, score] compute(); 展开成了什么代码。看完之后你将理解结构化绑定为什么能绑数组、能绑 std::pair、能绑你自己的类为什么 auto [a, b] 能修改原对象而 auto [a, b] 不行为什么有的类型绑出来是值有的绑出来是引用常见悬挂陷阱的根源在哪里二、结构化绑定是什么快递拆箱类比2.1 语法总览结构化绑定Structured Binding Declaration是 C17 引入的声明语法允许将一个复合对象拆成多个具名变量。基本语法attr(可选) cv-auto ref(可选) [ identifier-list ] expression; // ^^^^ ^^^^^^^^ // 绑定模式 绑定名字列表实际写法常见四种auto [a, b] obj; // (1) 按值复制绑定 auto [a, b] obj; // (2) 左值引用绑定可修改原对象 const auto [a, b] obj; // (3) const 左值引用绑定只读不拷贝 auto [a, b] getObj(); // (4) 转发引用绑定可绑定临时对象2.2 与普通声明的本质区别⚠️ 第一课很多人以为 auto [a, b] obj; 是定义了两个变量 a 和 b。这是错的。从语言标准[dcl.struct.bind]的角度看结构化绑定不是引入一组普通变量而是引入一组名字names这些名字是某个**隐藏的匿名变量e**的别名或成员。标准原文的展开逻辑是// 你写的 auto [a, b] expr; // 编译器实际等价于 auto e expr; // 1. 先创建一个匿名变量 e这里 auto 决定了 e 的类型 // 然后 a、b 分别被绑定到 e 的某一部分取决于绑定模式a 和 b不是新对象它们是 e 内部某些东西的名字。这正是它和普通变量声明最核心的区别——没有发生额外的拷贝/移动在某些模式下也没有引入真正独立的变量。通俗类比e 是那个快递箱a、b 是你贴在箱子内部物品上的标签。标签不是新的物品只是让你能直接称呼它们。三、底层三种绑定模式编译器如何拆箱编译器遇到结构化绑定会先判断 expr 的类型属于下面哪一类再决定怎么拆绑定模式判定条件绑定原理典型例子① 数组绑定expr 的类型是数组类型名字依次绑定到数组的每个元素int arr[3]② tuple-like 绑定存在 std::tuple_sizeE 的特化且 E 中成员可用名字绑定到 e.geti() 或 geti(e) 的返回值std::pair / std::tuple / std::array / 自定义类型③ 数据成员绑定E 是非联合体的类类型且所有成员都是非静态、公有、直接非基类成员名字直接绑定到 e.member普通的 struct / classpublic 成员判定优先级先看是不是数组 → 再看有没有 tuple_size 特化 → 都不是就要求数据成员。三种模式互斥一次只走一条路。3.1 模式①数组绑定最直白的拆法int arr[3] {10, 20, 30}; auto [x, y, z] arr; // 数组绑定等价展开编译器视角auto e arr; // e 的类型是 int[3]注意数组会先被拷贝 // x → e[0]y → e[1]z → e[2]名字是元素别名 // x、y、z 的类型是 int元素的类型⚠️关键坑点这里 auto e arr; 会拷贝整个数组因为 arr 是左值数组auto 推导数组时会发生数组→数组的拷贝初始化。如果数组很大代价不可忽视。想避免拷贝就用引用auto [x, y, z] arr; // e 是 int()[3]绑定原数组零拷贝修改 x 会改到 arr[0]3.2 模式②tuple-like 绑定最灵活的拆法这是结构化绑定最核心、最强大的模式。判定的关键是**类型属性trait**而不是类型本身存在 std::tuple_sizeE::value可用作编译期常量std::tuple_elementi, E::type 可用成员函数 e.geti() 可用或自由函数 geti(e) 可用等价展开编译器视角以 std::pair 为例// 你写的 std::pairint, double p{42, 3.14}; auto [id, score] p; // 编译器实际展开为 auto e p; // e 的类型是 std::pairint, double // id → 名字绑定到 e.get0() 的返回值按下面的规则 // score → 名字绑定到 e.get1() 的返回值注意这里有一个非常反直觉的细节名字绑定的不是成员变量而是 get0() / get1() 的返回值std::pair 的 geti 返回的是引用所以名字本质上指向成员。但如果你自定义一个 get 返回按值那么名字绑定的就是一个临时值——底层行为完全不同。为什么要走 get 而不是直接访问成员std::pair、std::tuple 的成员名不统一first / second、_M_head / _M_tail 等内部实现细节各编译器不同通过统一的 geti 接口任何类型都能教结构化绑定怎么拆自己这也是为什么你写的类只要提供 tuple_size / tuple_element / get就能被结构化绑定见第七节。3.3 模式③数据成员绑定最朴素的拆法当类型既不是数组、也没有 tuple_size 特化时编译器走最后一条路直接拆公有数据成员。struct Point { int x; int y; }; Point pt{3, 4}; auto [px, py] pt; // 数据成员绑定等价展开auto e pt; // e 的类型是 Point // px → e.xpy → e.y名字是 e 的数据成员的别名要求标准硬性约束所有成员必须是非静态成员静态成员不参与因为它是类级的不是对象级的所有成员必须是**公有public**成员所有成员必须直接属于该类不能是基类的成员类不能是联合体union。⚠️经典坑如果你的类有 private 成员或者继承自基类结构化绑定直接编译报错struct PrivatePoint { private: int x; public: int y; }; // auto [a, b] PrivatePoint{}; // ❌ 编译错误x 是 private不满足所有成员公有四、一步一步拆解等价代码展开实验这一节我们做一件编译器解剖师该做的事把结构化绑定手工展开成等价的传统代码一行行对照。4.1 实验 A按值绑定auto [a, b] ...#include iostream #include utility int main() { std::pairint, int p{1, 2}; // 你写的 auto [a, b] p; // 编译器等价展开 // 1) 创建匿名变量 e用 p 拷贝初始化auto 推导为 std::pairint,int // std::pairint,int e p; // 2) a → e.get0() 返回的引用的别名int 绑定到 e.first // b → e.get1() 返回的引用的别名int 绑定到 e.second a 100; // 修改 a —— 修改的是 e.firstp.first 不变 std::cout p.first p.first \n; // 仍是 1 return 0; }结论按值绑定 先拷贝一份到 e名字再指向 e 的内部。所以修改 a 不会影响 p。4.2 实验 B左值引用绑定auto [a, b] ...#include iostream #include utility int main() { std::pairint, int p{1, 2}; // 你写的 auto [a, b] p; // 编译器等价展开 // 1) auto e p; // e 是 std::pairint,int直接引用原对象零拷贝 // 2) a → e.first 的别名int即 p.first // b → e.second 的别名int即 p.second a 100; // 修改 a —— 直接改到 p.first std::cout p.first p.first \n; // 现在是 100 return 0; }结论引用绑定 不拷贝名字直接指回原对象。适合不想拷贝大对象、或需要回写原对象的场景。4.3 实验 C转发引用绑定auto [a, b] ...#include iostream #include tuple // 返回临时对象 std::tupleint, double makeData() { return {42, 3.14}; } int main() { // 你写的 auto [id, score] makeData(); // 编译器等价展开 // 1) auto e makeData(); // e 是右值引用延长临时对象生命周期 // 2) id → e.get0()intscore → e.get1()double std::cout id score \n; // 42 3.14临时对象被安全延长寿命 return 0; }结论转发引用绑定 能绑定右值/临时对象且把临时对象的生命周期延长到和绑定名字一样长。这是按值引用的混合体也是写通用代码时最灵活的选择。4.4 三个实验横向对比核心表格写法e 的类型是否拷贝能否修改原对象典型用途auto [a, b] p;值类型 std::pairint,int✅ 拷贝❌ 不能改的是 e 的内部小对象、只读使用auto [a, b] p;左值引用 std::pairint,int❌ 不拷贝✅ 能改的是 p 内部大对象、需要回写const auto [a, b] p;const 左值引用❌ 不拷贝❌ 不能只读大对象、只读auto [a, b] makeData();右值引用生命周期延长❌ 不拷贝✅ 能绑定临时对象/泛型代码五、引用绑定的秘密auto / const auto / auto5.1 名字的类型由 get 返回类型决定而不是由 auto 决定⚠️ 最容易误解的点很多初学者以为 auto [a, b] p; 里的 a 是 int、auto [a, b] p; 里的 a 是 int。事实是结构化绑定里auto/auto/auto 只决定匿名变量 e 的类型而每个名字的最终类型由对应 get 返回值的类型或成员/元素的类型决定。用代码说话#include iostream #include tuple int main() { std::tupleint, int t{/* 第一个元素是 int */}; int x 5; std::tupleint, int tp(x, 10); // e 的类型std::tupleint, intauto 拷贝 tuple但 int 成员仍指向 x auto [ref, val] tp; ref 999; // ✅ ref 的类型是 int由 get0 决定可以改 x std::cout x x \n; // x 变成 999 // val 的类型是 intget1 返回 int 值修改 val 不影响 tp 的第二个元素 return 0; }通俗类比快递箱e用什么材质值/引用打包取决于你下的单auto/auto但箱子里每样东西本身是什么值还是引用取决于发货方get 的返回类型。5.2 悬挂引用陷阱⚠️ 高危预警坑 1绑定到临时对象的成员引用#include iostream #include tuple int main() { // ⚠️ 危险写法e 是右值引用绑到临时 tuple而临时 tuple 里的 int 成员指向局部变量…… // 这里演示的是绑定临时 内部引用成员的经典悬挂场景 int local 7; auto [a, b] std::tupleint, int(local, 1); // 临时 tuple 生命周期被延长 ✅但 a 指向 local ✅所以这里安全。 // 真正危险的是下面这种 return 0; }真正的经典悬挂场景是成员函数返回引用 解构#include iostream #include vector struct Wrapper { std::vectorint v{1, 2, 3}; int getRef() { return v[0]; } // 返回内部元素的引用 }; int main() { // ⚠️ 危险e getWrapper() 是一个临时 Wrapper 对象 auto [r] ...; // 这只是示意看下面真正危险的 return 0; }最广为人知的坑是解构临时对象内部成员引用#include map std::mapint, int createMap() { return {{1, 10}, {2, 20}}; } int main() { // 这个没问题e 的生命周期被 auto 延长 auto [k, v] *createMap().begin(); // 没问题因为 e 是临时 map 的引用生命周期延长到语句块结束 return 0; }⚠️真正的雷auto [k, v] *createMap().begin(); —— 这里 e 是拷贝的 std::pairconst int, int没问题而如果写 auto [k, v] *createMap().begin();则 e 引用的是临时 map 内部的 pair临时 map 在语句结束时销毁k/v 变成悬挂引用后续使用就是未定义行为UB。#include map #include iostream std::mapint, int createMap() { return {{1, 10}, {2, 20}}; } int main() { // ❌❌❌ 高危e 引用临时 map 内部的 pair临时 map 语句结束即销毁 // auto [k, v] *createMap().begin(); // k/v 悬挂使用即 UB // 正确姿势 auto [k, v] *createMap().begin(); // ✅ e 是拷贝的 pair安全 // 或 auto [k2, v2] *createMap().begin(); // ✅ 临时 map 生命周期被延长 std::cout k k2 \n; return 0; }结论背诵版绑左值→ 用 auto 或 const auto零拷贝绑右值/临时→ 用 auto生命周期延长或 auto拷贝安全但费性能绝不用 auto 去绑一个即将销毁的临时对象的内部成员——那是自杀式悬挂。5.3 一个隐藏的坑数组按值绑定是拷贝#include iostream int main() { int arr[3] {1, 2, 3}; auto [a, b, c] arr; // e 是 int[3]拷贝整个数组 a 100; std::cout arr[0] \n; // 还是 1拷贝隔离 auto [x, y, z] arr; // e 是 int()[3]零拷贝 x 100; std::cout arr[0] \n; // 变成 100 return 0; }六、可运行示例从零到一完整演示下面是一份完整可编译运行的演示程序覆盖三种绑定模式与常见用法。复制到 demo.cpp用 g -stdc17 demo.cpp -o demo ./demo 编译运行MSVC 需 /std:c17。#include iostream #include string #include tuple #include map #include array #include vector // ---------- 示例 1tuple-like 绑定std::tuple / std::pair / std::array ---------- void demo_tuple_like() { std::cout 示例 1tuple-like 绑定 \n; // 1. std::pair std::pairint, std::string p{42, hello}; auto [id, msg] p; // 按值拷贝 std::cout pair 解构: id msg \n; // 2. std::tuple3 个元素 std::tupleint, double, std::string t{1, 3.14, pi}; auto [ti, td, ts] t; // 左值引用绑定零拷贝 ti 100; // 修改 t 的第一个元素 std::cout tuple 修改后: std::get0(t) \n; // 3. std::array底层走数组绑定不std::array 走 tuple-like std::arrayint, 3 arr{7, 8, 9}; auto [a0, a1, a2] arr; std::cout array 解构: a0 a1 a2 \n; // 注意std::array 是类模板走 tuple-likeget0不是模式①的 C 数组 } // ---------- 示例 2数据成员绑定自定义 struct ---------- struct Point { int x; int y; }; void demo_members() { std::cout \n 示例 2数据成员绑定 \n; Point pt{3, 4}; auto [px, py] pt; // 直接绑定到 pt.x / pt.y px 30; // 修改 pt.x std::cout pt ( pt.x , pt.y )\n; // (30, 4) } // ---------- 示例 3数组绑定 ---------- void demo_array() { std::cout \n 示例 3C 数组绑定 \n; int cArr[3] {1, 2, 3}; for (auto [i] : std::arraystd::arrayint,1,3{}); // 仅为占位演示 auto [x, y, z] cArr; // 拷贝 auto [rx, ry, rz] cArr; // 零拷贝 rx 100; std::cout cArr[0] cArr[0] \n; // 100 } // ---------- 示例 4range-for 循环中解构 map ---------- void demo_map() { std::cout \n 示例 4map 遍历解构 \n; std::mapstd::string, int scores{{Alice, 90}, {Bob, 85}}; // 注意map 的迭代器解出来是 pairconst Key, Valuekey 是 const for (const auto [name, score] : scores) { std::cout name : score \n; } } // ---------- 示例 5返回值直接解构 ---------- std::tupleint, int makePair() { return {1, 2}; } void demo_return() { std::cout \n 示例 5函数返回 转发引用解构 \n; auto [a, b] makePair(); // 临时对象生命周期延长 std::cout a b \n; } int main() { demo_tuple_like(); demo_members(); demo_array(); demo_map(); demo_return(); return 0; }预期输出七、高级玩法定制自己的类型支持结构化绑定想让你自己的类被结构化绑定拆开不用改语言、不用继承什么基类只需要给它配三样说明书std::tuple_sizeMyType 特化 → 告诉编译器我能拆成 N 个std::tuple_elementi, MyType 特化 → 告诉编译器第 i 个名字的类型是什么geti(obj) 函数 → 告诉编译器第 i 个名字绑到什么。7.1 完整可运行示例给自定义类做拆箱说明书#include iostream #include string #include tuple // 需要 tuple_size / tuple_element 的声明 // 一个假封装类数据明明有两个字段但我们想让它能被结构化绑定拆开 class User { private: int id_; std::string name_; public: User(int id, std::string name) : id_(id), name_(std::move(name)) {} // 提供 get 接口需要支持 const 版本否则 const User 拆不了 template std::size_t I auto get() const - std::tuple_element_tI, User { if constexpr (I 0) return id_; else if constexpr (I 1) return name_; } }; // 说明书 1告诉编译器我能拆成 2 个 namespace std { template struct tuple_sizeUser : integral_constantsize_t, 2 {}; } // 说明书 2告诉编译器第 0 个是 int第 1 个是 std::string namespace std { template size_t I struct tuple_elementI, User { using type std::conditional_tI 0, int, std::string; }; } int main() { User u(42, Alice); auto [id, name] u; // ✅ 现在 User 可以被结构化绑定了 std::cout id name \n; // 42 Alice return 0; }⚠️ 注意点上面 get 返回值auto 推导 if constexpr所以 auto [id, name] 拿到的是拷贝若想要引用语义get 要返回 T特化要写在 namespace std 里这是标准允许的针对用户自定义类型的模板特化tuple_element 需要 tuple 头文件。7.2 进阶get 返回引用让名字能直接修改内部数据// 把 get 改成返回引用版本 template std::size_t I auto get() { // 非 const 版本返回引用 if constexpr (I 0) return id_; else return name_; } template std::size_t I const auto get() const { // const 版本 if constexpr (I 0) return id_; else return name_; } // 这样写 auto [rid, rname] u; // rid / rname 直接绑定到 u 的内部成员 rid 999; // ✅ u.id_ 变成 9997.3 底层原理小结理解 tuple-like 的灵魂结构化绑定 (tuple-like) │ ▼ 需要三件套tuple_sizeE / tuple_elementi,E / geti(e) │ ▼ 编译器展开为auto e expr; name_0 → e.get0() // 名字是 get 返回值的别名 name_1 → e.get1()只要三件套齐全任何类型都能获得结构化绑定能力——这就是它比只能拆 public 成员更强大的地方也是 std::pair / std::tuple / std::array / std::map::iterator 等统统能拆的底层原因。八、对比表格结构化绑定 vs std::tie vs 手动解构C17 之前大家用的是 std::tie 或手动取成员C17 之后结构化绑定成了主流。三者的对比维度结构化绑定C17std::tieC11手动解构.first/.second引入版本C17C11远古时代语法简洁度⭐⭐⭐⭐⭐ 一行拆完⭐⭐⭐ 需要先声明变量再 tie⭐⭐ 要写很多行是否要求变量已存在否声明即绑定✅ 是变量必须先定义是能绑定临时对象/返回值✅ 可以auto / auto✅ 可以配合临时需要中间变量能修改原对象✅auto / auto✅tie 绑定的是引用✅支持自定义类型✅三件套❌ 基本只支持 tuple-like✅直接访问成员支持数组✅模式①❌✅下标访问配合 if constexpr / 泛型✅ 强大一般差典型局限不能显式写类型不能绑 bitfield不能用于 lambda 捕获列表变量需预声明、代码啰嗦啰嗦、易错写错 first/second8.1 经典对比代码#include iostream #include tuple #include utility int main() { std::pairint, double p{1, 2.5}; // 方式 A手动解构啰嗦 int a1 p.first; double a2 p.second; // 方式 Bstd::tie需要预声明变量 int b1; double b2; std::tie(b1, b2) p; // 方式 C结构化绑定最简洁 auto [c1, c2] p; std::cout a1 a2 | b1 b2 | c1 c2 \n; return 0; }8.2 对比修改原对象的三种姿势#include iostream #include tuple int main() { std::tupleint, int t{1, 2}; // std::tie 回写 int x 0, y 0; std::tie(x, y) t; // 把 t 的元素拷给 x, y // 结构化绑定回写要修改原对象必须引用绑定 auto [rx, ry] t; // rx/ry 直接引用 t 内部 rx 100; std::cout std::get0(t) \n; // 100 return 0; }选型建议新代码C17一律优先结构化绑定需要把已有变量打包回写时才用 std::tie追求极简只读时const auto 结构化绑定是最佳实践。九、常见陷阱与预警⚠️ 9.1 不能显式指定类型// ❌ 编译错误结构化绑定不允许显式类型 // int [a, b] p; // ✅ 只能写 auto 系列 auto [a, b] p;⚠️ 9.2 不能绑定位域bitfieldstruct Flags { unsigned a : 3; unsigned b : 5; }; // auto [x, y] Flags{}; // ❌ 编译错误不能绑定位域⚠️ 9.3 不能用于 lambda 捕获列表C20 前std::tupleint, int p{1, 2}; // ❌ C20 之前lambda 不能捕获结构化绑定 // auto f [a] { return a; }; // 错误a 不是可捕获的变量 // ✅ C20 起允许捕获按值捕获拷贝、按引用捕获引用⚠️ 9.4 名字的数量必须与元素数量严格一致std::tupleint, int, int t{1, 2, 3}; // auto [a, b] t; // ❌ 编译错误绑定数量不匹配3 vs 2 // auto [a, b, c, d] t; // ❌ 同样错误⚠️ 9.5 数据成员绑定要求所有成员公有且非静态class Secret { int a; public: int b; }; // auto [x, y] Secret{}; // ❌ 错误a 是 private⚠️ 9.6 悬挂引用重灾区再强调一次#include map std::mapint, int makeMap() { return {{1, 2}}; } // ❌ auto [k, v] *makeMap().begin(); // 临时 map 销毁 → k/v 悬挂 → UB // ✅ auto [k, v] *makeMap().begin(); // 拷贝安全 // ✅ auto [k, v] *makeMap().begin(); // 生命周期延长安全⚠️ 9.7 map 的 key 是 const不能通过解构修改std::mapint, int m{{1, 10}}; for (auto [k, v] : m) { // k 的类型是 const intpair 的第一个类型是 const int // k 99; // ❌ 编译错误 v 99; // ✅ value 可以改 }⚠️ 9.8 修改按值绑定的对象不会影响原对象反直觉std::pairint, int p{1, 2}; auto [a, b] p; // 拷贝 a 100; // p.first 还是 1十、常见问题速查表FAQ问题一句话答案1结构化绑定是 C 几的特性C17tuple 头配合使用2auto [a,b] p; 和 auto [a,b] p; 有什么区别前者拷贝一份到隐藏变量 e改 a 不影响 p后者零拷贝、a/b 直接引用 p 内部改 a 会改 p3绑临时对象用什么auto延长生命周期或 auto拷贝别用 auto4为什么我的 struct 不能被结构化绑定要求所有数据成员公有、非静态、直接成员或有 tuple_size/tuple_element/get 三件套5结构化绑定出来的变量能取地址吗名字本身不是独立对象不能 a 获取一个普通指针意义上的独立变量地址但在多数实现中 a 会得到内部成员的地址取决于实现细节标准要求名字是别名不应假设其有独立存储6能拆 std::map 的迭代器吗能for-range 里 for (const auto [k,v] : m) 是标准写法k 是 const7能拆 C 风格数组吗能模式①数组绑定按值会拷贝整个数组引用模式零拷贝8能拆位域成员吗❌ 不能编译错误9lambda 里能用吗C20 起允许捕获结构化绑定C17 不行10名字数量不匹配怎么办编译错误数量必须与元素数量一致11结构化绑定和 std::tie 能混用吗用途不同绑定声明新名字tie回写已有变量按需选12修改结构化绑定的值会不会影响原对象看模式auto 不会auto / auto 会13自定义类怎么支持结构化绑定特化 std::tuple_size、std::tuple_element并实现 geti()见第七节14结构化绑定能用于 switch/if 声明中吗可以配合 if (auto [ok, val] tryGet(); ok) 这类写法很方便15为什么叫绑定不叫定义变量标准语义上名字是隐藏变量 e 的别名/成员名不是独立新变量十一、总结结构化绑定是 C17 最亲民的底层特性之一语法只有一行背后却有一套清晰的编译期机制。回顾全文的五个关键认知本质结构化绑定不是定义新变量而是给隐藏匿名变量 e的部分取名字三种模式数组绑定、tuple-like 绑定三件套、数据成员绑定——编译器按优先级自动选择绑定模式决定语义auto 拷贝隔离、auto 零拷贝可回写、auto 绑定临时对象并延长生命周期名字类型由 get 决定不是由 auto 决定——这是最容易误解的底层细节安全红线不要用 auto 绑定即将销毁的临时对象内部成员否则悬挂引用 UB。掌握了这些底层原理你不仅会用结构化绑定还能自己给类型定制拆箱说明书并能在遇到奇怪编译错误或悬空引用时一眼看穿问题根源。参考标准ISO C17 [dcl.struct.bind]结构化绑定声明ISO C20 [dcl.struct.bind] 修订lambda 捕获结构化绑定