読者です 読者をやめる 読者になる 読者になる

夢とガラクタの集積場

落ちこぼれ三流エンジニアである管理人の夢想=『夢』と、潰えた夢=『ガラクタ』の集積場です。

Clojure勉強日記(その14 Clojureにおけるシーケンスの特徴とJavaオブジェクトの扱い

こんにちは。

そろそろ、書籍も説明的な内容が増えてきたので単にべた書きではなく、
理解したことをまとめていく方針を取ります。

1.遅延シーケンスと無限シーケンス

JavaにあってClojureにないシーケンスの一番の特徴がこの遅延シーケンスと無限シーケンスです。
遅延シーケンスはその名の通り、「各要素が必要になった場合に初めて評価される」シーケンス。
無限シーケンスは「自然数全て」のような、理論上無限に値が存在するシーケンス。

通常のプログラミング言語では「無限シーケンス」というのは具体化しえない。
生成した時点でメモリが溢れて終わり、でしょうから。

ただ、Clojureでは遅延シーケンスによって
「必要になった時点で評価する」「ユーザ側で必要な分だけ取得する」ということが可能なため、
無限の値を生成するシーケンスを記述することが可能となる。
#まぁ、値を限定する関数とペアで使用しないと最終的には溢れるんですが。

当然、遅延シーケンスには利点もあるんですが、
他の言語に慣れてきた人間にとっては使いにくかったりわかりにくい点がある。
特に「外部への入出力」を伴うような副作用つきの処理とは相性が非常に悪い。

何故なら、予めファイルを読み込んでおきたいのに遅延評価される関係上、
「使うときになったタイミングで読む」という形になり、あるタイミングだけファイルIOを発生させるというのが出来ないため。

・・・というわけで、遅延でないシーケンスも使えるそうです。
なのですが、正直違いがわかりません。

user=>(def print-range (for [index (range 10)] (do (println index))))
#'user/print-range
; この段階ではprint-rangeはrangeの中身を評価していないため、Clojureはrangeの部分を実行していない。
user=> (doall print-range)
0
1
2
3
4
5
6
7
8
9
(0 1 2 3 4 5 6 7 8 9)
; 非常に分かりにくいが、最初の10行がprintの結果で、最後の1行がシーケンス評価
user=> (def result (doall print-range))
#'user/result
; 更によくわからないことに結果を変数にバインドする際にはprintがされない。
user=> result
(0 1 2 3 4 5 6 7 8 9)
; かつ、doallありなしで動作も変わらず。不明です・・・
user=> (def lazy (range 10))
#'user/lazy
user=> (def imidi (doall (range 10)))
#'user/imidi
user=> lazy
(0 1 2 3 4 5 6 7 8 9)
user=> imidi
(0 1 2 3 4 5 6 7 8 9)
user=> (class lazy)
clojure.lang.LazySeq
user=> (class imidi)
clojure.lang.LazySeq
user=> (str lazy)
"clojure.lang.LazySeq@9ebadac6"
user=> (str imidi)
"clojure.lang.LazySeq@9ebadac6"

結局遅延評価シーケンスに対してdoallを使用することで具体的にどういう違いがあるかは分からず。
とりあえず、遅延評価によって問題が発生した場合に使う、位が現状の理解できることなんですかねぇ・・・

2.Javaの要素をシーケンスとして扱う

Javaでコレクション的な要素を含むものをClojureのシーケンスに渡すと
シーケンスとして扱われる。
これは文字列やバイト値についても「各文字ごとに分解したシーケンス」として扱うため、適用範囲が広いです。

user=> (seq "Hello Test")
(\H \e \l \l \o \space \T \e \s \t)
; 文字列を渡すとシーケンス化することができる
user=> (seq (.getBytes "Hello Test"))
(72 101 108 108 111 32 84 101 115 116)
user=> (first (.getBytes "Hello Test"))
72
user=> (rest (.getBytes "Hello Test"))
(101 108 108 111 32 84 101 115 116)

ただ、あくまでこれらは「元の要素を参照して作ったイミュータブルなシーケンス」であることに注意。
SystemPropertyを取得して作ったシーケンスをconsでマージしてマージ後のシーケンスを取得しても、
そのシーケンスの内容が実際のSystemPropertyに反映されているわけではないわけですね。

ちなみに、文字列に限っては下記の記法でシーケンスを文字列に戻すことが可能です。
※なぜかスーパーPre記法で記述すると中身が表示されないため、通常の文字列として記述しています。

user=> (rest "Hello Test")
(\e \l \l \o \space \T \e \s \t)
user=> (str (rest "Hello Test"))
"(\\e \\l \\l \\o \\space \\T \\e \\s \\t)"
user=> (apply str (rest "Hello Test"))
"ello Test"

と言いつつ、これもシーケンスの各要素に対してstr関数を適用(apply)しているだけで、
何か特殊な記述方法というわけではないです。

正規表現のシーケンスとしての扱い

Javaでは正規表現でマッチした各々の文字列を取りだす際に繰り返しマッチングをかけるか
ライブラリを使う必要がありますが、Clojureだと「マッチした文字列からなるシーケンス」を直接生成することができる。

user=> (re-seq #"\w+" "the quick brown fox")
("the" "quick" "brown" "fox")
user=> (first (re-seq #"\w+" "the quick brown fox"))
"the"
user=> (class (re-seq #"\w+" "the quick brown fox"))
clojure.lang.Cons
user=> (rest (re-seq #"\w+" "the quick brown fox"))
("quick" "brown" "fox")
user=> (sort (re-seq #"\w+" "the quick brown fox"))
("brown" "fox" "quick" "the")
user=> (take 3 (re-seq #"\w+" "the q||< uick brown fox"))
("the" "quick" "brown")

正規表現のマッチの結果をシーケンス化可能となったことで、
他のシーケンスと同じようにいろんなものが扱える、となりますね。
コードは非常にコンパクトにできそうです。

以後、ファイルシステム、ストリーム、XML等「連続した概念」をシーケンスとして扱えるようです。

ファイルシステムのシーケンス

下記のようにファイルの一覧をseq関数に渡すことで個々のファイル自体をシーケンスとして扱える。

user=> (.listFiles (new File "."))
#
user=> (seq (.listFiles (new File ".")))
(# # # # # # #)
user=> (map (fn [target] (.getName target)) (seq (.listFiles (new File "."))))
(".git" ".gitignore" "classes" "lib" "README.md" "src" "startRepl.bat")
; mapの返り値はLazySeqだが、既に評価の終わったリストを受け取ったシーケンスはArraySeqとなっている。
user=> (class (map (fn [target] (.getName target)) (.listFiles (new File "."))))
clojure.lang.LazySeq
user=> (class (seq (.listFiles (new File "."))))
clojure.lang.ArraySeq

後はそういったファイルシステム専用のシーケンス関数も存在する。

user=> (doc file-seq)

                                                • -

clojure.core/file-seq
([dir])
A tree seq on java.io.Files
user=> (count (file-seq (new File ".")))
142

で、これらは当然シーケンスであるため、シーケンスのフィルタを適用可能・・・
となるわけです。
当然ながらフィルタ=ファイルであることを前提とした述語になるわけではありますが。

user=> (defn modified? [targetFile] (> (.lastModified targetFile) (- (System/currentTimeMillis) 6000000)))
#'user/modified?
user=> (filter modified? (file-seq (new File ".")))
(#)
user=>

このあたり、抽象化を行ったことにより「特定の要素を前提としたシーケンス述語」が必要になりますが、
その上でもこれだけ柔軟に実行できるのは大きいですね。
と言いつつ、今回のケースの場合状態を考えない代わりにcurrentTimeMillisメソッドを大量呼び出ししているため、
その辺りのバランスはどこかで必要なのかもしれません。

ストリームのシーケンス

ストリーム・・・といっても、わかりやすいために「ファイル読み込みシーケンス」を用いた確認になります。
line-seq関数を使用するとファイルの中身をシーケンスとして取得できますが、
ファイルは開きっぱなしになります。
takeをつけて一部しか評価しない場合でも、全評価する場合でもそれは変わらないようです。

user=> (use '[clojure.java.io :only (reader)])
nil
user=> (take 2 (line-seq (reader "src/functions.clj")))
("(defn hello \"Returns the form 'Hello, username.'\"" " [username]")
user=> (line-seq (reader "src/functions.clj"))
user=> (line-seq (reader "src/functions.clj"))
user=> (line-seq (reader "src/functions.clj"))

・・・結果、下記のようにfunctions.cljをつかんだファイルハンドルが増えています。あれれ。

こういった問題にならないようにするためにはwith-openマクロで囲っておくことが必要なようです。
使う場合はwith-openマクロ内でcloseメソッドを保持するものをバインドして使います。
後はwith-openマクロで遅延評価がされるtake を使った場合、ストリームがクローズされている旨の
エラーが発生するあたりが遅延評価について実感できていい感じです。
で、doallを使うと遅延評価にならないので正常に動作すると。
・・・なんですが、このあたりをコード書いていて事前に検知するのはまだまだ難しそうではあります。

user=> (with-open (take 2 (line-seq (reader "src/functions.clj"))))
IllegalArgumentException with-open requires a vector for its binding in user:29 clojure.core/with-open (core.clj:3450)
user=> (doc with-open)

                                                • -

clojure.core/with-open
([bindings & body])
Macro
bindings => [name init ...]
Evaluates body in a try expression with names bound to the values
of the inits, and a finally clause that calls (.close name) on each
name in reverse order.
; 遅延評価されるため、評価のタイミングにおいては既にストリームがクローズされている
user=> (with-open [targetreader (reader "src/functions.clj")] (take 2 (line-seq targetreader)))
IOException Stream closed java.io.BufferedReader.ensureOpen (BufferedReader.java:115)
; doall関数を使って即時評価すると遅延評価と組み合わせても中身が見える
user=> (with-open [targetreader (reader "src/functions.clj")] (take 2 (doall (line-seq targetreader))))
("(defn hello \"Returns the form 'Hello, username.'\"" " [username]")

後は、Clojureのコードの行数をカウントする関数をwith-open節を用いて下記のような感じで作成できました。

; 空行でない行かどうかを判定する関数
(defn non-blank? [line] (if (re-find #"\S" line) true false))
; 指定されたファイルオブジェクトを受け取り、空行を除いた行のカウントを行う関数
(defn clojure-realline [file] (with-open [targetreader (reader file)] (count (filter non-blank? (line-seq targetreader)))))

後はClojureのソースかどうかを判定するフィルタ関数と、file-seqを用いてあるディレクトリ配下のClojureソースコードの行数を
判定する関数も下記くらいで出来ました。
・・・こういう状態をあまり考えなくていい処理であればこれだけのコードでかける辺りが驚きではあります。
とはいえ、1行で関数書いている関係上いまいち見にくいというのはあるんですが(汗

; 対象のファイルがClojureソースかどうかを判定する関数
(defn clojure-source? [file] (.endsWith (.toString file) ".clj"))
; 指定されたファイルオブジェクトの配下を再帰的に探索し、Clojureソースの行数をカウントする関数
(defn clojure-loc-dir [basefile] (reduce + (for [file (file-seq basefile) :when (clojure-source? file)] (clojure-realline file)))
; Clojure自体の行数を確認
user=> (clojure-loc-dir (new java.io.File "clojure-master"))
26305

尚、この26305という値、ほぼ瞬間的に出ます。
SSDとはいえ、数百ファイルの行数カウントがラグなく出来るのはうれしいですね。
ただ、内部で同並列処理されているかは現状不明なため、おいおい確認しましょうか。

XMLのシーケンス

同様に、XMLもシーケンス化して扱えるそうです。

user=> (use '[clojure.xml :only (parse)])
nil
user=> (parse (new java.io.File "clojure-master/pom.xml"))
{:tag :project, :attrs {:xmlns "http://maven.apache.org/POM/4.0.0", :xmlns:xsi "http://www.w3.org/2001/XMLSchema-instance", :xsi:schemaLocation "http://maven.apache.org/POM/4.0.0 〜省略
user=> (xml-seq (parse (new java.io.File "clojure-master/pom.xml")))
({:tag :project, :attrs {:xmlns "http://maven.apache.org/POM/4.0.0", :xmlns:xsi "http://www.w3.org/2001/XMLSchema-instance", 〜省略
; pom.xml中の:groupId要素から内容を抽出し、返す。
user=> (for [x (xml-seq (parse (new java.io.File "clojure-master/pom.xml"))) :when (= (:tag x) :groupId)] (:content x))
(["org.clojure"] ["org.sonatype.oss"] ["org.codehaus.jsr166-mirror"] ["org.clojure"] ["org.clojure"] ["org.apache.maven.plugins"] ["org.codehaus.mojo"] ["org.apache.maven.plugins"] ["org.apache.maven.plugins"])