《ANSI Common Lisp》- 第七章:输入与输出【笔记】

Common Lisp 有着威力强大的 I/O 工具。针对输入以及一些普遍读取字符的函数,我们有 read ,包含了一个完整的解析器 (parser)。针对输出以及一些普遍写出字符的函数,我们有 format ,它自己几乎就是一个语言。本章介绍了所有基本的概念。

Common Lisp 有两种流 (streams),字符流与二进制流。本章描述了字符流的操作;二进制流的操作涵盖在 14.2 节。

7.1 流 (Streams)

流是用来表示字符来源或终点的 Lisp 对象。要从文件读取或写入,你将文件作为流打开。但流与文件是不一样的。当你在顶层读入或印出时,你也可以使用流。你甚至可以创建可以读取或写入字符串的流。

输入缺省是从 *standard-input* 流读取。输出缺省是在 *standard-output* 流。最初它们大概会在相同的地方:一个表示顶层的流。

我们已经看过 read 与 format 是如何在顶层读取与印出。前者接受一个应是流的选择性参数,缺省是 *standard-input* 。 format 的第一个参数也可以是一个流,但当它是 t 时,输出被送到 *standard-output* 。所以我们目前为止都只用到缺省的流而已。我们可以在任何流上面做同样的 I/O 操作。

路径名(pathname)是一种指定一个文件的可移植方式。路径名包含了六个部分:host、device、directory、name、type 及 version。你可以通过调用make-pathname 搭配一个或多个对应的关键字参数来产生一个路径。在最简单的情况下,你可以只指明名字,让其他的部分留为缺省:

[1]> (setf path (make-pathname :name "myfile"))

#P"myfile"

开启一个文件的基本函数是 open 。它接受一个路径名 [1] 以及大量的选择性关键字参数,而若是开启成功时,返回一个指向文件的流。

你可以在创建流时,指定你想要怎么使用它。 无论你是要写入流、从流读取或者同时进行读写操作,都可以通过 direction 参数设置。三个对应的数值是 :input, :output , :io 。如果是用来输出的流, if-exists 参数说明了如果文件已经存在时该怎么做;通常它应该是 :supersede (译注: 取代)。所以要创建一个可以写至 "myfile" 文件的流,你可以:

[2]> (setf str (open path :direction :output

                      :if-exists :supersede))

#<OUTPUT BUFFERED FILE-STREAM CHARACTER #P"myfile">

流的打印表示法因实现而异。

现在我们可以把这个流作为第一个参数传给 format ,它会在流印出,而不是顶层:

[3]> (format str "Something~%")

NIL

如果我们在此时检查这个文件,可能有输出,也可能没有。某些实现会将输出累积成一块 (chunks)再输出。直到我们将流关闭,它也许一直不会出现:

[4]> (close str)

T

当你使用完时,永远记得关闭文件;在你还没关闭之前,内容是不保证会出现的。现在如果我们检查文件 “myfile” ,应该有一行:

Something

在我的电脑输入vim myfile,看到的结果如下图所示。

如果我们只想从一个文件读取,我们可以开启一个具有 :direction :input 的流 :

[5]> (setf str (open path :direction :input))

#<INPUT BUFFERED FILE-STREAM CHARACTER #P"myfile" @1>

我们可以对一个文件使用任何输入函数。7.2 节会更详细的描述输入。这里作为一个示例,我们将使用 read-line 从文件来读取一行文字:

[6]> (read-line str)

"Something" ;

NIL

[7]> (close str)

T

当你读取完毕时,记得关闭文件。

大部分时间我们不使用 open 与 close 来操作文件的 I/O 。 with-open-file 宏通常更方便。它的第一个参数应该是一个列表,包含了变数名、伴随着你想传给open 的参数。在这之后,它接受一个代码主体,它会被绑定至流的变数一起被求值,其中流是通过将剩余的参数传给 open 来创建的。之后这个流会被自动关闭。所以整个文件写入动作可以表示为:

[8]> (with-open-file (str path :direction :output

                          :if-exists :supersede)

  (format str "Something~%"))

with-open-file 宏将 close 放在 unwind-protect 里 (参见 92 页,译注: 5.6 节),即使一个错误打断了主体的求值,文件是保证会被关闭的。

7.2 输入 (Input)

两个最受欢迎的输入函数是 read-line 及 read 。前者读入换行符 (newline)之前的所有字符,并用字符串返回它们。它接受一个选择性流参数 (optional stream argument);若流忽略时,缺省为 *standard-input* :

[9]> (progn

    (format t "Please enter your name: ")

    (read-line))

Please enter your name: Chan

"Chan" ;

NIL

如果你想要原封不动的输出,这是你该用的函数。(第二个返回值只在 read-line 在遇到换行符之前,用尽输入时返回真。)

在一般情况下, read-line 接受四个选择性参数: 一个流;一个参数用来决定遇到 end-of-file 时,是否产生错误;若前一个参数为 nil 时,该返回什么;第四个参数 (在 235 页讨论)通常可以省略。

所以要在顶层显示一个文件的内容,我们可以使用下面这个函数:

(defun pseudo-cat (file)

  (with-open-file (str file :direction :input)

    (do ((line (read-line str nil 'eof)

              (read-line str nil 'eof)))

        ((eql line 'eof))

      (format t "~A~%" line))))

如果我们想要把输入解析为 Lisp 对象,使用 read 。这个函数恰好读取一个表达式,在表达式结束时停止读取。所以可以读取多于或少于一行。而当然它所读取的内容必须是合法的 Lisp 语法。

如果我们在顶层使用 read ,它会让我们在表达式里面,想用几个换行符就用几个:

[15]> (read)

(a

b

c)

(A B C)

换句话说,如果我们在一行里面输入许多表达式, read 会在第一个表达式之后,停止处理字符,留下剩余的字符给之后读取这个流的函数处理。所以如果我们在一行输入多个表达式,来回应 ask-number (20 页。译注:2.10 小节)所印出提示符,会发生如下情形:

>(ask-number)

Please enter a number. a b

Please enter a number. Please enter a number. 43

43

两个连续的提示符 (successive prompts)在第二行被印出。第一个 read 调用会返回 a ,而它不是一个数字,所以函数再次要求一个数字。但第一个 read 只读取到 a 的结尾。所以下一个 read 调用返回 b ,导致了下一个提示符。

你或许想要避免使用 read 来直接处理使用者的输入。前述的函数若使用 read-line 来获得使用者输入会比较好,然后对结果字符串调用 read-from-string 。这个函数接受一个字符串,并返回第一个读取的表达式:

[18]> (read-from-string "a b c")

A ;

2

它同时返回第二个值,一个指出停止读取字符串时的位置的数字。

在一般情况下, read-from-string 可以接受两个选择性参数与三个关键字参数。两个选择性参数是 read 的第三、第四个参数: 一个 end-of-file (这个情况是字符串) 決定是否报错,若不报错该返回什么。关键字参数 :start 及 :end 可以用来划分从字符串的哪里开始读。

所有的这些输入函数是由基本函数 (primitive) read-char 所定义的,它读取一个字符。它接受四个与 read 及 read-line 一样的选择性参数。Common Lisp 也定义一个函数叫做 peek-char ,跟 read-char 类似,但不会将字符从流中移除。

7.3 输出 (Output)

三个最简单的输出函数是 prin1 , princ 以及 terpri 。这三个函数的最后一个参数皆为选择性的流参数,缺省是 *standard-output* 。

prin1 与 princ 的差别大致在于 prin1 给程序产生输出,而 princ 给人类产生输出。所以举例来说, prin1 会印出字符串左右的双引号,而 princ 不会:

[19]> (prin1 "Hello")

"Hello"

"Hello"

[20]> (princ "Hello")

Hello

"Hello"

两者皆返回它们的第一个参数 (译注: 第二个值是返回值) ── 顺道一提,是用 prin1 印出。 terpri 仅印出一新行。

有这些函数的背景知识在解释更为通用的 format 是很有用的。这个函数几乎可以用在所有的输出。他接受一个流 (或 t 或 nil )、一个格式化字符串 (format string)以及零个或多个额外的参数。格式化字符串可以包含特定的格式化指令 (format directives),这些指令前面有波浪号 ~ 。某些格式化指令作为字符串的占位符 (placeholder)使用。这些位置会被格式化字符串之后,所给入参数的表示法所取代。

如果我们把 t 作为第一个参数,输出会被送至 *standard-output* 。如果我们给 nil , format 会返回一个它会如何印出的字符串。为了保持简短,我们会在所有的示例里演示怎么做。

由于每人的观点不同, format 可以是令人惊讶的强大或是极为可怕的复杂。有大量的格式化指令可用,而只有少部分会被大多数程序设计师使用。两个最常用的格式化指令是 ~A 以及 ~% 。(你使用 ~a 或 ~A 都没关系,但后者较常见,因为它让格式化指令看起来一目了然。) 一个 ~A 是一个值的占位符,它会像是用 princ印出一般。一个 ~% 代表着一个换行符 (newline)。

[21]> (format nil "Dear ~A, ~% Our records indicate..."

                                              "Mr. Malatesta")

"Dear Mr. Malatesta,

Our records indicate..."

这里 format 返回了一个值,由一个含有换行符的字符串组成。

~S 格式化指令像是 ~A ,但它使用 prin1 印出对象,而不是 princ 印出:

[22]> (format t "~S ~A" "a" "b")

"a"  b

NIL

格式化指令可以接受参数。 ~F 用来印出向右对齐 (right-justified)的浮点数,可接受五个参数:

1)要印出字符的总数。缺省是数字的长度。

2)小数之后要印几位数。缺省是全部。

3)小数点要往右移几位 (即等同于将数字乘 10)。缺省是没有。

4)若数字太长无法满足第一个参数时,所要印出的字符。如果没有指定字符,一个过长的数字会尽可能使用它所需的空间被印出。

5)数字开始印之前左边的字符。缺省是空白。

下面是一个有五个参数的罕见例子:

[23]> (format nil "~10,2,0,'*,' F" 26.21875)

"    26.22"

这是原本的数字取至小数点第二位、(小数点向左移 0 位)、在 10 个字符的空间里向右对齐,左边补满空白。注意作为参数给入是写成 '* 而不是 #\* 。由于数字塞得下 10 个字符,不需要使用第四个参数。

所有的这些参数都是选择性的。要使用缺省值你可以直接忽略对应的参数。如果我们想要做的是,印出一个小数点取至第二位的数字,我们可以说:

[24]> (format nil "~,2,,,F" 26.21875)

"26.22"

你也可以忽略一系列的尾随逗号 (trailing commas),前面指令更常见的写法会是:

[25]> (format nil "~,2F" 26.21875)

"26.22"

警告: 当 format 取整数时,它不保证会向上进位或向下舍入。就是说 (format nil "~,1F" 1.25) 可能会是 "1.2" 或 "1.3" 。所以如果你使用 format 来显示资讯时,而使用者期望看到某种特定取整数方式的数字 (如: 金额数量),你应该在印出之前先显式地取好整数。

7.4 示例:字符串代换 (Example: String Substitution)

作为一个 I/O 的示例,本节演示如何写一个简单的程序来对文本文件做字符串替换。我们即将写一个可以将一个文件中,旧的字符串 old 换成某个新的字符串new 的函数。最简单的实现方式是将输入文件里的每一个字符与 old 的第一个字符比较。如果没有匹配,我们可以直接印出该字符至输出。如果匹配了,我们可以将输入的下一个字符与 old 的第二个字符比较,等等。如果输入字符与 old 完全相等时,我们有一个成功的匹配,则我们印出 new 至文件。

而要是 old 在匹配途中失败了,会发生什么事呢?举例来说,假设我们要找的模式 (pattern)是 "abac" ,而输入文件包含的是 "ababac" 。输入会一直到第四个字符才发现不匹配,也就是在模式中的 c 以及输入的 b 才发现。在此时我们可以将原本的 a 写至输出文件,因为我们已经知道这里没有匹配。但有些我们从输入读入的字符还是需要留着: 举例来说,第三个 a ,确实是成功匹配的开始。所以在我们要实现这个算法之前,我们需要一个地方来储存,我们已经从输入读入的字符,但之后仍然需要的字符。

一个暂时储存输入的队列 (queue)称作缓冲区 (buffer)。在这个情况里,因为我们知道我们不需要储存超过一个预定的字符量,我们可以使用一个叫做环状缓冲区 ring buffer 的资料结构。一个环状缓冲区实际上是一个向量。是使用的方式使其成为环状: 我们将之后的元素所输入进来的值储存起来,而当我们到达向量结尾时,我们重头开始。如果我们不需要储存超过 n 个值,则我们只需要一个长度为 n 或是大于 n 的向量,这样我们就不需要覆写正在用的值。

在图 7.1 的代码,实现了环状缓冲区的操作。 buf 有五个字段 (field): 一个包含存入缓冲区的向量,四个其它字段用来放指向向量的索引 (indices)。两个索引是start 与 end ,任何环状缓冲区的使用都会需要这两个索引: start 指向缓冲区的第一个值,当我们取出一个值时, start 会递增 (incremented); end 指向缓冲区的最后一个值,当我们插入一个新值时, end 会递增。

另外两个索引, used 以及 new ,是我们需要给这个应用的基本环状缓冲区所加入的东西。它们会介于 start 与 end 之间。实际上,它总是符合:

start ≤ used ≤ new ≤ end

你可以把 used 与 new 想成是当前匹配 (current match) 的 start 与 end 。当我们开始一轮匹配时, used 会等于 start 而 new 会等于 end 。当下一个字符 (successive character)匹配时,我们需要递增 used 。当 used 与 new 相等时,我们将开始匹配时,所有存在缓冲区的字符读入。我们不想要使用超过从匹配时所存在缓冲区的字符,或是重复使用同样的字符。因此这个 new 索引,开始等于 end ,但它不会在一轮匹配我们插入新字符至缓冲区一起递增。

函数 bref 接受一个缓冲区与一个索引,并返回索引所在位置的元素。借由使用 index 对向量的长度取 mod ,我们可以假装我们有一个任意长的缓冲区。调用(new-buf n) 会产生一个新的缓冲区,能够容纳 n 个对象。

要插入一个新值至缓冲区,我们将使用 buf-insert 。它将 end 递增,并把新的值放在那个位置 (译注: 递增完的位置)。相反的 buf-pop 返回一个缓冲区的第一个数值,接着将 start 递增。任何环状缓冲区都会有这两个函数。

图 7.1 环状缓冲区的操作

接下来我们需要两个特别为这个应用所写的函数: buf-next 从缓冲区读取一个值而不取出,而 buf-reset 重置 used 与 new 到初始值,分别是 start 与 end 。如果我们已经把至 new 的值全部读取完毕时, buf-next 返回 nil 。区别这个值与实际的值不会产生问题,因为我们只把值存在缓冲区。

最后 buf-flush 透过将所有作用的元素,写至由第二个参数所给入的流,而 buf-clear 通过重置所有的索引至 -1 将缓冲区清空。

在图 7.1 定义的函数被图 7.2 所使用,包含了字符串替换的代码。函数 file-subst 接受四个参数;一个查询字符串,一个替换字符串,一个输入文件以及一个输出文件。它创建了代表每个文件的流,然后调用 stream-subst 来完成实际的工作。

第二个函数 stream-subst 使用本节开始所勾勒的算法。它一次从输入流读一个字符。直到输入字符匹配要寻找的字符串时,直接写至输出流 (1)。当一个匹配开始时,有关字符在缓冲区 buf 排队等候 (2)。

变数 pos 指向我们想要匹配的字符在寻找字符串的所在位置。如果 pos 等于这个字符串的长度,我们有一个完整的匹配,则我们将替换字符串写至输出流,并清空缓冲区 (3)。如果在这之前匹配失败,我们可以将缓冲区的第一个元素取出,并写至输出流,之后我们重置缓冲区,并从 pos 等于 0 重新开始 (4)。

图 7.2 字符串替换

下列表格展示了当我们将文件中的 "baro" 替换成 "baric" 所发生的事,其中文件只有一个单字 "barbarous" :

第一栏是当前字符 ── c 的值;第二栏显示是从缓冲区或是直接从输入流读取;第三栏显示需要匹配的字符 ── old 的第 posth 字符;第四栏显示那一个条件式 (case)被求值作为结果;第五栏显示被写至输出流的字符;而最后一栏显示缓冲区之后的内容。在最后一栏里, used 与 new 的位置一样,由一个冒号 ( :colon)表示。

在文件 "test1" 里有如下文字:

The struggle between Liberty and Authority is the most conspicuous feature

in the portions of history with which we are earliest familiar, particularly

in that of Greece, Rome, and England.

在我们对 (file-subst " th" " z" "test1" "test2") 求值之后,读取文件 "test2" 为:

The struggle between Liberty and Authority is ze most conspicuous feature

in ze portions of history with which we are earliest familiar, particularly

in zat of Greece, Rome, and England.

为了使这个例子尽可能的简单,图 7.2 的代码只将一个字符串换成另一个字符串。很容易扩展为搜索一个模式而不是一个字面字符串。你只需要做的是,将 char=调用换成一个你想要的更通用的匹配函数调用。

7.5 宏字符 (Macro Characters)

一个宏字符 (macro character)是获得 read 特别待遇的字符。比如小写的 a ,通常与小写 b 一样处理,但一个左括号就不同了: 它告诉 Lisp 开始读入一个列表。

一个宏字符或宏字符组合也称作 read-macro (读取宏) 。许多 Common Lisp 预定义的读取宏是缩写。比如说引用 (Quote): 读入一个像是 'a 的表达式时,它被读取器展开成 (quote a) 。当你输入引用的表达式 (quoted expression)至顶层时,它们在读入之时就会被求值,所以一般来说你看不到这样的转换。你可以透过显式调用 read 使其现形:

[28]> (car (read-from-string "'a"))

QUOTE

引用对于读取宏来说是不寻常的,因为它用单一字符表示。有了一个有限的字符集,你可以在 Common Lisp 里有许多单一字符的读取宏,来表示一个或更多字符。

这样的读取宏叫做派发 (dispatching)读取宏,而第一个字符叫做派发字符 (dispatching character)。所有预定义的派发读取宏使用井号 ( # )作为派发字符。我们已经见过好几个。举例来说, #' 是 (function ...) 的缩写,同样的 ' 是 (quote ...) 的缩写。

其它我们见过的派发读取宏包括 #(...) ,产生一个向量; #nA(...) 产生数组; #\ 产生一个字符; #S(n ...) 产生一个结构。当这些类型的每个对象被 prin1显示时 (或是 format 搭配 ~S),它们使用对应的读取宏 [2] 。这表示着你可以写出或读回这样的对象:

[29]> (let ((*print-array* t))

    (vectorp (read-from-string (format nil "~S"

                                      (vector 1 2)))))

T

当然我们拿回来的不是同一个向量,而是具有同样元素的新向量。

不是所有对象被显示时都有着清楚 (distinct)、可读的形式。举例来说,函数与哈希表,倾向于这样 #<...> 被显示。实际上 #<...> 也是一个读取宏,但是特别用来产生当遇到 read 的错误。函数与哈希表不能被写出与读回来,而这个读取宏确保使用者不会有这样的幻觉。 [3]

当你定义你自己的事物表示法时 (举例来说,结构的印出函数),你要将此准则记住。要不使用一个可以被读回来的表示法,或是使用 #<...> 。

Chapter 7 总结 (Summary)

1)流是输入的来源或终点。在字符流里,输入输出是由字符组成。

2)缺省的流指向顶层。新的流可以由开启文件产生。

3)你可以解析对象、字符组成的字符串、或是单独的字符。

4)format 函数提供了完整的输出控制。

5)为了要替换文本文件中的字符串,你需要将字符读入缓冲区。

6)当 read 遇到一个宏字符像是 ' ,它调用相关的函数。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • PHP常用函数大全 usleep() 函数延迟代码执行若干微秒。 unpack() 函数从二进制字符串对数据进行解...
    上街买菜丶迷倒老太阅读 1,352评论 0 20
  • linux资料总章2.1 1.0写的不好抱歉 但是2.0已经改了很多 但是错误还是无法避免 以后资料会慢慢更新 大...
    数据革命阅读 12,135评论 2 34
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,600评论 18 139
  • 不知道大家有没有发现,一些好的受欢迎的文章,往往是用词凌乱,排版凌乱,标点凌乱,甚至有错别字,尽管乱七八糟,却挡不...
    烟草有毒阅读 937评论 0 3
  • 红豆洗相思,冬来春意迟。 与君何所示,此心不可知。
    摘梨生阅读 163评论 0 0