从 Swift 视角看 Rust:所有权是答案,也是题目本身

我学 Rust 的过程,跟很多人一样,前两天非常飘 —— 觉得这不就是”更严格的 Swift”嘛,类型系统、OptionResultenummatch,一切看起来都熟。第三天开始撞墙。墙不是语法,是 borrow checker。

后来我慢慢想明白一件事:Rust 的难点不是语法,是数据关系的设计。 Swift 用 ARC 帮你兜了大半的内存管理,所以你想”两个对象互相持有”这种事,Swift 让你写得出,最多让你加个 weak。Rust 不让。它在编译期就把”谁拥有这块数据”这件事摆上台面,逼你回答清楚才让你过。

这篇笔记就是顺着这条迁移路线走一遍:从两门语言长得像的地方切入,再一刀刀切到它们真正分叉的地方。每一节后面都附我自己撞墙时记下来的反应,不是教程,是迁移记录。


一、两门语言的共同设计思路

很多人一上手 Rust 就先看所有权,我建议反过来 —— 先看它跟 Swift 像在哪。因为这两门语言的”价值观”其实非常接近:都不想让你写传统 C/C++ 那一套,都希望用类型系统把运行时错误尽量挡在编译期。先看共同点,能让你少一半的陌生感。

Q:Rust 和 Swift 有哪些相似的设计思路?

两者都不是传统 C/C++ 风格的语言。它们都希望用类型系统、明确的错误处理、值语义和现代抽象能力,减少运行时错误。

设计思路SwiftRust
代数类型 / 枚举enum + associated valuesenum + 数据字段
空值表达Optional<T>Option<T>
错误处理throws / Result<T, Error>Result<T, E>
类型推断支持支持
能力抽象protocoltrait
值类型优先struct 常用默认值语义
泛型GenericGeneric
模式匹配switchmatch

需要注意一点对应关系,初学时容易把两边混着用:

Swift 的 Optional 更接近 Rust 的 Option,表达”有没有值”。 Swift 的 throws / Result 更接近 Rust 的 Result,表达”成功或失败,并携带失败原因”。

// Swift Optional
let name: String? = nil

// Swift Result
let result: Result<String, Error>
// Rust Option
let name: Option<String> = None;

// Rust Result
let result: Result<String, String> = Ok("hello".to_string());

到这里你会觉得 Rust 学起来挺轻松。但欢迎期到此为止 —— 接下来就是真正的分叉点。


二、最大的分叉:ARC vs 所有权系统

如果说前面这些相似点是”表层共识”,那从这一节开始就是”地基不同”。Swift 和 Rust 都关心内存安全,但它们选择的路径几乎是镜像的:Swift 把决定权交给运行时,Rust 把决定权拽回编译期。这一节的对比,是后面所有概念的总开关 —— 所有权、借用、生命周期、Send/Sync,都是这个分叉的延伸。

Q:Swift 和 Rust 在内存安全上最大的不同是什么?

对比点SwiftRust
核心机制ARC 自动引用计数所有权系统 + 借用检查器
释放时机引用计数归零时释放所有者离开作用域时释放
运行时开销有 ARC 计数增减开销通常没有 GC / ARC 开销
共享数据class 默认共享必须显式写 Rc / Arc
并发安全Actor / Sendable / 编译期与运行时机制结合Send / Sync + 所有权系统,编译期约束更强
学习曲线相对平缓前期陡峭,尤其是 borrow checker

Swift 的核心特点

  • ARC 自动引用计数。
  • class 是引用语义,天然共享。
  • struct 是值语义,适合不可变数据和局部状态。
  • ArrayStringDictionary 等标准库类型使用 Copy-on-Write,表现为值语义,底层可以共享存储。
  • Swift 6 的严格并发检查会更多地把数据竞争问题提前到编译期,但 Swift 仍然保留较强的运行时模型和动态能力。

把这几条揉到一起看,Swift 的内存模型本质是”运行时驱动,编译期辅助”。ARC 在你不知情的情况下默默做引用计数的加减,class 默认就是共享的 —— 你想让两个对象指向同一份状态,写出来就是。struct + Copy-on-Write 是 Apple 在性能和直觉之间的折中:写起来像值类型,复制时不真的复制,等你修改再分裂存储。Swift 6 的严格并发检查把一部分数据竞争问题往编译期挪了一格,但整体调子没变 —— 语言不挡你的路,只是偶尔提醒你”这块小心”。

这种风格的代价你也能猜到:很多问题要跑起来才会暴露 —— 循环引用、并发改写、悬挂引用,都得靠测试和经验兜住。

Rust 的核心特点

  • 每个值只有一个所有者。
  • 值离开作用域自动释放。
  • 默认不允许隐式共享可变状态。
  • 所有共享、可变、跨线程行为都要写得非常明确。
  • 编译器会提前阻止悬空引用、数据竞争、use-after-free 等内存安全问题。

Rust 这边几乎是镜像。它放弃了”运行时帮你兜底”的承诺,把责任全部前移到编译期。“每个值只有一个所有者”这句话听起来朴素,但后劲非常大 —— 它直接切掉了”两个变量同时持有同一份堆数据”这种语义。释放时机也变成静态可读的:所有者一离开作用域,drop 立刻发生,不需要计数,不需要标记,看代码就知道什么时候析构。

共享、可变、跨线程这三件事 Rust 不替你猜,它要求你写出来 —— RcArcMutexSendSync,每一个标识都是你跟编译器签的字,签完了它才放你过去。代价是前期门槛高,收益是项目越大越值:那些在 Swift 里只能靠经验和 code review 兜住的并发坑,Rust 大多数能在 cargo build 阶段就拒绝。

一句话理解:

Swift 是”运行时帮你管理大多数内存问题”。 Rust 是”编译期逼你把所有权关系设计清楚”。

知道了”Rust 把所有权当一等公民”,下一个问题就来了:那 Swift 里的 structclass,到 Rust 里是什么?这就要回到值语义和引用语义的最基础区分。


三、struct、class 与值语义

我特意把这一节放在所有权之前,是因为这一组对照在 Swift 里其实就已经存在了 —— 你只是从来没把它看得这么重。Rust 的所有权模型,本质上就是把 Swift 里”值语义优先”这个倾向放大成了硬规则。所以在跨过去之前,先把 Swift 这边的值/引用语义彻底理清,到了对岸会顺很多。

Q:Swift 里 struct 和 class 的本质区别是什么?

核心问题只有一个:这个数据要被复制,还是要被共享?

class:引用语义,共享同一个对象

class Counter {
    var count = 0
}

let a = Counter()
let b = a
b.count = 10

print(a.count)  // 10,a 和 b 指向同一个对象

struct:值语义,赋值后是独立值

struct Counter {
    var count = 0
}

var a = Counter()
var b = a
b.count = 10

print(a.count)  // 0,a 不受影响

Q:let 修饰 struct 和 class 有什么不同?

// let 修饰 struct:整个值被冻结
let point = CGPoint(x: 0, y: 0)
point.x = 10  // 编译错误

// let 修饰 class:只是引用不能换,对象内容仍可变
let label = UILabel()
label.text = "hello"  // 可以修改属性

mutating 会触发内存重新分配吗?

这是个我自己也踩过的小坑。直觉上 mutating 听起来很重,好像会引发拷贝,其实不会。

mutating 只是一个编译期声明,告诉编译器:这个方法会修改 self,因此调用方必须用 var 声明。

struct Point {
    var x: Int
    var y: Int

    mutating func moveRight() {
        x += 1
    }
}

var point = Point(x: 0, y: 0)
point.moveRight()

更严谨地说:

  • mutating 本身不等于”重新分配内存”。
  • 普通小型值类型通常可以直接修改。
  • 实际是否发生复制,取决于值语义、编译器优化、逃逸情况和具体类型实现。
  • ArrayStringDictionary 这类标准库类型还有 Copy-on-Write 机制。

把这几条串起来看,mutating 这个词最容易让人误以为它会”动手脚”,但本质上它就是一个标签,作用范围是编译器和调用方之间的契约 —— “我会改 self,所以请你用 var 把我接住”。它跟”会不会复制”完全是两回事。

真正决定有没有复制的,是值语义本身和上下文:一个本地的 var point 调用 mutating 方法,就是直接原地改;如果它被存进了某个 let 包装的容器,编译器会告诉你不行;如果它是 Array 这种 CoW 类型,“是否复制”还要看背后的 buffer 引用计数。所以理解 mutating 的正确方式,不是把它当成性能特性,而是把它当成可变性的”声明书”。

什么时候 struct 不适合?

场景原因
需要多个地方共享同一个状态struct 是值语义,赋值后容易变成副本
需要继承体系struct 不支持继承
对象特别大,并且频繁复制传递复制成本可能变高
需要对象身份 identityclass 更自然,例如同一个用户对象、同一个连接对象

特别要注意:频繁修改属性本身不是使用 class 的理由。真正的判断标准是 —— 你需要”共享同一个对象”,还是”拿到一份独立数据”。

到这里 Swift 这边的铺垫就够了。把这个判断标准记在心里:“共享 vs 独立”。Rust 的所有权,可以理解为它把这个判断 强行 拉到了语言层。


四、Rust 的所有权:move、Copy、Clone

接下来是这趟迁移真正的”分水岭”。从这一节开始,Swift 那边教给你的直觉会开始失灵。最容易翻车的地方就是赋值 —— let b = a 这一行,在 Swift 和 Rust 里,做的可能是完全不同的事。

Rust 没有 Swift / Java 那种 class + 继承 体系,但可以用 structenumtraitimpl 完成数据建模、方法封装和能力抽象。

Rust 真正特殊的地方,是所有权。

4.1 所有权的三条基本规则

  1. Rust 中每个值都有一个所有者。
  2. 同一时间只能有一个所有者。
  3. 当所有者离开作用域,值会被自动释放。
fn main() {
    let name = String::from("iPhone");
    println!("{}", name);
} // name 在这里离开作用域,String 被释放

4.2 move:所有权转移

let a = String::from("hello");
let b = a;

println!("{}", b); // 可以
println!("{}", a); // 编译错误:a 的所有权已经转移给 b

这和 Swift 的赋值直觉不一样。

在 Swift 里:

let a = "hello"
let b = a
print(a)  // 可以

但在 Rust 中,String 是堆上数据,赋值默认会移动所有权,而不是隐式深拷贝。这一行的语义改变,是 Rust 给你的第一记下马威。

4.3 Copy:小型值可以直接复制

不是所有 Rust 类型都会 move。像整数、布尔值、字符这类简单类型实现了 Copy,赋值后原变量仍然可用。

let a = 10;
let b = a;

println!("{}", a); // 可以
println!("{}", b); // 可以

常见 Copy 类型:

类型是否 Copy
i32 / u32 / f64
bool
char
只包含 Copy 字段的 tuple
String
Vec<T>
自定义 struct默认不是,除非显式派生且字段都支持 Copy
#[derive(Copy, Clone)]
struct Point {
    x: i32,
    y: i32,
}

let p1 = Point { x: 0, y: 0 };
let p2 = p1;

println!("{}", p1.x); // 可以,因为 Point 是 Copy

4.4 Clone:显式深拷贝

如果你确实想复制堆数据,要显式调用 clone()

let a = String::from("hello");
let b = a.clone();

println!("{}", a); // 可以
println!("{}", b); // 可以

关键区别:

行为含义成本
move所有权转移通常低成本
Copy按位复制,小型值常见低成本
Clone显式复制数据可能有堆分配和较高成本

Rust 的设计意图: 贵的复制不能偷偷发生,必须让你写出来。

记住这条意图,会帮你后面看懂很多事 —— 包括为什么需要”借用”。因为如果每次传参都要 move 或者 clone,函数调用会立刻变得难用到不可接受。Rust 的解法不是退让,而是引入一个新工具。


五、借用:&T 与 &mut T

借用是 Rust 在所有权之上加的”日常生活协议”。所有权解决”谁负责释放”,借用解决”谁可以临时看一眼,谁可以临时改一下”。如果你只学所有权不学借用,Rust 就只能写一些函数签名极其难看的代码 —— 借用就是把它救回来的那只手。

Q:为什么需要借用?

如果每次传参都转移所有权,函数调用会很难用。

fn print_name(name: String) {
    println!("{}", name);
}

let name = String::from("iPhone");
print_name(name);
println!("{}", name); // 编译错误:name 已经被移动

借用让函数临时使用数据,但不拿走所有权。

fn print_name(name: &String) {
    println!("{}", name);
}

let name = String::from("iPhone");
print_name(&name);
println!("{}", name); // 可以

更惯用的写法是接收 &str,这样 String 和字符串字面量都能传。

fn print_name(name: &str) {
    println!("{}", name);
}

let name = String::from("iPhone");
print_name(&name);
print_name("MacBook");

借用分两种,对应你”想读”还是”想改”。

5.1 不可变借用:&T

同一时间可以有多个不可变借用。

let name = String::from("iPhone");

let a = &name;
let b = &name;
let c = &name;

println!("{} {} {}", a, b, c);

5.2 可变借用:&mut T

同一时间只能有一个可变借用,并且不能和不可变借用共存。

let mut name = String::from("iPhone");

let a = &mut name;
a.push_str(" 15 Pro");

println!("{}", a);

下面这种写法不允许:

let mut name = String::from("iPhone");

let a = &mut name;
let b = &mut name; // 编译错误:同一时间只能有一个可变借用

println!("{} {}", a, b);

也不允许这样混用:

let mut name = String::from("iPhone");

let a = &name;
let b = &mut name; // 编译错误:已有不可变借用时,不能再可变借用

println!("{}", a);

5.3 核心规则

  1. 同一时间可以有任意多个 &T
  2. 同一时间只能有一个 &mut T
  3. &mut T 存在时,不能同时存在 &T

这套规则的目的不是折磨开发者,而是防止数据竞争和别名修改。别名修改 这件事在 Swift 里也不是没踩过坑:你把同一个 class 实例传到两个地方,一个改一个读,结果就是俩地方互相打架。Rust 直接在编译期把这种可能性禁了。

5.4 Swift 里有类似 &mut T 的概念吗?

Swift 的 inout 和 Rust 的 &mut T 在直觉上接近:调用方都要显式传入”可被修改的变量”。

struct ShoppingCart {
    var items: [String] = []
}

func addItem(_ cart: inout ShoppingCart, item: String) {
    cart.items.append(item)
}

var cart = ShoppingCart()
addItem(&cart, item: "iPhone")
struct ShoppingCart {
    items: Vec<String>,
}

fn add_item(cart: &mut ShoppingCart, item: String) {
    cart.items.push(item);
}

let mut cart = ShoppingCart { items: vec![] };
add_item(&mut cart, "iPhone".to_string());

两者对应得很整齐 —— 直到你开始返回引用。一旦函数返回的是一个借用,就绕不开下一个问题:这个借用能活多久?


六、生命周期:引用不能比原值活得更久

很多人第一次看到 <'a> 这种语法会有点懵,会以为生命周期标注是”手动控制内存释放时机”的东西。不是。它根本不延长任何东西的生命,它只是一份契约:让编译器能够在你写出可疑代码时,准确指出”这个引用不安全在哪”。

生命周期不是”手动管理内存”。它是编译器用来证明引用安全的一套规则。

Q:为什么需要生命周期?

为了防止悬空引用。

let result;

{
    let name = String::from("iPhone");
    result = &name;
} // name 在这里释放

println!("{}", result); // 编译错误:result 指向已经释放的值

Rust 编译器会指出:

  1. 借用发生在哪里;
  2. 被借用的值在哪里死亡;
  3. 借用在哪里仍然被使用。

6.1 生命周期标注不是延长生命周期

生命周期标注只是告诉编译器:多个引用之间的存活关系是什么。

它不会让一个值活得更久。

fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

这里的意思是:返回值的生命周期不能超过 ab 中较短的那个。

6.2 'a 只是名字

'a 和泛型里的 T 一样,只是一个名字。

fn longer<'a>(a: &'a str, b: &'a str) -> &'a str { a }
fn longer2<'x>(a: &'x str, b: &'x str) -> &'x str { a }
fn longer3<'lifetime>(a: &'lifetime str, b: &'lifetime str) -> &'lifetime str { a }

这些写法本质一样,只是名字不同。看穿这一点之后,生命周期突然就不那么神秘了 —— 它就是个泛型参数,只不过这个泛型参数描述的不是”类型是什么”,而是”活多久”。

6.3 常见特殊生命周期

// 'static:整个程序期间都有效,字符串字面量通常是 'static
let s: &'static str = "hello";

// '_:匿名生命周期,让编译器推断
fn first_word(s: &'_ str) -> &'_ str {
    s.split_whitespace().next().unwrap_or("")
}

大多数时候可以省略:

fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

6.4 什么时候可以省略生命周期?

Rust 有生命周期省略规则。初学阶段不需要死背,只要记住下面几种情况:

场景是否通常可以省略
只有一个输入引用可以
方法里有 &self,返回引用来自 self可以
多个输入引用,返回值可能来自其中任意一个通常需要手动标注
// 可以省略:只有一个输入引用
fn first_word(s: &str) -> &str {
    s
}

// 需要标注:返回值可能来自 a,也可能来自 b
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

理解到这里,你已经掌握了 Rust 数据流的”静态部分” —— 谁拥有、谁借用、活多久。但有一类东西天然会把数据”带着走”,那就是闭包。闭包一捕获变量,就立刻把所有权和借用规则全部牵扯进来。


七、闭包:捕获、move 与多线程

Swift 里写闭包是非常自然的事情,逃逸闭包、@escaping[weak self],这些设施其实已经在偷偷帮你处理生命周期问题。Rust 把同一件事拿到了明面上 —— 闭包到底是借用了外面的变量,还是把它整个搬进来了?这件事必须写清楚。

Q:Rust 闭包语法和 Swift 有什么不同?

// Swift
let add = { (a: Int, b: Int) -> Int in
    a + b
}
// Rust
let add = |a: i32, b: i32| -> i32 {
    a + b
};

// 类型推断下可以更短
let add = |a, b| a + b;

7.1 || 会和逻辑或冲突吗?

不会。编译器会从上下文判断。

// 逻辑或
let value = false || true;

// 无参数闭包
let f = || true;
println!("{}", f());

7.2 闭包默认怎么捕获变量?

Rust 闭包会根据使用方式自动决定捕获方式:

使用方式捕获方式
只读取外部变量不可变借用
修改外部变量可变借用
消费外部变量移动所有权
let name = String::from("iPhone");

let print_name = || {
    println!("{}", name);
};

print_name();
println!("{}", name); // 可以,因为这里只是借用

7.3 move 闭包是什么?

move 会强制闭包拿走捕获变量的所有权。

let name = String::from("iPhone");

let print_name = move || {
    println!("{}", name);
};

print_name();
// println!("{}", name); // 编译错误:name 已经被移动进闭包

7.4 多线程为什么经常必须 move

因为新线程可能比当前函数活得更久。为了安全,线程闭包通常要拥有它用到的数据。

use std::thread;

let name = String::from("iPhone");

let handle = thread::spawn(move || {
    println!("{}", name);
});

handle.join().unwrap();

没有 move 时,线程可能引用了一个即将被释放的局部变量,所以编译器会拒绝。

写到这一节,你应该已经隐约感觉到一件事:所有权 + 借用规则确实安全,但它太”独占”了 —— 现实里我们经常需要让多个地方共同持有同一份数据。Rust 不是不允许,它只是让你必须显式写出来。


八、共享状态:Box、Rc、RefCell、Arc、Mutex

到这一节,主线开始转向”逃生通道”。前面六七节讲的都是”独占所有权”的世界,但写真实项目你迟早会发现:所有权的单线模型在某些场景下根本走不通。Rust 的处理方式不是开洞,而是给你一组明牌的智能指针,每一个都对应一种确定的共享语义。写哪种指针,就等于在告诉所有人你打算如何共享。

Rust 不鼓励隐式共享状态,但它不是不能共享。区别是:共享方式必须写清楚。

8.1 常见智能指针怎么选?

需求Rust 写法类比理解
单一所有者,数据放堆上Box<T>一个主人,堆分配
单线程,多所有者,只读共享Rc<T>单线程引用计数
单线程,多所有者,可变共享Rc<RefCell<T>>单线程共享 + 运行时借用检查
多线程,多所有者,只读共享Arc<T>原子引用计数,可跨线程
多线程,多所有者,可变共享Arc<Mutex<T>>跨线程共享 + 锁保护

下面把每一种依次拆开看。

8.2 Box<T>:单一所有者,放到堆上

Box<T> 最简单。它不解决共享问题,只是把值放到堆上,并由一个所有者持有。

let user = Box::new(String::from("Alice"));
println!("{}", user);

常见用途:

  • 大对象不想直接放在栈上;
  • 递归数据结构;
  • trait object,例如 Box<dyn Trait>

这三种用途看着零散,其实背后是同一件事:编译器在那一刻不知道值的大小,或者干脆不想把它锁死成某个具体类型。栈帧需要在编译期就排好布局,递归类型如果直接内嵌自己,大小会变成无穷;trait object 更是只在运行时才知道具体走的是哪条 impl。把值塞进 Box<T>,本质上是把”未知尺寸”挪到堆上,让栈上只留一个固定大小的指针。Swift 程序员对这件事其实很熟 —— 你写 class 的时候,运行时已经替你做了一遍同样的动作,只是你看不到。Rust 的差别在于,它要求你自己写出 Box,让”我知道这里要堆分配”变成一句明确的声明。

trait Animal {
    fn speak(&self);
}

struct Dog;

impl Animal for Dog {
    fn speak(&self) {
        println!("woof");
    }
}

let animal: Box<dyn Animal> = Box::new(Dog);
animal.speak();

8.3 Rc<T>:单线程多所有者

use std::rc::Rc;

let cart = Rc::new(String::from("cart"));
let a = Rc::clone(&cart);
let b = Rc::clone(&cart);

println!("{} {}", a, b);

Rc 的意思是 reference counted。每次 Rc::clone 增加一个共享者。这件事 Swift 程序员几乎是出于本能就懂 —— 这就是 ARC 在你眼前手动跑一遍。

注意:Rc<T> 只能用于单线程。

8.4 RefCell<T>:运行时借用检查

普通 Rust 借用检查发生在编译期。RefCell<T> 把借用检查推迟到运行时。

use std::cell::RefCell;

let value = RefCell::new(10);

*value.borrow_mut() += 1;
println!("{}", value.borrow());

如果运行时违反”多个只读或一个可写”的规则,会 panic。

let value = RefCell::new(10);

let a = value.borrow_mut();
let b = value.borrow_mut(); // 运行时 panic

所以 Rc<RefCell<T>> 能用,但不是首选。它通常说明你的对象关系比较复杂,需要重新检查设计。这个组合在 Swift 程序员的笔下出现频率极高,因为它最像 class。但越像越要警惕 —— 它的代价是把 borrow checker 的安全网搬到运行时。

8.5 Arc<Mutex<T>>:多线程共享可变状态

use std::sync::{Arc, Mutex};

let cart = Arc::new(Mutex::new(Vec::<String>::new()));

let list_page_cart = Arc::clone(&cart);
let checkout_page_cart = Arc::clone(&cart);

list_page_cart
    .lock()
    .unwrap()
    .push("iPhone".to_string());

println!("{}", checkout_page_cart.lock().unwrap().len());

每一步都有明确含义:

代码含义
Arc::new(...)创建可跨线程共享的引用计数对象
Arc::clone(&cart)增加一个共享者,引用计数 +1
Mutex::new(...)用锁保护内部数据
.lock()尝试拿锁,拿到后独占访问
.unwrap()如果锁中毒,直接 panic

Arc<Mutex<T>> 这一行写出来很啰嗦,但每个组件都不可省。这种”啰嗦”恰好是 Rust 的设计原则 —— 你共享的成本,不能藏在语言糖里。


九、多线程与 Mutex

.unwrap() 后面跟着的”锁中毒”是个值得单独拎出来讲的概念。Swift 这边没有完全对应的东西 —— Swift 的 actor 把锁的细节隐藏起来了。Rust 不藏,它把锁、争用、毒化都写在你脸上。

9.1 .lock() 失败是因为线程竞争吗?

不是。

std::sync::Mutex::lock() 来说,线程竞争时通常会阻塞等待,不是直接报错。

.lock() 返回 Result,主要是为了处理 Mutex poisoning(锁中毒):某个线程在持有锁时 panic 了,锁里的数据可能处于不一致状态。

use std::sync::{Arc, Mutex};
use std::thread;

#[derive(Debug)]
struct ShoppingCart {
    items: Vec<String>,
}

fn main() {
    let cart = Arc::new(Mutex::new(ShoppingCart { items: vec![] }));
    let cart_clone = Arc::clone(&cart);

    let handle = thread::spawn(move || {
        let mut c = cart_clone.lock().unwrap();
        c.items.push("iPhone".to_string());
        panic!("线程持有锁时崩溃");
    });

    let _ = handle.join();

    match cart.lock() {
        Ok(c) => {
            println!("正常,商品数:{}", c.items.len());
        }
        Err(poisoned) => {
            let c = poisoned.into_inner();
            println!("锁中毒,但继续使用,商品数:{}", c.items.len());
        }
    }
}

9.2 try_lock()lock() 不一样

这里要区分:

方法拿不到锁时
lock()阻塞等待
try_lock()立即返回错误

所以”.lock() 失败主要是锁中毒”这个说法,只适用于 std::sync::Mutex::lock() 这个语境。

9.3 用 sleep 等线程不是好习惯

// 不推荐:猜一个时间,不可靠
std::thread::sleep(std::time::Duration::from_millis(100));

应该用 join() 明确等待线程结束。

let handle = std::thread::spawn(move || {
    println!("子线程执行");
});

handle.join().unwrap();

线程的同步模型搞清楚了之后,下一个跳板自然就是异步。这两件事经常被混着说,但其实是两回事 —— 线程是”多个并行的执行体”,异步是”在一个执行体里见缝插针地做事”。


十、异步编程:async/await 与 Tokio

异步是 Rust 里另一个比较容易让人犯怵的领域,主要原因是它没有像 Swift 那样自带运行时。Swift 的 async/await 一写就能跑,是因为 Swift 把运行时缝在了语言里。Rust 把这一刀切开了 —— 语言只提供 async/await 这个语义,跑起来需要外挂运行时(一般是 Tokio)。这种”语言不替你做选择”的态度,会贯穿整个 async 生态。

10.1 Rust 的异步什么时候用?

场景推荐方式
网络请求、数据库查询、文件 IO 等 IO 密集任务async/await
CPU 密集计算多线程 / rayon 等并行方案
简单脚本、一次性 CLI 工具同步代码通常更简单

Rust 的 async/await 是语言能力,但它本身不提供完整运行时。任务调度、定时器、异步网络 IO 等通常需要运行时。Tokio 是最常用的 Rust 异步运行时之一。

10.2 常见依赖写法

版本会随时间变化,实际项目以 crates.io / docs.rs 最新稳定版本为准。当前可使用的典型写法如下:

[dependencies]
tokio   = { version = "1", features = ["full"] }
reqwest = { version = "0.13", features = ["json"] }

# 如果只需要同步 HTTP,也可以考虑 ureq
ureq = "3"

如果项目需要更稳定、少变化,可以使用当前团队已经验证过的版本并提交 Cargo.lock

10.3 为什么 Rust 标准库没有 HTTP 客户端?

Rust 标准库偏保守。标准库一旦稳定,后续就很难做破坏性调整。

HTTP、TLS、代理、压缩、重定向、Cookie、HTTP/2、HTTP/3 等能力变化很快,放进标准库容易形成历史包袱。所以 Rust 更倾向于把这些能力交给生态库,例如 reqwesthyperureq

这是和 Swift 的 URLSession 思路截然不同的选择 —— Swift 把网络请求装进了系统框架,更新跟着系统走;Rust 把它扔到生态里,让你自己挑当下最合适的版本。

10.4 async fn 的基本规则

用了 .await 的函数,自己通常也要标记为 async

use std::time::Duration;

struct User {
    id: u32,
    name: String,
}

struct Cart {
    items: Vec<String>,
}

struct Order {
    id: u32,
}

async fn fetch_user() -> Result<User, String> {
    tokio::time::sleep(Duration::from_millis(100)).await;
    Ok(User { id: 1, name: "Alice".to_string() })
}

async fn fetch_cart(user_id: u32) -> Result<Cart, String> {
    tokio::time::sleep(Duration::from_millis(100)).await;
    Ok(Cart { items: vec!["iPhone".to_string()] })
}

async fn checkout(cart: Cart) -> Result<Order, String> {
    tokio::time::sleep(Duration::from_millis(100)).await;
    Ok(Order { id: 42 })
}

async fn process() -> Result<(), String> {
    let user = fetch_user().await?;
    let cart = fetch_cart(user.id).await?;
    let order = checkout(cart).await?;

    println!("订单:{}", order.id);
    Ok(())
}

#[tokio::main]
async fn main() {
    match process().await {
        Ok(_) => println!("完成"),
        Err(e) => println!("出错:{}", e),
    }
}

10.5 串行等待 vs 并发等待

串行:一个完成后再执行下一个。

let user = fetch_user().await?;
let cart = fetch_cart(user.id).await?;

并发:多个任务同时推进。

let (user_result, recs_result) = tokio::join!(
    fetch_user(),
    fetch_recommendations()
);

如果希望任意一个失败就整体失败,可以用:

let (user, recs) = tokio::try_join!(
    fetch_user(),
    fetch_recommendations()
)?;

10.6 Rust 如何避免回调地狱?

核心组合是:Result + ? + async/await

async fn process() -> Result<(), Error> {
    let user = fetch_user().await?;
    let cart = fetch_cart(user.id).await?;
    checkout(cart).await?;
    Ok(())
}

逻辑可以保持线性阅读,不需要一层层嵌套回调。这一点其实和 Swift 5.5 引入 async/await 后做的事是一致的,本质都是把 Promise 链打回成顺序代码。

把所有权、借用、共享、并发、异步走到这里,你会发现还有一个很经典的麻烦没解决:循环引用。


十一、循环引用:weak 与 Weak

循环引用不是 Rust 独有的问题,Swift 有,Rust 也有。Swift 程序员熟悉 [weak self] 那一套,到了 Rust,对应的工具是 Weak<T>,但前提条件不一样 —— 在 Rust 里,能写出循环引用的,只可能是 Rc<T>Arc<T> 这种共享所有权的智能指针。换句话说,只有当你主动选择了共享,循环引用才有可能发生。

11.1 Swift 如何处理循环引用?

Swift 的 class 是引用语义。如果两个对象强引用彼此,就可能循环引用,导致 ARC 无法释放。

class Person {
    var pet: Pet?
}

class Pet {
    weak var owner: Person?
}

weak 不增加引用计数,所以可以打破循环。

11.2 Rust 对应怎么做?

Rust 中也可能出现引用计数循环,尤其是 Rc<RefCell<T>> 这种组合。

SwiftRust 单线程Rust 多线程
强引用Rc<T>Arc<T>
弱引用std::rc::Weak<T>,由 Rc::downgrade 创建std::sync::Weak<T>,由 Arc::downgrade 创建
use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Person {
    name: String,
    pet: Option<Rc<RefCell<Pet>>>,
}

struct Pet {
    name: String,
    owner: Option<Weak<RefCell<Person>>>,
}

fn main() {
    let alice = Rc::new(RefCell::new(Person {
        name: "Alice".to_string(),
        pet: None,
    }));

    let cat = Rc::new(RefCell::new(Pet {
        name: "Cat".to_string(),
        owner: None,
    }));

    alice.borrow_mut().pet = Some(Rc::clone(&cat));
    cat.borrow_mut().owner = Some(Rc::downgrade(&alice));

    if let Some(person) = cat
        .borrow()
        .owner
        .as_ref()
        .and_then(|weak| weak.upgrade())
    {
        println!("主人是:{}", person.borrow().name);
    }
}

11.3 Rust 会自动消灭内存泄漏吗?

不会。

Rust 可以在安全代码里保证内存安全,但不保证所有内存都一定及时释放。Rc<T> / RefCell<T> 组合仍然可以制造循环引用,导致内存泄漏。

区别是:Rust 会让共享关系更显式。你看到 RcWeakRefCell,就知道这里存在共享所有权、弱引用或内部可变性,需要重点审查设计。

11.4 Rc<RefCell<T>> 是最佳实践吗?

不是。

它有几个明显缺点:

  • RefCell 把借用检查推迟到运行时,违反规则会 panic。
  • 代码噪音大,到处都是 borrow() / borrow_mut()
  • 忘记使用 Weak 仍然可能泄漏。
  • 对象关系一复杂,调试成本会很高。

这几个问题串起来看,本质就是”你把 Rust 的优点亲手退还了一半”。Rust 最值钱的地方是编译期能直接告诉你”这里有数据竞争”或者”这个引用活得太久”,但 Rc<RefCell<T>> 把这层保护推迟到了运行时 —— 编译能过,崩在生产环境。再加上业务代码里到处是 borrow() / borrow_mut(),可读性也跟着下滑。最危险的是它”看起来能用”:原型阶段一切正常,等图结构稍微复杂一点,泄漏和 panic 才会冒头。

所以遇到双向引用的需求,比起立刻伸手去拿 Rc<RefCell<T>>,更稳妥的反应是:重新设计所有权关系,减少双向持有。 大多数时候你不是真的需要”两个对象互相持有彼此”,你只是需要在某一步操作里能同时访问到它们。这两件事在 Rust 里是完全不同的工程量。

这句话本身就是下一节的引子 —— 既然不推荐 Rc<RefCell<T>> 满天飞,那双向数据关系到底应该怎么设计?


十二、双向数据关系的设计模式

这一节是最”工程性”的部分。前面我们一直在讲 Rust 的工具是什么;这一节要回答的是:这些工具该怎么搭,才能避开 Rc<RefCell<T>> 这条捷径。 我自己刚开始写 Rust 时,本能反应也是 Rc<RefCell<T>>,因为最像 Swift 的 class。但写了几个项目以后才发现,绝大多数所谓的”双向”都是伪需求 —— 你只是临时需要读一下对方的数据,而不是要长期持有它。

很多 Swift / Objective-C / Java 开发者刚学 Rust 时,会习惯性想做对象之间的双向引用。

但在 Rust 里,要先区分两件事:

  1. 需要读取对方数据;
  2. 需要长期持有对方引用。

大多数时候,你只是需要读取对方数据,不一定要互相持有。

下面三种解法是我反复用到、能覆盖 90% 场景的设计模式。

12.1 解法一:Context 结构体

适合:操作是临时的,组装一次就用完。

struct File {
    name: String,
    is_deletable: bool,
}

struct Folder {
    is_encrypted: bool,
}

struct SystemMark {
    is_locked: bool,
}

struct FileContext<'a> {
    file: &'a File,
    folder: &'a Folder,
    system_mark: &'a SystemMark,
}

impl<'a> FileContext<'a> {
    fn open(&self) -> Result<(), String> {
        if self.folder.is_encrypted {
            return Err("已加密".to_string());
        }
        if self.system_mark.is_locked {
            return Err("系统锁定".to_string());
        }

        println!("打开 {}", self.file.name);
        Ok(())
    }

    fn delete(&self) -> Result<(), String> {
        if !self.file.is_deletable {
            return Err("不可删除".to_string());
        }
        Ok(())
    }
}

let ctx = FileContext {
    file: &file,
    folder: &folder,
    system_mark: &mark,
};

ctx.open()?;
ctx.delete()?;

12.2 解法二:只提取需要的字段

适合:对象长期存在,但只需要对方的一小部分信息。

struct FileProcessor {
    file: File,
    folder_encrypted: bool,
    system_locked: bool,
}

impl FileProcessor {
    fn new(file: File, folder: &Folder, mark: &SystemMark) -> Self {
        Self {
            file,
            folder_encrypted: folder.is_encrypted,
            system_locked: mark.is_locked,
        }
    }

    fn open(&self) -> Result<(), String> {
        if self.folder_encrypted {
            return Err("已加密".to_string());
        }
        if self.system_locked {
            return Err("系统锁定".to_string());
        }
        Ok(())
    }
}

12.3 解法三:重新设计所有权,由上层统一管理

适合:这些数据天然属于一个整体。

struct FileSystem {
    folder: Folder,
    files: Vec<File>,
    system_mark: SystemMark,
}

impl FileSystem {
    fn open_file(&self, index: usize) -> Result<(), String> {
        if self.folder.is_encrypted {
            return Err("已加密".to_string());
        }
        if self.system_mark.is_locked {
            return Err("系统锁定".to_string());
        }

        println!("打开 {}", self.files[index].name);
        Ok(())
    }
}

12.4 怎么选择?

情况优先选择
临时操作,需要多个对象共同参与Context 结构体
长期对象,只需要部分信息提取字段
数据天然属于同一整体重新设计上层 owner
确实需要复杂图结构再考虑 Rc<RefCell<T>> / Weak<T>

判断原则:

先用所有权设计解决问题,再用智能指针兜底。不要一上来就把所有东西包进 Rc<RefCell<T>>

到这里整条线就完整了:从两门语言的相似处出发,走过所有权、借用、生命周期、共享、并发、异步、循环引用,最后落到工程级的设计权衡。下面把整个对比浓缩成一张表。


十三、总结:Swift 与 Rust 的本质差异

对比点SwiftRust
内存安全ARC + 编译器规则 + 运行时机制所有权 + 借用检查器
共享数据class 默认共享必须显式选择 Rc / Arc
可变共享相对灵活,依赖设计和并发模型默认禁止,必须通过 &mutRefCellMutex 等表达
错误发现很多问题运行时暴露很多问题编译期暴露
循环引用weak 打破,否则可能泄漏Weak<T> 打破;Rust 不保证消灭泄漏,但共享关系更显式
并发Actor / Sendable / async-awaitSend / Sync / async-await / 线程模型
抽象机制protocol / extension / generictrait / impl / generic
开发体验快速、灵活、适合产品开发前期严格,后期维护稳定

Swift 的优势

  • 开发效率高。
  • UI 和 Apple 平台生态极强。
  • 语法更贴近应用开发。
  • ARC 降低了内存管理门槛。

Swift 的核心定位其实从这几条就能看出来 —— 它是一门”为产品而生”的语言。你写 SwiftUI 一天能搭出三个页面,借助 Apple 平台的工具链和系统框架,从原型到上架的距离非常短。ARC 在背后默默做了大半的内存管理工作,让大多数开发者根本不需要思考”这块内存什么时候释放”,把注意力还给业务本身。这种”快”是有代价的:很多问题被推迟到了运行时和测试阶段,但对绝大多数应用层项目来说,这个折中是划算的。

Rust 的优势

  • 无 GC,通常也无 ARC 开销。
  • 编译期暴露大量内存和并发问题。
  • 适合系统软件、服务端基础设施、高性能工具、嵌入式、区块链、数据库、编译器等领域。
  • 长期维护时,类型系统和所有权模型能降低很多隐性风险。

Rust 走的是相反的路线 —— 它愿意让你前期慢一点、痛一点,换取后期的稳。没有 GC 也没有 ARC,意味着运行时基本没有”看不见的开销”,性能曲线非常可预测,这对系统级软件、数据库、嵌入式、区块链这类对延迟和资源极度敏感的领域至关重要。更关键的是,Rust 把”内存安全”和”线程安全”塞进了类型系统:很多在其他语言里只能靠 code review 和经验兜住的问题,在 cargo build 这一步就被拦下来。等项目规模大到几万行、十几个模块、多人协作,这套约束的价值才真正显现 —— 它不是让你写得快,是让你改得起。

最核心的一句话

Swift 让你更快写出可用代码。 Rust 逼你更早写出结构正确的代码。


十四、学习建议:从 Swift 切到 Rust 的正确姿势

最后留给一些实操建议。这部分是我自己在前几个 Rust 项目里反复跌倒、又反复爬起来之后,回头总结的”如果重新来一遍我会怎么做”。

14.1 不要用 Swift class 的思路硬套 Rust

Swift 里很自然的写法:

class User {
    var orders: [Order]
}

class Order {
    weak var user: User?
}

在 Rust 里不要第一反应就写成 Rc<RefCell<User>>Weak<RefCell<Order>>

你应该先问:

  1. 谁真正拥有这些数据?
  2. 是否真的需要双向持有?
  3. 是否可以只传参数?
  4. 是否可以由上层统一管理?
  5. 是否可以使用 ID / index 代替引用?

14.2 看到 borrow checker 报错,先别急着绕

Rust 编译器报错经常是在提醒:

  • 你同时想读又想写;
  • 你想让引用活得太久;
  • 你的所有权边界不清楚;
  • 你的数据结构可能不适合 Rust。

这四条其实是 borrow checker 最常见的”潜台词”。它表面在抱怨语法,骨子里都在指向同一件事 —— 你的数据关系还没想清楚。Swift 程序员特别容易碰到第一条和第三条:习惯性地把对象传得到处都是,然后在某个意想不到的位置改一下;在 Rust 里这种写法直接被拒绝。第二条和第四条则更像是设计层的红灯,比如你想存储一个引用进 struct 但又不想引入生命周期参数,或者你试图在 Rust 里复刻一份带循环依赖的对象图 —— 这些场景下,编译器往往是在提示你 整个建模方向不对,而不是某一行代码错了。

第一反应应该是调整设计,而不是马上使用 clone()Rc<RefCell<T>> 或生命周期标注硬压过去。borrow checker 报错时,把它当成一个提早出现的 code reviewer,而不是麻烦制造者。

14.3 clone() 不是不能用,但不要滥用

初学 Rust 很容易为了通过编译到处 .clone()

可以用,但要知道代价:

  • 小数据 clone 问题不大。
  • 大字符串、大数组、复杂结构频繁 clone,会有性能成本。
  • 为了绕过所有权而 clone,通常说明设计还可以优化。

.clone() 真正的问题不在那一行的开销,而在于它会”养成习惯”。每次借用规则报错就 .clone() 一下,编译能过、程序能跑,然而你跳过了 Rust 想让你做的那个动作 —— 想清楚这块数据的所有权该归谁。短期看是省事,长期看是把一份学费分期付了,最终还是要在重构或排查性能问题时一并补上。

我自己的经验是把 .clone() 分成两类来看:一类是”我知道这里就是要复制,复制语义本身就是设计的一部分”,比如 Arc::clone 增加引用计数、克隆一份配置传给子任务,这种用得越显式越好;另一类是”我只是想让 borrow checker 闭嘴”,这种每出现一次都值得回头问一句 —— 是不是借用就够了?是不是该换 &str 而不是 String?是不是应该重新切一下函数边界?

14.4 推荐学习顺序

建议按这个顺序学:

  1. let / mut / 基本类型;
  2. struct / enum / match
  3. 所有权 move;
  4. Copy / Clone
  5. 借用 &T / &mut T
  6. 生命周期;
  7. Result / Option / ?
  8. trait / 泛型;
  9. Box / Rc / Arc
  10. RefCell / Mutex
  11. async / Tokio;
  12. 实战项目。

14.5 最适合练手的小项目

项目重点训练
CLI Todo 工具struct、Result、文件 IO
简单 HTTP 客户端reqwest、async、错误处理
本地日志分析器String、Vec、Iterator
多线程下载器Arc、Mutex、thread
小型配置解析器serde、Result、Option
简单 Web APIaxum / actix-web、async、状态管理

参考资料建议

建议后续学习时优先看:

  1. The Rust Programming Language(Rust 官方书)
  2. Rust by Example
  3. Rust Standard Library 文档
  4. Tokio 官方文档
  5. Swift 官方文档中的 Concurrency、Error Handling、Protocols、Generics 章节
  6. Swift 6 Concurrency Migration Guide

最终理解

写到最后想说一句:Rust 的难点不是语法,而是数据关系设计。

如果从 Swift 过来,最容易犯的错误是:

  • 把所有共享关系都当成 class
  • 把所有权报错当成语言麻烦;
  • .clone()Rc<RefCell<T>> 强行绕过设计问题;
  • 过早引入复杂生命周期;
  • 还没理解同步代码,就开始写 async。

这五条错误其实是同一个根 —— Swift 的肌肉记忆。在 Swift 里,class 是默认共享、ARC 自动兜底、async/await 写出来就能跑,所有”麻烦事”都被语言或运行时吃掉了。所以从 Swift 切过来的开发者,碰到 Rust 的第一反应往往是想办法重建那种”语言帮我兜住一切”的体验:用 Rc<RefCell<T>> 模拟 class,用 .clone() 抹平所有权报错,遇到生命周期就一通 <'a> 标上去。结果是 Rust 的优势全没用上,缺点全占齐了。

更稳的姿势是承认:Rust 不是带 borrow checker 的 Swift,它是另一门要求你重新规划数据关系的语言。前几个项目慢一点没关系,关键是把”先想清楚谁拥有数据”这件事变成默认动作,而不是把它当成偶尔需要应付的关卡。

更好的路线是:

先设计清楚谁拥有数据,再决定怎么借用;先减少共享,再考虑智能指针;先写同步逻辑,再引入并发和异步。

Swift 教会我”快速把东西跑起来”,Rust 教会我”先把关系想清楚再下笔”。两种节奏都值得拥有 —— 但要记得切换思路,不要把 Swift 的肌肉记忆直接移植过来。