三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

一团乱麻?带你厘清 Rust 中的函数指针、函数项、fn 类型、Fn Trait

一团乱麻?带你厘清 Rust 中的函数指针、函数项、fn 类型、Fn Trait


文章目录

  • 1. 知识图谱
  • 2. 什么是函数“类型”和“值”的分离?
  • 3. 函数指针与 fn 类型
  • 4. 函数项及其类型
  • 5. 闭包及其类型
  • 6. 函数类型大一统:Fn / FnMut / FnOnce
  • 7. 重新思考:Rust 如何实现了函数“类型”和“值”的分离?

对于 Rust 新手来说,一次性搞清楚函数指针、函数项、Fn trait、fn type 这些概念并不是一件容易的事情。很多人仔细研究了每一种机制,却依旧梳理不出“头绪”,对它们之间错综复杂的关系感到困惑和迷茫。本文,我们将带领大家沿着一条清晰的逻辑主线,逐一梳理这些概念,相信一定能带给你“豁然开朗”的感觉。

1. 知识图谱

函数指针、函数项、fn 类型、Fn Trait 这四种机制放之间的关系非常紧密,通常会在代码中“结伴”出现,它们往往出现在:回调、动态切换函数(策略模式)、高阶函数等一些典型场景中,在这些场景里,对函数的使用形式是一致的,即:

需要将一个函数 A 以参数形式传给另一个函数 B

这时我们会遇到一个现实问题:

函数 A 的“类型”应该怎样描述?因为在定义函数 B 时需要指明它需要的是一个什么样的函数参数,以及函数 A 的“值”(或者“引用”、“指针”)又该如何描述?因为在调用函数 B 时需要传入函数 A 的“值”(或者“引用”、“指针”)

我们今天要讨论的函数指针、函数项、fn 类型、Fn Trait 这四种机制可以说就是为了解决这个现实问题而引入的。为了让大家能清楚引入这些机制的“前因后果”,我们先简短地回顾一下函数的“进化史”:最初,以 C/C++ 为代表的编程语言率先引入了函数指针,实现了回调、运行时的函数切换(策略模式)等重要功能;后来,进入到以 Java/C# 为代表的基于虚拟机的纯面向对象编程语言时代,函数指针被“抛弃”了,通过接口、设计模式、匿名内部类等机制,这些纯面向对象语言实现了与函数指针类似的功能,在这一时期,由于函数是“委身”在类下面的“成员函数”,所以函数其实是被“弱化”了;再后来,随着函数式编程的兴起,函数在很多语言中的地位被抬升到了前所未有的高度,成为了语言中的“一等公民”,这一时期,函数已经实现了类型和值的分离,定义和使用方式发生了质的变化。

作为一门后发的编程语言,Rust 可以说是“博采众长”,在对“函数”的设计上,体现得淋漓尽致。一方面,作为一门系统系编程语言,Rust 沿用了 C/C++ 中的一些经典机制,“函数指针”就是其中之一;另一方面,Rust 大量引入了函数式编程的理念和机制,将函数“类型化”,使用特定的 trait 进行描述,再加上闭包的参与,形成了一套 FP 风格的函数机制;最后,为使传统的“函数指针”和“FP 风格的函数(闭包)”两种机制能有机地融合在一起,不至于形成两种割裂的风格,Rust 又做了一些“打通”工作。以上三方面基本概括了 Rust 语言在函数及其关联机制上的“布局”,这个布局可以用下面这张图清晰地表述出来:

这张图同时也是函数指针、函数项、Fn Trait、fn Type 这些概念和机制的“知识图谱”,它揭示了它们之间真正的内在联系,帮助大家从全局视角观察和理解这些概念和机制所处的位置以及相互之间的关系。本文,我们也是围绕这张图进行介绍的,其中主要涉及这样几个关键话题:

  • 什么是函数“类型”和“值”的分离?
  • 函数指针与 fn 类型
  • 函数项及其类型
  • 闭包及其类型
  • 函数类型大一统:Fn / FnMut / FnOnce
  • 重新思考:Rust 如何实现了函数“类型”和“值”的分离?

特别声明:本博客长期遭受 51CTO 无休止的盗文、剽窃,再次声讨其可耻行径!文章多处已嵌入此声明,盗用本文即传播了这份声明!

2. 什么是函数“类型”和“值”的分离?

在写作本文时,我们始终用“函数式编程”的视角来观察和审视 Rust 对函数的处理机制,因为函数式编程是函数进化到现在的最高形态,以这种“最高”视角观察,可以让我们看清 Rust 函数及其关联机制的“全貌”,也更能理解引入这些机制的“动机”。这个观察视角的“出发点”其实是在观察:什么是函数的“类型”?什么是函数的“值”?它们有没有被语言明确地“分离”开?

让我们看一个标准的“类型”和“值”分离后的函数定义,你就能深刻地体会到函数式编程所处的“境界”,这是一个用 Scala 实现的示例,不用担心看不懂 Scala 代码,示例其实非常简单。为什么不用 Rust 来写呢?因为 Rust 做不到,这里没有“贬低” Rust 的意思,实际上,在本文的最后,我们会看到 Rust 是如何用自己的方式实现了函数“类型”和“值”的分离,但在这里,我觉得有必要让大家知道:到底什么才是真正意义上的函数“类型”和“值”的分离:

valplusOne:Int=>Int={(num)=>num+1}

在这个极其简单的示例中,我们定义一个变量plusOne,但同时它又是一个函数:

  • val是用于定义变量的关键字,类似于 Rust 中的let

  • plusOne是变量名,当然,也是函数名;

  • Int=>Int是变量的类型,当然,也是函数的类型

  • { (num)=>num+1 }则是函数的值(也就是函数的实现)

在这个示例中,我们实际上演示了完全不同于fn func_name()这种传统形式的函数定义方式,这种像定义普通变量一样定义函数的形式可以归纳为:

函数名: 函数类型 = 函数值(函数的实现部分)

这种形式就是函数“类型”和“值”完全分离的标志,因为,如此一来,在传递函数参数时,我们就可以在形参上书写出所需函数的“类型”,在调用时,将函数的“值”作为实参传给函数了。关于函数“类型”和“值”分离的深层意义,可以参考《从函数字面量发现函数式编程》)。不过,如前文所述,在 Rust 中我们写不出这种形式的函数定义1,这里我们是想让大家明白:函数“类型”和“值”的分离应该是什么样的?这是我们的“愿景”,当一一介绍完全部机制,重新回顾 Rust 是如何实现函数参数的类型定义和传递时,我们会看到 Rust “异曲同工”的结果。

3. 函数指针与 fn 类型

让我们回归到 Rust 语言,看一下继承自 C/C++ 的传统函数指针是怎样工作的。看下面这个示例:

/// 示例 (1)fnplus_one(num:i32)->i32{num+1}fnhandle_vec(v:Vec<i32>,f:implFn(i32)->i32)->Vec<i32>{v.into_iter().map(f).collect()}fnmain(){letnums=vec![1,2,3];letplus_one_fn:fn(i32)->i32=plus_one;letresult=handle_vec(nums,plus_one_fn);println!("{:?}",result);}

示例中的plus_one_fn是一个“指针”,与 C/C++ 中的“函数指针”完全一致,打印它的值输出的就是一个内存地址。这个plus_one_fn的类型是:fn(i32) -> i32,它以函数参数列表为“骨架”抽取出类型声明,并在前面辅以fn关键字修饰,在 Rust 中,函数指针的类型就是“fn类型”。“fn类型”是 Rust 语言的内置类型(Primitive Type),并且是一种类型构造器(Type Constructor)。也就是说,它不是一种具体的类型,而是一类类型;示例中的fn(i32) -> i32就是fn类型的一种具体类型。此外,Rust 编译器会为fn类型自动实现Fn/FnMut/FnOnce三个 trait 中的一个或多个,这就是为什么plus_one_fn能匹配参数f: impl Fn(i32) -> i32并传给handle_vec的关键原因。关于Fn(i32) -> i32下文会专门讨论。

4. 函数项及其类型

上述介绍对于熟悉 C/C++ 函数指针的读者来说还是很容易理解的,尽管函数类型使用了“fn类型”这种称谓,但整体上是熟悉的语法和机制。比较独特的是出现在第 13 行等号后面的plus_one了,它是 Rust 中特有的一个概念,叫“函数项”(Function Item)。“函数项”是编译器为了表示某个具体函数而生成的一种零大小(Zero Sized)的类型。直白地说:只要把一个定义好的函数的“名字”拿出来,用于赋值或传参时,这个被拿出来的“函数名”就是“函数项”,它就代表那个函数本身。函数项和函数指针看上去非常像,实际上,函数项可以看成是"编译期”对一个函数的“引用”,而函数指针则是"运行期”的函数地址。正因为函数项是"编译期”对一个函数的引用,所以它才是零大小(Zero-Sized)的,因为函数代码存放在程序内存的 text 段里,而函数项在编译期仅仅是一个“名字”。就如同一个变量let a = 1,变量a只是程序员和编译器约定的一个“符号”,栈上不会保存一个内容是a的字符和关联的内存地址,变量名a只存在于编译阶段,在运行阶段根本没有所谓的“变量名”,程序完全靠 “内存地址偏移” 而非 “名字” 来定位栈上的变量,函数项也是如此,所以它才可以没有大小。

函数项的另一个重要问题是:函数项的“类型”是什么样的?例如示例中的plus_one,它是什么类型呢?首先,它的类型绝不是fn(i32) -> i32,我们说:函数指针和函数项是两种不同的类型,所以,函数项的类型必不可能是 fn 类型,实际上,编译器会为每一个函数项自动生成一个匿名类型,也就是说,每一个函数项都确确实实有一个自己的类型,但这个类型是匿名的,所以,我们写不出,也不需要显式地写出来,因为在使用函数项的地方,已经合适它的“另外的”类型候在那里了。有两种具体的情形,一种是当我们把一个“函数项”赋给一个“函数指针”时,Rust 编译器会帮我们把函数项类型自动转换为函数指针,在我们的示例代码中,作为函数项的plus_one之所以能赋给plus_one_fn这个函数指针是因为这里其实发生了一次自动类型转换;而另一种更常见的情形是:我们也可以把函数项作为参数传给使用 Fn 描述的类型参数。这是因为编译器也为函数项也自动实现了 Fn、FnMut、FnOne。看这个示例:

/// 示例 (2)fnplus_one(x:i32)->i32{x+1}fnhandle_vec(v:Vec<i32>,f:implFn(i32)->i32)->Vec<i32>{v.into_iter().map(f).collect()}fnmain(){letnums=vec![1,2,3];letresult=handle_vec(nums,plus_one);println!("{:?}",result);}

示例 (2) 与示例 (1) 的差别仅在于第 13 行,我们没有通过函数指针,而是直接把函数项,也就是plus_one传给了handle_vec,之所以可以这样做就是因为:函数项也自动实现了 Fn、FnMut、FnOne(准确地说是:编译器在为函数项生成它的匿名类型时,一并自动实现了 Fn、FnMut、FnOne),因此plus_one也能匹配参数f: impl Fn(i32) -> i32,可以直接传给handle_vec,这和上一节函数指针的情况类似。

如果不是在运行期需要动态切换执行函数,函数项要远比函数指针来得方便,仅仅是一个“函数名”,就可以作为参数传递了,这确实令人极为“舒适”,并且函数项还是一个编译期机制,具有零成本抽象优势。

5. 闭包及其类型

关于闭包,我们已经在《Rust 语言特性:闭包》 一文介绍过了,但是并没有介绍闭包的类型,这是闭包能作为参数传递给一个函数的关键。还是先看一个示例:

/// 示例 (3)fnhandle_vec(v:Vec<i32>,f:implFn(i32)->i32)->Vec<i32>{v.into_iter().map(f).collect()}fnmain(){letnums=vec![1,2,3];letresult=handle_vec(nums,|x|x+1);// let result = hof(nums, |x| x + 1 );println!("{:?}",result);}

在示例 (3) 中,我们不再使用函数去实现加 1 功能,而是直接改用闭包去实现:|x| x + 1,那这个闭包的类型是什么呢?是Fn(i32) -> i32吗?不是!闭包的类型也是由编译器自动生成的,匿名的,且自动实现了 Fn / FnMut / FnOnce 三个 trait 中的一个或多个,这就是为什么|x| x + 1可以匹配Fn(i32) -> i32类型的原因。如此一来,函数指针、函数项和闭包都可以匹配 Fn 类型的函数参数,三者在使用形式上就可以完全统一了。但要注意的是:函数指针、函数项、闭包三者的类型始终是不同的,是因为编译器自动为它们实现了 Fn / FnMut / FnOnce 三个 trait 中的一个或多个,才使得它们都可以作为 Fn 类型的参数传给高阶函数。

6. 函数类型大一统:Fn / FnMut / FnOnce

最终,我们来到了 Fn / FnMut / FnOnce。前面三个章节都提到过一个关键信息:

函数指针、函数项、闭包三种类型全部由编译器自动为其实现了 Fn / FnMut / FnOnce,这使得它们都可以传给由 Fn / FnMut / FnOnce 描述的函数参数,从而实现了函数的“指针”、“引用”、“值”等不同指代形式在类型上的“统一”,彼此可以无缝替换使用了。

为了更形象地说明这一点,我们把前面三个示例“打包”到一起,在不修改handle_vec函数签名的情况下,分别使用函数指针、函数项、闭包三种形式作为实参传给handle_vec,全部可以成功运行:

/// 示例 (4)fnplus_one(num:i32)->i32{num+1}fnhandle_vec(v:Vec<i32>,f:implFn(i32)->i32)->Vec<i32>{v.into_iter().map(f).collect()}fnmain(){// 1. 函数指针letplus_one_fn:fn(i32)->i32=plus_one;letresult_1=handle_vec(vec![1,2,3],plus_one_fn);println!("{:?}",result_1);// 2. 函数项letresult_2=handle_vec(vec![1,2,3],plus_one);println!("{:?}",result_2);// 3. 闭包letresult_3=handle_vec(vec![1,2,3],|x|x+1);println!("{:?}",result_3);}

最后,我们简单的介绍一下 Fn / FnMut / FnOnce 三者的区别:对于普通函数项和函数指针,它们都会实现 Fn、FnMut、FnOnce;因为普通函数没有所谓的“捕获环境”,不存在调用过程修改或消耗自用变量的问题,所以在作为参数约束时,通常都是使用 Fn。只有闭包由于会捕获环境,才需要根据它对捕获环境的使用方式区分 Fn / FnMut / FnOnce,它们对应三种情形:

  • 如果闭包对自己捕获的状态是只读的,无论执行多少次结果都一样,对于这样的闭包,编译器会自动为其实现 Fn;

  • 如果闭包需要修改自已捕获的状态,则编译器会自动为其实现 FnMut,比如闭包捕获了一个mut变量,并在闭包内修改了它的值;

  • 如果闭包需要消耗掉自已捕获的状态,则编译器会自动为其实现 FnOnce,意味者闭包只能执行一次,比如闭包捕获了一个 String 值,并在闭包内将它消耗了,则这个闭包只能执行这一次。

以上归纳起来,可以表述为如下的关系:

可调用对象 │ ┌────────────┼────────────┐ │ │ │ ▼ ▼ ▼ 函数项 函数指针 闭包 │ │ │ │ │ │ ▼ ▼ ▼ Fn Fn 根据捕获方式:是否捕获了可变引用 FnMut FnMut 或消耗了自由变量的值,来决定使用: FnOnce FnOnce Fn / FnMut / FnOnce

还有一点,以Fn(i32) -> i32这种书写形式就可以看出,Fn 绝不是一个普通的、完全编写代码就能定义出来的 trait,Rust 显然对这种 trait 做了特殊的支持!特别声明:本博客长期遭受 51CTO 无休止的盗文、剽窃,再次声讨其可耻行径!文章多处已嵌入此声明,盗用本文即传播了这份声明!

7. 重新思考:Rust 如何实现了函数“类型”和“值”的分离?

重新回顾一下前面几个章节的核心内容:

  • 函数指针是指向函数的“指针”,其“类型”是fn类型,同时编译器自动为其实现了Fn/FnMut/FnOnce

  • 函数项可以视作是编译期对函数的“引用”,它的“类型”是由编译器自动生成的,且是匿名的(没有类型名),同时编译器也自动为它实现了Fn / FnMut / FnOnce;

  • 闭包没有指针,没有函数项,其自身可以视作自身的“值”,它的“类型”也是由编译器自动生成的,且是匿名的(没有类型名),同时编译器也自动为它实现了Fn/FnMut/FnOnce三个 trait 中的一个或多个;

结合前文的示例来梳理的话,是这样的:

plus_one_fn │ └── 函数指针 │ └── 类型:fn(i32) -> i32 │ └── 其类型实现 Fn(i32) -> i32 plus_one │ └── 函数项 │ └── 由编译器生成匿名类型 │ ├── 可以转换成 fn(i32) -> i32 │ └── 其类型实现 Fn(i32) -> i32 |x| x + 1 │ └── 闭包 │ └── 由编译器生成匿名类型 │ └── 其类型实现 Fn(i32) -> i32

在 Rust 中,函数并没有在语言层面上实现“类型”和“值”的分离,函数的函数指针和函数项可以视作是对函数“值”(函数本身)的指针与引用,而闭包的“值”是闭包自身;在类型方面,函数指针、函数项和闭包都有自己的类型,但它们的类型又都由编译器自动为它们实现了Fn/FnMut/FnOnce三个 trait 中的一个或多个,使得三者在类型上得到了“统一”,Rust 是通过这样的语言机制布局实现了实际意义上的函数“类型”和“值”的分离,为传递函数型参数铺平了道路。


参考资料

  • https://www.yijiecc.com/public/spec/49
  • https://www.yijiecc.com/public/spec/47
  • https://juejin.cn/post/6991378217701474334
  • https://doc.rust-lang.org/reference/types/function-item.html
  • https://doc.rust-lang.org/std/primitive.fn.html
  • https://doc.rust-lang.org/reference/types/function-pointer.html

  1. 可能最接近的一种形式是let plus_one: &dyn Fn(i32) -> i32 = &|x| x + 1,但对于 Rust 来说这是一种很扭曲的函数书写形式,关键 Rust 并不是用我们所说的函数式的风格来处理这段代码的,这也是为什么说它是“扭曲”的原因。 ↩︎

← 返回列表