You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

372 lines
16 KiB

2 years ago
# 第五章 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 对其表示法是 `<lambda>123` ,与上一章介绍
的字典匿名函数一样,只是在编号前再加 `<lambda>` 前缀,同时这两套编号相互独立。
### 小结
偏包与 lambda 表达式,本质上都是闭包,而闭包也一般只以其函数引用的形式使用。
Vim8 引入这些编程概念的一个原因,是为了方便在局部环境中创建回调函数,与异步、
定时器等特性良好协作。