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圣经

Rust所有权及引用的更多相关文章

  1. Rust所有权语义模型

    编程语言的内存管理,大概可以分为自动和手动两种. 自动管理就是用 GC(垃圾回收)来自动管理内存,像 Java.Ruby.Golang.Elixir 等语言都依赖于 GC.而 C/C++ 却是依赖于手 ...

  2. Rust中的所有权,引用和借用

    这个有意思,指针解释获新生!!! fn main() { let mut s = String::from("hello"); s.push_str(", world!& ...

  3. 对Rust所有权、借用及生命周期的理解

    Rust的内存管理中涉及所有权.借用与生命周期这三个概念,下面是个人的一点粗浅理解. 一.从内存安全的角度理解Rust中的所有权.借用.生命周期 要理解这三个概念,你首要想的是这么做的出发点是什么-- ...

  4. iOS内存管理系列之一:对象所有权与引用计数

    当一个所有者(owner,其本身可以是任何一个Objective-C对象)做了以下某个动作时,它拥有对一个对象的所有权(ownership): 1. 创建一个对象.包括使用任何名称中包含“alloc” ...

  5. rust所有权

    所有权与函数 fn main() { let s = String::from("hello"); takes_ownership(s); //s的值移动到函数里 let x = ...

  6. Rust <4>:所有权、借用、切片

    tips:栈内存分配大小固定,访问时不需要额外的寻址动作,故其速度快于堆内存分配与访问. rust 所有权规则: 每一个值在任意时刻都有且只有唯一一个所有者 当所有者离开作用域时,这个值将被丢弃 所有 ...

  7. Rust入门篇 (1)

    Rust入门篇 声明: 本文是在参考 The Rust Programming Language 和 Rust官方教程 中文版 写的. 个人学习用 再PS. 目录这东东果然是必须的... 找个时间生成 ...

  8. rust 高级话题

    目录 rust高级话题 前言 零大小类型ZST 动态大小类型DST 正确的安装方法 结构体 复制和移动 特征对象 引用.生命周期.所有权 生命周期 错误处理 交叉编译 智能指针 闭包 动态分派和静态分 ...

  9. rust语法

    目录 rust语法 前言 一.数据类型 1.1 标量scalar 1.2 复合compound 1.3 切片slice 1.4 引用(借用)reference 1.5 智能指针smart pointe ...

随机推荐

  1. 2.16图论专题PB

    超神建图技巧合集 CF1368G 每个骨牌变成让空位移动的至多两条有向边,证明图中无环,形成森林. 然后黑白染色,两类森林互不影响.转为每次标记 A 类一棵子树与 B 类一棵子树形成的所有点对. 再转 ...

  2. 【记录一个问题】没用任何用处的解决了libtask的context.c在32位NDK下的编译问题

    32位下用ndk编译libtask出现这样的错误: [armeabi-v7a] Compile thumb : task <= context.c /Users/ahfu/code/androi ...

  3. WebGPU图形编程(1):建立开发环境 <学习引自徐博士教程>

    首先感谢徐博士提供的视频教程,我的博客记录也是学习徐博士进行的自我总结,老徐B站学习视频链接网址:WebGPU图形编程 - 免费视频教程(1):建立开发环境_哔哩哔哩_bilibili 创建之前你需要 ...

  4. maven常用打包命令

    常用maven命令 执行与构建过程(编译,测试,打包)相关的命令必须进入pom.xml所在位置执行 mvn clean:清理(打包好的程序放在生成的名为target的文件中,清理即删除文件中打包好的程 ...

  5. Redis 源码简洁剖析 04 - Sorted Set 有序集合

    Sorted Set 是什么 Sorted Set 命令及实现方法 Sorted Set 数据结构 跳表(skiplist) 跳表节点的结构定义 跳表的定义 跳表节点查询 层数设置 跳表插入节点 zs ...

  6. 如何在 pyqt 中解决国际化 tr() 函数不起作用的问题

    前言 有些时候我们在父类中使用了 self.tr('XXX'),使用 Qt Linguist 完成翻译并导出 qm 文件后,发现子类中仍然是英文原文.比如下面这段代码: class AlbumCard ...

  7. 类加载器(JVM)

    一.JVM概述 JVM是java是二进制字节码的运行环境 特点: 一次编译,到处运行(跨平台) 自动内存管理 自动垃圾回收功能 常见的JVM Sun Classic VM:世界上第一款商用的java虚 ...

  8. Lesson4——Pandas DataFrame结构

    pandas目录 思维导图 1 简介 DataFrame 是 Pandas 的重要数据结构之一,也是在使用 Pandas 进行数据分析过程中最常用的结构之一. 2 认识DataFrame结构 Data ...

  9. [SDOI2017]数字表格 & [MtOI2019]幽灵乐团

    P3704 [SDOI2017]数字表格 首先根据题意写出答案的表达式 \[\large\prod_{i=1}^n\prod_{j=1}^mf_{\gcd(i,j)} \] 按常规套路改为枚举 \(d ...

  10. Centos设置网络(固定IP)

    简介 设置为桥接模式,即将虚拟机的虚拟网络适配器与主机的物理网络适配器进行交接,虚拟机中的虚拟网络适配器可通过主机中的物理网络适配器直接访问到外部网络. 配置 虚拟机设置为桥接模式 进入网络配置文件, ...