ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rust函数与所有权:从传参到生命周期,设计安全函数边界

Rust函数与所有权:从传参到生命周期,设计安全函数边界 1. 先搞清楚第9章在讲什么Rust函数不是“输入-输出”那么简单Rust 的函数和其他语言最大的不同在于你写函数的时候必须同时想清楚“数据到底归谁管”。学完了变量绑定和所有权很多人卡在函数这一章是因为函数恰好是所有权转移发生得最频繁的地方。我见过不少朋友在 Rust 里写函数写着写着就陷入“这是我传进去的变量凭什么不能用了”的困惑。实际上 Rust 的所有权、借用检查、生命周期大概有一半的体现都发生在函数签名和函数调用的边界上。所以说白了第9章不仅仅是教你写 fn 关键字而是教你如何设计“安全的函数边界”。为什么这么说因为你一旦定义了参数类型是u32、是str、是String、是mut VecT你其实是在告诉编译器一套完整的数据流转规则这个数据是被复制一份进入函数还是被借出去还是干脆被移动走了。C 语言里你只管指针Java 里你只管引用Rust 里函数签名就是一份数据权限合同。这一章在整本书里的位置也很特殊。前面几章讲变量绑定、基本类型、复合类型是在搭积木到函数这一章是把这些积木做成“可复用的积木模版”。后面讲模块、错误处理、trait又都依赖函数这个基础单位。所以如果你在函数这一章把“所有权转移 借用 返回值的配合”理解透了后面读代码的速度会快很多。适合谁来读两种人最合适一是刚掌握 Rust 基础语法、开始用函数组织逻辑的人二是有其他语言经验、但总被借用检查器教做人的朋友。这一章解决的问题很明确为什么我的参数被移动了为什么函数返回引用报生命周期错误为什么闭包看起来和函数差不多但用起来要分三种带着这些问题学你会觉得第9章每一节都没有白写。2. 参数传递和所有权为什么你的变量进了函数就“没了”2.1 值传递的坑变量被移动了我最常看到的初学代码长这样fn main() { let s String::from(hello); print_string(s); println!({}, s); // 编译错误value borrowed here after move } fn print_string(s: String) { println!({}, s); }编译器的报错非常直白你在调用print_string(s)的时候s的所有权被移动到了函数参数上函数结束之后s就被释放了。你要是问“为什么 C 那套拷贝构造不生效”因为 Rust 对 String 这类堆类型默认就是移动语义它不搞隐式深拷贝。你不想要移动要么传引用要么显式克隆。这里最反直觉的是基础类型比如i32、f64、bool不会出现这个问题。因为它们在栈上实现了Copy所以传入函数时是复制一份。很多人一开始分不清“为什么有的类型传进去还能用有的不行”很简单实现了Copytrait 的类型传参时默认复制没实现Copy的类型传参时默认移动。String、Vec、Box 这些堆上的类型都没有实现 Copy。所以我给新手的第一条建议写函数之前先问自己“这个参数我需要拥有它还是只需要看一眼它的值”只需要看就传引用。当然也有例外比如你就是要写一个消费 String 的函数把它改完后返回一个新的 String那传值也完全合理但要记住所有权转移这件事。2.2 引用传参和借用检查让函数“只读”或“可变”理解了移动接下来就要把“借用”用好。借用就是传引用不转移所有权。引用分两种T是只读借用mut T是可变借用。写函数时你至少要明确三件事需不需要修改数据、会不会有多个调用方同时读、会不会有调用方在修改时还持有其他引用。一个典型的只读引用例子fn calculate_len(s: String) - usize { s.len() } fn main() { let s String::from(hello); let len calculate_len(s); println!(len {}, s {}, len, s); // s 还能用 }这里s被借进函数但所有权还在 main 手里函数结束后s依然存活。如果要修改就必须用可变引用fn append_world(s: mut String) { s.push_str( world); } fn main() { let mut s String::from(hello); append_world(mut s); println!({}, s); }注意一个细节调用可变引用的前提是变量本身用mut声明。这个mut不是摆设它意味着你明确允许这块内存的内容被改写。Rust 的借用检查规则在函数签名层面就已经生效了可变引用与不可变引用不能同时活跃同一时刻只能有一个可变引用。这不是限制是在编译期帮你排掉了数据竞争。我自己写代码的习惯是函数一开始就决定好参数是T还是mut T不要让后面代码来替你决定。因为一旦函数体里写着写着想“顺手改一下”某个外部变量编译器会立刻报错到时候回头改签名反而容易牵扯出借用冲突。2.3 返回值配合所有权转移写函数签名前的三个自问前面说传参可以把所有权移进函数反过来也一样返回值可以把所有权移出来。这是 Rust 里一种很常见的模式——“传进去改一改再传出来”fn add_excitement(mut s: String) - String { s.push(!); s } fn main() { let s String::from(hello); let s add_excitement(s); // 所有权从 s 移到参数再通过返回值移回来 println!({}, s); }这种写法在 Rust 里完全合法你能理解为啥吗因为变量s只是被重新绑定到了函数返回的 String 上原来的堆内存被同一个 String 继续持有整个过程没有深拷贝。注意第一次绑定s没加 mut第二次绑定到的那个返回值是可以被后续修改的虽然例子中没改。如果你同时需要修改多个数据二元组回归也是最直接的办法fn split_and_keep(s: String, sep: char) - (String, String) { match s.find(sep) { Some(i) (s[..i].to_string(), s[i 1..].to_string()), None (s.clone(), String::new()), } }但在真实工程里我建议你优先考虑传mut或返回结构体避免到处都是二元组。函数签名的可读性非常重要别人看你的函数第一眼就是看参数类型和返回类型如果返回值是个(String, String)还得猜哪个是哪个。所以我每次写函数签名前都会自己先回答三个问题第一这个函数是否需要拥有参数的所有权如果是为什么第二是否需要修改传入的数据如果需要用mut T而不是传值之后返回 T除非你需要同时完成“消费产出”。第三返回的是一个新对象还是某个参数的引用如果返回引用接下来就涉及生命周期标注见后面的章节。3. 函数体内的表达式思维Rust没有“return返回”的习惯3.1 语句与表达式的区别是Rust的入门分水岭Rust 是一门“表达式驱动”的语言。函数体里最后一个不带分号的表达式就是函数的返回值。这句话初学者可能无感但它在实战中影响非常大代码里到处可以见到“块表达式”作为值来使用。fn classify(num: i32) - static str { if num % 2 0 { even } else { odd } }这个函数没有 return没有分号if/else 块整体作为返回值。这里有个小坑如果你在 else 分支后面加了分号返回值就从str变成了()然后编译器很生气地告诉你类型不匹配。我每次看到“expectedstr, found()”这种报错就知道对方又在分支后面乱加分号了。再看一个稍复杂点的块表达式用法fn compute(x: i32) - i32 { let y { let t x * 2; t 1 // 这个表达式的值会赋给 y }; y * 10 }花括号包裹的代码块本身也是一个表达式它的值是块内最后一个表达式的值。这种能力你会在很多 Rust 项目里看到特别是你自己构造变量时希望封装一小段逻辑又不想单独抽一个函数的地方。很多从 Go 或 Python 转过来的朋友一开始总是习惯在函数开头声明一堆变量然后中间到处写return。这不是不行但 Rust 社区的审美倾向于“表达式即返回”。我自己在写复杂逻辑时能用 match 表达式就用 match 表达式因为它能把多个分支的结果直接作为返回值代码比连续 if-else return 干净得多。3.2 发散函数和panic永不返回的函数怎么用《通过例子学Rust》第9章专门有一段讲发散函数diverging functions。这类函数没有返回值甚至类型是!never type。最典型的就是panic!宏它内部做的事是打印错误信息并终止程序所以它永远不会“正常返回”。你可以在任何需要返回值的地方写panic!因为!类型可以强制转换成任意类型fn fail() - ! { panic!(this function never returns); } fn positive_or_fail(n: i32) - i32 { if n 0 { n } else { fail() } }这看起来有点抽象但实际用处很大。比如在一个不应该发生错误的分支里你不想写一堆恢复逻辑就可以直接unimplemented!()或todo!()它们的类型也是!所以代码能编译通过。我个人在做原型验证的时候经常写todo!()但强烈建议不要让它活过代码评审否则用户一旦走到那个分支程序直接崩溃。发散函数的另一个场景是死循环比如服务器的主循环loop { ... }类型也是!。类型系统层面它明确表达了“这个函数一旦调用就不会再回来”的意图。理解这一点后你再看到fn main() - !这样的签名也就不会觉得奇怪了——某些嵌入式设备的程序入口会这么写表示永远不会退出。4. 泛型函数与生命周期把函数签名的“自由度”管住4.1 泛型参数一份实现服务多种类型如果函数只处理具体类型写起来很简单但不够通用。Rust 的泛型函数写法和其他语言差别不大fn largestT: PartialOrd(list: [T]) - T { let mut largest list[0]; for item in list.iter() { if item largest { largest item; } } largest } fn main() { let numbers vec![1, 3, 5, 2]; let chars vec![a, x, c]; println!({}, largest(numbers)); println!({}, largest(chars)); }这里T: PartialOrd是一个 trait bound意思是“T 必须实现了 PartialOrd 才能比较大小”。写泛型函数的时候你至少要思考三个层面的问题我能对这个 T 做什么操作如果不能需要加什么 trait bound如果要返回 T 的引用生命周期怎么标注我见过很多人跷跷板式地写泛型函数一开始写fn fooT(x: T) - T然后往函数体里塞各种操作编译器报一个错误加一个 bound最后 trait bound 加了七八个。这不完全是坏事但说明一开始设计得不够清晰。更好的做法是先想清楚T必须满足哪些行为再去找对应的 trait。比如要打印就T: Display要比较就T: PartialOrd要克隆就T: Clone。4.2 生命周期标注当函数返回引用时的必要约束生命周期是很多人绕不过去的坎但放在函数这一章里你只需要抓住一个核心场景函数参数里有引用返回值也是引用那么返回的引用到底能活多久编译器需要知道这两者之间的关系。看这个经典例子fn first_word(s: str) - str { s.split_whitespace().next().unwrap_or() }这本书到第9章时很多例子会直接用这种函数但不一定讲了生命周期标注因为这里只有一个输入引用编译器可以自动推断返回值的生命周期和输入s的生命周期一致。但如果你有两个引用参数fn longest(x: str, y: str) - str { if x.len() y.len() { x } else { y } }编译器立刻报错missing lifetime specifier。因为它不知道返回的是 x 还是 y也就无法确定返回值应该跟随哪个参数的生命周期。这时需要手动标注fn longesta(x: a str, y: a str) - a str { if x.len() y.len() { x } else { y } }a是一个生命周期参数它表示 x、y 和返回引用三者必须拥有同样的生命周期约束。注意这并不代表三个引用的实际存活时间完全一致而是说返回引用的有效时间不能超过 x 和 y 中两者的公共有效范围。简单理解成编译器取 “x 和 y 生命周期中较小的那一个”并保证返回引用不超出这个界限。写函数时遇到生命周期标注我有一条实用经验先写函数体让编译器报错再根据报错信息去补标注。Rust 的编译器提示已经非常友好了它会告诉你需要在哪个位置加a。一开始不熟练的时候不要硬背规则用编译器当老师就行。等你写多了就会形成肌肉记忆输入只有单个引用时不用标多个引用且返回值与其中之一相关时通常要标。5. 方法、关联函数与trait对象把函数挂到类型上5.1 impl块中的方法self的不同形态Rust 里函数不仅能独立存在还能定义在impl块里成为某个类型的方法。这里最容易搞混的是self、self、mut self三种形态。它们其实是语法糖self表示方法获取了当前实例的所有权。调用完这个方法后原变量就不能再用了。常见于把 self 转换成另一种类型的场景。self表示方法只读取当前实例。这是最常见的形态相当于 Java 里的普通只读方法。mut self表示方法会修改当前实例。写一个例子struct Counter { count: u32, } impl Counter { fn new() - Counter { Counter { count: 0 } } fn increment(mut self) { self.count 1; } fn get(self) - u32 { self.count } fn into_value(self) - u32 { self.count } } fn main() { let mut c Counter::new(); c.increment(); println!({}, c.get()); // self读取 let v c.into_value(); // self转移所有权 // println!({}, c.get()); // 编译错误c 已被移动 }这个例子很有代表性increment需要修改 self所以必须mut selfget只是读取用selfinto_value直接消费掉 Counter返回内部字段。选择哪种 self 形态实际上是在表达“这个方法的侵入性”。工程师读代码的时候看一眼方法签名就能知道调用它会不会产生副作用这就是 Rust 设计的好处。5.2 关联函数String::from()这样的“构造函数”到底怎么来的还记得我们一直在用String::from(hello)吗from就是一个关联函数associated function它定义在impl块里但第一个参数不是self。这种函数通常被当作构造函数来用约定俗成用一个叫new的函数来创建实例但new并不是 Rust 的关键字只是一个命名习惯。看这个例子struct Point { x: f64, y: f64, } impl Point { fn new(x: f64, y: f64) - Point { Point { x, y } } } fn main() { let p Point::new(3.0, 4.0); }关联函数的调用方式是类型::函数名(参数)而不是通过实例方法调用。很多初学者会把关联函数和静态方法划等号这个理解大体不错但要注意 Rust 中它和实例方法的本质区别就是没有self参数。你在构造函数里做校验、默认值设置、从配置创建实例等这些都是关联函数非常典型的使用场景。一个更实用的例子读取环境变量创建配置对象。struct Config { host: String, port: u16, } impl Config { fn from_env() - ResultConfig, String { let host std::env::var(HOST).unwrap_or_else(|_| 127.0.0.1.to_string()); let port std::env::var(PORT) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(8080); Ok(Config { host, port }) } }这个from_env虽然没有new这个名字但意思非常清楚从环境变量创建一个 Config。工程上我喜欢把这种“替代构造函数”写成可读性很强的关联函数让调用方不用看一堆初始化逻辑。5.3 函数指针和trait对象把函数当值传来传去有时候你需要把函数本身作为参数传递。最基础的当然是函数指针fn add_one(x: i32) - i32 { x 1 } fn apply_twice(f: fn(i32) - i32, x: i32) - i32 { f(f(x)) } fn main() { let result apply_twice(add_one, 5); println!({}, result); // 7 }fn(i32) - i32就是一种函数指针类型可以被当作普通值传递。这是最朴素的“函数是一等公民”的体现。不过在 Rust 里更强大也更常用的实际上是闭包尤其是配合迭代器、trait 一起用。而函数指针的局限在于它无法捕获外部环境变量闭包可以。关于 trait 对象和Boxdyn Fn(i32) - i32它是“动态分发”的场景。当你在一个集合里放多种不同类型的闭包时必须把它们统一成 trait 对象fn make_adder(step: i32) - Boxdyn Fn(i32) - i32 { Box::new(move |x| x step) } fn main() { let add_5 make_adder(5); println!({}, add_5(10)); // 15 }这里闭包捕获了外部变量step返回类型就不再是函数指针而是dyn Fn(i32) - i32由于大小未知必须包在 Box 里。这些内容在第9章的后半部分会出现它不是写业务逻辑时最常用的东西但你在写回调函数、事件系统、插件架构时会用到。6. 闭包和高阶函数Rust函数式的灵魂6.1 闭包三兄弟Fn、FnMut、FnOnce闭包本质上也是一种变量只是把可执行逻辑打包了。Rust 的闭包有三条 traitFn、FnMut、FnOnce。很多初学者第一次接触到这三个名字时都懵了函数就函数还分这么细干嘛其实这三兄弟描述的是“闭包捕获变量的方式”FnOnce只能被调用一次的闭包。它通过所有权捕获变量调用时把捕获的变量消费掉。如果你把一个move进去的 String 打印一次后还想再打印一次就编译不过了。FnMut可以被多次调用但会修改捕获的变量。比如闭包内部需要对捕获的计数器加一由于修改了捕获状态闭包自然是 FnMut。Fn只能读取捕获到的变量不修改、不消费可以被多次调用。怎么判断一个闭包到底是哪一个最实用的办法是看你对捕获变量的操作。不动被捕获变量就是 Fn修改了它就是 FnMut把它 move 走了或者调用了本身就是 FnOnce 的东西就成了 FnOnce。编译器会根据闭包体自动推断 trait。看一个例子fn main() { let mut total 0; // 错误闭包是 FnMut不能用只要求 Fn 的函数来接收 // let mut add_to_total || { // total 1; // }; let mut add_to_total |x: i32| { total x; }; add_to_total(10); add_to_total(20); println!(total {}, total); }total在闭包中被修改所以这个闭包只能作为FnMut使用。如果你把一个FnMut类型的值传给需要Fn的接口编译器会拒绝。这种区分在写并发代码时尤其重要如果你要在多线程场景共享闭包就必须保证闭包是Fn因为FnMut内部状态不可在线程间并发访问。6.2 用迭代器链式调用闭包filter/map/fold 的真实案例提到闭包就不得不提迭代器链式调用。这是 Rust 函数式编程最直观的应用场景。以前写循环的时候是这样的let nums vec![1, 2, 3, 4, 5, 6]; let mut evens_squared_sum 0; for n in nums { if n % 2 0 { evens_squared_sum n * n; } }用迭代器和闭包写就是这样let nums vec![1, 2, 3, 4, 5, 6]; let evens_squared_sum: i32 nums .iter() .filter(|x| x % 2 0) .map(|x| x * x) .fold(0, |acc, x| acc x);注意.filter(|x| ...)的写法iter()迭代的是i32闭包参数如果写成|x|实际上是在模式匹配解引用拿到的是i32。我第一次写的时候经常被这个双引用绕晕后来形成了自己的习惯用|x|解引用简单明了。不过要注意这样写要求 i32 实现了 Copy否则你x解出来的就不是 Copy 类型编译器又会报“cannot move out of reference”。学会了迭代器链你会发现很多循环代码可以被压缩成一行可读性很高的表达式。但我也要提醒一句过度链式调用同样会牺牲可读性。如果你发现一行链超过七八个操作那不如拆成几个中间变量给每个中间变量起个有意义的名字比一大串闭包更容易让别人看懂。7. 错误处理与async现代Rust函数的两大高频扩展7.1 Result/Option作为返回值把“可能失败”写进签名函数返回什么类型直接暴露了这个函数“会不会失败”“失败后怎么处理”。C 语言里你只能靠返回 -1 或者错误码约定Java 里靠异常机制。Rust 干脆把“可能出错”写进了返回类型。函数要么返回T要么返回ResultT, E要么返回OptionT。这样设计的好处是调用方被强制处理错误无法忽略。看一个文件读取的例子use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; fn read_first_line(path: Path) - ResultString, std::io::Error { let file File::open(path)?; let reader BufReader::new(file); reader.lines().next().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, empty file) }) }这里用到了?运算符它是“提前返回错误”的语法糖如果File::open返回Err函数直接返回这个Err如果是Ok则把里面的值解出来赋值给变量。这个运算符彻底把多层嵌套的错误处理拍平了。以前写 Java 或者其他语言时见惯了 try-catch 套 try-catchRust 的?让代码清爽很多。关于OptionT它表示“可能有值可能没有”。如果你的函数在“找不到东西”这种场景下没有更复杂的错误信息要传递就用 Option 而不是 Result。比如在一个字符串里找指定子串的下标返回Optionusize就足够了不需要额外说明失败原因。7.2 async函数返回Future的函数怎么写才对第9章虽然没有大篇幅讲 async但现代 Rust 工程里 async 函数几乎无处不在。async fn和普通函数的区别是调用 async 函数并不会立即执行函数体而是返回一个Future。真正开始执行要靠执行器比如tokio::spawn或者block_on。一个简易的例子async fn fetch_data(id: u32) - String { format!(data-{}, id) } #[tokio::main] async fn main() { let future fetch_data(42); // 此刻函数体还没执行 let result future.await; // 此刻才开始执行 println!({}, result); }这个await是异步函数里的关键操作。future.await会挂起当前函数让出线程控制权等数据准备好了再恢复执行。写 async 函数时有个很重要的经验不要在 async 函数里做长时间同步阻塞操作比如直接std::thread::sleep否则会阻塞整个执行器线程。要睡就用tokio::time::sleep它会主动让出执行权。从函数设计的角度讲async 函数也是一种函数你同样要思考所有权和借用的问题。更麻烦的一点是异步函数里的借用会让Future的生命周期变得复杂。比如在这个例子中async fn process(s: str) - usize { s.len() }这个函数返回的 Future 借用了一个外部引用s意味着这个 Future 不能在s被销毁之后继续存在。写调用方代码时尽量让Future的生命周期短一点或者把需要的数据move进 async 块里能省掉很多生命周期上的纠结。8. 常见编译错误和调试技巧速查8.1 高频错误borrow of moved value 等函数这一章最容易遇到的编译错误翻来覆去就那么几个。第一个是borrow of moved value。原因是把变量移动进函数之后还在后面用原变量。解决办法传引用或者重新绑定返回值或者在调用前 clone 一份。第二个是cannot borrow *x as mutable more than once at a time。典型场景是你拿到一个可变引用之后在函数里又尝试创建第二个可变引用。解决办法是改变代码结构缩小可变引用的作用域让它不再同时存在。第三个是lifetime may not live long enough。这个多数出现在返回引用的函数上你需要给签名标注合适的生命周期参数。记住编译器不是故意刁难你它只是不知道“返回值的寿命应该依附于谁”补上就行。第四个是wrong number of type arguments: expected 1, found 0这类泛型推倒错误。我用HashMap和Vec的时候偶尔会遇到通常是因为显式声明类型和实际函数返回类型对不上或者在不该写泛型参数的位置写了泛型参数。看到这些错误不用紧张我的经验是先读错误信息的最终提示部分Rust 编译器通常会给出建议写法。除了极端情况直接按它的建议改大多数都能通过。8.2 我的调试习惯dbg!/RUST_BACKTRACE 与 VSCode 断点如何取舍在函数逻辑复杂时我会优先用dbg!宏来看关键位置的变量状态。它特别方便的地方是能打印出文件名、行号和表达式本身fn main() { let s String::from(hello); let len dbg!(s.len()); dbg!(len); }运行时会看到[src/main.rs:4] s.len() 5这样的输出比println!清楚得多。dbg!返回你所传入表达式的值所以可以直接嵌在表达式中间比如let len dbg!(s.len());。这一点实测很顺手调试完删起来也容易。如果程序 panic 了设置环境变量RUST_BACKTRACE1能看到完整的调用栈能够快速定位是哪个函数调用链出了问题。我第一次追一个“借用冲突”错误时就是靠 backtrace 找到了关键调用点节省了大量时间。断点调试也是一个常用手段。我在 VSCode 里配合 rust-analyzer 和 CodeLLDB 插件可以在函数入口、循环内、match 分支里打断点查看各种引用类型的实际值。不过我个人的习惯是先用dbg!或println!快速定位问题所在区域再决定是否要用断点做深入调试。因为断点调试要配置 launch.json有时候反而慢。还有一个很实用的小经验当你真的被某个函数搞懵了试着把函数体删掉只保留签名写一个todo!()或空实现让整个项目先编译通过再一段一段加回逻辑。这么做的好处是你能确定到底是“函数签名设计问题”还是“函数体实现问题”。如果是前者加了再多的逻辑也还是会报错如果是后者你恢复一半的时候就能定位到具体是哪个表达式出了问题。9. 函数设计的一些心法从编译通过到代码优雅说实话能编译通过的函数只是及格线。写函数真正考究的是边界设计参数的粒度、返回值的类型、错误处理的策略、泛型和生命周期的使用。我在实际项目中摸索出来几条比较实用的心法可能对你有帮助。一条是“小函数单一职责”。Rust 的编译器虽然不会因为你函数太长而报错但一旦涉及生命周期标注和借用关系函数越复杂越难推理。我见过一个 200 行的函数里面反复改同一个结构体的多个字段最终到处是mut借用冲突改成三个小函数后逻辑瞬间清楚了借用也不冲突了。另一条是“优先用引用少用所有权转移”。我见过不少团队里有人写函数时图省事把 String 随意传进函数再返回一个新的导致一堆clone()和数据搬来搬去性能倒是其次主要是读代码的人很难分辨哪些变量是“被消费了”哪些只是“借出去看了一眼”。绝大多数业务函数都应该用str、[T]、T只有在需要存储、并发转移、跨线程传数据时才考虑所有权转移。还有一条是关于返回值的能用OptionT就别返回OptionT的 clone 版本。比如你从 HashMap 里取一个值再返回直接返回引用比克隆一份完整数据高效得多前提是生命周期管理得当。写 API 的时候可以提供一个返回引用的方法需要克隆时让调用方看着办这比什么都替你决定要好。写第9章这段时间我自己最有感触的一点是函数在 Rust 中并不仅仅是一段可复用的代码它还承载了类型系统的约束。函数签名透露的信息量远超普通语言——它告诉你数据是否被消费、是否被修改、是否可以返回引用、是否可能失败。这种信息密度一开始可能让你觉得写个函数怎么这么麻烦但习惯之后你会发现自己读别人代码、回忆别人设计决策的速度反而更快了。如果你正在学习这章别急着赶进度把每个例子都亲手编译一下特别是那些故意写错的例子体会一下编译器报错和修错的全过程。亲自踩一遍坑比看十遍讲解都管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进