Rust所有权及引用

Rust 所有权和借用

Rust之所以可以成为万众瞩目的语言, 就是因为其内存安全性. 在以往内存安全几乎全都是通过GC的方式实现, 但是GC会引来性能、CPU以及Stop The World等问题, 在需要高性能的场景是不可以接受的,因此Rust使用一种与众不同的方式 解决内存安全问题: 所有权机制

Rust所有权

所有程序都必须和计算机的内存打交道, 如何从RAM中申请空间存放程序运行所需要的数据, 在不需要是回收内存空间, 成为了关键, 在计算机编程语言不断进化的过程中出现了三种解决方案:

  • 垃圾回收机制(GC) , 程序运行时RunTime 通过三色标记 引用计数 分代回收等算法 回收空闲内存 : Go Python Java

  • 手动管理内存的分配和释放, 编写通过函数调用的方式申请释放内存 : C malloc() free(), C++ new() delete()

  • 通过所有权机制管理内存, 在程序编译期间 确定内存申请 释放的时间, 将相关的数据硬编码到二进制程序中, 在程序运行期间不会有任何性能上的损耗

一段内存不安全的代码

int* foo() {
    int a;          // 变量a的作用域开始
    a = 100;
    char *c = "xyz";   // 变量c的作用域开始
    return &a;
}                   // 变量a和c的作用域结束

​ 这段C代码是可以顺利编译通过的 foo函数返回一个int指针类型, 但是变量a和c是foo函数内的局部变量, 我们都知道 函数和函数内的局部变量 都是存储在栈当中的, 当foo函数执行完成后 局部变量a,c及函数foo 在栈内申请的内存 就已经被回收了, 此时返回变量a的指针, 从而形成了悬空指针 (悬垂指针, 野指针) 因为a申请的内存数据在foo函数结束是已经被回收, 此时返回a的指针 指向的内存地址已经被回收或者被其他程序使用, 如果这块地址再次被其他程序申请到并放入数据, 那就跟我们程序预期的效果产生差异,容易导致程序崩溃.

例如: a程序中a的数据是100 , 回收后被其他程序申请存入数据为 "malloc"。

​ 我们再来看一下变量c, 变量c的问题在于内存的浪费, 也是对栈的空间的浪费, c变量申请的内存在他声明完成后没有任何操作, 但是他回收的时间需要在foo函数结束是才进行回收 产生了资源的浪费

​ 内存安全的问题一直都是令开发者头疼的问题, 所以如何保证内存安全成为我们对技术深度评判标准之一, Rust的所有权机制将解决大部分内存安全问题, 想要保证内存安全我们就需要对 堆 栈有足够的认知

堆 和 栈

堆和栈是编程语言最核心的两个数据结构, 在许多编程语言我们不需要深入了解, 因为GC会偷偷的无感知的帮我们进行内存的回收, 这也意味着性能的瓶颈, 但是对于Rust这种系统编程语言, 数据值位于栈 或 堆 上是很重要的, 因为他大大的影响程序运行时的性能

堆栈实际上都是我们RAM

栈 是按照顺序且连续存储值 并以相反的数据取值, 先进后出, 存储数据为进栈 , 取出数据为出栈。 栈中的数据值所申请的内存大小必须是已知的固定的内存空间, 如果数据值大小是未知的, 那么取出数据时, 你无法取出你想要的数据。

栈 通常存储的数据是 编程语言的内置的基本类型的数据 i32 i64 f32 f64 &str bool 、 函数、 函数内的局部变量 、堆指针地址、元祖

ulimit -s 用于查看操作系统的栈空间 间接的说明栈空间是有限的 如果申请栈内存空间超出栈 就会发生栈溢出 程序崩溃、Go内存逃逸分析 等场景

每一个程序运行时操作系统都会为其分配栈的内存空间 1-8M , 通常情况下不会出现栈溢出 如果出现死循环、深递归的时候就极有可能出现程序崩溃。

对比着栈来理解堆 更容易理解一些

栈是由cpu寄存器来访问控制回收, 堆是由开发者来控制堆内存的回收

栈中存储的数据值都是已知大小的数据, 堆内可以存储未知大小的动态数据 相对灵活 .

栈申请的内存用完立即释放, 堆内存需要根据生命周期和GC算法释放内存

栈是连续的内存空间, 堆是不连续的 很有可能会产生内存碎片 无法回收造成浪费

栈的空间是有限的, 堆的空间可以认为是无限的

栈为什么会比堆快

1.cpu高速缓存会缓存栈内的数据 不会缓存堆内的数据 跟他们的存储规则有关

2.栈是直接寻址 申请只存只需要移动一个指针即可, 堆是间接寻址的 首先要去栈内取得变量的堆指针, 才可以获取数据。

3.栈是由cpu的寄存器直接访问控制的

4.栈在程序开始运行就已经开辟好了内存空间, 而堆需要在程序运行时 运行到对应到指定位置才开辟内存空间

5.入栈比堆分配内存快, 因为入栈操作系统无需分配新的内存空间,只需将新数据放入栈顶

所有权原则

在理解堆栈的前提下, 更有利理解Rust的所有权

1.Rust中的每一个值 有且只有一个所有者(变量)

let s = String::from("teststr")  // 变量s就是字符串teststr的所有者

2.当所有者(变量)离开作用域范围时,这个值将被丢弃(free) 也就是释放内存空间

fn test() {
  let s = String::from("teststr")  // s为test函数中的局部变量
} // 函数执行完成  变量s 离开作用域 字符串teststr的内存将被释放 生命周期结束

简单介绍String类型

上边提到了String::from 方法 , 创建变量的类型是String

let s = String::from("teststr")  // 变量s就是字符串teststr的所有者

还有一种声明字符串的例子 这种声明的字符串类型是 字符串字面值 a 是被硬编码到程序的类型是&str 他不可修改

let a = "test"

所有权背后的数据交互

下面看这样一段代码

let x = 5;  // x 变量就是 整数5的所有者
let y = x;  // 拷贝 x 赋值给 y  最终x和y都等于5  且都可以调用 因为上述操作都是在栈中运作的 整数类型是rust的基本类型 基本类型赋值调用都会自动拷贝 不会在堆中进行分配使用  也不会引发所有权机制

// 可能有好奇宝宝 会想 这种栈中的的copy赋值 是不是太慢了些, 但是实际上在rust的基本类型足够简单 ,拷贝会非常快, 只需要赋值一个i32,4字节的内存即可

随即看这样一段代码:


let s1 = String::from("hello");
let s2 = s1;

println!("{}{}", s1, s2)
// 跟上边的整型拷贝很像吧 但是 String类型 并不是rust的基本类型  所以他是存放在堆上的 不会自动拷贝 此时打印s1,s2就会触发rust的所有权机制

// 我们可以先看一下上边这段代码具体发生了什么
//String类型是一个复杂的类型, 他的堆指针、字符串长度、字符串容量共同存放在栈中, 真实数据存放在堆中,下面我们分析 let s2 = s1 可能出现的两种情况
	1.拷贝栈上String堆指针 容量 长度 和存储在堆上的字节数组, 这就是深拷贝了
	2.只拷贝String的堆指针 容量 长度 8+8+8字节 理解为浅拷贝, 但是这样就跟Rust所有者机制产生了冲突  因为我们的数据的所有者有且只能有一个, 如果按照这种浅拷贝的情况 那么这个数据就出现了两个所有者, 那么当s1和s2离开作用域的时候都会释放同一块内存, 也称为二次释放, 导致内存污染 违背了Rust的所有权机制, 那么Rust是如何处理这种问题呢? 解决方法: 
	当s1将值赋值给s2的时候, Rust认为s1不再有效, 因此也无需在s1离开作用域后drop释放s1的内容, s1的数据的所有权已经转移给了s2, s1同时也就失效了, 不会产生二次释放的问题, 效率大大增加,

Rust所有权及引用

上图中就是第二中浅拷贝的情况rust解决的方案, s1赋值给s2后 s1自动失效, s2接管这块内存地址

深拷贝

Rust永远不会自动创建数据的"深拷贝", 因此, 任何的自动复制都不是深拷贝. 浅拷贝被认为运行时性能影响较小

let s1 = String::from("hehahi");
let s2 = s1.clone();  // 深拷贝
println!("{}{}", s1, s2)

此段代码编译运行畅通无阻, 因为s2 完成的clone了s1 包括栈内的堆指针 容量 长度 堆内的数据, 但是如果频繁使用clone深拷贝 将会带来性能上的降低。

函数参数传递及返回 所有权的转移

在变量作为参数传递给函数是, 同样会发生移动或者复制, 所有权就会对应的产生变化


fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

我们如果尝试在takes_ownership(s); 语句执行之后 打印s值 就会产生报错 因为s作为参数传递给takes_ownership函数 String类型 不是基本类型 不会自动拷贝, 所以String的所有权转移到函数内, 又转移给了println宏当中 但函数执行完成, String开辟的这块内存已经被释放了 所以在函数之后打印s 就会报错 ,但是如果makes_copy(x) 函数之后执行打印x 就不会报错的, 因为i32类型是基本类型, 存储在栈内会进行自动拷贝, 不会触发所有权机制 , 但如果不是存储在栈的数据 就需要将数据返回出来, 这样数据传来传去 很是麻烦, Rust就帮我们解决了这个问题 引入了借用机制。

借用

在Rust中借用 在变量前加& 就变成了借用 不会产生所有权的转移, 在其他语言我们称这样的变量是引用, 但是Rust解释器中明确表明 就称其为借用, Rust通过借用Borrow概念达成减少所有权传递程序复杂的目的: **获取变量的引用, 称之为借用 **, 可以很好的理解, 我们上学忘记带铅笔, 可以跟朋友同学去借, 但是在使用完成后, 要物归原主.这里排除老赖等极端情况...

引用与解引用

常规的引用是一个指针类型, 指向了对象存储的内存地址。 在下面我们创建一个x i32值的引用 y, 然后使用解引用得到内存中真实的数据

let x: i32 = 5;
let y = &x

assert_eq!(5, x)
assert_eq!(5, *y) // y 是 5这个i32类型的数据内存地址  *y就是反引用得到的就是内存中的真实的数据5

当然这个时候 x 和 y也都可以正常打印出来因为引用不会涉及到所有权转移的问题 x 的不会出现失效的情况

不可变引用


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

    let len = calculate_length(&s1); // 将s1的引用传递给函数

    println!("The length of '{}' is {}.", s1, len);
}

// 函数接受 String的引用 返回一个 usize类型  usize就是无符号的根据操作系统位数生成的整数类型 例如我们操作系统是64位 那就是u64 
fn calculate_length(s: &String) -> usize { 
    s.len()
}// 因为传入的是引用类型  所以函数执行完成后不会释放drop掉s 什么也不会发生, 通过下面看一下类型引用的整体结构

s            s1         

ptr    ->    ptr     ->   0  h
             len          1  e 
             cap          2  l
       										3  l
													4  o

上述场景我们函数传参的简易性有了, 我们不觉的想到如果想修改 数据的值可以吗, 接下来我们看下面的代码:


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

    calculate_length(&s1); // 将s1的引用传递给函数

}
 
fn calculate_length(s: &String) { 
    s.push_str(" world!"); // 再此处修改数据
}

push_str处就会报错。因为在rust中定义的引用 都是不可以更改原来的数据的 就好像我们去图书馆借书 看可以 但是如果在毁坏书籍 乱涂乱画是不被允许的, 那如何我想画就画呢? Rust 也帮我们解决了, 那就是定义引用的时候声明他是一个可变引用

可变引用

fn main() {
    let mut s1 = String::from("hello"); // 声明s1为可变参数

    calculate_length(&mut s1); // 将s1的引用传递给函数

}
 
fn calculate_length(s: &mut String) {  // 声明传递的参数必须是一个可变的String类型参数
    s.push_str(" world!"); // 再此处修改数据
}

这段代码就可以完美的运行了

但是可变引用必须遵从Rust的一个原则:可变引用同时只能存在一个, 也就是在同一个作用域中, 一个数据只能有一个可变的引用, 同时不可变可以拥有多个

也就是说 一本书我借给多个人 , 你们一堆人可以一起看, 其中只能有一个人可以对这本书 修改 , 这样的好处就是 Rust在编译时就避免了数据的竞争, 下面这段代码就出现了多引用:

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

	let r1 = &mut s;
	let r2 = &mut s;

	println!("{}, {}", r1, r2);
} 
// 这段代码就会报错  因为声明了两个可变引用 且他们在同一个作用域main函数中,第一个可变引用r1声明周期必须持续到print完成后 在r1的声明周期内又尝试创建了一个可变引用r2 引起了数据的竞争 




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

	let r1 = &s;
  println!("{}", r1); 
	let r2 = &mut s;   // 如果想要 一段代码中同时引用可变引用和不可变引用  他们的生命周期必须没有交集
	println!("{},", r2);  
} 

// 可变引用和不可变引用在新版本的编译器中是可以同时存在的, 1.31之前不可以
// 对于这种编译器的优化Rust专门去了一个名字NLL - Non-Lexical Lifetimes(NLL),, 就是专门找出某一个引用在作用域 } 结束之前就不在被使用的引用的位置


悬垂引用 (出现悬空指针、 也可称迷途指针 、 野指针)

悬空指针 就是 指针指向实际的数据, 但是这个值在使用之前之前就已经被释放掉了, 但是 指针 也就是引用存在, 释放掉的内存可能不存在任何值, 或者被其他程序变新使用了, 造成了数据污染 , 而Rust编译器可以永远保证 引用不悬垂。

发生悬垂的场景:

fn main() {
    let mut testStr = String::from("testing"); 
    let result = overhang(testStr); // 将String数据传给overhang函数 此时String的所有权转移到overhang函数当中
    println!("{}",result); // 悬空指针产生了因为引用真正数据已经被释放了 找不到原本你的数据了
}

fn overhang(mut s: String) -> &String {  // 
    s.push_str("123");  // 修改String
    &s  // 返回String 的引用
} // 在此处 s 离开当前作用域 s 被drop掉 内存释放 , 返回&s 危险

error : error[E0106]: missing lifetime specifier

这里出现了关于生命周期的概念: 程序中每一个变量都有对应的作用域, 当超出作用域之后变量就会被自动销毁 一句话说就是一个变量在创建 到 被释放的过程, 称之为生命周期.

不过即使不了解生命周期仅仅了解引用 就可以理解悬垂指针。

解决上述代码的方法:将String返回 而不是&String

fn overhang(mut s: String) -> &String {  // 
    s.push_str("123");  // 修改String
    s  // 返回String 的引用
} 

这样就没有任何问题了

本文部分参照: Rust圣经

上一篇:几本人工智能入门书籍


下一篇:学习Linux的一丢丢心得,以及一丢丢0基础以上的注意(ssh+vim)