本文是对 Declarative memory management 的整理与翻译。
内容结构概览
- 文章核心观点:Rust 不是手动内存管理,而是声明式内存管理。
- Overbook 出版社设定:公司风格指南规定,稿件里绝对不能出现逗号。
- C 版本起步:读取文件到固定 buffer,扫描逗号,发现错误就返回。
- 第一个需求变化:除了发现错误,还要显示错误位置。
- 共享 buffer bug:多个检查结果都指向同一个 buffer,最后全部显示最后一篇稿子的内容。
- 复制字符串修复 :用
malloc + memcpy复制错误片段,避免引用共享 buffer。 - 内存泄漏问题 :分配了
Mistake和location,必须手动free,而且顺序还要小心。 - 固定数组上限问题 :
MAX_RESULTS = 128,检查 130 篇稿件时越界。 - C 的根本困境:程序员要同时记住所有权、生命周期、buffer 复用、释放顺序和数组容量。
- Rust 重写第一步 :
read_to_string返回Result,不能假装文件读取永远成功。 Option替代 NULL :有错误是Some(Mistake),没错误是None。- 生命周期登场 :
Mistake<'a>持有&'a str,编译器要求说明引用来自哪里、能活多久。 - borrow checker 抓住 C 版同款 bug :结果收集到
Vec后,不能再次可变借用共享 buffer。 - 从借用改成拥有 :把
location: &str改成location: String,让错误结果拥有自己的文本。 String、&str与to_string():Rust 不需要手写 malloc/free,但仍然能表达是否拥有数据。- 多年后新需求:一篇稿件里要报告所有逗号,而不是只报告第一个。
- 用
Vec<String>收集多个位置片段:先解决功能,再发现复制大量文本不够优雅。 - 更好的设计 :
Mistake拥有整篇文本,locations: Vec<usize>只保存逗号位置。 - 格式化逻辑放到
Display:check()负责检查,report()/Display负责展示行号和高亮。 - 最终结论:Rust 不是让程序员手动管理内存,而是让程序员声明所有权和借用关系,和编译器一起收敛到正确设计。
很多人第一次接触 Rust 时,会把它归类成"手动内存管理语言"。
这个误解很自然。Rust 没有传统意义上的垃圾回收器,也不像 Java、Go、JavaScript 那样由运行时在后台帮你追踪对象是否还活着。它又经常和 C/C++ 放在一起讨论,说它能做到系统级性能、没有 GC pause、能精确控制内存布局。于是很多人会下意识认为:Rust 和 C 一样,需要程序员手动管理内存。
但 fasterthanlime 在这篇文章里提出了一个非常好的说法:
Rust 不是手动内存管理,而是声明式内存管理。
这句话很关键。
所谓手动内存管理,是你自己决定什么时候 malloc,什么时候 free,哪些指针指向哪里,释放顺序是什么,谁拥有这块内存,谁只是暂时借用。如果出错,可能是内存泄漏、use-after-free、double free、悬垂指针、数组越界、数据被覆盖,或者更糟糕的未定义行为。
而 Rust 做的事情不同。Rust 让你声明数据之间的关系:谁拥有它,谁借用它,借用能活多久,能不能修改,返回值是否包含引用,错误结果是否拥有自己的数据。编译器和 borrow checker 会检查这些声明是否自洽。如果不自洽,它不让你编译。你不需要手写 free,但你需要把内存关系讲清楚。
这篇文章没有一上来讲所有权理论,而是讲了一个故事:一个虚构出版社 Overbook 想写一个风格检查器,用来检查作者稿件里有没有逗号。因为 Overbook 的风格指南非常简单:永远不要使用逗号。
于是,一个员工 Robin 先用 C 写了一个检查器,后来被各种需求变化折磨,最后重写成 Rust。这个故事非常适合解释 Rust 的内存模型,因为它把问题一步步摆出来:一开始只是读文件,后来要保存错误位置,再后来要批量处理,再后来要释放内存,再后来固定数组不够用,最后还要报告所有错误和上下文。
每一步都像普通工程里的小需求。但在 C 里,这些小需求会不断把你推向手动内存管理的泥潭。而在 Rust 里,很多问题会提前变成编译错误,逼你把数据关系重新设计清楚。
一、Overbook 的风格指南:绝对不能有逗号
故事从一家虚构出版社开始。
Overbook 有一个非常严格的风格指南:稿件里绝对不能出现逗号。按照他们的审美,"She read then laughed" 没问题,但 "She read, she laughed" 不行。
一开始,出版社靠人工检查稿件。这当然很累。随着业务增长,人工检查成了瓶颈。于是他们想要一个自动化程序:输入一个 .txt 稿件,检查里面有没有逗号。
员工 Robin 会一点编程。很不幸,Robin 在大学里主要学的是 C。于是第一个版本就从 C 开始。
最初的程序非常简单:
c
#include <stdio.h>
int main() {
printf("Everything looks great.");
return 0;
}
然后 Robin 定义了一些结构体:
c
struct Mistake {
char *message;
};
struct CheckResult {
char *path;
struct Mistake *mistake;
};
CheckResult 表示检查结果。mistake == NULL 表示没有错误;如果不是 NULL,就说明发现了错误。
这是一种很典型的 C 风格设计:用指针是否为空来表达"有没有值"。
接着,Robin 准备一个 256 KiB 的固定 buffer,用来读取稿件:
c
#define BUF_SIZE 256 * 1024
char buf[BUF_SIZE];
这个限制看起来还可以,因为 Overbook 主要出版中篇小说。Robin 打开文件,读入 buffer,最后手动补上 \0,因为 C 字符串必须以空字节结尾:
c
FILE *f = fopen(path, "r");
size_t len = fread(buf, 1, BUF_SIZE - 1, f);
fclose(f);
buf[len] = '\0';
然后扫描 buffer,一旦发现逗号,就分配一个 Mistake:
c
for (size_t i = 0; i < len; i++) {
if (buf[i] == ',') {
struct Mistake *m = malloc(sizeof(struct Mistake));
m->message = "commas are forbidden";
result.mistake = m;
break;
}
}
这已经能工作了。没有逗号就什么都不输出;有逗号就报告发现错误。
对一个小工具来说,这看起来很成功。
但软件最可怕的地方在于:它总会收到新需求。
二、第一个新需求:显示错误位置
一周后,管理层发来邮件:程序能检测出错误,很好,但作者们想知道错误在哪里。也就是说,不只要告诉他们"有逗号",还要展示逗号附近的文本。
Robin 想了一个简单办法:让 Mistake 多一个字段,指向错误在 buffer 中的位置:
c
struct Mistake {
char *message;
char *location;
};
发现逗号时:
c
m->location = &buf[i];
m->message = "commas are forbidden";
报告时,用 %.12s 打印从该位置开始的最多 12 个字符:
c
printf("mistake: %s: '%.12s'\n",
result.mistake->message,
result.mistake->location);
如果只检查一篇稿件,或者检查完立刻报告,这没有问题。location 指向当前 buffer 中逗号的位置,而 buffer 还没被覆盖。
接着 Robin 想优化流程:先检查所有稿件,把结果存起来,最后统一报告。
于是写了一个固定大小数组:
c
#define MAX_RESULTS 128
struct CheckResult results[MAX_RESULTS];
int num_results = 0;
然后循环检查每个文件:
c
for (int i = 0; paths[i] != NULL; i++) {
struct CheckResult result = check(paths[i], buf);
results[num_results++] = result;
}
最后再统一打印。
这一步看起来很自然,也是很多程序都会做的重构:先收集结果,再统一展示。
但 bug 出现了。
如果先检查 sample.txt,再检查 sample2.txt,那么 sample.txt 的错误位置会显示成 sample2.txt 的内容。原因很快被 Robin 想明白了:所有 location 都指向同一个 buffer。每次读新文件,buffer 都被覆盖。于是旧结果里的指针仍然指向同一块内存,但那块内存里的内容已经变成了最后一个文件。
这是典型的悬垂语义问题:指针还有效,内存也还在,但它指向的数据语义已经不对了。
这个 bug 非常真实。C 里很多 bug 不是直接崩溃,而是"指针还能访问,但访问到的东西不是你以为的东西"。
三、复制错误片段:修掉一个 bug,引入内存管理负担
Robin 的修复方法也很典型:不要让 location 指向共享 buffer,而是复制一份文本。
c
size_t location_len = len - i;
if (location_len > 12) {
location_len = 12;
}
m->location = malloc(location_len + 1);
memcpy(m->location, &buf[i], location_len);
m->location[location_len] = '\0';
这一次,每个 Mistake 都拥有自己的 location 字符串。之后 buffer 怎么被覆盖,都不会影响已经保存的错误片段。
功能恢复正常。
但这一步引入了一个新问题:内存分配。
之前只 malloc 了 Mistake,现在又 malloc 了 location。那么就必须 free。程序一开始没有释放这些内存,于是被指出存在内存泄漏。
Robin 加上:
c
for (int i = 0; i < num_results; i++) {
free(results[i].mistake);
}
但这还不够。free(results[i].mistake) 只释放 Mistake 本身,不会递归释放它里面指向的 location。于是还要先释放 location:
c
free(results[i].mistake->location);
free(results[i].mistake);
顺序也很重要。你必须先释放 location,再释放 mistake。如果先释放 mistake,再访问 mistake->location,就变成 use-after-free。
这就是手动内存管理的本质:
text
你不仅要知道哪里分配了内存,
还要知道每块内存由谁拥有,
什么时候释放,
释放几次,
释放顺序是什么,
释放之后还有没有指针指向它。
对一个"检查逗号"的小程序来说,这已经有点过重了。
四、一年后:固定数组上限炸了
一年过去,程序运行良好。它检查了大量稿件,没有泄漏,也没有再打印错误位置。
直到某天,管理层说程序坏了。原因是这次同时检查了 130 篇稿件,而程序里的数组上限是:
c
#define MAX_RESULTS 128
于是:
c
struct CheckResult results[MAX_RESULTS];
只能容纳 128 个结果。超过之后,程序会写出数组边界。
这一次 Robin 很快修复:把 128 改成 256。
业务恢复了。
但 Robin 知道,这不是真正的修复。今天是 130,明天可能是 300。更糟糕的是,程序里还有另一个固定上限:buffer 只有 256 KiB。如果某篇稿件更大怎么办?
C 版本已经暴露出一堆问题:
text
共享 buffer 导致旧结果指向新内容
复制错误片段后要手动分配内存
分配后要手动释放
释放顺序要正确
固定数组会越界
固定 buffer 会截断大文件
NULL 表示无错误,要靠约定
每个新需求都可能破坏旧假设
Robin 最后决定:用 Rust 重写。
五、Rust 第一版:读取文件时,错误不能假装不存在
Rust 版本从读取文件开始:
rust
use std::fs::read_to_string;
fn main() {
let s = read_to_string("sample.txt");
}
然后 Robin 想直接:
rust
s.find(",");
编译器立刻报错:s 不是 String,而是 Result<String, std::io::Error>。
这就是 Rust 的第一道门槛。
文件读取可能失败。文件可能不存在,权限可能不足,磁盘可能出错,路径可能不合法。所以 read_to_string 不会直接给你 String,它给你:
rust
Result<String, std::io::Error>
你必须处理错误。
一种简洁写法是让 main 自己也返回 Result,然后用 ? 转发错误:
rust
fn main() -> Result<(), std::io::Error> {
let s = read_to_string("sample.txt")?;
s.find(",");
Ok(())
}
? 的意思是:如果是 Ok(value),取出 value;如果是 Err(e),直接从当前函数返回这个错误。
这和 C 版本的 fopen / fread 很不一样。C 版本里,Robin 一开始没有检查 fopen 是否返回 NULL,也没有检查 fread 是否失败。程序看起来短,但其实假设了很多事情永远不会出错。
Rust 把失败放在类型里。你不能假装它不存在。
六、Option 替代 NULL:有错误或没有错误
接着,Rust 里用 find 查找逗号:
rust
let result = s.find(",");
find 返回的是:
rust
Option<usize>
如果找到,就是 Some(index);如果没找到,就是 None。
这和 C 的 NULL 有点相似,但更安全、更明确。Option 是 enum,不是裸指针。你不能不检查就直接拿里面的值。你必须 match:
rust
match s.find(",") {
Some(index) => {
println!("found comma at {}", index);
}
None => {
println!("no mistakes!");
}
}
或者用 if let:
rust
if let Some(index) = s.find(",") {
println!("found comma at {}", index);
}
这就是 Rust 的第二个重要设计:缺失不是特殊指针值,而是一种显式状态。
C 里 mistake == NULL 靠约定表示没有错误。Rust 里 Option<Mistake> 直接表达:
text
Some(Mistake) 有错误
None 没错误
七、先用切片显示错误位置
为了显示逗号附近内容,Rust 可以直接切出字符串的一部分:
rust
let slice = &s[index..];
println!("{:?}", slice);
这里 slice 是 &str,它不是分配,不复制数据,只是指向 s 中某个范围的引用。
这和 C 里 &buf[i] 很像,但有一个巨大差异:Rust 的引用有生命周期。编译器知道 slice 借用了 s,也知道只要 slice 还活着,s 就不能被随便修改或释放。
这时 Robin 很开心:没有 malloc,没有 memcpy,没有 free,只要切片就能显示错误位置。
接着,他把逻辑抽出成函数:
rust
fn check(path: &str) -> Result<(), std::io::Error> {
let s = read_to_string(path)?;
match s.find(",") {
Some(index) => {
let slice = &s[index..];
println!("mistake: {:?}", slice);
}
None => println!("no mistakes!"),
}
Ok(())
}
到这里,Rust 版本已经比 C 版本舒服很多。
但当 Robin 想让 check() 返回一个结构化错误时,生命周期出现了。
八、Mistake<'a>:错误位置借用了文本
Robin 定义:
rust
struct Mistake {
location: &str,
}
编译器报错:缺少生命周期参数。
因为 location 是引用。它必须指向某个字符串,而那个字符串必须活得比 Mistake 足够久。Rust 不能让结构体里随便存一个引用,却不说明引用的有效期。
于是改成:
rust
struct Mistake<'a> {
location: &'a str,
}
'a 表示:这个 Mistake 中的 location 引用至少在 'a 这段生命周期内有效。
接着,check 要返回 Option<Mistake<'a>>。编译器又要求函数签名说明:这个 Mistake 借用的是哪个参数?是 path,还是 s?
因为 Robin 想复用一个外部 buffer,所以 check 变成:
rust
fn check<'a>(
path: &str,
s: &'a mut String,
) -> Result<Option<Mistake<'a>>, std::io::Error> {
s.clear();
File::open(path)?.read_to_string(s)?;
Ok(match s.find(",") {
Some(index) => {
let location = &s[index..];
Some(Mistake { location })
}
None => None,
})
}
这看起来比 C 麻烦。必须写 'a,必须把 s 和 Mistake<'a> 联系起来。
但这不是无意义的麻烦。它明确说出了一个事实:
text
返回的 Mistake 里有一个引用,
这个引用指向传入的 String buffer。
只要 Mistake 还活着,
那个 buffer 就不能被重新用来读别的文件。
这正是 C 版本里被忽略的事实。
九、borrow checker 抓住了 C 版同款 bug
Robin 接着要批量检查多个文件,先收集结果,最后统一报告:
rust
let mut s = String::with_capacity(256 * 1024);
let mut results = vec![];
for path in &paths {
let result = check(path, &mut s)?;
results.push(result);
}
for result in results {
report(result);
}
Rust 编译器拒绝:
text
cannot borrow `s` as mutable more than once at a time
这就是整篇文章最关键的时刻。
Robin 一开始困惑:我明明每次循环只调用一次 check,为什么说多次可变借用?
但很快他意识到:check 返回的 Mistake<'a> 里可能包含指向 s 的引用,而这个结果被放进了 results。也就是说,第一次循环结束后,results 里还可能有一个 Mistake 指向 s。这时如果第二次循环再次 &mut s,就会覆盖 buffer,从而让第一次结果指向的内容失效。
这不就是 C 版本里那个 bug 吗?
C 版本运行时才出现错误。Rust 版本在编译期就拒绝了。
这就是 borrow checker 的价值。
它不是在说"我不喜欢你这么写"。它是在说:"你正在保存一个指向 buffer 的结果,同时又想重用这个 buffer。这会导致旧结果引用新内容。你必须重新设计。"
这就是声明式内存管理的味道:你声明了 Mistake<'a> 借用 s,于是编译器就能检查所有使用点是否遵守这个关系。
十、从借用变成拥有:String 解决生命周期问题
要修这个问题,有两种方向。
第一种方向:每篇稿件保留自己的 buffer,让错误结果可以安全引用对应文本。
第二种方向:错误结果自己拥有需要展示的片段,不再借用共享 buffer。
Robin 先选择第二种。把:
rust
struct Mistake<'a> {
location: &'a str,
}
改成:
rust
struct Mistake {
location: String,
}
现在 Mistake 不再借用外部字符串,而是拥有自己的字符串。生命周期参数也不需要了。
编译器立刻提醒:你现在给 location 传的是 &str,但字段需要 String。建议使用转换方法:
rust
location: location.to_string()
于是:
rust
let location = s[index..].to_string();
Some(Mistake { path, location })
这和 C 里的 malloc + memcpy + '\0' 本质上做的是同一件事:复制错误片段,让结果拥有自己的数据。
但 Rust 里的差别是:
text
你不需要手写 malloc
不需要手写 memcpy
不需要手写 free
不需要记住释放顺序
也不需要担心忘记释放
String 负责分配和释放。Mistake 拥有 String。当 Mistake 被 drop,String 也会被 drop,对应内存自动释放。
这不是垃圾回收。没有后台 GC 去扫描对象图。它是所有权驱动的资源释放:谁拥有资源,谁负责在生命周期结束时释放它。
你声明"这个字段是 String,它拥有数据",Rust 就根据这个声明生成正确的 drop 行为。
十一、这不是手动内存管理
到这里,可以看出 Rust 和 C 的差别。
C 版本里,Robin 必须在脑子里维护:
text
buf 是一块复用 buffer
Mistake.location 有时指向 buf
如果要保存结果,就不能继续复用 buf
如果复制 location,就要 malloc
malloc 后要 free
free 要按正确顺序
数组大小要自己控制
文件大小也要自己控制
Rust 版本里,Robin 需要表达:
text
Mistake 是借用文本,还是拥有文本?
如果借用,借用来自哪个参数?
如果拥有,就用 String。
多个结果是否会活得比 buffer 更久?
如果会,就不能复用那个 buffer,或者必须复制数据。
这就是声明式内存管理。
你不是手动执行每个内存操作,而是声明数据关系。编译器根据关系检查设计是否合理。设计不合理时,它不让你继续。
这也解释了为什么 Rust 初学者会觉得 borrow checker 像敌人。因为它不断阻止你写"看起来能跑"的代码。但随着经验增加,你会发现它指出的很多问题,正是 C 版本里那些线上 bug 的根源。
十二、五年后:一篇稿件里要报告所有逗号
五年过去,Rust 版本运行良好。
然后新管理层又提出需求:不要只报告第一个逗号,要报告一篇稿件里的所有逗号。
Robin 修改 Mistake:
rust
struct Mistake {
path: &'static str,
locations: Vec<String>,
}
用 match_indices 找所有逗号:
rust
let locations: Vec<_> = s
.match_indices(",")
.map(|(_, slice)| slice.to_string())
.collect();
如果 locations 为空,就返回 None;否则返回 Some(Mistake { path, locations })。
这能工作,但输出只有逗号本身:
text
sample4.txt: commas are forbidden: ","
sample4.txt: commas are forbidden: ","
sample4.txt: commas are forbidden: ","
于是 Robin 想加入上下文:逗号前后各取 12 个字符。
他写了大概这样的逻辑:
rust
let start = max(0, index - 12);
let end = min(index + 12, s.len());
s[start..end].to_string()
这能在纯 ASCII 文本里工作,但文章里特意插入了一个提醒:这是不好的 Rust。如果字符串里有非 ASCII 字符,按任意字节 offset 切片可能出问题。
原因是 Rust 的 String 是 UTF-8。index 是字节位置,不是字符位置。如果你在一个多字节字符中间切开,程序会 panic,因为那不是合法 UTF-8 边界。
这个提醒非常重要:Rust 防了很多内存错误,但不代表你永远不会写逻辑 bug。类型系统能防止悬垂引用、use-after-free、数据竞争,但它不能自动知道"你想按字符显示上下文而不是按字节切片"。你仍然要理解数据格式。
十三、更好的设计:保存原文和位置,而不是复制片段
Robin 接着意识到:如果一篇稿件里有很多逗号,那么把每个逗号附近的上下文都复制成 String,可能几乎和复制整篇文章差不多大。
而且 check() 的职责应该只是检查,不应该负责格式化展示。展示行号、上下文、高亮,应该放在 Display 或 report() 里。
于是设计调整为:
rust
struct Mistake {
path: &'static str,
text: String,
locations: Vec<usize>,
}
这一次,Mistake 拥有整篇文本 text,同时用 locations 保存逗号在文本中的位置。
rust
fn check(path: &'static str) -> Result<Option<Mistake>, E> {
let text = std::fs::read_to_string(path)?;
let locations: Vec<_> = text
.match_indices(",")
.map(|(index, _)| index)
.collect();
Ok(if locations.is_empty() {
None
} else {
Some(Mistake {
path,
text,
locations,
})
})
}
这个设计比前面更清楚:
text
Mistake 拥有文本
locations 只是文本中的位置
格式化时再根据位置找行号、行内容和光标位置
这里有一个细节:locations 是 Vec<usize>,不是引用。作者也承认,这种设计不是完美的类型建模。usize 本身只是一个数字,你可以放错位置,也可以放超过文本长度的位置。只是因为字段是私有的,而且它们和 text 放在同一个结构体里,所以风险可控。
这也说明 Rust 的设计不是魔法。你仍然要决定不变量如何表达。有些不变量能直接放进类型系统,有些只能靠模块私有性和构造函数来维护。
十四、把展示逻辑放进 Display
接下来,Robin 给 Mistake 实现展示逻辑。
首先写一个私有方法,根据逗号位置找到所在行的起止范围:
rust
impl Mistake {
fn line_bounds(&self, index: usize) -> (usize, usize) {
let len = self.text.len();
let before = &self.text[..index];
let start = before.rfind("\n").map(|x| x + 1).unwrap_or(0);
let after = &self.text[index + 1..];
let end = after.find("\n").map(|x| x + index + 1).unwrap_or(len);
(start, end)
}
}
然后在 Display 中:
rust
impl fmt::Display for Mistake {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for &location in &self.locations {
let (start, end) = self.line_bounds(location);
let line = &self.text[start..end];
let line_number = self.text[..start].matches("\n").count() + 1;
let comma_index = location - start;
write!(f, "{}: commas are forbidden:\n\n", self.path)?;
write!(f, "{:>8} | {}\n", line_number, line)?;
write!(f, "{}^\n\n", " ".repeat(11 + comma_index))?;
}
Ok(())
}
}
输出就像编译器诊断一样,有行号,有原文,有 ^ 指出逗号位置。
这一步很漂亮,因为它把职责分开了:
text
check() 负责读取和检查
Mistake 负责保存检查结果
Display 负责格式化展示
main() 负责流程编排
而且内存关系也清楚:
text
Mistake 拥有 text
locations 是 text 中的位置
Display 借用 self,临时切片 text
展示结束后临时引用消失
Mistake 被 drop 时 text 和 Vec 自动释放
没有 malloc,没有 free,没有手动释放顺序。
十五、最终 Rust 版本到底避免了什么
对比 C 版本,Rust 版本避免了很多问题。
第一,文件读取错误不能被忽略。read_to_string 返回 Result,你必须处理。
第二,没有错误用 Option 表达,不需要 NULL 指针。
第三,借用关系被显式表达。Mistake<'a> 如果持有 &'a str,编译器会追踪这个引用来自哪里、能活多久。
第四,共享 buffer bug 被 borrow checker 抓住。只要结果还可能引用 buffer,就不能再次可变借用 buffer 去读下一篇稿件。
第五,拥有数据用 String 表达。把 &str 改成 String,就是从借用改成拥有。Rust 自动处理分配和释放。
第六,结果数量用 Vec,不再有 MAX_RESULTS = 128 这种固定数组上限。
第七,文本大小不再受 256 KiB 固定 buffer 限制。String 可以按需增长,或者直接让每篇 Mistake 拥有自己的文本。
第八,释放顺序不再由人维护。结构体 drop 时,字段按规则释放。
第九,格式化逻辑可以安全地临时借用文本,不需要长期保存指针。
这就是 Rust 的核心收益:它不是消除了所有思考,而是把思考提前到设计阶段,把错误提前到编译期。
十六、为什么叫"声明式内存管理"
"声明式"这个词在编程里通常和"告诉系统你想要什么,而不是一步步告诉它怎么做"有关。
在 C 里,你告诉机器怎么做:
text
分配一块 Mistake
分配一块 location
复制 12 个字节
补 0
使用完后先 free location
再 free Mistake
数组最多 128 个
buffer 最多 256 KiB
在 Rust 里,你更多是在声明关系:
text
这个函数可能失败,返回 Result。
这个检查可能没有错误,返回 Option。
这个 Mistake 借用某个字符串片段,所以有生命周期参数。
这个 Mistake 拥有错误片段,所以字段是 String。
这个 Mistake 拥有整篇文本,所以字段是 text: String。
这些位置是一组动态数量的结果,所以字段是 Vec<usize>。
你仍然在管理内存,但方式不同。你不是在每个地方手动调用分配和释放,而是在类型中表达所有权和借用。编译器根据这些声明插入释放逻辑,并检查引用不会越界活着。
这就是作者说的"声明式内存管理"。
它不是 GC。它不在运行时扫描对象图。它也不是 C 式手动管理。它是一种基于所有权、借用和生命周期的静态检查体系。
十七、borrow checker 是敌人,还是同事
初学 Rust 时,borrow checker 经常像敌人。
你写一点代码,它报错;你改一下,它又报错;你只是想复用一个 buffer,它说不能多次可变借用;你只是想把引用放进结构体,它要求生命周期;你只是想返回一个结果,它问你结果借用的是哪个参数。
但这篇文章最后给出一个很好的视角:borrow checker 不是敌人,而是同事。
它像一个严格的 code reviewer。它不会帮你写业务逻辑,但它会指出你设计里那些说不清的关系:
text
这个结果是不是还引用着 buffer?
如果是,你为什么要重用 buffer?
这个字段到底拥有字符串,还是只是借用?
如果只是借用,谁保证原字符串还活着?
这个数组能不能容纳所有结果?
这个错误状态能不能为空?
在 C 里,这些问题也存在,只是没有人强制你回答。你可以写完、编译、上线,然后等某天数据量变大、文件顺序变化、老板朋友看出内存泄漏、或者 130 篇稿件把数组打穿。
Rust 则把这些问题提前摆在你面前。
这很烦,但也很有价值。
十八、Rust 不是完全不用理解内存
需要注意的是,Rust 并不是让你完全不用理解内存。
你仍然要知道:
text
String 拥有堆上字符串数据
&str 是借用,不拥有数据
Vec<T> 拥有一段连续内存
&[T] 是借用切片
usize 位置是字节索引,不是字符索引
UTF-8 不能随便按字节切片
clone / to_string 会分配
引用不能比被引用的数据活得更久
Rust 只是把这些知识和类型系统连接起来,让编译器能帮助你检查一部分不变量。
它不会替你理解 Unicode。它不会替你决定错误上下文应该截取几个字符。它不会自动保证 locations: Vec<usize> 里的每个位置都合法,除非你用更精细的类型或构造函数封装。
所以,Rust 的内存安全不是"无需思考"。它是"把必须思考的事情表达出来,然后让编译器持续检查"。
十九、这篇文章真正想表达什么
表面上,这篇文章是 C 和 Rust 的对比。C 版本不断遇到内存 bug,Rust 版本不断被编译器拦住,最后得到更好的设计。
但更深层,它讨论的是工程方法。
手动内存管理要求程序员在脑子里维护一张完整地图:谁指向谁,谁拥有谁,谁什么时候释放,谁还能用,谁不能再用。这在小程序里勉强可以,在需求变化、多文件处理、批量结果、错误上下文、长期维护中会变得越来越难。
Rust 的方法是把这张地图拆成类型、所有权、借用和生命周期。你仍然要设计地图,但编译器会检查它是否自洽。你不用每天在脑子里模拟 malloc/free,而是和 borrow checker 谈判,直到你们都满意。
这就是声明式内存管理的精髓。
二十、总结
这篇文章用一个虚构出版社 Overbook 的风格检查器,解释 Rust 为什么不应该被叫作"手动内存管理"。Overbook 的规则是稿件里绝不能出现逗号。员工 Robin 先用 C 写了一个检查器:固定 256 KiB buffer 读文件,扫描逗号,发现错误就返回 Mistake。最初版本能工作,但新需求很快暴露问题。
当管理层要求显示错误位置时,Robin 让 Mistake.location 指向 buffer 中逗号的位置。只检查一篇稿件时没问题,但当程序先收集所有检查结果、最后统一报告时,所有结果都指向同一个复用 buffer。后续文件覆盖 buffer 后,旧结果显示成最后一个文件的内容。Robin 用 malloc + memcpy 复制错误片段修复这个 bug,但又引入内存泄漏,于是必须手动 free(location) 和 free(mistake),而且释放顺序必须正确。后来程序又因为 MAX_RESULTS = 128 的固定数组上限,在检查 130 篇稿件时越界。C 版本的问题不是 Robin 不够小心,而是所有权、借用、释放顺序、数组容量和 buffer 生命周期都只能靠人脑维护。
Rust 重写版本从 read_to_string 开始。这个函数返回 Result<String, io::Error>,所以读取失败不能被忽略。find(",") 返回 Option<usize>,所以"有错误"和"无错误"也不再靠空指针表示。为了返回错误位置,Robin 定义 Mistake<'a> { location: &'a str },编译器要求写出生命周期,并要求 check() 的签名说明返回的 Mistake 借用了哪个参数。当 Robin 想复用一个 String buffer、把结果收进 Vec、最后统一报告时,borrow checker 报错:不能多次可变借用 s。这正是 C 版本里那个共享 buffer bug 的编译期版本。
修复方式是改变所有权设计。把 location: &str 改成 location: String,让 Mistake 拥有错误片段。Rust 会提示 &str 不能直接放进 String 字段,并建议使用 to_string()。这相当于 C 里的复制,但没有手写 malloc、memcpy 和 free。后来新需求要求报告一篇稿件里的所有逗号,Robin 先用 Vec<String> 保存多个片段,再进一步改成 Mistake { path, text: String, locations: Vec<usize> }:错误结果拥有整篇文本,只保存逗号位置,格式化时再找行号、行内容和 ^ 指示位置。最终,check() 负责检查,Display 负责展示,Vec 负责动态数量,String 负责拥有文本,drop 自动释放资源。
文章最后给出的结论是:Rust 不是手动内存管理。Robin 在整个 Rust 版本里没有手动分配或释放内存。相反,他只是不断声明自己想要的关系:某个结果是否存在,用 Option;某个操作是否可能失败,用 Result;某个字段是借用,用 &str 和生命周期;某个字段拥有数据,用 String;多个结果,用 Vec。编译器和 borrow checker 根据这些声明检查设计是否合理。它们不是敌人,而是合作方。Rust 的内存管理方式不是"你自己记得 free",而是"你把所有权和借用关系讲清楚,编译器帮你守住边界"。这就是声明式内存管理。