美文网首页Clojure极简教程lisp语言lisp
Clojure文件操作和惰性序列

Clojure文件操作和惰性序列

作者: lambeta | 来源:发表于2016-09-04 14:36 被阅读339次

    读取和写入文件

    数据一般都是存储在纯文本文件当中,存储的形式多种多样。本文,我会介绍如何在Clojure中读取和写入这些数据。

    1. 打开文件

    新建文件hello.txt,放到resources目录,内容如下:

    hello world!
    hello lambeta!
    hello life!
    

    新建4io.clj,输入程序:

    (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) 
    

    运行程序,输出如下:

    "hello world! \nhello lambeta!\nhello life!\n"
    

    读取所有行

    (line-seq (io/reader data-file))
    ;;=> ("hello world!" "hello lambeta!" "hello life!")
    

    with-open宏

    with-open宏用于自动关闭打开的文件。

    1.1 读取一行,如下:

    (with-open [rdr (io/reader data-file)]
      (when-let [line (.readLine rdr)]
        (println line)))
    

    1.2 读取多行,如下:

    ;;; read multiple lines
    (with-open [rdr (io/reader data-file)]
      (loop [line (.readLine rdr)]
        (when line
          (println line)
          (recur (.readLine rdr)))))
    

    2. 读取文件的技巧

    想想读取文件可能有哪些场景?

    • 读取整个文本
    (slurp data-file)
    
    • 读取一行
    (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!"
    
    • 读取前n行
    (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 )返回了一个惰性序列,详细解释参见备注。

    • 读取前n个字符
    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,内容即代码注释所示:

    (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部分,如下:

    (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)))))
    

    或者

    (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读取字符串

    (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. 写入文件

    • 使用spit方法
    (spit "world.txt" "Hello, lambeta!" :append true)
    

    运行程序之后,项目的根目录下会生成world.txt文件,内容是Hello, lambeta。spit方法其实就是向Java的BufferedWriter中写入内容。

    • 使用clojure.java.io/writer

    我们在项目的根目录新建numbers.txt,内容是多行的数字对,如下:

    1.3 2.7
    10000 1
    -1 1
    

    我们需要把每行两个数字,和它们相加的结果写入到sum-of_numbers.txt文件中。也就是注释中的描述。

    (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.3 2.7 4.0
    10000 1 10001
    -1 1 0
    

    5. 多行记录

    5.1 有结束标识

    有时候,记录并不是以一行一行的方式存储在文件当中的,而是以多行数据描述一条记录。比如下面的蛋白质数据:
    清单 5.1 multimol.pdb

    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]坐标。
    我们需要一个函数,将数据读取出来并且以规定的格式输出,格式如下:

    (("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)的原子列表组成。

    (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-byEND进行分组,最后使用remove方法剔除掉["END"]这样的分组,得到如下中间结果:

    (("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

    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)))进行分组,得到的结果如下:

    (("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 )中的逻辑。

    稍稍修改原来的分组逻辑,如下:

    (map (fn [[name atoms]] (concat name atoms))
           (partition 2
                      (partition-by
                       #(.startsWith % "COMPND")
                       (line-seq (io/reader input-file)))))
    

    我们先使用(partition 2 )将第一步得到的列表每隔两个元素划为一组,如下:

    ((("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 )中提取出来,以便复用。改造如下:

    (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)))
    

    定义转换逻辑,如下:

    (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))))))
    

    最后,我们来调用的改造之后的方法:

    (read-all-molecules 
            file-without-markers->multi-records "multimol-without-end-marker.pdb")
    

    此时,5.1中的转换逻辑也可以提取出一个函数:

    (defn file->multi-records
      (remove #(= % ["END"]) 
                   (partition-by #(= % "END") (line-seq (io/reader input-file)))))
    

    原来的程序就重构成了如下的模样:

    (read-all-molecules file->multi-records "multimol.pdb")
    

    备注

    为了清楚定位这个问题,我们需要提前了解两个知识点

    1. 什么是惰性序列?
    2. 惰性序列在repl中什么时候变现(realizes)?

    惰性序列是用(lazy-seq [& body] )宏创建出来的。lazy-seq仅在需要的时候才会去调用它的body。
    当repl尝试pretty-print惰性序列的结果时,才会进行变现操作。

    有了上面的知识点,我们来考察with-open(take 2 (line-seq ))的关系。with-open是宏,我们使用clojure.walk/macroexpand-all展开下:

    (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)查看文档,得到

    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)的文档,得到

    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异常。

    到这里,解决了一大半问题,但是还有一个逻辑上解释不过去的点,就是

    (with-open [rdr (io/reader data-file)]
      (take 1 (line-seq rdr)))
    

    当我们尝试(take 1 )时并不会抛出异常!也就是说(take 1 )(take 2 )的行为不同,但是(take )明明都是返回惰性序列啊?

    带着这个疑惑,看看line-seq的源代码

    (when-let [line (.readLine rdr)]
        (cons line (lazy-seq (line-seq rdr)))))
    

    是不是有种豁然开朗的感觉?没有也没关系,我来解释一下。
    line-seqwhen-let语句并没有包在(lazy-seq )(这点可以和take的源码比较)中,这说明[line (.readline rdr)]是需要立即求值的。也就是说,我们在求值with-open时,rdr中第一行的内容会被(line-seq )给抓住了。那么当try...finally运行结束之后,pretty-print变现惰性序列时,发现第一行根本不需要从rdr中读,当然就不会抛出异常了。

    明确这几点之后,我们看看(doall )为何能解决惰性序列延迟求值的问题?(doall )其实强制变现了整个惰性序列(不断调用序列的next方法),所以并不会等到with-open求值完成之后才求值。

    换个角度,我们知道之所以抛出异常,是因为repl对返回的惰性序列求值了。那么如果我们不在repl中求值,程序还会抛出异常吗?

    (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方法。程序运行良好,因为根本没有人用到返回的惰性序列。

    如果我们加一句打印语句如下:

    (defn -main [& args]
      (println          ; 变现
          (with-open [rdr (io/reader "hello.txt")]
            (take 100 (line-seq rdr)))))
    

    再用lein run跑一个main方法,异常又不期而遇了。因为此处的println等价于replpretty print


    相关文章

      网友评论

        本文标题:Clojure文件操作和惰性序列

        本文链接:https://www.haomeiwen.com/subject/rtmlettx.html