5.5 自动函数

优质
小牛编辑
129浏览
2023-12-01

5.5 自动函数

自动加载函数(:h autoload-functions)自 Vim7 版本就支持了。不过它涉及的机制 就不仅仅是函数本身了,所以放在本章之末再讨论。其实自动加载机制已经在第一章就作 为 VimL 语言的一个特点介绍过了,请回头复习一下,在那里已经将自动函数的加载流程 描叙的比较细致了。

本节继续讲解有关自动函数的定义与使用。

函数未定义事件

自动加载函数的作用是,当安装的插件比较多时,不应该在启动 Vim 时全部加载(通过 vimrc :source 调用或放在 plugin/ 目录下),而应只在需要用到时才加载。当然 ,自定义命令与映射,相当于面向用户操作的 UI,那是应该在一开始就加载好(保证有 定义)。但是复杂功能的命令与映射,往往是调用函数完成实际功能的,然后所调用的函 数又可能只是个入口函数,其中又会涉及一堆相关功能的函数。那么这些函数的定义就可 以延后加载,只在首次用到时触发加载,就能达到优化 Vim 启动速度的命令。

在 Vim7 版本之用,用户可以利用 FuncUndefined 这个自动事件来实现延后加载脚本 的目的。例如,假设在任一 &rtp 目录下的 plug/ 中有如下脚本:

" >File: ~/.vim/plugin/delaytwice.vim

if !exists('s:load_first')
    command -nargs=* MYcmd  call DT_foo(<f-args>)
    nnoremap <F12> :call DT_foo()<CR>
    execute 'autocmd FuncUndefined DT_* source ' . expand('<sfile>')
    let s:load_first = 1
    finish
endif

if exists('s:load_second')
    finish
endif

function! DT_foo() abort
    " TODO:
endfunction
function! DT_bar() abort
    " TODO:
endfunction

let s:load_second = 1

这个脚本将分两步加载。首先,由于它位于 plugin/ 子目录,故在 Vim 启动时就会读 取。在这第一次加载时,会进入 if !exists('s:load_first') 分支,该分支应该很短 ,只定义了命令与映射,并用一个 s: 变量标记已加载过一次后直接结束。关键是定义 了自动事件 FuncUndefined,此后当调用了未定义函数且该函数名匹配 DT_* ,就会 重新加载这个脚本。第二次加载时,会跳过 if !exists('s:load_first') 分支,继续 加载后续代码,完成相应函数的定义。

后面那个 if exists('s:load_second') 分支,是为了避免第三次或更多次的加载。对 于其他普通脚本,也可用这个机制防止重复加载,不过为仅为实现延时加载,这个分支是 不必要的。一般地,如果脚本中主要是用 :function! 命令定义一些函数,重复加载也 没有太大坏处,毕竟重复定义而覆盖的函数与原来的是一样。但是若脚本中需要维护某些 s: 局部变量(尤其是较复杂的字典对象)的状态,重复加载脚本就会导致这些变量的 重新初始化,可能就不是想要的,这就需要避免加载。这与延时加载是两个理念,延时加 载是有意地设计为加载第二次。

实际上,这延时加载的两部分,可以分别写在不同的两个脚本中。这对于非常大的脚本, 可能还能进一步提高 Vim 启动速度。因为按上例,写在一个脚本中,虽然 vim 不必解释 后半部分的代码,但毕竟首先还是要打开整个脚本文件的。因些,将第一部分定义的命令 、映射与自动事件放在 plugin/ 目录下,令其在 Vim 启动时就加载。第二部分的函数 定义(主体内容,长)放在另一个目录下,只要不会被 vim 在启动阶段读取就可,例如 不妨就放在 autoload/ 子目录下。拆分结果示例如下:

" >File: ~/.vim/plugin/delaytwice.vim
command -nargs=* MYcmd  call DT_foo(<f-args>)
nnoremap <F12> :call DT_foo()<CR>
execute 'autocmd FuncUndefined DT_* source ' . expand('~/.vim/autoload/delaytwice.vim')
" >File: ~/.vim/autoload/delaytwice.vim
function! DT_foo() abort
    " TODO:
endfunction
function! DT_bar() abort
    " TODO:
endfunction

不过在拆分时,有一行代码要注意作相应修改。就是在定义 FuncUndefined 事件时, 需要加载正确的(另一个)文件路径。上例是硬编码写入了对应的全路径。而在前面的单 文件的版本中,可用 <sfile> 表示本脚本文件名。当然,在后面这个拆分版本中,若 按某种规范存在相对路径中,也是可以避名硬编码的,如用以下语句代替:

expand('<sfile>:p:h') . '/../autoload/' . expand('<sfile>:p:t')

此外还须说明的是,用这种(手动)延时加载方案时,所定义的函数名最好用统一的前缀 (或后缀),方便在定义 FuncUndefined 事件时指定相应的模式匹配,尽量使该匹配 不扩大影响,也能保证所用函数能正确延时加载到。

自 Vim7 版本后,有了自动延时加载机制,就不必用户自己实现手动延时加载方案了。不 过以上的手动延时方案,有助于理解 Vim 的自动加载机制。另外单文件版本的示例可能 仍有意义,不那么复杂的脚本若不想拆分多个文件,就可按此例用 FuncUndefined 事 件实现。

自动加载函数的定义

自动加载机制,与上节讨论的拆分版的延时加载方案示例类似,不过有以下几点不同:

  • 不必再写 FuncUndefined 事件;
  • 将函数名前缀的 DT_ 改为 DT#
  • 将定义函数的那个脚本文件名也改为 autoload/DT.vim
" >File: ~/.vim/plugin/delaytwice.vim
command -nargs=* MYcmd  call DT#foo(<f-args>)
nnoremap <F12> :call DT#foo()<CR>
" >File: ~/.vim/autoload/DT.vim
function! DT#foo() abort
    " TODO:
endfunction
function! DT#bar() abort
    " TODO:
endfunction

这样就可以了,不必再关注 DT#foo() 函数有没有定义,什么时刻定义,在任何地方直 接使用就可以了。vim 能自动识别函数名中间包含 # 符号的函数,当作自动加载函数 处理,将 # 符号之前的部分视为脚本文件名,在函数未定义时,自动到 &rtpautoload/ 子目录下查找。

所以关键是要让函数名的 # 前缀与文件名保持一致,也可以不改 delaytwice.vim 的文件名,而将函数名改为 delaytwice#foo()。这种函数名的首字符允许是小写,毕 竟全局函数名首字母大写的规则,主要是为了避免与内置函数冲突。

自动加载函数名中可以有多个 # 符号分隔,对应于 autoload/ 子目录下各级路径:

{&rtp}/autoload/sub1/sub2/filename.vim
function sub1#sub2#filename#func_name()

当 vim 加载含有 # 函数定义的脚本文件时,如果发现函数名前缀与文件路径不相符, 就会报错,即无法顺利完成该函数的定义。不过事实上它只检查是否在 autoload/ 目 录下的相对路径,至于 autoload/ 之上的父目录是否在 &rtp 中并不强制检测。因 为在正式应用环境下,该脚本文件已经是从 &rtp 中搜索到的。而另一方面,在开发测 试时,你只要建立相应的目录层次,把文件扔到(某个工程) autoload/ 子目录下, 即使暂没把工程目录加到 &rtp 下,在编辑这个脚本时,也可以用 :source % 加载 当前文件进行测试,并不会因为它还不在 &rtp 中就失败。

当有了 # 函数的自动加载机制,那是否可以与 FuncUndefined 事件联用协作呢?一 般情况下没有必要。但假设一种情况,如果按目前流行的方式用插件管理插件从 github 安装插件的话,一般是将每个插件放在独立的目录中,每个插件目录都加入了 &rtp中 。这样如果你真的很狂热地安装了许多插件,你的 &rtp 路径列表将变得很长。&rtp 路径在 vim 运行是至关重要,不仅这里介绍的自动加载函数,其他许多功能都要从 &rtp 中查找。如果某个插件的主要功能只是提供了 autoload/ 脚本,或许就可以尝 试合并 &rtp,自己再写一个 FuncUndefined 事件,从其他地方加载脚本。

那么就要注意 # 函数内置的自动加载时机,与 FuncUndefined 事件的触发,先后关 系如何,避免一些可能的冲突。下面做一个试验来探讨之。

首先,在 ~/.vim/autoload/delaytwice.vim 脚本末尾加入如下一些输入语句,用以跟 踪该脚本被加载的情况:

function! delaytwice#foo() abort
    echo 'in delaytwice#foo()'
endfunction

" bar: 
function! delaytwice#bar() abort
    echo 'in delaytwice#bar()'
endfunction
echo 'autoload/delaytwice.vim loaded'

然后,再自定义一个 FuncUndefined 事件:

execute 'autocmd FuncUndefined *#*  call MyAutoFunc()'

function! MyAutoFunc() abort
    echo 'in MyAutoFunc()'
    " TODO:
endfunction

它也匹配任何中间含 # 符号的函数名,但假设它们(有些)没放在 &rtp 中,所以 需要写个入口函数从其他地方查找并加载定义文件。将该代码放在 plugin/ 下某个文 件中,便于在每次启动 vim 时自动执行,保证该事件已定义。

接下来就可以测试了,重启 vim (或打开 vim 的另一个实例会话),在命令行执行如下 命令,其输入也附于其后:

: call delaytwice#foo()
autoload/delaytwice.vim loaded
in delaytwice#foo()

这说明只触发了 vim 内置的自动加载机制,它自动加载了 delaytwice.vim 文件,然 后 delaytwice#foo() 函数就是已定义了,就可调用该函数了,不会再触发 FuncUndefined 事件。

再重启一个 vim ,在命令行调用一个在该文件中并不存在的函数,比如将 foo() 小写 误写成了大写 Foo(),其输出如下:

: call delaytwice#Foo()
autoload/delaytwice.vim loaded
in MyAutoFunc()
autoload/delaytwice.vim loaded
E117: Unknown function: delaytwice#Foo

从结果可分析出,vim 仍是先按自动加载机制,找到 delaytwice.vim 并加载,然后再 尝试调用 delaytwice#Foo(),它仍是个未定义函数。这二次调用时,才触发 FuncUndefined 事件。当然我们这里自定义的 MyAutoFunc() 并没做实际工作,并不 能解决函数未定义问题。于是 vim 再按自动加载机制,找到并加载 delaytwice.vim。 加载两次后仍未解决问题,vim 就报错了。

当然了,如果在 &rtp 中并没有找到 delaytwice.vim 或者调用 :call nofile#foo() ,它只出输出 in MyAutoFunc() 这行以及错误行。但它显然是遍历过 一次 &rtp 未找到相应文件,才触发 FuncUndefined 事件的。

现在,又假设不自定义 FuncUndefined 事件与 MyAutoFunc() 处理函数,只按 vim 的自动加载机制,如果调用了在自动加载文件中其实并未定义的函数,会是什么情况呢:

: call delaytwice#Foo()
autoload/delaytwice.vim loaded
autoload/delaytwice.vim loaded
E117: Unknown function: delaytwice#Foo

: call delaytwice#bar()
in delaytwice#bar()

: call delaytwice#Bar()
autoload/delaytwice.vim loaded
E117: Unknown function: delaytwice#Bar

可见,第一次调用 delaytwice#Foo() 时,加载了两次脚本,其内的 delaytwice#foo()delaytwice#bar() 就是已定义的,可正常使用了。然后每次 误用 delaytwice#Foo()delaytwice#Bar() 都会再触发加载一次脚本。

综上,可得到如下结论:

  • vim 会记录已加载的脚本文件,当调用自动加载函数时,若分析自动加载函数所对应的 自动加载脚本并未加载,就会先搜索并加载相应的脚本,再次调用原函数。
  • 在自动加载脚本已加载或未找到相应脚本的情况下,调用未定义的自动加载函数才会触 发 FuncUndefined 事件,会先调用自定义的事件处理函数,若无法触发,再次搜索 加载相应的脚本。

自动函数与其他函数的比较

首先,要明确一件事,自动加载函数是在全局作用域的。也就是相当于全局函数,可以在 任何地方使用。

但是,它又有某些局部函数的作用。比如,可以在两个自动加载脚本中定义“相同”的函数 ,onefile#foo()another#foo()。但实际上它们仍是两个不同的函数,因为包含 # 在内的整个字符串 onefile#fooanother#foo 才是它们的函数名。它们只是 名字上包含相同后缀的相似函数而已。仅管如此,能在不同文件(插件)中,利用相同的 词根定义相同(或相似)功能的不同实现,也是很有意义的,增加代码可读性与维护。

在为自动函数定义函数引用时,也要使用其全名。同时,自动加载函数它是函数,不是变 量。所以,在定义 onefile#foo()onefile.vim 文件中,还可以定义 s:foo 变量。但最好不要这样增加混乱,除非将其定义为同名函数的引用,如 :let s:foo = function('onefile#foo')。因为即使在同一个文件中,也必须使用包含 # 的函数全 名,它可能很长,使用不太方便,所以定义一个局部于 s: 的函数引用,是有意义的。

除了函数名可以加 # 符号实现自动加载机制,全局变量名也可以加 # 符号。但是只 有当这个变量用于右值,如 :let default = g:onefile#default 才会触发搜索加载相 应脚本(onefile.vim)。但用于左值,如 :let g:onefile#default = 5 却并不会 触发加载脚本。这个区别其实是为了让用户在触发加载 onefile.vim 之前,就能设置 g:onefile#default 的值,那可作为相关插件的用户配置变量,比之前惯用的 g:onefile_default 变量名似乎更有意义,更像 VimL 风格。

正因为 # 符号也可用于变量名,才千万注意不要将一个函数引用变量保存在 # 变量 名中(包括 lambda 表达式),虽然那是合法的,但非常不建议如此混乱。只有真的有意 设计开放给用户的重要配置才定义为 # 全局变量,并始终加上 g: 前缀。而函数名 是不能加 g: 前缀的,如此容易直观地区分。

总之,自动加载函数是 VimL 中一个极优秀的设计。如果说函数引用是注重从内部管理, 那么自动加载函数则注重从外部管理。善加利用,可极大增强 VimL 代码的健壮性与可维 护性。若说有什么缺点的话,那就是自动加载函数名可能太太太长了。并且要由用户来保 证函数名前缀与文件名路径的一致性,如果脚本文件改名了,或移动了路径层次,手动修 改函数名也是一大问题。非常期望在后续版本中能增加什么语法糖,能使得在本文件中定 义与使用自动加载函数更加简洁些。