全部博文(65)
分类: LINUX
2010-06-04 18:11:54
探索 Vimscript 对列表和数组的支持
简介: Vimscript 为操作数据集提供出色的支持,该特性是编程的核心之一。在 本系列 的第三篇文章中,了解如何使用 Vimscript 的内置列表来简化日常操作,比如重新格式化列表、过滤文件名的序列和对行号集进行排序。您还将学习一些展示列表的威力的例子,它们扩展并改进了 Vim 的两个常见用途:创建用户定义函数来对齐赋值操作符;改进内置文本补全机制。
所有编程的核心都是创建和操作数据结构。到目前为止,我们在 本系列 中仅讨论了 Vimscript 的标量数据类型(字符串、数字和布尔值)和用于储存它们的标量变量。只有 Vim 脚本能够同时操作所有相关的数据集时,Vim 编程的威力才能得到体现:重新格式化文本行、访问配置数据的多维表、过滤文件名的序列和对行号集进行排序。
在本文中,我将探索 Vimscript 对列表和数组的出色支持,以及该语言的许多内置函数,这些函数让列表的使用更容易、高效和可维护。
在 Vimscript 中,列表是标量值的序列:字符串、数字、引用或它们之间的混合。
Vimscript 列表这样命名是有其理由的。在大多数语言中,“列表” 是一个值(而不是容器),一个由简单值组成的不可变序列。相反,Vimscript 中的列表的序列是可变的,并且在很多情况下类似于匿名数组数据结构。储存列表的 Vimscript 变量大多数情况下是一个数组。
您可以通过将一个以逗号分隔的标量值序列放在一对方括号中来创建列表。列表元素从 0 开始索引,并且通过常见的符号来访问和修改:使用其中包含索引的一对方括号:
|
您还可以使用小于 0 的索引,这些索引从列表的末尾往前算。因此,前面例子的最后一个语句可以这样写:
let data[-1] .= ' samurai' |
像在许多其他动态语言中一样,Vimscript 列表不需要显式的内存管理:它们自动伸缩以适应需要储存的元素,并且在应用程序不需要这些元素时自动进行垃圾回收。
除了储存字符串或数字之外,列表还能储存其他列表。像在 C、C++ 或 Perl 中一样,如果一个列表包含其他列表,它就类似于多维数组。例如:
|
在这里,单元格索引操作 (pow[x]
) 以 pow
的形式返回列表的元素之一。该元素本身也是一个列表,因此第二个索引操作 ([y]
) 返回嵌套列表的元素之一。
当您将一个变量赋值给任何列表时,您就为该列表分配了一个指针或引用。因此,从一个列表变量分配到另一个列表变量将导致它们同时指向或引用相同的底层列表。这通常导致我们不希望发生的同时作用现象:
|
为了避免别名效应,您需要调用内置的 copy()
函数来复制列表,然后改为对副本进行赋值:
|
不过要注意,copy()
仅复制列表的顶级。如果任何这些值本身是嵌套的列表,它就是指向其他独立的外部列表的指针/引用。对于这种情况,copy()
将复制该指针/引用,并且嵌套列表仍然被原始值和复制值所共享,如下所示:
|
如果这不是您想要的(通常发生这种情况),您可以使用内置的 deepcopy()
函数,它会一直复制任何嵌套的数据结构:
|
Vim 的大部分列表操作都是通过内置函数来提供的。这些函数通常接受一个列表并返回它的某些属性:
|
可以使用 range()
函数来生成一个整数列表。如果使用单整数参数调用该函数,它将生成一个范围为 0 至该参数之间的列表。如果使用两个参数调用该函数,它将生成一个范围为两个参数之间的系列列表。如果使用三个参数,还会生成一个系列列表,但是将以第三个参数作为增量递增每个元素:
|
您还可以通过将字符串拆分成 “单词” 序列来生成列表:
|
您还可以执行反向操作,将各个元素合并成原来的列表:
|
您可以在任何 Vim 会话中输入 :help function-list
来探索许多其他与列表相关的函数,然后向下滚动到 “列表操作
”。大部分这些函数实际上是过程,因为它们现场修改它们的列表参数。
例如,要将一个额外元素插入到列表中,您可以使用 insert()
或 add()
:
|
您可以使用 extend()
插入值列表:
|
或者从列表删除指定的元素:
|
或对列表进行排序
|
注意,所有与列表相关的过程也返回它们刚才修改的列表,因此您可能这样编写代码:
let sorted_list = reverse(sort(unsorted_list)) |
但是这样做通常是一个严重的错误,因为即使它们返回的值以这种方式使用,与列表相关的函数仍然修改它们的原始参数。因此,在前面的例子中,unsorted_list
中的列表还可能被进行排序或反转顺序。另外,unsorted_list
和 sorted_list
的别名可能使用相同的排序和反转列表(见 “列表赋值和别名”)。
这对大多数程序员而言是很不直观的,他们通常希望 sort
和 reverse
等函数返回原始数据的修改副本,而不改变原始数据本身。
Vimscript 列表并不是这样工作的,因此您需要培养良好的编程习惯,以避免这些槽糕的意外。这些习惯之一是永远只以纯函数的方式调用 sort()
和 reverse()
等函数,并且要经常传递需要修改的数据的副本。您可以使用内置的 copy()
函数来实现该目的:
let sorted_list = reverse(sort(copy(unsorted_list))) |
两个非常有用的过程列表函数是 filter()
和 map()
。filter()
函数接受一个列表作为参数并删除未能满足指定条件的元素:
let filtered_list = filter(copy(list), criterion_as_str) |
调用 filter()
将作为第二个参数传递的字符串转换成一段代码,然后将该代码应用到作为第一个参数传递的列表的每个元素。换句话说,它将对第二个参数反复执行 eval()
函数。对于每次计算,它都通过特殊的变量 v:val 将第一个参数的下一个元素传递给代码。如果已计算的代码的结果为 0(即 false ),那么将从列表删除对应的元素。
例如,要从列表中删除所有负数,请输入:
let positive_only = filter(copy(list_of_numbers), 'v:val >= 0') |
要从列表删除任何包含 /.*nix/
样式的名称,请输入:
let non_starnix = filter(copy(list_of_systems), 'v:val !~ ".*nix"') |
map()
函数类似于 filter()
,与后者不同的是它不是删除部分元素,而是使用用户指定的原始值转换替换每个元素。它的语法为:
let transformed_list = map(copy(list), transformation_as_str) |
和 filter()
一样,map()
计算作为第二个参数传递的字符串,并且通过 v:val
传递每个列表元素。不过,与 filter()
不同的是,map()
通常保留列表的每个元素,使用对每个值应用代码得到的结果替换这些值。
例如,要让列表中的每个数字增加 10,请输入:
let increased_numbers = map(copy(list_of_numbers), 'v:val + 10') |
或者大写列表中的每个单词:
let LIST_OF_WORDS = map(copy(list_of_words), 'toupper(v:val)') |
再次提醒一下,filter()
和 map()
当场修改它们的第一个参数。使用这两个函数经常发生的错误是这样编写代码:
let squared_values = map(values, 'v:val * v:val') |
正确的代码应该是这样:
let squared_values = map(copy(values), 'v:val * v:val') |
您可以使用 + 和 += 运算符合并列表,如下所示:
|
记住,运算符两边必须都是列表。不要将 += 误认为是 “append
”;您不能使用它来将单个值直接添加到列表的末尾:
|
您可以通过在索引操作的方括号中指定一个以逗号分隔的范围来提取列表的一部分。范围必须是常数、带数值的变量或任何数字表达式:
|
如果您省略开始索引,子列表将自动从 0 开始;如果您省略结尾索引,子列表将以最后的元素作为结尾。例如,要将一个列表分成相等(近似相等)的两部分,请输入:
|
通过例子能够很好地展示列表的威力和功能。让我们从改进一个现有的工具开始。
使用脚本编写 Vim 编辑器,第 2 部分:用户定义函数 探索了一个名为 AlignAssignments()
的用户定义函数,它以整齐的列对齐操作符。清单 19 再现了该函数。
|
这个函数的一个不足之处是它必须两次处理一个行:第一次(在第一个 for
循环)在段落的现有结构上收集信息,第二次(在最后一个 for
循环)调整每个行以适合新的结构。
这种重复的工作明显有待改善。将行储存在内部数据结构中然后直接重用它们会更好。了解您如何处理该列表,事实上您可以更加高效和干净地重写 AlignAssignments()
。清单 20 显示了修改后的函数,它利用了几个列表数据结构和前面描述的各种列表操作函数的优势。
|
注意,新函数中的前两个代码块与原来函数几乎相同。和原来一样,它们根据文本的当前缩进来定位需要对其的行的范围。
从第三个代码块开始发生变化,它使用内置的 getline()
函数的双参数形式来返回需要重新对齐的行的列表。
然后,for
循环遍历每个行,使用内置的 matchlist()
函数根据 ASSIGN_LINE
中的正则表达式匹配它:
let fields = matchlist(linetext, ASSIGN_LINE) |
调用 matchlist()
返回正则表达式捕捉到的所有字段的列表(即任何与 \(...\)
分隔符中的模式部分匹配的元素)。在这个例子中,如果匹配成功,生成的字段是一些片段,它们分隔任何对齐线的 lvalue
、操作符和 rvalue
。
尤其是,成功调用 matchlist()
将返回一个包含以下元素的列表:
matchlist()
通常返回所有匹配作为首个元素)对于这种情况,调用 add()
将最后 3 个字段的子列表添加到行列表。如果匹配失败(即该行不包含赋值项),那么 matchlist()
将返回一个空列表,从而使 add()
附加的子列表(下面的 fields[1:3]
)也为空。这用于表明不需要重新格式化的行:
call add(lines, fields[1:3]) |
第四个代码块部署 filter()
和
map()
函数来分析包含赋值项的每个行的结构。它首先使用一个 filter()
来过滤行列表,仅保留被前一个代码块成功分解成多个部分的行:
let op_lines = filter(copy(lines), '!empty(v:val)') |
接下来的函数确定每个赋值项的 lvalue
的长度,这通过将 strlen()
函数映射到已过滤的函数的副本上来实现:
map(copy(op_lines), 'strlen(v:val[0])') |
然后,生成的 lvalue
长度列表被传递给内置的 max()
函数来确定任何赋值项中的最长 lvalue
。最大长度决定所有赋值操作符都要根据其对齐的列(即超过最宽的 lvalue
的列):
let max_lval = max( map(copy(op_lines),'strlen(v:val[0])') ) + 1 |
同样,第四个代码块的最后一行决定容纳找到的各种赋值操作符所需的最大列数,它首先进行映射,然后最大化每个字符串的长度:
let max_op = max( map(copy(op_lines),'strlen(v:val[1])' ) ) |
最后的代码块同时遍历原来的缓存行号 (linenum
) 和行列表中的每个行,以重新格式化赋值行:
let linenum = firstline |
该循环的每次遍历检查是否需要重新格式化某个行(即它是否围绕赋值操作成功分解)。如果这样,该函数将修改该行,使用 printf()
来重新格式化行的元素:
if !empty(line) |
然后通过调用 setline()
将新的行写回到编辑器的缓存中,并且更新行跟踪以准备下一次遍历:
call setline(linenum, newline) |
当所有行处理完成之后,将完全更新缓存,并且所有相关的赋值操作符都对齐到一个合适的列。因为它能够利用 Vimscript 对列表和列表操作的出色支持,修改后的 AlignAssignments()
比原来缩短了大约 15%。不过,更重要的是该函数的缓存访问量只有原来的三分之一,并且代码更加简洁,更加易于维护。
Vim 有一个先进的内置文本补全机制,您可以在任何 Vim 会话中输入 :help ins-completion
了解它。
最常用的补全模式之一是 关键字补全。您可以在插入文本时通过按下 CTRL-N 使用它。按下该组合键之后,将搜索各个不同的位置(由 “complete
” 选项指定),查找与光标之前的字符开头的任何单词。默认情况下查找当前编辑的缓存、同一个会话中编辑的其他缓存、装载的任何标记文件,以及从文本包含的任何文件(使用 include
选项包含文本的文件)。
例如,如果在缓存中有两个段落,那么 —— 在插入模式下 —— 您将输入:
My use of Vim is increasingly so |
Vim 文本并确定以 "so..." 开头的唯一单词为 sophisticated,然后立即补全该单词:
My use of Vim is increasingly sophisticated_ |
另一方面,如果您输入:
My repertoire of editing skills is bu |
Vim 将检查到 3 个可能的补全:built、buffer 和 buffers。默认情况下,将显示一个候选菜单:
|
然后,您可以分别使用 CTRL-N 和 CTRL-P(或上下箭头)来在菜单的候选项上移动并选择您想要的单词。
要取消自动补全,输入 CTRL-E;要接受并插入当前选择的候选项,输入 CTRL-Y。输入任何键(通常是空格键或回车键)也将接受并插入当前选择的单词。
毫无疑问,Vim 的内置补全机制是极其有用的,但它还不是很智能。默认情况下,它仅匹配 “关键字” 字符序列(数字、字母和下划线),除了与光标左边进行匹配之外,它对上下文没有深刻的理解。
此外,补全机制还不是非常符合人体力学原理。CTRL-N 不是最容易输入的组合键,也不是程序员经常按的键。大部分命令行用户更加习惯使用 TAB 或 ESC 作为补全键。
令人高兴的是,我们能够轻松修改这些不便之处。让我们在插入模式下重新定义 TAB 键,从而使它能够在光标的任意一边识别文本中的模式,并为上下文选择适合的补全。我们还进行了这样的设置,如果新的机制不能识别当前的插入上下文,它将切换到 Vim 的内置 CTRL-N 补全机制。在设置完成之后,我们还要确保能够使用 TAB 键来输入制表符。
要构建这个更加智能的补全机制,我们需要储存为一个补全请求储存一系列 “上下文响应”。或者是列表的列表,假定每个上下文响应本身有 4 个元素组成。清单 22 显示如何设置该数据结构。
|
我们创建的列表的列表将用作上下文响应规范表,并且储存在列表变量 s:completions
中。列表中的每个条目本身就是一个包含 4 个值的列表:
为了填充该表,我们创建了一个小函数:AddCompletion()
。该函数接受 4 个参数:上下文的左边和右边、用于替换的文本和 “restore cursor
” 标记。这些参数被放到一个列表中:
[a:left, a:right, a:completion, a:restore] |
然后,使用 insert()
函数将该列表附加到 s:completions
变量的后面:
call insert(s:completions, [a:left, a:right, a:completion, a:restore]) |
因此重复调用 AddCompletion()
将创建一个由列表组成的列表,每个列表都指定一种补全。清单 22 中的代码用于实现该目的。
首次调用 AddCompletion()
:
" Left Right Complete with... Restore |
指定当新的机制在光标的左边遇到花括号时,它应该插入一个表示结束的花括号,然后将光标恢复到补全之前的位置。即在补全时:
while (1) {_ |
(where the _ represents the cursor), the mechanism will now produce:
while (1) {_} |
让光标留在一对花括号的中间,从而为输入提供方便。
第二次调用 AddCompletion()
:
" Left Right Complete with... Restore |
然后仍然让补全机制保持智能。它指定该机制在光标的左边和右边分别遇到左花括号和右花括号时,它应该插入一个新行,减少缩进右花括号(通过 CTRL-D),然后退出插入模式(ESC)并在右花括号上面打开一个新行(O
)。
假设启用了 “smartindent
” 选项,该序列的效果是在下面的上下文中按下 TAB 时
while (1) {_} |
该机制将生成:
while (1) { |
换句话说,由于向补全表添加了两个项,在左花括号之后的 TAB 补全在相同的行上关闭,然后立即执行第二个 TAB 补全,将区块 “扩展” 到几个行(使用正确的缩进)。
剩余的 AddCompletion()
调用为 3 个其他括号(方括号,圆括号和尖括号)重复该排列,并且为单引号和双引号提供特别的补全语义。在双引号之后补全将添加匹配的双引号,而在两个双引号之间补全将添加一个 \n
(新行)元字符。在单引号之后补全将添加匹配的单引号,而第二次补全尝试将不添加任何东西。
一旦设立了补全规范之后,剩下的任务就是实现一个功能来从表选择适当的补全机制,然后将该函数绑定到 TAB 键。清单 23 显示了该代码。
|
SmartComplete()
函数首先使用内置的 getpos()
函数和一个 '.'
参数(即 “获取光标的位置”)来定位光标。该调用返回一个包含 4 个元素的列表:缓存号(通常为 0)、行号和列号(都从 1
开始索引),以及一个特殊的 “虚拟偏移量”(通常也为 0,但在这里不相关)。我们主要对中间的两个元素感兴趣,因为它们表明光标的位置。尤其是,SmartComplete()
需要列号,它通过在 getpos()
返回的列表中建立索引来提取,如下所示:
let cursorcol = cursorpos[2] |
该函数还需要知道当前行上的文本,可以使用 getline()
来获取存储在 curr_line
中的当前行。
SmartComplete()
将把 s:completions
表中的每个条目转换成根据当前行进行匹配的模式。为了正确地在光标周围匹配左边和右边上下文,要确保该模式仅在光标所在的列进行匹配。Vim 有一个专门的子模式来实现该功能:\%Nc
(其中 N
是所需的列号)。因此,该函数通过插入前面找到的光标的列位置来创建子模式:
let curr_pos_pat = '\%' . cursorcol . 'c' |
因为我们最终将该函数绑定到 TAB 键,所以我们希望该函数仍然在可能的时候插入一个 TAB,尤其是在行的开头。因此 SmartComplete()
首先检查光标的左边是否存在空格,如果存在将返回一个简单的制表符:
if curr_line =~ '^\s*' . curr_pos_pat |
如果光标不在行的开头,那么 SmartComplete()
需要检查补全表中的所有条目,以确定是否存在适用的条目。部分这些条目将指定光标必须在补全之后返回到初始位置,这将要求从插入模式执行一个定制命令。该命令仅调用内置的 setpos()
函数,将原始的值信息从先前的调用传导到 getpos()
。要从插入模式执行该函数需要一个 CTRL-O 转义(在任何 Vim 会话中查看 :help i_CTRL-O
)。因此 SmartComplete()
将必要的 CTRL-O 命令预构建为一个字符串并存储在 cursor_back
中:
let cursor_back = "\ |
为了遍历补全列表,该函数使用一个特殊版本的 for
语句。Vimscript 中的标准 for
循环遍历一维的列表,一次遍历一个元素:
|
不过,如果列表是二维的(即每个元素本身是一个列表),那么您通常想要在遍历时 “解开” 每个嵌套列表的内容。为此,您可以这样做:
|
但是 Vimscript 提供更加简洁的办法:
|
循环在每次遍历时从 list_of_lists
获取下一个嵌套列表,并将该嵌套列表的第一个元素赋值给 name
,将第二个嵌套元素赋值给 rank
,将第三个嵌套元素赋值给 serial
。
使用这个特殊版本的 for
循环让 SmartComplete()
遍历补全表更加容易,并且为每个补全的各个组件提供一个逻辑名:
for [left, right, completion, restore] in s:completions |
在该循环内部,SmartComplete()
通过将左右上下文模式放到与光标匹配的子模式周围构造一个正则表达式:
let pattern = left . curr_pos_pat . right |
如果当前的行匹配生成的正则表达式,那么该函数就找到正确的补全(它的文本已经在补全中)并且能够立即返回它。当然,它还需要附加先前构建的光标复位命令,如果选择的补全要求这样做的话(即 restore
为 true)。
不幸的是,基于 setpos()
的光标复位命令有一个问题。在 Vim 版本 7.2 或更早的版本中,setpos()
有一个怪异的行为:如果光标原先在行尾并且补全文本只有一个字符,它就不会在插入模式下正确地复位光标。对于这种特殊情况,必须将复位命令更改为左箭头,它将光标移动回到新插入的字符之前。
因此在返回选择的补全之前,需要添加以下代码:
|
还需要做的就是返回选择的补全,在请求光标复位时添加 cursor_back
命令:
return completion . (restore ? cursor_back : "") |
如果这些来自补全表的条目与当前的上下文不匹配,SmartComplete()
将退出 for
循环,然后尝试剩余的其他两个选择。如果在光标前面的字符是 “关键字”,它将通过返回一个 CTRL-N 调用正常的关键字补全:
|
否则其他补全将不可行,因此它通过返回一个制表符来充当正常的 TAB 键:
|
现在我们需要让 TAB 键调用 SmartComplete()
,以了解它将插入什么内容。这可以通过一个 inoremap
来完成:
inoremap |
键映射将任何插入模式 TAB 转换成一个 CTRL-R=,调用 SmartComplete()
并插入它返回的补全字符串(参见 :help
i_CTRL-R
或 使用脚本编写 Vim 编辑器,第 1 部分:变量、值和表达式 了解该机制的细节)。
在这里使用 imap
的 inoremap
形式,因为 SmartComplete()
返回的一些补全字符串还包含 TAB 字符。如果使用常规的 imap
,插入返回的 TAB 将立即导致重新调用相同的键映射,再次调用 SmartComplete()
,这将返回另一个 TAB,等等。
使用 inoremap
得到的 TAB 键能够:
此外,将清单 22 和清单 23 放到 .vimrc 文件之后,您就可以通过额外调用 AddCompletion()
来扩展补全表,从而方便地添加新的上下文补全。例如,使用下面的代码能够更轻松地开始新的 Vimscript 函数:
call AddCompletion( 'function!\?', "", "\ |
这使得在函数关键字之后立即使用制表符会将对应的 endfunction
关键字附加到下一行。
或者,您可以巧妙地自动补全 C/C++ 注释(假设设置了 cindent
选项):
call AddCompletion( '/\*', "", '*/', 1 ) |
这使得:
/*_ |
将一个结束注释分隔符添加到光标之后:
/*_*/ |
并且在该位置的第二个 TAB 插入一个美观的多行注释,并将光标移动到注释中间:
/* |
储存和操作数据列表的能力使 Vimscript 能够轻松处理更多任务,但是列表通常不是收集和储存信息的理想解决办法。例如,清单 20 中修改后的 AlignAssignments()
包含一个如下所示的 printf()
调用:
printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2]) |
对代码行的各个元素使用 line[0]
、line[1]
和 line[2]
显然不利于阅读,这样不仅在最初实现时容易出错,而且让日后的维护变得困难。
这是一种常见的情形:相关的数据需要收集在一起,但它们没有内在的或有意义的顺序。对于这种情况,使用逻辑名对每条数据进行标识要比使用数字索引好。当然,我们通常可以创建一组变量来 “命名” 各个数字常量:
let LVAL = 0 |
但这是一种笨拙的解决办法,如果在行列表中改变了各个元素的顺序,将导致难以发现的错误,并且没有适当地更新变量。
由于命名数据集合在编程中是一种常见的需求,所以大部分动态语言中都通过一个通用的结构来提供它们:关联数组,或散列表,或字典。当然,Vim 也包含字典。在本系列的下一篇文章中,我们将探索 Vimscript 如何实现这个非常有用的数据结构。
学习
获得产品和技术
讨论
Damian Conway 是澳大利亚 Monash 大学计算机科学系的兼职副教授,并且是 的 CEO,这是一家国际性的 IT 培训公司。他是一位爱好 vi 的用户,拥有超过 25 年的经验。从目前看来,他似乎难以摆脱对 vi 的痴迷。