使用腳本編寫 Vim 編輯器,第 4 部分: 字典

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. 在字典中傳遞可選參數

點擊查看代碼清單

這個版本的函數,只傳遞了兩個參數:最重要的評論文本,以及一個選項字典。如果選項沒有指定,內置的 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 的內置事件模型,並探索如何建立在用戶編輯時自動觸發的用戶自定義函數。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章