2.4 函数定义与使用

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

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:xg:y 的值分别拷贝给 参数 a:xa: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' 这两个参数了,其值 分别为 15。如果用 :'<,'>call Fun() 调用,vim 也会自动从标记中计算出 实际数字地址来传给 a:firstline 与 'a:lastline' 参数。函数调用结束后,光标回 到指定范围的第 1 行,也就是 a:firstline 那行。

如果用 :1,5call Fun() 调用时,Fun() 却没指定 range 属性时。那又该怎办, Fun() 函数内没有 a:firstlinea: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 函数的基础部分,函数的进阶用法后面另有章节 专门讨论。