使用脚本配置vim编辑器(二)、用户定义函数

回复 收藏
创建基本的自动化构建块
要将应用程序分解为正确的、可维护的组件,从而管理实际编程任务的复杂性,用户定义函数是必不可少的一种工具。本文是 本系列文章 的第二篇,介绍了如何使用 Vimscript 语言创建和部署新函数,并通过一些实际的示例展示这样做的必要性。
用户定义函数
Haskell 或 Scheme 程序员会告诉您,函数对于任何严肃的编程语言来说都是最重要的特性。对于 C 或 Perl 程序员,他们也会告诉您完全相同的观点。函数为严肃的程序员提供了两个基本优势:
它们能够将复杂的计算任务细分为足够小的部分,从而能够容易地被人类理解。
它们允许这些细分后的部分具有逻辑的和可理解的名称,这样就十分适合由人类处理。
Vimscript 是一种严肃的编程语言,因此它天生就支持创建用户定义函数。事实上,它确实提供了比 Scheme、C 或 Perl 更加优秀的 用户定义函数支持。本文探究了 Vimscript 函数的各种特性,并展示了如何使用这些函数以可维护的方式增强并扩展 Vim 的内置函数。
声明函数
vimscript 中的函数使用 function 关键字定义,后跟函数名,然后是参数列表(这是强制的,即使该函数没有参数)。函数体然后从下一行开始,一直连续下去,直到遇到一个匹配的 endfunction 关键字。例如:
清单 1. 具有正确结构的函数
functionExpurgateText (text)    let expurgated_text = a:text
    for expletive in [ 'cagal', 'frak', 'gorram', 'mebs', 'zarking']
        let expurgated_text
        \   = substitute(expurgated_text, expletive, '[DELETED]', 'g')
    endfor
    return expurgated_text
endfunction
函数返回值使用 return 语句指定。可以根据需要指定任意数量的单独 return 语句。如果函数被用作一个过程,并且没有任何有用的返回值,那么可以不包含 return 语句。然而,Vimscript 函数始终 返回一个值,因此如果没有指定任何 return,那么函数将自动返回 0。vimscript 中的函数名必须以大写字母开头:
清单 2. 以大写字母开头的函数名
function SaveBackup ()
    let b:backup_count = exists('b:backup_count') ? b:backup_count+1 : 1
    return writefile(getline(1,'$'), bufname('%') . '_' . b:backup_count)
endfunction
nmap   :call SaveBackup()
这个例子定义了一个函数,它将递增当前缓冲区的 b:backup_count 变量的值(或初始化为 1,如果尚不存在的话)。函数随后获取当前文件(getline(1,'$'))中的每一行并调用内置的 writefile() 函数来将它们写入到磁盘中。writefile() 的第二个参数是将要写入的新文件的名称;在本例中,为当前文件bufname('%')的名称附加上计数器的新值。返回的值为对 writefile() 调用的 success/failure 值。最后,nmap 设置 CTRL-B 以调用函数来创建对当前文件的有限备份。
vimscript 函数没有使用前导大写字母,相反,可以使用显式的范围前缀声明函数(类似变量,如 第 1 部分 所述)。最常见的选择是 s:,它表示函数对于当前脚本文件是本地函数。如果函数使用这种方式确定范围,那么它的名称就不需要以大写开头;它可以是任意有效标识符。然而,显式确定范围的函数必须始终使用范围前缀进行调用。比如:
清单 3. 使用范围前缀调用函数
" Function scoped to current script file...function s:save_backup ()
    let b:backup_count = exists('b:backup_count') ? b:backup_count+1 : 1
    return writefile(getline(1,'$'), bufname('%') . '_' . b:backup_count)
endfunction
nmap   :call s:save_backup()
可重新声明的函数
vimscript 中的函数声明为运行时语句,因此如果一个脚本被加载两次,那么该脚本中的任何函数声明都将被执行两次,因此将重新创建相应的函数。
重新声明函数被看作一种致命的错误(这样做是为了防止发生两个不同脚本同时声明函数的冲突)。这使得很难在需要反复加载的脚本中创建函数,比如自定义的语法突出显示脚本。
因此 Vimscript 提供了一个关键字修饰符(function!),允许在需要时指出某个函数声明可以被安全地重载:
清单 4. 表示某个函数声明可以被安全地重载
function! s:save_backup ()
    let b:backup_count = exists('b:backup_count') ? b:backup_count+1 : 1
    return writefile(getline(1,'$'), bufname('%') . '_' . b:backup_count)
endfunction
对于使用这个修饰过的关键字定义的函数,没有执行任何重新声明检查,因此非常适合用于显式确定范围的函数(在这种情况下,范围已经确保函数不会和其他脚本中的函数发生冲突)。
调用函数
要调用函数并使用它的返回值作为语言表达式的一部分,只需要命名它并附加一个使用圆括号括起的参数列表:
清单 5. 使用函数的返回值
"Clean up the current line...
let success = setline('.', ExpurgateText(getline('.')) )
但是要注意,与 C 或 Perl 不同,Vimscript 并不 允许您在未使用的情况下抛出函数的返回值。因此,如果打算使用函数作为过程或子例程并忽略它的返回值,那么必须使用 call 命令为调用添加前缀:
清单 6. 在未使用返回值的情况下使用函数
"Checkpoint the text...
call SaveBackup()
否则,Vimscript 将假设该函数调用实际上是一个内置的 Vim 命令,并且很可能会发出报警,指出并不存在这类命令。我们将在本系列的后续文章中查看函数和命令之间的区别。
参数列表
vimscript 允许您定义显式参数 和可变参数列表,甚至可以将两者结合起来。在声明了子例程的名称后,您可以立即指定最多 20 个显式命名的参数。指定参数后,通过将 a: 前缀添加到参数名,可以在函数内部访问当前调用的相应参数值:
清单 7. 在函数内部访问参数值
function PrintDetails(name, title, email)
    echo 'Name:   '  a:title  a:name
    echo 'Contact:'  a:email
endfunction
如果您不清楚一个函数具有多少个参数,那么可以指定一个可变的参数列表,使用省略号(...)而不是命名参数。在本例中,函数可以使用任意数量的参数调用,并且这些值被收集到一个单一变量中:一个名为 a:000 的数组。为单个参数也提供了位置参数名:a:1、a:2、a:3,等等。参数的数量可以是 a:0。例如:
清单 8. 指定并使用一个可变的参数列表
function Average(...)
    let sum = 0.0
    for nextval in a:000"a:000 is the list of arguments
        let sum += nextval
    endfor
    return sum / a:0"a:0 is the number of arguments
endfunction
注意,在本例中,sum 必须被初始化为一个显式的浮点值;否则所有后续计算都将使用整数运算计算。
结合命名参数和可变参数
可以在同一个函数中同时使用命名参数和可变参数,只需要将可变参数的省略号放在命名参数列表之后。例如,假设您希望创建一个 CommentBlock() 函数,它将接收一个字符串并针对不同的编程语言将其格式化为相应的注释块。这类函数始终需要调用者为其提供一个字符串来进行格式化,因此应当使用命名参数。但是,您可能希望注释导入器(introducer)、“boxing” 字符和注释的宽度全部是可选的(在被省略时具有合理的默认值)。那么应当像下面这样调用:
清单 9. 一个简单的 CommentBlock 函数调用
call CommentBlock("This is a comment")
并且将返回一个多行字符串包含:
清单 10. CommentBlock 返回
//*******************
// This is a comment
//*******************
然而,如果提供额外的参数,那么将为注释导入器、“boxing” 字符和注释宽度指定非默认值。因此这个调用将为:
清单 11. 更加复杂的 CommentBlock 函数调用
call CommentBlock("This is a comment", '#', '=', 40)
would return the string:
清单 12. CommentBlock 返回
#========================================
# This is a comment
#========================================
这类函数的可能的实现方式为:
清单 13. CommentBlock 实现
function CommentBlock(comment, ...)
    "If 1 or more optional args, first optional arg is introducer...
    let introducer =  a:0 >= 1  ?  a:1  :  "//"
    "If 2 or more optional args, second optional arg is boxing character...
    let box_char   =  a:0 >= 2  ?  a:2  :  "*"
    "If 3 or more optional args, third optional arg is comment width...
    let width      =  a:0 >= 3  ?  a:3  :  strlen(a:comment) + 2
    " Build the comment box and put the comment inside it...
    return introducer . repeat(box_char,width) . "\"
    \    . introducer . " " . a:comment        . "\"
    \    . introducer . repeat(box_char,width) . "\"
endfunction
如果至少有一个可选参数(a:0 >= 1),那么导入器参数将指定给第一个选项(即 a:1);否则,将指定一个默认值 "//"。类似地,如果有两个或多个可选参数(a:0 >= 2),那么 box_char 变量被分配给第二个选项(a:2),或一个默认值 "*"。如果提供了三个或多个可选参数,那么第三个选项被分配给 width 变量。如果没有提供宽度参数,那么将自动根据注释参数本身计算相应的宽度(strlen(a:comment)+2)。
最后,将所有参数值解析后,将构建注释框的顶部和底部行:首先是一个注释导入器,后跟 boxing 字符的重复次数(repeat(box_char,width)),在这两者之间是注释文本本身。当然,要使用这个函数,需要以某种方式调用它。最理想的方法可能是使用一个插入映射:
清单 14. 使用一个插入映射调用函数
"C++/Java/PHP comment...
imap   ///  =CommentBlock(input("Enter comment: "))"Ada/Applescript/Eiffel comment...
imap   ---  =CommentBlock(input("Enter comment: "),'--')"Perl/Python/Shell comment...
imap   ###  =CommentBlock(input("Enter comment: "),'#','#')
对于每一个映射,将首先调用内置的 input() 函数来请求注释文本中的用户类型。CommentBlock() 函数随后被调用,以将文本转换为一个注释块。最后,前导 = 插入结果字符串。注意,第一个映射仅仅传递一个单一参数,因此默认使用 // 作为其注释标记。第二个和第三个映射传递第二个参数来指定 # 或 -- 作为它们各自的注释导入器。最后一个映射传递第三个参数,使得 “boxing” 字符匹配它的注释导入器。
函数和行范围
可以使用一个初始的行范围调用任何标准的 Vim 命令(包括 call),这将针对范围中的每一行重复一次命令:
"Delete every line from the current line (.) to the end-of-file ($)...
:.,$delete
"Replace "foo" with "bar" everywhere in lines 1 to 10
:1,10s/foo/bar/
"Center every line from five above the current line to five below it...
:-5,+5center
可以在任何 Vim 会话中输入 :help cmdline-ranges 来了解更多有关此工具的内容。
对于 call 命令,指定范围将致使所请求的函数被反复调用:对范围中的每一行调用一次。要了解这样做的原因,考虑一下如何编写一个函数来将当前行中的任何 “原始的” & 符号转换为适当的 XML & 实体,但是这样做也足够灵巧,可以忽略任何已经存在于其他实体中的 & 符号。这个功能的实现方式类似如下所示:
清单 15. 转换 & 符号的函数
function DeAmperfy()
    "Get current line...
    let curr_line   = getline('.')
    "Replace raw ampersands...
    let replacement = substitute(curr_line,'&\(\w\+;\)\@!','&','g')
    "Update current line...
    call setline('.', replacement)
endfunction
DeAmperfy() 中的第一行代码从编辑器缓冲区获取当前行(getline('.'))。第二行代码从当前行中查找其后未 跟随标识符和冒号的 &,使用了否定先行(negative lookahead)模式 '&\(\w\+;\)\@!'(参见 :help \@! 获得更多细节)。substitute() 调用随后使用 XML & 实体替换所有此类 “原始” & 符号。最后,DeAmperfy() 中的第三行代码使用修改后的文本更新当前行。如果从命令行调用该函数:
:call DeAmperfy()
将只对当前行执行替换。但是如果在 call 之前指定了一个范围:
:1,$call DeAmperfy()
那么将针对范围内的每一行调用一次函数(在本例中,指的是文件中的每一行)。
内部化函数行范围
这种针对每一行反复调用函数 的行为是一种方便的默认行为。然而,有时希望指定一个范围,但是只调用一次函数,然后在函数内部处理范围语义。这对于 Vimscript 也很简单。只需要将一个特殊修饰符(range)附加到函数声明之后:
清单 16. 函数内部的范围语义
function DeAmperfyAll() range"Step through each line in the range...
    for linenum in range(a:firstline, a:lastline)
        "Replace loose ampersands (as in DeAmperfy())...
        let curr_line   = getline(linenum)
        let replacement = substitute(curr_line,'&\(\w\+;\)\@!','&','g')
        call setline(linenum, replacement)
    endfor
    "Report what was done...
    if a:lastline > a:firstline
        echo "DeAmperfied" (a:lastline - a:firstline + 1) "lines"
    endif
endfunction
在参数列表之后指定了 range 修饰符后,使用如下范围调用 DeAmperfyAll() 时:
:1,$call DeAmperfyAll()
将只对函数执行一次调用,而两个特殊参数 a:firstline 和 a:lastline 被设置为范围的第一个行号和最后一个行号。如果未指定任何范围,那么a:firstline 和 a:lastline 都将被设置为当前行号。
函数首先构建一个包含所有相关行号的列表(range(a:firstline, a:lastline))。注意,对内置 range() 函数的调用与在函数声明中使用 range 修饰符一点关系也没有。range() 函数只是一个 list 构造函数,非常类似于 Python 中的 range() 函数,或者是 Haskell 或 Perl 中的 .. 运算符。确定了将要处理的行号列表后,函数使用 for 循环来遍历每个行号:
for linenum in range(a:firstline, a:lastline)
然后相应地更新每一行(正如最初的 DeAmperfy() 所做的那样)。最后,如果范围涵盖了多个行(即 a:lastline > a:firstline),函数将报告被更新的行的数量。
可视范围
一旦拥有了一个可以操作行范围的函数调用后,一个特别有用的技巧就是通过 Visual 模式(参见 :help Visual-mode 获得更多细节)调用该函数。例如,如果游标位于文本块的某个位置,那么可以使用下面的代码在周围的段落中编码所有 & 号:
Vip:call DeAmperfyAll()
在 Normal 模式下输入 V 将切换到 Visual 模式。ip 随后将使 Visual 模式突出显示您正位于其中的整个段落。之后,: 将您切换到 Command 模式并自动将命令范围设置为刚刚从 Visual 模式选择的行的范围。此时,调用 DeAmperfyAll() 对所有行执行 deamperfy 操作。注意,在这个实例中,可以使用下面的代码获得相同的效果:
Vip:call DeAmperfy()
惟一的不同之处在于 DeAmperfy() 函数将被反复调用:针对 Visual 模式下 Vip 中突出显示的每一行调用一次。
用于编码的函数
vimscript 中的大多数用户定义函数只需要很少的参数,并且通常情况下根本不需要参数。这是因为它们常常直接从当前编辑器缓冲区和上下文信息(比如当前游标位置、当前段落大小、当前窗口大小或当前行的内容)中获得数据。
此外,如果函数通过上下文而不是参数列表包含数据,那么往往更加有用和方便。例如,维护源代码的一个常见问题就是赋值运算符在聚集起来后无法对齐,这将损害代码的可读性:
清单 16. 无法对齐的赋值运算符
let applicants_name = 'Luke'
let mothers_maiden_name = 'Amidala'
let closest_relative = 'sister'
let fathers_occupation = 'Sith'
在每次添加新语句时手动重新对齐它们将十分费力:
清单 17. 手动重新对齐赋值运算符
let applicants_name     = 'Luke'
let mothers_maiden_name = 'Amidala'
let closest_relative    = 'sister'
let fathers_occupation  = 'Sith'
要让日常编程任务没那么乏味,可以创建一个键映射(比如 ;=),它可以选择当前代码块、定位具有赋值运算符的任何行,并自动对齐这些运算符。如下所示:
清单 18. 对齐赋值运算符的函数
function AlignAssignments ()
    "Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@= 0
            let max_align_col = max([max_align_col, left_width])
            let op_width      = strlen(matchstr(linetext, ASSIGN_OP))
            let max_op_width  = max([max_op_width, op_width+1])
         endif
    endfor
    "Code needed to reformat lines so as to align operators...
    let FORMATTER = '\=printf("%-*s%*s", max_align_col, submatch(1),
    \                                    max_op_width,  submatch(2))'
    " Reformat lines with operators aligned in the appropriate column...
    for linenum in range(firstline, lastline)
        let oldline = getline(linenum)
        let newline = substitute(oldline, ASSIGN_LINE, FORMATTER, "")
        call setline(linenum, newline)
    endfor
endfunction
nmap   ;=  :call AlignAssignments()
AlignAssignments() 函数首先创建两个正则表达式(参见 :help pattern 获得有关 Vim 正则表达式语法的必要细节):
let ASSIGN_OP   = '[-+*/%|&]\?=\@= 机制,惟一不同的是这种奇妙的行为只针对内置 substitute()函数的替换字符串(或在标准 :s/.../.../ Vim 命令中)。
在本例中,特殊替换形式对于每一行来说都将是相同的 printf ,因此它将在第二个 for 循环开始之前被预先存储到 FORMATTER 变量中:
let FORMATTER = '\=printf("%-*s%*s", max_align_col, submatch(1),max_op_width,  submatch(2))'
当最终被 substitute() 调用时,这个内嵌的 printf() 将把运算符左侧的所有内容(submatch(1))靠左对齐(使用 %-*s 占位符)并将结果放到字符宽度为 max_align_col 的字段中。随后将运算符本身(submatch(2))右对齐(使用 %*s)到第二个字段,其字符宽度为 max_op_width。参考 :help printf(),了解 - 和 * 选项如何修改这里使用的两个 %s 格式说明符(specifier)。有了这个格式化程序后,第二个 for 循环就可以遍历完整的行号范围,每次取回一行相应的文本缓冲内容:
for linenum in range(firstline, lastline)
    let oldline = getline(linenum)
循环随后使用 substitute() 来转换这些内容,方法是匹配位于任何赋值运算符之前并包括运算符在内的所有内容(使用 ASSIGN_LINE 中的模式)并使用 printf() 调用的结果替换文本(如 FORMATTER 中指定的那样):
    let newline = substitute(oldline, ASSIGN_LINE, FORMATTER, "")
    call setline(linenum, newline)
endfor
当 for 循环遍历了所有行之后,这些行中的赋值运算符将被正确对齐。剩余的工作是创建一个键映射来调用 AlignAssignments(),如下所示:
nmap   ;=  :call AlignAssignments()
2015-07-29 23:46 举报
已邀请:

回复帖子,请先登录注册

退出全屏模式 全屏模式 回复
评分
可选评分理由: