速成课第5节
在本课程中,我们将介绍我所说的“三个规则”,该规则适用于函数参数,迭代器和闭包。 我们已经看到了该规则适用于函数参数,但是没有那么明确地讨论。 我们将扩展参数,并使用它来启动有关迭代器和闭包的新信息。
这篇文章是基于 FP 完成 Rust 教学系列的一部分。 如果你在博客之外阅读这篇文章,你可以在介绍文章的顶部找到这个系列中所有文章的链接。 也可订阅 RSS 频道。
参数类型
我首先要处理的是一个潜在的误解。 这可能是“ 我的大脑被 Haskell 搅乱了” 的错误概念之一,这是命令式程序员感觉不到的,所以如果我只是在逗自己和其他 Haskellers 开心的话,我深表歉意。
这两个函数有相同的类型签名吗?
fn foo(mut person: Person) { unimplemented!() }
fn bar(person: Person) { unimplemented!() }Haskeller 尖叫“他们是不同的! ” 然而,它们是完全一样的。函数中 person 变量的内部可变性与该函数的调用者无关。 该函数的调用者会将 Person 值移入该函数,而不管该值是否可变。我们已经看到了这样的提示:我们可以将不可变的值传递给 foo 这样的函数。
fn main() {
let alice = Person { name: String::from("Alice"), age: 30 };
foo(alice); // it works!
}除去这个误解,让我们考虑另外两个类似的函数:
fn baz(person: &Person) { unimplemented!() }
fn bin(person: &mut Person) { unimplemented!() }首先,很容易说 baz 和 bin 的签名都不同于 foo。 这些是对 Person 的引用,而不是 Person 本身。 但是 baz vs bin 又如何呢? 它们是相同的还是不同的? 您可能倾向于遵循与 foo vs bar 相同的逻辑,并确定 mut 是函数的内部细节。 但这不是真的! 观察:
fn main() {
let mut alice = Person { name: String::from("Alice"), age: 30 };
baz(&alice); // this works
bin(&alice); // this fails
bin(&mut alice); // but this works
}对 bin 的第一个调用将不能编译,因为 bin 需要一个可变的引用,但是我们提供了一个不可变的引用。 我们需要使用第二个版本的调用。 这不仅有句法上的区别,而且还有语义上的区别:我们使用了一个可变的引用,这意味着我们不能同时使用其他引用(请记住我们从第2课中借用的规则)。
这样做的结果是,我们可以通过三种不同的方式将值传递给类型级别的函数:
按值传递(move 语义) ,如 foo
传递不可变引用,如 baz
通过可变引用传递,比如 bin
另外,捕获到的参数变量可以是不可变或可变的。
可变与不可变按值传递
这是一个相对容易看到。 通过可变的值传递,我们还能获得什么额外的功能? 当然,有能够改变 value 的能力! 让我们看一下实现生日功能的两种不同方式,这可以使某人的年龄增加1。
以下是一些重要的要点:
_immutable 实现遵循一个更具功能性的习惯用法,通过解构原始Person 值来创建新 Person 值。 这在 Rust 中可以正常工作,但不是惯用语言,并且效率可能较低。
我们以完全相同的方式调用这个函数的两个版本,强调了这两个函数具有相同签名的说法。
不能重用 main 中的 alice1或 alice2值,因为它们在调用期间被移动了。
Alice2 是一个不可变的变量,但是它仍然被传递给一个函数,这个函数对 alice2 进行了变异。
可变与不可变按引用传递
已经很难观察到这一点了,这表明Rust很简单:想要一个可变的变量作为引用是不寻常的。 下面的示例非常人为设计,需要使用更高级的显式生命周期参数概念,甚至使其有意义。 但这确实表明了 mut 出现位置之间的区别。
在我们深入讨论之前: 以单引号(’)开头的参数是生存周期参数,它指示引用需要存活多长时间。 在下面的示例中,我们说“这两个引用必须具有相同的生存期”。 我们不会在这里讨论更多的细节,至少现在还不会。 如果你想了解生命,请看《The Rust book》。
好的,让我们看看一个不可变变量和一个变量变量之间的区别!
birthday_immutable 是相当简单的。 我们有一个可变的引用,并将其存储在一个不可变变量中。 我们完全可以自由地改变这个引用所指向的值。 结论是:我们在改变值,而不是改变变量,因为变量是保持不变的。
birthday_mutable 是人为的,丑陋的一团糟,但它证明了我们的观点。 在这里,我们有两个引用: person 和 replacement。 它们都是可变的引用,但是 person 是可变的变量。 我们做的第一件事就是 person = replacement; 。 这会更改 person 变量所指向的内容,并且根本不会修改引用所指向的原始值。 事实上,在编译这个文件时,我们会得到一个警告,我们从未使用传递给 person 的值:
注意,在此示例中,我们需要将 alice 和 bob 都标记为可变。 这是因为我们通过可变引用传递它们,这要求我们有能力对它们进行修改。 这不同于使用 move 语义的值传递,因为在我们的主函数中,我们可以直接观察更改传入引用的效果。
还要注意,我们也有一个 birthday_immutable_broken 版本。 您可能会从名称中猜到它无法编译。 如果它是一个不可变的变量,我们无法更改 person 指向的对象。
挑战:在运行这个程序之前弄清楚它的输出是什么。
可变与不可变引用
我实际上不打算介绍这个案例,因为它基本上与前一个案例相同。 如果将一个变量标记为可变的,则可以更改它所持有的引用。 您可以随意使用上面这个使用不可变引用的示例。
可变到不可变
让我们指出最后一点:
到目前为止,根据我告诉您的内容,您应该期望该程序无法编译。 y 的类型为 &mut u32,但是我们将其传递给需要一个 &u32 的needs_immutable,类型不匹配,回家!
并没有那么快。由于可变引用要比不可变引用严格得多,因此您始终可以在需要不可变的地方使用可变引用。 (坚持下去,这对于下面的闭包很重要)。
参数三则规则汇总
有三种类型的参数:
按值传递
通过不可变引用传递
按可变引用传递
这就是我所说的三的法则。 函数中捕获的变量可以是可变的,也可以是不可变的,这与参数的类型是正交的。 然而,到目前为止,最常见的变量是通过值传递。 此外,在调用位置上,如果变量通过可变引用函数被调用,则该变量必须是可变的。 最后,您可以在请求不可变的地方使用可变引用。
练习1
修正下面的程序,使其输出数字10。确保没有编译器警告。
提示:您需要知道如何通过在变量前面添加星号(*)来取消引用。
迭代器
下面的程序的输出是什么?
没错,它打印数字1到5。这个怎么样?
它打印1,1,1,2,...,1,5,2,1,...,2,5。 很酷,很容易。 让我们移动 nums 的位置。 这是做什么的?
陷阱问题: 它不能编译!
这倒是有点道理。 第一次运行外循环时,我们将 nums 值移动到内循环中。 然后,我们不能在第二次通过循环时再次使用 nums 值。 好吧,合乎逻辑。
这是我个人和 Rust 在一起时的一个“震撼”时刻,我意识到这样的循环是多么复杂的生活轨迹。 Rust 是相当惊人的。
我们可以回到以前的版本,在第一个 for 循环中放置 nums, 这意味着每次通过外部 for 循环时都要重新创建该值。对于我们的小例子,这没什么大不了的。 但是想象一下构造nums是昂贵的。 这将是主要的开销!
如果我们想避免 nums 的移动,我们可以仅仅借用它而逃脱吗? 可以的!
这是可行的,但我有个问题要问:j 是什么类型的? 我有一个小窍门来测试不同的选项。 如果你把这个放在 println 的上方! 调用,你会得到一个错误消息:
不过,这样编译起来还不错:
通过迭代 nums 的引用,我们得到了对每个值的引用,而不是值本身。 这是有道理的。 我们能否用可变的引用来完成我们的“三的法则” ? 再试一次!
挑战:首先,在上面的程序中有一个编译错误。 在请求编译器帮助之前试着抓住它。 其次,在运行这个程序之前猜测它的输出。
我们的三个规则也将转化为迭代器! 我们可以有值的迭代器,引用的迭代器和可变引用的迭代器。 甜!
新命名法
Vec结构具有三种与上面的示例相关的不同方法。 从可变大小写开始,我们可以替换以下行:
和
该方法的签名是:
类似地,我们有一个 iter() 方法可以替换我们的不可变引用案例:
最后,值的迭代器 case 怎么样? 在这里命名为into_iter。其思想是,我们将现有值转换为迭代器,完全消耗前一个值(本例中为 Vec)。 这段代码不能编译,继续移动 let nums 语句来修复它。
重新检查循环
这里有一个很酷的小把戏,我以前没有提到过。 For 循环比我所暗示的要灵活一些。 我提到的 into_iter 方法实际上是 trait 的一部分,恰如其分地命名为 IntoIterator。 无论何时在 y 中使用 x,编译器都会自动调用 y 上的 into_iter()。 这允许您循环遍历那些实际上没有自己 Iterator 实现的类型。
练习2
通过为 InfiniteUnit 定义一个 IntoIterator 实现来编译这个程序。 不要为它定义 Iterator 实现。 您可能需要定义一个额外的数据类型。 (额外: 还要尝试在标准库中找到一个重复值的辅助函数)。
三个迭代器规则
就像函数参数一样,迭代器有三种类型,对应于下面的命名方案:
Into_iter() 是一个值迭代器,具有 move 语义
Iter() 是不可变引用迭代器
Iter_mut() 是可变引用迭代器
只有iter_mut() 要求原始变量本身是可变的。
闭包
到目前为止,我们在速成课上已经绕过了一些闭包问题。 闭包就像函数一样,可以在某些参数上调用它们。 闭包与函数不同,闭包可以从本地范围捕获值。 在发出警告之后,我们将通过示例进行演示。
一个警告:如果你来自一个非函数式编程的背景,你可能会发现 Rust 中的闭包非常强大,并且在库使用中非常常见。 如果您具有函数式编程背景,那么在使用闭包时,您可能会为数据所有权的问题感到恼火。 作为一个 Haskeller,这仍然是 Rust 方面,我最经常被抓住。 我保证,设计中的权衡是合乎逻辑的,也是实现 Rust 目标所必需的,但是与 Haskeller 相比,甚至与 Javascript 相比,这都有点麻烦。
好了,回到函数 vs 闭包的问题,你知道可以在一个函数中定义另外一个函数吗?
这很有意思,让我们稍微重构一下:
不幸的是,Rust 并不喜欢这样:
幸运的是,编译器准确地告诉我们如何修复它: 使用闭包:
现在,我们有一个闭包(由 || 引入),它带有0个参数。 一切都正常。
注意: 可以使用 let say_hi = || println!("{}", msg); 来缩短它,这样更地道。
练习3
重写上面的代码,这样,与其接受0个参数,不如说 hi 接受一个参数: msg 变量。 然后再次尝试 fn 版本。
闭包的类型
say_hi的类型到底是什么? 我将使用一个丑陋的技巧让编译器告诉我们:给它错误的类型,然后尝试编译。 可以假设闭包不是 u32,这很安全,因此,请尝试以下操作:
然后我们得到一个错误消息:
[closure@main.rs:3:23: 3:48] 看起来是个奇怪的类型... 但是让我们试一下,看看会发生什么:
但是编译器会把我们打倒:
哦,那不是一个有效的类型。那么编译器到底告诉我们什么呢?
匿名类型
在 Rust 中,闭包类型是匿名的。 我们根本无法直接引用它们。这让我们有些吃惊。 如果我们想将闭包传递给另一个函数怎么办? 例如,让我们尝试一下该程序:
我们在闭包中的 msg 参数上添加了一个类型注释。 这些在闭包中通常是可选的,除非类型推断失败。 由于我们当前的代码已经破损,类型推断肯定是失败的。 我们现在加入了它,以便以后获得更好的错误消息。
我们现在还有一个类型参数,名为 f,用于传入的闭包。 我们现在对 f 一无所知,但是我们打算用函数调用的方式来使用它。 如果我们编译这个,我们会得到:
好吧,很公平: 编译器不知道 f 是一个函数。 现在该终于可以介绍编译的魔力了: Fn trait!
现在我们已经对 F 设置了一个约束,即它必须是一个函数,该函数接受一个类型为 &str 的参数,并返回一个单元值。 实际上,返回单元值是默认值,所以我们可以省略这一部分:
Fn 的另一个妙处在于,它不仅仅适用于闭包。 它也可以运行常规的 ol 方法。
练习4
将 message 改写为 main 之外的函数,并使上面的程序能够编译。
这有点无聊,因为say_message实际上不是闭包。 让我们对此进行一些更改。
可变变量
还记得过去在网页上设置访问计数器的美好时光吗? 让我们重新创造那种美好的体验吧!
这是可行的,但是太无聊了! 让我们用一个结尾来让它更有趣些。
编译器不同意这种说法:
什么? 显然,调用一个函数算是借用它。 好吧,这就解释了为什么我们可以多次调用。但是现在由于某种原因,我们需要可变地借用它。 为什么?
原因相当简单:visit 捕获并修改了一个局部变量 count。 因此,任何借用它的行为也是间接地借用计数。 从逻辑上讲,这是有道理的。 但是在类型级别上呢? 编译器如何跟踪这种可变性? 为了看到这一点,让我们用一个 helper 函数来进一步扩展它:
我们得到一个错误消息:
太棒了! Rust 有两个不同的功能特征: 一个涵盖了不会改变环境(Fn)的功能,另一个涵盖了会改变环境(FnMut)的功能。让我们尝试修改使用 FnMut 的位置。 我们又得到了一条错误消息:
调用此可变函数需要对变量进行可变借用,并且需要将变量定义为可变。 继续,在 f:F 前面加上一个mut,您会很高兴。
多重traits
该闭包是Fn还是FnMut?
它不修改局部作用域中的任何变量,因此可以推测它是 Fn。 因此,将其传递到 call_five_times ---- 期望调用一个 FnMut ---- 应该会失败,对吗? 不要这么快,它工作得很好! 继续把这一行添加到上面的程序中,然后证明给你自己看:
每个 Fn 值都自动是一个 FnMut。 这与函数参数的情况类似:如果您有一个可变引用,您可以自动将其作为一个不可变引用使用,因为可变引用的保证性更强。 类似地,如果我们使用一个函数的方式使它是安全的,即使该函数是可变的(FnMut) ,那么对一个不可变的函数(Fn)执行同样的操作肯定是安全的。
这听起来有点像子类型吗?很好,它应该是:)
三个规则?
如果您已经注意到,我们现在在标题为 “三个规则” 的课程中提供两种不同类型的功能。 接下来可能会发生什么? 我们已经看到可以在不变的上下文中多次调用函数,就像不变的引用一样。 我们已经看到可以在可变上下文中多次调用函数,就像可变引用一样。 剩下的只是一件事…… value/move 语义调用!
我们定义一个用于移动局部变量的闭包。 我们将回过头来使用 String 而不是 u32,以避免 u32 是可复制的。 而且中间我们会使用一些奇怪的魔术语法来强制移动,而不是将其视为引用。 稍后,我们将详细介绍该技巧,并查看替代方法。
好了, name 已移至 welcome 闭包中。这是用 let name = name; 强制执行的。 还没有100% 确信这个 name 真的移动进来了? 看看这个:
name1 被定义为不可变的。 但 name2 是可变的,事实上我们确实成功地修改了它。 只有当我们通过值而不是通过引用传递时,才会发生这种情况。 想要进一步的证据吗? 在我们定义了 welcome 之后,尝试再次使用 name1。
第三个功能特质
让我们完成第三条规则。还记得我们的 call_five_times 吗? 让我们在 welcome 中使用它:
我们得到了一个全新的错误消息,这次是引用 FnOnce:
用 FnOnce() 替换 Fn() 应该可以解决编译问题,对吧? 错!
我们的循环最终会多次调用 f。 但是每次我们调用 f,我们都在移动这个值。 因此,该函数只能被调用一次。 也许这就是为什么他们把它命名为 FnOnce。
让我们重写一个只调用一次的 helper 函数:
这样就行了,太棒了!
进一步的功能子类型化
前面我们说过,每个 Fn 也是一个 FnMut,因为在任何可以安全地调用可变函数的地方,也可以调用不可变函数。 事实证明,每个 Fn 和每个 FnMut 也都是 FnOnce,因为您可以保证函数只被调用一次的任何上下文对于运行具有可变或不可变环境的函数都是安全的。
move 关键词
我们将要进入一个微妙的要点,直到写完本课,我才明白(感谢Sven Marnach的解释)。 《Rust by Example》闭包部分是帮助我全部击破的最佳资源。 我会尽力在这里解释。
函数显式接受参数,并使用类型签名完成。 您可以显式地声明参数是通过值传递、可变引用还是不可变引用传递。 然后,当你使用它的时候,你可以选择任何较弱的形式。 例如,如果通过可变引用传递一个参数,你可以通过不可变引用使用它。 但是,你不能按值传递来使用:
闭包接受参数,但是它们使类型注释成为可选的。 如果你省略它们,它们就是隐含的。 此外,闭包允许您捕获变量。 它们从不使用类型注释; 它们始终是隐式的。 尽管如此,还是需要一些关于如何捕获这些值的概念,就像我们需要知道如何将参数传递到函数中一样。
如何获取一个值将意味着我们在 Rust 中使用的同一套借用规则,特别是:
如果通过引用,那么其他引用可以与闭包并发存在
如果通过可变引用,那么只要闭包处于活动状态,就不存在对值的其他引用。 但是,一旦删除闭包,其他引用就可以再次存在。
如果按值,则该值将不能再被任何人使用。 (这自动意味着闭包拥有该值)。
然而,闭包和函数之间有一个重要的细微区别(恕我再重复一遍) :
闭包可以拥有数据,函数不能。
当然,如果通过值传递给函数,函数调用将在执行期间获得数据的所有权。 但闭包是不同的:闭包本身可以拥有数据,并在调用时使用它。 让我们来演示一下:
不管你怎么努力,你都不可能用一个普通的函数实现同样的功能,你需要将 name_outer 分开保存,然后传递给它。
好吧,让我们用更聪明的方法来迫使他们 move。 在上面的闭包中,我们让 name_inner = name_outer; 。 这将强制闭包按值使用 name_outer。 因为我们按值使用,所以我们只能调用这个闭包一次,因为它在第一次调用时完全使用 name_outer (继续尝试添加第二次调用)。 但实际上,我们只是在闭包内部使用不可变引用的名称。 我们应该可以多次调用它。 如果我们跳过按值强制使用,我们可以通过引用来使用,把 name_outer 保留在原来的范围内:
但是,如果我们稍微改变一下,使 name_outer 超出say_hi 的范围,一切都会崩溃!
我们需要的是这样一种说法: 我希望闭包拥有它捕获的值,但是我不想强制按值使用它。 这将允许闭包的寿命超过值的原始作用域,但仍然允许多次调用闭包。 为了做到这一点,我们引入了 move 关键字:
name_outer 的所有权从原始作用域传递到闭包本身。 我们仍然只是通过引用来使用它,因此我们可以多次调用它。 万岁!
最后一点。 使用像这样的 move 关键字将所有捕获的变量移动到闭包中,因此不能在闭包之后使用它们。 例如,这将无法编译:
勉强的Rust
好了,我们总结问题并深入研究示例之前还有最后一点。 捕获的类型在闭包中是隐式的, Rust 如何决定是按值,可变引用还是不可变引用捕获。 我喜欢把 Rust 想象成不情愿的: 它努力捕捉最弱的可能方式。 用《Rust by Example book》来解释:
闭包优先通过不可变引用捕获,然后通过可变引用捕获,最后通过值捕获。
在前面的例子中,我们使用 let name_inner = name_outer; ,我们强制 Rust 按值捕获。 然而,它不喜欢这样做,而是会通过引用(可变或不可变)捕获,如果它能做到这一点的话。 它这样做是基于该值的最强用法。 这就是:
如果闭包的任何部分按值使用变量,则必须按值捕获该变量。
如果闭包的任何部分通过可变引用使用变量,则必须通过可变引用捕获该变量。
如果闭包的任何部分通过不可变引用使用变量,则必须通过不可变引用捕获该变量。
即使这会导致程序编译失败,它也不愿捕获。 正如我们前面所看到的,通过引用而不是通过值捕获可能会导致生存期问题。 然而,Rust 没有考虑使用闭包的完整上下文来确定如何捕获,它只考虑闭包本身的内容。
但是,由于有许多正式的情况下,我们希望强制按值捕获来解决生存期问题,所以我们有 move 关键字来强制这个问题。
有时,Rust不仅将您的程序作为一个整体来看,而且猜测您是否希望添加 move,可能会有些烦人。 但是,我认为这是该语言的一个重大决定:这种“按我的意思做”的逻辑是脆弱的,而且常常令人惊讶。
要点: 所有权、捕获和使用
概括一下要点:
在闭包中,变量可以通过值、可变引用或不可变引用来使用
此外,闭包的所有变量都可以通过值、可变引用或不可变引用捕获
我们不能以比捕获更强大的方式使用变量。 如果它是由可变引用捕获的,则可以由不可变引用使用,不能由值使用。
要解决生存期问题,我们可以通过 move 关键字强制闭包按值捕获。
如果缺少 move 关键字,Rust 将不情愿,并且以闭包主体允许的最弱方式捕获它。
关于闭包的traits:
如果闭包按值使用任何东西,则闭包为 FnOnce
如果闭包通过可变引用使用任何内容,那么闭包就是一个 FnMut,这也自动意味着 FnOnce
闭包是 Fn,它自动同时暗示了 FnMut 和 FnOnce
我认为上述要点十分复杂,因此我列举了许多其他示例来帮助归纳要点。 这些都是受到 Rust by Example 示例的启发。
对于下面的所有示例,我将假设源代码中存在以下三个 helper 函数:
例子
考虑一下这个 main 函数:
Name 比 say_hi 生命周期更长,因此闭包保留对 name 的不可变引用没有问题。 因为它只有对环境的不可变引用,并且不使用任何值,所以say_hi 是 Fn、 FnMut 和 FnOnce,然后编译上面的代码。
相比之下,这个示例不能编译。 一旦我们离开花括号,名字就会超出范围。 但是,我们的闭包是通过引用来捕获它的,因此引用比值更有价值。 我们可以使用以前的技巧,强迫它通过价值来捕捉:
但是这只实现了一个 FnOnce,因为该值被捕获和消费,从而阻止了它再次运行。 还有更好的办法! 相反,我们可以强制闭包获得 name 的所有权,但仍然可以通过引用获取:
现在我们回到Fn,FnMut和FnOnce! 为了避免 say_hi 值本身随每次调用移动,我们现在将引用传递给 call_fn 函数。 我相信(尽管不是100%肯定)在第一个示例中没有必要这样做,因为上面没有捕获的环境,因此可以复制闭包。 使用捕获的环境闭包不能复制。
此示例使用 drop 函数消费 name。 因为我们按值使用,必须按值捕获,因此必须拥有值的所有权。尽管不会造成伤害,但在闭包前面使用 move 是不必要的。
在 String 上使用 + = 操作符需要一个可变的引用,因此我们已经超出了不可变引用捕获的范围。 Rust将通过可变引用退回到捕获。 这就要求 name 也必须声明为可变的。 由于 name 在闭包之前超出范围,因此我们需要将所有权移至关闭。 而且由于调用 say_hi 将使数据发生修改,因此我们也需要在其声明上添加一个 mut。
当我们将 say_hi 传递给调用函数时,我们需要使用&mut 来确保(1)该值未移动,并且(2)该值可以被更改。 另外,这里的 call_fn 无效,因为我们的闭包是 FnMut 和 FnOnce,但不是 Fn。
挑战:这个程序的输出是什么? 我们将字符串 “ and Bob” 添加到 name 多少次?
我们还可以通过让 name 比闭包存在的时间更长来避免捕获。
添加 println!最后,引用 name 的字段无效,因为 say_hi 仍在范围内。 这取决于词汇生命周期。 您可以通过在源代码顶部添加#![feature (nll)]来打开(撰写本文时)实验性功能非词汇性生存期。 或者,您可以显式使用花括号来表示闭包的范围:
您还可以(也许有些明显)以多种不同的方式使用一个值:
在这些情况下,最强大的用途决定了我们需要的捕获类型。 由于我们按上述值使用,因此我们也必须按值捕获,因此必须拥有所有权。
使用哪种 trait?
试着想想你需要这三个 traits 中的哪一个,可能会让你感到害怕。 您可以对此进行批评,让编译器对你大喊大叫。 引用《the Rust book》一书中的话:
大多数情况下,在指定 Fn trait 边界时,可以从 Fn 开始,编译器会根据闭包体中发生的情况告诉您是否需要 FnMut 或 FnOnce。
我会给出一个略有不同的建议,遵循 “对你所接受的要宽大为怀” 的格言。函数作为参数时,最宽松的开始是FnOnce。 如果您的使用限制更大,请听编译器的意见。
有关闭包作为输出参数的更多信息,请参见 Rust by Example 的章节。
闭包的三条规则
函数和闭包都使用 Fn 族的 trait 范围进行注释。 这些形成子类型关系,其中每个 Fn 也是 FnMut,每个 FnMut 也是 FnOnce。
FnOnce 工作方式类似于按价值传递
Fnmut 工作方式类似于通过可变引用传递
Fn 工作方式类似于不可变引用传递
闭包如何使用这些捕获的变量决定了它是这三个变量中的哪一个。 因为根据定义,函数从不捕获局部变量,所以它们总是 Fn。
练习5
把我们已经学到的关于迭代器和闭包的知识放在一起,修改下面的第5行(以 i 开头的那一行) ,以便程序输出数字2,4,6,。 . 20两次。
Gui 和回调
有什么比编写GUI和一些回调更好的方法来解决这些问题呢?我将使用 GTK + 和精彩的 GTK-rs crate。 我们的最终目标是创建一个只有一个按钮的 GUI。 当单击该按钮时,将向一个文件写入一条消息,该文件显示“ i was clicked”。
在这个例子中,你肯定想要使用一个 cargo 项目,继续运行:
现在添加 gtk 作为一个依赖项,在 Cargo.toml 的 [dependencies] 部分,添加以下行:
现在我们要剽窃 gtk-rs 网站上的示例代码。 把这个输入到你的 main.rs 中(如果你自己输入而不是复制粘贴的话,会得到额外的收获) :
假设您已经正确设置了所有的系统库,那么执行 cargo run 应该可以得到一个漂亮的、简单的 GUI。
如果您在安装 crates 时遇到麻烦,请首先查看 gtk-rs 的需求页面。
替换回调
您可能已经注意到,示例代码已经包含一个回调,它打印 Clicked! 每次点击按钮时都会显示一个标准输出。 这当然会让我们的生活变得轻松一点。 现在,在这个回调函数中,我们需要:
打开一个文件
向文件中写入一些数据
我们将在不进行任何错误处理的情况下进行第一次尝试。 相反,我们将在 Result 值上使用 unwrap(),导致我们的程序恐慌! 如果出了什么差错。 我们稍后再清理。
在标准库中搜索file很快会找到 std::fs::File,这看起来很有希望。 创建函数似乎也是最简单的入门方式。 我们将写入 mylog.txt。 页面顶部的示例显示 write_all (感谢 Rust 提供了非常棒的 API 文档!) . 首先,试试下面这段代码:
在解决了下面的练习6之后,您将看到这个错误消息:
这可是新玩意儿。 为了使用某个项目的trait,这个trait必须在范围之内。 很简单,我们只需在闭包中添加使用 std::io::Write:
练习6
如果您遵循应有的代码,则上面可能会出现不同的错误消息,而我在此处提供的代码实际上并不能解决所有问题。 您需要添加一个额外的方法调用来将 Result <File,Error> 转换为 File。 提示:我在上面提到过。
继续运行这个程序(通过货物运行) ,点击按钮几次,并关闭窗口。 然后查看 mylog.txt 的内容。 不管你点击了多少次,你只会得到一行输出。
问题在于,每次调用回调时,我们都从 File 调用 create,它会覆盖旧文件。 这里的一种方法是创建一个附加文件处理(对任何想要使用它的人来说都是很棒的奖金练习)。 我们将采取另一种方法。
共享文件
让我们将 create 调用移到闭包定义之外。 在 main 函数体中打开文件,闭包可以捕获对该文件的可变引用,这样一切都会顺利进行。
不幸的是,编译器真的不喜欢这样:
或者更简单地说:
connect_clicked 是一个接受 f 类型的某个函数 f 并返回 SignalHandlerId方法。 我们没有使用返回值,所以忽略它。 函数是 Fn。 因此,我们不允许通过一个 FnMut 或者一个 FnOnce。 必须允许 GTK 在不受可变上下文限制的情况下多次调用该函数。 因此,保持一个可变的引用是行不通的。
另一件有趣的事情是 +‘static。 我们在上面简要地提到了生命周期。'static 是一个特殊的生命周期参数,也就是说 ”可以在程序的整个生命周期存在” 作为一个很好的例子,所有的字符串文本都有类型 &‘static str,尽管我们通常只写 &str。
问题在于我们的文件没有 ‘static 生命周期。 它是在 main 函数中创建的,保留在 main 函数中,并且仅与 main 函数生命周期一样。 您可能会争辩说,main 函数贯穿程序的整个过程,但这并非完全正确。 在上面的示例中,调用 drop 时,按钮将失效文件(因为以FILO顺序执行 drop)。 如果某个按钮的 drop 按钮决定再次调用 click 回调,则说明内存不安全。
因此,剩下的是:我们需要一个没有对本地数据有可变引用的闭包。 我们该怎么做?
move 它
我们可以通过将变量移动到闭包中来让编译器停止抱怨生存期。 现在我们可以保证这个文件会一直存在,直到关闭本身,满足 'static 要求的保证。 做到这一点,请在闭包前面使用 move。
然而,这仍然不能解决我们的 Fn 问题。 怎么能允许我们的回调在移动值之后被多次调用呢?
引用计数(提示: 没有)
我们已经达到了Rust的正常借用规则还不够的地步。我们不能向编译器证明我们的回调将遵守可变引用规则: 在给定的时间里只有一个可变引用。 这种情况经常发生,以至于标准库为引用计数类型提供内置支持。
将以下语句添加到 main.rs 的顶部:
Rc 是一个单线程引用计数值。 还有一种 Arc 类型,它是原子类型的,可用于多线程应用程序。 因为 GTK 是一个单线程库,所以使用 Rc 而不是 Arc 是安全的。 Rust 真正令人敬畏的一点是,如果你在这方面犯了一个错误,编译器可以抓住你。 这是因为 Rc 没有实现 Sync 和 Send 特性。 请参阅 send 文档的更多内容。
无论如何,回到我们的例子。 我们可以使用以下引用计数来包装原始文件:
然后,我们如何获得对基础文件的访问权以使用它? 结果是:我们不需要做任何特别的事情。 保留我们的原始 file.write_all 就是我们想要的。 这是因为 Rc 实现了 Deref 特性:
这意味着您可以从 Rc <T> 获取对 T 的引用。 由于方法调用语法会自动获取引用,因此一切正常。 真好。
好吧,几乎所有东西:
引用计数允许我们对一个值有多个引用,但它们都是不可变的引用。 看起来我们的情况并没有比以前好到哪里去,我们确保了数据的唯一拥有者就是闭包。
RefCell
Refcell 就是为解决这个问题而设计的。 我不打算详细解释它,因为用于 std::cell 的 API 文档比我做得更好。 我建议你现在就去阅读那篇介绍文章,然后再回来研究这段代码,然后再去阅读文档。 就我个人而言,我不得不把这个解释读上4到5遍,然后多思考一些代码,最后才能正确地理解。
无论如何,添加 use std::cell::RefCell ;,然后将 RefCell 包裹原始File:
现在,我们的代码将无法进行编译并有一条不同的错误消息:
与 Rc 不同,使用 RefCell,我们不能依靠 Deref 实现来获取文件。 相反,我们需要在 RefCell 上使用一种方法来获取对 File 的引用:
但这并不完全有效:
幸运的是,这个修复就像使用 borrow_mut ()一样简单。现在我们的程序可以工作了,万岁!
通常,引用计数 (Rc 或 Arc) 和 cells(Cell、 RefCell 或 Mutex) 是密切相关的,这就是为什么我写本课的第一反应是同时使用 Rc 和 RefCell。 然而,在这种情况下,只需要 RefCell 就足够了。
练习7
该程序中的错误处理很乏味。 存在三个问题:
如果 gtk::init() 失败,我们程序的退出代码仍然是0(表示成功)。
如果打开 mylog.txt 失败,我们就会恐慌。
如果写入文件失败,会产生 panic。
要解决此问题,请在 main 函数中返回类型为 Result<(), Box<std::error::Error>> 的值。 其他大多数错误可以通过 From::from 自动强制转换为 Box 。对于问题 (1) 和 (2),请使用我们在第三课中讨论的标准错误处理机制。对于问题 (3), 当发生错误时,请使用eprintln打印错误消息。
无畏并发!
终于可以进行一些无所畏惧的并发了。 我们将编写一个程序,该程序将:
分配一个包含“ Fearless”的字符串
每秒 fork 一个线程,进行10次迭代
在 fork 的线程中:
在字符串后添加另一个叹号
打印字符串
在我们开始之前,你可能需要明确一些复杂的所有权部分,它们将在这里继续:
多个线程可以访问一些可变的数据
我们需要确保一次只有一个 writer
我们需要确保在每个线程都完成数据后,数据才会被释放
我们不会从一开始就试图为此设计一个伟大的解决方案,而是将其视为一个适当的速成课。 我们将尽可能地做最天真的事情,查看错误消息,并尝试改进。 如果你认为你现在可以自己实现整个程序,那么一定要试一试! 即使你认为自己无法实现它,也值得一试。 这种努力将使下面的解释更有帮助。
函数介绍
我们将使用以下三个函数:
std::thread::spawn产生一个线程。它有一个有趣的签名:
Send trait 意味着所提供的函数及其返回值都必须是可以发送到不同线程的值。 'static 表示我们不能保留对局部变量的任何引用。 FnOnce() 表示任何闭包都可以工作。
std::thread::sleep 使主线程进入睡眠状态。 它需要一个 Duration 类型的值,这将我们带到最后一个函数:
std::time::Duration::new 需要持续时间的秒数和纳秒数。
在介绍产生新线程的乐趣之前,让我们尝试一个单线程版本:
我们甚至可以在闭包中包装 msg.push 和 println! ,以得到更接近调用 spawn:
这给了我们一个错误信息:
继续修复这个问题,并编译这个代码。
引入spawn
引入 spawn 的最简单方法是将 inner() 调用替换为 spawn(inner):
和
然后添加 spawn 调用,我们会得到错误消息:
看起来很简单: 我们必须有一个自包含的闭包来传递给 spawn,它不能引用来自父线程的值。 让我们在闭包前面添加一个 move。 我们得到一个错误消息:
我仍然不觉得这些错误信息特别有启发性。 但是它告诉我们,我们正在尝试捕捉一个移动的值。 之所以会发生这种情况,是因为我们在循环的第一次迭代中将值移动到闭包中,然后尝试再次移动它。 这显然行不通!
破碎的解决方案
让我们为每个迭代创建一个字符串的新副本。 这很简单: 上面添加 let mut inner:
这将编译(带有警告)并运行,但是输出错误。 我们不会每次都多加感叹号。 我们实际上并没有处理共享的可变数据。 该死。
但是 clone 给了我另一个想法..。
计数引用
也许我们可以引入前面提到的引用计数,让每个线程保留一个指向相同数据片段的指针。
好吧,这是一个新问题:
这就是我们经常听到的无畏的并发性! 编译器阻止我们在线程之间发送 Rc 值。 如果编译器提到这一点就好了,但是我们已经知道,对于多线程应用程序,我们需要一个原子引用计数器,或者 std::sync::Arc。 继续切换到那个。 您应该会得到一个新的错误消息:
内部易变性
上面,我提到了 Rc 和 RefCell 通常一起去。 Rc 提供引用计数,RefCell 提供可变性。 也许我们也可以把 Arc 和 RefCell 结合起来?
更安全的并发:
您可以搜索更多信息,但拥有可变多线程 cell 的通常方式是使用 Mutex。 与 borrow_mut()不同,我们有一个 lock() 方法,它确保一次只有一个线程使用互斥锁。 让我们来试一试:
我们获得了一个错误:
啊对。 锁定可能会由于调用 poisoning 而失败 (查看文档以获取更多信息)。要引用文档:
大多数互斥对象只是简单对 Result 使用 unwrap(),在线程之间传播恐慌,以确保不会发现可能无效的不变式
这是我看过 Rust 文档中提到最接近运行时异常的地方,很好。 如果添加 .unwrap(),则会告知 msg 需要是可变的。 如果添加 mut,我们将使用共享的可变状态编写第一个多线程 Rust 应用程序。
注意编译器如何阻止我们犯一些严重的并发错误的吗? 太棒了
作为最后一步,看看你可以或不可以从最终程序中删除哪些 mut 和 move。 确保您可以向自己解释为什么编译器接受或不接受每个更改。
下节课
您现在已经深入到 Rust 的难点。 现在出现的事情是,对所有权和闭包的繁琐工作越来越熟悉,对库生态系统也更加满意。 我们准备在下一次获得更多真实的问题,并学习 Rust 中行业标准异步 I/O框架tokio。
最后更新于
这有帮助吗?