第二章 VimL 语言基本语法
2.4 函数定义与使用
函数是可重复调用的一段程序单元。在用程序解决一个比较大的功能时,知道如何拆分多 个小功能,尤其是多次用到的辅助小功能,并将它们独立为一个个函数,是编程的基本素 养吧。
VimL 函数语法
在 VimL 中定义函数的语法结构如下:(另参考 :help :function
)
function[!] 函数名(参数列表) 附加属性
函数体
endfunction
在其他地方调用函数时一般用 :call
命令,这能触发目标函数的函数体开始执行,以 产生它所设计的功效。如果要接收函数的返回值,则不宜用 :call
命令,可用 :echo
观察函数的返回结果,或者用 :let
定义一个变量保存函数的返回结果。实际上,函数 调用是一个表达式,任何需要表达式的地方,都可植入函数调用。例如:
call 函数名(参数)
echo 函数名(参数)
let 返回值 = 函数名(参数)
注:这里为了阐述方便,除了关键命令,直接用中文名字描述了。因而不是有效代码,在 每行的前面也就不加 :
了。
函数名
函数名的命令规则,除了要遵循普通变量的命令规则外,还有条特殊规定。如果函数是在 全局作用域,则只能以大写字母开头。
因为 vim 内建的命令与函数都以小写字母开始,而且随着版本提升,增加新命令与函数 也是司空见惯的事。所以为了方便避免用户自定义命令与函数的冲突,它规定了用户定义 命令与函数时必须以大写字母开头。从可操作 Vim 的角度,函数与命令在很大程度上是 有些相似功能的。当然,如果将 VimL 视为一种纯粹的脚本语言,那函数也可以做些与 Vim 无关的事情。
习惯上,脚本中全局变量时会加 g:
前缀,但全局函数一般不加 g:
前缀。全局函数 是期望用户可以直接从命令行用 :call
命令调用的,因而省略 g:
前缀是有意义的 。当然更常见的是将函数调用再重映射为自定义命令或快捷键。
除了接口需要定义在全局作用域的函数外,其他一些辅助与实现函数更适合定义为脚本作 用域的函数,即以 s:
前缀的函数,此时函数名可不一定要求以大写字母开头。毕竟脚 本作用域的函数,不可能与全局作用域的内建函数冲突了。
函数返回值
函数体内可以用 :return
返回一个值,如果没有 :return
语句,在函数结束后默认 返回 0
。请看以下示例:
: function! Foo()
: echo 'I am in Foo()'
: endfunction
:
: let ret = Foo()
: echo ret
你可以将这段代码保存在一个 .vim
脚本文件中,然后用 :source
加载执行它。如 果你也正在用 vim 读该文档,可以用 V
选择所有代码行再按 y
复制,然后在命令 行执行 :@"
,这是 Vim 的寄存器用法,这里不准备展开详述。如果你在用其他工具读 文档,原则上也可以将代码复制粘贴至 vim 的命令行中执行,但从外部程序复制内容至 vim 有时会有点麻烦,可能还涉及你的 vimrc
配置。因此还是复制保存为 .vim
文 件再 :source
比较通用。
这段示例代码执行后,会显示两行,第一行输出表示它进到了函数 Foo()
内执行了, 第二行输出表明它的默认返回值是 0
。这个默认返回值的设定,可以想像为错误码,当 函数正常结束时,返回 0
是很正常的事。
当然,根据函数的设计需求,可以显式地返回任何表达式或值。例如:
: function! Foo()
: return range(10)
: endfunction
:
: let ret = Foo()
: echo ret
执行此例将打印出一个列表,这个列表是由函数 Foo()
生成并返回的。
注意一个细节,这里的 :function!
命令必须加 !
符号,因为它正在重定义原来存 在的 Foo()
函数。如果没有 !
,vim 会阻止你重定义覆盖原有的函数,这也是一种 保护机制吧。用户加上 !
后,就认为用户明白自己的行为就是期望重定义同名函数。
一般在写脚本时,在脚本内定义的函数,建议始终加上 !
强制符号。因为你在调试时 可能经常要改一点代码后重新加载脚本,若没有 !
覆盖指令,则会出错。然后在脚本 调试完毕后,函数定义已定稿的情况下,假使由于什么原因也重新加载了脚本,也不外是 将函数重定义为与原来一样的函数而已,大部分情况下这不是问题。(最好是在正常使用 脚本时,能避免脚本的重新加载,这需要一些技巧)
不过这需要注意的是,避免不同脚本定义相同的全局函数名。
函数参数
在函数定义时可以在参数表中加入若干参数,然后在调用时也须使用相同数量的参数:
: function! Sum(x, y)
: return a:x + a:y
: endfunction
: let x = 2
: let y = 3
: let ret = Sum(x, y)
: echo ret
在本例中定义了一个简单的求和函数,接收两个参数;然后调用者也传入两个参数,运行 结果毫无惊喜地得到了结果 5
。
这里必须要指出的是,在函数体内使用参数 x
时,必须加上参数作用域前缀 a:
,即 用 a:x
才是参数中的 x
形参变量。a:x
与函数之外的 x
变量(实则是 g:x
)毫无关系,如果在函数内也创建了个 x
变量(实则是 l:x
),a:x
与之也无关系 ,他们三者是互不冲突相扰的变量。
参数还有个特性,就是在函数体内是只读的,不能被重新赋值。其实由于函数传参是按值 传递的。比如在上例中,调用 Sum(x, y)
时,是把 g:x
与 g:y
的值分别拷贝给 参数 a:x
与 a:y
,你即使能对 a:x
a:y
作修改,也不会影响外面的 g:x
g:y
,函数调用结束后,这种修改毫无影响。然而,VimL 从语法上保证了参数不被修改 ,使形参始终保存着当前调用时实参的值,那是更加安全的做法。
为了更好地理解参数作用域,改写上面的代码如下:
: function! Sum(x, y)
: let x = 'not used x'
: let y = 'not used y'
:
: echo 'g:x = ' . g:x
: echo 'l:x = ' . l:x
: echo 'a:x = ' . a:x
: echo 'x = ' . x
:
: let l:sum = a:x + a:y
: return l:sum
: endfunction
: let x = 2
: let y = 3
: let ret = Sum(-2, -3)
: echo ret
在这个例子中,调用函数 Sum()
时,不再传入全局作用域的 x
y
了,另外传入两 个常量,然后在函数体内查看各个作用域的 x
变量值。
结果表明,在函数体内,直接使用 x
代表的是 l:x
,如果在函数内没定义局部变量 x
,则使用 x
是个错误,它也不会扩展到全局作用域去取 g:x
的值。如果要在函 数内使用全局变量,必须指定 g:
前缀,同样要使用参数也必须使用 a:
前缀。
虽然在函数体内默认的变量作用域就是 l:
,但我还是建议在定义局部变量时显式地 写上 l:
,就如定义 l:sum
这般。虽然略显麻烦,但语义更清晰,更像 VimL 的风格 。函数定义一般写在脚本文件,只用输入一次,多写两字符不多的。
至于脚本作用域变量,读者可自行将示例保存在文件中,然后也创建 s:x
s:y
变量 试试。当然了,在正常的编程脚本中,请不要故意在不同作用域创建同名变量,以避免不 必要的麻烦。(除非在某些特定情境下,按设计意图有必要用同名变量,那也始终注意加 上作用域前缀加以区分)
函数属性:abort
VimL 在定义函数时,在参数表括号之后,还可以可选项指定几个属性。虽然在帮助文档 :help :function
中也称之为 argument
,不过这与在调用时要传入的参数是完全不 同的东西。所以在这我称之为函数属性。文档中称之为 argument
是指它作为 :function
这个 ex 命令
的参数,就像我们要定义的函数名、参数表也是这个命令 的 “参数”。
至 Vim8.0 ,函数支持以下几个特殊属性:
abort
,中断性,在函数体执行时,一旦发现错误,立即中断运行。range
,范围性,函数可隐式地接收两个行地址参数。dict
, 字典性,该函数必须通过字典键来调用。closure
,闭包性,内嵌函数可作为闭包。
其中后面两个函数属性涉及相同高深的话题,留待第五章的函数进阶继续讨论。这里先只 讨论前两个属性。
为理解 abort
属性,我们先来看一下,vim 在执行命令时,遇到错误会怎么办?
: echomsg 'before error'
: echomsg error
: echomsg 'after error'
在这个例子中,第二行是个错误,因为 echo
要求表达式参数,但 error
这个词是 未定义变量。这里用 echomsg
代替 echo
是因为 echomsg
命令的输出会保存在 vim 的消息区,此后可以用 :message
命令重新查看;而 echo
只是临时查看。
将这几行语句写入一个临时脚本,比较 ~/.vim/vimllearn/cmd.vim
,然后用命令加载 :source ~/.vim/vimllearn/cmd.vim
。结果表明,虽然第二行报错了,但第三行仍然 执行了。
不过,如果在 vim 下查看该文档,将这几行复制到寄存器中,再用 :@"
运行,第三行 语句就似乎不能被执行到了。然而这不是主流用法,可先不管这个差异。
然后,我们将错误语句放在一个函数中,看看怎样?
: function! Foo()
: echomsg 'before error'
: echomsg error
: echomsg 'after error'
: endfunction
:
: echomsg 'before call Foo()'
: call Foo()
: echomsg 'after call Foo()'
将这个示例保存在 ~/.vim/vimllearn/t_abort1.vim
,然后 :source
运行。结果错 误之后的语句也都将继续执行。
在函数定义行末加上 abort
参数,改为:
: function! Foo() abort
重新 :source
执行。结果表明,在函数体内错误之后的语句不再执行,但是调用这个 出错函数之后的语句仍然执行。
现在你应该明白 abort
这个函数属性的意义了。一个良好习惯时,始终在定义函数时 加上这个属性。因为一个函数我们期望它执行一件相对完整独立的工作,如果中间出错了 ,为何还有必要继续执行下去。立即终止这个函数,一方面便于跟踪调试,另一方面避免 在错误的状态下继续执行可能造成的数据损失。
那为什么 vim 的默认行为是容忍错误呢?想想你的 vimrc
,如果中间某行不慎出错了 ,如果直接终止运行脚本,那你的初始配置可能加载很不全了。Vim 在最初提供函数功能 ,可能也只是作为简单的命令包装重用,所以延续了这种默认行为。但是当 VimL 的函数 功能可以写得越来越复杂时,为了安全性与调试,立即终止的 abort
行为就很有必要 的。
如果你写的什么函数,确实有必要利用容忍错误这个默认特性,当然你可以选择不加 abort
这个属性。不过最好还是重新想想你的函数设计,如果真有这需求,是否直接写 在脚本中而不要写在函数中更合适些。
*函数属性:range
函数的 range
属性,表明它很好地继承了 Vim 风格,因为很多命令之前都支持带行地 址(或数字)参数的。不过 range
只影响一些特定功能的函数与函数使用方式,而在 其他情况下,有没有 range
属性影响似乎都不大。
首先,只有在用 :call Fun()
调用函数时,在 :call
之前有行地址(也叫行范围) 参数时,Fun()
函数的 range
属性才有可能影响。
那么,什么又是行地址参数呢。举个例子,你在 Vim 普通模式下按 V
进入选择模式, 选了几行之后,按冒号 :
,然后输入 call Fun()
。你会发现,在选择模式下按冒号 进入 ex 命令行时,vim 会自动在命令行加上 '<,'>
。所以你实际将要运行的命令是 :'<,'>call Fun()
。'<
与 '>
是两个特殊的 mark
位置,分别表示最近选区的 第一行与最后一行。你也可以手动输入地址参数,比如 1,5call Fun()
或 1,$call Fun()
,其中 $
是个特殊地址,表示最后一行,当前行用 .
表示,还支持 +
与 -
表示相对当前行的相对地址。
总之,当用带行地址参数的 :{range}call
命令调用函数时,其含义是要在这些行范围 内调用一个函数。如果该函数恰好指定了 range
属性,那么就会隐式地额外传两个参数 给这个函数,a:firstline
表示第一行,a:lastline
表示最后一行。
比如若用 :1,5call Fun()
调用已指定 range
属性的函数 Fun()
,那么在 Fun()
函数体内就能直接使用 a:firstline
与 'a:lastline' 这两个参数了,其值 分别为 1
与 5
。如果用 :'<,'>call Fun()
调用,vim 也会自动从标记中计算出 实际数字地址来传给 a:firstline
与 'a:lastline' 参数。函数调用结束后,光标回 到指定范围的第 1 行,也就是 a:firstline
那行。
如果用 :1,5call Fun()
调用时,Fun()
却没指定 range
属性时。那又该怎办, Fun()
函数内没有 a:firstline
与 a:lastline
参数来接收地址啊?此时,vim 会采用另一种策略,在指定的行范围内的每一行调一次目标函数。按这个实例,vim 会调 用 5 次 Fun()
函数,每次调用时分别将当前光标置于 1 至 5 行,如此在 Fun()
函数内就可直接操作 “当前行” 了。整个调用结束后,光标停留在范围内的最后一行。
函数的 range
属性的工作原理就是这样,然则它有什么用呢?如果函数在操作 vim 中 的当前 buffer 是极有用的。举个例子:
" File: ~/.vim/vimllearn/frange.vim
function! NumberLine() abort
let l:sLine = getline('.')
let l:sLine = line('.') . ' ' . l:sLine
call setline('.', l:sLine)
endfunction
function! NumberLine2() abort range
for l:line in range(a:firstline, a:lastline)
let l:sLine = getline(l:line)
let l:sLine = l:line . ' ' . l:sLine
call setline(l:line, l:sLine)
endfor
endfunction
finish
测试行
测试行
测试行
测试行
测试行
在这个脚本中,定义了一个 NumberLine()
不带 range
属性的函数,与一个带 range
属性的 NumberLine2()
函数。它们的功能差不多,就是给当前 buffer 内的 行编号,类似 set number
效果,只不过把行号写在文本行之前。
这里用到的几个内建函数稍作解释下,getline()
与 setline()
分别表示获取与设定 文本行,它们的第一个参数都是行号,当前行号用 '.'
表示。 line('.')
也表示获取 当前行号。
如果你正用 vim 编辑这个脚本,直接用 :source %
加载脚本,然后将光标移到 finish
之后,选定几行,按冒号进入命令行,调用 :'<,'>call NumberLine()
或 :'<,'>call NumberLine2()
看看效果。可用 u
撤销修改。然后可将光标移到其他地 方,手动输入数字行号代替自动添加的 '<,'>
试试看。
最后,关于使用 range
属性的几点建议:
- 如果函数实现的功能,不涉及读取或修改当前 buffer 的文本行,完全不用管
range
属性。但在调用函数时,也请避免在:call
之前加行地址参数,那样既无意义,还 导致重复调用函数,影响效率。 - 如果函数功能就是要操作当前 buffer 的文本行,则根据自己的需求决定是否添加
range
属性。有这属性时,函数只调用一次,效率高些,但要自己编码控制行号,略 复杂些。 - 综合建议就是,如果你懂
range
就用,不懂就不用。
*函数命令
:function
命令不仅可用来(在脚本中)定义函数,也可以用来(在命令行中)查看函 数,这个特性就如 :command
:map
一样的设计。
:function
不带参数,列出所有当前 vim 会话已定义的函数(包括参数)。:function {name}
带一个函数名参数,必须是已定义的函数全名,则打印出该函数 的定义。由此可见,vim 似乎通过函数名保存了一份函数定义代码的拷贝。:function /{pattern}
不需要全名,按正则表达式搜索函数,因为不带参数的:function
可能列出太多的函数,如此可用这个命令过滤一下,但是也只会打印函数 头,不包括函数体的实现代码,即使只匹配了一个函数。:function {name}()
请不要在命令行中使用这种方式,在函数名之后再加小括号, 因为这就是定义一个函数的语法!
*函数定义 snip
在实际写 vim 脚本中,函数应该是最常用的结构单元了。然后函数定义的细节还挺多, endfunction
这词也有点长(脚本中不建议缩写)。如果你用过 ultisnips
或其他 类似的 snip 插件,则可考虑将常用函数定义的写法归纳为一个 snip。
作为参考示例,我将 fs
定义为写 s:函数
的代码片断模板:
snippet fs "script local function" b
" $1:
function! s:${1:function_name}(${2}) abort "{{{
${3:" code}
endfunction "}}}
endsnippet
关于 ultisnips 这插件的用法,请参考:https://github.com/SirVer/ultisnips
小结
函数是构建复杂程序的基本单元,请一定要掌握。函数必须先定义,再调用,通过参数与 返回值与调用者交互。本节只讲了 VimL 函数的基础部分,函数的进阶用法后面另有章节 专门讨论。