计数:重复和正则表达式
重复执行和正则表达式是Emacs Lisp中非常强大的工具。这章讲解使用while循环和递归结合正则表达式进行查找进行字数统计。
字数统计
标准的Emacs发行版中包含了一个统计region中行数的函数。但没有统计字数的函数。
count-words-region 函数
字数统计函数可以统计行、段落、region、或者整个缓冲区。到覆盖范围该多大?Emacs的鼓励使用弹性的方式。可以将函数设计为处理region。这样即使需要统计整个缓冲区,也可以先用C-x h(mark-whole-buffer)先选定整个缓冲区。
统计字数是一个重复的动作:从region的开始位置,开始统计第一个词,然后是第二个,然后第三个,如此继续直到缓冲区的结束位置。这意味着单词统计的工作适合于使用递归或者while循环。
设计count-words-region函数
首先,我们将使用while循环实现单词统计,然后是递归。当然,这个命令需要交互。
交互式函数定义如下:
(defun name-of-function (argument-list)
"documentation..."
(interactive-expression...)
body...)
我们所要做的就是填空。
函数名应该是自描述的与已存在的count-lines-region类似。这可以让命令名容易被记住。count-words-region是一个较好的名称。
这个函数统计region中的字数。这说明参数列表中需要两个符号,分别绑定到region的开始位置和结束位置。这两个位置可以被称为 beginning和end。文档字符串的第一行必须是一个完整的句子,因为有些命令将只打印文档的第一行,比如apropos命令。交互式语句 (interactive "r")将把缓冲区开始位置和结束位置放到参数列表中。
函数体需要完成三个任务:第一,设置条件,在这个条件下while循环可以统计字数。第二,执行while循环。第三,向用户显示信息。
当用户调用count-words-region时point可能位于region的开始位置或结束位置。但是,计数处理只能从region的开始 位置到结束位置计数。这意味着如果point没有在region的开始位置,则我们需要将point设置到region的开始位置,执行(goto- char beginning)。为了保证在函数执行完后,point可以恢复原来的位置,将需要用到save-excursion语句。
函数体的中心部分是由一个while循环组成,它内部有一个每次向前跳转一个单词的语句,另一个语句负责计数。while语句的true-or-false-test应该在point达到region结束位置时返回false,在此之前返回true。
我们可以使用(forward-word 1)作为向前移动一个单词的语句,如果我们使用正则表达式搜索就很容易明白Emacs中对于'word'的界定。
通过一个正则表达式查找到那个位置并把point设置在最后一个字符的后面。这表示成功的向前移了一个单词。
实际上还有一个问题,我们需要这个正则表达式跳过单词间的空格和标点符号。这表明正则表达式需要能匹配单词后面的空白和标点符号。(一个单词后面也可能没有空白和标点,因此正则表达式的这一部分应该是可选的)
因此,我们需要的正则表达式,要能匹配一个或多个构词字符(能构成单词的字符),后面跟一个可选的由一个或多个非构词字符(不能用于构成单词的字符)。正则表达式如下:
\w+\W*
缓冲区的语法表决定了哪些是构词字符。
查找语句如下:
(re-search-forward "\\w+\\W*")
(注意w和W前面的双斜线。单个斜线对于Emacs Lisp解释器来说有特殊意义。它表明后面一个字符需要不同的处理。比如,\n
表示换行。两个斜线表示斜线)
我们还需要一个计数器用于计数;这个变量初始时必须为0,然后在每次执行while循环体时增加。这个语句如下:
(setq count (1+ count))
最后我们需要告诉用户region中有多少个字符。message函数用于向用户显示信息。显示信息只需要一个短语,我们并不需要很复杂。到底是简 单还是复杂。我们可以用一个条件语句来解决定个问题。共有三种可能:region中没有单词,region只有一个单词,或者有多个单词。这时crond 比较合适。
初步的函数定义如下:
;;; First version; has bugs!这个函数能够工作,但并不是在所有的情况下。
(defun count-words-region (beginning end)
"Print number of words in the region.
Words are defined as at least one word-constituent
character followed by at least one character that
is not a word-constituent. The buffer's syntax
table determines which characters these are."
(interactive "r")
(message "Counting words in region ... ")
;;; 1. Set up appropriate conditions.
(save-excursion
(goto-char beginning)
(let ((count 0))
;;; 2. Run the while loop.
(while (< (point) end)
(re-search-forward "\\w+\\W*")
(setq count (1+ count)))
;;; 3. Send a message to the user.
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
count-words-region函数中空白处理的Bug
前面描述的count-words-region命令有两个Bug,或者说一个Bug的两个表现。首先,如果 region中只在某些文本间有空白,count-words-region命令将告诉你region中只包含了一个单词。第二,如果region中只有 缓冲区结束位置或者narrowed缓冲区的可访问域的结束位置有空白,命令在执行时将显示错误信息:
Search failed: "\\w+\\W*"
可以在Emacs中先安装这个函数,然后将它绑定到按键上:
(global-set-key "\C-c=" 'count-words-region)可以在设置region后按
C-c =
执行(如果没有绑定按键,可以用M-x count-words-region执行)。
对下面的内容执行时Emacs将告诉你,region有3个单词。
one two three
如果把mark设置在这行的开头位置,point放在one
的前面。重新执行C-c =
。Emacs应该要告诉你region中没有单词,因为region只有空白。但是,Emacs告诉你region中只有一个单词。
第三个测试,复制上面例的整行到*scratch*缓冲区中并在行的结束位置输入一些空格。将mark设置在单词three
的后面,然后point设置在行的结束位置(在这里即缓冲区的结束位置)。输入C-c =
。这次Emacs应该告诉你region中没有单词。但是Emacs这次却显示了一个错误信息Search failed
。
这两个bug来自于同一个问题。
思考这个Bug的第一个表现,命令告诉你行的开始位置的空白包含一个单词。它是这样产生的:count-words-region命令先将 point移到region的开始位置。然后测试当前point的位置是否小于end变量的值。结果为true。接下来,通过表达式查找第一个单词。它将 point设置在第一个单词的后面。count被设置为1。while循环重复,但这时point已经大于end的值了,循环退出;函数显示信息说在 region中有一个单词。简单来说就是由于正则表达式查询时,它查找到的单词的结束位置超过了region的区域。
Bug的第二个表现中,region是缓冲区结束位置的空白。Emacs说Search failed。这是由于在while的true-or-false-test返回true,search语句被执行。但是由于没找到匹配项,因此查询失败。
这两种情况都是由于查询时扩展或者试图扩展到region的外部。
解决办法就是限制查询的区域,一个很简单的动作,但并没有想像的那么简单。
前面在讲re-search-forward函数时,它接收四个参数。第一个参数是必需的,其它三个是可选参数。它的第二个参数是用于限定查询范围 的。第三个可选参数,如果为t,则函数将在查询失败时返回nil,而不显示错误信息。第四个可选参数是重复次数。(可以用C-h f查找函数的文档)
在count-words-region函数定义中,region的结束位置被以设置到end参数上,它将作为函数参数传入。因此我们可以把end作为正则表达式查询时的参数。
(re-search-forward "\\w+\\W*" end)如果只对count-words-region的定义作上面的修改,在遇到一些空白字符时,仍将得到Search failed的错误。
这是因为,有可能在限制的范围内,搜索不到构词字符。搜索将失败,并显示错误信息。但我们在这时并不想要获取错误信息,我们需要显示"The region does NOT have any words."。
解决这一问题的办法就是将re-search-forward的第三个参数设置为t,这样在函数在搜索失败时将返回nil。
如果你尝试运行程序,你将看到信息"Couting words in region..."并一直看到这条消息,直到你输入C-g(keyboard-quit)。
当在限制查询范围的region中搜索时,和前面一样,如果region中没有构词字符,搜索将失败。re-search-forward语句返回 nil。这时point也不会被移动,而循环中的下一条语句将被执行。这条语句将计数增加。然后循环继续。true-or-false-test将一直返 回true,因为point仍小于end参数,程序将陷入死循环。
count-words-region的定义还需要一些修改,以便在搜索失败时让true-or-false-test返回false。可以在 true-or-false-test中增加一个条件,true-or-false-test在增加计数前需要满足下面的条件:point必须在 region之内,且查询的语句必须找到了一个单词。
因为两个条件都必须为true。所以区域范围检查和搜索语句可以用and连接起来,都作为while循环的true-or-false-test:
(and (< (point) end) (re-search-forward "\\w+\\W*" end t))re-search-forward在成功搜索到单词后将返回t,并移动point,只要能找到单词,point将继续移动。当搜索失败或者point达 到region的结束位置时,true-or-false-test将返回false。while循环退出,count-words-region函数显 示一个或多个信息。
修改完后的count-words-region函数如下:
;;; Final version: while
(defun count-words-region (beginning end)
"Print number of words in the region."
(interactive "r")
(message "Counting words in region ... ")
;;; 1. Set up appropriate conditions.
(save-excursion
(let ((count 0))
(goto-char beginning)
;;; 2. Run the while loop.
(while (and (< (point) end)
(re-search-forward "\\w+\\W*" end t))
(setq count (1+ count)))
;;; 3. Send a message to the user.
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
递归方式统计单词数量
上一节已经编写过了通过while循环进行计数的函数。
在这个函数中,count-words-region函数完成了三个工作:为计数设置适当的条件;计算region中的字数;将字数显示给用户。
如果我们在一个递归函数中执行所有的操作,则我们将在每次递归调用时都会得到字数的消息。如果region中包含了13个单词,消息将显示13次。 这并不是我们需要的,我们需要写两个函数来做这个工作,一个函数(递归函数)将在另一个函数内部被使用。一个设置条件和显示信息,国一个返回字数。
开始编写函数。我们仍把这个函数叫作count-words-region。
根据前一个版本,我们可以描述出这个程序的结构:
;; Recursive version; uses regular expression search定义很直接,不同的地方是递返回的数字必须传递给message来显示。这可以用let语句来完成:我们可以用let语句把字数赋给一个变量,并把这个值作为递归部分的返回值。使用cond语句,用于设置变量和显示信息给用户。
(defun count-words-region (beginning end)
"documentation..."
(interactive-expression...)
;;; 1. Set up appropriate conditions.
(explanatory message)
(set-up functions...
;;; 2. Count the words.
recursive call
;;; 3. Send a message to the user.
message providing word count))
通常let语句总被作为函数的'次要工作'。但在这里,let将作为函数的主要工作,统计字数的工作就是在let语句中。
使用let时函数定义如下:
(defun count-words-region (beginning end)
"Print number of words in the region."
(interactive "r")
;;; 1. Set up appropriate conditions.
(message "Counting words in region ... ")
(save-excursion
(goto-char beginning)
;;; 2. Count the words.
(let ((count (recursive-count-words end)))
;;; 3. Send a message to the user.
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
接下来我们需要编写递归计数函数。
递归函数至少有三个部分:'do-again-test','next-step-expresssion'和递归调用。
do-again-test决定函数是否继续调用。因为我们在统计region中的单词时我们使用了移动point的函数,do-again- test可以检查point是否位于region中。do-again-test需要检查point是位于region结束位置的前面还是后面。我们可以 使用point函数获取point的位置信息,我们还需要传递将region的结束位置作为参数传递到递归计数函数里。
另外,do-again-test还需要检查是否找到了一个单词。如果没有,函数就不再需要继续调用它自己了。
next-step-expression修改某个值以便递归函数能在适当的时候停止递归调用。在这里next-step-expression可以是移动point的语句。
递归函数的第三个部分是递归调用。
在这个函数中我们也需要在某个地方执行计数工作。
这样,我们有了一个递归计数函数的原型:
(defun recursive-count-words (region-end)现在我们需要填空。首先我们从最简单的一种情况开始:point位于region结束位置或位于region之外,region中没有单词,因此函数需要返回0。同样,如果搜索失败,函数也需要返回0。
"documentation..."
do-again-test
next-step-expression
recursive call)
另一方面,如果point在region内部,并且搜索成功,函数应该再次调用它自己。
这样,do-again-test应该如下:
(and (< (point) region-end)注意,查找语句是do-again-test函数的一部分,在搜索成功时返回t,失败时返回nil。
(re-search-forward "\\w+\\W*" region-end t))
do-again-test是if语句的true-or-false子句。如果do-again-test成功,则if语句的then部分执行,如果失败,则应该返回0,因为不管point是位于region的外面还是搜索失败都表示region中没有单词。
另外,do-again-test返回t或nil时,re-search-forward将在搜索成功时移动point。这是修改point的值并 让递归函数在point移出region后停止递归调用的操作。因此,re-earch-foreard语句就是next-step- expression。
recursive-count-words函数如下:
(if do-again-test-and-next-step-combined
;; then
recursive-call-returning-count
;; else
return-zero)
怎样加入计数机制呢?
我们知道计数机制应该与递归调用联合起来。由于next-step-expression将point一个个单词的移动,因此,针对每个单词都会调用一次递归函数,计数机制必须有一个语句将recursive-count-words的返回值加1。
思考下面几种情况:
- 如果region中有两个单词,函数在遇到第一个单词时,需要返回region中其它单词数量(这里为1)加1的值。
- 如果region中只有一个单词,函数在遇到第一个单词时,需要返回region中其它单词数量(这里为0)加1的值。
- 如果region中没有单词,函数需要返回0。
从上面的描述中可以看出if语句的else部分在没有单词时返回0。而if语句的then部分必须返回1加上region中其它单词数量的值。
语句如下,使用了函数1+使它的参数加1。
(1+ (recursive-count-words region-end))整个recursive-count-words函数如下:
(defun recursive-count-words (region-end)
"documentation..."
;;; 1. do-again-test
(if (and (< (point) region-end)
(re-search-forward "\\w+\\W*" region-end t))
;;; 2. then-part: the recursive call
(1+ (recursive-count-words region-end))
;;; 3. else-part
0))
研究一下它是如何工作的:
当region中没有单词时,if语句的else部分被执行,函数返回0。
如果region中有一个单词,point的值小于region-end并且搜索成功。这时,if语句的true-or-false-test为true,if语句的then部分被执行。计数语句被执行。这个语句将返回(整个函数的返回值)递归调用的返回值加1的结果。
与此同时,next-step-expression将使point跳过region中的第一个单词。这表示当(recursive-count- words region-end)在第二次时被执行,并作为递归调用的结果,point的值将等于或大于region的结束位置。这样,recursive- count-words将返回0。最初的recursive-count-words将返回0+1,计数正确。
如果region中有两个单词,第一次调用recursive-count-words将返回1加上在包含其它单词的region上调用recursive-count-words的返回值,这里将是1加1,2是正确的返回值。
类似地,如果region中包含有3个单词,第一次调用recursive-count-words将返回1加上在包含其它单词的region上调用recursive-count-words的返回值,如此继继续。
整个程序包含了两个函数:
递归函数:
(defun recursive-count-words (region-end)
"Number of words between point and REGION-END."
;;; 1. do-again-test
(if (and (< (point) region-end)
(re-search-forward "\\w+\\W*" region-end t))
;;; 2. then-part: the recursive call
(1+ (recursive-count-words region-end))
;;; 3. else-part
0))
包装函数:
;;; Recursive version
(defun count-words-region (beginning end)
"Print number of words in the region.
Words are defined as at least one word-constituent
character followed by at least one character that is
not a word-constituent. The buffer's syntax table
determines which characters these are."
(interactive "r")
(message "Counting words in region ... ")
(save-excursion
(goto-char beginning)
(let ((count (recursive-count-words end)))
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message "The region has 1 word."))
(t
(message
"The region has %d words." count))))))