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
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 引入这些编程概念的一个原因,是为了方便在局部环境中创建回调函数,与异步、
|
||
|
定时器等特性良好协作。
|