4.1 再谈列表与字符串

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

在第 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 的值赋给它。显然,现在 xy 的值都为 1。随后我们改变 y 的值,重 赋为 2,再查看两个变量的值,发现只有变量 y 的值改变了,x 的值是没改变的 。因此,即使在创建 y 变量时用 :let y = x 看似将它与 x 关联了,但这两个变 量终究是两个独立不同的变量,唯一有关联的也不外是 y 初始化时获取了 x 的值。 此后这两个变量分道扬镳,可分别独立地改变运作。对于字符串变量 ab,也是 这个过程。

然后再看看列表变量:

: 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'] 相当于分以下两步执行工作:

  1. new 列表实体 = ['a', 'aa', 'aaa']
  2. 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:iValuea:jValue 赋另外的值。但是,即使参数不是只读的 ,这样的交换函数也是没效果的(比如用 C 或 python 改写这个交换函数)。因为在调 用 Swap(a, b) 时,相当于先执行以下两个赋值语句给参数赋值:

: let a:iValue = a
: let a:jValue = b

此外,不管在函数内不管怎么倒腾参数 a:iValueb:jValue,都不会影响原来的 ab 变量。因为如前所述,标量赋值,只是拷贝了值,等号两边的变量是再无联 系的。

但是,交换列表不同位置上的元素是可实现的,比如把上面那个交换函数改成三参数版, 第一个参数是列表,跟着两个索引:

: 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:listl: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 不过是一种 特殊模式,它可以匹配任意字符之间(详情请参考正则表达式文档),所以结果就是将每 个字符分隔到列表中了。