Rust 所有权系统

本文介绍了 Rust 编程语言中的所有权概念,探讨了内存管理模型以及堆和栈的作用。我们详细解释了什么是所有权,以及它在编程中的重要作用。通过简单代码示例介绍所有权的规则和转移方式,能够帮助读者更好的理解如何正确管理数据的所有权。此外,我们还重新梳理了数据类型,并提供了一些常见问题的汇总,为读者提供全面的了解和回答疑惑的机会。通过阅读本文,读者将能够对 Rust 中的所有权有一个清晰的认识,并在编程实践中正确应用这一概念。

内存管理模型

不同的编程语言都有着自己的内存管理机制。

以 C 语言为例,它使用 malloc 和 free 来手动管理内存。对于高级程序员来说,这是一种具有无限可能性的技术,但对于大多数普通开发者而言,却是一个容易导致 Bug 的机制。

还有很多应用级编程语言,如 Python、Go、Java 等,都采用了垃圾回收(Garbage Collection)技术来自动管理内存,开发者只需申请内存而无需手动释放,垃圾回收器会自动检测不再使用的内存并进行释放。然而,由于垃圾回收机制的存在,导致程序性能天生下降,同时也带来了运行时的不确定性。任何的 GC 语言几乎都不可能用来编写底层程序,例如操作系统和硬件驱动。

在现实生活中,当存在两种合理但不同的方法时,我们应该探索将两者结合起来的可能性,以找到一种更优的解决方案。这种结合被称为混合(Hybrid)。举个例子,为什么只喝瑞幸咖啡或贵州茅台呢?我们可以将它们结合在一起,创造出美味的酱香拿铁。

Rust 采用了一种中间方案,即资源获取即初始化(RAII)。它既具备垃圾回收的易用性和安全性,又能保持极高的性能。

栈和堆

栈(stack)是一种后进先出(LIFO, Last-In-First-Out)的数据结构。而堆(heap)则是一种用于动态分配内存的内存区域,没有特定的顺序。

在堆中分配的内存可以被随机访问,没有像栈那样严格的先进先出规则。当我们在堆上分配内存时,操作系统会根据请求的大小为我们提供一块连续的内存空间,并返回一个指向该内存的指针。因此,我们可以根据需要在任何时间访问堆中的内存,而不需要按照特定的顺序进行操作。

堆内存的管理通常由编程语言的运行时系统或者手动编写的代码负责。在使用完堆上的内存后,我们需要手动释放它,以便让操作系统重新回收这部分内存。

Stack

Stack (LIFO) 后进先出

www.kirillvasiltsov.com/writing/how...

web.mit.edu/rust-lang_v...

练习一下 Stack 模型

It's called a 'stack' because it works like a stack of dinner plates: the first plate you put down is the last plate to pick back up. Stacks are sometimes called 'last in, first out queues' for this reason, as the last value you put on the stack is the first one you retrieve from it.

rust 复制代码
fn bar() {
    let i = 6;
}

fn foo() {
    let a = 5;
    let b = 100;
    let c = 1;

    bar();
}

fn main() {
    let x = 42;

    foo();
}

让我们详细分析一下这段代码中栈帧(stack frame)的生命周期情况:

  1. main() 函数被调用时,一个新的栈帧被创建并推入栈中。栈帧包含了 x 变量,并位于栈的顶部。

  2. foo() 函数内部,三个新变量 abc 被定义并分配到栈帧中。它们依次被添加到栈的顶部。

  3. bar() 函数被调用时,另一个新的栈帧被创建并推入栈中。栈帧包含了 i 变量,并位于栈的顶部。

  4. bar() 函数执行完毕后,栈顶的栈帧(对应 bar() 的栈帧)会被从栈中移除,因为函数已经返回。

  5. 回到 foo() 函数,在 bar() 函数调用之后,该栈帧继续存在。foo() 函数执行完毕后,它的栈帧会被从栈中移除。

  6. 最后回到 main() 函数,当 foo() 函数调用结束后,该栈帧也会被移除。

  7. main() 函数执行完毕后,整个程序的栈帧都会被清空,栈被释放,生命周期结束。

Tips:值得注意的是,在 foo() 函数执行完毕后,包括 abc 变量的栈帧会一起从栈上移除。这是因为栈的机制是基于函数调用的,当函数结束时,对应的栈帧就会被删除,而不是逐个删除栈帧内的变量。所以,无论是 abc 的定义顺序如何,它们都会在 foo() 函数结束时一起消失。

Heap

In Rust, you can allocate memory on the heap with the Box type. Here's an example:

rust 复制代码
fn main() {
    let x = Box::new(5);
    let y = 42;
    let z = "hello world";
}

Here's what happens in memory when main() is called. The memory now looks like this:

Address Name Value
(2^30) -1 5
(2^30) -2
(2^30) -3
(2^30) -4 "hello world"
... ... ...
2 z → (2^30) -4
1 y 42
0 x → (2^30) -1

这个表格说明了在堆和栈上分配的变量和数据的存储方式。堆上存储的数据可以通过指针进行访问,而栈上存储的数据则直接存储在相应的地址上。栈类似于叠盘子,后进先出(LIFO),而堆则更像一盘散沙,没有特定的顺序。

什么是所有权

所有权是 Rust 特有的机制,也是 Rust 这门编程语言的核心概念,它是语言层面上提供的解决方案,让程序员不必过多关注对 堆内存(Heap) 的管理,Rust 最引以为豪的内存安全正是建立在所有权之上的!

所有权是 Rust 中用于 管理堆内存的机制,所有权系统使 Rust 能够在编译时检查内存访问错误。通过这种方式,Rust 能够确保内存分配的安全性和有效性,同时避免了由于程序员手动管理内存而可能出现的错误,如内存泄漏、野指针和双重释放等。

在 Rust 中,每一个值都有一个所有者,即拥有该值的变量。当所有者超出作用域时,该值将被自动释放。这种自动释放的方式称为 "drop"。它确保在程序运行时,每个堆上的值都有唯一的所有者,并在编译时检查所有权的转移,从而避免了内存安全问题。

所有权的作用

在许多编程语言中,程序员不必经常关心数据存储在栈还是堆中。但是,在像 Rust 这样的系统级编程语言中,数据存储在栈还是堆上对语言的行为产生了重大影响,并且必须在这两者之间做出选择。

所有权系统的目的是追踪哪些代码正在使用堆上的哪些数据,最大限度地减少堆上重复数据的数量,并清理不再使用的数据以确保不会耗尽空间。一旦理解了所有权,程序员就不需要经常考虑栈和堆了,但是理解所有权系统可以帮助解释为什么必须以这种方式管理堆数据。

所有权与堆内存

在 Rust 中,栈和堆的使用方式不同,而且在语言层面上提供了不同的机制来管理它们。

栈的使用是由编译器自动管理的,通常用来存储固定大小的数据。每当一个变量被声明时,编译器就会为它在栈上分配一块空间,当它超出作用域后,编译器就会自动释放这块空间。这种自动管理的机制使得栈的使用非常高效,而且不容易出错。

堆的使用则需要手动管理,通常用来存储动态大小的数据,比如字符串、向量等。在 C/C++ 中,手动管理堆的内存很容易出错,比如内存泄漏、野指针等问题。但是在 Rust 中,通过所有权系统和智能指针,可以避免这些问题。所有权系统确保每个值只有一个所有者,从而确保了内存安全性;而智能指针则可以根据需要自动管理内存的生命周期,从而减少手动管理的负担。

因此,Rust 的特性不仅包括所有权,还包括借用、生命周期、模式匹配、函数式编程等。这些特性使得 Rust 成为一门高效、安全、灵活的系统编程语言。

所有权规则

我们需要时刻去思考,关于 Rust 这个变量,它的所有权是谁,它的所有权被传递到了哪里。它是如何产生的,它是如何结束的。

所有权是 Rust 语言中的一个重要概念,用于管理内存的分配和释放。以下是 Rust 中的所有权规则:

  1. Rust 中的每一个值都有一个被称为其所有者(owner)的变量。
  2. 在任意时刻,一个值有且只有一个有效的所有者。
  3. 当拥有某个值的所有者超出其作用域时,该值将被释放。

这些规则确保了内存的安全和有效的资源管理。

示例代码

让我们通过一个简单的代码示例来解释下 Rust 中所有权的三条规则:

rust 复制代码
fn main() {
    let s = String::from("Hello"); // 创建一个新的字符串

    {
        let s2 = s;                // 所有权转移,s2成为新的所有者
        println!("{}", s2);        // 可以正常访问和使用s2
    }                              // s2 的作用域结束,其所拥有的值将被释放

    println!("{}", s);             // 此处尝试访问已经被释放的值,会导致编译错误!

}                                  // s 的作用域结束,其所拥有的值也将被释放

在上述代码中,我们创建了一个 String 类型的变量 s,它是一个拥有 Hello 字符串的所有者。根据第一条所有权规则,每个值都有一个唯一的所有者,因此 s 是该字符串的唯一所有者。

然后,在新的作用域内,我们将 s 的所有权转移到了变量 s2。这符合第二条所有权规则,即一个值只能有一个有效的所有者。现在,s2 成为了该字符串的所有者,并且可以在该作用域内正常访问和使用它。

s2 的作用域结束时,按照第三条所有权规则,其所拥有的值将被释放。这意味着内存将被回收,并且无法再访问或使用该值。

如果我们尝试在 println!("{}", s); 处访问已经被释放的值,由于 s 的所有权已经转移给了 s2,编译器会报错,因为该值已不存在。

通过这个例子,我们可以看到 Rust 中的所有权规则如何管理对值的拥有和释放,确保内存安全和避免悬空引用。

所有权转移

在 Rust 中,所有权的转移是通过移动借用引用来实现的。

  • 移动(Move):当将一个值赋予另一个变量或传递给函数时,所有权会从一个所有者转移到另一个所有者。原始所有者将不再有效地拥有该值。
  • 借用(Borrow):可以通过借用将值的临时访问权(共享访问)授予其他变量或函数,而不转移所有权。借用可以是可变(mutable)或不可变(immutable)的。
  • 引用(Reference):通过引用,我们可以创建对值的非拥有性访问权,并允许多个引用同时存在。引用与借用类似,但有更灵活的作用域和生命周期。

使用所有权规则,Rust 能够在编译时检测并防止常见的内存错误,如空指针、数据竞争和悬垂引用。这使得 Rust 成为一个安全且高效的系统级编程语言。

示例代码

移动(Move)示例:

由于所有权转移,代码编译失败。

rust 复制代码
fn foo(s: String) {
    println!("{}", s);             // 打印传入的字符串变量s的值
}

fn main() {
    let s = String::from("hello"); // 创建一个拥有所有权的String对象并将其绑定到变量s
    foo(s);                        // 将变量s作为参数传递给函数foo,发生所有权的转移
    println!("{}", s);             // 尝试打印s,但由于s的所有权已经在调用foo函数时转移,所以会导致编译错误,"value borrowed here after move"
}

借用(Borrow)示例:

通过借用的方式修复了所有权转移引起的问题。

rust 复制代码
fn foo(s: &String) {
    println!("{}", s);             // 打印传入的字符串引用s的值
}

fn main() {
    let s = String::from("hello"); // 创建一个拥有所有权的String对象并将其绑定到变量s
    foo(&s);                       // 将s的引用作为参数传递给函数foo
    println!("{}", s);             // 可以正常打印s,因为只是借用了s的引用,没有发生所有权转移
}

引用(Reference)示例:

rust 复制代码
fn foo(s: &str) {
    println!("{}", s);
}

fn main() {
    let s = "hello";               // 这里的&str是一种静态字符串切片,它是对存在于程序的常量区的字符串的不可变引用或常量引用。
    foo(s);
    println!("{}", s);
}

名词解释

text 复制代码
【借用和引用(borrowing & reference)】
我们可以将借用视为一种特殊类型的引用。
在 Rust 中,"引用" 是一个通用的概念,表示对某个值的别名或指向。它包括可变引用和不可变引用,用于共享数据的访问权限。
而 "借用" 则是使用引用来共享数据的一种特定模式。它遵循 Rust 的借用规则(Borrowing Rules),以确保安全地访问和操作数据,同时避免数据竞争。
因此,可以说借用是引用的一个子集,是引用的一种特殊形式,用于描述通过引用共享数据并遵守借用规则的行为。
text 复制代码
【内存泄漏(memory leak)】
内存泄漏是指程序中分配的内存没有被正确释放,导致系统中出现无法访问的空闲内存。这种空闲内存可能会被其他程序占用,导致内存的浪费和程序的运行不稳定。
内存泄漏通常是由程序员错误使用内存分配和释放函数造成的,比如忘记释放内存、释放了错误的内存地址等。
text 复制代码
【野指针(dangling pointer)】
野指针是指一个指针,它指向已经被释放或者未分配的内存空间。这样的指针会导致程序崩溃或不可预期的行为,因为访问这样的内存地址可能会导致访问到其他程序正在使用的内存空间,或者操作系统不允许访问的内存空间。
野指针通常是由于程序员错误地使用了已经释放的内存地址或者没有初始化的指针变量,或者在变量的生命周期已经结束后仍然使用了指向该变量的指针。
因此,在编写程序时应该避免野指针的出现,可以通过一些技术手段来避免,如合理使用内存分配和释放函数,使用空指针或者空引用来代替未初始化的指针,以及在指针的使用过程中进行有效性检查等。
text 复制代码
【双重释放(double free)】
双重释放指的是在程序中释放同一个内存地址两次或以上。这通常是由于程序中出现了一些逻辑错误,例如多次调用了free()函数,或者对已经释放的指针进行了第二次释放。
双重释放会导致程序崩溃或者产生其他不可预知的结果,因为这可能会破坏内存管理系统的数据结构,例如堆的空闲列表或内存池等。此外,双重释放还可能导致安全漏洞,例如内存泄漏或者缓冲区溢出等。
为了避免双重释放,程序员需要在使用free()函数释放内存之前,确保该内存没有被释放过。通常,这可以通过对指针进行检查来实现,例如将指针设置为NULL,这样如果程序再次尝试释放这个指针,就会产生运行时错误。此外,使用一些内存安全的编程工具,例如智能指针、垃圾回收器等,也可以避免双重释放的问题。

重新梳理类型

当我们回顾了堆和栈的概念,深入了解了 Rust 的所有权系统,并学习了大部分的数据类型后,让我们再次回顾整理一下所学内容。

数据类型分类

在 Rust 中,我们可以将类型分为两个主要类别:基本类型(Primitive types)和复合类型(Compound types)。它们在内存中的分配方式和所有权机制上有所不同。

基本类型(Primitive types)

基本类型是 Rust 内建的简单数据类型,它们通常占用固定大小的空间,并按值进行复制。这些类型都是分配在栈上的。以下是一些常见的基本类型:

  • 整数类型(Integer types)
  • 浮点数类型(Floating-point types)
  • 布尔类型(Boolean type)
  • 字符类型(Character type)

由于基本类型是按值复制的,因此它们没有所有权的概念。

复合类型(Compound types)

复合类型由多个值组合而成,可以是拥有所有权的类型。这些类型通常具有动态大小,并且在堆上分配内存。以下是一些常见的复合类型:

  • 字符串类型(String type)
  • 数组类型(Array type)
  • 元组类型(Tuple type)
  • 结构体类型(Struct type)
  • 枚举类型(Enum type)
  • 函数类型(Function type)

在复合类型中,最常见的拥有所有权的类型是字符串类型 String。它使用堆内存来存储和管理动态长度的字符串数据。另外一些复合类型也可能涉及到所有权的转移或借用。

与此相反,&str 类型是一个字符串切片,它是对其他地方存储的字符串数据的引用。&str 自身存储在栈上,但它所引用的字符串数据实际上可能存储在常量区或堆上。

需要注意的是,并非所有分配在堆上的类型都具有所有权。例如,数组类型、元组类型和结构体类型可以在栈上或堆上分配,具体取决于它们的大小和生命周期。

总之,基本类型通常在栈上分配,并按值复制。复合类型可以包含拥有所有权的类型,并且通常在堆上分配,但并不是所有在堆上分配的类型都具有所有权。

常见问题汇总

问题一:字符串字面量和 String 类型有什么不同?

在 Rust 中,字符串有两种常见的表示方式:字符串字面量和 String 类型。下面详细解释一下它们之间的不同之处。

  1. 字符串字面量(String Literal) :在 Rust 中,字符串字面量是由双引号包围的文本,例如 "hello world"。这些字符串字面量是静态分配的,存储在程序的常量区(数据段)中。它们的长度是在编译时确定的,并且不可更改。当你创建一个字符串字面量时,编译器会为其分配内存,并将其视为 &str 类型的不可变引用。因此,let s = "hello world"; 创建的是一个指向静态数据区的 &str 引用。

    rust 复制代码
    fn main() {
        let s1 = "hello world"; // 创建一个字符串字面量,并将其绑定到变量s1
        let s2 = s1;            // 将s1赋值给s2,由于字符串字面量是不可变的,复制操作是通过对指针的复制来完成的
        println!("{:?}", s1);   // 此处打印s1,因为它是不可变的字符串字面量,所以仍然可以正常访问和打印
    }
  2. String 类型String 是一个动态分配的、可变长度的字符串类型,可以通过 String::fromto_string 方法来创建。与字符串字面量不同,String 类型存储在堆上,而不是常量区。它具有动态可变性,可以根据需要增长或缩小。创建 String 对象时,会在堆上分配足够的内存来存储字符串数据,并返回一个拥有所有权的 String 对象。

    rust 复制代码
    fn main() {
        let s1 = String::from("hello world"); // 创建一个动态分配的字符串,并将其所有权绑定到变量s1
        let s2 = s1;                          // 将s1的所有权转移给s2,s1将不再有效
        println!("{:?}", s1);                 // 此处尝试打印s1,但由于所有权已被转移,s1将不再有效,因此会发生编译错误
    }

总结起来,字符串字面量是静态分配的、不可变的,在常量区存储,并作为 &str 类型的不可变引用。而 String 类型是动态分配的、可变的,在堆上存储,并具有所有权。

这两种字符串表示方式在使用和操作上有一些不同,例如对于字符串的修改、拼接、传递等。根据具体需求和场景,选择合适的字符串类型可以使代码更加灵活和高效。

问题二:哪些类型可以在堆上分配内存,但并不具有所有权?

  1. 引用类型(References):包括借用引用 &T 和可变引用 &mut T。引用是对其他数据的非所有权引用,它们本身不负责内存的分配和释放。
  2. 字符串切片(&str):&str 是对其他地方存储的字符串数据的引用,它也不拥有该字符串数据的所有权。
  3. 动态数组切片(Vec<T>):Vec<T> 是一个动态分配的数组,它在堆上分配了一块连续的内存来存储元素。尽管 Vec<T> 拥有对这块内存的所有权,但它本身并不是一个拥有所有权的类型。相反,它通过引用或切片向外部提供对堆上数据的访问。

需要注意的是,虽然这些类型没有直接的所有权,但它们可以通过传递引用或切片来共享对堆上数据的访问权限。这种方式使得数据能够被多个地方同时引用而不需要进行拷贝,从而提高了效率。

问题三:为什么有的复合类型可以在栈上或堆上分配呢?

数组类型、元组类型和结构体类型在 Rust 中可以在栈上或堆上分配内存,具体取决于它们的大小和生命周期。

  1. 栈上分配:对于较小且大小在编译时可确定的数据结构,Rust 倾向于在栈上进行分配。这些数据结构包括固定长度的数组、元组(当元素数量和类型都是已知的)以及小型结构体。在栈上分配内存速度更快,不需要手动释放内存,也不会有堆上内存分配的开销。当这些数据结构超出作用域时,它们会自动被弹出栈并释放内存。

    rust 复制代码
    fn main() {
        let v1 = ('a', true, 1, "hello");  // 栈上分配!
        let v2 = v1;
        println!("{:?}", v1); // 由于元组的各成员值都是不可变的且具备 Copy trait(对于基本类型),或者是引用类型(不涉及所有权转移),因此栈上的数据可以被多个变量共享,不涉及借用或所有权转移等行为,程序能够正常运行。
    }
  2. 堆上分配 :对于较大或者在编译时大小未知的数据结构,Rust 通常在堆上分配内存。这样做的原因是堆上的内存空间更灵活,可以动态地增长或缩小。堆上分配需要手动管理内存,即需要调用 Box::new 或者 Vec::new() 等方法来创建一个指向堆上数据的指针,并在使用完后手动释放内存。使用堆上分配的数据结构可以跨越多个作用域存在。

    rust 复制代码
    fn main() {
        let v1 = ('a', true, Box::new(1), String::from("hello")); // 堆上分配!
        let v2 = v1;
        println!("{:?}", v1); // 由于元组的成员值中包含了堆上分配并且拥有所有权的对象,进行元组整体赋值给其他变量时会发生所有权的转移行为。根据 Rust 的所有权规则,一个对象的所有权只能属于一个变量,因此在转移所有权后,原来的变量将无法再访问这些堆上数据,并会导致编译错误("value borrowed here after move")。
    }

关于如何决定存储位置,Rust 编译器会根据数据结构的大小和生命周期来作出决策。较小且大小已知的数据结构通常在栈上分配,而较大或者大小未知的数据结构则在堆上分配。

这种灵活性使得 Rust 在内存管理方面能够更高效地处理不同类型的数据结构,同时保证安全性和性能。

问题四:切片类型到底是在栈上还是在堆上分配?

在 Rust 中,切片(Slice)数据结构实际上是一个指向已分配内存的引用,因此它本身并不存储数据。切片可以存在于栈上,但指向的数据通常是在堆上分配的。

具体来说,切片包含两个部分:指针和长度。指针指向切片引用的数据的起始位置,而长度表示切片引用的数据的大小。由于切片只是对底层数据的引用,并不拥有所有权,所以它的大小是固定的,并且在编译时就可以确定。

切片本身在栈上分配,并且可以轻松地复制和传递。但是,它所引用的数据通常是在堆上分配的,例如通过 StringVec<T> 动态分配的数据。这些动态分配的数据在堆上分配,并且被切片引用。

简而言之,切片本身是在栈上分配的,但它们通常引用在堆上分配的数据。

相关推荐
coderWangbuer1 小时前
基于springboot的高校招生系统(含源码+sql+视频导入教程+文档+PPT)
spring boot·后端·sql
攸攸太上1 小时前
JMeter学习
java·后端·学习·jmeter·微服务
Kenny.志1 小时前
2、Spring Boot 3.x 集成 Feign
java·spring boot·后端
sky丶Mamba1 小时前
Spring Boot中获取application.yml中属性的几种方式
java·spring boot·后端
千里码aicood2 小时前
【2025】springboot教学评价管理系统(源码+文档+调试+答疑)
java·spring boot·后端·教学管理系统
QMCY_jason2 小时前
Ubuntu 安装RUST
linux·ubuntu·rust
程序员-珍3 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
liuxin334455663 小时前
教育技术革新:SpringBoot在线教育系统开发
数据库·spring boot·后端
数字扫地僧3 小时前
HBase与Hive、Spark的集成应用案例
后端
架构师吕师傅3 小时前
性能优化实战(三):缓存为王-面向缓存的设计
后端·微服务·架构