web-dev-qa-db-ja.com

非関数型言語での永続データ構造の使用

純粋に関数型またはほぼ純粋に関数型の言語は、不変であり、関数型プログラミングのステートレススタイルによく適合するため、永続データ構造から恩恵を受けます。

しかし、時々、Javaのような(状態ベースのOOP)言語の永続データ構造のライブラリが見られます。永続的なデータ構造を支持してよく聞かれる主張は、不変であるため、それらはthread-safeであるというものです。

ただし、永続データ構造がスレッドセーフである理由は、1つのスレッドが永続コレクションに要素を「追加」すると、操作は元のようにnewコレクションを返しますが、要素が追加されます。したがって、他のスレッドは元のコレクションを参照します。もちろん、2つのコレクションは多くの内部状態を共有しています。そのため、これらの永続的な構造は効率的です。

ただし、スレッドごとにデータの状態が異なるため、永続的なデータ構造はnotで十分であるように見え、1つのスレッドが他のスレッドに見える変更を行うシナリオを処理するのに十分です。このため、アトム、参照、ソフトウェアトランザクションメモリ、または従来のロックと同期メカニズムなどのデバイスを使用する必要があるようです。

では、なぜPDSの不変性が「スレッドの安全性」に役立つものとして宣伝されているのでしょうか。 PDSが同期または並行性の問題の解決に役立つ実際の例はありますか?または、PDSは、関数型プログラミングスタイルをサポートするオブジェクトへのステートレスインターフェイスを提供する方法にすぎませんか?

17
Ray Toal

永続的/不変のデータ構造は、それ自体では同時実行性の問題を解決しませんが、それらの解決をはるかに容易にします。

セットSを別のスレッドT2に渡すスレッドT1について考えます。 Sが変更可能である場合、T1に問題があります。Sで何が発生するかを制御できなくなります。スレッドT2はそれを変更できるため、T1はSの内容にまったく依存できません。逆も同様です。T2はT1を確認できません。 T2が動作している間はSを変更しません。

1つの解決策は、T1とT2の通信にある種のコントラクトを追加して、スレッドの1つだけがSを変更できるようにすることです。これはエラーが発生しやすく、設計と実装の両方に負担をかけます。

別の解決策は、T1またはT2がデータ構造(または調整されていない場合は両方)を複製することです。ただし、Sが永続的でない場合、これは負荷の高いO(n)操作です。

永続的なデータ構造があれば、この負担から解放されます。構造体を別のスレッドに渡すことができ、構造体がそれで何をするかを気にする必要はありません。どちらのスレッドも元のバージョンにアクセスでき、元のバージョンに対して任意の操作を実行できます。他のスレッドの表示には影響しません。

参照: 永続データ構造と不変データ構造

15
Petr Pudlák

では、なぜPDSの不変性が「スレッドの安全性」に役立つものとして宣伝されているのでしょうか。 PDSが同期または並行性の問題の解決に役立つ実際の例はありますか?

その場合のPDSの主な利点は、すべてを一意にすることなく(いわばすべてを深くコピーすることなく)データの一部を変更できることです。これには、副作用のない安価な関数を作成できること以外にも多くの潜在的な利点があります。コピーと貼り付けられたデータのインスタンス化、簡単な取り消しシステム、ゲームの簡単な再生機能、簡単な非破壊編集、簡単な例外の安全性などです。

5
user204677

永続的だが変更可能なデータ構造を想像できます。たとえば、最初のノードへのポインタで表されるリンクリストと、新しいヘッドノードと前のリストで構成される新しいリストを返すprepend-operationを使用できます。以前のヘッドへの参照がまだあるので、このリストにアクセスして変更できますが、その間、新しいリスト内にも埋め込まれています。このようなパラダイムは可能ですが、永続的で不変のデータ構造(たとえば、デフォルトでは確かにスレッドセーフではありません。ただし、開発者が何をしているかを知っている限り、その用途があります。スペース効率のため。また、構造は言語レベルで変更可能である可能性がありますが、コードによる変更を妨げるものは何もありませんが、実際には不変であるかのように使用できます。アプリケーションロジックは、理論上は可能ですが、慣例により状態を変更しない場合があります。 。

長い話を短く、不変性がない(言語または規則によって強制される)場合、永続性odデータ構造はその利点のいくつか(スレッドの安全性)を失いますが、他のもの(いくつかのシナリオではスペース効率)を失います)。

非関数型言語の例については、JavaのString.substring()は、永続データ構造と呼ばれるものを使用します。文字列は、文字の配列と、実際に使用される配列の範囲の開始オフセットと終了オフセットで表されます。部分文字列が作成されると、新しいオブジェクトは変更された開始オフセットと終了オフセットのみを使用して、同じ文字配列を再利用します。 Stringは不変なので、(substring()操作に関しては、他のものではなく)不変の永続データ構造です。

データ構造の不変性は、スレッドセーフティに関連する部分です。それらの永続性(新しい構造が作成されたときに既存のチャンクを再利用すること)は、そのようなコレクションを操作するときの効率に関連しています。これらは不変であるため、アイテムの追加などの操作では既存の構造は変更されませんが、追加の要素が追加された新しい構造が返されます。構造全体がコピーされるたびに、空のコレクションから始まり、1000要素のコレクションになるように1000要素を1つずつ追加すると、0 + 1 + 2 + ... + 999 =の一時オブジェクトが作成されます。合計で500000要素ですが、これは膨大な無駄になります。永続的なデータ構造では、1要素のコレクションが2要素のコレクションで再利用され、3要素のコレクションで再利用されるため、最終的にはガベージノードがなくなるため、これを回避できます。割り当て済み-それぞれが、データ構造の最終状態で使用される最後にあります。

2

私は、言語とその性質、および私のドメイン、さらには言語の使用方法によっても、C++でこのような概念を適用するものとして偏見があります。しかし、これらのことを考えると、不変の設計は、スレッドセーフティなどの関数型プログラミングに関連するメリットの大部分を享受することに関して、最も興味深い側面ではないと思います、システムについての推論の容易さ、機能の再利用の増加(および不快な驚きなしにそれらを任意の順序で組み合わせることができることの発見)など.

この単純化したC++の例を見てみましょう(確かに、そこにいる画像処理の専門家の前で私を困惑させないように、単純化のために最適化されていません)。

// Inputs an image and outputs a new one with the specified size.
Image resized_image(const Image& src, int new_w, int new_h)
{
     Image dst(new_w, new_h);
     for (int y=0; y < new_h; ++y)
     {
         for (int x=0; x < new_w; ++x)
              dst[y][x] = src.sample(x / (float)new_w, y / (float)new_h);
     }
     return dst;
}

この関数の実装は、2つのカウンタ変数と一時ローカルイメージの形式でローカル(および一時)状態を変更して出力しますが、外部の副作用はありません。画像を入力し、新しい画像を出力します。私たちは心ゆくまでそれをマルチスレッド化できます。理由は簡単で、徹底的にテストするのも簡単です。何かがスローされた場合、新しい画像は自動的に破棄され、外部の副作用をロールバックすることを心配する必要がないため、例外的に安全です(いわば、関数のスコープ外で変更されている外部の画像はありません)。

上記の関数をC++でImageを不変にすることで、ほとんど得ることができず、失われる可能性が高くなります。

純度

したがって、(external副作用のない)純粋な関数は私にとって非常に興味深いものであり、C++でもチームメンバーに頻繁に使用することの重要性を強調しています。しかし、一般的にコンテキストとニュアンスがない場合にのみ適用される不変のデザインは、それほど興味深いものではありません。なぜなら、言語の必須の性質を考えると、効率的なプロセスでローカル一時オブジェクトを効率的に変更できることは有用であり、実用的だからです(両方とも)開発者およびハードウェア用)純粋な関数を実装します。

多額の構造の格安コピー

私が見つけた2番目に有用なプロパティは、非常に多額のデータ構造を安価にコピーできることです。そのコストは、厳密な入力/出力の性質を考慮して関数を純粋にするためにしばしば発生し、簡単ではありません。これらは、スタックに収まる小さな構造ではありません。それらは、ビデオゲームのScene全体のように、大きくて頑丈な構造になります。

レンダラーが同時に描画しようとしているシーンを物理学が変化させながら同時に物理学を深めている場合、お互いをロックしたりボトルネックにしたりせずに、物理学を並列化して効果的にレンダリングすることが難しいため、コピーのオーバーヘッドは、効果的な並列処理の機会を妨げる可能性があります。物理を適用して1つのフレームを出力するためだけにゲームシーン全体をコピーしても、同様に効果がない場合があります。しかし、物理システムが単にシーンを入力し、物理を適用して新しいものを出力するという意味で物理システムが「純粋」であり、そのような純粋さが天文学的なコピーのオーバーヘッドを犠牲にせずに、安全に並行して動作できた場合1つが他を待たないレンダラー。

したがって、アプリケーションの状態に関する非常に多額のデータを安価にコピーし、処理とメモリの使用に最小限のコストで新しい修正バージョンを出力する機能は、純粋さと効果的な並列処理のための新しい扉を開くことができ、多くの教訓が得られます永続的なデータ構造の実装方法から。しかし、このようなレッスンを使用して作成するものは、完全に永続的である必要はなく、不変のインターフェースを提供する必要があります(たとえば、コピーオンライト、または「ビルダー/トランジェント」を使用する場合があります)。関数/システム/パイプラインの並列性と純粋さを追求するために、メモリ使用量とメモリアクセスを倍増させることなく、コピーの一部のみをコピーして変更します。

不変性

最後に、これらの3つの中で最も興味深いとは言えない不変性がありますが、特定のオブジェクト設計が純粋な関数のローカル一時として使用されることを意図されていない場合、より広いコンテキストでは、 「オブジェクトレベルの純粋性」の一種。すべてのメソッドで外部の副作用が発生しなくなりました(メソッドの直接のローカルスコープの外でメンバー変数が変更されなくなりました)。

そして、C++のような言語では、これら3つのうちで最も興味深いものではないと考えていますが、重要なオブジェクトのテストとスレッドセーフおよび推論を確実に簡略化できます。たとえば、オブジェクトがそのコンストラクターの外で一意の状態の組み合わせを与えられないこと、および参照/ポインターによってさえ、constnessとread-に頼ることなく自由にそれを渡すことができるという保証で動作することは、負荷がかかる場合があります。反復子とハンドルなどのみ。ただし、元のコンテンツが変更されないことを保証します(少なくとも、言語内で可能な限り多く)。

しかし、私はこれが最も興味のないプロパティだと思います。なぜなら、純粋な関数(またはオブジェクトまたは一連の「純粋なシステム」のようなより広い概念)を実装するために一時的に、変更可能な形式で使用されるのと同じくらい有益だと私が思うほとんどのオブジェクト何かを入力するだけで、何かに触れることなく新しいものを出力するという究極の効果を備えて機能します)、そして、ほぼ命令型の言語で四肢に取られる不変性は、かなり逆効果的な目標だと思います。私は、コードベースの中で最も役立つ部分に、それを控えめに適用します。

最後に:

[...] 1つのスレッドが他のスレッドから見える変更を行うシナリオを処理するには、永続的なデータ構造だけでは十分ではないようです。このため、アトム、参照、ソフトウェアトランザクションメモリ、または従来のロックと同期メカニズムなどのデバイスを使用する必要があるようです。

当然、設計で変更が(ユーザー側の設計の意味で)発生と同時に複数のスレッドから見えるようにする必要がある場合は、同期に戻るか、少なくとも描画ボードに戻って、これに対処するいくつかの高度な方法を考えます(私は、関数型プログラミングにおけるこの種の問題を扱う専門家が使用する非常に手の込んだ例を見てきました)。

しかし、例として永続的なデータ構造で得られるように、そのようなコピーと高額な構造の部分的に変更されたバージョンを安価に出力できるようになると、多くの場合、多くの扉と機会が開かれます。厳密なI/Oのような並列パイプラインで互いに完全に独立して実行できるコードを並列化することについては、以前は考えていませんでした。アルゴリズムの一部が本質的にシリアルである必要がある場合でも、その処理を単一のスレッドに委ねることはできますが、これらの概念に頼ることで簡単に、そして心配することなく、多額の作業の90%を並列化するための扉が開かれたことがわかります。

0
Dragon Energy