Clojure

一時データ構造

背景

森の中で木が倒れたら、音はするのか?
純粋関数が、不変の戻り値を生成するためにローカルデータを変更しても問題ないのか?

興味深い質問です。Clojureのデータ構造は、`assoc`などの関数呼び出しのたびに、1つ以上の配列を作成して変更し、その後不変で使用するために返すことで、変更を使用します。その理由はパフォーマンスです。純粋関数と不変データだけを使用するよりも、高速に処理することはできません。しかし、いったん構築されて共有されると、不変性と永続性は堅牢なプログラムにとって不可欠です。Clojureが内部的に変更するのは、データ構造の内部ノードを構成する小さく新しく割り当てられた配列です。誰もその配列を見ることはありません。

同様のシナリオは、構築/変換コード以外のコードからは見えない複数のステップを使用して、大規模な永続的データ構造を初期化または変換する場合にも発生します。ここでの課題は、変換のソースが既存の永続的データ構造であり、関数の結果は共有されることです。従来の可変データ構造へのコピーとコピーバックにはO(n)のコピーが必要であり、内部コードはClojureコードの他の部分とはかなり異なる命令型の構造になります。さらに、特に作業を行うためにヘルパー関数を呼び出す必要がある場合、可変データ構造を誤って共有したり、エイリアスを作成したりするのを防ぐガードがありません。要するに、このようなコードの速度を上げるためにClojureのモデルから逸脱しなければならないのは残念です。一時データ構造は、この最適化問題に対するソリューションであり、Clojureモデルと統合され、Clojureで期待されるのと同じスレッドセーフティの保証を提供します。

動作原理

一時データ構造は、常に既存の永続的なClojureデータ構造から作成されます。Clojure 1.1.0以降、ベクトル、ハッシュマップ、ハッシュセットがサポートされています。すべてのClojureデータ構造がこの機能をサポートするわけではありませんが、ほとんどはサポートします。リストは、メリットがないためサポートされません。

`transient`関数を呼び出すことで、データ構造の一時的な「コピー」を取得します。これにより、ソースのコピーであり、同じパフォーマンス特性を持つ新しい一時データ構造が作成されます。実際、それはほとんどソースデータ構造そのものであり、一時データ構造の最初の機能、つまり作成がO(1)であることを強調しています。永続的なコピーと同様に、ソースと構造を共有します。

一時データ構造の2番目の機能は、作成がソースを変更せず、ソースは一時データ構造の使用によって変更できないことです。ソースデータは常に不変で永続的です。

一時データ構造は、ソースの読み取り専用インターフェースをサポートします。つまり、永続的なベクトルと同様に、`nth`、`get`、`count`を呼び出すことができ、一時ベクトルを関数呼び出しできます。

一時データ構造は、ソースデータ構造の永続的なインターフェースを**サポートしません**。`assoc`、`conj`などはすべて例外をスローします。なぜなら、一時データ構造は永続的ではないからです。したがって、永続的なデータ構造が必要なコンテキストに一時データ構造を誤って流出させることはできません。

一時データ構造は、類似した名前の後に`!`が付いた一連の「変更」操作をサポートします(`assoc!`、`conj!`など)。これらは、永続的な対応物と同じことを行いますが、戻り値はそれ自体が一時データ構造です。特に、一時データ構造は、その場で変更されるように設計されていないことに注意してください。次の呼び出しで戻り値を取得して使用する必要があります。このようにして、それらは、置き換える関数型永続コードと同じコード構造をサポートします。例で示すように、これにより、構造を変更することなく、コードのパフォーマンスを簡単に向上させることができます。

結果の構築が完了したら、`persistent!`関数を一時データ構造に呼び出すことで、永続的なデータ構造を作成できます。この操作もO(1)です。`persistent!`を呼び出した後、一時データ構造を使用しないでください。すべての操作は例外をスローします。これは、作成したエイリアスにも当てはまります。

非常に典型的な例を次に示します。これは、返すベクトルを構築するコードで、「変更」はすべて関数にローカルです。一時データ構造を使用するバージョンは、構造がまったく同じで、次のように変更されています。

  • ソースベクトルに`transient`関数を呼び出す

  • `conj`の代わりに`conj!`を使用する

  • 戻り値に`persistent!`関数を呼び出す

(defn vrange [n]
  (loop [i 0 v []]
    (if (< i n)
      (recur (inc i) (conj v i))
      v)))

(defn vrange2 [n]
  (loop [i 0 v (transient [])]
    (if (< i n)
      (recur (inc i) (conj! v i))
      (persistent! v))))

;; benchmarked (Java 1.8, Clojure 1.7)
(def v (vrange 1000000))    ;; 73.7 ms
(def v2 (vrange2 1000000))  ;; 19.7 ms

ああ、そうそう、**一時データ構造は高速です!**

並行使用

一時データ構造の使用は以上です。しかし、それにはもう1つの重要な制約があります。**一時データ構造はスレッドの分離が必要です。**一時データ構造の各操作の結果は、前の操作と(可変の)構造を共有するため、複数のスレッドが一度に一時データ構造を操作することはエラーになります。特定の一時データ構造インスタンスの使用は、単一スレッドのスコープで使用するか、他の手段で単一スレッドの制約を適用するフレームワークで使用することで制御する必要があります。

Clojure 1.6以前では、一時データ構造は作成したスレッド以外のスレッドからの使用(読み取りまたは書き込み)を検出し、例外をスローしていました。そのチェックは1.7で削除され、他の手段で単一スレッドの制約を適用するcore.asyncの`go`ブロックなどのフレームワークでより柔軟に使用できるようになりました。

まとめ

一時データ構造は、Clojureのデータ構造で動作し、重要な安全性の保証を提供する、関数型データ構造構築コードの高性能な最適化を提供します。

  • 単一パス使用

  • 永続的データ構造からのO(1)の作成

  • 永続的ソースとの構造の共有

  • 編集セッション終了時の永続的データ構造のO(1)の作成

  • 関数型バージョンと同じコード構造

    • 戻り値を取得し、次の呼び出しで使用

    • その場で変更しない

    • 永続的ではないため、中間値を保持したり、エイリアスを作成したりすることはできません

  • 永続的なバージョンを返した後は使用できません

  • 高速

一時的な永続ベクトル、ハッシュマップ、ハッシュセットは、Clojure 1.1で追加されました。