使用脚本编写 Vim 编辑器,第 4 部分: 字典

学习何时使用字典使代码更简洁,更迅速

字典是一个从列表提供不同优化和权衡的容器数据结构。特别说明的是,字典中元素存储的顺序是无关紧要的,每个元素的身份是明确的。在介绍 Vimscript 系列 文章的第四篇中,Damian Conway 将向您介绍字典,包括它们的基础语法和许多函数的概述。他最后还举出了一些例子,来说明如何使用字典获取更有效的数据处理和更简洁的代码。

Damian Conway, CEO 兼首席培训师, Thoughtstream

作者照片 - damian conwayDamian Conway 是澳大利亚 Monash 大学计算机科学专业的兼职副教授,还是一家国际性 IT 培训公司 Thoughtstream 的 CEO。Vim 是他的主要代码开发环境,而且 Vimscript 是他最喜欢的两种编程语言之一。



2010 年 4 月 08 日

Vimscript 中的字典 在本质上和 AWK 关联数组、Perl 哈希表,或者 Python 字典都是一样。也就是说,这是一个无序容器,按字符串而不是整数来进行索引。

Vimscript 系列 的第四篇将会介绍这一重要的数据结构,并解释其复制、过滤、扩展和整理的多项功能。这些例子重点说明列表和字典之间的差别,以及一些例子。在这些例子中,与讲述内置列表的 使用脚本编写 Vim 编辑器,第 3 部分:内置列表 中开发出的基于列表的解决方案相比,使用字典是一个更好的替代方案。

Vimscript 中的字典

您可以通过在键/值对列表上加花括号来创建一个字典。在每一对中,键和值用冒号分隔。例如:

清单 1. 创建一个字典
let seen = {}   " Haven't seen anything yet

let daytonum = { 'Sun':0, 'Mon':1, 'Tue':2, 'Wed':3, 'Thu':4, 'Fri':5, 'Sat':6 }
let diagnosis = {
    \   'Perl'   : 'Tourettes',
    \   'Python' : 'OCD',
    \   'Lisp'   : 'Megalomania',
    \   'PHP'    : 'Idiot-Savant',
    \   'C++'    : 'Savant-Idiot',
    \   'C#'     : 'Sociopathy',
    \   'Java'   : 'Delusional',
    \}

一旦完成了创建字典,您就可以使用标准方括号索引符号来访问它的值,但是要使用字符串作为索引而不是一个数字:

let lang = input("Patient's name? ")

let Dx = diagnosis[lang]

如果在字典中不存在该键,就抛出一个异常:

let Dx = diagnosis['Ruby']
**E716: Key not present in Dictionary: Ruby**

不过,您可以使用 get() 函数,安全地访问一个可能不存在的条目。get() 使用两个参数:一个是字典本身,另一个是在字典中查找的键。如果字典中存在该键,就会返回相应的值;如果键不存在,get() 就返回零。或者,您可以指定第三个参数,如果没有找到键,get() 返回这个值:

let Dx = get(diagnosis, 'Ruby')                     
" Returns: 0

let Dx = get(diagnosis, 'Ruby', 'Schizophrenia')    
" Returns: 'Schizophrenia'

访问一个特殊的字典条目还有第三个方法。如果这个条目的键只由标识符字符(字母数字和下划线)组成,您可以使用 “点符号” 访问相应的值,就像:

let Dx = diagnosis.Lisp                    " Same as: diagnosis['Lisp']

diagnosis.Perl = 'Multiple Personality'    " Same as: diagnosis['Perl']

这种特殊限制符号使得字典就像记录或者结构体一样易于使用:

let user = {}

let user.name    = 'Bram'
let user.acct    = 123007
let user.pin_num = '1337'

字典的批量处理

Vimscript 提供一些功能,允许您获取字典中所有键的列表、所有值的列表,或者所有键/值对的列表:

let keylist   = keys(dict)
let valuelist = values(dict)
let pairlist  = items(dict)

这个 items() 函数事实上返回一个列表的清单,其中,每个 “内部” 清单正好有两个元素:一个键及其对应值。因此,items() 在您想要迭代字典中所有条目的时候尤为方便:

for [next_key, next_val] in items(dict)
    let result = process(next_val)
    echo "Result for " next_key " is " result
endfor

赋值和身份

在字典中赋值就和在 Vimscript 列表中一样。字典由引用(即指针)来表示,所以将字典赋给另一个变量就将两个变量设为相同的底层数据结构,前者相当于后者的别名。您可以首先通过复制或者深复制(deep-coping)原始内容来解决这个问题:

let dict2 = dict1             " dict2 just another name for dict1
                                  
let dict3 = copy(dict1)       " dict3 has a copy of dict1's top-level elements

let dict4 = deepcopy(dict1)   " dict4 has a copy of dict1 (all the way down)

和列表一样,您可以用 is 操作符来比较身份,用 == 操作符来比较值:

if dictA is dictB
    " They alias the same container, so must have the same keys and values
elseif dictA == dictB
    " Same keys and values, but maybe in different containers
else
    " Different keys and/or values, so must be different containers
endif

添加和删除条目

将一个条目添加到字典中,只需要对新的键赋一个值:

let diagnosis['COBOL'] = 'Dementia'

要合并来自其它字典的条目,可以使用 extend() 函数。第一参数(正在进行扩展的)和第二参数(包含额外的条目)都必须是字典:

call extend(diagnosis, new_diagnoses)

当您想要显式添加多个条目时,使用 extend() 也是非常方便的:

call extend(diagnosis, {'COBOL':'Dementia', 'Forth':'Dyslexia'})

将一个独立条目从字典中删除有两种方法:使用内置的 remove() 函数,或者使用 unlet 命令:

let removed_value = remove(dict, "key")unlet dict["key"]

从一个字典删除多个条目时,使用 filter() 会更简洁,更有效。filter() 函数的工作方法和列表中的相同,除了用 v:val 来检测各条目的值,您还可以用 v:key 来检测它的键。例如:

清单 2. 检测值和键
" Remove any entry whose key starts with C...
call filter(diagnosis, 'v:key[0] != "C"')

" Remove any entry whose value doesn't contain 'Savant'...
call filter(diagnosis, 'v:val =~ "Savant"')

" Remove any entry whose value is the same as its key...
call filter(diagnosis, 'v:key != v:val')

其它字典相关函数

除了 filter() 以外,字典可以使用其它和列表相同的内置函数和方法。几乎在所有的情况(最显著的例外是 string())下,一个应用到字典的列表函数的行为就像该函数收到了该字典的一个值列表。清单 3 显示了最常用的函数。

清单 3. 其它适用于字典的列表函数
let is_empty = empty(dict)           " True if no entries at all

let entry_count = len(dict)          " How many entries?

let occurrences = count(dict, str)   " How many values are equal to str?

let greatest = max(dict)             " Find largest value of any entry
let least    = min(dict)             " Find smallest value of any entry

call map(dict, value_transform_str)  " Transform values by eval'ing string

echo string(dict)                    " Print dictionary as key/value pairs

内置的 filter() 在字典内规范化数据时非常方便。例如,给定一个包含首选用户名(或许按用户 ID 索引)的字典,您可以保证每个名称的大小写都正确,就像这样:

call map( names, 'toupper(v:val[0]) . tolower(v:val[1:])' )

调用 map() 可以遍历各个值,将其作为别名赋给 v:val,在字符串中计算表达式,并用表达式结果替换值。在这个例子中,它将名称的首字母大写,其它的字母保持小写,然后用修改过的字符串作为新的名称值。


部署字典获得更简洁的代码

本系列的 使用脚本编写 Vim 编辑器,第 3 部分:内置列表 用一个在指定文本周围生成评论框的小例子,解释了 Vimscript 的 variadic 函数参数。可选参数可以添加在文本字符串之后,用来指定评论人,用作 “框” 的字符,以及评论的宽度。清单 4 复制了原始的函数。

清单 4. 将可选参数传递为可变参数
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) . "\<CR>"
    \    . introducer . " " . a:comment        . "\<CR>"
    \    . introducer . repeat(box_char,width) . "\<CR>"
endfunction

可变参数(variadic arguments)用于指定函数选项是很方便的,但是有两个主要的缺点:它们对函数的参数强制进行明确排序,但在函数调用时没有明确排序。

重温自动评论

正如 清单 4 所说明的,当任何参数可选时,通常需要事前决定其指定的排序。然而,这一需要凸显了一个设计问题:为了指定一个稍后的选项,用户必须在此之前明确地指定所有选项。理想情况下,第一选项应该是最常用的,第二选项是第二常用的,以此类推。事实上,在函数广泛部署以前决定这些排序是很困难的;您如何能知道哪个选项对大多数人是最重要的?

例如,在清单 4 中的 CommentBlock() 函数,假设评论人可能是最需要的可选参数,所以将其放置在参数列表的第一位。但是如果这个函数用户只用 C 和 C++ 编程,所以从没有改变过默认评论人要怎么办?更糟糕的是,如果评论块的宽度因各个新项目而不同又会怎样?这将是让人恼火的,因为开发人员现在不得不每次指定所有三个可选参数,即便如此,头两个还是常常会给出其默认值:

" Comment of required width, with standard delimiter and box character...
let new_comment = CommentBlock(comment_text, '//', '*', comment_width)

这个问题也直接导致了第二个问题,即当任何选项需要被明确地指定时,它们中的几个都不得不被指定。但是,因为默认是通常最需要的值,用户可能会不熟悉如何指定选项,因此也不熟悉所需的排序。这就会导致如下的实现错误:

" Box comment using ==== to standard line width...
let new_comment = CommentBlock(comment_text, '=', 72)

……令人不安的是,这会产生像这样的(非)评论:

=727272727272727272727272727272 = A bad comment =727272727272727272727272727272

这个问题就是,可选参数没有明确指出它们打算设置哪个选项。它们的含义由它们在参数列表里的位置隐式确定,所以其排序中的任何错误都会悄悄改变其含义。

这是一个使用错误工具工作的经典案例。只有在顺序很重要,且位置最好地暗示身份时,列表才是完美的。但是,在这个例子中,可选参数的排序与其说是一个有利条件,倒不是说是个麻烦,它们的选项很容易被搞混,这会导致微妙的身份识别错误。

从某种意义上说,您所想要的和列表上给出的是完全相反的:一个顺序不相关,但身份明确的数据结构。换句话说,就是字典。清单 5 显示了相同的函数,但是使用的是通过字典,而不是可变参数指定的选项。

清单 5. 在字典中传递可选参数

点击查看代码清单

清单 5. 在字典中传递可选参数

function! CommentBlock(comment, opt)
    " Unpack optional arguments...
    let introducer = get(a:opt, 'intro', '//'                 )
    let box_char   = get(a:opt, 'box',   '*'                  )
    let width      = get(a:opt, 'width', strlen(a:comment) + 2)" Build the comment box and put the comment inside it...
    return introducer . repeat(box_char,width) . "\<CR>"
    \    . introducer . " " . a:comment        . "\<CR>"
    \    . introducer . repeat(box_char,width) . "\<CR>"
endfunction

这个版本的函数,只传递了两个参数:最重要的评论文本,以及一个选项字典。如果选项没有指定,内置的 get() 函数可以用来检索各个选项,或者其默认值。然后调用该函数,用已命名的选项/值对来配置其行为。在函数内实施参数解析就变得较为简洁,函数调用也变得更具可读性,不易出错。例如:

" Comment of required width, with standard delimiter and box character...
let new_comment = CommentBlock(comment_text, {'width':comment_width})

" Box comment using ==== to standard line width...
let new_comment = CommentBlock(comment_text, {'box':'=', 'width':72})

重构自动对齐

在本系列 使用脚本编写 Vim 编辑器,第 3 部分:内置列表 中,我们更新了较早的名为 AlignAssignments() 的示例函数,对其进行转换使用列表来存储正在修改的文本内容。清单 6 再现了这个函数的升级版。

清单 6. 更新后的 AlignAssignments() 函数
function! AlignAssignments ()
    " Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . 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

    " Decompose lines at assignment operators...
    let lines = []
    for linetext in getline(firstline, lastline)
        let fields = matchlist(linetext, ASSIGN_LINE)
        call add(lines, fields[1:3])
    endfor

    " Determine maximal lengths of lvalue and operator...
    let op_lines = filter(copy(lines),'!empty(v:val)')
    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])'  ) )

    " Recompose lines with operators at the maximum length...
    let linenum = firstline
    for line in lines
        if !empty(line)
            let newline
            \    = printf("%-*s%*s%s", max_lval, line[0], max_op, line[1], line[2])
            call setline(linenum, newline)
        endif
        let linenum += 1
    endfor
endfunction

这个版本对数据进行缓存而不是重新加载,很大程度上改善了函数的有效性,但这会需要更多的维护费用。具体来说,因为它将各行的各种组件存储在小的三元素数组中,代码被 “神奇索引”(例如 v:val[0]line[1])打乱,这些索引的名字对它们的用途没有任何提示。

字典就是专门解决这一问题的,因为,像列表一样,它将数据整理在一个单独的结构中,但和列表不一样的是,它们用一个字符串对各个数据进行标识,而不是用数字。如果这些字符串经过精心挑选,它们就可以使结果代码更简洁。不使用神奇索引,我们将获得有意义的名称(例如,v:val.lval 用于各行的 lvalueline.op 用于各行的操作符)。

使用字典来重写函数是非常简单的,如清单 7 所示。

清单 7. 进一步改良的 AlignAssignments() 函数
function! AlignAssignments ()
    " Patterns needed to locate assignment operators...
    let ASSIGN_OP   = '[-+*/%|&]\?=\@<!=[=~]\@!'
    let ASSIGN_LINE = '^\(.\{-}\)\s*\(' . 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

    " Decompose lines at assignment operators...
    let lines = []
    for linetext in getline(firstline, lastline)
        let fields = matchlist(linetext, ASSIGN_LINE)
        if len(fields) 
            call add(lines, {'lval':fields[1], 'op':fields[2], 'rval':fields[3]})
        else
            call add(lines, {'text':linetext,  'op':''                         })
        endif
    endfor

    " Determine maximal lengths of lvalue and operator...
    let op_lines = filter(copy(lines),'!empty(v:val.op)')
    let max_lval = max( map(copy(op_lines), 'strlen(v:val.lval)') ) + 1
    let max_op   = max( map(copy(op_lines), 'strlen(v:val.op)'  ) )

    " Recompose lines with operators at the maximum length...
    let linenum = firstline
    for line in lines
        let newline = empty(line.op)
        \ ? line.text
        \ : printf("%-*s%*s%s", max_lval, line.lval, max_op, line.op, line.rval)
        call setline(linenum, newline)
        let linenum += 1
    endfor
endfunction

新版本中的差异用黑体字标出。只有两处:各行的记录现在是一个字典,而不是一个哈希表,每个记录的元素的后续访问使用已命名的查询,而不是数值索引。总的结果是代码的可读性更强,更不容易发生数组索引常出现的离一(off-by-one)误差。


作为数据结构的字典

Vim 提供一个内置的命令,允许您从一个文件中删除重复的行:

:%sort u

u 选项会使内置的 sort 命令删除重复的行(如果它们已经被存储),前导的 % 会应用那个特殊的 sort 来应用到整个文件。如果您不在意保存文件中唯一一行的原始排序,这是非常方便的。如果这些行是获奖者的名单,一个有限资源的登记表,一个待办事项清单,或者其他注重顺序的序列,这就可能会是一个问题。

无排序唯一性

字典的键天生是唯一的,所以可以用字典来删除文件的重复行,为了完成这项工作可以采用保存这些行的原始排序的方法。清单 8 展示了一个用于完成该任务的简单函数。

清单 8. 一个保存排序唯一性的函数
function! Uniq () range
    " Nothing unique seen yet...
    let have_already_seen = {}
    let unique_lines = []

    " Walk through the lines, remembering only the hitherto-unseen ones...
    for original_line in getline(a:firstline, a:lastline)
        let normalized_line = '>' . original_line 
        if !has_key(have_already_seen, normalized_line)
            call add(unique_lines, original_line)
            let have_already_seen[normalized_line] = 1
        endif
    endfor

    " Replace the range of original lines with just the unique lines...
    exec a:firstline . ',' . a:lastline . 'delete'
    call append(a:firstline-1, unique_lines)
endfunction

Uniq() 函数被声明为接受一个范围,因此只能调用一次,即使在缓冲区内的一个行范围上调用时。

调用时,它首先设置一个空的字典(have_already_seen),这个字典用于跟踪在指定范围内已经遇到了哪些行。之前没有见过的行就会被添加到存储在 unique_lines 的清单中。

然后函数提供一个循环,准确地完成这一工作。它通过 getline() 从缓冲区获取代码的指定范围,并对各项进行迭代。它首先在每一行添加一个前导 '>' ,确保它不是空的。Vimscript 字典不能存储一个键为空字符串的条目,所以缓冲区中为空的代码不会被正确地添加到 have_already_seen

一旦这些行被规范化,那么函数就能检查该行是否已经作为键在 have_already_seen 字典中被使用过。如果是的话,被确定的这行肯定已经被查看过,所以被添加到 unique_lines,这样重复的部分就可以忽略。相反地,如果该行是第一次遇到,那么原始(未规范化的)一行必须被添加到 unique_lines,规范化的那一版必须作为键被添加到 have_already_seen

当所有的代码已经按这种方法过滤了一遍之后,unique_lines 将会只会包含它们中唯一的子集,按照遇见的先后顺序排列。所有留下的这些行将会删除其原始的行组,用这些积累下来的唯一行来替换它(通过一个 append())。

有了这样一个可用的函数,您可以设置一个正常模式的键映射来调用全部文件的命令,就像这样:

nmap ;u :%call Uniq()<CR>

或者您可以将其应用到一个代码的特殊集中(例如,一个在 Visual 模式中选定的范围),就像这样:

vmap  u :call Uniq()<CR>

展望未来

目前我们讨论的 Vimscript 基本特性(语句和函数,数组和哈希表)已经足够为 Vim 的核心特性集创建几乎任何类型的添加项。但是我们所看到的扩展,都需要用户通过发布一个正常模式命令,或者在插入模式中输入一个特殊的序列,明确地请求行为。

在本系列的下一篇文章中,我们会介绍 Vim 的内置事件模型,并探索如何建立在用户编辑时自动触发的用户自定义函数。

参考资料

学习

获得产品和技术

讨论

  • 加入 My developerWorks 社区。查看开发人员参与的博客、论坛、组和 wiki,并与其他 developerWorks 用户交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=481109
ArticleTitle=使用脚本编写 Vim 编辑器,第 4 部分: 字典
publish-date=04082010