分类: LINUX
2010-04-13 21:44:03
Haskell 或 Scheme 程序员会告诉您,函数对于任何严肃的编程语言来说都是最重要的特性。对于 C 或 Perl 程序员,他们也会告诉您完全相同的观点。
函数为严肃的程序员提供了两个基本优势:
Vimscript 是一种严肃的编程语言,因此它天生就支持创建用户定义函数。事实上,它确实提供了比 Scheme、C 或 Perl 更加优秀的 用户定义函数支持。本文探究了 Vimscript 函数的各种特性,并展示了如何使用这些函数以可维护的方式增强并扩展 Vim 的内置函数。
Vimscript 中的函数使用 function
关键字定义,后跟函数名,然后是参数列表(这是强制的,即使该函数没有参数)。函数体然后从下一行开始,一直连续下去,直到遇到一个匹配的 endfunction
关键字。例如:
function ExpurgateText (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 中的函数名必须以大写字母开头:
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 |
这个例子定义了一个函数,它将递增当前缓冲区的 b:backup_count
变量的值(或初始化为 1,如果尚不存在的话)。函数随后获取当前文件(getline(1,'$')
)中的每一行并调用内置的 writefile()
函数来将它们写入到磁盘中。writefile()
的第二个参数是将要写入的新文件的名称;在本例中,为当前文件(bufname('%')
)的名称附加上计数器的新值。返回的值为对 writefile()
调用的 success/failure 值。最后,nmap
设置 CTRL-B 以调用函数来创建对当前文件的有限备份。
Vimscript 函数没有使用前导大写字母,相反,可以使用显式的范围前缀声明函数(类似变量,如 第 1 部分 所述)。最常见的选择是 s:
,它表示函数对于当前脚本文件是本地函数。如果函数使用这种方式确定范围,那么它的名称就不需要以大写开头;它可以是任意有效标识符。然而,显式确定范围的函数必须始终使用范围前缀进行调用。比如:
" 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 |
Vimscript 中的函数声明为运行时语句,因此如果一个脚本被加载两次,那么该脚本中的任何函数声明都将被执行两次,因此将重新创建相应的函数。
重新声明函数被看作一种致命的错误(这样做是为了防止发生两个不同脚本同时声明函数的冲突)。这使得很难在需要反复加载的脚本中创建函数,比如自定义的语法突出显示脚本。
因此 Vimscript 提供了一个关键字修饰符(function!
),允许在需要时指出某个函数声明可以被安全地重载:
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
|
对于使用这个修饰过的关键字定义的函数,没有执行任何重新声明检查,因此非常适合用于显式确定范围的函数(在这种情况下,范围已经确保函数不会和其他脚本中的函数发生冲突)。
要调用函数并使用它的返回值作为语言表达式的一部分,只需要命名它并附加一个使用圆括号括起的参数列表:
"Clean up the current line... let success = setline('.', ExpurgateText(getline('.')) ) |
但是要注意,与 C 或 Perl 不同,Vimscript 并不 允许您在未使用的情况下抛出函数的返回值。因此,如果打算使用函数作为过程或子例程并忽略它的返回值,那么必须使用 call
命令为调用添加前缀:
"Checkpoint the text...
call SaveBackup()
|
否则,Vimscript 将假设该函数调用实际上是一个内置的 Vim 命令,并且很可能会发出报警,指出并不存在这类命令。我们将在本系列的后续文章中查看函数和命令之间的区别。
Vimscript 允许您定义显式参数 和可变参数列表,甚至可以将两者结合起来。
在声明了子例程的名称后,您可以立即指定最多 20 个显式命名的参数。指定参数后,通过将 a:
前缀添加到参数名,可以在函数内部访问当前调用的相应参数值:
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
。例如:
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” 字符和注释的宽度全部是可选的(在被省略时具有合理的默认值)。那么应当像下面这样调用:
call CommentBlock("This is a comment") |
并且将返回一个多行字符串包含:
//******************* // This is a comment //******************* |
然而,如果提供额外的参数,那么将为注释导入器、“boxing” 字符和注释宽度指定非默认值。因此这个调用将为:
call CommentBlock("This is a comment", '#', '=', 40) |
would return the string:
#======================================== # This is a comment #======================================== |
这类函数的可能的实现方式为:
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) . "\ |
如果至少有一个可选参数(a:0 >= 1
),那么导入器参数将指定给第一个选项(即 a:1
);否则,将指定一个默认值 "//"
。类似地,如果有两个或多个可选参数(a:0 >= 2
),那么 box_char
变量被分配给第二个选项(a:2
),或一个默认值 "*"
。如果提供了三个或多个可选参数,那么第三个选项被分配给 width
变量。如果没有提供宽度参数,那么将自动根据注释参数本身计算相应的宽度(strlen(a:comment)+2
)。
最后,将所有参数值解析后,将构建注释框的顶部和底部行:首先是一个注释导入器,后跟 boxing 字符的重复次数(repeat(box_char,width)
),在这两者之间是注释文本本身。
当然,要使用这个函数,需要以某种方式调用它。最理想的方法可能是使用一个插入映射:
"C++/Java/PHP comment... imap |
对于每一个映射,将首先调用内置的 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 &
实体,但是这样做也足够灵巧,可以忽略任何已经存在于其他实体中的 & 符号。这个功能的实现方式类似如下所示:
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
)附加到函数声明之后:
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 中的大多数用户定义函数只需要很少的参数,并且通常情况下根本不需要参数。这是因为它们常常直接从当前编辑器缓冲区和上下文信息(比如当前游标位置、当前段落大小、当前窗口大小或当前行的内容)中获得数据。
此外,如果函数通过上下文而不是参数列表包含数据,那么往往更加有用和方便。例如,维护源代码的一个常见问题就是赋值运算符在聚集起来后无法对齐,这将损害代码的可读性:
let applicants_name = 'Luke' let mothers_maiden_name = 'Amidala' let closest_relative = 'sister' let fathers_occupation = 'Sith' |
在每次添加新语句时手动重新对齐它们将十分费力:
let applicants_name = 'Luke' let mothers_maiden_name = 'Amidala' let closest_relative = 'sister' let fathers_occupation = 'Sith' |
要让日常编程任务没那么乏味,可以创建一个键映射(比如 ;=
),它可以选择当前代码块、定位具有赋值运算符的任何行,并自动对齐这些运算符。如下所示:
function AlignAssignments () "Patterns needed to locate assignment operators... let ASSIGN_OP = '[-+*/%|&]\?=\@"Locate block of code to be considered (same indentation, no blanks) let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S' let firstline = search('^\%('. indent_pat . '\)\@!','bnW') + 1 let lastline = search('^\%('. indent_pat . '\)\@!', 'nW') - 1 if lastline < 0 let lastline = line('$') endif "Find the column at which the operators should be aligned... let max_align_col = 0 let max_op_width = 0 for linetext in getline(firstline, lastline) "Does this line have an assignment in it? let left_width = match(linetext, '\s*' . ASSIGN_OP) "If so, track the maximal assignment column and operator width... if left_width >= 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 |
AlignAssignments()
函数首先创建两个正则表达式(参见 :help pattern
获得有关 Vim 正则表达式语法的必要细节):
let ASSIGN_OP = '[-+*/%|&]\?=\@ |
ASSIGN_OP
中的模式匹配任何标准的赋值运算符:=
、+=
、-=
、*=
,等等,但是注意不要匹配其他包含 =
的运算符,比如 ==
和 =~
。如果您喜欢的语言中包含其他赋值运算符(比如 .=
或 ||=
或 ^=
),那么可以扩展 ASSIGN_OP
正则表达式来识别这些运算符。另一种选择是,可以重新定义 ASSIGN_OP
来识别其他 “可对齐的” 类型,比如注释导入器或列表及,并对齐它们。
ASSIGN_LINE
中的模式只在行的起始部分(^
)开始匹配,首先匹配最小字符数(.\{-}
),然后匹配任何空白(\s*
),最后匹配赋值运算符。
注意,最初的 “最小字符数” 子模式和运算符子模式都在捕捉圆括号内进行了指定:\(
...\)
。这两个正则表达式组件捕获的子字符串稍后将通过调用内置 submatch()
函数来提取;具体来讲,通过调用 submatch(1)
来提取运算符前面的所有内容,然后调用 submatch(2)
来提取运算符本身。
AlignAssignments()
随后查找它将对其进行操作的行范围:
let indent_pat = '^' . matchstr(getline('.'), '^\s*') . '\S' let firstline = search('^\%('. indent_pat . '\)\@!','bnW') + 1 let lastline = search('^\%('. indent_pat . '\)\@!', 'nW') - 1 if lastline < 0 let lastline = line('$') endif |
在此前的例子中,函数依赖于一个显式的命令范围或一个 Visual 模式选择来确定要进行处理的行,但是这个函数则直接计算它自己的行范围。具体来讲,它首先调用内置 matchstr()
函数来确定出现在当前行(getline('.')
)起始部分的前导空白('^\s*'
)。随后在 indent_pat
中构建一个新的正则表达式,精确匹配任何非空行的起始处的相同序列的空白(即拖尾 '\S'
)。
AlignAssignments()
随后调用内置 search()
函数向上搜索(使用标记 'bnW'
)并定位位于游标上方的第一个不 具有相同缩进的行。向此行号加 1 将得出感兴趣的范围的起始行号,也就是说,具有相同缩进的第一个相邻行就作为当前行。
第二个 search()
调用随后向下搜索('nW'
)来判断 lastline
:具有相同缩进的最后一个相邻行。对于这种情况,搜索可能会到达文件的末尾,并且没有找到具有不同缩进的行,这种情况下 search()
将返回 -1
。要正确地处理这种情况,随后的 if
语句需要显式地将 lastline
设置为文件末端的行号(即设置为由 line('$')
返回的行号)。
这两个搜索的结果将使 AlignAssignments()
知道紧邻着当前行的上方或下方、具有与当前行完全相同的缩进的完整行范围。它使用这些信息来确保只对位于同一代码块中相同范围级别的赋值语句执行对齐。当然,如果代码的缩进不能正确反映它的范围,那么这种情况下必须进行重新格式化。
AlignAssignments()
中的第一个 for
循环判断其中的赋值运算符应当对齐的列。实现方法是遍历所选范围内的行列表(由 getline(firstline, lastline)
取回的行)并检查每个行是否包含赋值运算符(运算符的前面可能包含空格):
let left_width = match(linetext, '\s*' . ASSIGN_OP) |
如果该行中没有运算符,那么内置 match()
函数将无法找到匹配,因此将返回 -1
。对于这种情况,循环将直接跳到下一行。如果存在 运算符,那么 match()
将返回在其中显示运算符的(正)指数。if
语句随后使用内置 max()
函数判断这个最近的列位置是否比此前找到的运算符更靠右,从而跟踪所需的最大列位置来对齐范围内的所有赋值运算符:
let max_align_col = max([max_align_col, left_width]) |
if
中剩下的两行代码使用内置 matchstr()
函数检索实际的运算符,然后使用内置 strlen()
函数判断行的长度("="
的长度为 1,'+='
、'-='
的长度为 2,等等)。max_op_width
变量随后被用来跟踪所需的最大宽度,以对范围内的各种运算符执行对齐:
let op_width = strlen(matchstr(linetext, ASSIGN_OP)) let max_op_width = max([max_op_width, op_width+1]) |
一旦确定了赋值区域的位置和宽度,剩下的就是遍历范围中的行并相应地执行重新格式化。要执行重新格式化,函数将使用内置的 printf()
函数。这个函数十分有用,但是它的命名非常糟糕。它与 C、Perl 或 PHP 中的 printf
函数不同。实际上,它类似于以上这些语言中的 sprintf
函数。也就是说,在 Vimscript 中,printf
并不会输出其数据参数列表的格式化后的版本;它会返回一个字符串,其中包含了数据参数列表的格式化后的版本。
理想情况下,要重新格式化每一行,AlignAssignments()
将使用内置的 substitute()
函数,并使用经过 printf
重新整理后的文本替换运算符之前的所有内容。不幸的是,substitute()
要求使用固定的字符串作为它的替代值,而不是一个函数调用。
因此,要使用 printf()
来重新格式化每个替换文本,需要使用特殊的嵌入式替换形式:"\=expr"
。替换字符串中的前导 \=
要求 substitute()
对随后的表达式求值并使用结果作为替换文本。注意,这类似于 Insert 模式下的
机制,惟一不同的是这种奇妙的行为只针对内置 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 |
为了处理真实 Vim 编程任务的复杂性,需要将应用程序分解为正确的、可维护的组件,而函数是实现这个过程的基本工具。
Vimscript 允许您使用固定的或可变的参数列表来定义函数,并使它们通过自动的或用户控制的方式与编辑器的文本缓冲中的行范围进行交互。函数可以回调到 Vim 的内置特性(比如,回调到 search()
或 substitute()
文本),并且它们可以直接访问编辑器状态信息(比如通过 line('.')
确定游标所在的当前行)或者与当前进行编辑的任何文本缓冲进行交互(通过 getline()
和 setline()
)。
这无疑提供了非常强大的功能,但是通过编程的方式操作状态和内容始终受限于数据表示的整洁性和准确性,我们的代码将对这些数据进行处理。到目前为止,该 系列文章 一直关注单个标量函数(数值、字符串和布尔值)的使用。在接下来两篇文章中,我们将探讨更强大、更方便的数据结构的应用:有序列表和随机访问字典。