从 Swift 视角看 Rust:所有权是答案,也是题目本身
我学 Rust 的过程,跟很多人一样,前两天非常飘 —— 觉得这不就是”更严格的 Swift”嘛,类型系统、Option、Result、enum、match,一切看起来都熟。第三天开始撞墙。墙不是语法,是 borrow checker。
后来我慢慢想明白一件事:Rust 的难点不是语法,是数据关系的设计。 Swift 用 ARC 帮你兜了大半的内存管理,所以你想”两个对象互相持有”这种事,Swift 让你写得出,最多让你加个 weak。Rust 不让。它在编译期就把”谁拥有这块数据”这件事摆上台面,逼你回答清楚才让你过。
这篇笔记就是顺着这条迁移路线走一遍:从两门语言长得像的地方切入,再一刀刀切到它们真正分叉的地方。每一节后面都附我自己撞墙时记下来的反应,不是教程,是迁移记录。
一、两门语言的共同设计思路
很多人一上手 Rust 就先看所有权,我建议反过来 —— 先看它跟 Swift 像在哪。因为这两门语言的”价值观”其实非常接近:都不想让你写传统 C/C++ 那一套,都希望用类型系统把运行时错误尽量挡在编译期。先看共同点,能让你少一半的陌生感。
Q:Rust 和 Swift 有哪些相似的设计思路?
两者都不是传统 C/C++ 风格的语言。它们都希望用类型系统、明确的错误处理、值语义和现代抽象能力,减少运行时错误。
| 设计思路 | Swift | Rust |
|---|---|---|
| 代数类型 / 枚举 | enum + associated values | enum + 数据字段 |
| 空值表达 | Optional<T> | Option<T> |
| 错误处理 | throws / Result<T, Error> | Result<T, E> |
| 类型推断 | 支持 | 支持 |
| 能力抽象 | protocol | trait |
| 值类型优先 | struct 常用 | 默认值语义 |
| 泛型 | Generic | Generic |
| 模式匹配 | switch | match |
需要注意一点对应关系,初学时容易把两边混着用:
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 在内存安全上最大的不同是什么?
| 对比点 | Swift | Rust |
|---|---|---|
| 核心机制 | ARC 自动引用计数 | 所有权系统 + 借用检查器 |
| 释放时机 | 引用计数归零时释放 | 所有者离开作用域时释放 |
| 运行时开销 | 有 ARC 计数增减开销 | 通常没有 GC / ARC 开销 |
| 共享数据 | class 默认共享 | 必须显式写 Rc / Arc 等 |
| 并发安全 | Actor / Sendable / 编译期与运行时机制结合 | Send / Sync + 所有权系统,编译期约束更强 |
| 学习曲线 | 相对平缓 | 前期陡峭,尤其是 borrow checker |
Swift 的核心特点
- ARC 自动引用计数。
class是引用语义,天然共享。struct是值语义,适合不可变数据和局部状态。Array、String、Dictionary等标准库类型使用 Copy-on-Write,表现为值语义,底层可以共享存储。- Swift 6 的严格并发检查会更多地把数据竞争问题提前到编译期,但 Swift 仍然保留较强的运行时模型和动态能力。
把这几条揉到一起看,Swift 的内存模型本质是”运行时驱动,编译期辅助”。ARC 在你不知情的情况下默默做引用计数的加减,class 默认就是共享的 —— 你想让两个对象指向同一份状态,写出来就是。struct + Copy-on-Write 是 Apple 在性能和直觉之间的折中:写起来像值类型,复制时不真的复制,等你修改再分裂存储。Swift 6 的严格并发检查把一部分数据竞争问题往编译期挪了一格,但整体调子没变 —— 语言不挡你的路,只是偶尔提醒你”这块小心”。
这种风格的代价你也能猜到:很多问题要跑起来才会暴露 —— 循环引用、并发改写、悬挂引用,都得靠测试和经验兜住。
Rust 的核心特点
- 每个值只有一个所有者。
- 值离开作用域自动释放。
- 默认不允许隐式共享可变状态。
- 所有共享、可变、跨线程行为都要写得非常明确。
- 编译器会提前阻止悬空引用、数据竞争、use-after-free 等内存安全问题。
Rust 这边几乎是镜像。它放弃了”运行时帮你兜底”的承诺,把责任全部前移到编译期。“每个值只有一个所有者”这句话听起来朴素,但后劲非常大 —— 它直接切掉了”两个变量同时持有同一份堆数据”这种语义。释放时机也变成静态可读的:所有者一离开作用域,drop 立刻发生,不需要计数,不需要标记,看代码就知道什么时候析构。
共享、可变、跨线程这三件事 Rust 不替你猜,它要求你写出来 —— Rc、Arc、Mutex、Send、Sync,每一个标识都是你跟编译器签的字,签完了它才放你过去。代价是前期门槛高,收益是项目越大越值:那些在 Swift 里只能靠经验和 code review 兜住的并发坑,Rust 大多数能在 cargo build 阶段就拒绝。
一句话理解:
Swift 是”运行时帮你管理大多数内存问题”。 Rust 是”编译期逼你把所有权关系设计清楚”。
知道了”Rust 把所有权当一等公民”,下一个问题就来了:那 Swift 里的 struct 和 class,到 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本身不等于”重新分配内存”。- 普通小型值类型通常可以直接修改。
- 实际是否发生复制,取决于值语义、编译器优化、逃逸情况和具体类型实现。
Array、String、Dictionary这类标准库类型还有 Copy-on-Write 机制。
把这几条串起来看,mutating 这个词最容易让人误以为它会”动手脚”,但本质上它就是一个标签,作用范围是编译器和调用方之间的契约 —— “我会改 self,所以请你用 var 把我接住”。它跟”会不会复制”完全是两回事。
真正决定有没有复制的,是值语义本身和上下文:一个本地的 var point 调用 mutating 方法,就是直接原地改;如果它被存进了某个 let 包装的容器,编译器会告诉你不行;如果它是 Array 这种 CoW 类型,“是否复制”还要看背后的 buffer 引用计数。所以理解 mutating 的正确方式,不是把它当成性能特性,而是把它当成可变性的”声明书”。
什么时候 struct 不适合?
| 场景 | 原因 |
|---|---|
| 需要多个地方共享同一个状态 | struct 是值语义,赋值后容易变成副本 |
| 需要继承体系 | struct 不支持继承 |
| 对象特别大,并且频繁复制传递 | 复制成本可能变高 |
| 需要对象身份 identity | class 更自然,例如同一个用户对象、同一个连接对象 |
特别要注意:频繁修改属性本身不是使用 class 的理由。真正的判断标准是 —— 你需要”共享同一个对象”,还是”拿到一份独立数据”。
到这里 Swift 这边的铺垫就够了。把这个判断标准记在心里:“共享 vs 独立”。Rust 的所有权,可以理解为它把这个判断 强行 拉到了语言层。
四、Rust 的所有权:move、Copy、Clone
接下来是这趟迁移真正的”分水岭”。从这一节开始,Swift 那边教给你的直觉会开始失灵。最容易翻车的地方就是赋值 —— let b = a 这一行,在 Swift 和 Rust 里,做的可能是完全不同的事。
Rust 没有 Swift / Java 那种 class + 继承 体系,但可以用 struct、enum、trait、impl 完成数据建模、方法封装和能力抽象。
Rust 真正特殊的地方,是所有权。
4.1 所有权的三条基本规则
- Rust 中每个值都有一个所有者。
- 同一时间只能有一个所有者。
- 当所有者离开作用域,值会被自动释放。
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 核心规则
- 同一时间可以有任意多个
&T。 - 同一时间只能有一个
&mut T。 &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 编译器会指出:
- 借用发生在哪里;
- 被借用的值在哪里死亡;
- 借用在哪里仍然被使用。
6.1 生命周期标注不是延长生命周期
生命周期标注只是告诉编译器:多个引用之间的存活关系是什么。
它不会让一个值活得更久。
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
这里的意思是:返回值的生命周期不能超过 a 和 b 中较短的那个。
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 更倾向于把这些能力交给生态库,例如 reqwest、hyper、ureq。
这是和 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>> 这种组合。
| Swift | Rust 单线程 | 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 会让共享关系更显式。你看到 Rc、Weak、RefCell,就知道这里存在共享所有权、弱引用或内部可变性,需要重点审查设计。
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 里,要先区分两件事:
- 需要读取对方数据;
- 需要长期持有对方引用。
大多数时候,你只是需要读取对方数据,不一定要互相持有。
下面三种解法是我反复用到、能覆盖 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 的本质差异
| 对比点 | Swift | Rust |
|---|---|---|
| 内存安全 | ARC + 编译器规则 + 运行时机制 | 所有权 + 借用检查器 |
| 共享数据 | class 默认共享 | 必须显式选择 Rc / Arc 等 |
| 可变共享 | 相对灵活,依赖设计和并发模型 | 默认禁止,必须通过 &mut、RefCell、Mutex 等表达 |
| 错误发现 | 很多问题运行时暴露 | 很多问题编译期暴露 |
| 循环引用 | weak 打破,否则可能泄漏 | Weak<T> 打破;Rust 不保证消灭泄漏,但共享关系更显式 |
| 并发 | Actor / Sendable / async-await | Send / Sync / async-await / 线程模型 |
| 抽象机制 | protocol / extension / generic | trait / 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>>。
你应该先问:
- 谁真正拥有这些数据?
- 是否真的需要双向持有?
- 是否可以只传参数?
- 是否可以由上层统一管理?
- 是否可以使用 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 推荐学习顺序
建议按这个顺序学:
let/mut/ 基本类型;struct/enum/match;- 所有权 move;
Copy/Clone;- 借用
&T/&mut T; - 生命周期;
Result/Option/?;trait/ 泛型;Box/Rc/Arc;RefCell/Mutex;- async / Tokio;
- 实战项目。
14.5 最适合练手的小项目
| 项目 | 重点训练 |
|---|---|
| CLI Todo 工具 | struct、Result、文件 IO |
| 简单 HTTP 客户端 | reqwest、async、错误处理 |
| 本地日志分析器 | String、Vec、Iterator |
| 多线程下载器 | Arc、Mutex、thread |
| 小型配置解析器 | serde、Result、Option |
| 简单 Web API | axum / actix-web、async、状态管理 |
参考资料建议
建议后续学习时优先看:
- The Rust Programming Language(Rust 官方书)
- Rust by Example
- Rust Standard Library 文档
- Tokio 官方文档
- Swift 官方文档中的 Concurrency、Error Handling、Protocols、Generics 章节
- 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 的肌肉记忆直接移植过来。