读取和写入文件 数据一般都是存储在纯文本文件当中,存储的形式多种多样。本文,我会介绍如何在Clojure中读取和写入这些数据。
1. 打开文件 新建文件hello.txt ,放到resources 目录,内容如下:
1 2 3 hello world! hello lambeta! hello life!
新建4io.clj ,输入程序:
1 2 3 4 5 6 (ns the-way-to-clojure.4io (:require [clojure.java.io :as io] [clojure.string :as str])) (def data-file (io/resource "hello.txt" )) (slurp data-file)
运行程序,输出如下:
1 "hello world! \nhello lambeta!\nhello life!\n"
读取所有行 1 2 (line-seq (io/reader data-file))
with-open宏 with-open宏用于自动关闭打开的文件。
1.1 读取一行,如下:
1 2 3 (with-open [rdr (io/reader data-file)] (when-let [line (.readLine rdr)] (println line)))
1.2 读取多行,如下:
1 2 3 4 5 6 (with-open [rdr (io/reader data-file)] (loop [line (.readLine rdr)] (when line (println line) (recur (.readLine rdr)))))
2. 读取文件的技巧 想想读取文件可能有哪些场景?
1 2 3 4 5 6 (with-open [rdr (io/reader data-file)] (first (line-seq rdr))) (with-open [rdr (io/reader data-file)] (take 1 (line-seq rdr))) -> "hello world!"
1 2 3 (with-open [rdr (io/reader data-file)] (doall (take 2 (line-seq rdr)))) -> ("hello world!" "hello lambeta!" )
这里使用了(doall )
方法,如果不用这个方法,在repl中求值的时候会表达式导致抛出Unhandled java.io.IOException Stream closed
异常。究其缘由是(take 2 )
返回了一个惰性序列,详细解释参见文末备注。
1 2 3 4 5 6 7 8 9 with-open [rdr (io/reader data-file)] (loop [ch (.read rdr) len 20 ] (when-not (or (= -1 ch) (zero? len)) (println (char ch)) (recur (.read rdr) (dec len))))) | h | e | ... -> nil
在resources 目录下,新建records.txt ,内容即代码注释所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 (defn read-records [input-file] "Coloured fox fur production, HOPEDALE, Labrador, 1834-1842 #Source: C. Elton (1942) \"Voles, Mice and Lemmings\", Oxford Univ. Press #Table 17, p.265--266 22 29 2 16 12 35 8 83 166" (letfn [(skip [lines] (next lines))] (with-open [rdr ((comp io/reader io/resource) input-file)] (->> (for [line (skip (line-seq rdr)) :when (not (.startsWith line "#" ))] (read-string line)) (apply +))))) (read-records "records.txt" ) -> 373
我们在read-records
内部新建一个skip
方法,顾名思义,跳过第一个元素,然后返回后面的列表。这里旨在跳过文本的声明头。:when (not ...)
过滤了文本的注释部分(以#开头的行),并使用read-string
转换字符串到数字类型,(for )
求值完成后返回只包含数字的列表。最后,我们对列表做了一次累加操作。
我们试试非过滤而是跳过(删除)以”#”开头行的方式获取数字列表,这样更符合要求。重写with-open 部分,如下:
1 2 3 4 5 6 (with-open [rdr ((comp io/reader io/resource) input-file)] (apply + (let [lines (skip (line-seq rdr))] (->> lines (remove (set (for [line lines :while (.startsWith line "#" )] line))) (map read-string)))))
或者
1 2 3 4 5 6 (with-open [rdr ((comp io/reader io/resource) input-file)] (apply + (let [lines (skip (line-seq rdr))] (->> lines (drop (count (for [line lines :while (.startsWith line "#" )] line))) (map read-string)))))
3. 读取网络文件 通过slurp读取字符串
1 2 3 (slurp "http://robjhyndman.com/tsdldata/ecology1/hopedale.dat" :encoding "utf-8") -> "Coloured fox fur production, HOPEDALE, Labrador,, 1834-1925\n#Source: C. Elton (1942) \"Voles, Mice and Lemmings\", Oxford Univ. Press\n#Table 17, p.265--266\n 22 \n...
注意,这个网页上的数据是用UTF-8编码的,所以解码读取时,也应该使用UTF-8。
4. 写入文件
1 (spit "world.txt" "Hello, lambeta!" :append true )
运行程序之后,项目的根目录下会生成world.txt 文件,内容是Hello, lambeta 。spit方法其实就是向Java的BufferedWriter中写入内容。
我们在项目的根目录新建numbers.txt ,内容是多行的数字对,如下:
我们需要把每行两个数字,和它们相加的结果写入到sum-of_numbers.txt 文件中。也就是注释中的描述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 (defn sum-number-pairs [input-file output-file] "Read the data from input-file, which contains two floats per line separated by a space. Open file named output-file and, for each line in input-file, write a line to the output file that contains the two floats from the corresponding line of input-file plus a space and the sum of the two floats." (with-open [rdr (io/reader input-file) wtr (io/writer output-file :append true )] (loop [line (.readLine rdr)] (when line (let [pair (map read-string (str/split line #"\s" )) first (first pair) second (second pair) sum (+ first second)] (.write wtr (str first " " second " " sum "\n" ))) (recur (.readLine rdr)))))) (sum-number-pairs "numbers.txt" "sum-of-numbers.txt" )
with-open同时打开了一个用于读取、名为input-file 的文件以及一个用于写入、名为output-file 的文件,写入方式是追加:append true
。随后循环读取input-file 中的每行内容。若line 不是nil(即存在),那么用空格分隔这行内容,得到一个数组,如:”1.3 2.7” -> [“1.3” “2.7”]。此时数组的元素类型还不是数字(Number),我们使用(map read-string )
将元素转换为对应的数字类型,如:[“1.3” “2.7”] -> [1.3 2.7]。之后,分别提取数组的第一、二个元素以及两者的和。最后,写入到wtr中。
注意 :程序中的str/split 是通过(:require [clojure.string :as str])
方式引入str 命名空间的。
运行程序之后,sum-of-numbers.txt 中的内容如下:
1 2 3 1.3 2.7 4.0 10000 1 10001 -1 1 0
5. 多行记录 5.1 有结束标识 有时候,记录并不是以一行一行的方式存储在文件当中的,而是以多行数据描述一条记录。比如下面的蛋白质数据:清单 5.1 multimol.pdb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 COMPND AMMONIA ATOM 1 N 0.257 -0.363 0.000 ATOM 2 H 0.257 0.727 0.000 ATOM 3 H 0.771 -0.727 0.890 ATOM 4 H 0.771 -0.727 -0.890 END COMPND METHANOL ATOM 1 C -0.748 -0.015 0.024 ATOM 2 O 0.558 0.420 -0.278 ATOM 3 H -1.293 -0.202 -0.901 ATOM 4 H -1.263 0.754 0.600 ATOM 5 H -0.699 -0.934 0.609 ATOM 6 H 0.716 1.404 0.137 END
第一行描述的是分子的名字,接下来到END为止的每行代表原子的ID、类型以及在分子中分布的[x y z]坐标。 我们需要一个函数,将数据读取出来并且以规定的格式输出,格式如下:
1 2 3 4 5 6 7 8 9 10 11 12 (("AMMONIA" ("N" "0.257" "-0.363" "0.000") ("H" "0.257" "0.727" "0.000") ("H" "0.771" "-0.727" "0.890") ("H" "0.771" "-0.727" "-0.890")) ("METHANOL" ("C" "-0.748" "-0.015" "0.024") ("O" "0.558" "0.420" "-0.278") ("H" "-1.293" "-0.202" "-0.901") ("H" "-1.263" "0.754" "0.600") ("H" "-0.699" "-0.934" "0.609") ("H" "0.716" "1.404" "0.137")))
也就是说,我们需要把每条记录读入单个列表中,每个列表由分子的名称和多个(Type X Y Z)
的原子列表组成。
1 2 3 4 5 6 7 8 9 10 11 (defn read-all-molecules [input-file] (map (fn [molecules] (let [[_ name] (str/split (first molecules) #"\s+") atoms (map (comp #(drop 2 %) #(str/split % #"\s+")) (rest molecules))] (concat [name] atoms))) ;; 分割成多条记录 (remove #(= % ["END"]) (partition-by #(= % "END") (line-seq (io/reader input-file)))))) (read-all-molecules "multimol.pdb")
(remove #(= % ["END"]) (partition-by #(= % "END") (line-seq (io/reader input-file))))
这行代码做的事情就是把文件读取出来变成一个lazy-seq
,然后使用parttition-by
以END 进行分组,最后使用remove
方法剔除掉*[“END”]*这样的分组,得到如下中间结果:
1 2 3 4 5 6 7 8 9 10 11 12 (("COMPND AMMONIA" "ATOM 1 N 0.257 -0.363 0.000" "ATOM 2 H 0.257 0.727 0.000" "ATOM 3 H 0.771 -0.727 0.890" "ATOM 4 H 0.771 -0.727 -0.890") ("COMPND METHANOL" "ATOM 1 C -0.748 -0.015 0.024" "ATOM 2 O 0.558 0.420 -0.278" "ATOM 3 H -1.293 -0.202 -0.901" "ATOM 4 H -1.263 0.754 0.600" "ATOM 5 H -0.699 -0.934 0.609" "ATOM 6 H 0.716 1.404 0.137"))
这样离我们的目标已经很近了。观察上述结果,不难发现分子的名称处于列表的第一个(first )
,而原子列表可以使用(rest )
获取。然后,借助(map )
函数遍历所有的记录。
(let )
中的第一个binding是[_ name] (str/split (first molecules) #"\s+")
,首先用(split )
函数分割,再使用了解构提取出分子的名称;第二个binding是原子列表的提取,我们在(split )
的基础之上,使用(drop 2 )
函数剔除了不用的字段,如:ATOM和1。最后使用(concat )
函数将名称和原子列表的列表拼接到一起。
5.2 无结束标识 5.1中的记录项通过END 标识分隔,但是事实上这是一个多余的字段,记录项可以更简练,如下:清单 5.2 multimol-without-end-marker.pdb
1 2 3 4 5 6 7 8 9 10 11 12 COMPND AMMONIA ATOM 1 N 0.257 -0.363 0.000 ATOM 2 H 0.257 0.727 0.000 ATOM 3 H 0.771 -0.727 0.890 ATOM 4 H 0.771 -0.727 -0.890 COMPND METHANOL ATOM 1 C -0.748 -0.015 0.024 ATOM 2 O 0.558 0.420 -0.278 ATOM 3 H -1.293 -0.202 -0.901 ATOM 4 H -1.263 0.754 0.600 ATOM 5 H -0.699 -0.934 0.609 ATOM 6 H 0.716 1.404 0.137
现在的问题变成了没有END 标识符,如何进行分组?观察不难发现以COMPND 开头的数据行可以作为记录的分隔符。 使用(partition-by #(.startsWith % "COMPND") (line-seq (io/reader input-file)))
进行分组,得到的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 (("COMPND AMMONIA" ) ("ATOM 1 N 0.257 -0.363 0.000" "ATOM 2 H 0.257 0.727 0.000" "ATOM 3 H 0.771 -0.727 0.890" "ATOM 4 H 0.771 -0.727 -0.890" ) ("COMPND METHANOL" ) ("ATOM 1 C -0.748 -0.015 0.024" "ATOM 2 O 0.558 0.420 -0.278" "ATOM 3 H -1.293 -0.202 -0.901" "ATOM 4 H -1.263 0.754 0.600" "ATOM 5 H -0.699 -0.934 0.609" "ATOM 6 H 0.716 1.404 0.137" ))
此时,我们对比5.1中中间结果,会发现它们极为相似。也就是说,我们稍加转换就能让两者一致,而一致的好处就是可以复用原来(map )
中的逻辑。
稍稍修改原来的分组逻辑,如下:
1 2 3 4 5 (map (fn [[name atoms]] (concat name atoms)) (partition 2 (partition-by #(.startsWith % "COMPND") (line-seq (io/reader input-file)))))
我们先使用(partition 2 )
将第一步得到的列表每隔两个元素划为一组,如下:
1 2 3 4 5 6 7 8 9 10 11 12 ((("COMPND AMMONIA") ("ATOM 1 N 0.257 -0.363 0.000" "ATOM 2 H 0.257 0.727 0.000" "ATOM 3 H 0.771 -0.727 0.890" "ATOM 4 H 0.771 -0.727 -0.890")) ; 多出一对括号 (("COMPND METHANOL") ("ATOM 1 C -0.748 -0.015 0.024" "ATOM 2 O 0.558 0.420 -0.278" "ATOM 3 H -1.293 -0.202 -0.901" "ATOM 4 H -1.263 0.754 0.600" "ATOM 5 H -0.699 -0.934 0.609" "ATOM 6 H 0.716 1.404 0.137")))
然后使用(map (fn [[name atoms]] ...)
将每组里面的两个列表合成为一个列表,这样就得到和原来5.1一模一样的中间结果。
接下来,我们把转换的逻辑从(read-all-molecules )
中提取出来,以便复用。改造如下:
1 2 3 4 5 6 7 (defn read-all-molecules [f input-file] (let [data (f input-file)] (map (fn [molecules] (let [[_ name] (str/split (first molecules) #"\s+" ) atoms (map (comp #(drop 2 %) #(str/split % #"\s+" )) (rest molecules))] (concat [name] atoms))) data)))
定义转换逻辑,如下:
1 2 3 4 5 6 (defn file-without-markers->multi-records [input-file] (map (fn [[name atoms]] (concat name atoms)) (partition 2 (partition-by #(.startsWith % "COMPND" ) (line-seq (io/reader input-file))))))
最后,我们来调用的改造之后的方法:
1 2 (read-all-molecules file-without-markers->multi-records "multimol-without-end-marker.pdb" )
此时,5.1中的转换逻辑也可以提取出一个函数:
1 2 3 (defn file->multi-records (remove #(= % ["END" ]) (partition-by #(= % "END" ) (line-seq (io/reader input-file)))))
原来的程序就重构成了如下的模样:
1 (read-all-molecules file->multi-records "multimol.pdb" )
备注
为了清楚定位这个问题,我们需要提前了解两个知识点
什么是惰性序列?
惰性序列在repl中什么时候变现(realizes)?
惰性序列是用(lazy-seq [& body] )
宏创建出来的。lazy-seq
仅在需要的时候才会去调用它的body。 当repl尝试pretty-print
惰性序列的结果时,才会进行变现操作。
有了上面的知识点,我们来考察with-open
和(take 2 (line-seq ))
的关系。with-open
是宏,我们使用clojure.walk/macroexpand-all
展开下:
1 2 3 4 5 6 7 8 (clojure.walk/macroexpand-all '(with-open [rdr (io/reader data-file)] (take 2 (line-seq rdr)))) -> (let* [rdr (io/reader data-file)] (try (do (take 2 (line-seq rdr))) (finally (. rdr clojure.core/close))))
使用(doc line-seq)
查看文档,得到
1 2 3 4 5 clojure.core/line-seq [rdr] Added in 1.0 Returns the lines of text from rdr as a lazy sequence of strings. rdr must implement java.io.BufferedReader.
可以确认line-seq
返回一个惰性的字符串序列。 再看看(doc take)
的文档,得到
1 2 3 4 5 6 7 clojure.core/take [n] [n coll] Added in 1.0 Returns a lazy sequence of the first n items in coll, or all items if there are fewer than n. Returns a stateful transducer when no collection is provided.
所以take
返回的也是一个惰性序列,那么(do (take 2 (line-seq rdr)))
(等价于(take 2 (line-seq rdr))
)整个返回的就是一个惰性序列。
当我们通过repl求值with-open
时,它并没有真的变现(take 2 (line-seq rdr))
,而是在运行完try...finally
之后,直接返回这个惰性序列作为结果。此时,repl开始尝试pretty-print (take 2 (line-seq rdr))
,变现发生,但是rdr已经被关闭了,所以抛出Stream closed 异常。
到这里,解决了一大半问题,但是还有一个逻辑上解释不过去的点,就是
1 2 (with-open [rdr (io/reader data-file)] (take 1 (line-seq rdr)))
当我们尝试(take 1 )
时并不会抛出异常!也就是说(take 1 )
和(take 2 )
的行为不同,但是(take )
明明都是返回惰性序列啊?
带着这个疑惑,看看line-seq
的源代码
1 2 (when-let [line (.readLine rdr)] (cons line (lazy-seq (line-seq rdr)))))
是不是有种豁然开朗的感觉?没有也没关系,我来解释一下。line-seq
的when-let
语句并没有包在(lazy-seq )
(这点可以和take
的源码比较)中,这说明[line (.readline rdr)]
是需要立即求值的。也就是说,我们在求值with-open
时,rdr中第一行的内容会被(line-seq )
给抓住了。那么当try...finally
运行结束之后,pretty-print变现惰性序列时,发现第一行根本不需要从rdr中读,当然就不会抛出异常了。
明确这几点之后,我们看看(doall )
为何能解决惰性序列延迟求值的问题?(doall )
其实强制变现了整个惰性序列(不断调用序列的next
方法),所以并不会等到with-open
求值完成之后才求值。
换个角度,我们知道之所以抛出异常,是因为repl对返回的惰性序列求值了。那么如果我们不在repl中求值,程序还会抛出异常吗?
1 2 3 4 5 6 7 (ns the-way-to-clojure.core (:require [clojure.java.io :as io]) (:gen-class )) (defn -main [& args] (with-open [rdr (io/reader "hello.txt" )] (take 100 (line-seq rdr))))
接着,我们使用lein run
来运行main
方法。程序运行良好,因为根本没有人用到返回的惰性序列。
如果我们加一句打印语句如下:
1 2 3 4 (defn -main [& args] (println ; 变现 (with-open [rdr (io/reader "hello.txt")] (take 100 (line-seq rdr)))))
再用lein run
跑一个main
方法,异常又不期而遇了。因为此处的println
等价于repl
的pretty print
。