概述

  • 参考文档:The Rust Programming Language
  • Rust不用垃圾回收也不手动释放内存:所有权系统在编译期决定每块内存何时释放,没有运行期开销
  • 与其他语言的对比:java靠GC运行期追踪并回收,C/C++靠程序员手动malloc/free,Rust靠编译器检查一组所有权规则,违反规则直接编译不通过
  • 一次赋值或传参中,值要么移动、要么被借用、要么被克隆:
flowchart LR
  A["值绑定到所有者"] -->|"赋值或传参"| B["所有权移动(move)"]
  A -->|"&T 或 &mut T"| C["借用(borrow)"]
  A -->|".clone()"| D["深拷贝(clone)"]
  B --> E["所有者离开作用域"]
  E -->|"自动 drop"| F["内存释放"]
  C --> A
  D --> A

栈与堆

  • 栈(stack):后进先出,只存放编译期已知大小的数据,分配释放只是移动栈指针,速度最快
  • 堆(heap):存放编译期未知大小或可能增长的数据,分配需要查找空闲块并记账,访问要多一次指针跳转
  • String是典型布局:栈上放三元组(指针, 长度, 容量)指向堆上的字节,drop时释放堆内存

所有权规则

  • 每个值有唯一的所有者(某个变量)
  • 同一时刻值只能属于一个所有者
  • 所有者离开作用域时值被丢弃:编译器自动插入对drop的调用

移动语义

  • 堆上值的赋值与传参默认是移动(move)而非复制:原变量交出所有权后立即失效,编译器禁止再使用它

    rust
    let s1 = String::from("hello");
    let s2 = s1;         // 所有权从 s1 移动到 s2
    println!("{}", s1);  // 编译错误:s1 已经失效
    
  • 之所以这样设计:若按java的思路让两个引用指向同一块堆内存,两个变量离开作用域时会对同一块内存释放两次(double free),Rust用移动在编译期排除这个可能

  • Copy类型例外:整型、浮点、bool、char以及全部由Copy类型组成的元组,赋值时自动按位复制,旧变量仍然可用

  • Clone显式深拷贝:对堆上数据想保留原变量时,调用.clone()复制出独立的一份,拷贝发生的位置在调用处直接可见

    rust
    let s1 = String::from("hello");
    let s2 = s1.clone(); // s1 与 s2 各持一份堆内存
    
  • 函数传参与返回同样发生移动:值作为参数传入即交出所有权,返回时再移交给调用者

    rust
    fn take(s: String) { } // s 在函数结束时被 drop
    

借用与引用

  • 传值会交出所有权,函数用完还得还回来太繁琐,引用(reference)允许访问值而不获取所有权,创建引用称为借用(borrowing)

    rust
    fn len(s: &String) -> usize { s.len() } // 借用 s,函数结束后原变量仍可用
    
  • &T共享引用:只读,可以同时存在任意多个

  • &mut T独占引用:可写,同一时刻最多一个,且与共享引用互斥,被借用的变量本身必须是mut

    rust
    let mut s = String::from("hi");
    let r1 = &s;     // 共享引用
    let r2 = &mut s; // 编译错误:r1 仍存活时不能有可变引用
    
  • 这条规则在编译期排除了数据竞争(data race)与迭代器失效:一边读一边改的场景根本无法通过编译

  • 非词法作用域生命周期(NLL):引用的存活范围到最后一次使用为止,而不是作用域结束,先后分别使用多个引用是合法的

    rust
    let mut s = String::from("hi");
    let r1 = &s;
    println!("{}", r1); // r1 的最后一次使用
    let r2 = &mut s;    // 合法:r1 已经不再存活
    
  • 悬垂引用(dangling reference)被编译期拒绝:函数不能返回指向局部变量的引用,返回值的生命周期必须由入参或'static保证

切片

  • 切片(slice)是对集合中连续一段的引用,不获取所有权,类型记作&[T]与&str

  • &str:字符串切片,指向一段UTF-8字节并携带长度,字符串字面量的类型是&'static str

    rust
    let s = String::from("hello world");
    let hello = &s[0..5]; // &str,指向 s 的一段
    

生命周期

  • 生命周期的唯一任务:保证引用不会比被引用的值活得久,全部在编译期检查,运行期零开销

  • 绝大多数函数不需要标注:编译器的省略规则(elision)会自动推断

  • 当返回值是引用且编译器无法判断它借用自哪个入参时,需要用'a显式标明"返回值与哪个入参活得一样久"

    rust
    fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
        if x.len() > y.len() { x } else { y }
    }
  • 'a是一个生命周期参数,名字随意('b、'ctx都行),在<>里声明,写法与泛型类型参数<T>一致

    • &'a str中的'a表示这个引用至少在'a这段期限内有效,'a的实际范围由编译器在调用处代入
    • 同一个'a出现在两个入参与返回值上,把三者绑成一条约束:返回值的存活范围不能超过两个入参中较短的那个
    • 约束在调用点检查,而不是在函数体内:编译器看实参实际能活多久,代入'a后验证返回值的使用范围放得下
  • 为什么函数体自身不够用:签名是函数对外的契约,编译器检查别的代码调用它时只看签名不看函数体,返回值借用自x还是y这件事必须写进签名

  • 违反约束时的报错示例:

    rust
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("hi");
        result = longest(&s1, &s2);
    } // 编译错误:s2 在这里结束,而 result 还要用返回的引用
    println!("{}", result);
    • 报错信息为borrowed value does not live long enough,意思是被借用的值死得比引用早
  • 'a不改变任何引用的实际存活时间,也不产生任何运行期代码,只描述约束供编译器验证

  • 两个入参的存活范围确实不同时,可以声明多个参数,返回值只跟它真正借用的那个绑定

    rust
    fn announce<'a, 'b>(text: &'a str, logo: &'b str) -> &'a str {
        println!("{}", logo);
        text
    }
  • 结构体持有引用时同样要标注:'a写进结构体名后面,声明"结构体实例活多久,这个引用就至少得活多久"

    rust
    struct Excerpt<'a> {
        part: &'a str,
    }
    • impl块要原样带上:impl<'a> Excerpt<'a> { ... }
  • &'static str表示引用在整个程序运行期间有效:字符串字面量直接编进二进制,所以它的类型是&'static str

  • 实际开发的观感:手写'a集中在结构体持有引用与返回引用的函数两类场景,其余交给省略规则,遇到报错时按"谁活得太短"排查