
文章目录1. 知识图谱2. 什么是函数“类型”和“值”的分离3. 函数指针与 fn 类型4. 函数项及其类型5. 闭包及其类型6. 函数类型大一统Fn / FnMut / FnOnce7. 重新思考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:IntInt{(num)num1}在这个极其简单的示例中我们定义一个变量plusOne但同时它又是一个函数val是用于定义变量的关键字类似于 Rust 中的letplusOne是变量名当然也是函数名IntInt是变量的类型当然也是函数的类型{ (num)num1 }则是函数的值也就是函数的实现在这个示例中我们实际上演示了完全不同于fn func_name()这种传统形式的函数定义方式这种像定义普通变量一样定义函数的形式可以归纳为函数名: 函数类型 函数值函数的实现部分这种形式就是函数“类型”和“值”完全分离的标志因为如此一来在传递函数参数时我们就可以在形参上书写出所需函数的“类型”在调用时将函数的“值”作为实参传给函数了。关于函数“类型”和“值”分离的深层意义可以参考《从函数字面量发现函数式编程》。不过如前文所述在 Rust 中我们写不出这种形式的函数定义1这里我们是想让大家明白函数“类型”和“值”的分离应该是什么样的这是我们的“愿景”当一一介绍完全部机制重新回顾 Rust 是如何实现函数参数的类型定义和传递时我们会看到 Rust “异曲同工”的结果。3. 函数指针与 fn 类型让我们回归到 Rust 语言看一下继承自 C/C 的传统函数指针是怎样工作的。看下面这个示例/// 示例 (1)fnplus_one(num:i32)-i32{num1}fnhandle_vec(v:Veci32,f:implFn(i32)-i32)-Veci32{v.into_iter().map(f).collect()}fnmain(){letnumsvec![1,2,3];letplus_one_fn:fn(i32)-i32plus_one;letresulthandle_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{x1}fnhandle_vec(v:Veci32,f:implFn(i32)-i32)-Veci32{v.into_iter().map(f).collect()}fnmain(){letnumsvec![1,2,3];letresulthandle_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:Veci32,f:implFn(i32)-i32)-Veci32{v.into_iter().map(f).collect()}fnmain(){letnumsvec![1,2,3];letresulthandle_vec(nums,|x|x1);// 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{num1}fnhandle_vec(v:Veci32,f:implFn(i32)-i32)-Veci32{v.into_iter().map(f).collect()}fnmain(){// 1. 函数指针letplus_one_fn:fn(i32)-i32plus_one;letresult_1handle_vec(vec![1,2,3],plus_one_fn);println!({:?},result_1);// 2. 函数项letresult_2handle_vec(vec![1,2,3],plus_one);println!({:?},result_2);// 3. 闭包letresult_3handle_vec(vec![1,2,3],|x|x1);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 绝不是一个普通的、完全编写代码就能定义出来的 traitRust 显然对这种 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/49https://www.yijiecc.com/public/spec/47https://juejin.cn/post/6991378217701474334https://doc.rust-lang.org/reference/types/function-item.htmlhttps://doc.rust-lang.org/std/primitive.fn.htmlhttps://doc.rust-lang.org/reference/types/function-pointer.html可能最接近的一种形式是let plus_one: dyn Fn(i32) - i32 |x| x 1但对于 Rust 来说这是一种很扭曲的函数书写形式关键 Rust 并不是用我们所说的函数式的风格来处理这段代码的这也是为什么说它是“扭曲”的原因。 ↩︎