Skip to content

Rust 系统学习:从基础语法到工程实践 · 开始之前至所有权、借用与生命周期 ​

返回分篇目录

Rust 系统学习:从基础语法到工程实践 ​

开始之前 ​

先完成工具链与第一个项目,再按本文顺序练习。学习目标是理解值如何传递、如何设计函数接口,以及如何把输入转换成有测试保护的业务逻辑。示例使用稳定 Rust 和标准库。

学习顺序与练习方式 ​

  1. 变量、类型与控制流:能写出有参数、有返回值的函数。
  2. 所有权、借用与生命周期:能解释为什么一个值被移动,为什么某个借用发生冲突。
  3. 结构体、枚举与 trait:能用类型表达数据和状态。
  4. 集合、字符串与迭代器:能区分拥有数据与借用数据。
  5. 错误处理:能把失败交给调用者处理。
  6. 模块与测试:完成一个包含有效输入、无效输入测试的小库。

每个独立示例可以替换工具链章节所建项目的 src/main.rs,运行 cargo run;最后一节使用 src/lib.rs 和 cargo test。不要把多个含 fn main() 的代码块直接拼成一个文件。

先认清示例中的检查方式:assert!(条件) 要求条件为真,assert_eq!(实际值, 期望值) 比较两个值,不满足时会 panic 并使当前示例失败。末尾的 ! 表示宏调用。放在 main 中的断言由 cargo run 执行;cargo test 不会运行这个 main,最后一节才把检查写成带 #[test] 的测试函数。

变量、类型与控制流 ​

绑定、可变性与常量 ​

let 创建绑定,默认不能重新赋值;mut 允许修改。重新写一个 let 是遮蔽,可以改变类型。常量使用 const,必须写类型,其值需要能在编译时求值,包含允许的 const fn 调用。

rust
const MAX_RETRIES: u8 = 3;

fn main() {
    let text = " 42 ";
    let text = text.trim(); // 遮蔽,不修改原来的字符串
    let mut attempts = 0;
    attempts += 1;
    assert_eq!(text, "42");
    assert!(attempts <= MAX_RETRIES);
}

mut 影响绑定所允许的操作,不能据此把“数组”“元组”说成不可变类型。下例都可以修改:

rust
fn main() {
    let mut point = (1, 2);
    point.0 = 3;
    let mut values = [1, 2, 3];
    values[0] = 9;
    assert_eq!(point, (3, 2));
    assert_eq!(values, [9, 2, 3]);
}

类型推断依赖上下文;常用整数是 i32,集合索引是 usize,浮点数通常是 f64。bool 表示真假;char 是 Unicode 标量值,不等同于用户看到的一个字形。元组允许不同类型,数组 [T; N] 的元素同类型且长度属于类型的一部分。详见基础类型。

整数运算不要依赖构建模式决定溢出结果。let n: u8 = 256 本身是超出范围的字面量;运行时运算的检查则受构建配置影响。需要明确行为时选 checked_add(显式报告溢出,返回值在下文 Option 中学习)、saturating_add(饱和)或 wrapping_add(环绕)。Cargo profiles说明了 overflow-checks 配置。

函数、表达式与分支 ​

函数参数写出类型;函数末尾不带分号的表达式作为返回值。if 的条件必须是 bool,作为值使用时各分支的类型要兼容。下例返回字符串字面量,类型写成 &'static str;先把它理解为可在整个程序运行期间使用的字符串引用,借用与生命周期会在下一节展开。

rust
fn label(score: u8) -> &'static str {
    if score >= 60 { "pass" } else { "retry" }
}

fn main() {
    let mut passed = 0;
    for score in [55, 60, 80] {
        if label(score) == "pass" {
            passed += 1;
        }
    }
    assert_eq!(passed, 2);
}

for score in [55, 60, 80] 依次访问数组元素。while 适合循环条件,loop 适合持续循环并由 break 退出。match 按模式选择分支,必须覆盖所有可能情况;例如可将上面的 if 表达式换成 match score { 0..=59 => "retry", _ => "pass" }。0..=59 包含两个端点,_ 接住其余情况。参见控制流。

练习:写一个接收 u8 分数、返回等级的函数,用 match 覆盖多个区间,并测试边界值 59 和 60。

所有权、借用与生命周期 ​

移动、复制与释放 ​

先观察类型和函数签名,再判断所有权。String 拥有可增长的字符串数据;把它赋值给新变量或按值传给函数会移动所有权。移动后原绑定不能继续使用。i32 等实现 Copy 的类型在赋值时复制;是否 Copy 由类型决定,不能仅凭“在栈上还是在堆上”判断。官方所有权章节解释了移动与 clone 的区别。

String::from("rust") 调用关联函数,从字符串字面量创建一个拥有数据的 String;text.len() 是在值上调用方法,这里的结果是字节数。

rust
fn consume(text: String) -> usize {
    text.len()
} // text 在这里释放其拥有的资源

fn main() {
    let first = String::from("rust");
    let second = first;
    // println!("{first}"); // 取消注释后应产生“使用已移动的值”错误
    assert_eq!(consume(second), 4);

    let count = 3;
    let copied = count;
    assert_eq!(count + copied, 6);
}

需要两份独立的 String 时显式调用 clone(),它会复制字符串数据。不要只为消除借用报错而到处克隆:先检查函数是否只需读取。如果拥有者正常离开作用域且值尚未被移走,Rust 会清理该值拥有的资源;Drop 是资源管理的一部分,不只用于内存。

通过引用读取与修改 ​

&T 共享借用,&mut T 独占可变借用。对同一数据的重叠访问,需要遵守多个共享借用与一个独占可变借用之间的约束;引用还必须指向有效数据。普通读取接口通常接收 &str、&[T],这样既能接收拥有者的借用,也能接收子区间。引用与借用给出了规则与编译器诊断。

下例的 split_whitespace().count() 先按空白分词,再统计词数;迭代器的组合用法在集合一节展开。

rust
fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

fn append_mark(text: &mut String) {
    text.push('!');
}

fn main() {
    let mut message = String::from("hello rust");
    let view = &message;
    assert_eq!(word_count(view), 2); // view 的最后一次使用
    append_mark(&mut message);
    assert_eq!(message, "hello rust!");
}

借用通常可以在最后一次使用之后结束,不一定持续到外层 }。旧译文中“必须加大括号才能结束借用”的判断不能直接套到现行代码。反过来,如果把 view 的使用移到 append_mark 后面,共享借用与可变借用发生冲突,应该重新安排读取和修改的顺序。

&mut T 允许修改引用指向的数据;持有这个引用的绑定本身不需要写 mut,除非需要给该绑定重新赋值。需要直接读取或修改所指的值时,使用解引用运算符 *,例如后文的 *score += 1。安全 Rust 还提供 Cell、RefCell 等内部可变性工具;这些工具有各自的约束,不属于初学时绕过借用检查的通用办法。

切片与生命周期 ​

[T] 是切片类型,T 在这里代表元素类型;常用 &[T] 或 &mut [T] 借用连续元素。长度在运行时携带,可变切片允许修改元素,但不能借它给原集合增删元素。数组能借出切片,后文的动态数组 Vec 也可以,数据不因借用而复制。切片章节说明了这种接口。

rust
fn reset_first(values: &mut [i32]) {
    if !values.is_empty() {
        values[0] = 0;
    }
}

fn main() {
    let mut values = [4, 5, 6];
    reset_first(&mut values[..2]);
    assert_eq!(values, [0, 5, 6]);
    reset_first(&mut []); // 空切片也应能安全处理
}

生命周期注解描述引用之间的有效期关系,不会延长数据存活时间。一个输入引用对应一个输出引用时通常可省略;多个输入引用、返回值可能来自其中任意一个时,需要明确约束。

rust
fn longer<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

fn main() {
    let left = String::from("rust");
    let right = String::from("go");
    assert_eq!(longer(&left, &right), "rust");
}

这里返回的借用只能在两个输入借用共同有效的范围内使用,并不要求两个字符串本身同时创建、同时销毁。函数不能返回指向局部 String 的引用;如果结果由函数新建,就返回拥有数据的 String。参见生命周期。

练习:让 word_count 先接收 String,观察调用后继续使用原字符串的错误,再改回 &str。这能验证签名为何重要。

继续阅读