6.1 操作数据类型

第六章 VimL 内建函数使用 #

一般实用的语言包括语法与标准库,毕竟写程序不能完全从零开始,须站在他人的基石之 上。而要开发更有产品价值的程序,更要站在巨人的肩膀上,比如社区提供的第三方库。

细思起来,VimL 语言的“标准库”包括两大类:内建命令与内建函数。用户在此基础上可 自定义命令与自定义函数,再合乎语法地组成起来,以达成所需的功能。第三章简要地介 绍了部分基础命令,其实那更倾向于 Vim 编辑器的功能。本章要介绍的内建函数,则更 倾向于 VimL 语言的功能。

不过本章将会是比较无聊的一章。帮助文档 :help function-list 会按类别列出内置 函数,:help functions 则会按字母序列出内置函数,可供参考。中文用户可找一份帮 助文档的中译本,虽然可能不是最新版本的,不过绝大部分内置函数都应该是稳定向下兼 容的。

所以本章不会(也没必要)罗列所有内建函数,只择要讲些内建函数的使用经验技法。要 查看某个函数的解释,请直接 :help func_name() ,请注意加一对括号,限定查函数 的文档,否则有可能查到的是同名的命令或选项等。

6.1 操作数据类型 #

字符串运算 #

Vim 是文本编辑器,所以处理文本字符串是一重点任务。每个字符在计算机内部都用一个 整数表示(即编码值,与用数字字符组成表示的可读整数不同概念),具体如何对应取决 于编码系统(有时简称编码)。目前计算机界的趋势是用 utf-8 编码表示 Unicode 字 符集,因为它与最早的 ASCII 编码兼容。一个汉字在此编码下用 3 个字节表示,英文字 符仍用一个字节表示。

  • nr2char() 将编码值转为字符
  • char2nr() 将字符转为编码值
  • str2nr() 将字符串转为整数
  • str2float() 将字符串转为浮点数

由于 VimL 没有字符串类型,char2nr() 其实是将字符串首字符将为编码值的。默认按 utf-8 编码获取编码值与字符的对应,但可传入额外参数按其他编码系统对应。例如:

: echo char2nr('中国') |" --> 20013
: echo nr2char(20013)  |" --> 

整数类型与字符串一般可自动转换,一般用不上 str2nr() 。但如果从安全考虑习惯主 动判断类型的话,要注意从命令行输入的参数都是字符串,不是整数。此外,字符串不会 自动转为浮点数,而是截断为整数,所以确实要处理浮点数是,用 str2float() 转换 。

  • printf() 格式化字符串

简单的字符串连接用连接操作符 . 即可。要组装复杂字符串时可用 printf() 函数 ,通过字符串模式与 % 占位符,插入变量。其用法与 C 语言的 sprintf() 类似, 因为该函数会返回结果字符串,“打印”字符串用 :echo 命令。

  • escape() 将字符串中指定的字符用反斜杠 \ 转义
  • shellescape() 转义特殊字符以适于 shell 命令
  • fnameescape() 转义特殊字符以短于 Vim 命令,主要用于转义文件名参数

当组装字符串用于当作命令执行时,为安全起见,应先调用 shellescape()fnameescape() 进行转义。这两个函数自有其转义策略,适用大部分情况。当有特殊需 求时,可用 escape() 指定要转义哪些字符(传入第二参数的字符串中出现的所有字符 都表示要转义的)。

  • tolower() 将字符中转为小写
  • toupper() 将字符串转为大写
  • tr() 按一一对应的方式转换字符串
  • strtrans() 将字符串转换为可打印字符串

tr() 函数进行简单的字符串转换(不是正则替换),效果如同 unix 工具 tr。大小 写转换是其一种特例策略,如转大写相当于 tr(str, 'abcdefg...', 'ABCDEFG...')。 而 strtrans() 是按 vim 的自定策略将不可打印字符转换为可视字符(组合,一般以 ^ 开关)表示。

  • strlen() 按字节数获取字符串长度
  • strchars() 按字符数获取字符串长度,当含宽字符(如汉字时)与字节长度有差异
  • strwidth() 字符串宽度,显示在屏幕上时将占用的列宽度,但未处理制表符
  • strdisplaywidth() 字符串实际显示宽度,并按设置处理制表符宽度
  • byteidx() 第几个字符的字节索引,不单独处理组合字符
  • byteidxcomp() 也是字符索引转为字节索引,组合字符单独处理

字符串可像列表一样用中括号索引,那是按字节索引的。当字符串中存在宽字符时,字符 数与字节数不一致,这就需要处理字符索引与字节索引的不同。请仔细观察以下示例:

: let str = 'vim 中国'
: echo strlen(str)    |" --> 10
: echo strchars(str)  |" --> 6vim 加空格加两汉字
: echo len(str)       |" --> 10
: echo strwidth(str)  |" --> 8每个汉字三字节但两宽度
: echo str[0:2]       |" --> vim
: echo str[4]         |" --> <e4>(中字的第一个字节
: echo str[4:5]       |" --> <e4><b8>
: echo str[4:6]       |" --> 

组合字符(有的书籍叫重音字符),中国人一般不必关注,欧洲人才用得到。比如 é 是通过一个正常的 e 字母加上重音符组成而成的('e' . nr2char(0x301)),显示 上像是一个字符,但计算机要用两个字符表示。至于算一个字符还是两个字符,似乎都有 理有用,所以就提供了不同的函数或可选参数来处理这种情况。这与汉字宽字符的情况不 一样。汉字的三个字节是不可分的,取第一字节是无效字符。但组合字符的第一字节仍是 个有效字符(字母)。

字符与编码看似简单,如同空气与水一样简单,但深入细处还挺复杂。所以建议初学者不 必深究,始终用英文文本示例测试学习即可。当实际工作中遇到中文问题时再回头查阅。 此外,据说早期的中国程序员常要念经“一个汉字等于两个字节”,那是用 GB 编码的原因 ,现在请升级经文“一个汉字等于三个字节”。

  • stridx() 查找一个短字符串在另一个长字符串第一次出现的起始索引
  • strridx() 查找一个短字符串在另一个长字符串最后一次出现的起始索引
  • strpart() 截取字符串从某个索引开始的定长子串

查找简单子串存在情况可用 stridx() ,要求精确匹配,且大小写敏感。返回的结果索 引是字节索引,索引从 0 开始,若不存在子串返回 -1。截取子串可用中括号索引切片方 式,如上例 str[4:6],参数是起始索引与终止索引(含双端)。而 strpart() 的参 数是起始索引与长度,如上例等效于 strpart(str, 4, 3)

  • match() 查找一个正则表达式在字符串出现的起始索引,不匹配时返回 -1
  • matchend() 查找一个正则表达式在字符串出现的终止索引
  • matchstr() 返回字符串中匹配正则表达式的部分,不匹配时返回空串
  • matchlist() 将正则匹配结果按分组返回至列表中,不匹配时返回空列表
  • substitute() 正则表达式替换,:s 命令的函数式
  • submatch() 获取正则匹配的分组子串,只可用于 :s 命令的替换部分

这几个函数用于处理正式表达式的匹配查找与替换。如果仅是要判断是否匹配,可直接用 操作符 if str =~# patternmatch() 函数主要是还能返回匹配成功的起始索引, 相应地 matchend() 返回的是终止索引。matchstr() 返回的是匹配到的整个子串。 如果正则表达式中有括号分组 \(\),最好用 matchlist() 函数,它返回的列表中, 第一个元素([0])就是匹配到的整个子串,其后是按顺序的分组子串。其中的关系可 用如下伪代码表示:

let s = some_string
let p = search_pattern
if some_string =~# search_pattern
    let sidx = match(s, p)
    let eidx = match(s, p)
    sidx != -1; eidx != -1
    s[sidx:eidx] == mathcstr(s, p)
    let slist = matchlist(s, p)
    slist[0] == matchstr(s, p) == & == submatch(0)
    slist[1] == \1 == submatch(1)
    slist[2] == \2 == submatch(2)
    ...
endif

替换函数 substitute() 的参数及意义与 :substitute 命令的几个部分完全一样。 不过命令可以缩写为 :s,函数不可以缩写。如以下两个语句功能类似:

: s/pat/sub/flag |" 对当前行替换 pat  sub
: call substitute(line('.'), pat, sub, flag)

在替换部分 {sub} 可用表达式,以 \= 开始即可,如此 submatch() 表示前面 {pat} 部分的分组子串。而在以常规字面字符串表示 {sub} 部分时,则用 \1 表 示分组子串。

  • string() 将其他任意变量或表达式转为字符串表达,类似 :echo 的显示
  • expand() 将具有特殊意义的标记(如 % # <cword> 等)展开
  • iconv() 转换字符串编码
  • repeat() 将字符串重复串接多次生成长字符串
  • eval() 将字符串当作表达式来执行,并返回结果
  • execute() 将字符串当作命令来执行,将结果返回为字符串

注意,eval()execute() 很灵活,但比较低效,也可能有一定风险。如有其他更 优雅的实现写法,尽量用替代方案。

浮点数学运算 #

用 VimL 做数学运算并不常见,但如果啥时想到需要她,她也在那儿。一般整数运算直接 用操作符,浮点运算才需要调用函数,且这些内置函数的结果一般也是浮点数,即使参数 都是整数。

  • float2nr() 将浮点数转为整数类型
  • trunc() 截断取整
  • round() 四舍五入取整
  • floor() 向下取整
  • ceil() 向上取整

这几个函数都是取整运算,但实际上只有 float2nr() 的结果是整数类型( v:t_number),其他函数取整后仍为浮点数(v:t_float)。float2nr()trunc() 意义一样是截断取整。当涉及负数,取整可能不太直观,请看示例:

: echo float2nr(4.56) float2nr(-4.56) |" --> 4 -4
: echo trunc(4.56) trunc(-4.56)   |" --> 4.0 -4.0
: echo round(4.56) round(-4.56)   |" --> 5.0 -5.0
: echo floor(4.56) floor(-4.56)   |" --> 4.0 -5.0
: echo ceil(4.56) ceil(-4.56)     |" --> 5.0 -4.0

当四舍五入正好在中值时(如小数部分是 0.5),取远离 0 那个整数,类似:

: round(+float) == trunc(+float + 0.5) |" --> 正数取整
: round(-float) == trunc(-float - 0.5) |" --> 负数取整
  • fmod() 取余数
  • pow() 取幂
  • sqrt() 开平方

整数取余数可直接用操作符 % ,该操作符不能用于浮点数。fmod() 可用于浮点数的 取余,即使整数也当作浮点处理。VimL 并没有整数取幂的操作符(其他语言有用 ^** 作幂运算的),须用 pow() 函数求幂,结果也总是浮点数;

echo 10 % 3          |" --> 1
echo fmod(10, 3)     |" --> 1.0
echo pow(2, 10)      |" --> 1024.0
echo pow(4, 1/2)     |" --> 1.0 (先计算 1/2 = 0
echo pow(4, 1/2.0)   |" --> 2.0
echo sqrt(4)         |" --> 2.0
  • exp() 自然指数
  • log() 自然对数,以 e = 2.718282 为底
  • log10() 常用对数,以 10 为底
  • 三角函数与反三角函数:sin() cos() asin() acos() 等

log()exp() 的反函数,在数学上的记号是 ln;而数学上记为 lg 的对数, 程序上是 log10()。这个命令习惯应该是源自 C 语言的标准库函数。同样,一众三角 函数也是类似 C 语言的,参数是以弧度单位表示的角度。不过很难想像需要在 VimL 中 用到这些略为高深的数学计算的场景。

  • isnan() 判断是否为非数

自 Vim8 引入非数的判断。像 0/0 这样的计算结果叫非数,在其他一些计算机语言与 文档中习惯用 NaN 来表示。可能为了更好地与其他数据文件交互,Vim 也增加这个函 数来处理非数。

列表与字典运算 #

在第四章介绍数据结构时,已经顺便介绍了操作列表与字典的函数。这里不再重复,只作 些补充说明。

首先,很多函数可同时作用于列表与字典,甚至字符串。因为脚本语言弱类型的缘故,没 法限定传入函数的参数,只能根据参数类型作出不同的合理反馈。例如:

  • len() 取列表或字典集合中元素个数,也取字符串的(字节)长度
  • empty() 可判断是否空列表、空字典或空字符串,整数 0 也认为是空的
  • match() 还能匹配字符串列表,返回能匹配成功的元素索引

其次,列表与字典都是集合,有一类高阶函数,可接收另一个函数(引用)作为参数,用 于处理集合内的每一个元素。比如 map()filter() 函数。

前面提及,VimL 有许多与命令同名的函数,都是实现类似的功能。但 map() 是例外, 它与定义键映射的 :map 命令没有语义关系,完全是不同的概念。

  • map({expr1}, {expr2}) 修改集合的每个元素

其中参数一 {expr1} 可以是列表如字典,参数二 {expr2} 是函数引用。参数一集合 的每个元素,传给参数二所代表的函数,将结果值替换原来的元素。最终会原位修改列表 或字典。关键是参数二所引用的函数定义要遵循一定的规范,它应接收两个参数,map() 会将每个元素的索引与值传给该函数(字典元素的索引即是键名)。整个流程可如下模拟:

function! MapDict(dict, fun)
    for [l:key, l:val] in items(a:dict)
        let l:val_new = a:fun(l:key, l:val)
        let a:dict[l:key] = l:val_new
    endfor
endfunction

function! MapList(list, fun)
    for l:idx in range(len(a:list))
        let l:val = a:list[l:idx]
        let l:val_new = a:fun(l:idx, l:val)
        let a:list[l:idx] = l:val_new
    endfor
endfunction

如果处理每个元素的函数很简单,则可不必创建函数再传入函数引用。可用一个字符串代 替,该字符串调用 eval() 执行后,将结果替换原元素。在字符串用,用内置变量 v:val 代表迭代的每个元素值,v:key 代表元素索引(键名)。相当于传入函数版本 的两个参数。用这种方法得注意字符串的转义,建议用单引号括起字面字符串。可用其他 字符串函数或操作符组装,最终结果的字符串再调用 eval() 计算结果新值。

事实上,在低版本的 vim 中, map() 函数的参数二只能用字符串。这才需要 v:keyv:val 这两个特殊变量标记置于可执行字符串中。自 vim8 后,强烈建议使用函数 引用参数。这就无须理解 v:keyv:val 的即时意义。不过在定义处理元素的函数 时,建议也用 keyval 作为函数形参,这使整个代码的可读性更佳:

function! MapHandle(key, val) abort
    let l:result = deal with a:key and a:val
    return l:result
endfunction

如果处理函数很简单,也可不必预定义函数,即时定义 lambda 也可以,因为 lambda 表 达式的值也正是一个函数引用。例如:

let list1 = [1, 2, 3]
let list2 = map(list2, {idx, val -> val * 2})
echo list1
echo list2

let list3 = map(copy(list2), {idx, val -> val * 3})
echo list2
echo list3

以上示例也说明了 map() 函数是原位修改的,如果不想修改原集合,可先调用 copy() 创建副本。低版本中等效的用字符串调用方式如下:

echo map([1, 2, 3], 'v:val * 2')

像这样简单的功能,也许字符串方式写来更简洁,但稍为复杂的功能,可执行字符串的表 示法就可能比较费解了。比如要将原列表中每个元素加上尖括号 <> 括起来,以下三种 调用方式都能实现:

echo map([1, 2, 3], '"<" . v:val . ">"')
echo map([1, 2, 3], 'printf("<%s>", v:val)')
echo map([1, 2, 3], {idx, val -> printf('<%s>', val)})

用 lambda 表达式可避免多重引号的理解困难。而且 lambda 表达式或函数若预先定义的 话,在其他地方也是可用的。而含 v:val 的特征字符串,放在其他地方几乎是没什么 意义了。

  • filter({expr1}, {expr2}) 过滤集合内的元素

filter()map() 函数类似。参数二所代表的处理函数,也接收索引与值两个参数 ,但是要求返回布而逻辑值。如果返回的是真(数字 1),则保留不处理,如果返回的是 假(数字 0),则删除相应的元素。模拟流程如下:

function! FilterDict(dict, fun)
    for [l:key, l:val] in items(a:dict)
        let l:bKeep = a:fun(l:key, l:val)
        if empty(l:bKeep)
            unlet a:dict[l:key]
        endif
    endfor
endfunction

自己模拟过滤列表可能略有麻烦,因为如果正向迭代,删除元素后,索引可能会变化。当 然了,你不必真的自己写或用这样的模拟函数,请用内置的库函数!

同样地,可以用字符串或 lambda 表达式。且在支持 lambda 表达式的 vim 中,尽量用 lambda 表达式。

  • sort({list} [, {fun}, {self}]) 为列表排序,从小到大
  • uniq({list} [, {fun}, {self}]) 删除相邻重复元素

几乎在任一本算法教科书,排序都是重点。但是几乎在任一个语言中,排序都有已优化实 现的库函数,不必自己写的,自己需要做的只是提供比较函数,说明要如何排序的需求。 VimL 要求的比较函数能接收两个参数,返回值意义如下:

  • 0 两个参数视为相等
  • 1 第一个参数视为比第二个参数大
  • -1 第一个参数视为比第二个参数小

sort() 函数只能为列表排序,因为字典是无序的。第二参数 {fun} 一般是函数引用 ,可用 lambda 表达式,但不支持像 map() 那样的可执行字符串。然而,可以是普通 字符用于表示 vim 预设的几种排序策略(常用需求):

  • 空串或省略,按字符串排序,类似 :sort 命令为当前 buffer 的排序行为。
  • li,忽略大小写的排序
  • n 按数字排序,非数字类型的元素认为是 0
  • N 按数字排序,字符串会转为数字
  • f 按数字排序,列表元素限定仅是数字或浮点数

VimL 的 sort() 是稳定排序算法,即如果两个元素相等(按 {fun} 返回 0),排 序后它们也保持原来的相对顺序。如果 {fun} 参数是含 dict 属性的函数,则要提 供第三参数 {self} ,一个作为 self 的字典变量。

uniq() 函数的参数用法与 sort() 相同。且一般应该对已排序的列表调用 uniq() ,因为它只比较相邻元素而去重。

小结 #

VimL 的标量主要就是字符串与数字,集合也就列表与字典。所以为这些数据类型提供了 大量的库函数 api。用 :h type() 查看支持的所有变量类型。但其他类型需要支持的 操作非常有限,故无必要有什么专门函数处理。