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解决的方案, 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圣经