☰
Rust模式匹配:通配符_与变量绑定_x的所有权与借用检查差异
2026/10/9 9:10:10 网站建设 项目流程

1. 两张几乎一样的代码,一张能编译一张炸了

Rust 的模式匹配是我这些年用得最顺手、也最常被问出问题的语言特性。经常有人拿着两段看起来一模一样的代码来找我,说“你看看这段怎么编译不过”。仔细一看,区别往往就藏在一个下划线里——一个是_,一个是_x。对刚接触 Rust 的人来说,这两者都叫“忽略”,一个忽略值、一个忽略变量,能有多大差别?但实际差别大到能从“轻松编译”变成“借用检查器直接拦下”,而且跟所有权、资源释放、match 守卫这些核心机制纠缠在一起。这篇文章,我想把这个看似细节的语义问题彻底讲透,读完你不仅会明白_和_x的本质区别,还会真正理解 Rust 模式匹配里“绑定”这件事到底意味着什么。

1.1 现象复现

先看两段代码,我建议你直接跑一下,感受最直观。

第一段,用通配符_忽略Option内部的String:

let opt = Some(String::from("rust")); match opt { Some(_) => { println!("有值,但我并不需要它"); } None => { println!("没值"); } } println!("{:?}", opt);

第二段,只是把_换成了_data:

let opt = Some(String::from("rust")); match opt { Some(_data) => { println!("有值,但我并不需要它"); } None => { println!("没值"); } } println!("{:?}", opt);

第一段能通过编译,输出Some("rust");第二段一跑,借用检查器立刻报错。明明只有一个下划线开头的名字的差别,为什么结果差这么多?原因在于_data虽然在 human 眼里是“忽略”,但在编译器眼里是一个变量绑定,绑定的动作本身就会转移所有权。

1.2 错误信息能告诉我们什么

把第二段的完整报错拿来看一下:

error[E0382]: borrow of partially moved value: `opt` --> src/main.rs:10:22 | 5 | Some(_data) => { | ----- value partially moved here 10 | println!("{:?}", opt); | ^^^ value borrowed here after partial move

关键词是partially moved。编译器明确告诉你:_data绑定了opt内部的String,这一步把String的所有权从opt身上“撕”了下来。opt作为一个Option<String>,外壳还在,但里面的数据已经被人拿走了,所以它处于“部分移动”状态,不能再作为整体被借用。

反观第一段的_,它虽然也“匹配”到了内部的String,但因为不是绑定,Rust 认为这个值没有被取出、没有被拥有,只是被“看了一眼且没做任何事”。于是opt的内部数据完好无损,你可以在 match 之后继续使用整个opt。

1.3 从现象到本质:绑定与不绑定

现象背后的本质,是语义上“绑定”和“不绑定”的区别。_是不绑定模式;_data(以及data、my_value这类命名标识符)是绑定模式。绑定模式负责把匹配到的值“绑”到一个名字上,这个动作在 Rust 里对非 Copy 类型就是一次所有权转移,对 Copy 类型则是一次复制。不绑定模式则什么都不做,值还是原来那个值。

读到这里你可能觉得“那我以后只用_不就行了?”但问题没这么简单。有些场景你必须绑定才能完成匹配,有些场景你绑定只是为了“取出来看一眼”,这些都需要在不同场景里灵活选择。先把“绑定 = 所有权转移”这个观念刻在脑子里,再看后面的细节,会清晰很多。

2. 通配符与变量绑定的完整语义对照

2.1 通配符_:真正的“不绑定”

通配符_是 Rust 里一个非常特殊的模式,它的行为可以概括为几点:它匹配任意值,但不产生任何绑定;它可以出现在同一个模式的多个位置;它在let、match、函数参数、闭包参数等所有模式位置都可用。

比如let (_, _, x) = (1, 2, 3);,前两个元素直接被忽略,只有x被绑定到 3。再比如:

fn consume_but_ignore(_: i32) {}

这个函数接收一个i32,但完全无视它。这种写法在实现 trait 或回调函数时非常常见,因为签名要求必须接收参数,但你的实现并不要用。

理解_最好的方式,是把它当成一个“不占名字的占位符”。它参与了模式的形状描述,告诉编译器“这里有东西,但我既不命名也不转移”。编译器看到_,就知道这块数据不需要被绑定、不需要被保留,也不需要被转移。

2.2 变量绑定:哪怕是_x也是绑定

任何一个标识符出现在模式中,都是在声明一个变量绑定。这个变量的命名规则和普通变量一样,_x也只是一个合法的变量名。不同点在于:当变量名以下划线开头时,编译器不会在“没人用它”时给出unused_variables警告。这个设计是为了方便程序员表达“我知道这里有值,但我暂时不需要它”。

但是,这里有个最重要的但是:命名上的“忽略”意图,并没有改变它在编译器眼中的本质。_x依然是绑定,依然会触发所有权的转移。编译器不会因为你“自称忽略”就网开一面。

用一句话总结:

_是“我没看见”,_x是“我看见了,并且把它拿走了”。

很多从其他语言转来的开发者会觉得 Rust 这个设计很苛刻。但恰恰是这种苛刻,让资源的所有权在编译期就清清楚楚。如果你写一个下划线变量但后续代码依赖容器里的内容,编译器会毫不犹豫地吼你,而不是让你在运行时发现数据突然消失了。

2.3 一张表看懂两者的根本差异

维度__x/x
是否产生绑定否是
是否触发所有权移动(非 Copy 类型)否是
是否触发 Copy 复制否(无需复制)是
是否抑制unused_variables警告天然不涉及以下划线开头时不警告
能否在同一模式中出现多次可以,如(_, _)不能,同名绑定报 E0416
能否在分支体 / 守卫中被引用不能能
对原值的影响无影响部分移动或整体转移

这张表基本概括了全篇的核心。看不懂没关系,后面每一行都有对应案例。

3. 所有权视角:_不移动,_x会移动

3.1 非 Copy 类型在两种写法下的命运

String是最典型的非 Copy 类型。在 match 里,如果写Some(_data),值的所有权就跑到了_data手上。这里的关键是:所有权转移只和“绑定”有关,和名字是否以下划线开头无关。你写成Some(_)就不会转移,因为_不是变量。

这时候有读者会问:如果我不在分支体里用_data,那它不还是会在分支结束时被 drop 吗?没错,_data在作用域结束时会被释放。但释放是“谁拿走东西谁扔”,而_是“根本没人拿走”。这两者对外部变量的影响完全不同:前者让opt缺了一块,后者让opt完整无缺。

举一个更贴近日常的例子:你有一个HashMap的Option<Value>,用Some(_)检查它对 Value 的占用情况,Value 仍然留在原容器里;用Some(_v)时,Value 已经被搬出来,原容器里的那条数据就没了。对于某些需要“再次使用原数据”的逻辑,这个区别可能直接决定代码能不能编译。

3.2 部分移动与字段级跟踪

Rust 编译器对结构体、元组支持字段级的部分移动跟踪。也就是说,你从opt里取出内部的String,opt只是部分移动,而不是整体报废。但部分移动之后,整个opt就不能被访问了,除非你单独访问那些还没被移动的字段。

看一个元组对比:

let pair = (String::from("left"), String::from("right")); match pair { (left, _) => println!("{}", left), } println!("{}", pair.1); // 能过编译,因为 _ 没绑定 pair.1

这段代码能过,因为_不绑定pair.1,pair.1的数据没有被移动,你还可以单独访问它。再来对比:

let pair = (String::from("left"), String::from("right")); match pair { (left, _right) => println!("{}", left), } println!("{}", pair.1); // error:pair.1 已经被 _right 拿走了

这里_right绑定了pair.1,数据被移走,访问pair.1就会报 E0382。这是非常值得在本地跑一遍的对比案例。很多人在看完代码后意识不到:一个“看起来没什么用”的_right,怎么就把pair.1弄没了?原因就是绑定本身,而不是“你是否用这个名字”。

3.3 Copy 类型为什么看不出区别

如果你的值类型是i32、bool、char这种实现了Copy的类型,那么_和_x的行为几乎一样:反正都是复制一份,原始值不受影响。

let pair = (1, 2); match pair { (a, _) => println!("{}", a), } println!("{:?}", pair); // OK let pair = (1, 2); match pair { (a, _b) => println!("{}", a), } println!("{:?}", pair); // 也 OK

这就是为什么很多新人在i32上测试,怎么测都觉得没区别,直到某天把类型换成String或自定义结构体,突然就过不了编译了。所以判断需求时,先问一句:这个类型是 Copy 吗?如果是,所有权差异对你无关紧要;如果不是,任何一个绑定都会留下痕迹。

3.4 借用、引用匹配与 match ergonomics

如果你既想绑定值的内容,又不想拿走所有权,标准做法是匹配引用,或者显式用ref。

let opt = Some(String::from("rust")); match &opt { Some(_data) => { println!("绑定了引用,类型是 {:?}", _data); } None => {} } println!("{:?}", opt); // 仍然可用

这里的_data类型是&String,不是String,所以没有所有权转移。这种写法在实际工程里非常常见,因为很多时候你只是“想看一眼里面的东西”,并不想消费它。

另一种方式是Some(ref _data),在老代码里很常见。现在 match ergonomics 普及之后,大家更常直接match &opt,但理解ref依然有价值:它是告诉编译器“这个绑定要绑引用,不要绑值”的显式手段。

let opt = Some(String::from("rust")); match opt { Some(ref _data) => {} None => {} } println!("{:?}", opt); // OK

注意:ref同样作用于_data这种下划线开头的名字,说明借用和通配符是两个维度。_表示不绑定,ref表示以引用方式绑定,_x表示以值方式绑定。别把三者混在一起,否则你会写出各种奇奇怪怪的代码。

4. 模式忽略中最容易踩的五个坑

4.1 坑一:把_x当成_

这是第一大坑,前面已经反复强调。不少人在一个Result或Option上只想“看看它成功没有”,顺手写了个Ok(_some_value),结果这个操作把内部值从原来的变量里移走了。后面代码又拿原变量做判断,借用检查器立刻爆出连锁错误。我的建议是:在模式里看到_x时先在心里默念“这是绑定、这是绑定”,再决定要不要用。如果你真的只是想忽略,直接写_。

4.2 坑二:let _ = expr与let _x = expr的销毁时机

这个坑和锁特别相关。看两行代码:

let _ = mutex.lock(); // 锁在语句结束时就释放了,后面再想访问共享数据会直接撞上“锁没持有”的事实 let _guard = mutex.lock(); // 锁会持有到当前作用域结束

let _ = ...里的_不是变量,它没有为右侧表达式建立一个持有者。MutexGuard在语句结束时就 drop 了,锁立刻释放。而let _guard = ...让 guard 存活到这个作用域的末尾才释放锁。

如果你需要“用完之后立刻释放锁”,那故意写let _ = ...是可行的。如果你想“暂时忽略锁”,那应该写let _guard = ...。这种微妙的语义差,正是通配符和变量绑定在“忽略”层面最实用的体现。

再补一个#[must_use]类型的情况:

let _ = dangerous_computation(); // 不会触发 must_use 警告 dangerous_computation(); // 触发 unused_must_use 警告 let _result = dangerous_computation(); // 也不会,但 _result 会存活到最后

需要根据意图选择:单纯“必须调用但不管返回值”用let _ = ...;可能之后要用但暂存,用let _x = ...。

4.3 坑三:..与字段级忽略的边界

..是模式忽略的另一个重要工具,它的语义是“忽略剩余所有字段/元素”,但它有几个限制:每个模式里最多出现一次;它不产生任何绑定;它会忽略相邻位置的所有字段。

struct Point { x: i32, y: i32, z: i32 } let p = Point { x: 1, y: 2, z: 3 }; match p { Point { x, .. } => println!("{}", x), // 忽略 y 和 z }

注意,Point { x, .., y }这种写法是错的,一个模式里不能出现两个..。..不绑定被忽略的字段,所以在所有权上,这些字段没有被移动。不过实际编码中,如果你想在 match 之后继续使用被..略过的字段,代码会变得很绕。我更推荐直接match &p或者提前clone你关心的字段,让意图清晰。

4.4 坑四:match 守卫里的绑定与外层遮蔽

有时你需要“既匹配又判断”,就必须用变量绑定:

let num = Some(4); match num { Some(n) if n % 2 == 0 => println!("偶数 {}", n), _ => println!("其他"), }

这里n是绑定,if n % 2 == 0是守卫,n在守卫和分支体里都能用。如果用_,你连判断条件都没法引用值。这个场景里必须用变量绑定。

遮蔽问题也常在这里出现:

let x = 10; match Some(5) { Some(x) => println!("{}", x), // 输出 5,遮蔽了外层 x None => {} }

x一旦绑定,外层变量就被遮蔽。如果你本意是“忽略 Some 里的值,却想用外层x做点什么”,那会踩坑。正确做法是给模式里的绑定换一个名字,比如Some(x_inner),或者用_彻底不绑定。

4.5 坑五:同名变量在同一模式中绑定两次

有人想表达“两个元素无所谓”,于是写了这样的代码:

let (_v, _v) = (1, 1);

看起来像是在说“反正我都忽略,名字一样也无所谓”,但 Rust 不允许。同一个模式中不能重复绑定同一个标识符,编译器会报 E0416:identifier '_v' is bound more than once in the same pattern。因为模式绑定本质上是声明变量,一个作用域里不能有两个同名声明。而_没有这个限制,(_, _)完全合法。这也从侧面证明了_和一般标识符根本不是一个层面的事物。

5. 工程实践:到底该用_还是该用绑定

5.1 必须用_的四个场景

第一,match 的兜底分支。任何match里写_ => {},都是在告诉读者“剩下的一切情况我都不想管”。

第二,结构体解构时忽略不关心的字段。Point { x, .. }比Point { x, y, z }更聚焦,也更能抗结构体字段变化。团队扩字段时,你不需要逐个改解构代码。

第三,函数和闭包的无用参数。比如回调需要满足某个 trait 的签名,但实参对你没用:

button.on_click(|_| { /* 不做任何事 */ });

第四,需要明确“不移动值”的匹配场景。比如你只想检查一个Option是否Some,并不想把内部资源拿出来,写Some(_)是最稳妥的。

5.2 应该用绑定(哪怕是_name)的场景

如果分支体内需要用到被匹配值,当然得绑定。此外还有两个典型场景。

一是 match 守卫里需要根据值做条件判断,也就是 4.4 中的例子。

二是你希望把值从容器里“取出来”但暂时不处理。比如日志系统里常这样写:

match response { Ok(_body) => { // 已经取出数据了,但这里先不解析 // 后续某个函数会消费 _body } Err(err) => println!("失败: {}", err), }

如果你不想现在取出,那就用_。如果取出后要跨分支使用,那就用_body并把它传给后续逻辑。判断标准很简单:你到底动不动容器的内容。

5.3 命名与注释:让“忽略”表达清楚

工程代码是写给人看的。_没有信息量,_name有信息量。当你真的给忽略起名字时,最好带上语义。

我见过比较好的写法:

match response { Ok(_data) => { /* 本期不处理数据 */ } Err(_network_err) => { /* 网络错误暂不处理,但清楚它是什么 */ } }

这种写法比Ok(_)/Err(_)更能传达意图:读者能猜到这里是“成功的返回但我们不关心数据”,还是“网络错误我们暂时不管”。但前提是你能接受绑定会引起所有权转移,不会在 match 之后继续用原始结构体。如果想既命名又不转移所有权,配合&或as_ref()使用:

match response.as_ref() { Ok(_data) => { /* _data: &Vec<u8> */ } Err(_network_err) => { /* _network_err: &Error */ } }

5.4 if let / while let / 闭包中的忽略细节

这几个场景里,同样的规则在起作用。

if let Some(_) = opt { count += 1; } // opt 仍可用,因为没绑定任何东西 if let Some(_value) = opt { println!("取出 {}", _value); } // opt 部分移动,后续不能整体访问

while let里最常见的用法是“逐个消费迭代器元素”:

let mut iter = "a,b,c".split(','); while let Some(_item) = iter.next() { count += 1; }

这里的_item绑定没问题,因为它本就是从迭代器next取出来的临时值,你不需要保留迭代器本身的状态。重点仍然是:遇到这类场景,先想清楚你动不动容器的所有权,再选_还是变量绑定。

闭包参数也是一个高频位置。|_|和|_x|的区别本质和前文一致:_不绑定不转移,_x绑定并转移。对 Copy 类型影响不大,对非 Copy 类型会决定值是被传进闭包还是被忽略。写闭包时多一层谨慎总是好的。

6. 我写了几年 Rust,对“忽略”最大的体会

老实说,我最初也把_x和_混用过很久,直到一次代码评审里被同事指出来,才认真把这段语义抠明白。那次我们改了一处Some(_)为Some(_data),结果后面一个看似无关的字段访问突然报错。查了半天,原因就是_data把内层值拿走了,外部结构体不再完整。后来我把“绑定 = 所有权转移”这句话当成模式匹配的第一性原理,就再没犯过类似的错。

如果非要给一条可操作的建议,我会说:写代码时先问自己“我到底要不要这个值”。答案是不需要,就写_;答案是要但暂时不用,就写_name,并且配合&或as_ref()保证别乱动所有权。模式匹配本身不难,难的是别让“看起来一样”遮蔽掉“语义不一样”。rustc 会在你犯错时吼出来,但听不听得懂它吼什么,就看你有没有把这些基础概念吃透。希望这篇分享能让你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询