Rust 不是手动内存管理:它是声明式内存管理

本文是对 Declarative memory management 的整理与翻译。

内容结构概览

  1. 文章核心观点:Rust 不是手动内存管理,而是声明式内存管理。
  2. Overbook 出版社设定:公司风格指南规定,稿件里绝对不能出现逗号。
  3. C 版本起步:读取文件到固定 buffer,扫描逗号,发现错误就返回。
  4. 第一个需求变化:除了发现错误,还要显示错误位置。
  5. 共享 buffer bug:多个检查结果都指向同一个 buffer,最后全部显示最后一篇稿子的内容。
  6. 复制字符串修复 :用 malloc + memcpy 复制错误片段,避免引用共享 buffer。
  7. 内存泄漏问题 :分配了 Mistakelocation,必须手动 free,而且顺序还要小心。
  8. 固定数组上限问题MAX_RESULTS = 128,检查 130 篇稿件时越界。
  9. C 的根本困境:程序员要同时记住所有权、生命周期、buffer 复用、释放顺序和数组容量。
  10. Rust 重写第一步read_to_string 返回 Result,不能假装文件读取永远成功。
  11. Option 替代 NULL :有错误是 Some(Mistake),没错误是 None
  12. 生命周期登场Mistake<'a> 持有 &'a str,编译器要求说明引用来自哪里、能活多久。
  13. borrow checker 抓住 C 版同款 bug :结果收集到 Vec 后,不能再次可变借用共享 buffer。
  14. 从借用改成拥有 :把 location: &str 改成 location: String,让错误结果拥有自己的文本。
  15. String&strto_string():Rust 不需要手写 malloc/free,但仍然能表达是否拥有数据。
  16. 多年后新需求:一篇稿件里要报告所有逗号,而不是只报告第一个。
  17. Vec<String> 收集多个位置片段:先解决功能,再发现复制大量文本不够优雅。
  18. 更好的设计Mistake 拥有整篇文本,locations: Vec<usize> 只保存逗号位置。
  19. 格式化逻辑放到 Displaycheck() 负责检查,report() / Display 负责展示行号和高亮。
  20. 最终结论: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 怎么被覆盖,都不会影响已经保存的错误片段。

功能恢复正常。

但这一步引入了一个新问题:内存分配。

之前只 mallocMistake,现在又 malloclocation。那么就必须 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,必须把 sMistake<'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() 的职责应该只是检查,不应该负责格式化展示。展示行号、上下文、高亮,应该放在 Displayreport() 里。

于是设计调整为:

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 只是文本中的位置
格式化时再根据位置找行号、行内容和光标位置

这里有一个细节:locationsVec<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 里的复制,但没有手写 mallocmemcpyfree。后来新需求要求报告一篇稿件里的所有逗号,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",而是"你把所有权和借用关系讲清楚,编译器帮你守住边界"。这就是声明式内存管理。

相关推荐
AI人工智能_电脑小能手1 小时前
【大白话说Java面试题 第125题】【并发篇】第25题:说说 Java 线程的中断机制
java·后端·面试
fliter1 小时前
Box 里到底装了什么:从 Go interface 到 Rust trait object
后端
Java内核笔记1 小时前
Spring Security 源码解析(六)无状态 JWT 实践:Session 共享与自定义过滤器
java·后端
乘云数字DATABUFF1 小时前
5分钟部署开源APM Databuff:OpenTelemetry全链路追踪入门实战
运维·后端
杨利杰YJlio1 小时前
OpenClaw / clawdbot 是什么?看懂 Agent 体系
前端·后端
SamDeepThinking2 小时前
一条UPDATE语句在MySQL 8.0中到底加了几把锁?
后端·mysql·程序员
CodeSheep2 小时前
他俩只靠写代码,登上了胡润财富榜!
前端·后端·程序员
IT_陈寒2 小时前
React状态更新总是慢半拍?你可能忘了这个默认行为
前端·人工智能·后端
candyTong2 小时前
阿里开源 AI Code Review 工具:ocr review 的执行链路解析
javascript·后端·架构