Rust学习 I
Rust - I
Intros
Rust,好!可能主要由于所有权机制上的创新,学习时的感觉与学其他语言的感觉完全不同,于是没有像学JS一样,觉得无聊,也没有像觉像学haskell一样,觉得过于抽象。但是这样一种语言创新,必然会给学习带来障碍,毕竟编程思想是完全不同的。此外,可能我使用Rust的工具链不对,个人认为vscode对于Rust的支持明显不足(缺乏自动补全,没有函数快速查看以及定义跳转等等),第一天学的时候,只能实现一些强逻辑性算法(比如什么快排,归并排序等等),无法深入使用数据结构(给我一个数据结构我根本不知道里面有什么方法)。
第一天快结束时,想学习一下Rust的可视化工具Plotters,结果发现,之前从菜鸟教程了解的写法过于粗浅,基本看不懂Plotters代码,遂投身更加深入的学习。但却发现,给自己设置的小目标 --- 写一个链表,按照之前了解的语法知识,我都是写不出来的。快要放弃只是接触到了一个教程以及其官方文档:
教程详细介绍了对于链表的实现,较为通俗易懂,有些难以思考的问题,其实沉下心来想也很快能想出来。本文是跟着教程实现过程中,笔者对于遇到的一些问题的处理方法以及自己的心得。由于笔者非常不喜欢依葫芦画瓢(因为这样,感觉自己完全学不到东西),所以笔者也在自己的实现中整活(超前学习),本文也记录了整活过程中遇到的坑及处理方法。本篇为Rust学习心得的第一章。
II. 用Rust实现链表
2.1 null pointer optimization的意义?
假设我们有一个这样的数据结构:
1 | enum Foo { |
通常来说,enum类型Foo
(可以将enum的内存功能理解为一个union)需要有足够空间以存储其表示的数据,也即在Empty
以及NotEmpty
两个类型之间,选择所占空间最大的那个类型对应的内存消耗作为enum类型Foo
的大小。但是,Foo
如何才能知道当前表示的是Empty
还是NotEmpty
类型?通常,需要额外的空间(比如一个字节)存储一个叫tag的域,以指示当前Foo
的真正含义。有一种例外,例如以上这个例子:
(1)Empty
没有关联数据
(2)NotEmpty
我们认为是一个指针,对于此指针而言,数据位为0(空指针)是不允许的
也即,此例子中,NotEmpty
中不被允许的结果(全0)恰好可以被用于表示Empty
。则此时,我们不再需要tag域来表示Foo
的实际意义(Foo中数据可以直接判定)。
2.2 错误 -
private type 'xxx' in public interface
如果我直接写如下代码:
1 | struct Foo {elem: i32} |
这样是完全没有问题的。但是如以下这两种写法,都会出现问题
1 | struct Foo {elem: i32} |
两种写法都会报错,编译器认为:can't leak private type
。struct中的权限,不同于C++的结构体(默认public),struct默认private,如果显式定义了pub
关键字才成为public的。而对于enum而言,只要enum本身是public的,其中的所有元素都是public的。但Rust不允许用户在公开的类型中,暴露私有类型,正如参与国家机密项目的科学家,即使这个科学家(的存在)并非机密,他也不能随意讨论机密的内容。
2.3 mem::replace
的作用
如果我们使用链表实现stack,也即不记录链表的尾部,假设我们需要push新的值到链表中,就会涉及到修改链表的表头:我们需要新创建一个Node,此Node的head是原来链表的head(stack堆积性),假设Node的两个域:data(数据)与next(链表指向下一个元素的“指针”),有两个内容需要修改:
(1)head本身的值,需要修改为最新的Node,这个只需要修改head的指向
(2)新Node的next,由于我们需要原来的head作为next,这里涉及到移动或者复制。如果用复制:我很惊讶我瞎搞一通竟然过编译了(见下代码块),首先,定义一下stack使用的列表:
1 | pub struct List { |
那么假设我们需要写一个push,就涉及到了将head赋值给next的操作:
1 | impl List { |
但是直接这样是不允许的,因为self.head 会被move掉,move会使得当前链表的head直接被销毁,也即链表部分被摧毁了(或者说成,partially-initialized,有一部分没有初始化)。如上,在同样的位置(next处)使用clone,如注释所示,也是不允许的。编译器提示:clone是没有被实现的特性(self.head没有Clone函数),那么我们来实现一下。考虑到head是一个Link,那么我们把Link的clone函数实现了,是不是就可以了?如下所示:
1 | impl Clone for Link { |
其中需要注意的点:Link
的enum语法特性,需要判定当前Link
的实际意义(match)。如果是Empty
需要返回Empty
,请使用namespace指定
返回的Empty
是Link
中定义的。
如果是More
,这里需要注意两点:
(1)Link::More(next)
此处的next是一个shared
reference,如果要用来初始化则需要解引用*next
。但是很遗憾,这里会报错,直接*next
是一个移动操作。会使得Link
成为
partially-initialized的数据,这是不被允许的。
(2)直接使用next.clone()
。很抱歉,这里又有问题:
- the method
clone
exists for structBox<Node>
, but its trait bounds were not satisfied- struct Node ----------- doesn't satisfy
Node: Clone
个人猜测,这是由于Node本身没有实现clone函数导致的,于是这里我实现clone。
1 | impl Clone for Node { |
这样竟然过编译了,并且,结果也是正确的,说明这样的方法是有效的,可以通过暂时保存一份原数据的copy。但是这样是否更慢?测试显示,这种需要clone的方法,在同样进行2000次push的情况下,比官方文档中使用std::mem::replace
的版本,慢将近2000倍...
所以还是好好学习官方实现吧。
Time elapsed for my push: 309.202965ms Time elapsed for swap push: 160.718µs
2.4 在循环中使用match方法
学习教程的时候我们已经知道了,对于只有两个分类的enum,可以使用match的语法糖:
1 | let ptr: &Link = &stack.head; |
注意,Link::More(node)
本身是enum类型(此例子中,是Link
)的一个常引用(shared
ref,之所以称之为常引用,是因为个人认为这个与C++中的常引用非常类似),而node则是对应的Link::More
类型的常引用,此处也就是&Box<Node>
类型的。那么根据这个语法糖,可以写出一个遍历栈的方法:
1 | pub fn show_stack(&self) { |
首先,ptr需要是【可变的】常引用(相当于const type
*),才能改变其指向。我们判定,如果ptr不是Link::Empty
,由于此时ptr
实际对应了一个&Box<Node>
,我们将ptr
对应的Node中元素取出,并使得ptr指向下一个元素(注意,是node.next
是Link
类型的,但我们需要的ptr
是对Link
的引用,故需要符号&
)
2.5 关于泛型编程(generics)
我想直接使用模板类型T作为栈的类型。于是需要修改定义:
1 | pub struct List<T> {head: Link<T>} |
所有相关位置都需要增加<T>
以指示当前类型是模板类型。需要注意以下几个问题:
(1)如果要初始化模板类型,在类型声明时需要带有<T>
,而构造时(等号右边)不需要,比如:let new_node: Node <T> = Node {...};
,如果右边带了模板,则会报错:chained comparison operator
。类型声明同样也包括返回值。如果不声明类型,则会进行自动类型推断,绑定类型。T
根据先后顺序进行类型绑定,例如Node
的两个变量都是类型T
,而初始化时,由第一个变量传入的f32
绑定T = f32
,而第二个变量则是i32
,产生冲突,则会报错。
(2)impl
需要有<T>
,也即例如impl<T> ... Link<T>
但是事情并没有那么简单,不是改好了所有的模板声明与定义就能通过编译了(sad)。这里存在的一个问题时,我之前实现了一版基于clone
方法的push,那么想要正确应用clone
,需要
类型支持clone方法。而原来使用的i32
,由于是基本类型,在Node
进行clone时直接按值传递,无需clone,现在由于是未知的类型T
,由于其很可能不是基本类型,故直接
elem: self.elem
默认引发 move。
想要避免move,简单的想法是我直接
elem: self.elem.clone()
。聪明吧?不太聪明。self.elem
作为类型T
的变量,需要实现Clone
方法。对于类型T
而言,我不知道怎么实现它的Clone
方法(只学了两天,巨菜的),那我只能限制类型T
包含了Clone
特性了。于是:
1 | pub struct List<T> where T: Clone {head: Link<T>} |
其中,where限制了T
需要存在的特性,这被称为trait bound
。没有满足就会报错:E0277: required by this bound in xxx
。对应地,我们需要在impl
块中,告知编译器类型T
已经实现了某个trait,也即impl<T: Clone>
。
这样还没完!之前写的函数还是存在问题,猜猜改了什么?
1 | impl<T: std::fmt::Display + Clone> List<T> { |
答案是,将impl<T: Clone>
修改为impl<T: std::fmt::Display + Clone>
。这是因为Rust编译器认为,类型T
是什么啊?我怎么display它啊?你实现了Display方法没?显然我没有。那么我们是否有必要在整个stack中都添加此约束呢?比如pub struct List<T> where T Clone + std::fmt::Display
?答案是,可有可无。如果不加,对于一些复杂的自定义T
而言,不能直接调用show_stack
方法了。也即,impl
块在此处,只限制了对于此方法的调用。没有实现std::fmt::Display
特性的类型,不调用这个函数就行了,其他功能并不影响。P.S.
注意多重约束的写法是加号。P.S.2,为了简洁起见,我可能会考虑删除基于clone的版本,使得复杂类型T
不一定需要实现Clone
特性。P.S.3,注意,在type用法中,如果type
alias是一个模板类型,则无需写特性约束(写了也会被编译器忽略并且报一个warning的)。
2.6 关于所有权,租借以及move
是时候写pop了。pop,很显然需要考虑栈是否空。由于栈是否空由enum的状态表示,这里直接使用match即可,我使用了一下match的语法糖:
1 | pub fn pop(&mut self) -> T { |
这里有两个需要注意的点:
(1)进行match操作时,如果直接写let Link::More(node) = self.head
,会发生move(毕竟有let在这里嘛,可以理解成node是一个新创建的变量,self.head
将变更所有权)。我们并不希望self.head
丧失所有权(而被销毁),则可以通过一个可变引用来使用self.head。涉及到引用的地方是不可以发生move的,因为引用只是租借了变量,获得了临时的访问或修改能力,就像住房问题,共享引用(shared)只可以使用,而不能修改(比如如果选择住酒店,就不能随意装修),与之相比,可变引用就像是对房子的长租,可以进行装修。但这两种引用都不改变原房主对房屋的所有权,此时如果发生move(所有权的转让),则是在进行违法犯罪活动(没有所有权的人,要把当前房屋的所有权转移给别人)。故self.head
之前需要增加所有权引用。
(2)由于增加了引用,此处的node
也是原self.head
实际数据的引用(&Box<Node<T>>)。那么这就涉及到一个问题,self.head
是值(value),我们希望用self.head.next
(实际不能这样访问,我这里只做一个示意)来重写self.head
。但node
是一个引用,我们在self.head = node.next
的过程中,会将node
的next项move给self.head
,对引用进行的move,是不被允许的。果不其然,最后报错了。
此处依然可以使用mem::replace
。注意mem::replace
的性质:
1 pub fn replace<T>(dest: &mut T, src: T) -> TMoves
src
into the referenceddest
, returning the previousdest
value.
replace将目的位置用src替换,并且 按值
返回dest位置的变量。正好,我们需要self.head
按值而非按引用返回的结果。这样获得的node
就可以进行move(因为按值传递的node
具有所有权)。则可以简单重写第二行:
1 | if let Link::More(node) = mem::replace(&mut self.head, Link::Empty) {... |
这样就可以过编译了。P.S:如果需要可控的错误控制,可以返回Option<T>
,当Empty时返回None,就不用不可恢复的panic宏了。
2.7 Drop特性的实现
由于原本的stack链表,自动析构过程不能进行尾递归优化,为了防止析构时爆栈,需要手动析构,也即实现Drop特性。直接实现如下:
1 | impl<T: Clone + Default> Drop for List<T> { |
看起来好像很有道理?首先,我将ptr指向的本节点换出(到值),同时使得原变量置为Empty,再使得ptr指向换出后的值对应的next。但这样写是有问题的,因为我这样实现,利用了一个特性:我将ptr换出到值,此值是临时变量,在结束本次循环之后,会析构,也即此node无效了,并且原变量也设成了Empty。但我对node.next进行的引用操作,阻止了我利用临时变量短生命周期的特性,ptr指向的内容将可能无效。故这样写会触发编译错误。
正确的写法仍然是,按值传递:ptr应该一直是下一个node的值,可以写成:
1 | impl<T: Clone + Default> Drop for List<T> { |
这样,每一个node原有的变量都会由replace方法,将所有权转移到ptr中,原变量全部置为Empty。注意,由于是按值转移的,给原变量赋值Empty也并不会妨碍我们在replace后使用ptr取出其中的node,因为所有权以及变量真正的信息已经转移到了ptr中。其中需要注意的两点:
(1)node这玩意,如果不加前面的mut,是错的。因为这里我们写的是let <variable> = ...
这样的句式,我们定义了一个变量node,但是没有将其定义成可变的。对于不可变变量,不能使用可变引用,在下一行的replace处会报错。对while let
句式中,等号前面变量,也需要理解成是一个正规的变量定义过程。那么此处,将while let
写为标准的match形式,也有同样的操作:
1 | impl<T: Clone + Default> Drop for List<T> { |
这里需要注意的是,loop与match的配合用法(在多类型enum下使用的逻辑)。则根据这个例子,我们可以认为,match的过程中可能定义新的变量,那么此变量的性质可以根据括号内的内容进行确定。
(2)还有另一种写法while let Link::More(node) = &mut ptr
,我们来分析一下为什么也可以过编译:这样写,node是ptr的一个可变引用。如果需要使用结构体中的某个值,自然可以可变引用自这个结构体的可变引用了。此处的引用不会叠加,&mut node.next
不会称为引用的引用(因为node.next
不是引用,node
才是)。故replace后,可以把node,next
处对应的变量所有权移动到ptr中,变量的类型(ptr是Link<T>
)也可以对应replace的结果。
III. Stack with Option
3.1 转向Option
不难发现,前面的enum Link
就是一个弱化版的Option。至于为什么是弱化版的,答案很显然,Option作为一个标准库提供的模块,自然有更多方便使用的函数。其中一个就是take函数。我们可以看take函数的定义:
1 pub fn take<T>(dest: &mut T) -> TReplaces
dest
with the default value ofT
, returning the previousdest
value.
这里有两种形式的take,一个是std::mem::take
(官方文档非常清晰),另一种是:实例化的Option可以调用的take函数。两者的功能是类似的,只不过mem::take
针对所有类型。查看Option的官方文档可以知道,take返回Option自身的同时,将原变量设为None,相当于进行所有权转移的函数,用此函数可以避免move。而对于mem::take
,官方文档给了一个简单易懂的例子:
1 | struct Buffer<T> { buf: Vec<T> } |
上面的error说的就是:由于函数传入的是self的可变引用,不能被move,故报错。
那么,使用take函数,可以简化原来使用mem::replace
的位置,如drop函数:
1 | impl<T: Clone + Default> Drop for List<T> { |
只需要记住,ptr需要接受按值传递的结果,这样不会引起move问题。此外,Option还实现了两个重要的方法:map
以及unwrap
。
map,有那么一些类似于python中的map。python中的map可以将一个iterator中的所有元素通过某个函数进行映射。而此处的map,是将Option进行变换。官方的定义是:map可以通过传入的函数,将Option<T>
转换为Option<U>
:
1 pub fn map<U, F>(self, f: F) -> Option<U>Maps an
Option<T>
toOption<U>
by applying a function to a contained value.
官方文档提供了一个这样的例子。对于Option<String>
类型的变量来说(也就是带有None选项的String),直接求len()
是非常繁琐的,我们需要通过好几行match指令块或者if let
语法糖才能得到其长度。而对于map而言,由于传入的函数,操作的是T,本质是一个T->U的映射函数,可以很轻松地获得其长度:
1 | let result = some_string.map(|s| {s.len()}); |
那么,map的变体有map_or
以及map_or_else
,我觉得比较有价值的是map_or
。其实不光是map函数有_or
以及_or_else
实现,其他一些函数也有这样的实现,如之后要说的unwrap
,
ok
,or
本身也是一个函数。map_or
的输入则是:
1 | pub fn map_or<U, F>(self, default: U, f: F) -> U |
也即需要提供一个默认值。也即当结果为None时,返回一个默认的结果。map_or_else
则是,传入两个函数,如果当前值非None,则执行第二个函数(f),否则执行第一个函数。
而unwrap,根据其名字理解,也即解包。显然,在Option中,我们将有意义的值包在了Some中,但通常我们可能需要内部的值。使用match块同样太过复杂,我们考虑用unwrap,这个函数实际就是一个Option<T>
到T的映射。同样地,其_or
以及_or_else
方法,都有类似的map对应函数的函数思想。值得一提的是,unwrap_or_else
中有这么一句话:
Returns the contained
Some
value or computes it from a closure.
闭包?这里说的实际上是我们传递的匿名函数,写法就是|Some的内部变量| {有返回值的函数体}
。此函数与C++的匿名函数很像,也可以从外部捕获变量(捕获的变量也一样无需写在|·|
(或C++中[·]
)中),用在函数体中进行计算。
知道map
的工作原理之后,我们可以立刻用map进行一些程序改写。比如stack中的pop,我们希望在修改head的同时,可以返回原head对应node的值。则可以按照如下方式进行书写:
1 | impl<T: Default> List<T> { |
此处的逻辑很简单:首先take()将self.head
置None,并且返回实际存储的Some(node),Some(node)进入map中的函数被处理,取出其中的node(按值),重新覆盖self.head
(node变量内部值的move),按值返回node的elem域。但注意如果没有.unwrap()
,如上返回的Some(T)或者None,如果要返回真正的T
类型值,则需要加上unwrap
,但这不是很危险,当stack为empty时,也不知道会发生什么(我们删去了empty处理)。
3.2 as_ref | as_mut | as_deref
之前我想实现一个top函数,不过此top函数是返回值的函数,而官方教程则说:我们应该实现一个返回引用的top(教程叫做peek)。开始我觉得这好像也不是什么难事吧?于是我写了一波:
1 | pub fn top(& self) -> &T { |
结果直接报错了,错误信息:
&node.elem returns a reference to data owned by the current function
self.head.map(|node|{... move occurs because
self.head
has typeOption<Box<second::Node<T>>>
, which does not implement theCopy
trait
我的理解是,self.head.map由于实际是match方法的一种简化形式,那么根据match倾向于move值这个特性,self.head被隐式move了。这样会导致错误。而与原先不同,我们不能使用take()函数(take会使得原来的self.head被设为None,同样一次take的结果也不能被move到多个变量中)。教程上推荐使用as_ref
方法,于是我在官方文档上查了一下这个函数。看到这个函数中的其中一句话,我终于透彻地理解了文档中的两句话的意义:
- "Consume the original" 表示原变量将被销毁(被move,所有权丧失)
- "Preserving the original" 表示保留原变量
但是as_ref
文档中所说的:"Converts from
&Option<T>
to Option<&T>
."
具体表示什么意思呢?引用符号在内部外部的区别是什么?我是这么理解的,仍然以stack中的内容为例子。stack的Link,其类型是Option<Box<Node<T>>>
。那么上面这句话的意思就是将&...
转化为Option<& Box<Node<T>>>
。为什么要这样呢?我们先回顾一下map的性质。我们在函数的参数列表中写的是& self
,这就导致了self.head.map
无法进行,这是因为,由于map具有类似match的属性,会导致self.head
发生move(到新产生的变量node
中,如果还不是很明白,请参考本博客的这里),引用内部是不允许发生move的,故报错。而take
则将内部的Node取了出来,但是是按值取的,原来的head
内容变成了None,并且难以恢复。故这里,我们希望以一种不破坏原来的self.head
的方式,返回一个对self.head
中node的引用。注意到:由于参数列表中&self
的存在,这里的self.head
实际上
就是&Option<Box<Node<T>>>
。
如果将其转化成Option <&Box<Node<T>>>
,那么map得到的结果node,就是一个引用(&Node<T>
),我们可以通过如下代码来确定:
1 | fn top_node(&self) -> &Node<T> { |
注意,由于以上代码只是测试使用,我没有定义成public函数(事实上,定义成public函数会报错,Node是一个私有类型,被放在了公共区域,private
type
leak错误)。这段代码是可以过编译的(放在对应的impl
块中)。则说明,node本身就是一个引用。故不发生move,self.head
被成功保存下来了。太好了,我们又赢啦!那么,最后如果要返回&node.elem
,只需要在self.head
之后,加上.as_ref()
即可。
那么余下的两个函数,根据分析as_ref
时的经验,理解起来应该会容易很多:
as_mut
: Converts from&mut Option<T>
toOption<&mut T>
. 也即,当结构体成员函数使用&mut self
输入时,as_mut
后接的map
将可以输出node的可变引用as_deref
: 我错了,我暂时不想去递归deref以及Deref::Target
是个什么玩意。
这里我想再按照自己的理解,解读一下官方教程的例子。官方教程想通过自定义的peek_mut
修改栈顶数据,但是:
1 | list.peek_mut().map(|&mut value| { |
这样写是显然不可以的。首先,peek_mut
就如我们的top_node_mut
一样,返回的是&mut T
(官方教程则是返回Option<&mut T>
),在map之后,将成为&mut T
,也即,value本身就是一个mutable
reference,那么此例子中,对于一个mutable
reference,再进行引用,成了mutable reference的mutable
reference,会怎么样呢?首先,mutable reference变量value,只是说
其指向的内容是可变的,但并没有声明,value本身可以变(也即value不可以改变指向,但是能改变指向的内容),只有value本身有mut属性,才能安全地对value进行mutable
reference。这里显然没有这样的条件,并且即使挂上了这个引用,最后修改的也是value值,而不是value指向的值。
Rust 其他TODO项
- 值得一记,回头搞清楚原理(主要是dyn关键词用法):使用函数作为参数进行传递(callback类写法)
1 | fn main() { |
Vec
切片的to_vec
函数与切片类型(切片转数组或其他数据类型时的overhead)Vec
切片之后会变成&[type]
?比如&vec[3..9]
实际上是& [i32]
?
cargo new --lib <name>
的lib
具体起什么作用?Box
说是堆的指针?其作用类似于C中动态分配内存的结构?