第五章 VimL 函数进阶
5.4* 闭包函数
自 Vim8,进一步扩展与完善了函数引用的概念,并增加了对闭包与 lambda 表达式的支 持。请用 :version
命令确认编译版本有 +lambda
特性支持。
闭包函数定义
学习 Vim 新功能,在线帮助文档是最佳资料。查阅 Vim8 的 :help :function
,可发 现在定义函数时,除了原有的几个属性 range
abort
dict
外,还多了一个 closure
属性。这就是定义闭包函数的关键字。并给出了一个示例,我们先将其复制到 一个脚本中并执行:
" >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
。
现在来使用这个闭包,可在命令行中直接输入以下语句试运行:
let Fn = Foo()
echo Fn()
echo Fn()
echo Fn()
可见,在每次调用 Fn()
,也就是调用 Bar()
时,它会返回递增的自然数,在两次调 用之间,会记住变量 x
的值。对比普通函数,当其返回后,其部分变量就离开作用域 不再可见,每次调用必须重新创建与初始化局部变量。而 Bar()
函数能记住 x
变量 的状态,就是由于 closure
关键字的作用。
除些之外,Bar()
就与普通函数一样了。特别地,它的函数全名就是 'Bar'
,即它也 是个全局函数,也可以直接在命令行调用。如下语句依然正常地输出递增自然数:
echo Bar()
echo Bar()
echo Fn()
另外必须指出的是,在 Foo()
函数内创建 Bar()
引用时,用的是 funcref()
函 数,而不是 function()
函数。funcref()
也是 Vim8 才引入的内置函数,它与之前 的 function()
函数功能一样,也就是创建一个函数引用。只有一个差别, function()
只简单地按函数名寻找它所“引用”的函数,而 funcref()
是按真正的函 数引用寻找目标函数。这其中的差别只在原函数被重定义了才能体现。
例如,我们再用 function()
创建一个类似的闭包函数引用,为示区别每次递增 2。将 以下代码附加在原脚本之后,再次加载运行。
" >>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()
函数:
function! Bar()
return 'Bar() redefined'
endfunction
echo Bar()
echo Fn()
echo Gn()
运行结果表明,Fn()
能继续递增数值,但 Gn()
却调用了重新定义的函数,失去了 递增的原意。
所以,为了保证闭包函数的稳定性,务必使用新函数 funcref()
,而不要用旧函数 function()
。当然,function()
函数除了为保证兼容性外,应该也还有其适合场景 。
另外,非常不建议直接调用闭包函数,应该坚持只通过函数引用变量来调用闭包。但是, 目前的 VimL 语法,似乎没法完全阻止直接调用闭包。因为 :function
定义的是函数 ,而非变量,不能为函数名添加 l:
前缀来限制其作用域。可以加 s:
定义为脚本范 围的函数,但它仍然可以从外部调用(相对于创建闭包的 Foo()
环境而言)。一个建 议是为闭合函数名添加一些特殊后缀,给直接书写调用增加一些麻烦。
闭包变量理解
闭包函数的关键是闭包变量,也就是闭包函数内所用到的外部局部变量。
其实,在一个函数内使用外部变量是很平凡的。比如:
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 的两个闭包放在一个 工厂函数内创建,借用列表同时返回两个闭包:
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
,这里将之称为偏包。
请看以下示例:
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()
的第一参数,不仅可以是函数名,也可以是其他函数引用。于是偏包的定 义可以链式传递(有的叫嵌套)。例如:
let Part1 = function('Full', [3])
let Part2 = function(Part1, [4])
call Part2(5) |" => call Full(3, 4, 5)
须要注意的是,在创建偏包时,即使只要固定一个参数,也必须写在 []
中,作为只有 一个元素的列表传入。
为什么这叫偏包,因为偏包本质上是个自动创建的闭包。例如以上为 Full()
创建的偏 包,相当于如下闭包:
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()
创建通用偏包的功能,可用如下闭包模拟:
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
。
举个例子,假设有如下定义的函数:
function! Distance(point) abort
let x = a:point[0]
let y = a:point[1]
return x*x + y*y
endfunction
这里假设用只含两个元素的列表来表示坐标上的点,该函数的功能是计算坐标点的平方和 ,这可作为距离原点的度量。几何上的距离定义其实是平方和再开根号,不过开根号的浮 点运算效率低,尤其是相对整数坐标来说。所以在满足程序逻辑的情况下,可以先不开这 个根号,比如只在最后需要显示在 UI 上才开这个根号。
然而无关背景,这个函数或许很重要,但实现很简单,实际上也可用 lambda 交待代替:
let Distance = {pt -> pt[0] * pt[0] + pt[1] * pt[1]}
当然了,这两段代码不能同时存在,因为函数引用的变量名,不能与函数名重名。分别执 行这两段,测试 :echo Distance([3,4])
能输出 25
。
前面说过,闭包函数不能在脚本(或命令行)顶层定义,但 lambda 表达式可以。因为 lambda 表达式其实是相当于创建闭包的外层工厂函数(及其调用),那当然是可以写在 顶层了。不过就这个 Distance
实例,并未用到外部变量,可不必纠结是否闭包。
然后,我们利用这个函数写一个具体功能,比如计算一个三角形的最大边长。输入参数是 三个点坐标,输出最大边长(的平方):
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 引入这些编程概念的一个原因,是为了方便在局部环境中创建回调函数,与异步、 定时器等特性良好协作。