04 所有权与借用(Rust 的“核心门槛”与核心价值)

本章目标

  • 你能解释:为什么 Rust 不需要 GC 也能安全释放内存。
  • 你能在真实代码里解决常见编译错误:move、借用冲突、生命周期相关提示。
  • 你能理解 asset-light 里两类典型“共享可变”场景:
    • UI 状态:Signal<AppState>.read() / .write()
    • DB 连接:Mutex<Connection> + OnceLock<Database>

关键概念(先把话说清楚)

1) 所有权(Ownership)

  • 每个值在任意时刻只有一个“拥有者”(owner)
  • owner 离开作用域,值会被 drop(资源释放)

2) 移动(Move)

对“拥有资源”的类型(如 StringVec<T>):

  • 赋值 / 传参默认是 move
  • move 之后,旧变量失效(防止“双重释放”)

3) 借用(Borrow)与引用(Reference)

  • &T:不可变借用(多读)
  • &mut T:可变借用(单写)

Rust 的核心规则:

  • 同一时间:
    • 要么有任意多个 &T
    • 要么只有一个 &mut T

与 JS/Java 的直觉冲突点(你一定会遇到)

  • “我只是传个参数,为啥变量就不能用了?”
    因为你传的是 move(把所有权交出去了),不是引用。
  • “我就想同时读写一下,为啥不行?”
    因为 Rust 要在编译期保证没有数据竞争/悬垂引用。
  • “我明明只是在 UI 里读 state,为啥又报借用冲突?”
    因为 .read()/.write() 可能拿到内部借用句柄,作用域没结束就会冲突。

String vs &str(Rust 初学者最常见困惑)

  • String:拥有所有权、可增长、堆分配
  • &str:字符串切片引用(通常指向 String 或静态字符串)

经验法则:

  • API 接收参数时,优先用 &str(更通用、避免不必要的 clone)
  • 存储字段时,用 String(拥有数据,生命周期更简单)

在 asset-light 中的“共享可变”怎么做到安全

1) UI 全局状态:Signal<AppState>

文件:src/state/app_state.rs

  • provide_app_state()use_context_provider 注入一个 Signal<AppState>
  • 组件内用 use_app_state() 获取它
  • .read():拿到只读视图
  • .write():拿到可写视图(通常是短作用域)

你在页面里经常看到类似写法:

  • let mut state = use_app_state();
  • let assets = state.read().assets.clone();
  • state.write().assets = assets;

为什么常见 clone()
因为 UI 渲染往往需要拥有数据(或需要跨作用域),而借用规则不允许你把 &T 引用带出读取作用域。

学习阶段先接受 clone 的存在,等掌握后再优化。

2) DB 单例与连接:OnceLock<Database> + Mutex<Connection>

文件:src/db/mod.rssrc/db/connection.rs

  • OnceLock<Database>:保证数据库只初始化一次(全局单例)
  • Database 内部用 Mutex<Connection>
    • 互斥锁保证同一时间只有一个线程/调用在访问 Connection
    • 让“全局可变”在类型系统下变得安全

你会看到:

  • let conn = self.conn.lock().unwrap();

这行 unwrap() 在工程上可以进一步改进(见后续“错误处理”章节),但它体现了“锁拿到了才能继续用连接”的共享可变模式。

常见编译错误“翻译器”(带你读懂报错)

1) use of moved value

你把值 move 走了,然后又用旧变量。

解决思路(按优先级):

  1. 改成传引用:fn foo(s: &str) / fn foo(v: &Vec<T>)
  2. 必要时 clone:let b = a.clone();
  3. 重新设计数据流:把 ownership 留在更高层

2) cannot borrow ... as mutable because it is also borrowed as immutable

同一个值同时存在不可变借用与可变借用。

解决思路:

  1. 缩小借用作用域:用代码块 { ... } 包住 .read() 的使用
  2. 先把数据拷贝出来再写:let x = state.read().foo.clone(); state.write().foo = x;
  3. 重构:拆成两个 signal/字段,减少冲突

3) 生命周期相关提示(先不硬啃)

学习期建议:

  • 先用拥有类型(String/Vec<T>)把功能做出来
  • 当你开始做性能优化/减少 clone 时,再回头系统学习 lifetime 标注

本章练习(用真实代码练 borrow 思维)

练习 A:把 “读写同一个 state” 的作用域缩小

找一个页面(例如 src/pages/assets.rs)观察这种模式:

  • state.read() 拿数据
  • 后面 state.write() 更新数据

尝试把读取逻辑包进一个更小的作用域块,确保你理解“借用结束点”:

  • { let assets = state.read().assets.clone(); ... }
  • 再进行写入

目的不是改功能,而是让你建立“借用在何时结束”的直觉。

练习 B:识别哪些类型是 Copy,哪些会 move

在本项目里找几个字段:

  • CategoryCopy:枚举,Clone, Copy
  • Uuid(非 Copy,通常 move / clone)
  • Decimal(非 Copy,通常 clone 或引用)
  • String(move 语义最典型)

你需要能解释:为什么 Category 可以随便传来传去,而 String 不行。

练习 C:理解为什么 DB 连接需要 Mutex

打开 src/db/connection.rs,回答:

  • 如果没有 Mutex,我们如何在多个调用点安全地复用一个 Connection
  • 为什么 Rust 不允许“全局可变静态变量直接拿来改”?