Clojure

Clojureの遅延化

(注: このページは、Richがstreamsと呼ばれる代替案を探求していた際に、シーケンスに対する最後の大きなアップデートで行われた変更について説明しています。このページは、リファレンスドキュメントではなく、これらの設計決定に関する有用な歴史的記録として扱ってください。)

streamsに取り組んでいる中で、いくつかのことが明らかになりました。

  • streamコードは醜く、命令型です。

    • 安全にしても、依然として醜く、状態を保持します。

  • streamsは完全な遅延をサポートします。

    • これは非常に良い結果になります。

  • streamsを透過的に統合するには(つまり、mapとmap-streamの両方を持たないためには)、コアシーケンス関数(map、filterなど)の契約を緩和する変更が必要です。

    • もしそうするなら、Clojureの美しい再帰スタイルを維持しながら、同じ完全な遅延を実現できるでしょうか?

      • 既存のコードとの互換性を大幅に維持しながら。

      • はい!

        • しかし、理想的には、いくつかの名前を変更する必要があります。

現在のseqモデル

  • 元々はCommon Lispのconsをモデルとしています。

  • 有効なfirstを持つseqか、何もない(nil)かのどちらかです。

  • seqは論理的なカーソルのようです。

  • restは基本的にeagerです。

    • 別のseqまたはnilを返します。

    • 戻り値がnilかどうかを判断するために、さらに何かがあるかどうかを判断する必要があります。

    • lazy-consはfirst/restの計算を遅延させることができますが、restがあるかどうかを判断することはできません。

      • それを決定するには、多くの場合、内部seqを「引っ張る」必要があり、有効な遅延が減少します。

  • シーケンス関数は現在、seqまたはnilを返します。

**rest**のeagernessは、シーケンス関数が完全に遅延しているわけではないことを意味します。少なくともfirstがあるかどうかを判断する必要があります。

seqモデルの拡張 - seqに対する3番目の操作-'next'

  • 変更: (rest aseq) - 空の可能性のあるseqを返し、決してnilを返さない

    • 引数が既にseqでない場合は、seqを呼び出します。

    • 返されるseqは空かもしれません。

      • ()と表示されますが、単一のsentinelオブジェクトは表示されません。

    • nilを返すことはありません。

      • 現在、サードパーティのseqでは強制されていません。

    • 残りのアイテムへの(可能性のある)遅延パス(もしあれば)。

  • 変更: seqは空にすることができます。

    • 常にISeqです。

  • 変更: seq関数 - ISeqに対してはもはや恒等関数ではありません。

    • 依然としてseqまたはnilのいずれかを返します。

    • (seq aseq) → aseqが空の場合、恒等関数ではなくなり、nilを返す

    • nilでも動作します。

  • first関数は変更されません。

    • 引数が既にseqでない場合は、seqを呼び出します。

    • 最初のアイテムを返します。

    • nilでも動作します。

  • 新規: next関数は、restが以前行っていたことを行います。

    • あれば次のseqを返し、なければnilを返します。

    • 引数が既にseqでない場合は、seqを呼び出します。

    • (next aseq) === (seq (rest aseq))

    • nilでも動作します。

  • 変更: seq?

    • (seq? ()) → true

  • 変更: シーケンス関数(map、filterなど)はseqを返しますが、nilは返しません。

    • seq/nilを取得するには、戻り値にseqを呼び出す必要があります。

      • seqは終了のテストとしても機能し、既に慣習的です。

        (when (seq coll)
          ...)
    • 完全な遅延を許可します。

    • nilパニングをサポートしていません。

      • シーケンス関数がseq/nilを返さなくなったためです。

レシピ - 新しいモデルで遅延シーケンス関数を記述する方法

  • lazy-consに別れを告げ、lazy-seqをこんにちは

    • lazy-consはなくなりました。

    • 新しい遅延マクロ - lazy-seq

      • seq、nil、またはシーケンス可能なものを生成するボディを取ります。

      • ボディを呼び出すことでseqを実装する論理的なコレクションを返します。

        • 最初にseqが呼び出されたときにのみボディを呼び出し、結果をキャッシュします。

        • 既にseqまたはnilでない場合は、ボディの戻り値にseqを呼び出します。

    • その効果は、seqが呼び出されるまで何も動作しない仮想コレクションの作成です - 完全な遅延です。

    • すべてのコレクション操作をサポートします。

    • 空にすることができます - 例えば、それにseqを呼び出すとnilを返す可能性があります。

      • 空の場合、()として表示されます。

  • lazy-seqは遅延シーケンス関数の最上位に配置されます。

    • ネストされたlazy-consの代わりに。

  • 内部では、通常のcons呼び出しを使用します。

    • 必要になるまで作成されません。

  • 別のseqを使用する場合は、nextではなくrestを使用します。

古い方法

(defn map
  ([f coll]
   (when (seq coll)
     (lazy-cons (f (first coll)) (map f (rest coll)))))
...

新しい方法

(defn map
  ([f coll]
   (lazy-seq
    (when-let [s (seq coll)]
      (cons (f (first s)) (map f (rest s))))))
...

when-letの使用に注意してください。これは、first/restが引数にseqを呼び出す場合でも、seqを一度取得して、後のfirstおよびrestでの使用のために取得します。この新しいモデルでは、パフォーマンス上のメリットがあります。

犠牲 - nilパニング

CLのconsがリストの終わりにnilを使用する良い点の1つは、条件式でのnilのテスト可能性と組み合わせると、consを返す関数を述語のように使用できることです。現在、そのように使用できるのは**seq**と**next**のみです - map、filterなどは使用できません。seq/nilダイアドの経済性の多くは依然として適用されます(上記のmapでのwhenの使用など)。

ISeqの拡張

ISeqを拡張する場合は、**ISeq.more()**(restの基礎)をサポートする必要があります。幸いなことに、ほとんどのISeqエクステンダはASeqから派生しており、**next**の観点から**more()**を定義しています。ASeqからseqを派生させる場合は、more()を定義しないでください。ASeqによって提供されるバージョンを使用してください。rest()メソッドの名前をnext()に変更するだけです。

レシピ - ポート

新しいモデルに移行するには、次の手順をこの順序で実行する必要があります。

  • **rest**への呼び出しをすべて**next**を呼び出すように名前変更します。

  • **lazy-cons**を使用して独自の遅延シーケンス関数を定義していた場合は、上記レシピを使用して**lazy-seq**に切り替えます。再帰呼び出しでは**next**ではなく**rest**を呼び出すようにしてください。

  • nilパニングについてコードを監査します。遅延ブランチは、条件式で遅延シーケンスの真偽値をテストしようとするとアサートするデバッグモードでコンパイルをサポートしており、実行すると例外をスローします。Clojureを次のようにビルドするだけです。

    • ant -Dclojure.assert-if-lazy-seq=true

    • 次に、次のようないくつかのnilパニングは例外をスローします。

      • (when (filter neg? [1 2]) :all-pos)

      • (not (concat))

      • (if (rest (seq [])) 1 2)

    • いずれの場合も、シーケンスをseq呼び出しでラップすることでnilパニングを修正できます。

      (when (seq (filter neg? [1 2])) :all-pos)
      -> nil
    • 完了したら、速度が低下するため、フラグなしで再構築します。

頭を抱えないで

再帰的に定義された遅延シーケンス関数は、エレガントで理解しやすいです。非常にメモリ効率が高く、メモリに収まらないデータソースを処理できます。これは、現在使用されているデータ構造の部分のみがメモリにある必要があるためです。ローカル変数によってまだ参照されている可能性があるため、現在使用されている部分を判断するのは難しい場合があります。Clojureは、テールコールでローカル変数のクリアを実行して、スタックに残っている参照がないようにしますが、1つの残りのケース - 閉じたローカル - は、特にlazy-seqのようなマクロを使用する場合、制御が困難でした。

元の、完全に遅延していないfilterの定義を考えてみましょう。

(defn filter
  "Returns a lazy seq of the items in coll for which
  (pred item) returns true. pred must be free of side-effects."
  [pred coll]
    (when (seq coll)
      (if (pred (first coll))
        (lazy-cons (first coll) (filter pred (rest coll)))
        (recur pred (rest coll)))))

関数自体に再帰することで、各反復ごとにcoll引数を効果的に消去しているため、述語に一致しない要素をスキップしている間、collを保持していないように見えます。問題は、filterの呼び出しがlazy-cons内にある場合があり、collを閉じ込めるクロージャに展開されるため、ループが発生している間、それを保持し、呼び出された関数がそれについてできることは何もありません。これは、次の式が

(filter #(= % 20) (map inc (range 10000000)))

メモリ不足の例外を引き起こす可能性があることを意味します。それを回避する唯一の方法は、変異を使用してfilterを書き直すことでした。最悪です。

新しいfilterは次のようになります。

(defn filter
  "Returns a lazy sequence of the items in coll for which
  (pred item) returns true. pred must be free of side-effects."
  [pred coll]
  (let [step (fn [p c]
                 (when-let [s (seq c)]
                   (if (p (first s))
                     (cons (first s) (filter p (rest s)))
                     (recur p (rest s)))))]
    (lazy-seq (step pred coll))))

古いfilterの本体はヘルパー関数に入れられ、lazy-consはconsに置き換えられ、次に呼び出し全体が上記レシピに従ってlazy-seqでラップされます。ただし、lazy-seqもcollを閉じ込めるクロージャを作成します。何らかの拡張がないと、このfilterは、遅延しているにもかかわらず、古いものと同じメモリフットプリントになります。新しい遅延ブランチには、これと同様のシナリオのためのコンパイラ拡張が含まれています。**lazy-seq**と**delay**はどちらも、本体のテールコールで閉じ込められたローカルのクリアを実行し、テールコールが実行されるときにクロージャ自体に残っている参照がないようにします。これらは結果をキャッシュするため、クロージャは一度だけ呼び出されることがわかるため、これを行うことができます。したがって、遅延ブランチは上記のfilter式に問題がなく、同様の手法を使用して独自の遅延関数でメモリ使用量を制御できます。