ARTICLE · 1238241
从 0 到 1 学 Codex 源码:Rust绕不开的所有权,一篇讲透
温馨提示:这篇信息量较大,不必一次读完,也不用急着掌握所有细节。可以分几次阅读,或者先看第 0 章和第 1 章,对所有权、借用以及它们的设计原因有个印象。后面的规则补充、接口练习和附录,等读源码遇到相关问题时,再回来查阅也可以。
所有权是 Rust 的一个重要特性。读 Codex 源码时,经常会看到数据被移交、借用,或者要求某个引用继续有效。这一篇,我们从一个字符串开始,理解这些写法背后的规则。如果你有编程经验,已经认识 Rust 的变量、函数、结构体和 match,就可以跟着例子往下看,不需要提前理解所有权、借用或生命周期。
刚接触时也不用给自己太大压力。不少类型信息与生命周期标注,是为了让编译器检查数据能否安全使用;暂时不熟悉它们,通常并不妨碍我们先理解“这段代码在做什么业务”。遇到数据交接、修改或返回引用的地方,再停下来分析就好。这篇也是我边学边写的记录,如有不准确之处,欢迎指正。
零 从一个字符串开始理解所有权
先不看结构体和真实项目,只看一个字符串。下面每个代码块都是一个独立的完整程序,可以分别放进 main.rs;后面的版本是在修改前面的例子,不是接着粘贴到它的末尾。
配套代码已上传 GitHub:macOS 示例 · Windows 示例。本篇对应 v0.2.0,共 36 个独立的 Rust 示例,其中 27 个可以运行,9 个用于观察预期的编译错误。两仓 Rust 代码相同,都在 articles/02-understanding-ownership-and-borrowing/ 下按阅读顺序组织;每个目录的 README 说明了相应平台的运行方法和预期结果。预期失败的示例用于理解规则,不需要把它们改成编译通过。
配套代码地址(复制到浏览器打开):
macOS: https://github.com/NewComerC/codex-rust-learning-macos/tree/v0.2.0/articles/02-understanding-ownership-and-borrowingWindows: https://github.com/NewComerC/codex-rust-learning-windows/tree/v0.2.0/articles/02-understanding-ownership-and-borrowing
0.1 先创建一个值
fn main() { let a = String::from("hello"); println!("{a}");}这个程序输出 hello。先拆开第一行:
String是一种字符串类型。 String::from("hello")调用 String 这个类型下的 from 函数,创建一个内容为 hello 的 String 值。 let a = ...把创建的值绑定给变量 a。这里不用写类型,编译器可以推断 a 的类型是 String。
在这个例子里,我们说 a 持有这个 String 值,也就是 a 拥有它。 这不只是说“可以通过 a 读到文本”,还包含清理责任:正常走到 main 最后的右花括号时,这个尚未转交出去的 String 会被销毁,它管理的文本内存也会释放。我们不用再写一行释放代码。String 的内容与存储
因此,可以先把这个例子中的所有权理解为:这个值目前归谁持有,正常结束时就由相应的持有关系决定在哪里清理。
0.2 只加一行赋值 会发生什么
现在把程序改成下面这样:
fn main() { let a = String::from("hello"); let b = a; println!("{b}");}仍然输出 hello。新增的 let b = a 把原来由 a 持有的 String 交给了 b。Rust 把这个动作叫作移动(move):值的持有者变了,这次赋值没有再复制一份 hello 文本。
到这里可能还看不出区别。我们再把打印 a 的语句加回来:
fn main() { let a = String::from("hello"); let b = a; println!("{b}"); println!("{a}");}这次编译失败,核心错误是:
error[E0382]: borrow of moved value: `a`这句话可以先读成:“a 中的值已经被移走,后面却又想通过 a 读取它。”编译器会在 let b = a 处标出 value moved here,在最后一次打印 a 的位置标出 value borrowed here after move。
按语句顺序看,就是:
创建 String,让 a 持有它。 把这个 String 从 a 交给 b。 通过 b 读取它是合法的。 再通过 a 读取已经交出去的值,不合法。
文本没有消失,它现在由 b 持有。 a 也不是被设成了空字符串或 null;编译器直接拒绝通过 a 读取已移走的值。失败的这一版程序根本不会开始运行,前面的 println!("{b}") 也不会输出。
删掉最后一行,程序就恢复正常;结束时清理 b 持有的 String,不会再通过 a 清理同一份文本。这里先记住现象,第一章再解释为什么要这样设计。值的移动与复制规则
0.3 如果确实要两份文本 就显式复制
把 let b = a 改成 let b = a.clone():
fn main() { let a = String::from("hello"); let b = a.clone(); println!("a = {a}, b = {b}");}这次可以编译,输出 a = hello, b = hello。
a.clone() 是调用 a 的 clone 方法。对 String 来说,这会复制文本内容,创建另一个 String。 a 继续持有原来的值,b 持有复制出来的新值;它们各自管理自己的文本,结束时也各自清理。
所以,let b = a 和 let b = a.clone() 表达的是不同需求:前者把已有的值交过去,后者主动再创建一份。这里的复制说明针对 String,其他类型的 clone 可能有不同的实现。String 的 clone
延伸阅读:熟悉 Java 的读者可能会想到引用、字符串常量池和
new String(...):Rust 这里究竟复制了什么,文本存在哪里?这部分放在附录 A。如果还联想到 Redis SDS,可以继续看附录 B。
0.4 如果只是想看看 就借用
如果 b 只是想读取文本,并不需要一份独立副本,可以把那行改成 let b = &a:
fn main() { let a = String::from("hello"); let b = &a; println!("a = {a}, b = {b}");}它同样输出 a = hello, b = hello,但这次没有复制文本。
关键是新出现的 &。在表达式 &a 中,它表示借用 a 持有的值,得到一个引用。引用可以先理解为一个访问已有值的入口:a 的类型还是 String,b 的类型则是 &String,读作“对 String 的引用”。
a 仍然拥有原来的 String;b 通过引用访问它,不负责清理那份文本。这就是“拥有”和“可以访问”的区别。借用也不是先把所有权交出去、用完再还回来:这次操作中,String 的所有权始终没有离开 a。
引用能用多久也有限制:b 还要使用这份文本时,文本必须继续有效。Rust 会检查这种关系,后面再展开具体限制。借用运算符
这里也解释一下反复出现的 println!:这些打印语句只是借用值来显示内容,不会把 String 取走。因此,打印一次以后,原来的值仍然可以继续使用。
0.5 换成函数调用 规则仍然一样
赋值能把 String 交给另一个变量,传参也能把它交给函数参数。先看一个预期编译失败的程序:
fn show(text: String) { println!("{text}");}fn main() { let a = String::from("hello"); show(a); println!("{a}");}报错仍然是 error[E0382]: borrow of moved value: a,这次移动发生在 show(a),再次读取发生在最后的打印语句。
从语法上看,text: String 表示参数 text 要接收一个 String 值。调用 show(a) 时,a 持有的 String 被移给参数 text,就像前面从 a 交给 b 一样。
即使 show 的函数体只是打印,参数的接收方式也已经决定了这次转交。 假如删掉最后读取 a 的那一行,程序就能运行;show 打印后正常返回时,text 持有的 String 会被清理,因为这个函数没有把它返回或交到别处。
如果需求只是让函数看看文本,就把整个程序改成下面这个独立版本:
fn show(text: &String) { println!("{text}");}fn main() { let a = String::from("hello"); show(&a); println!("{a}");}这次正常输出两行 hello。注意改了两个位置:
定义处的 text: &String:函数接收一个引用,通过它访问已有 String。调用处的 show(&a):创建对 a 中 String 的借用,把引用交给函数。
调用结束后,a 仍然持有原来的 String,所以最后一次打印合法。**判断函数是否接管数据,要看参数类型和调用方式,不能只看函数体有没有修改内容。**引用作为函数参数
这里先用 &String,方便和 String 直接对照;后文再讲更常用的字符串读取参数 &str。
0.6 整数为什么看起来不一样
前面一直特意使用 String。换成整数,下面的代码是可以运行的:
fn main() { let a: i32 = 5; let b = a; println!("a = {a}, b = {b}");}输出是 a = 5, b = 5。这里的 i32 是整数类型,它具备 Copy 能力,赋值时复制数值,原来的 a 仍然可用;String 不具备这个能力,所以前面的赋值会移动它。Copy 的规则
因此,不能把规则记成“赋值以后,旧变量就不能用了”。按值传递时,要结合类型判断是复制还是移动。 Copy 的具体定义后面再讲,现在先把已经见过的写法对应起来:
- 写法
:String 的 let b = a;这个例子里发生了什么:原值交给 b,不能再通过 a 读取它 - 写法
:String 的 let b = a.clone();这个例子里发生了什么:创建文本副本,a、b 各自持有一份 - 写法
:String 的 let b = &a;这个例子里发生了什么:b 借用原值,String 继续由 a 持有 - 写法
:i32 的 let b = a;这个例子里发生了什么:复制数值,a、b 都能继续使用
前面已经知道:借用能让我们读取原来的文本,不必复制,也不必拿走它。接下来补上一个限制:如果原来的文本已经被清理,这个引用还能继续用吗?
0.7 借来的引用 不能在原数据销毁后继续使用
回到 0.4 的借用。原来的变量负责持有和清理 String,引用只提供访问入口。因此,通过引用读取时,那份 String 必须还在。
为了看清清理发生的位置,我们给创建文本的代码加一层花括号。下面用 message 保存文本,view 保存借来的引用:
fn main() { let view; { let message = String::from("hello"); view = &message; println!("{view}"); } // message 持有的 String 在这里销毁}程序输出 hello。这里的 let view; 是先声明变量,稍后再赋值;view = &message 才让它保存一个引用。打印发生在内层右花括号之前,此时文本还在,所以可以读取。
现在只把打印语句挪到内层花括号外面:
fn main() { let view; { let message = String::from("hello"); view = &message; } // message 持有的 String 在这里销毁 println!("{view}");}这次编译失败,核心错误是:
error[E0597]: `message` does not live long enough可以读成:“message 没有存活到读取 view 所需要的位置。”按照这段代码的安排,先销毁 String,再通过引用读取它;view 没有自己的文本副本,也不会让原来的 String 自动保留下来,因此编译器拒绝了这个程序。它不会等程序真的读到已释放的内存才报错。
把打印移回内层,就恢复成第一个合法版本。关键是使用引用时数据还有效,不能只看 view 这个变量名是否仍在作用域内。
Rust 把引用可以合法使用的范围称为它的生命周期(lifetime)。前面的 show(&a)、let b = &a 也一直受这个要求约束。我们现在只是把这条借用规则单独拿出来看:所有权关系决定数据由谁持有和清理,引用的有效范围则限制别人能借用到哪里。引用的生命周期
0.8 什么时候需要把生命周期写出来
这里有一个容易影响理解的习惯:读到陌生语法,会下意识地想“程序运行到这里,会执行什么动作”。但源码里既有运行时要执行的操作,也有供编译器检查的类型信息。生命周期标注属于编译期的约束信息,真正参与检查,不只是方便人阅读。
TypeScript 中的类型标注可以帮助理解这个区别。例如参数里的 value: number,把开发者对参数类型的要求交给编译器检查;生成 JavaScript 时,类型标注会被移除,它本身不会自动添加运行时类型校验。这里类比的是“源码承载编译期信息”这一点,TS 并没有因此具备 Rust 的借用检查。TypeScript 的类型标注与擦除
带着这个区别,我们先按“可以省略 → 不能省略 → 补全后通过”的顺序看三个程序,再补充两个输入引用共用生命周期参数的情况。
0.8.1 只有一个输入引用 为什么可以省略
下面的函数接收一个文本引用,再原样返回:
fn borrow_text(source: &String) -> &String { source}fn main() { let message = String::from("hello"); let view = borrow_text(&message); println!("{view}");}这个程序不写生命周期标注也能编译,输出 hello。-> &String 表示返回引用;函数体最后的 source 没有分号,把传进来的引用作为结果返回。view 访问的仍是 message 持有的文本。
能省略的原因是:这里只存在一个输入引用,Rust 的生命周期省略规则会把返回引用的有效范围关联到它。调用方使用返回的 view 时,source 指向的数据必须继续有效。
因此,这个例子确实不需要额外标注。省略的是写法,输入引用与返回引用之间的约束仍然存在。 也正因为如此,单看这个例子,很难体会什么时候必须手写标注。生命周期省略规则
0.8.2 增加第二个输入引用 不标注就会编译失败
现在增加第二个引用参数,函数体仍然返回第一个:
fn first_text(first: &String, _second: &String) -> &String { first}fn main() { let message = String::from("hello"); let other = String::from("world"); let view = first_text(&message, &other); println!("{view}");}第二个参数暂时不用,所以取名 _second;下划线前缀只是避免“变量未使用”的提示,与生命周期无关。
这段代码不能编译。 使用本文的工具链实际编译,得到的核心报错是:
error[E0106]: missing lifetime specifier意思是“缺少生命周期说明”。它在编译阶段就被拒绝,不会运行到最后的打印语句。
虽然我们看函数体,知道实际返回的是 first,但这个普通函数的签名有两个输入引用。省略规则无法确定:返回引用的有效范围应该关联第一个输入、第二个输入,还是同时受两个输入限制。
Rust 要求这类关系体现在函数签名中,不会根据函数体里返回了谁,替这个签名补全关系。调用方需要依据接口约定使用返回引用,而不是靠读完实现去猜使用条件。函数签名中的生命周期关系
0.8.3 加上标注 明确输入引用与返回引用的关系
这个函数只返回 first,我们就把第一个输入引用和返回引用关联起来:
fn first_text<'borrow>(first: &'borrow String, _second: &String) -> &'borrow String { first}fn main() { let message = String::from("hello"); let other = String::from("world"); let view = first_text(&message, &other); println!("{view}");}现在可以编译,输出 hello。与失败版本相比,函数体和调用代码都没变;改变的是签名里增加了编译器需要的约束信息。
按三个位置拆开读:
- 写法
: first_text<'borrow>;在这里的作用:声明一个名叫 'borrow 的生命周期参数 - 写法
: first: &'borrow String;在这里的作用:输入引用需要在 'borrow 所代表的范围内有效 - 写法
: -> &'borrow String;在这里的作用:返回引用也满足这个有效范围的要求,与第一个输入建立联系
'borrow 是一个编译期的生命周期参数名,不是关键字,也不是额外传入的运行时参数。单引号是名称的前缀,不需要结束引号;把三个位置一起改成 'text 或后文常见的 'a,含义都不变。可以替换的是名字,输入与返回引用共用这个参数所表达的关系有实际约束作用。
这里关联的不是“调用方的变量”和“函数内部参数变量”各自的存活时间,而是输入引用与返回引用的有效范围。函数返回后,参数变量 first 已经结束,但原 String 仍由调用方的 message 持有,所以返回的 view 仍可使用。
在这个例子里,调用方继续使用 view 时,需要维持第一个输入的借用有效性;第二个输入没有加入这层返回约定。究竟返回哪个引用,由函数体中的 first 决定,生命周期标注不会执行一次选择,也不会延长任何 String 的寿命。
这个过程也体现了开发者与编译器之间的信息如何传递:开发者把引用关系写进函数签名,编译器据此检查函数实现是否满足约定、调用方的使用是否符合约定;其他开发者也能通过签名读懂这些限制。
0.8.4 如果两个输入都可能返回 就把它们一起纳入约定
上一版始终返回 first,所以只把第一个输入与返回引用关联起来。现在改成二选一:use_first 为 true 时返回第一个,为 false 时返回第二个。这次让两个输入引用和返回引用都使用同一个 'borrow:
fn choose_text<'borrow>( first: &'borrow String, second: &'borrow String, use_first: bool,) -> &'borrow String { if use_first { first } else { second }}fn main() { let message = String::from("hello"); let other = String::from("world"); println!("{}", choose_text(&message, &other, true)); println!("{}", choose_text(&message, &other, false));}程序依次输出 hello 和 world。这里的两层作用需要分开看:
- 函数体中的 if 决定实际返回哪个引用。
true、false 改变选择,生命周期参数不参与这个运行时判断。 - 签名中的 'borrow 规定返回引用的有效性约束。
因为两个输入都可能被返回,调用方使用返回引用的范围必须同时满足两个输入的借用限制。
共用 'borrow 不要求两个 String 同时创建、同时销毁;它要求两个输入引用都能覆盖这次调用所需的共同有效范围。即使某次调用实际选中了存活更久的那份文本,调用方也仍然要遵守签名对两个输入建立的约定。
这也不是说“所有引用参数都应该标成同一个名字”。上一版固定返回 first 时,第二个输入不需要加入返回约定;这一版可能返回任意一个,才把两个输入都纳入约束。函数签名中的生命周期约束
阅读这里的 'borrow 时,要把它看成参与编译检查的类型约定。程序运行时不会再执行一个名叫 borrow 的动作,标注也不会延长任何 String 的寿命。接下来再回头看 Rust 为什么这样设计。
一 Rust 为什么这样设计所有权
1.1 从内存的申请与释放说起
第 0 章里,我们看到 let b = a 之后不能继续读取 String 类型的 a。这条限制要解决什么问题?
先从 String 背后的文本说起。程序创建一段文本,需要内存来保存它;不再保留这段文本时,相应空间需要释放,供后续分配使用。如果释放安排得不正确,就会出现几类问题:忘记释放会占住不再需要的空间;释放得太早,其他代码可能还在访问它;对同一块空间重复释放,则可能破坏内存管理。内存的申请与释放
有些语言通过垃圾回收器,在程序运行时追踪对象是否还可达,再回收相应内存。Rust 希望在不依赖垃圾回收器的情况下提供内存安全保证,因此把值由谁持有、怎样转交以及引用能否继续使用,纳入编译器检查的规则。Rust 的所有权设计目标
回到 let b = a:如果不复制文本,又让 a、b 分别承担同一块文本内存的释放责任,那么它们结束时就可能清理两次。Rust 对 String 的选择是转交:由 b 接手,a 不再持有原值,也不能再读取它。需要另一份独立文本时,程序员可以显式写 clone,把复制需求和成本表达出来。
但只有转交还不够。一个函数可能只想看看文本,如果每次都要复制,或者接管后再返回,使用起来会很麻烦。因此 Rust 又提供借用:让函数临时访问原值,同时保持原有的持有关系。借用必须受到约束,否则仍可能在数据已经失效后继续访问;后面的共享借用与可变借用规则,正是为了约束这些访问。
现在可以给出更完整的定义:所有权是一组规定值由谁持有、怎样转交,以及何时销毁的语言规则;借用规则进一步约束其他代码怎样访问这些值。 两者配合,让编译器能检查许多与内存使用有关的错误,程序运行时再按安排执行清理。
这也解释了为什么只记“一个值有一个所有者”还不够。读代码时,还要看其他地方是否正在借用它。持有者不能因为拥有数据,就随意执行与有效借用冲突的修改、移动或销毁。结构体字段也可以持有值,所以“所有者”也不局限于我们刚才写出的变量名。
1.2 不需要一个开启所有权的关键字
沿用第 0 章的赋值形式,再看一个完整程序:
fn main() { let a = String::from("解释这个项目"); let b = a; println!("{b}");}它会输出“解释这个项目”。let b = a 把 a 中的 String 移动给 b,之后由 b 持有这个值。如果在这行之后再加一句 println!("{a}");,编译器就会报告使用了已经移动的值。
这里没有 ownership 关键字,也没有写 move。所有权规则已经包含在普通赋值、传参和返回的含义中。 编译器结合类型与表达式的用法判断发生什么:这个例子中的 String 不具备第 0 章提到的 Copy 能力,因此值被移动,不能继续通过 a 读取已移走的 String。移动与复制规则
这也不是程序运行后把 a 设置成 null。后续读取 a 的程序在编译阶段就会被拒绝。
源码中与这套机制有关的写法,可以先按职责认识:
- 写法
: fn consume(value: T);表达什么:按值接收参数;发生移动还是复制,要看具体类型 - 写法
: fn inspect(value: &T);表达什么:接收共享引用,借用已有数据 - 写法
: fn change(value: &mut T);表达什么:接收可变引用,借用数据以进行修改 - 写法
: &value、&mut value;表达什么:在表达式中创建相应借用 - 写法
: let mut value;表达什么:允许修改这个绑定,不决定它是否拥有一个值 - 写法
: 'a;表达什么:在需要时标明引用有效范围之间的约束
表中的 T 只是代指某个类型,暂时不需要学习泛型定义。& 是这里的借用运算符,在类型位置则构成引用类型。Copy、Clone、Drop 是与复制或清理有关的 trait,可以先理解为类型遵循的能力约定,它们都不是关键字。
Rust 确实有 move 关键字,用来指定闭包或 async 块按值捕获所需变量。它并不是每次转移所有权都必须写的标记,闭包的细节留到后续再讲。move 关键字
1.3 编译器怎样知道一个值还能不能用
要理解实现过程,可以把语言规则、写下的代码、编译检查和运行时行为连起来看。
语言首先规定哪些操作合法。程序通过变量、字段、参数类型和表达式表达数据的关系。编译器再分析这些关系:值是否已经初始化,是否已经移走,当前有哪些借用,引用在使用时是否仍然有效。
例如,分析前面的程序时,编译器知道 a 最初保存 String;执行到 let b = a 后,a 中的值已经移出。如果后面再读取 a,这条使用路径就不合法。对于借用,它还会检查:某个引用后续是否仍要使用,中间的修改或移动是否会与这个引用发生冲突。
当前 rustc 的借用检查在 MIR 上完成。MIR 是编译器处理后的程序表示,里面保留了赋值、分支和调用等信息。编译器在它上面做数据流分析与引用范围分析,最后报告不满足规则的操作。这里只需要知道:**检查依赖类型和程序的控制流,不是简单搜索有没有某个关键字。**rustc 借用检查说明
通过检查后,生成的程序仍要在运行时申请内存、读取内容和释放资源。编译器会按照销毁规则安排相应的清理逻辑。对于前面的简单程序,正常退出 main 时,b 持有的 String 需要清理;a 的值已经移走,不能再按原来的 String 清理一次。
类型可以通过实现 Drop 定义清理行为。编译器生成的析构逻辑还会处理字段,因此一个 Request 即使没有自己实现 Drop,销毁它时仍然会清理内部的 String 字段。析构逻辑的生成
- 阶段
:编写代码;在这个例子中完成的工作:用 String、赋值和变量绑定表达数据关系 - 阶段
:编译检查;在这个例子中完成的工作:判断 a 的值已经移走,拒绝后续非法读取 - 阶段
:生成程序;在这个例子中完成的工作:安排仍需执行的清理逻辑 - 阶段
:运行程序;在这个例子中完成的工作:创建文本、打印内容,并在相应位置执行清理
普通 String 不需要一个通用的运行时所有者登记表来完成上述检查,但这不意味着所有资源管理都没有运行时成本。分配和释放本身需要执行;某些条件分支还可能需要判断一个值是否仍应清理。后面遇到的引用计数类型,也有各自的运行时工作。
1.4 所有权思想并非 Rust 独有
“谁负责一个资源、什么时候释放、能否把责任交给别人”,这些问题在其他语言里同样存在。理解 Rust 时,可以借助已有经验,但要说明类比的边界。
C++ 的 RAII 和 unique_ptr,最适合帮助理解持有与清理。 RAII 将资源的管理与对象生命周期关联起来,对象析构时执行释放;unique_ptr 则表达独占持有,可以通过移动转交所管理的资源。现代 C++ 已经提供了这些工具,不能把它概括为到处手写 new/delete。C++ 资源管理指南
不过,自动清理资源并没有回答所有访问问题。即使资源由 unique_ptr 管理,另行取出的普通指针或引用,仍可能在拥有者销毁或重置之后悬垂。RAII 和智能指针本身不等于 Rust 的借用检查;Rust 的安全引用需要满足相应的有效性约束。C++ 关于悬垂引用的说明
两种语言里的“移动”也不能完全等同。C++ 的 std::move 本身是类型转换,实际行为取决于后续的构造、赋值等操作。以 unique_ptr 的移动构造为例,源 unique_ptr 会变空,但它仍是可以检查、重新赋值的对象。Rust 的 String 被移走后,则不能继续读取原绑定中的那个值。C++ move 语义、unique_ptr 移动构造
Java、Python 更适合帮助理解多个入口访问同一对象。 例如,在 Python 中执行下面的完整片段:
a = [1, 2]b = ab.append(3)print(a) # [1, 2, 3]这里没有复制列表,a 和 b 都能访问同一个对象。Java 中复制一个对象引用,也可以让两个变量继续引用同一对象。这与 Rust 的 let b = a 移动 String 的结果不同,不能照搬“多了一个引用,原来的名字当然还能用”的直觉。Python 赋值语句、Java 对象引用
Java 的 GC 管理堆存储回收,文件等资源的及时关闭则是另一项责任。try-with-resources 能帮助我们理解离开代码块时自动关闭资源,但它不会同时建立 Rust 的移动和借用规则。JVM 堆存储管理、AutoCloseable
所以,Rust 的特点应当落在这套组合上:把值的归属和转移、引用的有效性、访问之间的约束纳入语言规则,并在普通安全代码中由编译器检查。 接下来我们就从 String 开始,看这些规则怎样影响一段具体代码。
二 补齐修改与读取数据时的规则
这一章补上几处读代码时会遇到的细节:类型怎样决定复制行为,借用怎样允许修改,以及为什么读取文本的参数经常写成 &str。
2.1 Copy 和 Clone 还需要注意什么
先把前面见过的两个赋值例子放在这里对照。下面两个代码块都是独立的完整程序。
先看整数:
fn main() { let a: i32 = 5; let b = a; println!("a = {a}, b = {b}");}这个程序可以编译,输出 a = 5, b = 5。赋值以后,a、b 都能继续使用。
再看 String。下面这一版预期编译失败:
fn main() { let a = String::from("hello"); let b = a; println!("{b}"); println!("{a}");}核心错误是 error[E0382]: borrow of moved value: a:let b = a 已经把 String 移给 b,最后一行却又试图通过 a 读取它。整个程序无法通过编译,所以前面的 println!("{b}") 也不会输出。
同样是 let b = a,整数赋值后两边都能使用,String 赋值后原绑定却不能继续读取。区别在于类型是否实现了 Copy。
Copy 是一个 trait,可以先理解为类型声明的一种能力:这个类型的值可以通过简单复制其表示来得到另一个值,赋值或按值传参时不必显式调用 clone。 i32、u32 都具备这种能力,String 不具备。Copy 文档
[!warning] 易错点 判断时不要套用“在栈上就复制,在堆上就移动”,也不能只看值的大小。
一个自定义结构体即使只含整数,也不会自动实现 Copy;包含 String 字段的结构体则不能直接实现 Copy。
只含整数的结构体,可以用 #[derive(Copy, Clone)] 声明这两项能力。看一个完整程序:
#[derive(Copy, Clone)]struct Count { value: u32,}fn main() { let a = Count { value: 5 }; let b = a; println!("a = {}, b = {}", a.value, b.value);}#[derive(Copy, Clone)] 让编译器为 Count 自动生成 Copy 和 Clone 的实现。这里的字段 u32 支持这两项能力,因此可以这样声明;实现 Copy 的类型也必须实现 Clone,所以把它们一起写上。
程序输出 a = 5, b = 5。有了 Copy,let b = a 会复制 Count 的值,a 仍然可用。如果删掉 #[derive(Copy, Clone)],这次赋值就会移动 a,最后一行再读取 a.value 时便会编译失败。字段是整数,只说明这个结构体可以实现 Copy,不代表它已经自动具备 Copy。
Clone 则提供显式调用 clone 的能力,具体怎样复制由类型实现决定。对 String 来说,它复制文本内容;有些类型克隆的却是指向共享数据的句柄。因此,**不能把所有类型的 clone 都理解成完整复制底层数据。**Clone 文档
后面读到 clone,先看接收者是什么类型,再判断这次调用创建了什么。需要另一份独立文本,才是 String 的 clone 对应的需求;只是读取已有内容,可以继续借用。
2.2 可变借用允许修改原来的数据
如果函数需要修改文本,可以使用 &mut String。下面是完整程序:
fn add_hint(text: &mut String) { text.push_str(",先看目录");}fn main() { let mut text = String::from("解释这个项目"); add_hint(&mut text); println!("{text}");}输出中会包含追加的提示,因为函数修改的就是调用方持有的 String。
这里有两个不同位置的 mut:let mut text 允许修改 text 持有的 String;调用中的 &mut text 对它进行可变借用,得到一个可变引用,交给 add_hint 使用。函数通过这个引用修改原来的文本,String 的所有权仍在调用方。借用运算符
前面使用 &text 时,进行的是共享借用,得到的共享引用用来读取文本。同一个 String 可以有多个共享引用,而可变借用要求独占访问。如果后面还要通过某个共享引用读取文本,就不能在中间通过可变借用修改同一份 String。
接下来用一个只改变语句顺序的例子,看看这种冲突怎样发生。
2.3 引用最后在哪里使用很重要
下面这段代码先借用 text,把得到的共享引用保存在 view 中;接着修改 text,最后再通过 view 打印。这个完整程序预期编译失败:
fn main() { let mut text = String::from("解释这个项目"); let view = &text; text.push_str(",先看目录"); // 修改需要可变借用 println!("{view}"); // 前面的共享引用还要继续使用}问题出在:后面还要通过 view 读取 text,中间却先修改了 text。 按顺序看这三行:
let view = &text:对 text 进行共享借用,把得到的共享引用保存在 view 中。view 没有复制文本,它访问的仍然是 text 持有的那份 String。 text.push_str(",先看目录"):准备往同一份 String 中追加内容。这个方法需要可变借用;虽然调用时没有写出 &mut text,方法调用仍然会借出可变引用。println!("{view}"):后面还要通过 view 读取文本,所以编译器不能在前面的 push_str 调用之前,就把 view 的这次借用视为已经结束。
于是,push_str 要修改文本时,前面的共享借用还没结束。只要后面还要使用这个共享引用,就不能在中间通过可变借用修改它指向的数据。 这项检查发生在编译时,程序因此无法通过编译。
把顺序改成“先通过 view 打印,用完这次借用,再修改 text”,下面这个完整程序就能通过:
fn main() { let mut text = String::from("解释这个项目"); let view = &text; println!("修改前:{view}"); // view 的最后一次使用 text.push_str(",先看目录"); println!("修改后:{text}");}这次,println!("修改前:{view}") 已经完成了通过 view 读取文本的工作,后面的代码不再使用 view。因此,编译器可以在这行之后结束这次共享借用,接下来的 push_str 就能借出可变引用,追加文本。最后通过 text 打印,得到的是 修改后:解释这个项目,先看目录。
这里不需要等到 main 最后的右花括号,才算“借用结束”。在这个例子中,编译器会检查 view 后面是否还会被使用;只要这次借用已经用完,就可以继续修改 text。借用范围与最后一次使用
借用结束,String 本身还在。 文本一直由 text 持有,所以第二个程序还能继续修改、打印它;等 main 正常结束时,这份 String 才会被清理。
对照这两个程序,可以先找修改数据的那一行,再往后看:修改之后,还会不会用到前面借出的共享引用? 第一个程序还要用 view,所以报错;第二个程序已经用完 view,所以可以修改。
2.4 String 拥有文本而字符串切片借用文本
先看一个用 &String 接收文本的完整程序:
fn show(text: &String) { println!("{text}");}fn main() { let a = String::from("hello"); show(&a); println!("{a}");}程序输出两行 hello。show 接收一个指向 String 的共享引用,用它打印文本;调用结束后,a 仍然持有原来的 String。
如果函数只是读取一段文本,Rust 中更常见的参数类型是 &str。它不要求调用方一定提供 String,也可以接收字符串字面量或一段文本的切片。
str 是字符串切片类型,&str 是对一段有效 UTF-8 文本的共享引用。它可以引用 String 中的全部或部分文本,也可以来自字符串字面量。String 则拥有自己管理的可增长缓冲区。字符串切片
下面这个完整程序用参数为 &str 的 show_text 展示几种调用方式:
fn show_text(text: &str) { println!("{text}");}fn main() { let text = String::from("解释这个项目"); show_text(text.as_str()); // 显式取得整个 String 的字符串切片 show_text(&text); // 这里也可以把 &String 转换为所需的 &str show_text("先查看目录"); // 字符串字面量可以直接传入 println!("仍由调用方持有:{text}");}前三次调用都只是借用文本,show_text 不需要知道文本是不是由某个 String 持有。最后 main 仍然可以使用原来的 String。
因此,选择类型时可以从函数的用途出发:只读一段文本,通常用 &str;需要接收并保存一个 String,就按值接收 String;需要修改已有 String 的内容,可以借用 &mut String。这里先理解用途,不要求记住所有字符串转换方法。
三 从 Request 的接口看数据怎样流动
下面定义一个 Request 结构体,用来保存请求文本和最大步数,并观察数据怎样进入、借出和移出结构体。这是一个教学模型,不是 Codex 源码的直接摘录。
3.1 用一个完整程序串起创建 读取 修改与接管
new 构造请求,is_ready 检查是否可以开始,set_max_steps 修改步数,text 借出文本,into_text 则接收请求并取出文本。先看完整程序,再沿接口逐步拆解:
struct Request { text: String, max_steps: u32,}impl Request { fn new(text: String) -> Self { Self { text, max_steps: 3 } } fn is_ready(&self) -> bool { !self.text.is_empty() && self.max_steps > 0 } fn set_max_steps(&mut self, max_steps: u32) { self.max_steps = max_steps; } fn text(&self) -> &str { self.text.as_str() } fn into_text(self) -> String { self.text // 将字段中的 String 移出,交给调用者 }}fn main() { let text = String::from("解释这个项目"); let mut request = Request::new(text); println!("可以开始:{}", request.is_ready()); request.set_max_steps(5); let view = request.text(); println!("借用文本:{view}"); // view 的最后一次使用 request.set_max_steps(8); println!("最大步数:{}", request.max_steps); let text = request.into_text(); println!("取出的文本:{text}"); // println!("{}", request.is_ready()); // 取消注释后编译失败:request 已移动}程序先检查请求,再修改步数、借出文本,最后把文本从请求中取出。先看最前面的构造过程:
- 位置
: let text = String::from(...);值的去向:main 中的 text 持有创建的 String - 位置
: Request::new(text);值的去向:String 按值交给 new 的参数 text - 位置
: Self { text, max_steps: 3 };值的去向:参数中的 String 移入 Request 的 text 字段 - 位置
:new 返回,赋给 request;值的去向:调用方持有返回的 Request,其中的字段继续持有 String
调用方的 text 和函数参数 text 恰好同名,但它们是不同位置的绑定。这里发生了责任转交,并不是两边各自管理同一份文本。
new 返回时,数据也没有全部被销毁。String 已经进入返回的 Request,并跟着它回到调用方。函数结束,只会按规则清理仍应在该处清理的值;作为返回值交出去的数据可以继续存活。 这也是为什么函数可以创建数据,再把它返回给其他代码使用。
再对照一个按值接收 String、只负责打印的完整程序:
fn show(text: String) { println!("{text}");}fn main() { let a = String::from("hello"); show(a);}这个程序输出 hello。调用 show 时,a 持有的 String 被移给参数 text;show 打印后没有把它返回或保存到别处,所以正常返回时就会清理它。
Request::new 同样按值接收 String,但会把它放进字段,再将整个 Request 返回给调用方。参数接收方式相同,数据的后续去向可以不同。 读到按值接收的函数时,还要继续看它把值保存、返回,还是留在函数里使用后清理。
3.2 从 self 的形式判断方法怎样访问请求
先把要比较的方法和调用放在一起。下面是一个完整程序:
struct Request { text: String, max_steps: u32,}impl Request { fn new(text: String) -> Self { Self { text, max_steps: 3 } } fn is_ready(&self) -> bool { !self.text.is_empty() && self.max_steps > 0 } fn set_max_steps(&mut self, max_steps: u32) { self.max_steps = max_steps; } fn into_text(self) -> String { self.text }}fn main() { let mut request = Request::new(String::from("解释这个项目")); println!("可以开始:{}", request.is_ready()); request.set_max_steps(5); println!("最大步数:{}", request.max_steps); let text = request.into_text(); println!("取出的文本:{text}");}输出为:
可以开始:true最大步数:5取出的文本:解释这个项目new 没有 self 接收者,是关联函数;其余是方法。方法中的三种接收者分别表示:
- 接收者
: &self;当前例子中的含义:共享借用 Request;调用后的状态:请求仍由调用方持有;若返回引用,继续遵守该引用的借用约束 - 接收者
: &mut self;当前例子中的含义:可变借用 Request;调用后的状态:修改作用于原请求,调用方仍持有它 - 接收者
: self;当前例子中的含义:按值接收 Request;调用后的状态:这个 Request 不实现 Copy,因此原值被移动给方法
调用 request.is_ready() 和 request.set_max_steps(5) 时,方法调用语法会按接收者类型自动借用 request,不需要在调用处另写 &request 或 &mut request。into_text 接收的则是 Request 本身。方法调用中的接收者调整
再看共享引用对后续调用的影响。下面的完整程序预期编译失败:
struct Request { text: String, max_steps: u32,}impl Request { fn new(text: String) -> Self { Self { text, max_steps: 3 } } fn text(&self) -> &str { self.text.as_str() } fn set_max_steps(&mut self, max_steps: u32) { self.max_steps = max_steps; }}fn main() { let mut request = Request::new(String::from("解释这个项目")); let view = request.text(); request.set_max_steps(8); // 错误:后面还要使用 view println!("{view}");}view 是 text 方法返回的共享引用,指向 request 中的文本。最后一行还要通过它读取文本,所以调用 set_max_steps 时,对 request 的共享借用还不能结束。
set_max_steps 虽然只修改步数,但它的签名要求 &mut self,也就是可变借用整个 Request。这与尚未结束的共享借用冲突,编译器会拒绝这次调用。这里不能仅凭“没有修改 text 字段”就判断调用合法,还要看方法签名借用的是哪个值。
3.3 同样是返回文本 返回值承担什么责任
text 和 into_text 都能让调用方得到请求中的文本,但返回的东西不同。先用一个只保留 text 字段的完整程序比较这两个方法:
struct Request { text: String,}impl Request { fn text(&self) -> &str { self.text.as_str() } fn into_text(self) -> String { self.text }}fn main() { let request = Request { text: String::from("解释这个项目"), }; let view = request.text(); println!("借用文本:{view}"); // 之后不再使用 view let text = request.into_text(); println!("取出的文本:{text}");}输出为:
借用文本:解释这个项目取出的文本:解释这个项目text 返回 &str,调用方得到一个共享引用,文本仍由 request 持有。into_text 按值接收 request,再把其中的 String 返回;调用方得到这个 String 的所有权,之后不再依赖 request。
这个教学 Request 没有自定义 Drop,因此可以在 into_text 中将 String 字段移出。它不会随着函数退出再被释放一次;返回的 String 由调用方继续持有,其他仍需清理的字段按各自规则处理。实现了自定义 Drop 的类型对移出字段另有约束,此处先不展开。字段移动与析构
返回共享引用时,还要保证它指向的文本仍然有效。text 的签名省略了生命周期标注,编译器会根据方法的规则,把返回引用关联到对 self 的借用。下面并列的是同一个方法的两种签名,含义相同:
省略标注:fn text(&self) -> &str显式标注:fn text<'a>(&'a self) -> &'a str参数和返回值上的 'a 表示这两个引用之间的关系:调用方还要使用返回的引用时,对 request 的共享借用就必须继续有效。这个标注不会让 request 活得更久,也不要求引用一直用到 request 被销毁。方法接收者的生命周期省略规则
如果返回的引用指向方法内部新建的文本,会怎样?下面的完整程序预期编译失败:
struct Request { text: String,}impl Request { fn bad_text(&self) -> &str { let local = String::from("临时文本"); local.as_str() // 错误:不能返回指向局部 String 内容的引用 }}fn main() { let request = Request { text: String::from("解释这个项目"), }; println!("原有文本:{}", request.text); let view = request.bad_text(); println!("{view}");}这里返回的引用指向 local 中的“临时文本”,并不指向 request 中的文本。bad_text 结束时,local 持有的 String 就会被销毁;调用方却还想通过返回的引用读取它。编译器会在编译时拒绝这段实现,给签名补上 'a 也不能让 local 活得更久。返回局部数据的引用
如果需求就是创建新文本交给调用方,可以返回 String。下面是能正常编译的完整程序:
struct Request { text: String,}impl Request { fn new_text(&self) -> String { let local = String::from("临时文本"); local // 将 String 作为返回值移交给调用者 }}fn main() { let request = Request { text: String::from("解释这个项目"), }; let text = request.new_text(); println!("新建文本:{text}"); println!("原有文本:{}", request.text);}输出为:
新建文本:临时文本原有文本:解释这个项目这次返回的是 String 本身。它的所有权从 local 移交给调用方,所以方法结束后,main 仍然可以使用这份新文本。原来的 request 也仍然可以使用,因为 new_text 只共享借用了它。
所以,读返回值时可以连着问两句:返回的是拥有的数据,还是引用?如果是引用,它实际指向哪里?text 借出请求已有的文本,into_text 移交已有文本,new_text 创建并返回新文本。
四 练习追踪分支中的数据去向
函数签名说明数据怎样进出函数,函数体里的分支则决定具体处理什么。接下来用 match 练习:一边读分支的业务含义,一边观察里面的字段去了哪里。
4.1 借用枚举时 分支中的字段也可以借用
请求之外,源码里也经常用枚举表示不同输入。下面定义一个教学用的 Input,分别保存文本和本地图片路径。describe 只需要生成描述,并希望调用后继续保留原输入,因此接收 &Input。下面是完整程序:
enum Input { Text { text: String }, LocalImage { path: String },}fn describe(input: &Input) -> String { match input { Input::Text { text } => format!("文本:{text}"), Input::LocalImage { path } => format!("本地图片:{path}"), }}fn main() { let input = Input::Text { text: String::from("看看这个项目"), }; println!("{}", describe(&input)); println!("{}", describe(&input)); // 原输入仍在,可以再次借用}这次匹配的对象是 &Input,模式会按照相应的绑定规则对字段进行共享借用。Text 分支中的 text 和图片分支中的 path 都是 &String 类型的共享引用,没有把字段取走。format! 创建的是描述结果 String,原输入继续由调用方持有。模式的绑定方式
4.2 对照 Option 中的借用与取出
换成 Option,也可以沿字段去向判断。下面这个完整程序先借用标题,再把标题取出来:
fn main() { let title = Some(String::from("项目分析")); let display = match &title { Some(text) => text.as_str(), // text 是 &String,返回 &str None => "未命名任务", }; println!("借用显示:{display}"); // display 的最后一次使用 let owned_title = match title { Some(text) => text, // text 是 String,按值取出 None => String::from("未命名任务"), }; println!("取出的标题:{owned_title}"); // println!("{title:?}"); // 取消注释后编译失败:不能再把 title 作为完整值读取}第一次匹配对 title 进行共享借用,显示以后原值仍在。第二次匹配按值绑定 String 字段,将它移到 owned_title;随后不能再完整读取原来的 title。
因此,match 除了选择分支,还需要结合匹配对象、模式和字段类型,看它是在复制、移动还是借用数据。不能把所有 match 都归为“消费整个值”,也不能只看分支执行了什么而忽略字段的去向。
4.3 带着业务问题继续读源码
读一段业务源码时,可以按下面的顺序梳理:
- 先看这段代码要完成什么。
是创建请求、检查条件、修改配置,还是生成输入描述?先把业务动作连起来。 - 再看数据怎样进入和离开接口。
参数接收 String 还是 &str,方法使用 self、&self 还是 &mut self,返回的是拥有的值还是引用? - 遇到交接和修改,再追踪后续使用。
数据存进了哪个字段,match 是否取出了字段,借来的引用后面是否还要用?
例如,“创建请求后,还要读取请求文本”可以直接写成下面这个完整程序:
struct Request { text: String,}impl Request { fn new(text: String) -> Self { Self { text } } fn text(&self) -> &str { self.text.as_str() }}fn main() { let text = String::from("解释这个项目"); let request = Request::new(text); println!("请求文本:{}", request.text());}这里仅保留读取文本所需的字段和方法。String 被交给请求后,仍然可以通过 request.text() 读取,所以不需要为了这次打印提前 clone。确实要保留一份能够独立修改的文本时,才需要另外复制。先明确业务需要,再看数据如何传入和返回,就更容易判断应当移动、复制还是借用。
这些判断会随着阅读逐渐熟悉。现在先能说清业务在做什么、数据由谁继续持有,以及哪里存在借用,就可以带着这层认识往源码里走;遇到更复杂的约束,再结合当时的代码补充。
写完这一篇,我们对 Rust 基础的集中学习就先告一段落。接下来,这个专栏会围绕 Codex 的具体业务与源码展开,遇到需要的 Rust 知识,再结合实际代码补上。敬请期待。
附录 深度理解
以下内容按主题独立组织,可以按需要查阅:
附录 A String 的内存与 Java 字符串的区别:字面量、字符串池、动态分配、复制与释放。 附录 B Rust String 与 Redis SDS 有什么异同:动态字符串的相似思路,以及布局、编码和管理责任的区别。
附录 A String 的内存与 Java 字符串的区别
先看 String 复制文本的完整程序:
fn main() { let a = String::from("hello"); let b = a.clone(); println!("a = {a}, b = {b}");}输出是 a = hello, b = hello。这里的 a.clone() 创建了独立的文本副本,a、b 各自持有一份。如果学过 Java,很容易联想到“变量保存引用,对象放在堆里,字面量还有字符串常量池”。这些经验可以帮助理解 Rust,但需要分清三个层次:变量里保存什么、文本放在哪里、谁负责释放文本。
A.1 先对齐 Java 中的引用和字符串池
Java 方法里常见下面这样的局部代码:
String a = "hello";String b = a;String c = new String("hello");a、b 保存的是同一个 String 对象的引用,b = a 没有复制对象或文本,两边都能继续使用。字面量 "hello" 另有驻留(intern)规则:相同内容的字符串字面量会引用同一个规范化实例,通常说的“字符串常量池”与此有关。复制引用和字符串驻留是两件事;即使 a 引用的是通过 new 创建的对象,b = a 也仍然只是复制引用。Java 对象引用、字符串字面量
c 则引用一个新建的 String 对象。但“新对象”不能直接推导成“底层文本也完整复制了一遍”:Java String 不可变,具体实现可以共享内部文本存储。因此,不能把 Java 的 new String(a) 直接等同于 Rust String 的 a.clone()。Java String 构造器
A.2 Rust 的字面量与 String 是两种不同的东西
先看 let literal = "hello"。这里得到的是 &'static str:一个共享引用,指向具有静态存储期的 UTF-8 文本。'static 是 Rust 规定的固定生命周期名称,不能自行换名;它表示这份被引用的文本在程序运行期间一直有效,不表示局部变量 literal 自己不会离开作用域。它不是每次执行这条绑定时,都申请一块新的堆内存来保存 hello。Rust 字符串字面量
再看 let a = String::from("hello")。这次创建的是一个拥有文本缓冲区的 String 值。对这个非空例子,标准库会为动态文本准备堆上的空间,把 hello 的字节复制进去;后续可以通过这个 String 管理和扩展文本。它不会因为内容恰好是 hello,就自动去某个字符串池里共享一个 String。String 转换实现
String 值本身包含三项信息:指向缓冲区的指针、已使用的字节数、缓冲区容量。文本在它管理的堆缓冲区里。不要把 String 值本身也一律想成“堆上的 Java 对象”:普通局部值通常可以放在栈上,它也可以内嵌在另一个结构体里;具体机器存储还会受到编译优化影响。String 的表示
因此,Rust 的字面量确实有静态存储,但这不等于普通 String 具有 Java 字面量那样按内容驻留的规则。String::from 创建的动态文本和字面量原有的静态文本,需要分别看待。
A.3 用一个程序区分字面量 副本与借用
下面是一个完整程序:
fn main() { let literal = "hello"; let a = String::from(literal); let mut b = a.clone(); b.push_str("!"); let string_ref = &a; let view = a.as_str(); println!("literal = {literal}"); println!("a = {a}, b = {b}, view = {view}"); println!("string_ref = {string_ref}");}输出:
literal = helloa = hello, b = hello!, view = hellostring_ref = hello这里的 mut 允许修改 b,push_str("!") 追加文本。&a 借用整个 String,as_str() 借出它的文本切片,两者得到的都是共享引用。打印时,五个变量的关系如下:
- 变量
:literal;保存的值: &'static str,指向静态文本的引用;访问哪份文本:程序原有的 hello 字面量 - 变量
:a;保存的值:String,拥有自己的缓冲区;访问哪份文本:从字面量复制得到的动态文本 hello - 变量
:b;保存的值:String,拥有另一份缓冲区;访问哪份文本:clone 得到的副本,随后改为 hello! - 变量
:string_ref;保存的值: &String,对 String 值的共享引用;访问哪份文本:a 持有的 String,没有复制文本 - 变量
:view;保存的值: &str,借用文本范围的引用;访问哪份文本:a 管理的那份 hello,没有再复制文本
&str 可以用“起始地址加字节长度”理解,它本身不承担所引用文本的释放责任。它既能指向字面量的静态文本,也能像 view 一样指向 String 的动态缓冲区;**不能看到 &str 就认为它一定来自常量池,或者一定具有 'static 生命周期。**str 与字符串切片的表示
这也让移动、复制和借用的内存含义更明确:String 的普通移动转交已有缓冲区的管理责任;clone 为这个非空字符串创建独立文本副本;借用只提供访问已有数据的引用。程序中的 string_ref 借用 String 值,view 借用它的文本,两者都不会因此复制文本内容。
A.4 它相当于 new 或 alloc 吗
如果想表达“为这段文本准备动态内存”,可以这样理解本例的 String::from("hello"):由标准库完成分配和内容复制,再返回负责管理这块空间的 String 值。 程序员不用直接调用底层 alloc,也不用自己记住稍后配对释放。
但不要把函数名和分配行为绑定起来。Rust 中的 new 只是常见的函数名;String::new() 创建空字符串时并不分配初始缓冲区,等后续写入内容时才可能申请空间。String::new
在上面的完整程序里,main 正常结束时,a、b 各自持有的 String 会被销毁,分别释放所管理的动态缓冲区;literal、string_ref 和 view 作为引用,不负责释放它们指向的数据。这与 Java 的局部引用变量结束后,由 GC 按对象的可达性管理回收,是不同的安排。Rust 的销毁规则、Java 堆存储管理
这里解释的是存储关系与标准库行为,不据此承诺程序最终恰好执行几次底层分配调用;编译器可以在不改变程序可观察行为的前提下消除或合并分配。分配与编译优化
附录 B Rust String 与 Redis SDS 有什么异同
看到“缓冲区、长度、容量”,熟悉 Redis 的读者可能会想到 SDS(Simple Dynamic Strings,简单动态字符串)。这个联想很有帮助:两者都能管理可增长的内容,并记录长度、利用预留空间减少重复分配。 但存储思路相似,不代表内存布局和使用规则相同。下面以 Redis 8.0.0 的实现为对照。
B.1 相同点与差异速查
先看相同的地方:
- 都记录字节长度,取长度是 O(1)。
Rust 用 s.len(),SDS 用sdslen(s),不必像普通 C 字符串的 strlen 那样扫描到结尾的\0。这里统计的是字节数,不是汉字或其他字符的数量。 - 都区分已用长度与可用容量,可以预留空间。
后续追加能先利用剩余空间,减少反复分配。Rust 可以用 s.capacity()查看容量,SDS 可以用sdsavail(s)查看剩余空间。 - 都用连续的动态缓冲区保存内容。
常见的非空字符串内容放在堆上;追加超过容量时,由相应接口申请更多空间,缓冲区地址可能改变。
再把语言、存储和使用规则放在一起比较:
- 比较点
:所属语言与定位;Rust String:Rust 标准库中拥有文本的类型,受 Rust 所有权规则约束;Redis SDS(8.0.0):用 C 实现的动态字符串库,是 Redis 的内部基础组件 - 比较点
:使用者手里的值;Rust String:包含指针、字节长度 len、容量 capacity 的 String 值;Redis SDS(8.0.0): sds是char *,指向内容起点 - 比较点
:元数据的位置;Rust String:长度、容量保存在 String 值中,内容在它管理的缓冲区中;Redis SDS(8.0.0):长度等信息放在内容之前的头部,通过内容指针找到头部 - 比较点
:编码与二进制数据;Rust String:保证有效 UTF-8,允许内部有 \0;任意字节用Vec<u8>;Redis SDS(8.0.0):不规定编码,可以保存任意字节,包括内部的\0 - 比较点
:与 C 字符串接口配合;Rust String:不保证末尾有 \0;需要 C 字符串时通常转成 CString;Redis SDS(8.0.0):内容后保留一个\0,方便部分 C 接口使用;遇到内部\0仍需注意截断 - 比较点
:追加时的扩容策略;Rust String:容量不足时增长,可以额外预留空间;不承诺固定倍数;Redis SDS(8.0.0):常用追加路径会额外预留空间,按所需新长度选择翻倍或增加 1 MiB,具体规则见下文 - 比较点
:普通赋值;Rust String: let b = a移动 String,之后不能再从 a 读取原值;Redis SDS(8.0.0):sds b = a复制指针,两者指向同一份内容 - 比较点
:独立复制;Rust String: a.clone()复制文本,得到独立的 String;Redis SDS(8.0.0):sdsdup(a)复制内容,得到独立的 SDS - 比较点
:释放与访问约束;Rust String:正常销毁值时自动释放缓冲区;安全 Rust 的移动、借用规则由编译器检查;Redis SDS(8.0.0):独立分配的 SDS 由程序安排 sdsfree,并维护指针有效性;C 没有同样的所有权检查
布局上,不要把 String 简化成“元数据一定在栈上”。 String 值可以是局部值,也可以是另一个结构体的字段,具体放在哪里取决于它所在的位置及编译优化。这里也不承诺三个字段的固定排列顺序。SDS 则有不同大小的头部,用较小的整数记录较短内容的信息,减少开销;最紧凑的 type 5 把长度编码在 flags 中,没有独立的容量字段,也不记录可供追加的剩余空间。String 的表示、SDS 类型与头部
允许内部有 \0,不等于能保存任意二进制数据。 String 仍须满足 UTF-8 约束。SDS 能保存任意字节,但读写时要使用 sdsnewlen、sdscatlen 这类接收显式长度的接口;sdsnew 会先调用 strlen,在第一个 \0 处结束计数。把这样的内容交给普通 C 字符串接口,也可能只处理第一个 \0 之前的部分。Rust 的 CString 则要求内容中不能有内部零字节,并保证末尾有零字节,不能把任意 String 都直接当成 CString。String 的 UTF-8 约束、SDS 创建实现、CString 的约束
扩容倍数属于实现策略,需要带上版本和接口来读。 Redis 8.0.0 的 sdsMakeRoomFor 在空间不足时,先计算“已有长度+本次追加长度”:结果小于 1 MiB(1024 × 1024 字节)时,以这个结果的两倍申请内容容量;否则在这个结果上再加 1 MiB。它不是简单地把旧容量翻倍,最终容量还可能受到内存分配器取整的影响;另有不主动多预留空间的 sdsMakeRoomForNonGreedy。Rust String 底层使用 Vec,标准库不保证 Vec 采用某个固定增长倍数,因此不宜把“Rust String 总是翻倍扩容”当成语言特性记忆。SDS 扩容实现、1 MiB 阈值定义、String 的底层表示、Vec 的增长保证
B.2 更关键的区别是 谁约束这块内存的使用
假设 a 指向一个 SDS,C 中的 sds b = a; 只是复制指针。a、b 都指向同一份内容,编译器不会因为这次赋值,就像 Rust 移动 String 那样禁止继续使用 a。需要独立副本时,要调用 sdsdup;通过 SDS 分配接口独立创建的字符串,使用后要由程序安排 sdsfree。**SDS 提供了这些操作,但没有把“谁负责释放、谁还能访问”变成 Rust 式的语言检查规则。**SDS 的复制与释放
追加内容时也能看到这层区别。SDS 空间足够时可以沿用原缓冲区;扩容后地址可能变化,调用方需要按 API 约定使用返回的指针,处理分配失败,并保证其他地方没有继续使用失效的旧指针。SDS 扩容与返回指针、追加接口
Rust String 也可能因扩容改变缓冲区地址。安全 Rust 会在编译时检查借用与修改是否冲突。下面这个完整程序预期编译失败:
fn main() { let mut text = String::from("解释这个项目"); let view = &text; text.push_str(",先看目录"); // 修改需要可变借用 println!("{view}"); // 前面的共享引用还要继续使用}view 没有复制文本,它仍然访问 text 持有的 String。最后一行还要通过 view 读取,中间的 push_str 却需要可变借用同一份 String,因此两次借用发生冲突。先通过 view 打印、用完这次共享借用,再追加文本,就可以通过编译。这项限制不取决于本次追加是否真的扩容:即使容量足够,也要遵守同样的借用规则。正常销毁 String 时,缓冲区的释放也已经由持有关系和清理规则安排好。
因此,可以借 SDS 理解 String 管理动态缓冲区的思路;要理解 Rust 的所有权,还必须再看语言怎样约束移动、借用和销毁。
B.3 Redis 的 String 数据类型又是另一层概念
最后区分一下名称:这里比较的是 Rust 标准库的 String 与 Redis 内部的 SDS。Redis 面向用户的 String 数据类型还包着对象与编码层;在这个版本中,字符串对象可采用 RAW、EMBSTR 或 INT 编码,整数编码不必保存一份 SDS 文本,EMBSTR 则把对象与 SDS 放在同一次分配中。因此,不能把每个 Redis String 都等同于一个需要单独 sdsfree 的 SDS。Redis 字符串对象的创建、对象释放
文中的完整程序、局部片段及预期编译失败的写法均已标明并核对。原有 Rust 示例的核查记录为 rustc 1.99.1、Rust 2024 edition,日期为 2026 年 10 月 22 日。本次补充与调整的完整程序另使用 rustc 1.95.0、Rust 2024 edition 复核,日期为 2026 年 10 月 23 日。
我是程工造Agent,做 Agent 系统与 RAG 的工程师。这个专栏记录我从零学习 Rust、阅读 Codex 源码的过程。
下一篇:回到 Codex 源码,顺着仓库与命令路径往下读。















































































