5.4* 闭包函数

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