程式碼編輯器系列 #3 文字的儲存 進化篇
ice1000.org 這是原文,發表在我的部落格。
在上上篇文章中我說過,
以後的方向主要是講 JB 式編輯器的實現
在上一篇文章中我又說,
那麼這篇文章先說點別的吧
簡直是王鏡澤定理的完美演繹啊。 為什麼我要在半個月來第一篇部落格開頭說這個呢?因為這次講的依然不是 JB 式編輯器的實現,真香。
關於上一篇部落格在說 gap buffer 的時候提到的資料結構論文 Flexichain,我當時說讀下來沒學到什麼東西。 實際上論文有兩篇,內容應該基本上都在另一篇講實現的裡面。這是某活躍於 freenode #lisp 的 CL 廚告訴我的。
在我最近的新專案裡,我使用了 Flexichain 論文裡提到的 Hemlock 編輯器使用的資料結構 (我的那個使用C++實現,改自一個單獨的 Java 專案 ofollow,noindex">text-sequence ,前文提到的實驗專案也換成了這個專案裡的 GapBuffer
), 這個資料結構在piece table 的論文裡有一個類似物叫 Line Span,於是我就直接叫它 LineSpan
了。
本文包含這篇論文中的大部分知識。
前文說,
由於 Swing 的 API 看起來更低效
其實不是的, Swing 文字編輯器內部實現是一個 GapContent
, 也就是一個過度 OO 設計的 GapBuffer
(可見寫 Swing 的人並不是文盲,而且文化程度不低),這比我那時候用的更高效,所以我那時的想法還是太幼稚了。
既然本文是現代篇(對應上一篇的遠古篇),那麼講的肯定是現代編輯器使用的資料結構啦。
介紹一個概念 end of line ,通常是 \n
但考慮到有些情況下還有別的行分隔符就使用了 end of line 代表這個東西。
Code 早期使用的資料結構
應該很少有人使用可執行檔案的名字來稱呼這個全名叫 Visual Studio Code 的編輯器吧,正好可以顯得很裝逼(逃。 根據黑歷史考據,我看到 Code 團隊在blog 裡自述曾經使用按行儲存的策略, 然後他們獲得的好處是可以按行執行 Tokenizer ,可以提高程式碼高亮的效能(意思就是直接不考慮包含 end of line 的 Token ,很符合前端人員的程式設計思想)。
這歷史應該是黑成碳了。根據上面那個連結裡的部落格來看,他們也沒有使用優化 active line 的策略(比如我使用 GapBuffer )。 不過呢,按行儲存可以進行渲染上的優化(因為行可以被視為一個渲染單位,而且在螢幕移動時每行渲染出來的樣子是不變的), 這在某種意義上也是一種好處了(根據一個研究超算的軟粉的說法,Visual Studio 會快取每行的程式碼渲染後得到的 texture)。
LineSpan 的實現相對 GapBuffer 較為繁瑣,它在插入的時候需要檢查是否有插入 end of line 來考慮是否要拆掉當前的 active line 、 需要在刪除的時候檢查是否刪除了一個 end of line 來考慮是否要合併當前行和下一行。 好處是,可以對行進行批量操作。
目前我的編輯器也是臨時使用的這種資料結構,而且比起他們這個還提高了當前行的編輯效率和獲取行的效率。 我原本計劃使用一個線段樹維護每行的長度的字首和(之所以沒有選擇我最喜歡的樹狀陣列,是因為我還需要刪點),這樣可以獲得 O(log(n))
的 getLineInfoAt
等函式。 但是在後來發現要獲取對應行的迭代器還需要對連結串列進行高效的隨機訪問(或者儲存迭代器到線段樹裡,但這意義已經沒有那麼大了,因為要換 Piece Table 實現),我就放棄了。 不過鄙視人家的黑歷史也沒什麼意思,畢竟 Code 除了程式碼編輯的其他地方做的還是很不錯的。
我做了一個 LineSpan 的資料結構視覺化(非常妙),上傳到了bilibili 和YouTube。
其他雜七雜八的資料結構
- Split-Merge Tree ,保證 Rope 的最小單位 Sequence 在一個長度範圍內,超過就拆,小於就合併兩個相鄰的,是比平衡樹更平衡的平衡樹
- Fixed-Size-Buffer ,扁平版 Split-Merge Tree,你也可以叫它 Split-Merge LinkedList
文字序列資料結構的通用性質
看到這裡,我們腦中應該已經有了一個文字序列資料結構的一個通用模型了。 在前文講 Rope 的時候我曾經提到,它是將最小的文字表示作為了一種抽象結構並使用平衡樹組合他們,然後舉例了幾種可能的實現—— 一個獲取文字的函式、字元陣列(C 風格字串)、惰性讀取的檔案、另一顆平衡樹等。 GapBuffer 其實也是一種實現。
我們可以嘗試把這種思想套到 LineSpan 上,然後發現也完全適用——它其實只是提出了一種新的最小文字表示的實現—— Line
,並使用連結串列或者平衡樹去組合他們而已。 我們來給這個抽象裡的概念起個名字吧。
- 一個 item 指文字序列的最小單位,通常是
char
或者wchar_t
- 一個 sequence 指一系列以各種方式組合並在邏輯上是連續的 item ,即剛才提到的抽象結構,比如
LinkedList<Character>
,std::list<char>
- 一個 buffer 指一段在物理上連續的 item ,一般只在實現中涉及
- 比如
std::vector<char>::data
,java.util.ArrayList<Character>
裡面的那個陣列 - 比如 mmap
- 一個 item 序列如果同時是 sequence 和 buffer 那麼它是一個 span
- 比如
java.lang.String
,std::string
- buffer 中的邏輯連續的一部分可以算作一個 span ,比如 GapBuffer
- 一個 descriptor 指描述一段 sequence 的資料結構,比如 LineSpan 中通常需要一個
LineInfo
來儲存 span 的位置、長度,那麼這個LineInfo
就是一個 descriptor - descriptor 通常持有一個 sequence 的指標
- 這個定義和論文裡說的不太一樣,是我覺得更好的定義
然後我們來總結一下我們見過的各種東西吧:
-
java.lang.Character
,char
,wchar_t
,ImWchar
是 item - Ruby/Lua/JavaScript/Dart/Perl 等語言中的
string
是 span - Rope 是一個遞迴的 sequence (
type Rope = BalancedTree (Either Span Rope)
) - 平衡樹的
Node
類可以看作 span 的 descriptor
-
java.lang.StringBuilder
是一個大 buffer ,最左邊那部分是一個 span, descriptor 持有這個StringBuilder
要 build 的String
的長度 - GapBuffer 內部有一個大 buffer ,它由兩個中間有一個 gap 的不斷變化的 span 組成
- 第一個 span 的 descriptor 儲存這個 span 的長度,第二個 span 的 descriptor 儲存這個 span 在大 buffer 裡的的起始點
- LineSpan 可以看作一個
java.util.LinkedList<java.lang.CharSequence>
或者std::list<std::string>
的封裝,屬於 sequence - 連結串列的迭代器就是 descriptor ,裡面存的是 sequence , Code 早期的這些 sequence 全是 span ,我的實現裡 active line 是 sequence ,其他行是 span
是不是一下子就搞懂這些名詞的含義了?下面我們將使用這套名詞介紹一個新資料結構。
Piece Table
這是我目前覺得最好的文字編輯器儲存資料所使用的資料結構。
Piece Table 由一個巨大的、 immutable 的、最好是 lazy 的 buffer (mmap 很適合作為這個 buffer)和一系列指向 span 的 descriptor 組成。 Descriptor 儲存兩個資訊,頭指標和長度。這一系列 descriptor 可以由連結串列儲存(實現簡單),也可以使用平衡樹儲存(更高效)。 由於我們不需要修改或者刪除這些 descriptor 指向的 span ,我們可以把他們持有的 span 放進一個 buffer ,我們稱之為 add buffer ,可以理解為一個 ArrayList<Item>
。
在建立一個 Piece Table 的時候,我們需要初始化這個巨大的 buffer ,比如文字編輯器開啟一個檔案的時候就可以使用檔案的 mmap 。 此時我們也初始化第一個 descriptor ,頭指標指向大 buffer 的開頭,長度就是整個 buffer 。
查詢
如果給定 offset
要取一個 Item ,就涉及儲存 descriptor 的資料結構了—— 連結串列的話從頭開始遍歷 descriptor ,找到這個 offset
所在的 descriptor 然後就可以從 buffer 裡取值了,複雜度 O(n)
。 如果是平衡樹就可以直接從 offset
去找,複雜度 O(log(n))
。
插入
插入任意內容時(假設輸入了 offset
(即 index
)和 sequence
),類似 LineSpan 插入 end of line 的情況,需要把這個 offset
所在的那個 descriptor 拆掉,變成兩個分別描述原本的 descriptor 在 offset
前的那一半和後的那一半,然後把 sequence
新增到 add buffer 的尾部, 然後在剛才這兩個 descriptor 的中間插入一個指向這個 sequence
在 add buffer 中位置的 descriptor 。
舉個例子,假設我們有一個檔案,裡面有 Piece
這幾個字元。我們用它建立一個 Piece Table 後,大 buffer 裡就是 Piece
,長度為 5。 Descriptor 序列是這樣的:

我們對他進行 insert(1, "Tb")
,那麼先把 1 這個 offset
所在的 0 號(查詢 descriptor 的方法同查詢) descriptor 拆開,原本長度為 5 的變成一個長度為 1 的和一個長度為 4 的:

然後向 add buffer 新增 T
和 b
這兩個 Item ,然後在剛才兩個 descriptor 中間插入一個新的 descriptor :

刪除
查詢到 offset
所在的 descriptor ,拆成兩個並讓左邊那個的長度減一即可。
Piece Table 的優良性質
- 可持久化資料結構
- 背後的大 buffer 可以完全 lazy 化,記憶體中的資料量將會巨小
- descriptor 數量少
- 可以方便地使用平衡樹優化
- 可以被翻譯成『坨坨桌子』,很可愛
- 它可是 Code 使用的資料結構啊,搞懂了之後拿來吹的時候逼格挺高的
Benchmark
看論文去。
<end of blog>