对比学习 Go 和 Rust:函数

2022年5月20日 // Go Rust 编程

为了更好地组织程序代码,常将一些特定的任务组织成为具有相对独立性的函数。作为现代化编程语言,Go 和 Rust 都支持函数式编程,这意味这可以非常灵活地使用函数。

函数声明

函数声明包括函数名称、形式参数列表、返回值列表(可省略)以及函数体。函数参数叫做形参(parameter),形参的具体值是是实参(argument)。

Go 的函数声明

在 Go 中,声明函数的方式如下:

func funcName(参数列表) (返回值列表) {
    // 函数体内代码
}

先看一些函数声明的示例:

// 无返回值的函数
func sayHello() {
    fmt.Println("Hello, friend!")
}

// 带一个参数的函数
func sayHelloName(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

// 有一个匿名返回值的函数
func three() int {
    return 3
}

// 带参数且具有一个命名的返回值的函数
func plusThree(x int) (result int) {
    return x + 3
}

// 当返回值已命名时,return 语句可以不跟参数
func plusOne(x int) (result int) {
    result = x + 1
    return
}

// 带两个参数的函数,且分别标注参数类型
func sub(a int, b int) int {
    return a - b
}

// 使用空白标识符 _ 忽略参数名称
func coordinate2D(x, y, _ float64) (_, _ float64) {
    return x, y
}

// c 是变长类型,它其实是一个切片
func add(a, b int, c ...int) int {
    result := a + b
    for _, v := range c {
        result += v
    }
    return result
}

// 经常在返回值中包含一个错误 error
func min(a ...int) (int, error) {
    if a == nil {
        return 0, errors.New("no input parameters")
    }
    min := a[0]
    for _, v := range a {
        if v < min {
            min = v
        }
    }
    return min, nil
}

关于 Go 的函数声明,有以下要点:

  • 参数列表就像是省略 var 关键字的变量声明,即多项参数用逗号分割,且相同类型可以成组地声明。
  • Go 的函数可以返回零个、一个或多个值,且每个返回值可以指定名称,也可以省略名称,但多个返回值必须同时指定或同时省略名称。当指定了返回值的名称时,返回值列表的形式就像参数列表的形式一样;但当不指定返回值的名称时(即所谓匿名返回值),就只给出返回值的类型,各个类型用逗号分割。当没有返回值,或只有一个匿名的返回值时,返回值列表外面的圆括号对可以省略。
  • 可以用 return 语句结束函数的执行。若函数没有返回值,可以没有 return语句,函数执行到末尾自动结束。若函数有返回值,则函数体中必须有 return 语句。若返回值是匿名的,则 return 后面必须跟与返回值列表中返回结果个数、类型对应相同的返回值。若返回值已命名,则 return 后面可以什么都不跟,对应将返回这些命名的变量的值。
  • 参数名和结果名不能重名,应该给他们赋予一个有意义的名称,且要短一点,这些名称也可以是空白标识符 _
  • 函数中的最后一个参数可以是变长参数,该参数的类型是切片类型,通过在切片元素类型的前面加三个句点 ... 来表示。因此,...T 相当于 []T,但变长参数在调用时更方便。

Rust 的函数声明

Rust 声明函数的方式如下:

fn func_name(参数列表) -> 返回值类型 {
    // 函数体内代码
}

同样先看一波示例:

// 无返回值的函数
fn say_hello() {
    println!("Hello, friend!");
}

// 带一个参数的函数
fn say_hello_name(name: String) {
    println!("Hello, {}!", name);
}

// 有一个返回值的函数,通过 return 返回函数的值
fn three() -> i32 {
    return 3;
}

// 带两个参数的函数,函数体最后一行是一个表达式
fn sub(a: i32, b: i32) -> i32 {
    a - b
}

// 使用空白标识符 _ 忽略参数名称,并返回一个元组
fn coordinate_2d((x, y, _): (f64, f64, f64)) -> (f64, f64) {
    (x, y)
}

// 函数获得了 v 的所有权,并且不论原来是否可变,都将其设置为可变
fn append_str(mut v: String) -> String {
    v.push_str(", world!");
    v
}

// 函数获得了 v 的所有权,并且不论原来是否可变,都将其设置为可变
fn modify_str(mut v: String) -> String {
    v.push_str(", world!");
    v
}

// 函数不获取 v 的所有权,但以不可变借用的方式传递参数
fn print_str(v: &String) {
    println!("{}", v);
}

// 函数不获取 v 的所有权,但以可变借用的方式传递参数
fn modify_str(v: &mut String) {
    v.push_str(", world!");
}

// b 函数被内嵌在 a 函数中
fn a() {
    fn b() {
        println!("Hello!");
    }
    b();
}

通过以上寥寥几个示例,基本上已经把要点都包括了。

相对于 Go,Rust 的函数声明有如下特征:

  • Go 语言中函数的声明用 func 关键字,Rust 中则用 fn。Rust 的缩写有点过分了,哈!
  • Go 的函数名一般使用驼峰式(CamelCase)大小写命名法,Rust 则使用蛇形(snake_case)小写命名法。
  • Rust 的参数列表也像省略的 let 关键字的变量声明,多个变量同样用逗号分割。与 Go 相比,参数名称和参数类型之间要用冒号 : 而不是空格分割,也不能像 Go 那样可以成组地声明。
  • Go 的参数是可变的,而 Rust 的参数默认是不可变的,要使其可变,需要在前方加上 mut
  • 当函数具有返回值时,Go 在参数列表和后面的返回值列表之间用空格分割,而 Rust 在参数列表和后面的返回值类型之间用箭头 -> 分割。Go 支持多个返回值,Rust 不支持,但通过返回一个元组,再解构元组,可以轻易地实现和多个返回值相似的功能。另外,Rust 不支持对返回值命名。
  • 函数的声明属于声明语句,而函数声明中带大括号的代码块属于语句中的表达式,该表达式的值就是函数的返回值。Go 和 Rust 都可以使用 return 语句终止函数的执行,以及返回函数的值,但 Rust 也可以甚至经常省略最后的 return 语句,这要求最后的一行是表达式而非语句(切记,该表达式的末尾不能有分号)。另外,即便 Rust 函数没有显式地返回任何值,它也隐式地返回了一个单元值 ()
  • Go 函数支持变长类型的参数,这有点像黑魔法,但使用起来却很方便;Rust 不支持变长参数,要实现类型的功能,需要使用宏功能,或者转而将这些不确定个数的多个参数转换为一个集合类型参数。我们在前面已经接触过这种宏了,分别是 println!vec!,这将留待以后再讲。
  • Rust 同时支持具名和匿名的内嵌函数,Go 只支持匿名的内嵌函数。

函数调用

在函数声明之后,就可以通过函数名调用函数,向函数传入实参,并返回函数的执行结果,这很容易理解。只不过在 Go 中,向函数传递参数相当于给变量赋值。而 Rust 的传参则是进行模式匹配,Rust 中函数的形参都是不可反驳的模式。Go 语言所有的传参都是值传递(传值),即传递了实参的一份拷贝;若参数是一个指针,也是传递该指针而非指针指向数据的拷贝。Rust 的参数可以按值传递,也可以按引用传递。当按值传递时,对于栈上数据,将执行复制语义;对于堆上数据,将转移所有权。当按引用传递时,所有权不会发生变化,又可分为可变借用和不可变借用。Rust 中函数参数的传递涉及其所使用一套极具创新性的所有权规则,这将在以后讲解。

在 Go 中,当函数没有返回值时,应以独立的语句方式调用函数;当函数具有返回值时,则函数调用相当于一个表达式。而在 Rust 中,函数总是有返回值,函数调用属于表达式。

作为一个示例,这里展示用这两种语言以递归调用的方式求解斐波那契数的示例。斐波那契数又译为菲波拿契数、菲波那西数、斐氏数、黄金分割数。所形成的数列称为斐波那契数列。斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。

以下是 Go 语言官方网站上展示的求斐波那契数列中第 n 个数的方法,该函数通过递归调用函数自身进行求值:

func fib(n int) int {
    if n < 2 {
        return n
    }

    return fib(n-2) + fib(n-1)
}

将上述代码改写为 Rust 代码:

fn fib(n: i32) -> i32 {
    if n < 2 {
        return n;
    }

    fib(n - 2) + fib(n - 1)
}

函数式编程

Go 和 Rust 都支持函数式编程。(该部分内容有点超前,你可以留待以后再看。)

相关术语

函数式编程涉及许多术语,以下先给出这些术语的定义:

  • 函数式编程:一种声明式的编程范式。相对于指令式编程,其中程序是通过应用和组合函数来构造的;函数定义不是能更新运行状态的指令语句序列,而是将值映射到其他值的表达式树;函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。在函数式编程中,函数被当作头等公民。这允许多个小函数以模块化的方式组合在一起,形成声明式和组合式的编程风格。
  • 头等函数:若一种编程语言将函数当作头等公民(也被译作一等公民),则可以说该语言具有头等函数。这意味着,函数可以作为别的函数的实参、函数的返回值,赋值给变量或存储在数据结构中,以及支持匿名函数(或称作函数字面量)。头等函数是函数式程序设计所必须的。
  • 高阶函数:在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:① 接受一个或多个函数作为输入;② 输出一个函数。在数学中它们也叫做算子(运算符)或泛函。微积分中的导数就是常见的例子,因为它映射一个函数到另一个函数。高阶函数与头等函数的概念有点类似,他们都允许将函数作为其他函数的实参和返回值。不过,高阶函数所描述的更加偏重于数学中函数的概念,而头等函数则是一个计算机科学的术语。
  • 函数类型:可以被赋值为一个函数的变量或参数的类型,或者是接受或返回函数的高阶函数中的实参或返回值的类型。或者说,函数类型是指具有同样的参数和返回类型的所有函数。函数类型取决于函数的参数和返回值的类型,而与函数名称无关。
  • 函数签名:函数名称、作用域、输入参数、输出返回值的定义,一般除了函数体之外的函数声明都属于函数签名。
  • 内嵌函数:在另外一个包围函数内部定义的函数。根据简单递归作用域规则,内嵌函数内部的内容对其直接外层包围函数是不可见的,但内嵌函数却可以访问该包围函数的局部对象。内嵌函数可以是具名的或匿名的。
  • 匿名函数:指没有绑定标识符(函数名)的函数定义,又称函数字面量、Lambda 函数、Lambda 表达式。匿名函数通常作为传递给高阶函数的实参,或用于构造需要返回函数的高阶函数的返回值。如果一个函数只使用一次或有限次,那么使用匿名函数会比具名函数更轻便。匿名函数在函数式编程语言和其他具有头等函数的语言中非常常用。在这些语言中,他们相对于函数类型的地位与各种字面量相对于数据类型的地位是一样的。
  • 自由变量:指用于函数中的变量,该变量既非局部变量,又非函数的参数。其概念基本等同于非全局变量。与局部变量相对立的一个术语是约束变量。比如,假如我们要基于胡克定律计算线性弹簧所受到的力 f = k x 的值,我们编写一个签名形如 func force(x float64) float64(或 fn force(x: f64) -> f64)的函数,该函数计算所依赖的 k 值不在函数内部定义,而是来自于函数声明所处的上下文,那么 k 就是自由变量,x 是约束变量。
  • 非局部变量:不是在局部作用域中定义的变量。虽然非局部变量也包括全部变量,但它主要被用于内嵌函数或匿名函数的上下文中,用于指代一些既非局部、也非全局作用域的变量。
  • 闭包:又称词法闭包或函数闭包,是一种在支持头等函数的编程语言中实现词法作用域名称绑定的一种技术——这是维基百科上比较抽象但相对准确的定义。更通俗地讲,一个函数和对其周围状态(词法环境)的引用捆绑在一起,这样的组合就是闭包。不像普通函数,闭包允许捕获调用者作用域中的值。或许还是比较难于理解,稍后将通过进一步的示例解释。

将函数赋值给变量

根据上面对头等函数的定义,可知当编程语言支持头等函数时,一个函数实例可以像其他类型的字面量一样赋值给一个变量,以及作为高阶函数的实参或返回值,并且该函数实例具有特定的类型。这里还主要使用前面给出的公式 f = k x,来编写一些示例代码。

先看看 Go 语言的一些示例。现在我们已经编写好如下函数:

func force(k, x float64) float64 {
    return k * x
}

我们可以将其赋值给一个变量,并通过该变量进行函数调用:

func main() {
    g := force
    v := g(5.0, 3.3)
    fmt.Println(v)
}

以上代码中的 g 属于函数类型,函数类型属于引用类型。本来我们可以直接调用函数 force 的,但为了说明问题,我们不必要地将事情弄得复杂了点。我们甚至可以把事情弄得更复杂,即先声明一个函数类型变量,再对其进行赋值:

func main() {
    var g func(float64, float64) float64
    g = force
    v := g(5.0, 3.3)
    fmt.Println(v)
}

其中 func(float64, float64) float64 表示函数的类型,同时也是函数签名。这里没有给出函数参数和返回值的标识符;为提高可读性,也可以给出这些名称;不过分别对参数列表和返回值列表,必须要么同时给出名称,要么同时不给。如下都是有效的函数类型,由于参数和返回值类型的不同,他们各自分属不同的类型:

func()
func(x int) int
func(a, _ int, z float32) bool
func(a, b int, z float32) (bool)
func(prefix string, values ...int)
func(a, b int, z float64, opt ...interface{}) (success bool)
func(int, int, float64) (float64, *[]int)
func(n int) func(p *T)

在 Go 中,也可定义用户自定义函数类型:

type HookeLaw func(float64, float64) float64

在前面刚通过 var g func(float64, float64) float64 语句声明了函数变量 g 之后,g 的值没有初始化为具体函数实例,其具有初始零值 nil

下面看看如何用 Rust 实现相同的操作:

fn main() {
    let g: fn(f64, f64) -> f64 = force;
    let v = g(5.0, 3.3);
    println!("{}", v);
}

fn force(k: f64, x: f64) -> f64 {
    k * x
}

如果要细纠,以上代码有很多说道。其中 force 函数声明表示一个函数(函数项)。当该函数被引用时(直接使用函数名称),该函数会产生一个相应的零尺寸(不包含数据)的头等值,该值属于函数项类型,调用该值就相当于直接调用该函数。每个函数的函数项类型是唯一的,且没有具体的名称,编译器会以 fn(f64, f64) -> f64 {force} 的形式表示 force 所对应的函数项类型。以上的 fn(f64, f64) -> f64 则表示一个函数指针类型(用 fn 标识)。而 let g: fn(f64, f64) -> f64 = force; 语句将函数项类型值 force 赋值给具有相同签名的函数指针类型变量 g,这其中通过模式匹配发生了自动强制类型转换。函数指针就是指向函数的指针,其值为函数的地址,调用函数指针相当于调用其所指向的函数。

Rust 中的函数指针类型(相当于 Go 中函数类型)可以表示如下:

fn()
fn(x: i32) -> i32
fn(a: i32, b: i32, z: f32) -> bool
fn(a: i32, _: i32, z: f32) -> bool
fn(a: i32, b: i32, f32) -> bool
fn(i32, i32, f64) -> (f64, Vec<i32>)
fn(n: i32) -> fn(i: i32)
fn(n: i32) -> fn(i: i32) -> i32
fn(n: i32) -> fn(i: i32) -> fn(i: i32) -> i32;

由以上可知,Rust 函数指针类型中的参数可以部分给出名称,部分不给,而不像 Go 那样必须同时给出或不给名称。

Rust 同样支持用户自定义函数指针类型:

type HookeLaw = fn(f64, f64) -> f64;

另外,由于 Rust 支持在函数内声明函数,可以将前面上 force 函数的声明移到 main 函数之内,放在 main 函数体的最前、最后等位置都是可以的。

那么,如何像 Go 那样同样做到先声明函数指针变量,再对其赋值呢?只要按如下方式修改 main 函数即可:

fn main() {
    let g: fn(f64, f64) -> f64;
    g = force;
    let v = g(5.0, 3.3);
    println!("{}", v);
}

fn force(k: f64, x: f64) -> f64 {
    k * x
}

由以上示例可知,在函数类型表示上,Go 和 Rust 是十分相似的。

闭包

以上声明的 force 函数仅被使用有限次,为了进一步简化代码,完全可以不指定函数名称,即使用匿名函数(函数字面量)。在 Go 和 Rust 中,匿名函数就是闭包

在 Go 中使用闭包,需要将原来的 force 函数声明移动到 main 函数体内,这显得很紧凑:

func main() {
    g := func(k float64, x float64) float64 {
        return k * x
    }
    v := g(5.0, 3.3)
    fmt.Println(v)
}

我们甚至可以在函数声明的末尾立即调用该函数,这就更紧凑了:

func main() {
    v := func(k float64, x float64) float64 {
        return k * x
    }(5.0, 3.3)
    fmt.Println(v)
}

在闭包的定义上,Rust 就和 Go 不一样了。请看示例:

fn main() {
    let g = |k: f64, x: f64| -> f64 {
        k * x
    };
    let v = g(5.0, 3.3);
    println!("{}", v);
}

这里没有像我们预想的那样用 fn 关键字来定义闭包,而是多出了 || 包围的参数列表。即在声明时不用 fn 关键字,不用函数名,反而使用 || 替代 () 将输入参数列表包括起来。

不像 Go 那样可以在闭包声明的最后加一对圆括号 () 立即调用该函数,Rust 必须在下一个语句或表达式中调用。

以上 Rust 中闭包的输入参数和返回值都显式标注了类型,其实闭包的定义可以省略更多的东西。如下各行都是有效的:

let g = |k: f64, x: f64| -> f64 { k * x };
let g = |k, x|           -> f64 { k * x };
let g = |k, x|                  { k * x };
let g = |k, x: f64|             { k * x };
let g = |k, x|                    k * x  ;

为什么可以这样呢?因为闭包通常都是临时使用的短小的函数,其参数和返回值的类型都不必暴露给外部代码,因此也就不必显式标记他们的类型了,将由编译器根据上下文自动推断他们的类型——Rust 的编译器最喜欢干些额外的脏活累活了。日常使用中,也习惯不标注参数或返回值的类型。当闭包中的代码只有一行时,甚至都不用在外围加大括号,如以上代码的最后一行。

不过,一旦闭包的参数或返回值类型被推断出来了,就不能将其应用在与类型不匹配的调用场景。以下代码就无法通过编译:

fn main() {
    let g = |k, x| k * x;
    let v = g(5.0, 3.3);
    println!("{}", v);
    let v = g(5, 3.3);
    println!("{}", v);
}

原因第一次调用 g 变量对应的闭包时,已经确定其参数 kf64 类型,第二次却给该参数传入了 i32 类型的 5,编译器就会告诉你:expected floating-point number, found integer

在 Go 中,闭包和具名函数都属于具体的函数类型。在 Rust 中,闭包并不是函数,它没有具体的类型。若一个地方需要 Rust 闭包并需要预先声明其类型,可以有两种方法:使用和闭包相似签名的函数指针类型 fn 标记类型;使用闭包所自动实现的一些 trait(包括 FnFnMutFnOnce)标记类型。在本文的稍后将会解释这些。

高阶函数

在了解了函数类型、闭包之后,很容易做到使函数的返回值是一个函数,或使函数的输入参数包含一个函数。

以下两个示例代码,分别演示了在 Go 和 Rust 中,一个函数如何返回另外一个函数:

func main() {
    g := linearSpringFunc()
    v := g(5.0, 3.3)
    fmt.Println(v)
}

func linearSpringFunc() func(k, x float64) float64 {
    return func(k, x float64) float64 {
        return k * x
    }
}
fn main() {
    let g = linear_spring_func();
    let v = g(5.0, 3.3);
    println!("{}", v);
}

fn linear_spring_func() -> fn(k: f64, x: f64) -> f64 {
    fn linear_spring(k: f64, x: f64) -> f64 {
        k * x
    }
    linear_spring
}

由于在 Go 的函数中无法再定义具名函数,因此以上 linearSpringFunc 的返回值必须是一个闭包。但在 Rust 中,函数内部可以定义具名函数,以上 linear_spring_func 返回的是一个具名函数。但是否可以使 Rust 像 Go 一样,返回一个闭包呢?可以的,请看如下代码:

fn main() {
    let g = linear_spring_func();
    let v = g(5.0, 3.3);
    println!("{}", v);
}

fn linear_spring_func() -> impl Fn(f64, f64) -> f64 {
    |k, x| k * x
}

请注意,这里 linear_spring_func 函数的返回值不是 fn(k: f64, x: f64) -> f64 类型,而是 impl Fn(f64, f64) -> f64。这种写法是推荐的,因为在 Rust 中,闭包不是 fn 类型的函数指针,同时,linear_spring_func 函数返回的闭包实现了 Fn trait(这将在稍后讲述),因此才可以用 Fn 表示该闭包的类型。

其实,由于上例中的闭包并没有捕获周围环境,因此把 linear_spring_func 函数的返回值标记为 fn(k: f64, x: f64) -> f64 类型也是可以的。这是因为:如果闭包没有移动、借用或以其他方式捕获局部环境变量时,该闭包将能被自动强制转换为一个函数指针 fn,这种转换就和前面将函数项自动强制转换为函数指针一样的。如下所示:

fn main() {
    let g = linear_spring_func();
    let v = g(5.0, 3.3);
    println!("{}", v);
}

fn linear_spring_func() -> fn(k: f64, x: f64) -> f64 {
   |k, x| k * x
}

以下两个示例代码,分别演示了在 Go 和 Rust 中,一个函数的参数中包含函数的示例:

func main() {
    g := func(k, x float64) float64 {
        return k * x
    }
    printSpringForce(g, 5.0, 3.3)
}

func printSpringForce(f func(float64, float64) float64, k, x float64) {
    v := f(k, x)
    fmt.Println(v)
}
fn main() {
    let g = |k, x| k * x;
    print_spring_force(g, 5.0, 3.3)
}

fn print_spring_force(f: impl Fn(f64, f64) -> f64, k: f64, x: f64) {
    let v = f(k, x);
    println!("{}", v);
}

用闭包捕获环境

在 Go 和 Rust 中,闭包的实现都可以将闭包所引用的外部环境中的变量(也称自由变量)和闭包自身绑定起来,或者说闭包可以捕获其环境并访问其被定义的作用域的变量——这正是闭包区别于具名函数的地方。这里说匿名函数就是闭包,似乎与前面定义列表中给出的闭包定义并不一样,因为前面说匿名函数和其周围状态捆绑在一起共同组成闭包。其实这些都是大同小异了,因为这里说闭包的时候,已经认为函数周围状态已附着于闭包了。

虽然前面已经给出了许多闭包的示例,但并没有展示出闭包可以与外部环境中的变量绑定在一起的特征。这里新给出一个示例:

func main() {
    g := generateALinearSpring(5.0)
    v := g(3.3)
    fmt.Println(v)
}

func generateALinearSpring(k float64) func(x float64) float64 {
    return func(x float64) float64 {
        return k * x
    }
}

在以上示例中,generateALinearSpring 的返回值是一个闭包,该闭包接收一个参数 x,而同时其运行要依赖外部环境的变量 k。当通过 g := generateALinearSpring(5.0) 调用 generateALinearSpring 函数后,按常规情况 k 已经因超出作用于而不能使用了,但为了确保能正确地使用 g 引用的闭包,编译器将通过一定的机制确保闭包所引用的外部环境中变量 k 仍然可用,并且只要 g 所指向的闭包实例存在,k 就一直可用。

由此可见,闭包并不是生硬造出的东西,它是保证高阶函数的完整性、确保代码能正常运行的自然而然的选择。

Go 的闭包捕获外部变量很简单,但 Rust 可并非如此。让我们尝试把上面的 Go 代码改为 Rust。

第 1 次尝试:

fn main() {
    let g = linear_spring_func(5.0);
    let v = g(3.3);
    println!("{}", v);
}

fn linear_spring_func(k: f64) -> fn(x: f64) -> f64 {
    |x: f64| k * x
}

以上代码不能编译,其错误信息如下:

error[E0308]: mismatched types
 --> src/main.rs:8:5
  |
7 | fn linear_spring_func(k: f64) -> fn(x: f64) -> f64 {
  |                                  ----------------- expected `fn(f64) -> f64` because of return type
8 |     |x: f64| k * x
  |     ^^^^^^^^^^^^^^ expected fn pointer, found closure
  |
  = note: expected fn pointer `fn(f64) -> f64`
                found closure `[closure@src/main.rs:8:5: 8:19]`
note: closures can only be coerced to `fn` types if they do not capture any variables
 --> src/main.rs:8:14
  |
8 |     |x: f64| k * x
  |              ^ `k` captured here

根据出错信息可知,这里 linear_spring_func 函数签名中的返回类型是函数指针 fn(x: f64) -> f64,而实际却返回了闭包。由于该闭包捕获了外部环境,因此不能被自动强制转换为函数指针。这种规定将绑定了外部环境变量的闭包与普通的函数区分开来。那么我们把返回类型更改为闭包所自动实现的 Fn trait 试试。

第 2 次尝试:

fn main() {
    let g = linear_spring_func(5.0);
    let v = g(3.3);
    println!("{}", v);
}

fn linear_spring_func(k: f64) -> impl Fn(f64) -> f64 {
    |x: f64| k * x
}

仍然出错:

error[E0597]: `k` does not live long enough
 --> src/main.rs:8:14
  |
8 |     |x: f64| k * x
  |     -------- ^ borrowed value does not live long enough
  |     |
  |     value captured here
9 | }
  |  -
  |  |
  |  `k` dropped here while still borrowed
  |  borrow later used here

这是说 k 是闭包借用的值,但它在离开 linear_spring_func 函数的作用域就不存在了,因此导致返回的闭包无效。这里就是 Rust 和 Go 不一样的地方了,Go 会自动延长所捕获变量的生存时间,保证环境变量的生存时间和闭包一样长。对于 Rust 来说,闭包和环境变量的生存时间是不一样的的,除非环境变量的所有权被移动给闭包。上面代码中的 klinear_spring_func 退出后就被销毁,这时闭包就变得无效。为了延长闭包所捕获变量的生存时间,需要在闭包定义的参数列表前添加 move 关键字,从能能把通过不可变借用或可变借用捕获的变量转变为通过值捕获的变量,即强制复制 Copy 类型的变量,或转移非 Copy 类型变量的所有权。那么,让我们通过添加 movek 的所有权转移给闭包。在多线程编程时常用到 move

第 3 次尝试:

fn main() {
    let g = linear_spring_func(5.0);
    let v = g(3.3);
    println!("{}", v);
}

fn linear_spring_func(k: f64) -> impl Fn(f64) -> f64 {
    move |x: f64| k * x
}

总算可以通过编译了!

从以上这些尝试可以看出,Rust 的闭包是比较复杂的。当闭包捕获变量时,相当于隐式地向闭包传递参数。由于 Rust 独特的所有权规则,其函数获取参数的方式分三种:不可变借用、可变借用和获取所有权。很显然,为了保证内存安全,使用闭包捕获环境中变量时,也应该遵照这一套规则。实际上,Rust 编译器会根据闭包使用环境中变量的方式来推断我们希望如何引用捕获环境中的变量。编译器倾向于按如下优先次序捕获环境中的变量:① 不可变借用,② 唯一的不可变借用,③ 可变借用,④ 移动。这种优先次序选择只与闭包表达式的内容有关;编译器不考虑闭包表达式之外的代码,比如所涉及的变量的生存期。

以上第 ①、③、④ 三种情况都好理解,那么第 ② 种捕获方式又是什么意思呢?这里专门跑题用点篇幅解释一番。唯一的不可变借用发生在修改可变引用的引用对象时,请看示例(为了更便于讲述,显式标注了各变量的类型):

fn main() {
    let mut n: i32 = 33;
    let x: &mut i32 = &mut n;
    let c = || { *x = 44; };
    let y: &&mut i32 = &x;
    c();
}

该段代码无法编译,其编译错误信息为:

error[E0501]: cannot borrow `x` as immutable because previous closure requires unique access
 --> src/main.rs:5:24
  |
4 |     let c = || { *x = 44; };
  |             --   -- first borrow occurs due to use of `*x` in closure
  |             |
  |             closure construction occurs here
5 |     let y: &&mut i32 = &x;
  |                        ^^ second borrow occurs here
6 |     c();
  |     - first borrow later used here

以上代码中,在闭包的定义中出现了 x,通过 x 修改 n。由于 x 在声明时没有标注 mut,因此闭包对 x 的借用必然是不可变的。但实际上闭包却又能修改 x 指向的内容,就像可变借用一样,因此适用于所有权系统中可变借用规则,即同一时期只能存在一个可变借用。这种特有的借用称为唯一的不可变借用,即虽然使用的是不可变借用,但在同一时期却只能借用一次。接下来的 let y: &&mut i32 = &x; 再次借用了 x,这显然与规则相违背。要使编译通过,需要将 let y: &&mut i32 = &x;c(); 两行互换,等 c() 已不再使用,其对 x 的借用将结束,因此可以重新通过 y 借用 x 了。

回到正题。前面已经说过,当闭包绑定了环境变量时,为了和普通函数有所区分,将无法被转换为函数指针。若高阶函数的参数、返回值中包含闭包,常用 Fn 系列 trait 对该闭包的类型进行注解。Fn 系列 trait 有三个,分别对应不同的闭包捕获环境的方式:

  • Fn:闭包以不可变借用(&T)的形式从周围环境中捕获变量。这种闭包没有改变环境的能力,可以多次调用。
  • FnMut:闭包以可变借用(&mut T)的形式从周围环境中捕获变量。这种闭包有改变环境的能力,也可以多次调用。
  • FnOnce:通过传值(T)的形式从周围环境中捕获变量。外部作用域中的 Copy 类型将被复制进闭包,并保持原来的外部变量不变;非 Copy 类型将被移动进闭包。这种闭包可能消费从周围环境中捕获的变量,因此只能调用一次。所有闭包都实现了该 trait。

以上三个 trait 中,FnMut 继承自 FnOnce(或者说 FnMutFnOnce 的子 trait,FnOnceFnMut 的超集),Fn 又继承自 FnMut。因此凡是能用 Fn 的地方,也都能用 FnMutFnOnce,凡是能用 FnMut 的地方,也都能用 FnOnce。在给定闭包或函数类型时,要尽量限制他们捕获环境的能力,优先使用要求较少权限的闭包或函数,因此以上三个 trait 的优先选择顺序应该是 FnFnMutFnOnce。闭包类型实现什么样的 trait 是由闭包对捕获的变量值做了什么来决定的,而与如何捕获他们无关。正因为如此,即便通过添加 move 实现以移动所有权方式捕获变量,这些闭包仍然可能实现了 FnFnMut

先看一个闭包消费外部变量,从而只能使用 FnOnce 的示例:

fn main() {
    let hello = String::from("hello");
    let c = gen_closure(hello);
    c();
    // println!("{}", hello);
}

fn gen_closure(s: String) -> impl FnOnce() {
    || drop(s)
}

以上代码中,由于在闭包中将字符串 s 给销毁(drop)了,因此 gen_closure 所返回的闭包必须用 FnOnce 表示,用 FnFnMut 都不行。在 main 函数中对闭包的调用 c(); 也只能出现一次。以上被注释的 println!("{}", hello); 是不能编译的,因为在该行之前,hello 已经被移动到闭包了。c 执行一次后就不复存在了,因此不必将其设置为可变的。再看另外一段代码:

fn main() {
    let mut n = 0;
    let mut s = String::from("Hello");
    let c = || {
        if n < 1000 {
            s.push_str(", world");
            println!("{}", s);
            n += 1;
        } else {
            drop(s);
        }
    };
    c();
    // c();
}

尽管从代码逻辑上,多调用几次 c() 是没问题的,但实际 c(); 行只能出现一次,因为闭包中出现了 drop(s) 最终消耗了环境变量,这就意味着 s 的所有权被传递给了闭包,它自动实现了 FnOnce,因此其依然只能被调用一次。

再来看一个使用 FnMut 的示例:

fn main() {
    let s = String::from("hello");
    let mut c = gen_closure(s);
    c();
}

fn gen_closure(mut s: String) -> impl FnMut() {
    move || s.push_str(", world!")
}

以上在闭包中修改了字符串 s,因此此闭包需要使用 FnMut 对其进行类型注解,当然,使用 FnOnce 也是可以的。这里的 c(); 行是可以多次出现的。另外,let mut c = gen_closure(s); 中的 mut 是必不可少的,因为每次调用,闭包所捕获的外部变量将会改变,这相当于闭包也发生改变。由于 gen_closure 函数中 s 和返回闭包的生存时间不一样,因此必须在闭包的定义前面加 move

再来看一个使用 Fn 的示例:

fn main() {
    let s = String::from("hello");
    let c = gen_closure(s);
    c();
}

fn gen_closure(s: String) -> impl Fn() {
    move || println!("{}", s)
}

以上代码中,只是在闭包所打印字符串,因此只需要不可变借用就行了。当然,在这里把 Fn 更改为 FnMutFnOnce 也都是可行的。不过,要是将 gen_closure 函数的返回值类型更改为 impl FnMut,则必须将 main 函数中的变量 c 更改为 mut c。若使用 FnOnce,则 c(); 行只能出现一次。

再让我们回到前面的 linear_spring_func 函数,这次,我们希望在闭包中调整一下 k,则需要如此做:

fn main() {
    let mut g = linear_spring_func(5.0);
    let v = g(3.3);
    println!("{}", v);
}

fn linear_spring_func(mut k: f64) -> impl FnMut(f64) -> f64 {
    move |x: f64| {
        k += 0.1;
        k * x
    }
}

最后,让我们利用捕获外部变量的特征,再次重写用闭包求解斐波那契数的示例。先是 Go 代码:

func main() {
    f := fib()
    // 各函数调用将按从左到右的顺序求值。
    fmt.Println(f(), f(), f(), f(), f())
}

// fib 函数返回一个函数,该函数返回下一个 Fibonacci 数。
func fib() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

然后是 Rust 代码:

fn main() {
    let mut f = fib();
    // 各函数调用将按从左到右的顺序求值。
    println!("{}, {}, {}, {}, {},", f(), f(), f(), f(), f());
}

// fib 函数返回一个函数,该函数返回下一个 Fibonacci 数。
fn fib() -> impl FnMut() -> i32 {
    let (mut a, mut b) = (0, 1);
    move || {
        (a, b) = (b, a + b);
        a
    }
}

讨论

  • 在基础的函数声明和调用中,Go 和 Rust 的差别并不大,由于 Go 支持变长类型参数,使 Go 中的函数使用要更加灵活一点。
  • 两种语言都支持函数式编程风格,不过在闭包的使用上,由于涉及到对闭包所捕获环境中变量的所有权管理,Rust 要比 Go 复杂太多——你需要大毅力来学习领会。