闭包是一种神奇,它不可命名的类型啊,它是有这个编译器,在使用这个管道符号和这个画括号语法时候,自动生成的
为啥异步Rust令人头秃
Rust中的闭包
https://www.bilibili.com/video/BV13a46etEbn/
欢迎回来,Rust 小伙伴们!如果你是新来的,我叫 Bogdan,这个频道专注于 Rust 编程语言。在上一个视频中,我们讲解了《Rust 编程语言》书籍的第十二章,在那一章中我们创建了一个 CLI 程序。在这期视频中,我们将讲解第十三章,特别是第十三章的第一部分,我们将讨论闭包。话不多说,让我们开始 Rust 之旅吧!
首先,什么是闭包?闭包和函数很像,只不过它们是匿名的,也就是说它们没有名字。闭包可以被存储为变量,并且可以被传递。它们甚至可以作为函数的输入参数传递,并且它们可以捕获在定义它们的作用域中的变量。
为了更好地理解闭包,我们会在接下来的例子中使用它们。想象一下,你正在为一个健身应用构建后端,而这个后端是用 Rust 构建的。这个健身应用会根据一些因素为用户生成定制的锻炼计划,这些因素包括用户的年龄、体质指数(BMI)、锻炼偏好和强度等级。
现在,算法的具体实现并不重要,重要的是该算法的一部分会运行一个耗时的计算,而这个计算需要几秒钟才能完成。在这里,我们用一个名为 simulated_expensive_calculation 的函数来模拟这个计算。这个函数接受一个强度参数,并打印出“正在慢慢计算…”,然后让线程休眠两秒钟,最后返回该强度值。
现在,在 main 函数中,我们模拟了用户想要生成新的锻炼计划时会调用的代码。我们调用了 generate_workout,这是一个我们还没有创建的函数。generate_workout 接受两个参数:用户指定的锻炼强度和一个随机数,用来为生成的锻炼计划提供一些变化。
由于在这个例子中我们并没有实际构建前端,因此我们传入一个模拟的强度值 10。至于随机数,我们可以使用 rand crate 来生成一个随机数,但这不是我们这个例子的重点,所以我们直接将随机数设为固定的 7。
接下来,我们将定义 generate_workout 函数,紧接着 main 函数之后。generate_workout 函数接受两个参数:强度和随机数。如果强度小于 25,我们会打印出做多少个俯卧撑和多少个仰卧起坐。为了确定具体做多少个俯卧撑和仰卧起坐,我们调用了我们的耗时计算函数。如果强度大于 25,则进入 else 分支。在这里我们会检查随机数,如果它正好是 3,我们会打印“休息一下”,否则我们会打印跑步多少分钟。再次计算跑步分钟数时,我们使用了耗时计算函数。
现在,这个代码是可以工作的,但它需要一些重构。一个问题是我们在多个地方调用了耗时函数。因此,如果我们改变了函数的调用方式,比如增加了一个参数,那么我们就需要改变所有调用的地方。而且,我们还在多个地方不必要地调用了耗时函数,例如在这个 if 代码块中,我们调用了两次耗时函数,但实际上我们只需要调用一次,然后将返回值传递给两个 print 语句。
对于普通函数来说,这也许没什么问题,但要记住这个是一个耗时的函数,运行需要两秒钟,因此我们希望尽量减少调用这个函数的次数。让我们通过将耗时函数的结果存储在一个变量中来解决这两个问题。
因此,我们会在函数的顶部创建一个新变量,叫做 expensive_result,并将其设置为耗时函数的调用结果。然后,我们在所有的 print 语句中使用这个变量。
这样做解决了我们的重复调用问题,但现在我们有了另一个问题:无论下面执行什么代码,我们都会在函数顶部调用耗时函数。但是你可以看到,如果随机数是 3,我们并不需要调用耗时函数,因为我们只是简单地打印了这段文本。我们想要的是在一个地方定义我们的代码,但只在必要时执行它。因此,让我们使用闭包来重构这个代码。
让我们滚动回到程序的顶部,而不是定义这个 expensive_result 变量。
使用闭包。让我们滚动回到程序的顶部,而不是定义这个 expensive_result 变量。
我们将定义一个闭包。这里我们有一个名为 expensive_closure 的变量,它等于我们的闭包。请记住,闭包是匿名函数,而闭包和函数之间的主要视觉区别是,闭包的输入参数不是放在圆括号中,而是放在竖线之间。因此,这里我们有一个输入参数 num,后面跟着花括号,花括号内是闭包的主体。如果闭包的主体只有一行代码,那么我们甚至不需要花括号。在闭包主体内,我们执行我们的耗时计算函数,并返回 num。最后,我们需要在 let 语句末尾加上分号。
注意,我们的 expensive_closure 变量并没有存储闭包的返回值,而是存储了闭包本身。还要注意的是,闭包的主体与我们之前定义的 expensive 函数的主体是相同的。现在我们定义了闭包,可以在 print! 语句中调用它。
正如你所看到的,调用闭包的语法类似于调用函数的语法。我们指定存储闭包的变量名,然后跟上圆括号,并传入输入参数。现在,我们的逻辑定义在一个地方,并且只在需要时才调用耗时操作。但我们回到了一个老问题,那就是在这个 if 代码块中,我们两次调用了我们的耗时操作,这并不好。我们可以通过在这个 if 语句的顶部存储 expensive_closure 调用的结果来解决这个问题,但还有另一种方式来解决这个问题,我们稍后会讨论。
在解决这个问题之前,你可能已经注意到我们不需要为闭包的输入参数标注类型,也不需要标注闭包的返回值。对于常规函数,我们必须指定输入参数的类型和返回值的类型,这是因为函数是对外公开的接口,所以对传入和返回的类型达成一致非常重要。而闭包通常很短,且仅在有限的上下文中有意义,因此编译器能够推断出输入参数的类型和返回值的类型。这类似于编译器能够推断大多数变量的类型。
注意,如果我们愿意,也可以显式地指定类型,如下所示。这种方式显式地显示了类型,但代价是更加冗长。需要注意的是,闭包定义中每个输入参数只能推断出一种具体类型。例如,这里我们有一个名为 example_closure 的变量,它等于一个闭包,该闭包接收 x,然后返回 x。由于闭包在第 26 行使用了一个字符串,编译器推断输入参数的类型是字符串。但在第 27 行,我们用一个整数调用了 example_closure 变量,编译器会报错,提示我们类型不匹配:我们期望的是字符串,但得到的是整数。因此,编译器的工作方式是,传递给闭包的第一个类型将成为输入参数的具体类型。
现在让我们回到在这个 if 代码块中两次调用 expensive_closure 的问题。我们可以通过在 if 代码块的顶部创建一个变量并存储 expensive_closure 的结果来解决这个问题,然后在这两个 print! 语句中使用该结果。不过,我们要尝试另一种方法:我们将使用备忘模式,通过创建一个结构体来保存我们的闭包及其结果。
在 generate_workout 函数上方,我创建了一个 Cacher 结构体。现在,为了定义使用闭包的结构体、枚举或函数参数,我们需要使用泛型和 trait 约束。这里我们的 Cacher 结构体使用了一个泛型 T,并且我们为这个泛型定义了一个 trait 约束,而我们使用的 trait 是 Fn,它代表函数。不过,不深入讨论 Fn trait 是什么,只需知道它是标准库提供的,所有闭包都实现了这三个 Fn trait 之一:Fn、FnMut 和 FnOnce。我们稍后会讨论这三者的区别。
这里我们为 Fn trait 添加了类型,用于表示闭包的输入参数(一个无符号的 32 位整数)和返回参数(同样是无符号的 32 位整数)。然后在结构体的主体中,我们有两个字段。第一个是 calculation,它将存储我们定义的泛型类型,因此 calculation 可以是任何符合上述 trait 约束的闭包。第二个是 value,它将是一个可选的 32 位整数。value 是可选的,因为当我们初始化 Cacher 时,它的值将是 None,一旦我们调用了 calculation,我们将把返回结果存储在 value 字段中。
值得注意的是,常规函数也实现了这三个 Fn trait,因此我们也可以将常规函数存储在 calculation 字段中。接下来,我将粘贴 Cacher 结构体的实现块,我们来讲解一下。
让我们逐步讲解这个实现。我们有 Cacher 的实现块,它与 Cacher 结构体拥有相同的泛型和 trait 约束。第一个函数是 new,它是一个构造函数,接受一个类型为 T 的 calculation(我们的闭包),然后创建一个新的 Cacher,传入 calculation 并将 value 设置为 None。
接下来是 value 方法,它是一个方法,因为第一个参数是对 self 的引用,实际上是对 self 的可变引用。下一个参数是 arg,它是我们传入闭包的参数,类型是无符号的 32 位整数,返回类型也是无符号的 32 位整数。在 value 方法内,我们将对 self.value 做一个匹配表达式,所以我们在检查 self.value。记住,self.value 是一个 Option 类型,当我们刚创建 Cacher 时,它的值将是 None,因此我们将执行 None 分支。
在 None 分支中,我们创建了一个名为 v 的变量,并将其设置为调用 calculation 闭包的结果,传入 arg 变量。然后,我们修改当前 Cacher 实例的 value 字段,将其设置为 Some(v)。这就是缓存发生的地方,我们将 calculation 的返回值缓存到 value 字段中,最后我们简单地返回 v。
接下来,让我们在 generate_workout 函数中使用我们的 Cacher 结构体。但在此之前,让我们先运行一下程序,看看效果。
正如你所看到的,我们的 expensive_closure 被调用了两次,一次是为了计算俯卧撑的数量,另一次是为了计算仰卧起坐的数量。让我们通过将我们的闭包定义包裹在 Cacher 结构体中来提高效率。
在这里,我们调用了 Cacher 结构体的 new 函数,并传入了我们的闭包,这个闭包将被设置为 Cacher 结构体中的 calculation 字段。让我们将变量名改为 cached_result,同时我们也需要让这个变量是可变的,因为我们将调用 value 方法,而该方法会修改 Cacher 结构体。然后,代替调用 expensive_closure,我们将调用 cached_result.value。
让我们再次运行程序,正如你所看到的,这次我们只调用了一次耗时操作。
现在,缓存值通常是一个非常有用的行为,因此我们可能希望在不同的上下文中使用我们的 Cacher。但有两个问题阻止我们这么做。第一个问题是调用 value 方法将返回相同的值,无论 arg 输入参数是什么。例如,假设第一次我们调用 value 方法时传入 arg 为 1,因为这是我们第一次调用 value 方法,self.value 将被评估为 None,所以我们会进入 None 分支,然后调用闭包,传入的 arg 值为 1。接着我们会将结果值保存到 self.value 中。
现在,想象我们再次调用 value 方法,但这次传入的 arg 是 2,这次 self.value 已经存在,因此我们会进入 Some 分支,只返回存储在 Some 中的值。这是个问题,因为 arg 被传递给闭包,这意味着它可能会改变闭包返回的结果,但在我们当前的实现中,无论传入的参数是什么,value 始终会等于首次调用 value 方法时的结果。我的意思是,我们需要为每个传入的参数缓存一个值,而不是无论传入什么参数都缓存同一个值,因为参数会影响返回的结果。作为练习,你可以通过存储一个 HashMap 来修复这个实现,HashMap 的键将是传入 value 的参数,而 HashMap 中的值将是调用闭包时的结果。然后,在 value 方法的主体中,你需要查找 HashMap 中的 arg,如果找到了该 arg 对应的值,就直接返回该值;如果没有找到,则运行耗时的计算,并将结果存储在 HashMap 中。
我们 Cacher 实现的第二个问题是,我们使用了硬编码类型。例如,我们要求闭包必须接受一个整数并返回一个整数,而我们的 value 也必须是一个整数。要修复这个问题,你可以简单地使用泛型来代替硬编码的值。
最后,我想谈谈闭包捕获环境的能力。与函数不同,闭包可以访问在定义它的作用域内定义的变量。这里是一个简单的例子:在顶部我们有一个变量 x,它等于 4,然后我们有一个名为 equal_to_x 的闭包,它接受一个名为 z 的变量,并返回一个布尔值,布尔值等于 z == x。尽管 x 是在闭包外部定义的,但我们的闭包仍然可以访问 x,因为它们都定义在相同的作用域中。然后我们定义了一个名为 y 的变量,并将其值也设置为 4,最后我们调用了闭包,传入 y。闭包调用被包裹在一个 assert! 宏中,如果闭包调用的结果是 false,则会触发 panic。让我们运行程序,看看结果。
正如你所看到的,我们并没有触发 panic。现在,让我们看看如果使用函数而不是闭包会发生什么。所以我们将 equal_to_x 闭包改为一个函数。
你会看到 x 下方有一些红色波浪线,如果我将鼠标悬停过去,可以看到错误提示:无法在函数中捕获动态环境,请使用闭包代替。所以编译器实际上在告诉我们使用闭包而不是函数,因为闭包能够捕获它们的环境。闭包需要使用额外的内存来存储这些上下文,但因为函数不会捕获它们的环境,所以它们不会产生相同的开销。
闭包有三种方式从它们的环境中捕获值,这三种方式直接对应于函数可以接受输入参数的三种方式:通过获取所有权、通过不可变借用或通过可变借用。这三种捕获环境的方式被编码在我们之前提到的 Fn trait 中,分别是 FnOnce、FnMut 和 Fn。FnOnce 获取闭包环境中变量的所有权,Once 这个词代表闭包不能多次获取同一变量的所有权,因此这些闭包只能被调用一次。FnMut 可变地借用了变量,而 Fn 不可变地借用了变量。
当你创建一个闭包时,Rust 会根据你如何在闭包环境中使用这些值来推断应该使用哪个 trait。不过,我们可以通过在闭包定义前使用 move 关键字来强制闭包获取它所使用的值的所有权。这在你将闭包从一个线程传递到另一个线程时尤其有用,这样你也可以将变量的所有权从一个线程传递到另一个线程。例如,在这个例子中 x 等于一个向量,我们的闭包没有改变,然后我们打印出 x,接着我们有一个 y 也等于一个向量,然后我们再次调用闭包,传入 y 并断言它返回 true。现在,因为在闭包中我们只是将 x 与 z 进行比较,并没有在闭包中获取 x 的所有权。但我们可以通过在闭包定义前指定 move 关键字来强制闭包获取 x 的所有权。
现在我们的闭包确实获取了 x 的所有权,并且在闭包定义下方的打印语句中我们会收到一个错误。如果我将鼠标悬停在红色波浪线上,错误提示会显示:我们在 x 被移动后尝试使用它。这是合理的,因为在上面的闭包中,x 的所有权已经被获取,所以我们不能在其被移动后再次使用它。
好了,这就结束了第十三章的第一部分,在这一部分中我们讲解了闭包。我知道闭包可能难以理解,而且我们并没有覆盖所有使用闭包的方式。因此,如果你想看更多关于闭包的例子,请在下方评论。当然,如果你喜欢这个视频,别忘了点赞。在下一期视频中,我们将讲解迭代器。如果你想收到通知,记得点击订阅。我们下期再见!
原文链接: https://dashen.tech/2018/07/20/Rust中的闭包/
版权声明: 转载请注明出处.