十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Rust函数深度解析:所有权、借用与生命周期实战指南

Rust函数深度解析:所有权、借用与生命周期实战指南 我从 C 转 Rust 的时候对“函数”这一章其实是不太当回事的——同样的函数写了十几年还能翻出什么新意结果第一周就被打脸了。Rust 的函数跟 C/C 有一个最本质的差异函数签名不只是“参数 返回类型”它同时还是一份内存权限契约。同样一个String写成String参数、String参数和mut String参数调用者手里的变量命运完全不同。这种设计刚开始让人很不适应但一旦理解了你会觉得这是我见过的最诚实的一门语言。这篇文章围绕《通过例子学Rust》第9章“函数”展开把函数声明、所有权传递、回调与闭包、泛型生命周期以及一些容易踩的坑串起来讲一遍。适合刚学完 Rust 基础语法、准备写真实小项目的读者也适合写过一点 Rust 但总被 borrow checker 拦住的人。我会尽量少讲空泛的概念多放可以直接复制运行验证的例子。1. 别被“函数”骗了Rust 函数首先是“表达式机器”1.1 一个最简单的函数长什么样Rust 的函数声明看起来和大多数语言差不多关键字fn参数列表箭头后面跟返回类型fn is_adult(age: u32) - bool { age 18 } fn main() { println!({}, is_adult(18)); println!({}, is_adult(16)); }这里有个细节值得注意函数体里没有return也没有分号。age 18这个表达式本身就是函数的返回值。如果你在结尾加了分号变成age 18;那函数实际返回的就是单元类型()而你的签名写的是bool编译器立刻报mismatched types。很多新手在这里被绕晕是因为脑子里还带着 C/Java 的“语句式”思维函数体就是一堆语句最后靠return把值送出去。Rust 走的完全是另一条路——它把“函数体”看作一个表达式块块的最后一个表达式值就是整个函数的返回值。那么return是不是就没用了也不是。return在 Rust 里只用于“提前返回”正常的结尾直接写表达式即可。我见过一些项目代码把return用得漫天飞能跑但风格很怪读起来和 Rust 社区的代码格格不入。1.2 为什么 Rust 选择“表达式导向”这不仅是语法风格问题它深刻影响了语言里其他部分的设计。因为函数体是表达式所以if、match也都可以作为表达式使用不需要专门搞一个三元运算符let status if is_adult(age) { adult } else { minor };再看一个用match当返回值的例子fn age_group(age: u32) - static str { match age { 0..12 child, 13..19 teen, 20..64 adult, _ senior, } }match的每个分支都返回static str整个match表达式就是函数的返回值。你不需要在每个分支里写return child也不需要定义一个中间变量再返回它。这种设计让函数体天然适合做“数据变换”输入一个值经过一系列表达式计算输出另一个值。我自己的体会是从表达式导向去理解 Rust 函数比死记“最后一个表达式不能加分号”有效得多。一旦你养成了“函数体是表达式块”的心智模型写map、filter这类链式调用会非常顺手因为你不再纠结“这到底是语句还是表达式”而是直接看它求值的结果是什么。2. 传参就是“过户”所有权、借用与函数签名的关系2.1 值传递的语义是 move先看一段代码fn take_string(s: String) - usize { s.len() } fn main() { let name String::from(rust); let len take_string(name); println!({}, name); // 编译失败use of moved value }在 C 里take_string(name)默认会发生一次拷贝除非写了 move在 Java 里传引用不会影响原变量。但在 Rust 里把一个String直接传给函数意味着你把这块堆内存的所有权“过户”给了函数参数。函数结束s被释放原变量name就变成不可用的状态。为什么这么设计因为String在堆上持有数据。如果允许多个变量同时“拥有”同一块堆内存释入时就会 double free如果每次传参都深拷贝性能又太浪费。Rust 选择在编译期用 move 语义解决一块内存只有一个拥有者想转移就转移编译器帮你检查和销毁。这也是 Rust 没有 GC 却能保证内存安全的根本原因之一。刚开始学的时候你可能会觉得这个限制很烦。但写多了你会发现move 语义把“资源释放”这个运行时问题变成了“所有权转移”这个编译期问题。你不需要再去背诵“谁负责 delete”只要跟着类型走编译器会在你犯错的时候直接告诉你。2.2 借用不转移所有权的传参如果你只是想让函数读一下数据没有必要把所有权交出去。这时候用借用fn read_len(s: String) - usize { s.len() } fn append_exclamation(s: mut String) { s.push(!); } fn main() { let mut name String::from(rust); let len read_len(name); append_exclamation(mut name); println!({}, len{}, name, len); }借用分为两种。String是不可变借用函数只能读mut String是可变借用函数可以修改内容。借用不会让原变量失效所以调用完read_len之后name依然可用。Rust 对借用有一套严格的规则同一时刻要么允许多个不可变借用要么只允许一个可变借用两者不能共存。这套规则在函数调用处同样适用。比如你写read_len(name)之后再调用append_exclamation(mut name)是没问题的因为两个调用是先后发生的没有重叠但如果你在read_len借用的同时还要可变借用编译器就会报 E0502。三种传参方式可以简单对比一下参数类型调用后原变量状态函数内能否读取函数内能否修改String已 move不可用可以可以String仍可用可以不可以mut String仍可用可以可以2.3 实战中参数类型怎么选第一原则能借用就不要 move除非函数确实需要接管所有权。第二原则字符串参数能用str就不要用String。fn first_part(s: str) - str { s.split(:).next().unwrap_or(s) } fn main() { let a String::from(hello:world); let b plain text; println!({}, first_part(a)); println!({}, first_part(b)); }用str的好处是灵活它既能接受String的引用自动 deref 转换也能接受字符串字面量。如果你写成String调用方还得先构造一个String才能传进来自找麻烦。关于字符串处理顺便提醒一个新手高频坑Rust 不能用下标直接截取字符串比如s[0..2]会直接 panic因为字符串是 UTF-8 编码切片下标必须落在字符边界上。函数如果要截取用s.get(..2)返回Optionstr更安全。这类问题在函数设计阶段就该考虑清楚否则调用方拿到一个 panic 的函数体验很差。3. 回调函数的正确姿势函数指针与闭包3.1 函数指针最朴素的“把函数当参数”普通函数在 Rust 里可以作为一个值传递类型写成fn(参数类型) - 返回类型fn add_one(x: i32) - i32 { x 1 } fn apply_twice(f: fn(i32) - i32, x: i32) - i32 { f(f(x)) } fn main() { println!({}, apply_twice(add_one, 3)); // 5 }这里的add_one没有捕获任何外部变量所以它可以被当作函数指针传给apply_twice。函数指针本身可以存在变量里、放在数组里、作为参数传递行为很纯粹。这种写法在 C 语言里很常见Rust 保留了这种能力但在实际项目里更常使用的是闭包。3.2 闭包Rust 的“箭头函数”如果你写过 JavaScript看到 Rust 闭包会有一种熟悉感。JS 的箭头函数是(x) x 1Rust 闭包写得更精简let offset 5; let add_offset |x| x offset; println!({}, add_offset(10)); // 15|x| x offset捕获了外层变量offset并把它用在闭包体内。不需要像函数指针那样声明参数类型和返回类型编译器会自己推断。闭包可以赋值给变量也可以作为参数传给其他函数。需要注意闭包的捕获方式决定了它实现的是哪个 trait。Rust 标准库里定义了三个和闭包相关的核心 traitFnOnce只能调用一次因为它会消费捕获的变量。FnMut可以调用多次但会修改捕获的变量。Fn可以调用多次且只会读取捕获的变量。看一个FnMut的例子fn main() { let mut count 0; let mut increment || { count 1; }; increment(); increment(); println!(count {}, count); // 2 }这个闭包内部给count赋值属于“修改捕获变量”所以它是FnMut。如果你用let increment ...而不是mut编译器会报错因为调用一个FnMut闭包需要它的绑定本身可变。在实际使用中最简单的选择逻辑是默认用Fn约束闭包确实要修改环境里的变量就用FnMut闭包要把捕获的变量 move 进去并且只调用一次就用FnOnce。写高阶函数时泛型约束加 是最常见的方式fn call_twiceF(f: F, x: i32) - i32 where F: Fn(i32) - i32, { f(x) f(x) }很多教程会把 FnOnce / FnMut / Fn 当作语法细节一笔带过但在真实代码里这直接关系到你写的高阶函数能不能被调用方接受。比如一个闭包捕获的是String并且闭包内部把它push进其他容器这种闭包就只能是FnOnce你偏要用F: Fn(...)约束编译器会毫不留情地报错。3.3 闭包作为回调的几个典型场景Rust 里闭包最常见的用途是在迭代器上做变换。比如let numbers vec![1, 2, 3, 4, 5]; let doubled: Veci32 numbers.iter().map(|x| x * 2).collect(); println!({:?}, doubled); // [2, 4, 6, 8, 10]再比如排序时自定义规则let mut scores vec![(10, bob), (50, alice), (30, carol)]; scores.sort_by_key(|item| item.0); // 按数字升序 println!({:?}, scores);还有多线程里把闭包发送到新线程执行let data vec![1, 2, 3]; std::thread::spawn(move || { println!(data {:?}, data); }) .join() .unwrap();线程场景里必须加move因为新线程可能比当前线程活得更久不把捕获变量 move 进去所有权关系就说不清。这里容易踩的坑是如果你在闭包里只用到了data的引用仍可能被要求加move它会直接把data整个移入闭包原变量后续就不能用了。4. 让函数签名表达约束泛型、trait 与生命周期4.1 泛型函数一份逻辑适配多种类型如果你写过 C 模板对这个不会陌生。Rust 的泛型函数写法更简洁但需要显式声明 trait 约束。比如写一个取最大值的函数fn largestT: PartialOrd Copy(list: [T]) - T { let mut max list[0]; for item in list { if item max { max item; } } max } fn main() { println!({}, largest([1, 3, 2])); // 3 println!({}, largest([a, f, e])); // f }T: PartialOrd Copy表示“任何实现了PartialOrd和Copy的类型”。PartialOrd提供比较能力Copy表示变量在赋值时是拷贝而不是 move。如果不约束Copylist[0]会 move 出去之后就不能再访问整个数组了。泛型在编译时会做单态化也就是编译器为你用到的每一种具体类型生成一份专用代码。所以你不用担心运行时类型开销代价就是编译出来的二进制可能变大。Rust 团队目前没有完全消除这个问题但大多数项目里这点体积可以接受。4.2 生命周期标注给引用加上有效范围生命周期lifetime是 Rust 新手最头疼的部分但它只在“函数返回引用”的时候才特别扎眼。看这个例子fn longesta(x: a str, y: a str) - a str { if x.len() y.len() { x } else { y } }这里的a是生命周期参数表示“x 和 y 这两个引用至少存活了a这么久返回的引用也至少存活a这么久”。编译器看到这个签名之后就能确保返回的引用不会超过入参引用的有效期。为什么不能省略a因为函数可能返回x也可能返回y编译器自己判断不出来。你可能会问那就让它判断返回的具体是哪个引用不行吗不行编译器不会分析函数体里的分支流来判断生命周期它只看签名。签名是函数和外部世界的契约生命周期是这个契约里用来保证内存安全的关键部分。实际上Rust 有生命周期省略规则很多常见场景不需要手写。比如只有一个输入引用时fn first_word(s: str) - str { s.split_whitespace().next().unwrap_or(s) }这里可以省略因为只有一个输入引用返回的引用默认和它同生命周期。还有self方法也有特殊规则。真正需要手写的主要是“多个引用参数 返回引用”和“结构体里存引用”这两类场景。4.3 悬垂引用和 E0106 报错生命周期标注存在的最终目的是不让别人拿到“已经失效的引用”。一个经典的错误fn dangle() - String { let s String::from(hello); s }这段代码编译不过因为s在函数结束时就没了返回的引用悬空。编译器会报“missing lifetime specifier”或者直接提示“returns a reference to data owned by the current function”。有些新手一看报错说缺生命周期标注就盲目加了一个a结果编译器继续报错。其实问题的根源不是“没写标注”而是“返回了局部变量的引用”这时候得改变设计比如让字符串由调用方传入或者返回String而不是String。我的经验是遇到生命周期报错时不要急着改签名先想清楚一个问题返回的引用到底指向哪里如果指向参数那就加生命周期关联参数如果指向函数内部创建的变量那这条路根本走不通换一种返回方式。5. 容易被忽略的函数形态方法、发散函数、const fn 与递归5.1 关联函数与方法Rust 里的函数不一定独立存在还可以在impl块里定义关联函数和方法。关联函数是“挂在类型上的函数”方法则是第一个参数是self的函数。听起来绕看代码就懂了struct Rectangle { width: u32, height: u32, } impl Rectangle { fn new(width: u32, height: u32) - Self { Self { width, height } } fn area(self) - u32 { self.width * self.height } fn set_width(mut self, width: u32) { self.width width; } }new是个关联函数通过Rectangle::new(3, 4)调用。它没有self参数也不强制要求叫new这只是 Rust 社区的一个惯例。area是方法因为它接收self通过实例调用rect.area()。set_width接收mut self调用后实例内容会被修改。这里常见的问题是新手分不清什么时候用self什么时候用self什么时候用mut self。规则很简单方法只读取数据用self方法要修改数据用mut self方法要把对象本身消费掉、并返回其他值时用self。比如rect.into_area()这种把矩形转换成面积的场景可以用self。5.2 发散函数返回类型是!Rust 有一种特殊的函数返回值类型是!表示“永不返回”。它常用于程序崩溃、无限循环或者直接退出fn diverging() - ! { panic!(This function never returns); }这东西在match分支里特别有用。比如你写fn safe_div(a: i32, b: i32) - i32 { match b { 0 panic!(division by zero), _ a / b, } }panic!(...)的类型是!它“匹配”任何类型所以整个match仍然能返回i32。发散函数是这种能力的一种显式表达了解它对理解 panic 宏、todo!宏的行为有帮助。todo!()、unimplemented!()这些宏本质上都会发散它们的类型就是!。5.3 const fn在编译期执行的函数Rust 允许你定义一个const fn这种函数可以在编译期被求值。早期版本限制很多不能有循环不能有堆分配等近几个版本放开了一部分限制但很多特性仍然不稳定。一个简单的例子const fn square(x: u32) - u32 { x * x } const AREA: u32 square(12); fn main() { println!({}, AREA); }square(12)在编译期就算出了 144直接嵌入到二进制里。做嵌入式、做常量配置时这个能力很有用复杂的初始化逻辑可以交给const fn而不是手写一长串魔法数字。不过要注意const fn目前还不能随便使用动态分配、Vec、String等运行时特性如果发现编译报错先检查是不是用了不能在 const 上下文里用的操作。递归函数同样会影响调用栈。Rust 不保证尾递归优化所以递归层数太深时依然有栈溢出风险。默认线程栈一般只有几 MB如果函数递归一万层很可能直接 segfault。处理这类问题时建议改用显式栈的迭代版本。6. 项目里函数设计的几个可复用经验6.1 命名、组织和错误传播函数命名上Rust 社区约定使用snake_case。布尔谓词函数通常用is_、has_、can_开头比如is_valid、has_permission。构造函数习惯叫new类型转换建议实现标准库的Fromtrait而不是发明一堆to_xxx、from_xxx。命名这种小事对代码可维护性的影响比想象中大。错误处理也是函数设计的一部分。如果一个函数可能失败返回值尽量用ResultT, E而不是失败时panic!或者默默返回一个默认值。函数一旦把Result返回给调用方调用方就可以用?操作符方便地传播错误。把错误处理放在函数边界上想清楚项目越到后面越省心。6.2 常见的编译器报错到底在说什么报错信息典型场景处理办法E0308 mismatched types函数体内最后一个表达式带了分号返回了()去掉末尾分号或者改用显式return value;E0382 borrow of moved value向函数传入了String后原变量还想继续用改为传引用或者调用方先clone()E0502 cannot borrow as mutable/immutable同一作用域里同时存在可变借用和不可变借用缩小借用范围让可变借用和不可变借用不要重叠E0106 missing lifetime specifier多个引用参数且返回引用生命周期没有关联起来添加生命周期泛型如a这些报错是 Rust 学习路上最常见的拦路虎但它们的信息其实非常明确。我的建议是不要背错误码而是去理解背后的所有权规则。E0502 几乎每天都会碰到它和 NLL非词法作用域生命周期机制关系很大。只要你在不可变借用结束后再做可变借用编译器通常都会接受。6.3 一个小技巧单元测试直接访问私有函数Rust 的单元测试模块可以直接访问同文件里的私有函数因为子模块能看到父模块的私有项。这意味着你不需要为了测试去给函数加pub非常舒服fn is_adult(age: u32) - bool { age 18 } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_adult() { assert!(is_adult(18)); assert!(!is_adult(16)); } }我习惯在写完一个函数后立刻补上这种小测试。Rust 的测试体验很顺cargo test跑一下几个边界值一验函数的设计问题很快就能暴露出来。这个习惯帮我减少了大量低级错误也让我在调整函数签名时更有底气。函数这块内容看着简单实际把所有权、借用、生命周期、闭包串起来才是真正入门 Rust 的开始。
返回列表