TL;DR

  • 出力先がコレクションと分かっているときは、中間的な遅延シーケンスはオーバーヘッドにしかならない。into にトランスデューサを渡すと、入力から出力コレクションへ直接変換できる。
  • トランスデューサは comp で連結でき、複数の変換を入力の 1 回の走査で適用するので、メモリ使用量を大幅に減らせる。
  • 外部リソースのデータを遅延シーケンスで返すと、リソースを閉じるタイミングが分からなくなる。エダクションを使うと、いつ処理が終わり、いつリソースを解放できるかを把握できる。

内容

この節で出てくる「入力(input)」「出力(output)」という言葉は、関数に対する操作を指す。

出力コレクションの生成

シーケンスは必要なときだけ出力を生成するので、無限シーケンスも扱える。しかし、出力がシーケンスではなく、永続的なコレクションであってほしいという状況もある。この場合、すべての変換を完了させたいので、遅延による恩恵は受けられない。

出力ベクタを生成する関数のサンプルコード

たとえば、シーケンスに関数を適用して、ベクタを生成することを考える。この場合、最後に vec を適用して、シーケンスの要素をコレクションに戻す必要がある。

(defn square [x] (* x x))
(defn sum-squares-seq [n]
  (vec (map square (range n))))

遅延シーケンスによるオーバーヘッド

シーケンスは評価結果をキャッシュするので、コレクションと同様にイミュータブルである。この例は次の流れで処理される。

  1. 入力シーケンスが生成され、メモリに値が格納される
  2. map の出力が生成され、メモリに格納される
  3. 出力ベクタが生成される

シーケンス間で同じ値があれば、(イミュータブルなので)シーケンスは値を共有するが、値を保持するオーバーヘッドは 3 倍になる。

トランスデューサ(transducer)

これを回避するために、入力コレクションの値に対して中間変換(intermediate transformation)を実行し、その結果を出力ベクタに直接配置する。具体的には、map(トランスデューサ)を into に渡す。この意味を説明する前に、まずそれがどのようなものかを見てみる。

(defn sum-squares
  [n]
  (into [] (map square) (range n)))

ここで into の呼び出しは、出力コレクション、変換、入力コレクションの 3 つの引数を取る。

この変換に当たるものをトランスデューサと呼ぶ。シーケンス関数(map など)の多くは、入力コレクションの引数なしで呼び出すことができ、その場合はトランスデューサを返す。トランスデューサは入力コレクションや出力コレクションの形式に縛られず、コレクション変換の要素だけを指定する関数である。

into は入力範囲の要素をマップ変換に渡し、その結果を出力コレクションに直接追加する。出力先がコレクションであることが分かっている場合、中間的な遅延シーケンスの作成はオーバーヘッドにしかならない。したがって、この処理は即実行され、戻り値としてコレクションを返す。

パフォーマンス最適化

トランスデューサを組み合わせて、複数の変換をまとめることもできる。

例として、これまでに読み込んだ名前空間の述語関数をすべて見つける処理を考える。具体的には、読み込まれたすべての名前空間を見つけ、それらの public な var の中から ? で終わるものだけに絞り込む。

->> マクロを使ったシーケンスによる実装

シーケンスを使っていると、->> で変換を連結することがよくある。

(defn preds-seq []
  (->> (all-ns)
       (map ns-publics)
       (mapcat vals)
       (filter #(clojure.string/ends-with? % "?"))
       (map #(str (.-sym %)))
       vec))

この変換の各ステップではシーケンスを作り、シーケンスの中間値をキャッシュする。そして最後に、結果をベクタにまとめる。

comp 関数を使ったトランスデューサによる実装

トランスデューサは comp を使って、->> と同じように左から右へと連結できる。

(defn preds []
  (into []
        (comp (map ns-publics)
           (mapcat vals)
           (filter #(clojure.string/ends-with? % "?"))
           (map #(str (.-sym %))))
        (all-ns)))

2 つの実装の違い

シーケンスの実装は4 つの中間シーケンスを生成する。これに対し、トランスデューサの実装は、4 つの変換を 1 つの変換にまとめて、(all-ns) の入力シーケンスを 1 回走査する間に適用する。そして、結果を出力ベクタに直接配置する。

トランスデューサによる実装は、特に入力コレクションのサイズが大きい場合や、変換の数が多い場合に、メモリ使用量を大幅に削減できる。加えて、さらにいくつかの最適化を利用できる。

入力コレクションに reducible な値を取る

まず、入力コレクションが reduce 可能な場合、コンパイルされたコードはより効率的になる。たとえば、トランスデューサの節で実装した sum-squares は、入力コレクションとして reduce 可能な range を取っているので効率的である。

transients(トランジエント)

また、一部の Clojure のコレクションは、トランジエント(transients)を利用して、効率的に一括で読み込める。トランジエントとは、一時的にイミュータブルなコレクションをミュータブルにし、完了したらイミュータブルなコレクションに戻す操作を指す。

この操作はミュータブルな状態で公開されることがないように、制約のあるスコープ内で行われる。トランスデューサの節で使った into は、この最適化を自動的に活用する。

外部リソースの管理

取得したデータを遅延シーケンスで返したときの問題点

外部リソース(ファイルや DB、Web サービスなど)からデータを読み取る場合、読み取るのと同時にデータ処理を始められるように、遅延シーケンスを返したくなる。

(defn non-blank? [s]
  (not (clojure.string/blank? s)))
 
(defn non-blank-lines-seq [file-name]
  (let [reader (clojure.java.io/reader file-name)]
    (filter non-blank? (line-seq reader))))

ここでの問題はリーダが閉じられないことである。このリソースは開いたままで、いつ閉じるかも明確でない。遅延シーケンスの最後の行の処理を待たなければならないので、閉じるタイミングが決まらない。

遅延シーケンスを返す前にリソースを閉じる

別のアプローチとして、返す前にリソースを閉じる方法もある。

(defn non-blank-lines [file-name]
  (with-open [reader (clojure.java.io/reader file-name)]
    (into [] (filter non-blank?) (line-seq reader))))

これでリソースの問題は解決するが、空白でない行のベクタ全体を即時作成してそれを返すことによってのみ解決している。したがって、小さなファイルには適しているが、大きなファイルでは OutOfMemoryError の原因になる。

必要なのは、遅延シーケンスのキャッシュ機能なしでファイルを処理し、処理し終えたらリーダを閉じる方法である。

エダクション(eduction)を使う

まず reader を関数の引数として受け取るようにし、エダクションを返すようにする。

(defn non-blank-lines-education [reader]
  (eduction (filter non-blank?) (line-seq reader)))

エダクションはシーケンスのように要求されたタイミングで実行されるが、要求されるたびに(通常は一度だけ)入力全体を処理する。毎回新しく処理されるので、シーケンスのようなキャッシュはない。その代わりに、変換は通常入力全体に適用され、出力する際には reduce(出力がコレクション)または into(出力が単一の計算された値)と一緒に実行される。

たとえば、non-blank-lines-eduction を使って、空行でない行数を数えられる。

(defn line-count [file-name]
  (with-open [reader (clojure.java.io/reader file-name)]
    (reduce (fn
              [cnt _]
              (inc cnt))
            0
            (non-blank-lines-eduction reader))))

line-count 関数では、まず with-open ブロックの中でリーダを作る。これにより、本体の実行結果を返す前に自動的にリーダが閉じられる。次に、エダクションを reduce のコンテキストで処理する。エダクションは各行を順番に処理し、関連するメモリを解放し、一度に 1 行だけをメモリに保持する。

結論

トランスデューサとエダクションを使うと、入力ソースがいつ処理されるかを正確に指定できる。これにより、外部リソースの処理が完了し、解放の準備が整ったときを把握できる。

一方、遅延シーケンスを使う場合、処理が完了したタイミングや解放のタイミングを把握するのが難しくなる。

キーワード