C++程序设计:原理与实践(进阶篇)15.6 实例:一个简单的文本编辑器

15.6 实例:一个简单的文本编辑器


列表最重要的性质就是可以在不移动元素的情况下对其进行插入或删除操作。下面我们通过一个例子来说明这一点。考虑应该如何在文本编辑器中表示一个文本文件中的字符。所选用的表示方式应当能够使对文本文件进行的操作简单而高效。

那么具体会涉及哪些操作呢?假设文件能存储在计算机的内存中。也就是说,我们可以选择任何一种适合的表示方式,当需要保存到文件中时,只要把它转换成一个字节流就可以了。相应地,我们也可以把一个文件中的字符转成字节流,从而把它读入内存中。这说明我们只需要选择一种合适的内存中的表示方式就可以了。所选择的表示方式需要能够很好地支持以下5种操作:

从输入的字节流创建该表示方式。

插入一个或多个字符。

删除一个或多个字符。

在其中查找一个string。

产生一个字节流从而输出到文件或屏幕中。

最简单的表示方式就是vector<char>。但是,在vector中,每加入或删除一个元素时我们就需要移动后面所有的元素。例如:

 

我们希望加入字符t:

 

但是,如果用vector<char>来存储这些字符,那么我们就需要把从h开始的所有字符都向右移动。这有可能会导致大量的复制操作。实际上,如果要在一篇70?000字的文本(差不多是英文版本章的长度,计空格)中插入或删除一个字符,那么我们平均需要移动35?000个字符。它所需要的执行时间会很长,使我们难以接受。因此,我们不妨把表示方式分成几块,这样不需要移动很多元素就可以对文本的某一部分进行修改。我们把文本文件看成由一系列“行”组成,并用list<Line>进行表示,其中Line是一个vector<char>。例如:

 

现在,当我们需要插入字符t时,只需要移动相应一行中的元素就可以了。而且,如果需要的话,我们可以插入新的一行而不需要移动任何元素。例如,我们可以在“document.”后面加入字符串“This is a new line.”。

 

而我们需要做的只是在它们中间加入一行:

 

能够在不移动现有链接的情况下在链表中加入新的链接是非常重要的,其逻辑上的原因是我们可能正用迭代器指向这些现有链接,或用指针(以及引用)指向这些链接中的对象。这样,这些迭代器或指针就不会受到插入行或删除行操作的影响。例如,文字处理器利用vector<list<Line>::iterator>来存储指向当前Document的每个标题和子标题的迭代器:

 

我们可以在不影响指向15.3节的迭代器的情况下向15.2节加入新的行。

也就是说,出于性能和逻辑上的考虑,我们采用的是一个“行”的list,而不是“行”的vector或一个存储所有字符的vector。请注意,由于这种情况很少出现,我们前面提到的“尽量使用vector”的准则仍然适用。如果要用list替代vector,那么你最好有充分的理由说服你自己(见15.7节)。不管(链接的)list还是vector都可以表示逻辑上的“列表”结构。STL中和我们日常所说的列表(如待办事项、杂货店列表或日程表)最为接近的是序列,而大多数序列最好的表示方式是vector。

15.6.1 处理行

我们应该如何判定文档中什么是“一行”呢?有三种显然的选择:

1. 靠换行符(例如'\n')来判断。

2. 用某种“自然的”标点(如.)采用某种方法分析文档。

3. 把所有超过给定长度(如50个字符)的行都分成两行。

毫无疑问还有其他不那么直观的方法。简单起见,这里我们选择第一种方法。

我们把编辑器中的文档表示成Document类的一个对象。抛开所有优化,我们的文档类型如下所示:

 

每个Document对象都以一个空行开始:Document的构造函数会创建一个空行并把它加入行的链表中。

读取文档并分行的操作可以按照如下方式完成:

 

vector和list都有一个back()成员函数,返回指向尾元素的引用。但使用back()时一定要确定确实存在尾元素:不要在一个空容器上使用它。这也是我们定义的Document都以一个空Line结尾的原因。注意我们会保存输入的每个字符,包括换行符('\n')。保存这些换行符极大地简化了输出,但你必须小心如何定义字符数(简单统计字符数得到的结果会包含空格和换行)。

15.6.2 遍历

如果用vector<char>来存储文档,那么遍历它就方便多了。那要如何遍历一个行的链表呢?我们当然可以使用list<Line>::iterator遍历链表,但如果希望可以逐个处理字符而不必考虑换行呢?为此,我们为Document类专门定义一个迭代器:

 

 

为了发挥Text_iterator的作用,我们为Document定义常规的begin()和end()操作:

 

我们需要奇怪的(*line.begin()).begin()语法,这是因为我们想获得line.begin()所指向的数据的开始位置;由于标准库迭代器支持->运算符,我们也可以使用替代语法line.begin()-> begin()。

现在我们可以按照如下方式遍历文档的字符了:

 

以字符序列的形式呈现文档在很多方面是很有用的,但通常我们遍历文档不是查找字符,而是查找更特定的东西。例如,下面的代码删除第n行:

 

 

调用advance(p,n)将迭代器p向前移动n个元素;advance()是标准库函数,不过我们也可以像下面这样实现自己的版本:

 

注意,advance()可用来模拟下标操作。实际上,对于一个vector v,p=v.begin(); advance(p, n);*p=x和v[n]=x大体上是一样的。之所以说是“大体一样”,是因为advance()一个元素一个元素费力地移动过前n-1个元素,直到下标指向第n个元素为止。对于一个list,我们不得不采用这种费力的方法,这是为更灵活的元素布局所付出的代价。

如果迭代器既支持向前移动又支持向后移动,比如list的迭代器,那么向标准库advance()函数传递负的参数会使迭代器向后移动。对能处理下标操作的迭代器,例如vector的迭代器,标准库advance()会直接移动到指定的元素,而不会用++运算符缓慢地移动。显然,标准库advance()确实比我们自己定义的版本要聪明一些。这点很值得注意:标准库特性通常都是花费了很多时间精心设计的,我们很难付出同样精力,所以还是优先使用标准库特性而不是“家庭制作”吧。

试一试

重写advance()函数,使它接受负的参数时向后移动。

对用户来讲,查找可能是最直观的一种迭代了。我们会查找某一个单词(例如milkshake或Gavin),查找某一不能被看作词组的字符序列(例如secret\nhomestead,也就是说,前一行以secret结尾,而后一行以homestead开始),或查找正则表达式(例如[bB]\w*ne,表示大写或小写字母B后接0或多个字母再接ne,详见第23章)。下面我们看看如何处理第二种情况:在我们的Document中查找某一给定的字符串。我们采用一种简单的非最优算法:

在文档中查找搜索串的第一个字符。

判断该字符及其后的字符是否与我们的搜索串匹配。

如果是,则结束;否则,继续查找搜索串首字符下一次出现的位置。

出于通用性的考虑,我们采用STL中常用的方式——将待搜索的文本定义为由一对迭代器所表示的序列。这样我们就可以像搜索完整文档一样对文档的任意部分应用我们的搜索函数。如果在文档中找到了我们所要的字符串,就返回一个指向其首字符的迭代器;否则返回一个指向序列末端的迭代器。

 

 

返回序列尾来表示“未找到”是一个非常重要的STL规范。match()函数非常简单,它只是对两个字符序列进行了比较。请尝试自己编写出这个函数。用来在字符序列中查找某一给定字符的f?ind()函数可能是标准库中最简单的算法了(见16.2节)。我们可以像下面这样使用f?ind_txt()函数:

 

我们的“文本处理器”非常简单。显然,我们的目的是简洁高效,而不是提供一个“特性丰富”的编辑器。但不要因此就傻傻地认为提供高效的插入、删除或查找任意序列的操作是一件非常简单的事情。我们选择这个例子来展示序列、迭代器和容器(如list和vector)等STL概念结合某些STL规范(或者说技术,如返回序列尾来表示失败)是多么强大和通用。注意,如果我们想要的话,也可以把Document定义成一个STL容器——实际上通过提供Text_iterator,我们已经完成了将一个Document表示为一个值序列的关键工作了。

上一篇:Python 进阶_OOP 面向对象编程_实例属性和方法


下一篇:集合的生产力工具类:Collections,我直呼好家伙。。