# 第五章 VimL 函数进阶 ## 5.4\* 闭包函数 自 Vim8,进一步扩展与完善了函数引用的概念,并增加了对闭包与 lambda 表达式的支 持。请用 `:version` 命令确认编译版本有 `+lambda` 特性支持。 ### 闭包函数定义 学习 Vim 新功能,在线帮助文档是最佳资料。查阅 Vim8 的 `:help :function`,可发 现在定义函数时,除了原有的几个属性 `range` `abort` `dict` 外,还多了一个 `closure` 属性。这就是定义闭包函数的关键字。并给出了一个示例,我们先将其复制到 一个脚本中并执行: ```vim " >File: ~/.vim/vimllearn/closure.vim function! Foo() let x = 0 function! Bar() closure let x += 1 return x endfunction return funcref('Bar') endfunction ``` 这里有几点需要说明: * 函数可以嵌套了,在一个函数体内可以再定义另一个函数。 * 内层函数 `Bar()` 指定了 `closure` 属性,就是将其定义为闭包函数。 * 在内层闭包函数 `Bar()` 中,可以使用外层环境函数 `Foo()` 的局部变量 `x`。 * 外层函数返回的是内层函数的引用。 * 当 `Foo()` 函数返回后,在 `Bar()` 内仍然可正常使用局部变量 `x`。 现在来使用这个闭包,可在命令行中直接输入以下语句试运行: ```vim let Fn = Foo() echo Fn() echo Fn() echo Fn() ``` 可见,在每次调用 `Fn()`,也就是调用 `Bar()` 时,它会返回递增的自然数,在两次调 用之间,会记住变量 `x` 的值。对比普通函数,当其返回后,其部分变量就离开作用域 不再可见,每次调用必须重新创建与初始化局部变量。而 `Bar()` 函数能记住 `x` 变量 的状态,就是由于 `closure` 关键字的作用。 除些之外,`Bar()` 就与普通函数一样了。特别地,它的函数全名就是 `'Bar'`,即它也 是个全局函数,也可以直接在命令行调用。如下语句依然正常地输出递增自然数: ```vim echo Bar() echo Bar() echo Fn() ``` 另外必须指出的是,在 `Foo()` 函数内创建 `Bar()` 引用时,用的是 `funcref()` 函 数,而不是 `function()` 函数。`funcref()` 也是 Vim8 才引入的内置函数,它与之前 的 `function()` 函数功能一样,也就是创建一个函数引用。只有一个差别, `function()` 只简单地按函数名寻找它所“引用”的函数,而 `funcref()` 是按真正的函 数引用寻找目标函数。这其中的差别只在原函数被重定义了才能体现。 例如,我们再用 `function()` 创建一个类似的闭包函数引用,为示区别每次递增 2。将 以下代码附加在原脚本之后,再次加载运行。 ```vim " >>File: ~/.vim/vimllearn/closure.vim function! Goo() let x = 0 function! Bar() closure let x += 2 return x endfunction return function('Bar') endfunction let Gn = Goo() echo Gn() echo Gn() echo Bar() echo Gn() ``` 初步看来,`Goo()` 函数能与 `Foo()` 完全一样地使用,获取一个闭包引用,依次调用 ,并且可与所引函数 `Bar()` 交替调用,也能保持正确的状态。 但要注意,在 `Goo()` 函数内定义的闭包函数也是 `Bar()` 。所以在每次调用 `Goo()` 或 `Foo()` 都会重新定义全局函数 `Bar()`。如果用 `function()` 获取 `Bar()` 的引 用,它就是使用最新的函数定义。如果用 `funcref()` 获取 `Bar()` 的引用,它就一直 使用当时的函数定义。 例如,我们直接在外面再次重定义一下 `Bar()` 函数: ```vim function! Bar() return 'Bar() redefined' endfunction echo Bar() echo Fn() echo Gn() ``` 运行结果表明,`Fn()` 能继续递增数值,但 `Gn()` 却调用了重新定义的函数,失去了 递增的原意。 所以,为了保证闭包函数的稳定性,务必使用新函数 `funcref()` ,而不要用旧函数 `function()`。当然,`function()` 函数除了为保证兼容性外,应该也还有其适合场景 。 另外,非常不建议直接调用闭包函数,应该坚持只通过函数引用变量来调用闭包。但是, 目前的 VimL 语法,似乎没法完全阻止直接调用闭包。因为 `:function` 定义的是函数 ,而非变量,不能为函数名添加 `l:` 前缀来限制其作用域。可以加 `s:` 定义为脚本范 围的函数,但它仍然可以从外部调用(相对于创建闭包的 `Foo()` 环境而言)。一个建 议是为闭合函数名添加一些特殊后缀,给直接书写调用增加一些麻烦。 ### 闭包变量理解 闭包函数的关键是闭包变量,也就是闭包函数内所用到的外部局部变量。 其实,在一个函数内使用外部变量是很平凡的。比如: ```vim let s:x = 0 function! s:Bar() " closure let s:x += 1 return s:x endfunction ``` 这里只用以前的函数知识定义了一个 `s:Bar()` 脚本函数,它用到脚本局部变量 `s:x` 。每次调用 `s:Bar()` 时,也能递增这个变量。似乎也能达到之前闭包函数的作用,然 而这只是幻觉。因为 `s:x` 不是专属于 `s:Bar()` 函数的,即使也限制了脚本作用域, 也能被脚本中其他函数或语句修改。 而之前闭包函数 `Bar()` 的变量 `x` ,原是 `Foo()` 函数内创建的局部变量。当 `Foo()` 函数返回后,这个局部变量理论上要释放的,也就无从其他地方再次访问,只能 通过 `Bar()` 这个即时定义的闭包函数才能访问。 所以,闭包变量既是外部变量,更重要的是外部的局部变量。这才能保证闭包函数对于闭 包变量的专属访问。也因为这个原由,在顶层(脚本或命令)定义的函数不能指定闭包属 性。如上定义 `s:Bar()` 函数时若加上 `closure` 将会直接失败。而一般只能嵌套在另 一个函数中定义闭包函数,这个外层函数有的也叫工厂函数。工厂函数为闭包提供一个临 时的局部环境,闭包变量先是在工厂函数中创建并初始化,而在闭包函数里面则是自动检 测的,凡用到的外部局部变量都会转为闭包函数。当然了,在工厂函数或闭包函数内都可 以有其他各自的普通局部变量。 在工厂函数内创建闭包函数时,闭包变量就成为了闭包函数的一个内部属性。每次调用工 厂函数时,会创建闭包函数的不同副本,也就会有相应闭包变量的不同副本。也就是说, 每次创建的闭包函数会维护各自的状态,互不影响。 为说明这个问明,再举个例子。比如把上面实现的递增 1 与递增 2 的两个闭包放在一个 工厂函数内创建,借用列表同时返回两个闭包: ```vim function! FGoo(base) let x = a:base function! Bar1_cf() closure let x += 1 return x endfunction function! Bar2_cf() closure let x += 2 return x endfunction return [funcref('Bar1_cf'), funcref('Bar2_cf')] endfunction echo 'FGoo(base)' let [Fn, X_] = FGoo(10) echo Fn() echo Fn() echo Fn() let [X_, Gn] = FGoo(20) echo Gn() echo Gn() echo Gn() echo Fn() echo Fn() ``` 另一个改动是给工厂函数传个参数,让其成为闭包递增的初值。在调用工厂函数时,也利 用列表解包的语法,同时获得返回的两个闭包函数(引用)。第一次 `let [Fn, X_] = FGoo(10)` 用 `10` 作为初值,且只关心第一个闭包 `Fn` ,第二个 `X_` 只作为占位变 量弃而不用。在执行 `Fn()` 数据后,第二次调用 `let [X_, Gn] = FGoo(20)` 传入另 一个初值,且只取第二个闭包 `Gn`。然后可以发现这两个闭包能并行不悖地执行。这说 明闭包变量 `x` 虽然是在 `FGoo` 中创建,却不随之保存,而是保存在各个被创建的闭 包函数中。 ### 偏包引用 自 Vim8 ,不仅为创建函数引用增加了一个全新的内置函数,而且还为 `function()` 与 `funcref()` 升级了功能。除了提供函数名外,还可以提供一个可选的列表参数,作为所 引用函数的部分的参数。如此创建的函数引用叫做 `partial` ,这里将之称为偏包。 请看以下示例: ```vim function! Full(x, y, z) echo 'Full called:' a:x a:y a:z endfunction let Part = function('Full', [3, 4]) call Part(5) ``` 首先定义了一个“全”函数 `Full()` ,它接收三个参数,不妨把它认为是三维空间上的坐 标点。假设有种需求,平面坐标已经是固定的了,只是还要经常改变高坐标。这时就可用 `function()` (或 `funcref()`)创建一个偏包,将代表固定平面坐标的前两个参数放 在一个列表变量中,传给 `function()` 的两个参数。然后调用偏包时,就不必再提供那已 固定的参数,只要传入剩余参数即可。如上调用 `Part(5)` 就相当于调用 `Full(3, 4, 5)` 。 `function()` 的第一参数,不仅可以是函数名,也可以是其他函数引用。于是偏包的定 义可以链式传递(有的叫嵌套)。例如: ```vim let Part1 = function('Full', [3]) let Part2 = function(Part1, [4]) call Part2(5) |" => call Full(3, 4, 5) ``` 须要注意的是,在创建偏包时,即使只要固定一个参数,也必须写在 `[]` 中,作为只有 一个元素的列表传入。 为什么这叫偏包,因为偏包本质上是个自动创建的闭包。例如以上为 `Full()` 创建的偏 包,相当于如下闭包: ```vim function! FullPartial() let x = 3 let y = 4 function! Part_cf(z) closure let z = a:z return Full(x, y, z) endfunction return funcref('Part_cf') endfunction let Part = FullPartial() call Part(5) ``` 至于用 `function()` 创建通用偏包的功能,可用如下闭包模拟: ```vim function! FuncPartial(fun, arg) let l:arg_closure = a:arg function! Part_cf(...) closure let l:arg_passing = a:000 let l:arg_all = l:arg_closure + l:arg_passing return call(a:fun, l:arg_all) endfunction return funcref('Part_cf') endfunction let Part = FuncPartial('Full', [3, 4]) call Part(5) ``` 以上的语句 `let l:arg_all = l:arg_closure + a:000` 表明了在调用偏包时,传入的 参数是串接在原来保存在闭包中的参数表列之后的。其实,那三条 `let` 语句创建的中 间变量是可以取消的,只须用 `return call(a:fun, a:arg + a:000)` 即可。其中 `a:fun` 与 `a:arg` 变量来源于外部工厂函数 `FuncPartial()` 的参数,将成为闭包变 量,而 `a:000` 则是在调用闭包函数时传入的参数。 这个 `FuncPartial()` 只为说明偏包与闭包之间的关系,请勿实际使用。另请注意这两 概念的差别,闭包是函数,偏包是引用,偏包是对某个自动创建的闭包的引用。 创建函数引用尤其是偏包引用的 `function()` 与 `funcref()` 函数,不仅可以接收额 外的列表参数,还可接收额外的字典参数。这与 `call()` 函数的参数意义是一样的。当 需要创建引用的函数有 `dict` 属性时,传给 `function()` 的字典参数就将传给目标函 数的 `self` ,实际上也将该字典升格为闭包变量。之后再调用所创建的偏包引用时,就 不必再指定用哪个字典当作 `self` 了。 不过 `function()` 与 `call()` 的参数用法也有两个不同: * `call()` 至少要两个参数,即使目标函数不用参数,也要传 `[]`。`function()` 默 认只要一个参数即可。 * `function()` 可以直接传字典变量当作第二参数,不必限定第二参数必须用列表,不 必用 `[]` 空列表作占位参数。当然也可以同时传入列表与字典参数,此时应按习惯不 要改变参数位置。 ### lambda 表达式 lambda 表达式用于创建简短的匿名函数,其语法结构如:`let Fnr = {args -> expr}` 。几个要点: * 整个 lambda 表达式放在一对大括号 `{}` 中,其间用箭头 `->` 分成两部分。 * 箭头之前的部分是参数,类似函数参数列表,多个参数由逗号分隔,也可以无参数。无 参数时箭头也不可以缺省,如 `{-> expr}` 形式。 * 箭头之后是一个表达式。该表达式的值就是以后调用该 lambda 时的结果。这有点像函 数体,但函数体是由多个 ex 命令语句构成。lambda 的“函数体” 只能是一个表达式。 * `expr` 部分在使用 `args` 的参数时,不要加 `a:` 参数作用域前缀。 * 在 `expr` 部分中还可以使用整个 lambda 表达所处作用域内的其他变量,如此则相当 于创建了一个闭包。 * 一般需要将 lambda 表达式赋值给一个函数引用变量,如此才能通过该引用调用 lambda 。也就是说 lambda 表达式自身的值类型是 `v:t_func`。 举个例子,假设有如下定义的函数: ```vim function! Distance(point) abort let x = a:point[0] let y = a:point[1] return x*x + y*y endfunction ``` 这里假设用只含两个元素的列表来表示坐标上的点,该函数的功能是计算坐标点的平方和 ,这可作为距离原点的度量。几何上的距离定义其实是平方和再开根号,不过开根号的浮 点运算效率低,尤其是相对整数坐标来说。所以在满足程序逻辑的情况下,可以先不开这 个根号,比如只在最后需要显示在 UI 上才开这个根号。 然而无关背景,这个函数或许很重要,但实现很简单,实际上也可用 lambda 来代替: ```vim let Distance = {pt -> pt[0] * pt[0] + pt[1] * pt[1]} ``` 当然了,这两段代码不能同时存在,因为函数引用的变量名,不能与函数名重名。分别执 行这两段,测试 `:echo Distance([3,4])` 能输出 `25` 。 前面说过,闭包函数不能在脚本(或命令行)顶层定义,但 lambda 表达式可以。因为 lambda 表达式其实是相当于创建闭包的外层工厂函数(及其调用),那当然是可以写在 顶层了。不过就这个 `Distance` 实例,并未用到外部变量,可不必纠结是否闭包。 然后,我们利用这个函数写一个具体功能,比如计算一个三角形的最大边长。输入参数是 三个点坐标,输出最大边长(的平方): ```vim function! MaxDistance(A, B, C) abort let [A, B, C] = [a:A, a:B, a:C] let e1 = [A[0] - B[0], A[1] - B[1]] let e2 = [A[0] - C[0], A[1] - C[1]] let e3 = [B[0] - C[0], B[1] - C[1]] let d1 = Distance(e1) let d2 = Distance(e2) let d3 = Distance(e3) if d1 >= d2 && d1 >= d3 return d1 elseif d2 >= d1 && d2 >= d3 return d2 else return d3 endif endfunction ``` 这里,直接用单字母表示参数了,似乎有违程序变量名的取名规则。不过这也要看具体场 景,因为这是解决数学问题的,直接用数学上习惯的符号取名,其实也是简洁又不失可读 性的。该函数先从顶点坐标计算边向量,再对边向量调用 `Distance()` 计算距离,返回 其中的最大值。 如果 `Distance` 是上面定义的函数版本,这个 `MaxDistance()` 直接可用。比如在命 令行中试行:`:echo MaxDistance([2,8], [4,4], [5,10])` 将输出 `37` 。 但如果是用 lambda 表达式版本,将 `let Distance = ...` 写在全局作用域中,那么在 调用 `MaxDistance()` 时再调用 `Distance()` 就会失败,指出函数未定义的错误。把 这个 lambda 表达式写在 `MaxDistance()` 开头,剩余代码才能正常工作。 不过这个困惑与 lambda 无关,只是作用域规则。解析 `let d1=Distance(e1)` 时,如 果 `Distance` 不是一个函数名,就会尝试函数引用。然而在函数内的变量,缺省前缀是 `l:` ,所以它找不到在外部定义的 `g:Distance`。基于这个原因,个人非常建议在函数 内部也习惯为局部变量加上 `l:` 前缀,这样就能使函数引用变量名与函数名从文本上很 好地区分,避免迷惑性出错。 同时,这也说明了 lambda 的习惯用法,一般是在需要用的时候临时定义,而不是像常规 函数那样预先定义。 最后提一下,lambda 作为匿名函数,vim 对其表示法是 `123` ,与上一章介绍 的字典匿名函数一样,只是在编号前再加 `` 前缀,同时这两套编号相互独立。 ### 小结 偏包与 lambda 表达式,本质上都是闭包,而闭包也一般只以其函数引用的形式使用。 Vim8 引入这些编程概念的一个原因,是为了方便在局部环境中创建回调函数,与异步、 定时器等特性良好协作。