Function Item
第一次接触到function item这个新概念可能会令人感到困惑,因为在其它语言中几乎都找不到类似的设计,但它却是 Rust 零成本抽象(zero-overhead abstractions)的一个重要体现
1 |
|
上述代码中的 add
& sub
就是function item,它们的size都为0,类型分别为 func::add
& func::sub
(func
是crate name)
如果按照其它语言中对「函数」这一概念的惯性思维,我们可能会编写出如下的代码:
1 | let mut f = add; |
但它并不能通过编译。这是由于function item直接表示函数本身,虽然它们具有相同的函数签名,但实际是两个不同的类型。由此我们也能窥见Rust中对于「函数」的谁急似乎和其他语言存在着差异
Function Pointer
function pointer和 function item的区别可以概括为:function pointer通过存储的地址引用函数;function item通过类型信息定位函数
可以将具有相同函数签名的function item强转(coerce)为function pointer,来支持改变f
的指向:
1 | let mut f: fn(i32, i32) -> i32 = add; |
下面通过一个完整例子探索一下function item & function pointer
1 |
|
使用rust-lldb
,在第19行处打一个断点,然后运行至断点处打印变量观察:
1 | (lldb) v |
item
和 item1
是function item type,虽然lldb这里输出了地址,但是如果尝试访问会发现它是无效地址,可能是lldb
无法识别function item(可以看到这里标注的类型还是(int (*)(int, int))
这种函数指针类型),也可能是调试信息的中间产物,故忽略
主要注意ptr
和ptr1
,它们分别指向0x00000001000017ac
和 0x00000001000017f0
,对这两个地址进行反汇编:
1 | (lldb) disassemble -s 0x00000001000017ac |
可以看到它们指向的正是add
& sub
函数的入口地址,因此可以得出结论:function pointer 本质就是一个指针,内部指向一个函数的地址
当程序执行完 ptr = sub;
语句后,再次打印变量:
1 | (int (*)(int, int)) ptr = 0x00000001000017f0 (main`main::sub::hdca0f0b5bfd66359 at main.rs:7) |
此时ptr
& ptr1
都指向sub
的入口地址0x00000001000017f0
为了更进一步理解,可以对main
函数进行反汇编:
1 | (lldb) disassemble -n main |
由于我使用的是Apple芯片的mac,这里输出的是arm汇编,和amd汇编还是有较大区别,但这不妨碍我们挑重点进行阅读:
1 | 0x100001844 <+16>: add x8, x8, #0x7ac ; main::add::h243b87047811e43e at main.rs:2 |
把0x7ac
(add
的地址)存储在栈空间偏移 0x10
处;把0x7f0
(sub
的地址)存储在栈空间sp
处
1 | 0x100001868 <+52>: bl 0x1000017ac ; main::add::h243b87047811e43e at main.rs:2 |
这两条语句对应的是item(2, 2)
& item1(2, 2)
的调用,可以看到这里是直接拿到了对应的函数地址进行执行
1 | 0x100001884 <+80>: ldr x8, [sp, #0x10] |
从栈的偏移量0x10
处加载函数地址到x8
,所以x8
当前保存的是add
的地址,之后通过blr
指令跳转到x8
存储的地址并执行,对应ptr(1,2)
的调用
1 | 0x100001890 <+92>: ldr x8, [sp] |
从栈顶加载sub
函数地址到x8,str x8, [sp, #0x10]
ldr x8, [sp, #0x10]
这两句看着有些费解,先将x8的地址保存在偏移量0x10
处,又从偏移量0x10
处再次加载函数地址到x8
,猜测和未开启编译优化有关。最后还是通过blr指令跳转执行,对应ptr
重新赋值为sub
后,ptr(2,2)
的调用
和直接跳转地址相比,可以发现blr x8
这种调用方式多了一层寻址操作
Closure
闭包是将函数,或者说代码和其环境一起存储的一种数据结构。闭包引用的上下文中的自由变量,会被捕获到闭包的结构中,成为闭包类型的一部分。在 Rust 中,闭包可以用 |args| {code}
来表示。写段代码探索一下Rust的闭包:
1 | use std::{collections::HashMap, mem::size_of_val}; |
可以发现:closure的大小跟参数、局部变量都无关,只跟捕获的变量有关。
通过rust-lldb
设置断点,观察各个变量的结构:
1 | (lldb) v |
主要观察几个长度不为 0 闭包内存里存放的数据是什么。从输出可以看出c3
中的 _ref__name
字段是一个引用,指向地址为 0x000000016fdfea68
的数据。继续看看这个地址里存的数据是什么:
1 | (lldb) x/3xg 0x000000016fdfea68 |
Rust 的String
在内存中表示为ptr | cap | len
的结构,因此在内存中占用3个words。由于编译器内存重排,导致输出顺序和逻辑位置有些差异。其中,ptr
指向了具体的数据:
1 | (lldb) x/c 0x00006000015cc000 |
closure 类型是由编译器生成的匿名数据类型,故无法在代码标注具体类型。对于closure来说,编译器知道像函数一样调用闭包 c4()
是合法的,并且知道执行 c4()
时,代码应该跳转到什么地址来执行。在执行过程中,如果遇到自由变量,可以从自己的数据结构中获取。从rust-lldb
的输出可得,closure是存储在栈上,并且除了捕获的数据外,closure本身不包含任何额外函数指针指向闭包的代码,至于在调用时跳转到哪个地址,是由编译器在编译期静态生成。
1 | let mut closure = |a: i32, b: i32| a + b; |
和function item类似,具有相同签名的closure并不是同一个类型。所以上述代码并不能够编译
通过为每个 closure 生成一个匿名类型,使得调用闭包时可以直接和代码一一对应,省去了使用函数指针再次寻址的额外消耗。这种方式比起golang的[function value](golang中的function value | Caelansar)二级指针的设计不知道高到哪里去了🤷
价值
说了这么多,我们已经理解了Rust对于「函数」的设计,至于为什么这么设计,就让我们结合一个实际例子来看看这些设计所带来的实际价值
1 |
|
calculate
函数的作用是使用传入的 f
对range (0..5)
中的每个值进行运算,再将结果累加返回。只要实现了 FnMut(i32) -> i32
的类型,都可以作为参数传入calculate
,将函数作为参数的写法在函数作为一等公民的语言中很常见,但Rust在这背后实际上通过单态化(monomorphization)实现了对f
的静态派发(static dispatch)
通过 cargo rustc --release -- --emit asm
输出汇编代码
1 | __TEXT,__text,regular,pure_instructions |
可以发现calculate
函数直接在编译期完成了map
& sum
运算,直接得到了结果20
对代码稍作修改,新增f1 & f2 函数,其中f1 和 f 完全一致,f2 和 f 逻辑一致
1 |
|
再次观察输出的汇编:
1 | __TEXT,__text,regular,pure_instructions |
可以发现,Rust并未对f1
& f2
生成相应的汇编代码,只是将它们的值直接赋值为f
。得益于单态化,Rust在编译期就能明确f
究竟是什么,无论是function item或是closure背后的匿名结构体,都能通过静态派发为calculate的参数f
提供足够的类型信息,也就避免了函数指针间接调用带来的开销,并进行更激进的优化
你可能会好奇这个优化能带来多少性能提升。空口无凭,这时候就得进行benchmark对比看看
在Rust中,一般使用criterion 进行benchmark
1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; |
注意这段代码:
calculate
和calculate_pointer
函数中map的执行次数不同,calculate
中map的次数要多3个数量级,对于function item,编译器在编译期就能得到函数的所有信息,所以这里的数量级差异对性能影响为零- 定义了function pointer
mut fp: fn(u128) -> u128
, 并在之后改变了它的指向,如果不进行修改操作,在fp指向未改变的情况下,编译器也会对它进行优化,完成和function item相同的编译期求值
benchmark的结果为:
1 | Running benches/func.rs (target/release/deps/func-1b1df2d0b22f971a) |
可以看出相较于function pointer,function item 在性能上有数量级的领先,这还是在循环次数多了3个数量级的前提
总结
通过对function item,function pointer,closure的讨论,我们可以得出:
Rust通过
使用所有权和借用机制,解决了内存归属问题有关。不用费劲分析捕获变量究竟是放在stack还是heap
提供
Fn/FnMut/FnOnce
trait,对函数调用这一行为定义了统一的抽象,function item,function pointer和closure能够像函数一样调用,仅仅因为他们实现了这些trait,而不是编译器借助函数地址施展的黑魔法使用单态化,在编译期将泛型代码具体化为特定的类型实例,从而进行优化,在享受泛型灵活性的同时避免了运行时开销
当回归到最初的本质,一个好的设计所解决的不是单个问题,而是由此引发的所有问题。我们不必为堆内存管理设计GC,不必为资源的回收提供 defer
关键字,不必为并发安全进行诸多限制,也不必为闭包性能费尽心思优化
参考
Function item types
Function pointer types
fn - Rust
rust quiz
“Expected fn item, found a different fn item” when working with function pointers
javascript - What are free variables? - Stack Overflow