如果你刚学 Rust,肯定会被 “所有权” 这个词搞懵 —— 但别慌,它其实是 Rust 最牛的 “杀手锏”:不用垃圾回收(GC),照样能保证程序不出现内存泄漏、双重释放这些问题。想真正学会 Rust,搞懂所有权就是绕不开的第一步,今天咱们用大白话把它讲透。

一、先记住:所有权的 3 条核心规则

所有权系统看着复杂,其实根基就是 3 条简单规则,记牢这 3 条,后面的内容都好理解:

  1. 每个值都有一个 “主人”(变量):不会存在 “没人管(没有主人/所有者)” 的内存数据;
  2. 同一时间,只能有一个 “主人”:每个值不能同时归两个人 “完全所有”,避免“既归你又归我”的局面,最后数据乱套;
  3. “主人” 离开自己的 “活动范围”(作用域),值就会被自动回收:好比你离开房间时,会把自己的东西收好,不会让它一直占着空间浪费资源。

二、变量的 “活动范围”:作用域与内存管理

先搞懂 “作用域” 是什么 —— 它就是变量 “活着” 的范围,超出这个范围,变量就 “消失” 了,对应的内存也会被回收。咱们分两种情况说:栈上的变量和堆上的变量。

2.1 栈变量:简单数据,自动管理

栈上的变量都是编译时就确定大小的简单数据,比如字符串字面量("hello")、整数这些。它们的内存管理特别简单:

{
    let s = "hello"; // 进入作用域:s“出生”,栈上分配内存
    println!("{}", s); // 能用s,没问题
} // 离开作用域:s“死亡”,栈内存自动释放,不用我们管

就像你在教室借了一支粉笔,用完放回讲台就行,不用额外操作。

2.2 堆变量:复杂数据,需要 “手动” 回收(但 Rust 帮你做了)

堆上的变量是运行时才申请内存的复杂数据,比如用String::from("hello")创建的字符串 —— 它的内容大小不确定,需要先向系统 “申请” 一块堆内存存内容。Rust 在这里做了个贴心设计:当堆变量离开作用域时,会自动调用一个叫drop的方法,把堆内存还给系统,不用我们手动写 “释放代码”:

{
    let s = String::from("hello"); // 堆上申请内存,s是这块内存的“主人”
    println!("{}", s); // 正常使用s
} // 自动调用drop,堆内存被释放,干干净净

这里要注意一个小坑:别给栈变量用drop方法!比如你给let x=5调用drop(x),编译器会警告 “调用 drop 没用”—— 因为栈变量会自动复制和回收,drop根本派不上用场。

三、变量和数据怎么 “互动”?3 种方式讲明白

我们写代码时,经常会把一个变量的值赋给另一个变量,比如let y = x。但在 Rust 里,这不是简单的 “复制”,而是分 3 种情况:复制(Copy)、移动(Move)、克隆(Clone)。

3.1 复制(Copy):简单数据,“复制粘贴” 不影响原变量

对于栈上的简单数据(比如整数、布尔值),赋值时会直接 “复制” 一份新的给目标变量 —— 原变量还在,照样能用。这就像你有一张写着 “5” 的纸条,复制一张给同学,你手里还有原来的,互不影响:

fn main() {
    let x = 5; // 栈上的整数
    let y = x; // 复制x的值给y,相当于“复印”一张纸条
    println!("x = {}, y = {}", x, y); // 能正常输出“x=5, y=5”,x没丢
}

哪些类型能这样 “复制” 呢?记住这些常见的:

  • 所有整数类型(比如i32u64,不管是正数还是负数);
  • 布尔值(truefalse);
  • 字符类型(比如'a''中'这种单个字符);
  • 浮点数类型(带小数点的,比如f32f64);
  • 元组(只要元组里每个元素都能复制,比如(i32, bool)可以,(String, i32)就不行)。

3.2 移动(Move):复杂数据,“所有权转移”,原变量不能用了

对于堆上的复杂数据(比如StringVec),赋值时不会复制堆里的内容,而是把 “所有权” 从原变量转移给新变量 —— 就像你把自己的笔记本完全交给同学,你手里没本了,再想写东西就不行了:

fn main() {
    let s1 = String::from("hello"); // s1是堆内存的“主人”
    let s2 = s1; // 把所有权从s1“转移”给s2,s1变成“空壳”
    // println!("{}", s1); // 这里会报错!编译器说“s1已经被转移了”
    println!("{}", s2); // 没问题,s2现在是新主人
}

为什么要这么设计?核心是避免 “双重释放”:如果 s1 和 s2 都能 “拥有” 同一块堆内存,等它们离开作用域时,会各自调用drop释放同一块内存 —— 这会导致内存混乱,Rust 从根源上禁止了这种情况。

3.3 克隆(Clone):想复制复杂数据?用clone手动 “深拷贝”

如果既想让新变量有数据,又不想让原变量 “失效”,该怎么办?这时候就用clone方法 —— 它会做 “深拷贝”:不仅复制栈上的变量信息,连堆里的实际内容也复制一份,相当于你把笔记本里的内容全抄到新本子上,两个本子各自独立:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 手动克隆,堆里的内容也复制了一份
    println!("s1 = {}, s2 = {}", s1, s2); // 两边都能用,输出正常
}

注意:clone会复制堆数据,比 “移动” 慢,所以不是必要的时候不用它 —— 能移动就移动,能借用就借用,效率更高。

四、函数调用时,所有权也会 “动”

变量传给函数、函数返回值,其实也会发生所有权转移 —— 和咱们上面说的 “移动” 逻辑一样。

4.1 函数参数:传进去,所有权就 “交出去” 了

如果你把一个堆变量传给函数,相当于把 “所有权” 交给了函数里的参数 —— 函数执行完,参数离开作用域,内存就被回收了,你在外面再想用原来的变量就会报错:

fn main() {
    let s = String::from("hello");
    take_ownership(s); // 把s的所有权传给函数里的some_string
    // println!("{}", s); // 报错!s已经不是主人了
}

fn take_ownership(some_string: String) {
    println!("{}", some_string); // 函数里能用,因为它是新主人
} // some_string离开作用域,内存被释放

4.2 函数返回值:返回时,所有权 “交回来”

函数返回一个堆变量时,会把所有权转移给调用它的变量 —— 相当于函数 “造了一个新东西”,交给你当主人:

fn main() {
    // gives_ownership返回的String,所有权转移给s1
    let s1 = gives_ownership(); 
    let s2 = String::from("hello");
    // s2把所有权传给函数,函数再把它转给s3
    let s3 = takes_and_gives_back(s2); 
}

// 函数创建一个String,返回时所有权转移给调用者
fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string // 不用写return,最后一行就是返回值
}

// 接收一个String,再把它返回,所有权从s2→a_string→s3
fn takes_and_gives_back(a_string: String) -> String {
    a_string
}

五、不想转移所有权?试试 “借用”(Borrowing)

有时候我们只是想 “用一下” 变量的值,不想把所有权交出去 —— 比如借同学的笔写个字,用完还回去。Rust 里这种 “借” 的操作叫 “借用”,用&符号表示 “引用”(相当于 “借条”)。

5.1 不可变借用:只能看,不能改

最常见的是 “不可变借用”—— 借过来的东西只能读,不能改。而且可以同时借多个人,只要大家都只看不用:

fn main() {
    let s = String::from("hello");
    let r1 = &s; // 借s的“只读借条”给r1
    let r2 = &s; // 再借一张“只读借条”给r2,没问题
    println!("{} and {}", r1, r2); // 能正常打印,大家都只看
    
    // r1.push_str(", world"); // 报错!只读借条不能改内容
}

函数里也能这么用 —— 传引用给函数,不用转移所有权,函数用完,变量还是你的:

fn main() {
    let s = String::from("hello");
    // 传&s(引用)给函数,不是传所有权
    let len = calculate_length(&s); 
    // 函数用完后,s照样能用,一点不影响
    println!("The length of '{}' is {}.", s, len); 
}

// 参数是&String(字符串引用),不拿所有权
fn calculate_length(s: &String) -> usize {
    s.len() // 只计算长度,不修改
} // s离开作用域,但不用释放内存——它只是个“借条”,不是主人

5.2 可变借用:能改,但规矩多

如果想借过来改内容(比如借同学的笔记本改错题),就得用 “可变借用”—— 但 Rust 给了两个严格规矩,避免出问题:

  1. 同一时间,一个变量只能有一个可变引用;
  2. 可变引用和不可变引用不能同时存在。

先看第一个规矩:同一时间只能有一个可变引用。就像你不能把笔记本同时借给两个同学改 —— 不然一个改这里,一个改那里,最后内容全乱了:

fn main() {
    // 要先把s设为可变的(mut),才能借出去改
    let mut s = String::from("hello");
    let r1 = &mut s; // 借可变引用给r1
    // let r2 = &mut s; // 报错!同一时间只能有一个可变引用
    r1.push_str(", world"); // 能改,没问题
    println!("{}", r1); // 输出“hello, world”
}

再看第二个规矩:可变和不可变引用不能同时存在。比如你借了笔记本给同学改(可变引用),就不能再借另一个同学看(不可变引用)—— 改的过程中内容会变,看的同学可能看到错的内容:

fn main() {
    let mut s = String::from("hello");
    let r1 = &s; // 借不可变引用(只能看)
    let r2 = &s; // 再借一个不可变引用,没问题
    // let r3 = &mut s; // 报错!不能同时有可变和不可变引用
    println!("{} and {}", r1, r2); // 能正常看
}

不过有个小例外:引用的 “作用域” 到最后一次使用就结束了,不是非要等到变量出大括号。比如你在小括号里借可变引用,用完后,外面还能借不可变引用:

fn main() {
    let mut s = String::from("hello");
    {
        let r1 = &mut s; // 小括号里的可变引用
        r1.push_str(", world");
    } // r1的作用域结束,可变引用失效
    let r2 = &s; // 现在能借不可变引用了,没问题
    println!("{}", r2); // 输出“hello, world”
}

5.3 悬垂引用:Rust 不允许 “借一个不存在的东西”

“悬垂引用” 就是:你借的东西已经被回收了,你的 “借条” 还在 —— 比如你借了同学的笔,结果同学已经把笔扔了,你手里的借条就成了 “空头支票”。Rust 在编译时就会阻止这种情况,比如下面的代码直接报错:

// 错误示例:想返回一个“已经被回收的引用”
fn dangle() -> &String {
    let s = String::from("hello"); // s在函数里创建
    &s // 想返回s的引用,但函数结束后s就被回收了
} // s离开作用域,内存释放,返回的引用就成了“悬垂”的

怎么解决?别返回引用,直接返回值本身 —— 把所有权转移给调用者,这样调用者就成了新主人,不会出现 “引用指向空内存” 的问题:

fn no_dangle() -> String {
    let s = String::from("hello");
    s // 返回值本身,所有权转移给调用者
}

六、生命周期(Lifetimes):确保引用 “活” 得比指向的数据短

你可能会问:如果有多个引用,Rust 怎么知道它们能 “活” 多久?这就需要 “生命周期”—— 它描述了引用的 “有效时间”,核心是保证:引用不会比它指向的数据 “活” 得更久

大部分时候,Rust 能自动推断生命周期,不用我们手动写;只有复杂情况才需要标注。

6.1 生命周期标注:用'a这样的符号表示

生命周期标注的语法很简单:用'开头,后面跟小写字母(比如'a'b),表示 “某个生命周期”。它不改变引用的实际存活时间,只是告诉编译器 “这些引用的生命周期要保持一致”。

比如下面的longest函数,接收两个字符串引用,返回较长的那个。我们需要标注:xy和返回值的生命周期都是'a—— 意思是 “返回值的生命周期,不能超过 x 和 y 中活得最短的那个”:

fn main() {
    let s1 = String::from("abc");
    let s2 = String::from("abcde");
    // res的生命周期,和s1、s2中短的那个一致
    let res = longest(&s1, &s2);
    println!("res={}", res); // 输出“abcde”
}

// 标注:x、y、返回值的生命周期都是'a
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

6.2 结构体中的生命周期:让结构体和引用 “同生共死”

如果结构体里存了一个引用,就必须标注这个引用的生命周期 —— 保证结构体实例 “活” 着的时候,它里面的引用也一定 “活” 着:

// 标注:part的生命周期和结构体实例一致,都是'a
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    // first_sentence是novel的切片(引用)
    let first_sentence = novel.split('.').next().expect("没找到'.'");
    // 创建结构体实例,part用first_sentence
    // 实例i的生命周期,不会超过novel(因为first_sentence依赖novel)
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

6.3 生命周期省略规则:编译器帮你省力气

大部分时候,我们不用手动标注生命周期 ——Rust 有 3 条 “省略规则”,能自动推断:

规则 1:每个引用参数,都有自己的独立生命周期

如果函数有多个引用参数,编译器会给每个参数分配不同的生命周期。比如下面的print_both函数,编译器会自动给a分配'a,给b分配'b

// 我们写的代码:没标注生命周期
fn print_both(a: &str, b: &str) {
    println!("{} and {}", a, b);
}

// 编译器推断后的代码:自动加了'a和'b
fn print_both<'a, 'b>(a: &'a str, b: &'b str) {
    println!("{} and {}", a, b);
}

因为这个函数只是打印,不用关心ab谁活多久,所以各自独立就行。

规则 2:只有一个引用参数,返回值的生命周期和它一致

如果函数只有一个引用参数,返回值的生命周期就和这个参数一样。比如first_word函数,返回字符串的第一个单词,编译器会自动让返回值的生命周期和s一致:

// 我们写的代码:没标注
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

// 编译器推断后的代码:返回值用了s的生命周期'a
fn first_word<'a>(s: &'a str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}
规则 3:方法里的&self,生命周期会传给返回值

在结构体的方法里(比如impl块里),如果方法用了&self(借结构体自己),返回值的生命周期就和self一致。比如下面的get_first_part方法,返回的切片生命周期和Text实例一样:

struct Text<'a> {
    content: &'a str,
}

impl<'a> Text<'a> {
    // 我们写的代码:没标注返回值生命周期
    fn get_first_part(&self) -> &str {
        self.content.split('.').next().unwrap_or("")
    }
}

// 编译器推断后的代码:返回值用了self的生命周期'a
impl<'a> Text<'a> {
    fn get_first_part(&'a self) -> &'a str {
        self.content.split('.').next().unwrap_or("")
    }
}

这 3 条规则覆盖了 90% 以上的场景,只有当编译器 “搞不清楚” 的时候(比如多个参数,返回值可能来自任意一个),才需要我们手动标注。

七、切片(Slices):不占所有权,只取 “一部分”

最后咱们说个和所有权、借用密切相关的概念:切片。切片就是 “取集合里连续的一部分”,比如从字符串里取前 5 个字符,从数组里取中间 3 个元素 —— 而且切片是引用,不占所有权,特别适合共享部分数据。

7.1 字符串切片:&str类型,取字符串的一部分

字符串切片的类型是&str,用法是&变量[起始索引..结束索引]—— 注意:结束索引是 “不含” 的,比如[0..5]是取索引 0 到 4 的字符:

fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5]; // 取0-4的字符,就是“hello”
    let world = &s[6..11]; // 取6-10的字符,就是“world”
    println!("{} {}", hello, world); // 输出“hello world”
}

还有几个简化写法,记不住全索引也没关系:

  • &s[..n] = &s[0..n]:从开头取到 n(不含 n);
  • &s[n..] = &s[n..s.len()]:从 n 取到结尾;
  • &s[..] = &s[0..s.len()]:取整个字符串。

比如上面的代码,hello可以写成&s[..5]world可以写成&s[6..],效果一样。

7.2 其他切片:数组、Vec 也能切

不止字符串,数组、Vec 这些集合也能创建切片。比如数组切片的类型是&[元素类型],用法和字符串切片一样:

fn main() {
    let a = [1, 2, 3, 4, 5]; // 数组
    let slice = &a[1..3]; // 取索引1和2的元素,就是[2,3]
    // 断言:切片和&[2,3]相等,没问题
    assert_eq!(slice, &[2, 3]); 
}

切片的好处是:不用复制数据,只是引用原集合的一部分,既高效又安全 —— 因为切片的生命周期和原集合绑定,原集合没了,切片也不能用了。

八、总结:所有权系统到底好在哪?

看到这里,你可能觉得所有权系统有点 “麻烦”—— 又是规则又是限制,但这些限制正是 Rust 的优势:

  1. 不用 GC,性能更高:内存自动回收,不用像 Java 那样等 GC “打扫”,程序运行更流畅;
  2. 从编译时杜绝内存 bug:双重释放、悬垂引用这些问题,编译器直接拦住,不用等到运行时才崩溃;
  3. 安全和共享兼顾:借用机制让数据能安全共享,可变 / 不可变规则避免多线程下的数据竞争。

虽然刚开始学的时候会觉得 “束手束脚”,但熟悉之后,你会发现:这些规则其实是在帮你写更稳健的代码 —— 这就是 Rust 能在系统编程、嵌入式、WebAssembly 这些领域脱颖而出的原因。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐