Skip to content

Rust 内存管理方式

  1. 既不像C语言那样手动分配手动释放
  2. 也不想GO/Java/Python/JS那样有垃圾回收机制

通过所有权系统管理内存, 编译器在编译时会根据一系列的规则进行检查

如果违反了任何这些规则, 程序都不能编译, 在运行时, 所有权系统的任何功能都不会减慢程序

简而言之, Rust 选择让程序员在写代码时候就遵循一定的规则, 然后让编译器来检查, 如果违法这些规则就无法通过编译, 这就导致, 所有代码都必须按照编译器的规则来写, 从而实现了, 类似垃圾回收的效果(但并不是真正的GC), 但是性能却高于有GC的语言

什么是所有权

所有权(ownership) 是 Rust 用于如何管理内存的一组规则

实例化一个类型并且将其绑定到变量名上将会创建一些内存资源 而这些内存资源将会在其整个生命周期中被 Rust 编译器检验, 被绑定的变量即为该资源的所有者

rust
fn main() {
  let x = 10; // 10 这个数据的所有权就是 x 变量
}

所有权规则

  1. Rust 中每一个值都有一个所有者 owner
  2. 值在任何时候都只有一个所有者
  3. 当所有者(变量)离开作用域, 这个值就会被丢弃(内存被释放)

作用域

rust
fn main() {
    let a = 10;
    { // {} 表达式可以创建单独的作用域

        // println!("b={b}");
        // 在变量声明之前,是无法访问的
        let b = a + 10;
        println!("inner: {b}");
    };

    // 此处(超出作用域范围)是无法访问到 b 这个变量的
    // 随着 {} 表达式执行完, 它也会被销毁
    // println!("outer: {b}");

    println!("outer: {a}");
}

String 类型了解

创建一个String结构体实例(也就是字符串), 为了方便理解, 我会用C语言实现一个 String 结构体

rust
fn main() {
    // String::from 返回一个字符串对象(String 结构体的实例)
    let mut str = String::from("hello");
    str.push_str(" world");

    println!("s1 is: {str}");
}
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 定义一个结构体来表示字符串
typedef struct {
  char *data;      // 存储字符串的实际内容
  size_t capacity; // 字符串的最大容量
  size_t length;   // 当前字符串的长度
} String;

// 初始化一个新的空字符串
String *string_new(void) {
  String *str = (String *)malloc(sizeof(String));
  if (str == NULL) {
    fprintf(stderr, "Failed to allocate memory\n");
    return NULL;
  }

  str->data = NULL;
  str->capacity = 0;
  str->length = 0;
  return str;
}

// 从一个字符数组创建一个新的字符串对象(结构体实例)
String *string_from(const char *source) {
  size_t source_len = strlen(source);
  String *str = string_new();
  if (str == NULL) {
    return NULL;
  }
  str->capacity = source_len + 1; // 加上 \0 字符的位置
  str->data = (char *)malloc(str->capacity);
  if (str->data == NULL) {
    fprintf(stderr, "Failed to allocate memory\n");
    free(str);
    return NULL;
  }

  strcpy(str->data, source);
  str->length = source_len;
  return str;
}

// 释放字符串占用的内存
void string_free(String *str) {
  if (str == NULL) {
    return;
  }
  free(str->data);
  free(str);
}

// 扩展字符串容量(增加内存)
static void string_grow(String *str, size_t grow_szie) {
  if (str->length >= str->capacity) {
    str->capacity += grow_szie;
    str->data = (char *)realloc(str->data, str->capacity);
    if (str->data == NULL) {
      fprintf(stderr, "Failed to reallocate memory\n");
      exit(EXIT_FAILURE);
    }
  }
}

// 追加字符串
void string_push_str(String *str, const char *other) {
  size_t other_len = strlen(other);
  size_t new_length = str->length + other_len;
  string_grow(str, other_len);
  memmove(&str->data[str->length], other, other_len);
  str->length = new_length;
  str->data[new_length] = '\0'; // 确保字符串以null终止
}

int main() {
  // 创建字符串对象实例
  String *str = string_from("hello");
  string_push_str(str, " world");

  printf("s1: %s\n", str->data);

  // 释放内存
  string_free(str);

  return 0;
}

内存与分配

因为不确定字符串到底需要多大的内存空间, 所以需要在运行时, 动态的分配内存空间, 学习过C语言的, 会非常容易理解这个概念, 在笔记中也有一个c语言动态分配内存的例子

  1. 内存是有限制的, 所以在使用完之后必须释放
  2. 对于有垃圾回收机制的编程语言来说, 我们不用管内存释放的问题
  3. 但是 Rust 没有垃圾回收机制, 所以需要注意内存释放的问题

变量与数据的交互方式: 移动

这里直接用c语言代码对比,方便理解数据存储在哪

栈内存中的数据

栈内存中的数据都是确定内存空间大小的, 这点和C语言一样

推荐阅读

rust
fn main() {
  // 局部变量(确定需要多大内存的数据)是存在栈上的
  // 像这样的确定数据宽度的局部变量肯定是放到栈区的
  let a: i8 = 5;
  let b: i8 = a;
  println!("a={a},b={b}");
}
c
#include <stdio>
int main() {
  // 局部变量(确定需要多大内存的数据)是存放在栈区的,
  // 那么 Rust 的局部变量也类似
  int a = 1;
  int b = a;
  printf("a=%d, b=%d\n", a, b);
}

堆内存中的数据

无法确定需要分配多少内存, 所以需要动态的分配, 比如C语言那个 String 的例子

rust
fn main() {
  let s1 = String::from("hello");
  let s2 = s1;

  // 此处是无法访问 s1 的, Rust 认为 s1 的
  // 所有权移交给了 s2, 那么 s1 就是没用的, 可丢弃的

  ///// 为什么如此设计?
  // A: 为了实现类似自动垃圾回收的的效果,
  // 1. 这种不确定数据宽度的数据肯定是存在堆区的
  // 2. 为了速度, 这个赋值操作肯定是拷贝指针而不是实际的内存数据
  // 3. 为了避免重复释放,内存泄漏等问题, s1 移交所有权后就立即销毁
  // 这也是所有权规则2: 值在任何时候都只有一个所有者
  println!("s2={s2}");
}
c
/* include ... */
/* struct String ...  */

int main() {
  String *s1 = string_from("hello");
  string_push_str(s1, " world");

  // 此时 s1 s2 都是指针
  String *s2 = s1;

  /**
    Rust中对应的代码为: let s2 = s1;
    在 Rust 中变量移交所有权之后就立即释放掉, 相当于执行了: string_free(s1);
    它认为你将一个指针赋值给一个新的指针那么, 原来的指针就没用了, 应该释放掉
    所以 Rust 不允许你在移交所有权之后再次访问

    为什么要这样设计?
    1. 为了防止申请的内存不释放,或者重复释放的问题,所以需要失去所有权就马上释放
    2. 这样的话, 只要遵守Rust编译器的所有权规则, 就能实现类似垃圾回收的效果
       (不用手动的释放内存), 既不用手动管理内存, 又可以达到C语言级别的运行效率
  */
  printf("s2: %s\n", s2->data);

  // 最后代码全部执行完的时候, 释放s2
  string_free(s2);
  return 0;
}

栈区数据和堆区数据所有权的不同

rust
fn foo(x: i32) {
    println!("foo x={}", x);
}

fn bar(s: String) {
    println!("bar s={}", s);
}

fn main() {
    let x = 10;
    foo(x); // 将数据传递给函数
    println!("main x:{}", x); // 这是可以, 因为 x 是一个 数据(变量)

    let s = String::from("hello");
    bar(s); // 将指针传递给函数(失去所有权, 会销毁数据)
    println!("main s:{}", s); // 这是不可以的, 因为 s 是一个指针(变量)
}

变量与数据的交互方式: 克隆

这个克隆肯定是针对堆区的数据来说的, 栈区的本来就是直接赋值, 并不是操作指针

rust
fn main() {
  let s1 = String::from("hello");
  let s2 = s1.clone();

  // 注意了,这个是重新申请内存并填充数据,
  // 不是移交所有权, 而是创建一份新的数据
  // 和前面的 let s2 = s1; 有本质区别
  println!("s1={s1}, s2={s2}");
}

:::

所有权和函数

函数和之前的理论也是一样的, 栈和堆中的处理方式是不一样的

c
fn copy_number(x: i32) -> i32{
    x
}

fn move_ownership(str: String)->String {
    str
}

fn main() {
    let a = 10;
    let b = copy_number(a); // 此时是复制栈中的数据
    println!("a={a},b={b}"); // 所以可以访问 a 和 b

    let s1 = String::from("hello");
    let s2 = move_ownership(s1); // 移交所有权给函数
    println!("s2={s2}"); // 所以此时可以访问s2, 但是无法访问s1
}

直接获取所有权并不方便

比如: 我有个函数, 只获取字符串的长度, 但是我后续还需要操作这个字符串

rust
fn get_str_len(s: String) -> usize {
    s.len()
}

fn main() {
    let s1 = String::from("hello");

    // 后续无法再访问 s1, 因为 s1 会将所有权移交给 get_str_len
    let s1_len = get_str_len(s1);

    // borrow of moved value: `s1` value borrowed here after move
    println!("s1:{}, s1_len:{}", s1, s1_len);
}

那为了后续还能继续操作 s1 我就只能将 s1 所有权从函数中再移交出来

rust
fn get_str_len(str: String) -> (String, usize) {
    let len = str.len();
    (str, len)
}

fn main() {
    let s = String::from("hello");
    let (s, len) = get_str_len(s);
    println!("str = {s}");
    println!("len = {len}");
}

这操作感觉有点太变态了

有没有什么办法, 在传入数据的时候, 不要获得所有权

引用与借用

在之前的代码中, 我们想要将一个值传入函数中, 就会失去他的所有权, 但是有的时候这个很不方便, 如何将一个值传入函数, 但是不失去所有权?

注意引用和借用的区别: 他们是同一个概念的不同方面

什么是引用 References

引用是指访问数据的一种手段, 他是一个名词

什么是借用 Borrowing

借用是指创建引用的过程, 他是一个动词

rust
fn main() {
    let x = 5;
    let y = x + 10;  // 通过 变量 直接访问数据
    println!("x:{}, y:{}", x, y);

    let r = &x;      // 创建引用的过程(借用)
    let y = *r + 10; // 通过 引用 间接访问数据
    println!("x:{}, y:{}", x, y);
}

引用 References

和 c 语言类似, 可以操作数据的内存地址并且保存到其他变量 在 c 语言中称之为 指针变量(或简称为指针), 那么在 Rust 中就叫做 引用

rust
fn main() {
  let x = 10;
  let r = &x; // 创建引用
  println!("x:{}, r: {}", x, *r); // 通过引用获取值
}
c
#include <stdio.h>

int main(void) {

  int x = 15;
  int *r = &x; // 创建指针变量
  printf("x:%d, r:%d\n", x, *r); // 通过指针变量获取值
  return 0;
}

什么是只读引用和可变引用?

Rust 中变量默认就是只读的, 需要使用 mut 关键字才可以修改 那么引用也是同理, 默认是只读的, 需要使用 &mut 关键才可以修改

rust
fn append_str(str: &mut String) -> &String {
    str.push_str("-append-string");
    str
}

fn main() {
    let mut s = String::from("hello");
    let s2 = append_str(&mut s);
    // println!("s = {s}"); // 此时无法访问s, 因为可变引用会移交所有权
    println!("s2 = {s2}");
}

借用 Borrowing

什么是只读借用和可变借用

在创建引用时, 没有将引用标记为可变的, 那么这个创建的过程就是 只读借用 在创建引用时, 将引用标记为可变的, 那么这个创建的过程就是 可变借用

rust
fn get_str_len(str: &String) -> usize {
    str.len()
}

fn main() {
    let s = String::from("hello");
    let len = get_str_len(&s); // 传入只读引用
    println!("str = {s}");
    println!("len = {len}");
}

解引用

在 C 语言中, 使用指针变量对应的值是需要用 * 来解引用才能获取到值的, 但是在 Rust 中, 多数时候不需要解引用(编译器会自动解引用), 只有少数情况 需要手动解引用

rust
fn main() {
  let x = 10;
  let r = &x;
  println!("x:{}, r: {}", x, *r);
  println!("x:{}, r: {}", x, r);
  // 观察以上代码输出发现, 不手动解引用, 他也会自动解引用
  // 这是因为, 编译器发现你传入的是引用(指针)不是数据
  // 编译器会自动的解引用
}
c
#include <stdio.h>

int main(void) {

  int x = 15;
  int *r = &x; // 创建指针变量
  printf("x:%d, r:%d\n", x, *r);

  // 在 C 中, 不会自动解引用
  // 编译器会输出引用(指针)对应的内存地址
  printf("x:%d, r:%p\n", x, r);
  return 0;
}

为什么设计为只读和可变两种模式

如果像 C 那样, 所有的指针(类似引用)都是可以随意修改内存的, 那么就会导致, 你可以在任何地方通过指针操作内存中的数据 这可能会导致 数据竞争问题 脏数据问题 等, Rust 这样设计是为了内存安全, 所以很多资料上会说: Rust 是内存安全的语言

数据竞争会导致一些问题很难发现, 难以调试 为了避免这些由操作内存可能导致的问题, 所以 Rust 让编译器在编译 时就发现问题, 让不符合 Rust 编译器内存安全策略的代码不能编译

Rust 引用规则

  1. 在任何时间, 同一个作用域下, 要么只能有一个可变引用, 要么只能有多个不可变引用
  2. 引用必须总是有效的, 不能出现悬垂引用
rust
fn main() {
    let mut s = String::from("hello");

    // 这样是没有问题的, 同一时间可以有多个只读引用
    let r1 = &s;
    println!("r1={}", r1);

    let r2 = &s;
    println!("r2={}", r2);

    let r3 = &s;
    println!("r3={}", r3);

    println!("r1={}", r1);
    println!("r2={}", r2);
    println!("r3={}", r3);

    // 也可以用有一个可变的引用
    // 但是不能有多个可变的引用(在同一个作用域)
    let mr1 = &mut s; // s 将所有权移交给 mr1

    println!("mr1={}", mr1); // 此时可以通过 mr1 访问 s 的值

    let mr2 = &mut s; // s 将所有权移交给 mr2,
    println!("mr2={}", mr2);

    // 此时不能通过 mr1 来访问 s 的值, 因为 s 的所有权已经移交给 mr2 了
    // println!("mr1={}", mr1);
}

通过以上代码观察发现: 只读引用不会获得数据的所有权, 可变引用会获得数据的所有权

如果我就是需要多个可变引用怎么办?

比如调用两次 append_str 此时就需要两个可变引用

此时就需要用到 {} 表达式创建作用域, 当可变引用离开作用域就会销毁, 那么所有权也就没有了

rust
fn append_str(str: &mut String) -> &String {
    str.push_str("-append-string");
    str
}

fn main() {
    let mut s = String::from("hello");
    {
        append_str(&mut s);
    };

    // 此时上面{}中的&mut s随着作用域结束就会被丢弃
    // 那么就不会有所有权了, 此时再 &mut s 就没有问题
    // 这其实就是避开了第一条规则: `在任何时间, 同一个作用域下...`
    // 两个 &mut s 是在不同的作用域, 所以可以通过
    let s2 = append_str(&mut s);

    // println!("s = {s}"); // 此时无法访问s, 因为可变引用会移交所有权
    println!("s2 = {s2}");
}

什么是悬垂引用

悬垂引用(Dangling References)和C语言中的悬垂指针是一样的道理

rust
fn get_str() -> &String {
    let s = String::from("hello");
    &s
    // returns a reference to data owned by the current function
}

// 随着 get_str 的作用域结束, s 会被销毁, 内存会被释放
// 此时返回一个指针(指向被销毁的内存空间), 后续操作肯定就会报错
// 所以编译器直接报错, 让你无法通过编译
c
int* get_num() {
  int x = 10;

  // address of stack memory associated with local variable 'x' returned
  return &x;
}

// 随着 get_num 作用域结束, x会被销毁,
// 内存会被释放掉此时返回一个目标内存地址被销毁的指针是没有意义的

Released under the MIT License.