第四章 VimL 数据结构进阶
在第 2.1 章已经介绍了 VimL 的变量与类型的基本概念。本章将对变量类型所指代的数 据结构作进一步的讨论。
4.1 再谈列表与字符串
引用与实体
前文讲到,列表作为一种集合变量,与标量变量(数字或字符串)有着本质的区别。其中 首要理解的就是一个列表变量只是某个列表实体的引用。
直接用示例说话吧,先看数字变量与字符串变量的平凡例子:
: let x = 1
: let y = x
: echo 'x:' x 'y:' y
: let y = 2
: echo 'x:' x 'y:' y
:
: let a = 'aa'
: let b = a
: echo 'a:' a 'b:' b
: let b = 'bb'
: echo 'a:' a 'b:' b
我们先创建了一个数字变量 x
,并为其赋值为 1
,然后再创建一个变量 y
,并为 x
的值赋给它。显然,现在 x
与 y
的值都为 1
。随后我们改变 y
的值,重 赋为 2
,再查看两个变量的值,发现只有变量 y
的值改变了,x
的值是没改变的 。因此,即使在创建 y
变量时用 :let y = x
看似将它与 x
关联了,但这两个变 量终究是两个独立不同的变量,唯一有关联的也不外是 y
初始化时获取了 x
的值。 此后这两个变量分道扬镳,可分别独立地改变运作。对于字符串变量 a
与 b
,也是 这个过程。
然后再看看列表变量:
: let aList = ['a', 'aa', 'aaa']
: let bList = aList
: echo 'aList:' aList 'bList:' bList
: let bList = ['b', 'bb', 'bbb']
: echo 'aList:' aList 'bList:' bList
结果似乎与上面的数字或字符中标题很相似,没什么差别嘛。虽然 bList
一开始与 aList
表示同一个变量,但后来给 bList
重新定义了一个列表,也没有改变原来的 aList
列表。这与字符串 a
b
的关系很一致呢。
但是,我们重新看下面这个例子:
: unlet! aList bList
: let aList = ['a', 'aa', 'aaa']
: let bList = aList
: echo 'aList:' aList 'bList:' bList
: let bList[0] = 'b'
: echo 'aList:' aList 'bList:' bList
这里先把原来的 aList
bList
变量删除了,以免上例的影响。仍然创建了列量变量 aList
,与 bList
并让它们“相等”。然后我们通过 bList
变量将列表的第一项 [0]
改成另一个值 b
,再查看两个列表的值。这时发现 aList
列表也改变了,与 bList
作出了同样的改变,两者仍是“相等”。
通过这组试验,想说明的是,当 VimL 创建一个列表(变量)时,它其实是在内部维护了 一个列表实体,然后这个变量只是这个列表实体的引用。命令 :let aList = ['a', 'aa', 'aaa']
相当于分以下两步执行工作:
- new 列表实体 = ['a', 'aa', 'aaa']
- let aList = 列表实体的引用
然后命令 :let bList = aList
,它只是将 aList
变量对其列表实体的引用再赋值给 变量 bList
,结果就是,这两个变量都引用了同一个列表实体,或说指向了同一个列表 实体。而命令 :let bList[0] = 'b'
则表示通过变量 bList
修改了它所引用的列表 的第一个元素。但变量 aList
也引用这个列表实体,所以再次查看 aList
时,发现 它的第一个元素也变成 'b'
了。实际上,不管是对 aList
还是 bList
进行索引 操作,都是对同一个它们所引用的那个列表实体进行操作,那是无差别的。
对于普通标量变量,则是另一种情况。当执行命令 :let b = a
时,变量 b
就已经 与 a
是无关的两个独立变量,它只是将 a
的值取出来并赋给 b
而已。但 :let bList = aList
是将它们指向同一个列表实体,在用户使用层面上,可以认为它 们是同一个东西。但是当执行 :let bList = ['b', 'bb', 'bbb']
后,变量 bList
就指向另一个列表实体了,它与 aList
就再无联系了。
可见,当对列表变量 bList
进行整体赋值时,就改变了该变量所代表的意义。这时与 对字符串变量 b
整体赋值是一样的意义。然而,标量始终只能当作一个完整独立的值 使用,它再无内部结构。例如,无法使用 let b[0] = 'c'
来改变字符串的第一个字符 ,只能将另一个字符串整体赋给 b
而达到改变 b
的目的。
总结,只要牢记以下两条准则:
- 标量变量保存的是值;
- 列表变量保存的是引用。
函数参数与引用
我们再通过函数调用参数来进一步说明列表的引用特性。
举个简单的例子,交换两个值,可以引入一个临时变量,由三条语句完成:
: let tmp = a
: let a = b
: let b = tmp
这种交换值的需求挺常见的,考虑包装成一个函数如何?
: function! Swap(iValue, jValue) abort
: let l:tmp = a:iValue
: let a:iValue = a:jValue
: let a:jValue = l:tmp
: endfunction
但是,当尝试调用 :call Swap(a, b)
时,vim 报错了。因为参数作用域 a:
是只读 变量,所以不能给 a:iValue
或 a:jValue
赋另外的值。但是,即使参数不是只读的 ,这样的交换函数也是没效果的(比如用 C 或 python 改写这个交换函数)。因为在调 用 Swap(a, b)
时,相当于先执行以下两个赋值语句给参数赋值:
: let a:iValue = a
: let a:jValue = b
此外,不管在函数内不管怎么倒腾参数 a:iValue
与 b:jValue
,都不会影响原来的 a
与 b
变量。因为如前所述,标量赋值,只是拷贝了值,等号两边的变量是再无联 系的。
但是,交换列表不同位置上的元素是可实现的,比如把上面那个交换函数改成三参数版, 第一个参数是列表,跟着两个索引:
: function! Swap(list, idx, jdx) abort
: let l:tmp = a:list[a:idx]
: let a:list[a:idx] = a:list[a:jdx]
: let a:list[a:jdx] = l:tmp
: endfunction
请试运行以下语句确认这个函数的有效性:
: echo aList
: call Swap(aList, 0, 1)
: echo aList
在写较复杂的 VimL 函数时,一般不建议在函数体内大量使用 a:
作用域参数。因为传 入的参数是无类型的,很可能是不安全的。最好在函数的开始作一些检查,合法后再将 a:
参数赋给一个 l:
变量,然后在函数主体中只对该局部变量操作。此后,如果能 参数的假设需求有变动,就只在修改函数前面几行就可以了。例如再将交换函数改成如下 版本:
: function! Swap(list, idx, jdx) abort
: if type(a:list) == v:t_list || type(a:list) == v:t_dict
: let list = a:list
: else
: return " 只允许第一参数为列表或字典
: endif
:
: let i = a:idx + 0 " 显式转为数字
: let j = a:jdx + 0
:
: let l:tmp = list[i]
: let list[i] = list[j]
: let list[j] = l:tmp
: endfunction
再用以下语句来测试修改版的交换函数:
: call Swap(aList, 1, 2)
: echo aList
可见,即使在函数体内,将参数 a:list
赋给另一个局部变量 l:list
,交换工作也 正常运行。因为 g:aList
a:list
与 l:list
其实都是同一个列表实体的引用啊。
列表解包
在 3.4 节我们用 execute
定义了一个 :LET
命令,用于实现连等号赋值。但实际上 可以直接用列表赋值的办法实现类似的效果。例如:
: LET x=y=z=1
: let [x, y, z] = [1, 1, 1]
: let [x, y, z] = [1, 2, 3]
其中前两个语句的结果完全一样,都是为 x
y
z
三个变量赋值为 1
。注意等号 左边也需要用中括号把待赋值变量括起来,分别用等号右侧的列表元素赋值。这种行为就 叫做列表解包(List unpack),即相当于把列表元素提取出来放在独立的变量中。显然 用这种方法为多个变量赋值更具灵活性,可以为不同变量赋不同的值。
这个语法除了可多重赋值外,还能方便地实现变量交换,如:
: let [x, y] = [y, x]
用过 python 的对此用途应该很有亲切感。不过在 VimL 中,等号两边的中括号不可省略 ,且等号两边的列表元素个数必需相同,否则会出错。不过在左值列表中可以用分号分隔 最后一个变量,用于接收右值列表的剩余元素,如:
: let [v1, v2; rest] = list
" 相当于
: let v1 = list[0]
: let v2 = list[1]
: let rest = list[2:]
在上例中假设 list
列表元素只包含简单标量,则解包赋值后,v1
v2
都是只接收 了一个元素值的标量,而 rest
则接收了剩余元素,它还是个(稍短的)列表变量。而 list[2:]
的语法是列表切片(slice)。
索引与切片
这里再归纳一下列表的索引用法:
- 索引从 0 开始,不是从 1 开始。
- 可以使用负索引,-1 表示最后一个索引。
- 可以使用多个索引,这也叫切片,表示列表的一部分。
要索引一个列表元素时,用正索引或负索引等效的,这取决于应用场合用哪个方便。如果 列表长度是 n
,则以下表示法等效:
list[n-1] == list[-1]
list[0] == list[-n]
list[i] == list[i-n]
然而,不管正索引,还是负索引,都不能超出列表索引(长度)范围。
列表切片(slice)是指用两个索引提取一段子列表。list[i:j]
表示从索引 i
到索 引 j
之间(包含两端)的元素组成的子列表。注意以下几点:
i
j
同样支持负索引,不管用正负索引,如果i
索引在j
索引之后,则切片 结果是空列表。- 如果
i
超出了列表左端(0
或-n
),或j
超出列表右端,结果也是空列表 。 - 可省略起始索引
i
,则默认起索引为0
;省略结束索引j
,则默认是最后一个索 引-1
;如果都省略,只剩一个冒号,list[:]
与原列表list
是一样的(但是 另一个拷贝列表)。 - 可以为切片赋值,即将一个列表的切片放在等号左边作为左值,可改变索引范围内的元 素值,但一般右值要求是与切片具有相同项数的列表。
- 不支持三索引表示步长,
list[i:j:step]
或list[i:j:step]
在 VimL 中是非法 的,不支持跳格切片,只支持连续切片。 list[s:e]
表示法有歧义,因为可能存在脚本局部变量s:e
,则用该变量值单索引 列表。可在冒号前后加空格避免歧义,list[s : e]
表示切片。
处理列表的内置函数
VimL 提供了一些基本的内置函数用于列表的常用操作,详细用法请参考文档 :help list-functions
,这里仅归纳概要。
查询列表信息的函数:
- len(list) 取列表长度,列表的最大索引是 len(list)-1。
- empty(list) 判断列表是否为空,即列表长度为 0。
- get(list, i) 相当于 list[i],但是当 i 超出索引范围时,get() 函数不会出错,且 可再提供第三参数表示超出索引时的默认值(如果省略,默认值0)。
- index(list, item) 查找一个元素在列表中的位置,如果不存在该元素,则返回 -1。
- count(list, item) 检查一个元素在列表中出现多少次。
- max(list) min(list) 查询一个列表中的最大或最小元素。
- string(list) 将列表转化为字符串表示法。
- join(list, sep) 将列表中的元素用指定分隔符连接为一个字符串表示。
修改列表元素的函数:
- add(list, item) 在列表末尾添加一个元素。
- insert(list, item) 在列表头部添加一个元素,比 add() 尾添加低效。但 insert() 可额外提供第三参数表示要插入的索引位置,省略即 0 表示插在最前面。
- remove(list, idx) 删除位置 idx 上的一个元素,remove(list, i, j) 删除从 i 到 j 索引之间的所有无素,相当于 unlet list[i:j]。
生成列表的函数:
- range() 支持一至三个参数,生成连续或定步长的数字列表。
- extend(list1, list2) 连接两个列表,相当于 list1+list2,但 extend 会原位修改 list1 列表。与 add() 函数不同的是,add 只增加一个元素,而 extend 是加入另一 个列表。
- repeat(list, count) 相当于不断连接自身,总计重复 count 次,生成一个更长的列 表。
- copy(list),生成一个列表副本,用等号赋值只是引用同一个列表实体,用 copy() 函 数才能生成另一个新列表(每个元素值与原列表相同而已)。copy() 函数是浅拷贝, 列表元素直接赋值。如果要考虑列表元素也可能是列表或字典(引用),则用 deepcopy(list) 递归拷贝完全的副本。
- reverse(list) 将一个列表倒序排列,原位修改原列表。
- split(list, pattern),将一个字符串分解为列表,相当于 join() 的反函数。
分析列表的高阶函数:
- sort(list) 为一个列表排序。
- uniq(list) 删除列表中相邻的重复元素,列表需已排序。
- map(list, expr) 将列表每个元素进行某种运算,将结果替换原元素。
- filter(list, expr) 将列表每个元素进行某种运算,若结果为 0,则删除相应元素。
这些高阶函数,除了都会原位修改作为第一个参数的列表外,都还能接收额外参数表明如 何处理每个元素。由于额外参数可以是另一个函数(引用),所以称之为高阶函数。其具 体用法略复杂,在后面相关章节将继续讲解部分示例。
字符串与列表的关系
字符串在很大程序上可以理解为字符列表,可以用类似的索引与切片机制。但是,字符串 与列表的最大区别在于,字符串是一个完整的不可变标量。所以,凡是可以改变列表内部 某个元素的操作(如索引赋值、切片赋值)或函数(如 add/remove 等),都不可作用于 字符串。而 copy() 也没必要用于字符串,直接用等号赋值即可。不过 repeat() 函数作 用于字符串很有用,能方便生成长字符串。
将字符串打散为字符数组,可用如下函数方法:
: let string = 'abcdefg'
: let list = split(string, '\zs')
: echo list
split(string, pattern) 函数是将字符串按某种模式分隔成列表的。\zs
不过是一种 特殊模式,它可以匹配任意字符之间(详情请参考正则表达式文档),所以结果就是将每 个字符分隔到列表中了。