Rust 语言学习笔记(五)

终于来到了 Rust 的精髓所在了,那就是使之不依赖于垃圾回收又能保障内存安全且高效运行的所有权系统(Ownership System)。想要用 Rust 做一个稍显规模项目必定绕不过它,所有权系统包括所有权(Ownership), 借用(Borrowing), 生命周期(Lifetimes)。

以下概念的复述基本是从 《Rust编程: 入门, 实战与进阶》一书中而来,那里面有些内容是来自于官方的 The Rust Programming Languge - Understanding Ownership

所有权系统的基本概念

Rust 的编程语法很快就能上手,让学习 Rust 曲线陡然大增的也就是这个所有权系统。所有权检测在编译期完成,Rust 能编译出来的代码就是安全高效的。要理解 Rust 的所有权系统必须首先明白以下两组概念:

  1. 栈内存(Stack),值语义(Value Semantic),按位复制(浅复制)(Shallow Copy),复制语义(Copy Semantic)
  2. 堆内存(Heap), 引用语义(Reference Semantic), 深复制(Deep Copy),移动语义(Move Semantic), 借助(Borrowing)

和其他语言一样,大小固定的所有基本类型都可以存储在栈上,栈上存取数据总是在栈顶操作,很快,而访问堆内存需要搜索内存地址。所有权系统的主要任务是用来跟踪堆上的数据,即引用语义的数据。

值语义(Value Semantic): 基本类型都是值语义,像函数的基本类型参数,会产生一个复制。按位复制就是浅复制(Shallow Copy),只复制栈上数据。深复制(Deep Copy) 对栈上的堆上数据一起复制。复制语义(Copy Semantic) 就是指具有值语义特征的变量在本上进行按位复制。

引用语义(Reference Semantic): 通过存储在栈中的指针来管理堆内存的数据,禁上按位复制(Shallow Copy), 因为这会造成两个指针指向同一份数据造成堆内存二次释放。字符串也属生引用语义。相应的移动语义(Move Semantic) 是指对堆内存中的数据只能进行深复制,所谓的移动说的是进行深复制后,栈上要移动指向堆上堆据的指针地址。(移动后原来的指针及被指向的堆内存如何处理的呢?)

借用(Borrowing) 是对引用行为的描述。引用分为不可变引用(& 操作符) 可变引用(&mut 操作),对应着不可变借用和可变借用, 所以 &x 称作为 x 的借用,通过 & 操作符完成对所有权的借用,不会造成所有权的转称。

Rust 通过所有权系统来管理内存的,它是 Rust 的核心功能,而它的核心有以下三点

  1. 每个值都有一个被称为其所有者的变量,也就是每块内存空间都有其所有者,它负责该块内存空间的释放和读写权限
  2. 每个值在任一时刻有且仅有一个所有者(难道可以随时切换所有者?)
  3. 当所有者(变量)离开作用域,这个变量将被丢弃(每个函数是否有一个新的所有者,从函数中返回后,其中申请的堆内存都将自动释放?)

变量绑定

Rust  用 let 声明的变量并非传统上的变量定义,本质上是绑定语义, 像 Lisp 族的语言也是用绑定(Binding) 这个概念。let 将一个变量与一个值(内存空间)进行绑定,从而成为了这个值(内存) 的所有者。Rust 保证对每块内存空间只有一个绑定变量与之对应,不允许两个变量同时指向同一块内存空间(两次 let 用同名的变量声明会产生怎么样的效果?)

变量绑定除了一空间属性,还有时间属性(绑定的时效性),也就是它的生命周期(Lifetime), 终于来到这 Lifetime 这个概念。一个变量的生命周期是指它从创建到销毁的全过程。let 创建的变量在作用域内有效,当变量离开作用域,它所绑定的资源就会被释放,变量也会随之无效。

看变量绑定的例子

如果我们在 foo() 方法出口处主动调用 drop(s) 会怎样呢?

执行,一切正常,看来主动释放的 s 不会再次被 Rust 释放

如果 drop(s) 后再使用 s 会怎么样呢?

编译时出错

通过上面的错误信息可以好好理解前面的概念了

let s = String::from("Hello");  -move occurs because s has type String, which does not implement the Copy trait
s 是在堆中分配的内存,引用语义,没有实现浅复制

drop(s): - value move here
堆内存中的数据被释放了,栈上的指针也叫移动了

println!("{}", s); ^ value borrowed here after move
println 宏中采用了借用(&)操作来使用 s 的值,不会转换所有权

上面提到在退 foo() 作用域时 Rust 会自动调用 drop 操作释放内存,这里我们可以验证一下

执行后控制台输出

> Dropping a

说明确实是调用了 Drop trait 的 drop 方法。

所有权转移

学到这里时,感觉学习 Rust 就是学习 Rust 的所有权系统,必须好好理解。其他的语法, trait, 泛型,属性,数据类型,错误处理变得越发次要了,只需要用时参考文档。而没有扎实掌握 Rust 的所有权系统,编译时势必处处碰壁,闹个灰头土脸,欲罢不能。

在掌握 Rust 的所有权系统中时刻要知晓一个变量是在栈还是在堆中分配的,所有权机制只针对堆上分配的数据。如基本类型是在栈中分配的,把一个变量赋值给另一个变量会进位按位复制,再与新的变量绑定,这和其他语言是一样的行为。

在栈中,x 的值会复制到新内存,然后再与 y 绑定,而后改变 x 的值不会影响 y 的值,这理解起来很自然

变量赋值 move 所有权

对于 struct 数据类型,默认也是在栈中分配内存,struct 中的成员是非基本类型会分配在堆内存中,如以下重赋值会有 student1 和 student2 两份独立的内存

如果用 Box::new 来声明 struct 变量就会分配在堆内存中

再次看变量赋值时的所有权转移

上面代码无法通过编译,这应该是 Rust 学习者必犯的问题

由于 String 是引用语义, 它会在堆中分配内存,所以 s1 赋值给 s2 后就完成了所有权的转移,s1 不再可用了。

十分有必要引用几张图片来说明这个所有权转移的过程

let s1 = String::from("hello"); 分配的内存如上图,左边是分配在栈中的一组数据结构,包括指向堆中数据的指针,长度和容量;右边是堆内存中字符的内容

let s2 = s1; 后内存中的状态,栈中的数据结构复制后绑定到了 s2, s2 指向了相同的堆内存地址,但 s1 变得无效了。如果程序从当前作用域中退出,可保证堆中的内存只由 s2 释放一次。

Rust 不像 C/C++ 那样 char * s2 = s1 赋值后,会有两个指针指向同一块堆内存区,如下 C++ 代码

注:g++ 编译命令为 g++ hello.cpp -o hello,生成可执行的 hello 文件

无论是 delete s1; 或 delete s2; 都会释放同一块内存,执行后输出的内容会是

@�Gt

而不是 "hello",  因为无论是 delete s1 还是 delete s2, s1 和 s2 它们所指向的内存都会被释放,所经它们都将成为野指针。如果 Rust 也依照 C/C++ 的行为的话,效果就会是下面这样的

当然,在 C/C++ 中的 char* 字符串实现是不一样的,在 C/C++ 中 s1, s2 只需一个简单的指针,在堆内存区存储 hello 要求多一个字节放置字符串结束符  \0。像 C/C++ 那样赋值会多个指针指向同一个堆内存,内存释放就不易于自动管理了,而且很可能产生野指针。

如果 Rust 在堆上的数据如这里的 String 也实现了深度拷贝的话,let s2 = s1 的执行效果将会是

产生了 s1, s2 各自指向独立的内存区域,各自释放内存或退出作用域名自动释放 s1, s2 都是安全的,缺点只是多了一份内存拷贝。所以 Rust 对引用语义(堆内存数据) 引入了 move 来转移所有权,这能在退出作用域时能自动释放分配的内存,并且在编译器就能保证这一点。

如果用 i32 Box 类型为例,上面的内存状态变迁图会简单许多,比如

i1 或 i2 在栈都只有一个指针,在堆中就是 4 个字节的区域。

向函数传递值 move 所有权

到此为止对 Rust 的 move 概念应该理解的差不多了,所有权转换除了发生在变量赋值,还出现在向函数传递值,从函数返回值的情况。

看引用语义传值的情况

上面的代码编译不过,因为调用 take_ownership(s) 之后 s 变得不可以,编译器推荐用 take_ownership(s.clone()) 的方式调用。

对函数 take_owership(str: String) 的调用效果相当于

调用方法时,s 的所有权 move 到了 tmp, 函数在退出时释放了 tmp

但是对值语义的参数调用无妨

因为 i32 类型的值在栈中分配,对 make_copy(int: i32) 的调用效果如下

我们可以在 make_copy(int: i32) 最后一行写上 drop(int), 不过编译器会有一行警告说

drop(int); // warning: calls to std::mem::drop with a value that implements Copy does nothing

&str 类型作为函数传递给函数不会转移所有权

参照 C/C++, 因为 s 类型是 &str 已经是一个指针,no_move(str: &str) 函数的参数是 &str, 似乎可以理解为指针作为值在栈上被复制了一份并绑定到一个临时变量(指针的指针 &&str),退出作用域只释放了指针的指针, 对最初指针指向的数据不受影响。仍然难于理解,那可以想像前面对 no_move(str: &str) 函数的调用效果如下

学到这里, Rust 基本上就是 C/C++ 不易理解的指针的指针概念用所有权来解释。或者说当引用语义的变量再取引用,就相当是指针的指针,它重新变成了值语义,所以不存在所有权的概念。

还可以主动对引用语义的变量再取引用来调用函数,也不会转移所有权

比如我们在使用集合类时,以 HashMap 为例,&str 的值可直接作为 key/value, 而 String 类型需用 & 再次取引用

用 String 类型的话

如果直接调用

map.insert(key, value);

在 map.insert(String, String) 函数中 key, value 的所有权就会被转到函数,然后由该函数在退出之前释放掉 key, value 所对应的堆内存,后面 key/value 就不可用了

HashMap 的 insert 是一个泛型函数,声明为

可惜 Rust 在对引用语义变量使用 & 来调用参数不转移所有权,又重新回到了 C/C++ 的指针的指针概念, 在 C/C++ 中指针的指针传递到函数也不畏惧在函数中被释放; Rust 中或许可称作引用的引用,后面会讲到在 Rust 中被称为借用(Borrowing) 

从函数返回值的所有权

这个规则很简单,从函数中返回的变量所有权会转移给调者

由于函数返回值所有权转移的这一特性,因函数参数转移所有权的参数可原本从函数中返回出来,可修改前面 take_ownership() 的代码

函数的多个返回值可放在一个元组中,在函数中 (str, 32),  然后  let (s1, another) = take_ownership(s)

如果对上面的代码进行单步调试,两个 &s(类似于指针的指针) 的地址有发生变化,但指向堆内存应该是同一个地址,由于第一个 s 的所有权转移给了第二个 s, 所以在退出 main()  时相应内存只会释放一次。

关于所有权讲了很多,以后还需要阅读更多的相关资料加强理解。

如果 C/C++ 也遵循 Rust 编译器所要求的所有权规则也能写出更安全的代码。

浅复制与深复制?

感觉 《Rust 编程: 入门,实战与进阶》 一书中讲浅复制与深复制说 "浅复制是指复制栈上数据,深复制是指复制栈上和堆上数据" 这个定义有点不准确,这里有个疑问 --  实现了 Copy, Clone trait 的结构体只会在栈中分配内存吗?。最主要应该是要清楚是值语义还是引用语义,实现了 Copy trait 的类型都是值语义。哪些类型可以是值语义

  1. 基本类型都实现 Copy trait
  2. 元组中每个类型都实现了 Copy trait 的话该元组也就实现了 Copy trait
  3. 结构体如果实现了 Copy 和 Clone trait, 并且每个成员都实现了 Copy trait
  4. 枚举如果实现了 Copy 和 Clone trait, 它会成为值语义

结构体和枚举用 #[derive(Copy, Clone)] 属性来实现默认的 Copy, Clone trait。

值语义在变量赋值给另一变量,或调用函数传递参数时不会发生所有权的转移

实现了 Clone 的可用  clone() 来复制

像是 Java 的 clone()。

在判断 Rust 的代码是否会转移所有权时说到底就只有一个规则:值语义的类型不会转移所有权(move), 实现了 Copy trait 的类型是值语义类型,引用语义类型的引用又重新变成了值语义; 引用语义的类型会转移所有权。

在书写代码过程中完全需要自己去分辨类型是引用类型还是值类型,然后再思考它什么情况下会发生所有权的转移。 回望过去, C/C++ 反而更简单些,变量前有一个星号(*) 的就是引用类型,没有就不是,前面有两个星号(**) 的就是指针的指针,所指向的指针变成了值类型。

开始有种想法,既然花那么多时间去掌握 Rust 的所有权,生命周期等复杂的概念,何不多花些时间把 C/C++ 掌握得更扎实些,毕竟目前高效的库还是仰赖 C/C++。静机 Rust 吧,只要 Rust 能编译出来的代码就不会太糟。若有人问可以用 Rust 做什么,不少人的回答是正在用 Rust 改写原来 C/C++ 的库。

再论引用与借用

Rust 的引用与借用两个概念有些含混不清,&x, &mut 分别是不可变引用和可变引用,又可相应的称作借用。通过 & 操作符完成对所有权的借用,不会造成所有权的转移。

看下面几个例子

Rust 的引用,解除引用,println! 中的引用又引用

引用本质上是一种指针语义,* 操作解除引用,println! 宏比较有能耐,不管你多少次不断的引用都能显示出最终内存中的值,就像 HTTP 的 301 或  302 重定向,请求时一直跟随机最原始的请求。

看 Rust  通过引用改变原地址中的内容

采用不可变引用调用函数,要满足三个要求,1) 定义的变量必须是 mut 的,2) 函数参数必须是 &mut, 3) 调用函数时必须用 &mut

参照 C/C++ 中如何修改引用地址中的内容

在 Rust 中 & 同时表达着引用与借用的概念,还需要作更深入的学习

对借用规则还是难以理解,比如下面一系列的代码

如果一用 y 就受不了

如果用 y 前不作  &x 借用也没问题

&mut x 借用后,可以碰 x

但不能同时去碰 x 和 y

通过以上的例子,我们能总结出什么样的引用规则呢?还要留待以后去解决,想要完全搞清楚 Rust 的所有权,引用,借用等关系还有一个漫长的过程。

Rust  从数组,动态数组,字符串等可获得一个切片(slice),切片本身是没有所有权的,还可以从可变集合得到一个可变切片.

迭代器用 IntoIter, Iter, IterMut, 不同迭代器有不同的所有权类型

  1. IntoIter: into_iter(self) -> IntoIter<T, A>, 转移所有权,原容器不能再使用
  2. Iter: iter(&self) -> Iter<'_, T>, 不可变借用,借用不转移所有权,原容器可用
  3. IterMut(): iter_mut(&mut self) -> IterMut<'_, T>, 可变借用,不转移所有权,原容器可用,且可修改原容器中的数据

本文链接 https://yanbin.blog/rust-language-learning-5/, 来自 隔叶黄莺 Yanbin Blog

[版权声明] Creative Commons License 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments