rust入坑小记-09-函数式编程与指针
三、rust进阶篇
1 rust与函数式编程
rust的设计灵感来源于很多现存的语言和技术。其中一个显著的影响就是函数式编程(functional programming)。函数式编程风格通常包含将函数作为参数值或其他函数的返回值、将函数赋值给变量以供之后执行等等。
我们不会讨论函数式编程是或不是什么的问题,而是展示rust的一些在功能上与其他被认为是函数式语言类似的特性。主要内容如下:
- 闭包(Closures),一个可以储存在变量里的类似函数的结构。(这里的闭包是函数式编程概念的闭包)
- 迭代器(Iterators),一种处理元素序列的方式。
- 模式匹配
- 枚举
- 闭包和迭代器的性能。
在基础篇,我们已经介绍了其它受函数式风格影响的rust功能:模式匹配和枚举,因此本章的重点放在闭包和迭代器,掌握闭包和迭代器是编写符合语言风格的高性能rust代码的重要一环。
1.1 闭包
首先要说明的是,我们这里提到的闭包仅限于函数式编程概念上,而不是闭包最初始的定义。对于闭包的另一种解释,在我的文章编译原理中有介绍。其中有一些概念相同,而有一些则不同,我们这里主要讨论rust所定义的闭包。
在rust中的闭包(closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。我们将展示闭包的这些功能如何复用代码和自定义行为。
闭包捕获环境
首先来了解如何通过闭包捕获定义它的环境中的值以便之后使用。
考虑如下场景:有时 T 恤公司会赠送限量版 T 恤给邮件列表中的成员作为促销。邮件列表中的成员可以选择将他们的喜爱的颜色添加到个人信息中。如果被选中的成员设置了喜爱的颜色,他们将获得那个颜色的 T 恤。如果他没有设置喜爱的颜色,他们会获赠公司现存最多的颜色的款式。
有很多种方式来实现这些。例如,使用有 Red 和 Blue 两个成员的 ShirtColor 枚举(出于简单考虑限定为两种颜色)。我们使用 Inventory 结构体来代表公司的库存,它有一个类型为 Vec<ShirtColor> 的 shirts 字段表示库存中的衬衫的颜色。Inventory 上定义的 giveaway 方法获取免费衬衫得主所喜爱的颜色(如有),并返回其获得的衬衫的颜色。
1 |
|
在上面的代码中,main 函数中定义的 store 还剩有两件蓝衬衫和一件红衬衫可在限量版促销活动中赠送。我们用一个期望获得红衬衫和一个没有期望的用户来调用 giveaway 方法。这里采取的实现都是基础篇中讲过的内容,除了giveaway 方法体中使用的闭包。
giveaway 方法获取了 Option<ShirtColor> 类型作为用户的期望颜色并在 user_preference 上调用 unwrap_or_else 方法。该方法由rust标准库中的Option提供,它获取一个没有参数、返回值类型为 T (与 Option<T> 的 Some 成员所存储的值的类型一样,这里是 ShirtColor)的闭包作为参数。如果 Option<T> 是 Some 成员,则 unwrap_or_else 返回 Some 中的值。 如果 Option<T> 是 None 成员, 则 unwrap_or_else 调用闭包并返回闭包的返回值。
因此,对于第一个成员,他喜爱的颜色user_pref1是Some(ShirtColor::Red),则调用unwrap_or_else时返回的就是Some中的ShirtColor::Red。
重点是第二个成员,他没有设置喜爱的颜色(user_pref2是None),则调用unwrap_or_else时,会去调用闭包并返回闭包的返回值。
我们将闭包表达式 || self.most_stocked() 用作 unwrap_or_else 的参数。这是一个本身不获取参数的闭包(如果闭包有参数,它们会出现在两道竖杠之间)。闭包体调用了 self.most_stocked()。我们在这里定义了闭包,而 unwrap_or_else 的实现会在之后需要其结果的时候执行闭包。对于本例来说,第二个成员就会执行该闭包,调用most_stocked方法获取公司现存最多的颜色的款式并返回。
这里一个有趣的地方是我们传递了一个会在当前 Inventory 实例上调用 self.most_stocked() 的闭包。标准库并不需要知道我们定义的 Inventory 或 ShirtColor 类型或是在这个场景下我们想要用的逻辑。闭包捕获了一个 Inventory 实例的不可变引用到 self,并连同其它代码传递给 unwrap_or_else 方法。相比之下,函数就不能以这种方式捕获其环境。
闭包的定义
通常,类似于lambda表达式一样,闭包是一个匿名函数,不需要标注类型,定义形式如下:
1 | |param1, param2,...| { |
如果只有一个返回表达式:
1 | |param1| 返回表达式 |
如果没有参数:
1 | || 返回表达式 |
闭包的一些特性:
- 声明时使用
||将输入参数括起来。 - 如果有多行,需要用
{},对于单个表达式则不需要。 - 闭包有能力捕获外部环境的变量
- 闭包中最后一行表达式返回的值,就是闭包执行后的返回值
- 将闭包绑定到变量上并不会执行它,比如定义
let a = || ...,这里的a就相当于函数一样可以调用:a()
闭包类型推断和注解
函数与闭包还有更多区别。闭包并不总是要求像 fn 函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格定义这些接口对保证所有人都对函数使用和返回值的类型理解一致是很重要的。与此相比,闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。
闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠地推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样;当然,同时也有编译器需要闭包类型注解的极少数情况。
类似于变量类型标注一样,如果我们希望增加明确性和清晰度也可以为闭包添加类型标注,坏处是使代码变得更啰嗦。比如:
1 | let expensive_closure = |num: u32| -> u32 { |
我们定义了一个闭包并将它保存在变量中,并为参数和返回值增加了类型注解。使用这种语法就更类似函数了,下面是函数和闭包在形式上的对比:
1 | fn add_one_v1 (x: u32) -> u32 { x + 1 } |
第一行展示了一个函数定义,第二行展示了一个完整标注的闭包定义。第三行闭包定义中省略了类型注解,而第四行去掉了可选的大括号,因为闭包体只有一个表达式。这些都是有效的闭包定义,并在调用时产生相同的行为。
调用闭包是 add_one_v3 和 add_one_v4 能够编译的必要条件,因为类型将从其用法中推断出来。这类似于 let v = Vec::new();,rust需要类型注解或是某种类型的值被插入到 Vec 才能推断其类型。
编译器会为闭包定义中的每个参数和返回值推断一个具体类型。但是,类型推断不是泛型,一旦类型确定,编译器就会一直使用该类型,如果尝试对同一闭包使用不同类型则就会得到类型错误,比如:
1 | let example_closure = |x| x; |
这里定义了一个闭包,接受一个参数x并直接将其返回。在闭包定义中没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包,所以第一次调用闭包时,我们使用String类型作为参数,编译器推断这个闭包中 x 的类型以及返回值的类型是 String。接着这些类型被锁定进闭包 example_closure 中。
紧接着第二次我们使用 i32调用闭包,这与编译器之前推导的 String 类型不符,因此报错:
1 | | |
捕获引用或者移动所有权
闭包可以通过三种方式捕获其环境,它们直接对应到函数获取参数的三种方式:不可变借用,可变借用和获取所有权。闭包会根据函数体中如何使用被捕获的值决定用哪种方式捕获,一般来说,在满足使用需求的前提下以最小的访问捕获,比如:如果不可变借用可以完成任务,就不会使用可变借用。
获取不可变引用
1 | fn main() { |
这里定义了一个捕获名为 list的动态数组的不可变引用的闭包,因为只需不可变引用就能打印其值。因为同时可以有多个 list 的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问 list。代码可以通过编译。
获取可变引用
接下来,我们修改闭包体让它向 list动态数组增加一个元素。
1 | fn main() { |
闭包现在捕获一个可变引用。注意在 borrows_mutably 闭包的定义和调用之间不再有 println!,当 borrows_mutably 定义时,它捕获了 list 的可变引用。闭包在被调用后就不再被使用,这时可变引用结束。由于可变引用与不可变引用不能同时存在,当可变引用存在时(第5~8行)不允许用println!获取不可变引用来打印,尝试取消第6行的注释并运行,看看会获得什么错误。
另外,由于在闭包中改变了外部list的值,捕获的是它的可变引用,此时还需要将闭包绑定的变量也设置为mut,即:let mut borrows_mutably。
获取所有权
即使闭包体不严格需要所有权,如果希望强制闭包获取它用到的环境中值的所有权,可以在参数列表前使用 move 关键字。在将闭包传递到一个新的线程时这个技巧很有用,它可以移动数据所有权给新线程。关于并发的细节我们放到后面,现在首先来简单探讨用需要 move 关键字的闭包来生成新的线程。
1 | use std::thread; |
我们生成了新的线程,给这个线程一个闭包作为参数运行,闭包体打印出列表。这里闭包通过不可变引用捕获 list,因为这是打印列表所需的最少的访问。这个例子中,尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move 关键字来指明 list 应当被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,或者也可能主线程先执行完。如果主线程维护了 list 的所有权但却在新线程之前结束并且丢弃了 list,则在线程中的不可变引用将失效。因此,编译器要求 list 被移动到在新线程中运行的闭包中,这样引用就是有效的。如果去掉 move 关键字或在闭包被定义后在主线程中使用 list 就会报错。
将被捕获的值移出闭包和Fn特征
闭包可以当做参数传递给函数,这就涉及到一个问题:函数的参数必须显式标注其类型,而闭包的类型随着参数和返回值的变化而变化。有没有一种方式可以统一表示一类闭包呢?没错,可以利用泛型和泛型的特征约束来做到这一点。对应于捕获其环境的三种方式:转移所有权、可变借用、不可变借用,相应的我们将其称为 Fn 特征,也有三种:
FnOnce表示捕获方式为通过获取所有权(T)的闭包。适用于能被调用一次的闭包,所有闭包都至少实现了这个特征,因为所有闭包都能被调用。一个会将捕获的值移出闭包体的闭包只实现FnOnce特征,这是因为它只能被调用一次。FnMut表示捕获方式为通过可变引用(&mut T)的闭包。适用于不会将捕获的值移出闭包体的闭包,但它可能会修改被捕获的值。这类闭包可以被调用多次。Fn表示捕获方式为通过不可变引用(&T)的闭包。适用于既不将被捕获的值移出闭包体也不修改被捕获的值的闭包,当然也包括不从环境中捕获值的闭包。这类闭包可以被调用多次而不改变它们的环境,这在会多次并发调用闭包的场景中十分重要。
FnOnce
下面来看看我们在之前使用的Option<T> 上的 unwrap_or_else 方法的定义:
1 | impl<T> Option<T> { |
T 是表示 Option 中 Some 成员中的值的类型的泛型。类型 T 也是 unwrap_or_else 函数的返回值类型:举例来说,在 Option<String> 上调用 unwrap_or_else 会得到一个 String。接下来注意到,unwrap_or_else 函数有额外的泛型参数 F。 F 是 f 参数(即调用 unwrap_or_else 时提供的闭包)的类型。在where中,泛型 F 的特征约束是 FnOnce() -> T,这意味着 F 必须能够被调用一次,没有参数并返回一个 T。
在特征约束中使用 FnOnce 表示 unwrap_or_else 将最多调用 f 一次。在 unwrap_or_else 的函数体中可以看到,如果 Option 是 Some,f 不会被调用。如果 Option 是 None,f 将会被调用一次。由于所有的闭包都实现了 FnOnce,unwrap_or_else 能接收绝大多数不同类型的闭包,十分灵活。
再看一个例子:
1 | fn fn_once<F>(func: F) |
这里定义了一个函数,F的特征约束是FnOnce(usize) -> bool,这意味着F有一个usize类型的参数,返回值为bool类型。这里仅实现了FnOnce,因此在调用时会转移这个闭包所有权,如果将第6行取消注释,会提示转移所有权后再次调用的错误。
FnMut
1 | fn fn_mut<F>(mut func: F) |
同样定义一个函数,在这里F的特征约束为FnMut(&str) -> (),意味着F有一个&str类型的参数,没有返回值。在闭包中,我们调用 a.push_str 去改变外部 a 的字符串值,因此这里捕获了它的可变引用。由于闭包内部捕获了可变引用,闭包变量也要添加mut声明,即mut func。在函数内多次调用闭包,修改外部a的值,最终会输出helloworld!。
Fn
1 | fn exec<F>(func: F) |
同样地,我们将闭包改为获取a的不可变引用,并修改F的特征约束为Fn即可。
深入理解Fn特征
对闭包所要捕获的每个变量,编译器会根据闭包的行为自动推断以限制最少的方式来捕获。但是,一个闭包实现了哪种Fn特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。因此,使用 move 的闭包也可能实现了Fn 或 FnMut 特征。
1 | fn exec<F>(func: F) |
对于这段代码,闭包的行为本身只需要获取a的不可变引用即可,但这里用move将a的所有权转移进闭包。此时这个闭包不仅仅实现了FnOnce,还实现了Fn 和 FnMut 特征。将FnOnce修改为Fn依然可以通过编译:
1 | fn exec<F>(func: F) |
可以这样理解,一个闭包实现哪几种Fn特征,是由该闭包如何使用被捕获的变量而决定的:
- 所有的闭包都自动实现了
FnOnce特征,因此任何一个闭包都至少可以被调用一次 - 没有移出所捕获变量的所有权的闭包自动实现了
FnMut特征 - 不需要对捕获变量进行改变的闭包自动实现了
Fn特征
使用move可以强制获取捕获的变量的所有权,但与闭包实现了哪些特征没有必然联系。
通过源码来看看这三种特征的定义:
1 | pub trait FnOnce<Args: Tuple> { |
这里用到了特征“继承”,如果你接触过支持继承的语言,应该不难理解:继承通常用来描述子类拥有父类的特征和行为。在rust中,可以定义依赖于某个特征的特征(可以理解为特征继承)。比如,定义特征
A和B,希望B特征也实现了A特征,如此一来就可以在B中使用A特征的关联项。我们可以称特征B继承特征A,实现继承的方式很简单,在定义特征B时使用冒号加上特征A即可。
1
2 trait A {}
trait B: A {}
主要关注以下两点:
Fn继承FnMut,FnMut继承FnOnce,因此要实现Fn就要同时实现FnMut和FnOnce- 另外,
Fn获取&self,FnMut获取&mut self,而FnOnce获取self。FnOnce中,call_once函数第一个参数为self,也就是闭包自身。因此,FnOnce闭包一旦被调用,闭包本身的所有权就会转移到call_once内部,这也是为什么FnOnce特征的闭包至多能够被调用一次。FnMut中,call_mut方法中传入的是引用&mut self,因此call_mut可以被调用多次。不过,由于继承关系的存在,FnMut也可以调用call_once(相当于将FnMut当成FnOnce来使用),一旦调用了call_once,就不能再调用其它方法了。Fn中,call只获取了不可变引用,因此也可以调用多次,同样,由于它继承了FnMut,一旦调用了call_once,就不能再调用其它方法了。
在实际项目中,应该优先使用 Fn 特征,让编译器去推断是否使用正确,以及该如何选择。
闭包作为函数的返回值
闭包表现为特征,在返回实现了特征的类型中,介绍过函数返回实现了某个特征的类型。比如:
1 | fn factory() -> impl Fn(i32) -> i32 { |
上面的代码中,函数签名impl Fn(i32) -> i32表示返回一个闭包类型,它实现了 Fn(i32) -> i32 特征。注意需要对闭包添加move转移被捕获变量num的所有权,因为闭包可能比当前函数活得更久,但它借用了函数拥有的本地变量num。
这样做可以,但正如返回实现了特征的类型提到的,这种方式有一个局限,如果返回类型不单一,则无法通过编译:
1 | fn factory(x:i32) -> impl Fn(i32) -> i32 { |
即使签名一样的闭包,类型也是不同的。不过,解决方法也和impl Trait的解决方法一样,使用特征对象即可:
1 | fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> { |
1.2 迭代器
迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。
rust中的迭代器是惰性的(lazy),在调用方法使用迭代器之前,它都不会有效果:
1 | fn main() { |
迭代器被储存在 v1_iter 变量中,此时还没有对它进行迭代。一旦创建迭代器之后,可以选择用多种方式利用它,比如对它进行遍历:
1 | fn main() { |
在标准库中没有提供迭代器的语言中(如C),我们可能会使用一个从0开始的索引变量,使用这个变量索引vector中的值,并循环增加其值直到达到vector的元素数量。而迭代器没有使用索引,无需去关心索引的起始位置、终止位置,迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。并且,对于不同的序列,如数组、动态数组、hashmap都可以使用相同的逻辑处理。
Iterator特征和next方法
所有的迭代器都实现了Iterator特征,它定义于标准库中。这个特征的定义如下:
1 | pub trait Iterator { |
不过,此处有两处新的语法:type Item 和 Self::Item,它们定义了特征的关联类型,我们会在后面介绍关联类型。现在只需知道这段代码表明实现 Iterator 特征要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。
next 是 Iterator 实现者被要求定义的唯一方法。next一次返回迭代器中的一个项,封装在Some中,当迭代器结束时,它返回None。可以直接使用next方法:
1 | fn main() { |
需要为迭代器添加mut,因为在迭代器上调用 next 方法改变了迭代器中用来记录序列位置的状态。换句话说,代码消费(consume)了,或使用了迭代器。从源码的角度来看,next方法的签名fn next(&mut self),获取的是当前迭代器实例的可变引用。每一个 next 调用都会从迭代器中消费一个项。
for循环和迭代器
这里有一个细节,为什么在for循环时不需要将迭代器设置为可变的呢:
1 | fn main() { |
其实for本身是迭代器的语法糖,如果没有特别指定,for 循环会对in后面的集合隐式应用 into_iter 方法,这个方法是在IntoIterator特征中被定义的,该特征声明如下:
1 | pub trait IntoIterator { |
根据The Rust Reference,for in可以等价地写成:
1 | fn main() { |
通过into_iter方法,会获得这个动态数组的迭代器,使用match去匹配它,匹配项mut iter说明,在内部将这个迭代器转换为可变的。然后,通过一个loop,不停地调用next方法,匹配它的返回值:如果是Some(x)就打印它们,直到遇到None,就终止循环。
对于迭代器本身,它也实现了 IntoIterator特征,源码是这样实现的:
1 | impl<I: Iterator> const IntoIterator for I { |
因此:
- 对于非迭代器(如动态数组),
into_iter会尝试将其转化为迭代器并返回。 - 对于迭代器,
into_iter会返回它本身self.
由于for的语法糖,在内部已经自动实现了mut iter这样的转换,所以不需要将迭代器设置为可变的。
迭代器和所有权
在前面我们介绍了IntoIterator特征中的into_iter方法,该方法会获取集合的所有权,因此使用for循环遍历一个集合将消耗该集合。
1 | fn main() { |
有些时候可能希望迭代一个集合而不是获取它的所有权,许多集合提供了迭代器获取引用的方法,通常分别称为iter和iter_mut。顾名思义:
iter返回的迭代器会获取集合元素的不可变引用iter_mut返回的迭代器会获取集合元素的可变引用into_iter返回的迭代器会获取集合元素的所有权
通常情况下,如果一个集合类型 C 提供iter方法,那么它通常还为 &C 实现 IntoIterator特征,而该实现只是调用 iter方法;同样,提供iter_mut方法的集合类型 C 通常为 &mut C 实现 IntoIterator特征,这样操作的好处就是:
1 | fn main() { |
可以便捷地通过&和&mut而不需要使用方法名。
最后需要注意几点:
into_iter是IntoIterator特征必须实现的方法,另外两个方法并不要求必须实现。尽管许多集合都提供
iter方法,但并非所有集合都提供iter_mut。例如,如果键的哈希值发生更改,则对hashset或hashmap的键进行更改可能会使该集合处于不一致状态,因此这些集合仅提供iter。
消费适配器与迭代器适配器
在Iterator特征中定义了一些默认实现的方法,有一些方法通过next 方法来消费元素。这些方法被称为消费适配器(consuming adaptors)。比如sum方法:
1 | fn main() { |
这个方法获取迭代器的所有权并反复调用 next 来遍历迭代器,因而会消费迭代器。当其遍历每一个项时,它将每一个项加总到一个总和并在迭代完成时返回总和,如果是空迭代器,则会返回该类型的零值。
强调一下,sum会获取迭代器的所有权,因此it在调用sum后失效,但values仍然可用,因为我们是使用iter方法创建的迭代器。
另外一类方法被称为迭代器适配器(iterator adaptors),它们允许我们将当前迭代器变为不同类型的迭代器。这样就可以实现链式调用,不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。
1 | fn main() { |
这里展示了一个调用迭代器适配器方法 map 的例子,该 map 方法使用闭包来调用每个元素以生成新的迭代器。这里的闭包创建了一个新的迭代器,对vector中的每个元素都加1。不过由于迭代器是惰性的,还需要一个消费迭代器。
1 | fn main() { |
collect 方法消费迭代器并将结果收集到一个数据结构中。我们为 v2 标注了 Vec<_> 类型,就是为了告诉 collect:请把迭代器中的元素消费掉,然后把值收集成 Vec<_> 类型,至于为何使用 _,因为编译器会帮我们自动推导。至于为什么 collect 在消费时要指定类型?是因为该方法其实很强大,可以收集成多种不同的集合类型,Vec<T> 仅仅是其中之一,因此我们必须显式地告诉编译器我们想要收集成的集合类型。
还有一点值得注意,map 会对迭代器中的每一个值进行一系列操作,然后把该值转换成另外一个新值,该操作是通过闭包 |x| x + 1 来完成:最终迭代器中的每个值都增加了 1,从 [1, 2, 3] 变为 [2, 3, 4]。
从这个例子中也可以看出,迭代器的许多方法都可以使用闭包作为参数,它最大的好处不仅在于可以就地实现迭代器中元素的处理,还在于可以捕获环境值。
1 | struct Shoe { |
这里使用 filter 方法来获取一个闭包。该闭包从迭代器中获取一项并返回一个 bool。如果闭包返回 true,其值将会包含在 filter 提供的新迭代器中。如果闭包返回 false,其值不会被包含。使用 filter 和一个捕获环境中变量 shoe_size 的闭包来遍历一个 Shoe 结构体集合。最终通过 collect 收集为 Vec<Shoe> 类型。
enumerate
在流程控制for循环中,曾经介绍过enumerate获取可迭代对象的索引,通过本章的介绍,应该会更加深入地理解这个过程。
1 | fn main() { |
这里的 a.iter() 创建迭代器,然后调用 Iterator 特征上的 enumerate方法,该方法产生一个新的迭代器,其中每个元素均是元组 (索引,值)。这里的enumerate就是一个迭代器适配器,可以对其返回的迭代器继续进行链式调用:
1 | fn main() { |
这里filter的闭包参数将索引能被2整除的元素保留,然后通过map内的闭包将其索引去掉,最后使用fold计算累计和,最终结果为8。
迭代器与循环的性能
迭代器作为一个高级的抽象,被编译成了与手写的底层代码大体一致性能代码。迭代器是rust的零成本抽象(zero-cost abstractions)之一,它意味着抽象并不会引入运行时开销。这与C++ 的设计和实现者本贾尼·斯特劳斯特卢普(Bjarne Stroustrup)在 《Foundations of C++(2012)》中所定义的零开销(zero-overhead)如出一辙:
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
从整体来说,C++ 的实现遵循了零开销原则:你不需要的,无需为他们买单。更有甚者的是:你需要的时候,也不可能找到其他更好的代码了。
rust编写的音频解码器的代码,解码算法使用线性预测数学运算来根据之前样本的线性函数预测将来的值。这些代码使用迭代器链来对作用域中的三个变量进行了某种数学计算:
1 | let buffer: &mut [i32]; |
为了计算 prediction 的值,这些代码遍历了 coefficients 中的 12 个值,使用 zip 方法将系数与 buffer 的前 12 个值组合在一起。接着将每一对值相乘,再将所有结果相加,然后将总和右移 qlp_shift 位。
当然,无需看懂这段代码,这里想要表达的是:这里创建了一个迭代器,使用了两个适配器,接着消费了其值。rust代码将会被编译为什么样的汇编代码呢?事实上,它被编译成与手写的效率相同的汇编代码。遍历 coefficients 的值完全用不到循环:rust知道这里会迭代 12 次,所以它展开(unroll)了循环。展开是一种移除循环控制代码的开销并替换为每个迭代中的重复代码的优化。所有的系数都被储存在了寄存器中,这意味着访问他们非常快。这里也没有运行时数组访问边界检查。
总之,大胆地使用迭代器和闭包吧,它们使得代码看起来更高级,但并不为此引入运行时性能损失。
1.3 一些例子
例1
rust官方提供了一个练习rust的仓库,叫做rustlings,里面有很多rust语法的练习题。这里介绍其中的一道迭代器和闭包的的练习题,它位于exercises/iterators/iterators4.rs:
1 | pub fn factorial(num: u64) -> u64 { |
题目很简单,要求计算num的阶乘。但是做了一些要求,不可以使用return,尝试不使用命令式循环(for,while,loop)和额外的变量,作为额外的挑战,不能使用递归。
如果没有这些限制我们可以创建一个额外变量存储结果,然后使用for循环遍历这个整数;或者使用递归代码。但是在rust中,你可以使用另一个函数方法,使用range和迭代器优雅地计算阶乘。
在rust中,使用..可以创建Range类型的变量,它表示一个连续的范围,比如:
1 | let a: std::ops::Range<i32> = 0..3; |
这个类型实现了Iterator特征,因此,我们可以使用fold方法。fold 接受两个参数:一个初始值和一个带有两个参数的闭包:一个“累加器”和一个元素。闭包返回累加器下一次迭代应具有的值。如:
1 | let a = [1, 2, 3]; |
因此,要计算阶乘,我们只需要这样实现即可:
1 | pub fn factorial(num: u64) -> u64 { |
很好,这符合题目的限制,并且只需要一行代码。
2 智能指针
指针 (pointer)是一个包含内存地址的变量的通用概念。rust中最常见的指针是引用。引用以&符号为标志并借用了它们所指向的值。除了引用数据没有任何其他特殊功能,也没有额外开销。
智能指针(smart pointers)是一类数据结构,它们的表现类似指针,但是也拥有额外的元数据和功能。智能指针的概念并不为rust所独有;其起源于C++并存在于其他语言中。rust标准库中定义了多种不同的智能指针,它们提供了多于引用的额外功能。为了探索其基本概念,我们来看看一些智能指针的例子,这包括引用计数 (reference counting)智能指针类型。这种指针允许数据有多个所有者,它会记录所有者的数量,当没有所有者时清理数据。在rust中因为引用和借用,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反,在大部分情况下,智能指针拥有它们指向的数据。
在之前的章节中,其实已经出现过智能指针:String和Vec<T>,只不过当时并没有这样称呼它们。这些类型都属于智能指针因为它们拥有一些数据并允许你修改它们。它们也拥有元数据和额外的功能或保证。例如String存储了其容量作为元数据,并拥有额外的能力确保其数据总是有效的UTF-8编码。
智能指针通常使用结构体实现。智能指针不同于结构体的地方在于其实现了Deref和Drop特征,Deref 特征允许智能指针结构体实例表现的像引用一样,这样就可以编写既用于引用、又用于智能指针的代码。Drop 特征允许我们自定义当智能指针离开作用域时运行的代码。
智能指针是一个在rust经常被使用的通用设计模式,这里并不会覆盖所有现存的智能指针。很多库都有自己的智能指针而你也可以编写属于你自己的智能指针。这里将会讲到的是来自标准库中最常用的一些:
Box<T>,用于在堆上分配值Rc<T>,一个引用计数类型,其数据可以有多个所有者Ref<T>和RefMut<T>,通过RefCell<T>访问。(RefCell<T>是一个在运行时而不是在编译时执行借用规则的类型)。
另外还会涉及内部可变性(interior mutability)模式,这是不可变类型暴露出改变其内部值的API。我们也会讨论引用循(reference cycles)会如何泄漏内存,以及如何避免。
2.1 Box<T>
Box<T>是rust中非常常见的智能指针,它允许你将一个值放在堆上而不是栈上。留在栈上的则是指向堆数据的指针。除了数据被储存在堆上而不是栈上之外,box没有性能损失。不过也没有很多额外的功能。它们多用于如下场景:
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候(见:box允许创建递归类型)
- 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候(见:使用box避免大量数据拷贝)
- 当希望拥有一个值并只关心它的类型是否实现了特定特征而不是其具体类型的时候(==todo==:examples)
使用box在堆上存储数据
首先,让我们熟悉一下语法以及如何与储存在 Box<T> 中的值进行交互。
1 | fn main() { |
这里定义了变量 b,其值是一个指向被分配在堆上的值 5 的 Box。这个程序会打印出 b = 5。由于实现了Deref,println! 可以正常打印出 a 的值,它隐式地调用了 Deref 对智能指针 a 进行了解引用。在这个例子中,我们可以像数据是储存在栈上的那样访问box中的数据。正如任何拥有数据所有权的值那样,当像 b 这样的box在main的末尾离开作用域时,它将被释放。这个释放过程作用于box本身(位于栈上)和它所指向的数据(位于堆上),这是因为 Box<T> 实现了 Drop 特征。另外Box::new(T)会转移T的所有权。
不过,这段代码实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将像单个 i32 这样的值储存在栈上,也就是其默认存放的地方在大部分使用场景中更为合适。
使用box避免大量数据拷贝
对于实现Copy特征的简单类型,值存储在栈上,当栈上数据转移所有权时,会进行内存的拷贝。如果是堆上的数据则底层数据并不会被拷贝,当转移所有权时,仅仅会拷贝栈上指向堆内存的指针,然后将这个指针绑定到新的位置上,再使原来的位置的指针失效即可。
1 | fn main() { |
在希望转移所有权,但又希望大量数据不被拷贝,使用box就是很好的方案。
box允许创建递归类型
递归类型(recursive type)的值可以拥有另一个同类型的值作为其的一部分。这会产生一个问题,因为rust需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限的进行下去,所以rust不知道递归类型需要多少空间。因为box有一个已知的大小,所以通过在循环类型定义中插入box,就可以创建递归类型了。
cons list就是递归类型的例子,它由嵌套的列表组成。它的名字来源于Lisp中的 cons 函数(construct function的缩写)。它利用两个参数来构造一个新的列表,通过对一个包含值的列表和另一个值调用cons,可以构建由递归列表组成的cons list。比如:
1 | (1, (2, (3, Nil))) |
cons list的每一项都包含两个元素:当前项的值和下一项,其最后一项值包含一个叫做 Nil 的值且没有下一项。
不过在rust中,这并不常见,当你需要列表的时候,Vec<T> 可能是一个更好的选择。不过,这里主要研究的是如果利用box创建这样的类型。
1 | enum List { |
使用枚举创建了一个List,它要么是Cons要么是Nil,其中Cons包含一个 i32 值,还包含了一个新的 List,因此这种嵌套可以无限进行下去。
使用这个结构来储存列表 1, 2, 3 :
1 | use crate::List::{Cons, Nil}; |
错误表明这个类型有无限的大小,其原因是 List 的一个成员被定义为是递归的:它直接存放了另一个相同类型的值。
rust计算非递归类型的内存大小是这样的:
1 | enum Message { |
对于上面的枚举,rust需要知道为 Message 分配多少空间,它可以检查每一个成员并发现 Message::Quit 并不需要任何空间,Message::Move 需要足够储存两个 i32 值的空间,依此类推。因为枚举实际上只会使用其中的一个成员,所以 Message 值所需的空间等于储存其最大成员的空间大小。
与此相对,当rust检查List时,它无法计算到底需要多少空间:编译器尝试计算出储存一个 List 枚举需要多少内存,并开始检查 Cons 成员,那么 Cons 需要的空间等于 i32 的大小加上 List 的大小。为了计算 List 需要多少内存,它检查其成员,从 Cons 成员开始。Cons 需要的空间等于 i32 的大小加上 List 的大小…这会无限递归下去。对于这种在编译时无法知道具体的大小的类型,称为动态大小类型DST(Dynamic Sized Type)。
注意上面编译器的报错提示“insert some indirection (e.g., a Box, Rc, or &) to break the cycle“,为了解决这个问题,可以使用box:
1 | use crate::List::{Cons, Nil}; |
因为 Box<T> 是一个指针,指针的大小并不会根据其指向的数据量而改变。这意味着可以将 Box 放入 Cons 成员中而不是直接存放另一个 List 值。Box 会指向另一个位于堆上的 List 值,而不是存放在 Cons 成员中。
Cons 成员将会需要一个 i32 的大小加上储存 box 指针数据的空间。Nil 成员不储存值,所以它比 Cons 成员需要更少的空间。现在我们知道了任何 List 值最多需要一个 i32 加上 box 指针数据的大小。
现在,就可以打破这种递归,顺利通过编译了。
Box::leak
Box 中还提供了一个非常有用的关联函数:Box::leak,它可以消费掉 Box 并且强制目标值从内存中泄漏,例如,你可以把一个 String 类型,变成一个 'static 生命周期的 &str 类型:
1 | fn main() { |
在之前的代码中,如果 String 创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String,而通过 Box::leak 我们不仅返回了一个 &str 字符串切片,它还是 'static 生命周期的。要知道真正具有 'static 生命周期的往往都是编译期就创建的值,例如 let v = "hello, world",这里 v 直接打包到二进制可执行文件中,因此该字符串具有 'static 生命周期,再比如 const 常量。如果手动为变量标注 'static,并不代表真正的生命周期,而使用 Box::leak 就可以将一个运行期的值转为 'static。
2.2 通过Deref特征对智能指针解引用
追踪指针的值
常规引用是一个指针类型:
1 | fn main() { |
这里 y 就是一个常规引用,包含了值 5 所在的内存地址,然后通过解引用 *y,我们获取到了值 5。可以断言 x 等于 5。然而,如果希望对 y 的值做出断言,必须使用 *y 来追踪引用所指向的值(也就是解引用),一旦解引用了 y,就可以访问 y 所指向的整型值并可以与 5 做比较。
如果尝试编写 assert_eq!(5, y);,则会得到如下编译错误:
1 | error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较 |
不允许比较数字的引用与数字,因为它们是不同的类型。必须使用解引用运算符追踪引用所指向的值。
像引用一样使用Box<T>
可以使用 Box<T> 代替引用重写上一小节的代码:
1 | fn main() { |
相比之下,主要不同的地方就是将 y 设置为一个指向 x 值拷贝的 Box<T> 实例,而不是指向 x 值的引用。在最后的断言中,可以使用解引用运算符以 y 为引用时相同的方式追踪 Box<T> 的指针。接下来让我们通过实现自己的类型来探索 Box<T> 能这么做有何特殊之处。
自定义智能指针
为了体会默认情况下智能指针与引用的不同,我们创建一个类似于标准库提供的 Box<T> 类型的智能指针。接着学习如何增加使用解引用运算符的功能。
1 | struct MyBox<T>(T); |
从根本上说,Box<T> 被定义为包含一个元素的元组结构体,所以这里以相同的方式定义了 MyBox<T> 类型。我们还定义了 new 函数来对应定义于 Box<T> 的 new 函数。结构体 MyBox 使用了一个泛型参数 T,因为我们希望其可以存放任何类型的值。
MyBox 是一个包含 T 类型元素的元组结构体。MyBox::new 函数获取一个 T 类型的参数并返回一个存放传入值的 MyBox 实例。
现在,尝试用这个自定义的box替换掉标准库的Box<T>:
1 | fn main() { |
编译,会得到一个错误:
1 | error[E0614]: type `MyBox<{integer}>` cannot be dereferenced |
MyBox<T> 类型不能解引用,因为我们尚未在该类型实现这个功能。为了启用 * 运算符的解引用功能,需要实现 Deref 特征。
实现 Deref 特征
Deref特征由标准库提供,它要求实现名为deref的方法,其借用self并返回一个内部数据的引用。
1 | use std::ops::Deref; |
这里又出现了type Target = T ,它定义了用于此特征的关联类型,关联类型是一个稍有不同的定义泛型参数的方式,现在还无需过多的担心它。
deref 方法体中写入了 &self.0,这样 deref 返回了希望通过 * 运算符访问的值的引用。.0 用来访问元组结构体的第一个元素。
现在,之前的代码就可以编译并能通过断言了。没有Deref特征的话,编译器只会解引用 & 引用类型。deref方法向编译器提供了获取任何实现了Deref特征的类型的值,并且调用这个类型的deref方法来获取一个它知道如何解引用的&引用的能力。
事实上,输入 *y 时rust在底层运行了如下代码:
1 | *(y.deref()) |
将*运算符替换为先调用deref方法再进行普通解引用的操作,如此我们便不用担心是否还需手动调用deref方法了。rust的这个特性可以让我们写出行为一致的代码,无论是面对的是常规引用还是实现了Deref的类型。
deref 方法返回值的引用,以及 *(y.deref()) 括号外边的普通解引用仍为必须的原因在于所有权。如果 deref 方法直接返回值而不是值的引用,其值(的所有权)将被移出 self。在这里以及大部分使用解引用运算符的情况下我们并不希望获取 MyBox<T> 内部值的所有权。
注意每次当我们在代码中使用 * 时, * 运算符都被替换成了先调用 deref 方法再接着使用 * 解引用的操作,且只会发生一次,不会对 * 操作符无限递归替换,
函数和方法的隐式Deref强制转换
Deref强制转换(deref coercions)将实现了Deref特征的类型的引用转换为另一种类型的引用。例如,Deref强制转换可以将&String转换为&str,因为String实现了Deref特征因此可以返回&str。
Deref强制转换是rust在函数或方法传参上的一种便利操作,并且只能作用于实现了Deref特征的类型。当这种特定类型的引用作为实参传递给和形参类型不同的函数或方法时将自动进行。这时会有一系列的 deref 方法被调用,把我们提供的类型转换成了参数所需的类型。Deref强制转换的加入使得程序员编写函数和方法调用时无需增加过多显式使用&和*的引用和解引用。这个功能也使得我们可以编写更多同时作用于引用或智能指针的代码。
现在,继续使用上一节定义的MyBox<T>,看下面的例子:
1 | use std::ops::Deref; |
这里定义了hello函数,它有&str 类型的参数 name,可以使用字符串切片作为参数调用hello函数,比如 hello("Rust");,而用 MyBox<String> 类型值的引用调用 hello 也可以通过编译。
这是因为在MyBox<T>上实现了Deref特征,rust可以通过deref调用将&MyBox<String>变为&String,标准库中提供了String上的Deref实现,其会返回字符串切片,rust再次调用deref将&String变为&str,这就符合hello函数的定义了。从这里也可以看出,Deref强制转换支持连续的隐式转换。
如果rust没有实现Deref强制转换,为了使用 &MyBox<String> 类型的值调用hello,则不得不编写这样的代码:
1 | fn main() { |
(*m) 将MyBox<String>解引用为 String。接着&和[..]获取了整个String的字符串切片来匹配hello的签名。没有Deref强制转换所有这些符号混在一起将更难以读写和理解。Deref强制转换使得rust自动的帮我们处理这些转换。
当所涉及到的类型定义了Deref特征,rust会分析这些类型并使用任意多次Deref::deref调用以获得匹配参数的类型。这些解析都发生在编译时,所以利用Deref强制转换并没有运行时损耗。
在使用方法、赋值中的Deref强制转换如下:
1 | fn main() { |
对于 s1,我们通过两次 Deref 将 &str 类型的值赋给了它(赋值操作需要手动解引用);而对于 s2,我们在其上直接调用方法 to_string,实际上 MyBox 根本没有没有实现该方法,能调用 to_string,完全是因为编译器对 MyBox 应用了 Deref 的结果(方法调用会自动解引用)。
Deref强制转换与可变性
Deref特征重载不可变引用的*运算符,类似地,rust提供了DerefMut特征用于重载可变引用的 * 运算符。
rust在发现类型和特征实现满足三种情况时会进行Deref强制转换:
- 当
T: Deref<Target=U>时从&T到&U。 - 当
T: DerefMut<Target=U>时从&mut T到&mut U。 - 当
T: Deref<Target=U>时从&mut T到&U。
第一种情况表明如果有一个 &T,而 T 实现了返回 U 类型的 Deref,则可以直接得到 &U。第二种情况表明对于可变引用也有着相同的行为。
第三个情况有些微妙:rust也会将可变引用强转为不可变引用。但是反之是不可能的:不可变引用永远也不能强转为可变引用。因为根据借用规则,如果有一个可变引用,其当前必须是这些数据的唯一引用(否则程序将无法编译)。将一个可变引用转换为不可变引用永远也不会打破借用规则。将不可变引用转换为可变引用则需要初始的不可变引用是数据唯一的不可变引用,而借用规则无法保证这一点。因此,rust无法假设将不可变引用转换为可变引用是可能的。
还是以MyBox<T>为例:
1 | use std::ops::{Deref, DerefMut}; |
在上述代码中:
- 要实现
DerefMut必须要先实现Deref特征:pub trait DerefMut: Deref T: DerefMut<Target=U>解读:将&mut T类型通过DerefMut特征的方法转换为&mut U类型,对应上例中,就是将&mut MyBox<String>转换为&mut String
2.3 使用Drop特征运行清理代码
对于智能指针来说第二个重要的特征是Drop,其允许我们在值要离开作用域时执行一些代码。可以为任何类型提供 Drop trait 的实现,同时所指定的代码被用于释放资源。
我们在智能指针里讨论 Drop 是因为其功能几乎总是用于实现智能指针,例如,当 Box<T> 被丢弃时会释放box指向的堆空间。
在一些语言中的某些类型,我们不得不记住在每次使用完智能指针实例后调用清理内存或资源的代码。如果忘记的话,运行代码的系统可能会因为负荷过重而崩溃。在rust中,可以指定每当值离开作用域时被执行的代码,编译器会自动插入这些代码。于是我们就不需要在程序中到处编写在实例结束时清理这些变量的代码,而且还不会泄漏资源。
指定在值离开作用域时应该执行的代码的方式是实现Drop特征。Drop特征要求实现一个叫做 drop 的方法,它获取一个 self 的可变引用。
1 | struct A(); |
这个例子中,展示了rust何时执行drop方法。Drop特征包含在 prelude 中,所以无需导入它,drop 函数体是放置任何当类型实例离开作用域时期望运行的逻辑的地方,这里暂时使用println!代替。
编译运行,会得到如下打印结果:
1 | CustomSmartPointers created. |
这说明,当实例离开作用域rust会自动调用 drop,并调用我们指定的代码。同时可以看出drop的顺序:
- 变量以被创建时相反的顺序被丢弃,所以
t在s之前被丢弃。 - 结构体内部按照字段定义的顺序丢弃,所以先丢弃
dataA后丢弃dataB - 先丢弃结构体再丢弃内部的字段
另外,rust为几乎所有类型都实现了Drop,因此,注释掉下面的这段代码:
1 | impl Drop for CustomSmartPointer { |
也能够正常通过编译。
通过std::mem::drop手动丢弃
有时你可能需要提早清理某个值。一个例子是当使用智能指针管理锁时;你可能希望强制运行 drop 方法来释放锁以便作用域中的其他代码可以获取锁。rust并不允许我们主动调用 Drop 特征的drop方法(std::ops::Drop::drop),当我们希望在作用域结束之前就强制释放变量的话,我们应该使用的是由标准库提供的std::mem::drop。
这二者是不同的,前者在值超出作用域后被隐式调用,并且不允许显式调用。比如:
1 | struct Foo { |
当你进行x.drop()时,编译器会报如下错误:
1 | | |
rust不允许我们显式调用std::ops::Drop::drop,因为rust仍然会在main作用域的结尾对值自动调用drop,这会导致一个double free错误,因为rust会尝试清理相同的值两次。
这里提示你使用drop函数,如果需要强制提早清理值,可以使用std::mem::drop函数,它位于prelude,无需显式导入即可使用。该方法可以通过传递希望强制丢弃的值作为参数:
1 | struct Foo { |
实际上,std::mem::drop函数的源码是这样的:
1 | pub fn drop<T>(_x: T) { } |
这个drop函数内部是一个空实现,它所做的仅仅是带走_x的所有权。该值在drop作用域的末尾仍然会自动调用std::ops::Drop::drop,以完成丢弃工作。虽然看起来是你手动丢弃了x,但编译器并不知道这些,它所做的只是移动x的所有权,然后在作用域结尾将其自动丢弃。
互斥的Copy和Drop
无法为一个类型同时实现 Copy 和 Drop 特征。因为实现了 Copy 的特征会被编译器隐式的复制,因此非常难以预测析构函数执行的时间和频率。因此这些实现了 Copy 的类型无法拥有析构函数。
1 |
|
总之,Drop 特征实现中指定的代码可以用于许多方面,来使得清理变得方便和安全:比如可以用其创建我们自己的内存分配器。通过 Drop 特征和rust所有权系统,你无需担心之后的代码清理,rust会自动考虑这些问题。
我们也无需担心意外的清理掉仍在使用的值,这会造成编译器错误:所有权系统确保引用总是有效的,也会确保 drop 只会在值不再被使用时被调用一次。
2.4 Rc<T>引用计数智能指针
大部分情况下所有权是非常明确的:可以准确地知道哪个变量拥有某个值。然而,有些情况单个值可能会有多个所有者。例如,在图数据结构中,多个边可能指向相同的节点,而这个节点从概念上讲为所有指向它的边所拥有。节点直到没有任何边指向它之前都不应该被清理。
为了启用多所有权需要显式地使用rust类型 Rc<T>,其为引用计数(reference counting)的缩写。引用计数意味着记录一个值引用的数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。引用计数并不是rust独有的,在C++11中,也有同样的概念。在一些语言的垃圾回收(GC)机制中,也用到了引用计数算法。
Rc<T> 用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。
使用Rc<T>共享数据
1 | fn main() { |
这里使用两次box存储s,毫无疑问会报错。因为第一次调用Box::new时已经将s所有权移动到box。
现在,使用 Rc<T> 代替 Box<T>:
1 | use std::rc::Rc; |
需要使用 use 语句将 Rc<T> 引入作用域,因为它不在prelude中。使用 Rc::new 函数创建了一个新的 Rc<String> 智能指针并将s储存到变量 t,在调用Rc::new函数时,会将引用计数加1。然后调用 Rc::clone 函数并传递 t 中 Rc<String> 的引用作为参数,此时的引用计数增加到 2。
也可以调用 t.clone() 而不是 Rc::clone(&t),不过在这里rust的习惯是使用 Rc::clone函数。Rc::clone 的实现并不像大部分类型的 clone 实现那样对所有数据进行深拷贝。Rc::clone 只会增加引用计数,这并不会花费多少时间。深拷贝可能会花费很长时间。通过使用 Rc::clone 进行引用计数,可以明显的区别深拷贝类的克隆和增加引用计数类的克隆。当查找代码中的性能问题时,只需考虑深拷贝类的克隆而无需考虑 Rc::clone 调用。
查看引用计数的变化
接下来,修改上一节的例子,观察引用计数的变化:
1 | use std::rc::Rc; |
使用Rc::strong_count函数观察t中Rc<String> 的的引用计数变化,这段代码会打印出:
1 | count after creating t = 1 |
能够看到 t 中 Rc<String> 的初始引用计数为1,接着每次调用 clone,计数会增加1。当 u 离开作用域时,计数减1。不必像调用 Rc::clone 增加引用计数那样调用一个函数来减少计数;由于Drop特征的实现,当 Rc<T> 值离开作用域时会自动减少引用计数。
从这个例子我们所不能看到的是,在 main 的结尾当 z 然后是 t 离开作用域时,此处计数会是0,同时 Rc<String> 被完全清理。使用 Rc<T> 允许一个值有多个所有者,引用计数则确保只要任何所有者依然存在其值也保持有效。
不可变引用
实际上,Rc<T> 是指向底层数据的不可变的引用,它允许在程序的多个部分之间只读地共享数据。如果 Rc<T> 也允许多个可变引用,则会违反借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。
1 | use std::rc::Rc; |
Rc<T>只能用于单线程,后面会介绍如何用Arc在多线程中使用计数引用,详见线程安全的引用计数Arc<T>。
当然,我们有时也会需要修改数据,在下一小节进行介绍。
2.5 内部可变性模式和RefCell<T>
内部可变性模式
内部可变性(Interior mutability)是rust中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用unsafe代码来模糊rust通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。
当可以确保代码在运行时会遵守借用规则,即使编译器不能保证的情况,可以选择使用那些运用内部可变性模式的类型。所涉及的 unsafe 代码将被封装进安全的 API 中,而外部类型仍然是不可变的。后面会介绍unsafe,见不安全的rust。
通过RefCell<T> 在运行时检查借用规则
不同于 Rc<T>,RefCell<T> 代表其数据的唯一的所有权。那么RefCell<T>与Box<T>的区别是什么?回忆一下借用规则:
- 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)
- 引用必须总是有效的
对于引用和 Box<T>,借用规则的不可变性作用于编译时。对于 RefCell<T>,这些不可变性作用于运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于 RefCell<T>,如果违反这些规则程序会panic并退出。
在编译时检查借用规则的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响,因为所有的分析都提前完成了。为此,在编译时检查借用规则是大部分情况的最佳选择,这也正是rust的默认行为。
相反在运行时检查借用规则的好处则是允许出现特定内存安全的场景,而它们在编译时检查中是不允许的。静态分析,正如rust编译器,是天生保守的。但代码的一些属性不可能通过分析代码发现:其中最著名的就是 停机问题(Halting Problem)。
因为一些分析是不可能的,如果rust编译器不能通过所有权规则编译,它可能会拒绝一个正确的程序;从这种角度考虑它是保守的。如果rust接受不正确的程序,那么用户也就不会相信rust所做的保证了。然而,如果rust拒绝正确的程序,虽然会给程序员带来不便,但不会带来灾难。RefCell<T> 正是用于当你确信代码遵守借用规则,而编译器不能理解和确定的时候。
类似于 Rc<T>,RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用RefCell<T>,会得到一个编译错误。
内部可变性
借用规则的一个推论是当有一个不可变值时,不能可变地借用它。例如,如下代码不能编译:
1 | fn main() { |
然而,特定情况下,令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的。RefCell<T> 就是一个获得内部可变性的方法,它并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应地在运行时检查借用规则。如果违反了这些规则,会出现panic而不是编译错误。
1 | use std::cell::RefCell; |
这里引入了std::cell::RefCell,并使用new创建了一个RefCell<i32>的引用。当创建不可变和可变引用时,我们分别使用 & 和 &mut 语法。对于 RefCell<T> 来说,则是 borrow 和 borrow_mut 方法,这属于 RefCell<T> 安全API的一部分。borrow 方法返回 Ref<T> 类型的智能指针,borrow_mut 方法返回 RefMut<T> 类型的智能指针。这两个类型都实现了 Deref,所以可以当作常规引用对待。它的运行结果如下:
1 | init value owner1 = RefCell { value: 4 } |
初始时,owner1储存的值为4,且没有使用mut。然后通过borrow_mut方法获取了一个可变引用,并修改原始值为3,此时owner1中的可变引用计数为1,当离开{}作用域时,可变引用计数减1,然后又创建了可变引用m2。这符合借用规则,因此在运行时也不会报错。并且,我们通过RefCell获取了内部可变性,即使没有mut也能够修改自身。
如果去掉{}:
1 | use std::cell::RefCell; |
虽然代码可以通过编译,但是在运行时仍然会报错:thread 'main' panicked at 'already borrowed: BorrowMutError'。这是因为违反了借用规则:可变引用计数为2(m1和m2)。
这看起来像是推迟了报错的时机:从编译时报错推迟到运行时报错。它的意义在于,rust编译器是保守的,当编译器无法判断代码是否正确,就一律按照错误处理,而RefCell则是让编译器相信你的代码是正确的:至少可以通过编译。
总之,当你确信编译器误报但不知道该如何解决时,或者你有一个引用类型,需要被四处使用和修改然后导致借用关系难以管理时,都可以优先考虑使用 RefCell。
RefCell<T> 记录当前有多少个活动的 Ref<T> 和 RefMut<T> 智能指针。每次调用 borrow,RefCell<T> 将活动的不可变借用计数加1。当 Ref<T> 值离开作用域时,不可变借用计数减1,可变引用同理。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T> 的实现会在运行时出现panic。
Cell<T>
Cell<T> 和 RefCell<T> 在功能上没有区别,区别在于 Cell<T> 适用于 T 实现Copy特征的情况:
1 | use std::cell::Cell; |
以上代码展示了 Cell 的基本用法,变量c前面没有mut,但是我们仍然可以改变它。有几点值得注意:
- “asdf” 是
&str类型,它实现了Copy特征 c.get用来取值,c.set用来设置新值
取到值保存在 one 变量后,还能同时进行修改,这个违背了rust的借用规则,但是由于 Cell 的存在,我们很优雅地做到了这一点,但是如果你尝试在 Cell 中存放String:
1 | let c = Cell::new(String::from("asdf")); |
编译器会立刻报错,因为 String 没有实现 Copy 特征。
由于 Cell 类型针对的是实现了 Copy 特征的值类型,因此在实际开发中,Cell 使用的并不多,以下是Cell和RefCell的区别:
Cell只适用于Copy类型,用于提供值,而RefCell用于提供引用Cell不会panic,而RefCell会Cell没有额外的性能损耗,RefCell有一点运行期开销,它包含了一个字大小的“借用状态”指示器,该指示器在每次运行时借用时都会被修改
总之,需要使用内部可变性时,可以首选 Cell,只有需要存储的类型没有实现 Copy 特征,才去选择 RefCell。
最后,Cell和RefCell仅用于单线程的共享引用,Mutex<T>是一个线程安全版本的RefCell<T>,在并发编程章节中会介绍它,详见:互斥锁Mutex。
RefCell<T>的使用场景
很多时候RefCell<T>都用在结构体的字段上。这样,你就可以共享这个结构体,但是仍能够对某个字段做修改。
1 | pub trait Messenger { |
这里定义了一个消息发送器特征 Messenger,它只有一个发送消息的功能:fn send(&self, msg: String),因为发送消息不需要修改自身,因此在定义时,使用了 &self 的不可变借用。
然后我们使用该特征实现一个异步消息队列,出于性能的考虑,消息先写到本地缓存(内存)中,然后批量发送出去,因此在 send 方法中,需要将消息先行插入到本地缓存 msg_cache 中。但问题是,该 send 方法的签名是 &self,上述代码会报错:
1 | | |
虽然编译器提示修改函数签名,但我们希望在不修改这个特征定义的前提下,解决这个问题:
1 | use std::cell::RefCell; |
通过修改结构体字段的定义,包裹一层 RefCell,让 &self 中的 msg_cache 成为一个可变值,然后实现对其的修改,并且也没有修改Messenger特征的定义。
组合使用Rc<T>和RefCell<T>来拥有多个可变数据所有者
RefCell<T> 的一个常见用法是与 Rc<T> 结合。 Rc<T> 允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T> 的 Rc<T> 的话,就可以得到有多个所有者并且可以修改的值。
1 | use std::cell::RefCell; |
上面代码中,我们使用 RefCell<String> 包裹一个字符串,同时通过Rc::new方法创建了它的三个所有者:s、s1和s2,并且通过其中一个所有者 s2 对字符串内容进行了修改。由于 Rc 的所有者们共享同一个底层的数据,因此当一个所有者修改了数据时,会导致全部所有者持有的数据都发生了变化。
运行结果也是如此:
1 | RefCell { value: "hello, world" } |
2.6 Borrow, BorrowMut, ToOwned
下面介绍三个与借用数据相关的三个特征,它们全部位于std::borrow下。
Borrow
第一个特征是Borrow,定义是这样的:
1 | pub trait Borrow<Borrowed> |
这里的Borrowed就是被借用的类型。在rust中,通常为不同的用例提供类型的不同表示。例如,可以通过诸如 Box<T> 或 Rc<T> 之类的指针类型选择合适的存储位置和管理方式来使用一个值。除了这些可以与任何类型一起使用的通用包装器之外,一些类型还提供了可选的特性,从而提供潜在的可能代价高昂的功能(potentially costly functionality)。这种类型的一个示例是 String ,它向基本 str 添加了扩展字符串的功能。这需要为简单、不可变的字符串保留不必要的附加信息。
这些类型通过对数据类型的借用提供对基础数据的访问。例如,可以将Box<T>作为T进行借用,而String可以作为str进行借用。
这些类型可以实现Borrow<T>来表示它们可以作为某种类型T进行借用,并在特征的 borrow 方法中提供对 T 的借用的具体实现。一种类型可以自由借用为多种不同的类型。如果它希望可变地借用类型——允许修改底层数据,它可以另外实现 BorrowMut<T>——我们很快在后面会介绍。
此外,在提供其他特征的实现时,需要考虑它们是否应该由于充当底层类型的表示而与底层类型具有相同的行为。当通用代码依赖于这些附加特征实现的相同行为时,通常可以使用 Borrow<T> 。特别是 Eq 、 Ord 和 Hash 对于借用值和自有值必须等效: x.borrow() == y.borrow() 应该给出与 x == y 相同的结果。
上面这句话可能不太好理解,简单来说就是,有一个类型U,当你为U实现了Borrow<T>,这意味着你可以将U作为T进行借用。此时,当你再为U实现其它的特征时(尤其是 Eq 、 Ord 和 Hash),比如Eq,那么为U实现的行为应该与为T实现Eq的行为相同。
再具体一点,比如哈希表HashMap<K, V>拥有键和值。如果键的实际数据包装在某种管理类型中,则仍然可以使用对键数据的引用来搜索值。例如,如果键是字符串,则它可能与哈希映射一起存储为 String ,而应该可以使用 &str 进行搜索。因此, insert 需要在 String 上操作,而 get 需要能够使用 &str 。下面是HashMap<K, V>的简化实现:
1 | use std::borrow::Borrow; |
整个哈希映射对于键类型 K 是通用的。由于这些键与哈希映射一起存储,因此该类型必须拥有键的数据。当插入键值对时,映射会被赋予这样一个 K ,并且需要找到正确的哈希桶并根据该 K 检查该键是否已经存在。因此它需要 K: Hash + Eq 。
当我们使用get方法时,参数k类型是&Q,而不是&K。它指出 K 通过要求 K: Borrow<Q> 作为 Q 借用。通过额外要求 Q: Hash + Eq ,它表示要求 K 和 Q 具有 Hash 和 Eq 的实现产生相同结果的特征。假设哈希表为:HashMap<String, _>,那么这里的K就是String,由于标准库为String实现了Borrow<str>,并且特征约束中要求K: Borrow<Q>,因此这里的Q就可以是str,这就意味着我们可以通过get(k: &str)来获取键对应的值。同时,注意到Q的特征约束Q: Hash + Eq + ?Sized,这就是额外的特征,这就是要求为String实现Hash + Eq和为str实现Hash + Eq产生的结果必须相同。
什么叫做产生的结果相同呢?我们插入哈希表时使用的是String,也就是说我们根据String计算了哈希值,但是get使用的是&str,因此就必须保证根据str计算的哈希值与根据String计算的哈希值保持相同。
因此,如果包装 Q 值的 K 产生与 Q 不同的哈希值,则哈希映射会中断。
假设有一个包装字符串的类型,它的Hash 实现忽略 ASCII 大小写:
1 | pub struct CaseInsensitiveString(String); |
CaseInsensitiveString 可以实现 Borrow<str> 吗?它当然可以通过其包含的拥有的字符串提供对字符串切片的引用。但由于其 Hash 实现不同,因此其行为与 str 不同,因此实际上不能实现 Borrow<str> 。如果它想允许其他人访问底层 str ,它可以通过 AsRef<str> 来实现,这个特征与Borrow不同,它不带有任何额外的要求。
BorrowMut
1 | pub trait BorrowMut<Borrowed>: Borrow<Borrowed> |
作为 Borrow<T> 的好朋友,此特征允许通过提供可变引用来借用类型作为基础类型。它要求实现borrow_mut方法。另外,这里的定义用到了特征的“继承”,在深入理解Fn特征中,介绍过这种语法:
1 | trait A {} |
因此,由于BorrowMut继承自Borrow,要实现BorrowMut就必须先实现Borrow。比如Vec<T>实现了BorrowMut<[T]>,所以可以将Vec<T>作为[T]进行借用。见示例:
1 | use std::borrow::BorrowMut; |
泛型T自动实现了Borrow和BorrowMut
rust为泛型T和&T自动实现了Borrow<T>和BorrowMut<T>。
1 |
|
Borrow<T>和BorrowMut<T>之所以被定义为泛型特征,可以让同一个类型同时有多个实现Borrow和BorrowMut特征的类型,这样,一个类型就可以同时被借用为多个不同的类型:
1 | fn main() { |
引用类型&str和&String都可以作为String类型的借用。即通过实现Borrow特征可以让一个类型被借用成不同的引用。
ToOwned
下面看另一种特征:
1 | pub trait ToOwned { |
这里type Owned: Borrow<Self>叫做关联类型。简单来说,这个类型需要ToOwned的实现者定义,而设计特征的人不需要关心它具体是什么。另外,这个类型Owned有一个特征约束,该类型必须实现Borrow<Self>。
特征要求实现to_owned方法,它可以将类型从借用变为拥有所有权。一般来说,这种转换可以通过Clone特征达到,为什么还需要ToOwned特征呢?我们先通过一个例子看看使用clone和to_owned的区别,然后再概括总结。
举一个例子,如果一个类型实现了Clone 特征,则从这个类型的引用,生成并拥有其所指向对象副本的通用方式是调用clone,比如从一个&String克隆得到String,可以从一个&Vec<i32>克隆得到Vec<i32>:
1 | let s: String = String::from("hello"); |
这里,从s1进行clone,可以得到String类型的s2。
但是,当你要克隆&str时:
1 | let s: &str = "hello"; |
得到的仍然是&str。此时就不可以使用clone了,而是要使用to_owned:
1 | let s: &str = "hello"; |
我们看到了二者的区别,参考这篇文章:Rust | The Difference Between .clone() and .to_owned(),下面尝试概括它们。
.clone() 和 .to_owned() 之间的差异发生在切片(例如字符串切片 &str )或具有未定义容量的数组(例如 &[i8] ,处于借用状态 ( &T ):
.clone()生成类型的副本,例如&str或&[u8],其借用状态具有相同类型 (&T)。这意味着在&str和&[u8]上使用.clone()将分别生成&str和[u8]。.to_owned()生成具有所有权的类型的副本,例如&str或&[u8]。这意味着在&str和&[u8]上使用.to_owned()将分别生成String和Vec<u8>。
.clone() 方法可用于派生 Clone 特征的所有类型。默认情况下,所有基本类型( str 、 i8 、 u8 、 char 、 array 、 bool 等),可以访问 .clone() 方法。并且,.clone() 生成具有相同类型 T 的对象 T 的副本,这意味着如果:
- a 是
u8,副本将是u8 - a 是
String,副本将是String - a 是
&[&str],副本将是&[&str] - a 是自定义结构体
MyObject,副本将是MyObject
我们知道,rust有几种主要基本类型(数值类型,布尔类型,字符类型),它们代表单一值。这意味着所有整数 ( 12 )、浮点数 ( 3.4 )、布尔值 ( true 、 false ) 和字符 ( 'a' 、 'z' )无论使用多少次都具有相同的值。正因为如此,它使得从借用状态生成拥有的副本成为可能。换句话说,从 &T 到 T :
1 | let integer: &u8 = &1; // type is &u8 |
当原始数组具有所有权时, .clone() 方法可以复制数组,这意味着从 [T] 到 [T] :
1 | let owned_array: [i32; 3] = [1, 2, 3]; |
只要定义了数组容量, .clone() 也可以从 &[T] 到 [T] 。
1 | let borrowed_array: &[i32; 3] = &[1, 2, 3]; |
即使没有分配显式类型定义( &[T] ),数组容量也将被隐式定义。在以下示例中,生成的副本是 [T] 中的 [T] 。
1 | let borrowed_array = &[1, 2, 3]; // it will implicitly define the type as &[i32; 3] |
但是,如果定义类型为变量是数组而没有显式定义容量,虽然 .clone() 仍会生成副本。然而,这个新的副本仍处于借用状态。因此,它将从 &[T] 到 &[T] 。
1 | let numbers: &[i32] = &[1, 2, 3]; |
正如rust文档所述, .to_owned() 方法是借用数据的 Clone 特征的泛化。这意味着 .to_owned() 生成副本。因此,如果在 T 上应用 .to_owned() ,则副本将为 T 。
1 | let number:u8 = 10; // type u8 |
.to_owned() 的一个关键方面是该方法能够从给定类型的任何借用中构造拥有的数据。这意味着,如果将 .to_owned() 应用于任何 &T ,则副本将为 T 。
1 | let number: &u8 = &10; // type u8 |
对于刚才字符串切片的例子而言,字符串切片引用 &str 处于借用状态。这意味着,存储字符串切片引用的任何变量都不拥有该值。我们已经知道.clone() 方法可以从 &T 转换为 T ,其中 T 可以是基本类型,例如布尔值或数字或自定义结构,例如 MyObject 。但它们与 str 的区别在于,字符串切片不是原始类型,而是字符序列。换句话说,字符数组 [T] 。字符串切片的一个特点是没有所有权。 .clone() 方法尝试从“借用”变为“拥有所有权”成为可能,但这并不意味着总是会发生。如你所知,这就是字符串切片引用&str的情况。
.to_owned() “概括” Clone 特征以通过借用给定类型构造数据。 to_owned() 的核心功能是确保副本始终拥有所有权,这意味着即使必须以与原始数据类型不同的数据类型分配值,也要保证这一点。
通过从字符串切片引用 &str 生成 String , to_owned() 满足提供所有权的标准。因为如果你查看 String 的定义,你会发现它是一个基于Vec<u8>的结构体:
1 | pub struct String { |
还有另一个有趣的事情,使用 .to_owned() 方法会得到不同类型的副本。当将 .to_owned() 应用于具有未定义容量的引用数组时,会发生这种情况,例如:
1 | let array_i8: &[i8] = &[1, 2, 3]; |
2.7 Cow
Cow也是一个智能指针,用于实现写时克隆(Clone on write)。它定义如下:
1 | pub enum Cow<'a, B> |
首先Cow是一个枚举,它具有两个成员Borrowed和Owned。这里定义了一个泛型B,枚举要么是对类型B的不可变引用(Borrowed),要么是一个拥有类型B的所有权的数据(Owned)。
下面来看看B的特征约束'a + ToOwned + ?Sized,其中
'a是生命周期约束,它表示当Cow内部类型B的生命周期为'a时,Cow的生命周期也是'a,也就是说它至少活得和B一样长ToOwned就是我们在上一节介绍的,它可以从借用的&B得到具有所有权的B?Sized表示类型B可能是也可能不是在编译时已知大小的类型(因此这里要求实现ToOwned而不是Clone)
接下来再看Owned,这里用到了我们会在后面介绍的完全限定语法,<B as ToOwned>::Owned中,尖括号的内容向rust提供了类型注解,表示我们希望将B当做ToOwned特征来对待,然后来指定希望访问的是的是ToOwned内部的关联类型Owned。
Cow作为独立的一种智能指针,存在的意义在于减少内存的分配和复制。在很多场景中,我们对一个变量都是读多写少,而读取只需要不可变引用即可,并不需要所有权。我们来看官方文档的例子:
1 | use std::borrow::Cow; |
第一个切片中,由于没有小于零的数字,因此不发生克隆行为。第二个切片中,由于出现了负数,因此需要获取对数据所属形式的可变引用,由于尚未拥有数据,因此发生了克隆。最后一个已经拥有所有权,因此不会发生克隆。
另外一个例子是敏感词替换:
1 | use std::borrow::Cow; |
例子中给出了remove_sensitive_word和remove_sensitive_word_old两种实现,前者的返回值使用了Cow,后者返回值使用的是String。 仔细分析一下,很明显前者的实现效率更高。因为如果输入的字符串中没有敏感词时,前者Cow::Borrowed(words)不会发生堆内存的分配和拷贝,后者words.to_owned()会发生一次堆内存的分配和拷贝。
试想一下,如果例5的敏感词替换场景,是大多数情况下都不会发生替换的,即读多写少的场景,remove_sensitive_word实现中使用Cow作为返回值就在很大程度上提高了系统的效率。