读取文本节点_文本编辑器数据结构进化之PieceTable

c337f06ac9fb3f2717d2f072e3ae5968.png

原始数据结构

问题:当你打开一个文本文件时,首先从磁盘加载数据,这些数据会被保存在内存中,我们用怎样的数据结构来存储呢?

我们大家正常想到应该就是两个

字符串

    contents = “第一行内容n第二行内容”

字符串数组

     lines = [
       “第一行内容n”, // 第一行
       “第二行内容” // 第二行
    ]

直接使用字符串简单粗暴,我们知道每次改动字符串都会重新申请内存,这样会导致内存极度不稳定。

如果使用字符串数据,在长数组的情况,依然会存在移动的性能问题(2018年前VsCode其实一直都是使用的这种模式)。

21bc45379632f29b0eea144f28a8039c.png

针对这种情况业界提出了Append-Only的概览,也是我们后续说的PieceTable。

Piece Table

一种Append-Only的方式,更新的内容都是以追加的形式保存的,Piece Table整体可以归纳为原始内容(original),新增内容(added),内容顺序关系对象(pieces)。

让我们来看看Piece Table是如何工作的,首先当用户从磁盘读取文档内容到内存中时,我们会把文本内容记录为original字符串。

    let PieceTable = {
		"original": “ 第一行内容n第二行内容”,
		...
	}

当我们插入文本的时候,文本会append到一个added的字符串中,初始化为空字符串,后续无论我们在哪些位置增删改,我们都是把增加的文本放入到added字符串末尾的,通过这种方式,我们记录了用户插入到文本中的所有字符,避免了在中间插入文本的问题。

假如我们在第二行的位置插入一段文本,他在Piece Table中表示为:

        let PieceTable = {
		"original": “ 第一行内容n第二行内容”,
		"added": “在第二行位置插入一行n", // 这个字符串只增不减
	}

为了知道用户在哪里输入的文本,Piece Table需要记录哪个区域是从original来的,哪些是从added来的,这样需要一个Piece结构存储以下三个内容信息

type:属于哪个字符串

start:字符串中的开始位置

length:总共有多少个字符

    {
	    "original": “ 第一行内容n第二行内容”,
	    "added": “在第二行位置插入一行n", // 这个字符串只增不减
	    "pieces": [
		    Piece(start=0, length=6, type="original"),
		    Piece(start=0, length=11, type=“added"),
		    Piece(start=6, length=5, type="original")
		],
    }

上述结构就是一个简单的PieceTable了,这样的结构真的就能满足我们的需求了吗?答案是否定的,我们来看看VsCode是怎样一步一步来优化的。

VsCode 版 PieceTable

快速查找行

基于上述的结构,我们每次查找行都需要遍历一次pieces,找到每个piece里面的字符串中包含多少个换行(类似用split('n')),这样效率就有点底下了,所有这里vscode这里把数据结构做了一下优化,增加一个lineStarts来缓存当前piece里面每个换行符的位置,如果我需要去第一个piece的第二行,就变成去original字符串中lineStarts[0]到lineStarts[1]的数据了。

    {
	    "original": “ 第一行内容n第二行内容”,
	    "added": “在第二行位置插入一行n", // 这个字符串只增不减
	    "pieces": [
		    Piece(start=0, length=6, type="original", lineStarts=[5]),
		    Piece(start=0, length=11, type=“added", lineStarts=[10]),
		    Piece(start=6, length=5, type="original", lineStarts=[])
		],
    }

字符串上限带来的坑

V8里面的字符串的大小是有限制的256M~1G,假设我们要打开超过1G以上的大文件,当我们把文本数据从磁盘中加载出来拼接到original的时候,这时候就会超过V8的限制了。

VsCode通过把original和added都一起整合到一个buffers的字符串数组来解决这个问题,数组中每个字符串的最大值为64K,数据结构变化为

    {
	    "buffers": [“ 第一行内容n第二行内容”, “在第二行位置插入一行n"], // 
	    "pieces": [
		    Piece(start=0, length=6, bufferIndx=0, lineStarts=[5]),
		    Piece(start=0, length=11, bufferIndx=1, lineStarts=[10]),
		    Piece(start=6, length=5, bufferIndx=0, lineStarts=[])
		],
    }

基于lineStarts快速查找行内容性能再次迎来了新挑战

假设用户打开一个640M的文件,按照每个buffer为64k的大小,这时候初始化的buffers的长度就10000了,这时候就算存在lineStarts的缓存,查询某一行的内容的,也会变成一个最差的时间复杂度为O(N)的查询(这个取决于所查询行的大小)。

如果在每个Piece中缓存绝对的行号,这时候通过二分法查找就能变成log(N)复杂度,但是这时候又回来一个新问题,一旦用户在中间插入一行,就会导致后续所有的行号都要改变,就会变成一个编辑是O(N)的行为。

虽然二分法并不能帮我们解决问题,但是也能给我们一些启发,我们把二分法改为二叉查找树,问题就变成了,如何让二分查找树实现编辑也为log(N),如果我们存储绝对行号,用二叉树编辑也会是一个O(N)

3cf7fab1e392df8a80f3615694ad6765.png

如何设计一个二叉排序树,让其编辑和查找都为log(N),正常二叉排序树查找的时间复杂度为log(N)~ O(N),所以想要log(N)首先得二叉树是平衡的,VsCode这里就用的红黑树来实现的。我们已经解决查找的速度问题,还剩下编辑的时间复杂度的问题,这时候为了保证log(N),我们只能设计二叉树的跟行相关的元数据只能与子树相关,这样就能保证每次更新行的元数据信息,就需要更新父节点的信息了,vsCode这里引入了left_subtree_lfcnt:左子树换行符数.(当然这里如果想要查找具体某一个位置的字符,则可改为left_subtree_length,这里我们不重点讲了)

    {
	    "buffers": string[10000], // 
	    "pieces": [
		    Piece(start=0, length=, bufferIndx=0, lineStarts=[...], left_subtree_lfcnt=),
		    ...,
		    Piece(start=0, length=, bufferIndx=0, lineStarts=[10000], left_subtree_lfcnt=),
		],
    }

我们这里做一次转换把绝对行号转化为left_subtree_lfcnt和cur_lfcnt(当前节点字符串中的换行符数,这个根据lineStarts转化而来,这个lineStarts后续还会再提到,**先mark下**),我们把绝对行号转化为(left_subtree_lfcnt,cur_lfcnt)结构:

b7c14c468a9c7c40df3f682c75ae6f83.png

这样设计为什么能解决编辑的时间复杂度呢?我们来看看 在21行这个位置新插入4行 的情况

4ce1583c6e5b0986b993ca7c28f90dd4.png

很显然时间复杂度为log(N)了。

改成这样后,我们怎么查询呢?问题:查询第22行的位置

b4ae33c334a4f3a2aad133148941f38a.png

性能极致优化

上面我们有一个lineStarts的缓存行号,我们这里有一个很差的情况,假设我们一个lineStarts长度为10000,一旦在中间插入,这个lineStarts被对半分,这时候就变成了两个长为5000的lineStarts了,由于不是直接在线性内存上进行操作,因此将数组拆分为两个比仅移动指针的开销更大。

为了解决这个问题,VsCode用了一个很讨巧的方式,基于buffers Append-Only的特性,把linestarts绑定到只Append-Only Buffer上,每个buffer只要生成了,它就是不变的了。

    {
	    "buffers": string[{ value: '', lineStarts: [] }],
	    "pieces": [
		    Piece(start=(0, 0), end=(line, column), bufferIndx=0, lineStarts=[...], left_subtree_lfcnt=),
		    ...,
		    Piece(start=(line, column), end=(line, column), bufferIndx=0, lineStarts=[10000], left_subtree_lfcnt=),
		],
    }

这里其实是把start和end又之前的偏移number转化为:某一个buffer的第几行的第几列,我们来看到vsCode textBuffer中的源码截图

b77c863da061f8a3213a80c92db9b5d4.png

这些基本上就是整个VsCode文本编辑器pieceTable的进化的核心思想了,大家有不同看法,欢迎一起沟通,当然有说的不对的地方,也欢迎来指正。

参考文档:Text Buffer Reimplementation, a Visual Studio Code Story

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值