関数型プログラミングでは、ほとんどのデータ構造を不変にするために、より多くのメモリ使用量が必要ですか?


63

関数型プログラミングでは、ほとんどすべてのデータ構造が不変であるため、状態を変更する必要がある場合、新しい構造が作成されます。これはより多くのメモリ使用量を意味しますか?オブジェクト指向プログラミングのパラダイムをよく知っているので、今は関数型プログラミングのパラダイムについて学ぼうとしています。すべてが不変であるという概念は私を混乱させます。不変の構造を使用するプログラムは、可変の構造を持つプログラムよりも多くのメモリを必要とするように思われます。私はこれを正しい方法で見ていますか?


7
それ意味しますが、ほとんどの不変のデータ構造は、変更のために基礎となるデータを再利用します。エリックリペット約偉大なブログのシリーズがあるC#での不変性
オデッド

3
私はそれが(本は主にSMLですが)Haskellのコンテナライブラリのほとんどを書いた同じ人によって書かれている素晴らしい本だ、純粋に機能的なデータ構造を見てみましょう
jozefg

1
メモリ消費ではなく実行時間に関連するこの回答も興味深い場合があります。stackoverflow.com
9000

回答:


35

これに対する唯一の正しい答えは「時々」です。関数型言語がメモリの浪費を避けるために使用できる多くのトリックがあります。コンパイラはデータが変更されないことを保証できるため、不変性により、関数間やデータ構造間でもデータを共有しやすくなります。関数型言語は、不変の構造として効率的に使用できるデータ構造の使用を奨励する傾向があります(たとえば、ハッシュテーブルの代わりにツリー)。多くの関数型言語と同様に、ミックスに遅延を追加すると、メモリを節約する新しい方法が追加されます(メモリを浪費する新しい方法も追加されますが、私はそれには触れません)。


24

関数型プログラミングでは、ほとんどすべてのデータ構造が不変であるため、状態を変更する必要がある場合、新しい構造が作成されます。これはより多くのメモリ使用量を意味しますか?

これは、データ構造、実行した正確な変更、および場合によってはオプティマイザーによって異なります。一例として、リストの先頭に追加することを考えてみましょう。

list2 = prepend(42, list1) // list2 is now a list that contains 42 followed
                           // by the elements of list1. list1 is unchanged

ここで、追加のメモリ要件は一定prependです。呼び出しの実行時コストも同様です。どうして?prepend単純に42、頭とlist1尻尾を持つ新しいセルを作成するからです。これlist2を実現するために、コピーしたり繰り返したりする必要はありません。つまり、保存42に必要なメモリを除きlist2、で使用されるのと同じメモリを再利用しlist1ます。両方のリストは不変なので、この共有は完全に安全です。

同様に、バランスのとれたツリー構造で作業する場合、ほとんどの操作では、ツリーの1つのパスを共有できるため、対数的な追加スペースのみが必要です。

配列の場合、状況は少し異なります。そのため、多くのFP言語では、配列はそれほど一般的に使用されません。あなたのような何かを行う場合は、arr2 = map(f, arr1)およびarr1この行の後に再び使用されることはありません、スマートオプティマイザは、実際に変異するコードを作成することができますarr1(プログラムの動作に影響を与えずに)代わりに、新しい配列を作成するのを。その場合、パフォーマンスはもちろん命令型言語のようになります。


1
興味深いことに、最後に説明したように、どの言語のどの実装がスペースを再利用しますか?

@delnan私の大学では、Qubeという研究言語がありました。しかし、これを行う野生の言語があるかどうかはわかりません。ただし、Haskellの融合は多くの場合同じ効果を達成できます。
sepp2k

7

単純な実装では、実際にこの問題が明らかになります。既存のデータ構造をその場で更新するのではなく、新しいデータ構造を作成する場合、オーバーヘッドが必要です。

異なる言語はこれに対処する異なる方法を持ち、それらのほとんどが使用するいくつかのトリックがあります。

1つの戦略はガベージコレクションです。新しい構造が作成された瞬間、またはその直後に、古い構造への参照が範囲外になり、GCアルゴリズムに応じて、ガベージコレクターがすぐにまたはすぐにそれを取得します。つまり、オーバーヘッドはまだありますが、一時的なものであり、データ量に比例して増加することはありません。

もう1つは、さまざまな種類のデータ構造の選択です。配列が命令型言語(通常std::vector、C ++ などの動的再割り当てコンテナにラップされている)のリストのデータ構造である場合、関数型言語はリンクリストを好むことがよくあります。リンクリストを使用すると、プリペンド操作( 'cons')は既存のリストを新しいリストの末尾として再利用できるため、実際に割り当てられるのは新しいリストの先頭だけです。他の種類のデータ構造(セット、ツリー、名前など)にも同様の戦略が存在します。

そして、Haskellのような怠laな評価があります。アイデアは、作成するデータ構造がすぐに完全に作成されるわけではないということです。代わりに、「サンク」として保存されます(必要なときに値を構築するためのレシピと考えることができます)。値が必要な場合のみ、サンクは実際の値に展開されます。つまり、評価が必要になるまでメモリ割り当てを延期でき、その時点で、複数のサンクを1つのメモリ割り当てに結合できます。


うわー、1つの小さな答えと非常に多くの情報/洞察。ありがとう:)
ジェリー

3

私はClojureについて少しだけ知っていて、それは不変のデータ構造です。

Clojureは、不変のリスト、ベクター、セット、マップのセットを提供します。変更できないため、不変コレクションから何かを「追加」または「削除」することは、古いコレクションと同じように必要な変更を加えた新しいコレクションを作成することを意味します。永続性とは、「変更」後も古いバージョンのコレクションが引き続き使用可能であり、コレクションがほとんどの操作に対してパフォーマンスの保証を維持するプロパティを説明するために使用される用語です。具体的には、これは、完全なコピーを使用して新しいバージョンを作成できないことを意味します。これには、時間がかかるためです。必然的に、永続コレクションはリンクされたデータ構造を使用して実装されるため、新しいバージョンは以前のバージョンと構造を共有できます。

グラフィカルに、次のようなものを表すことができます。

(def my-list '(1 2 3))

    +---+      +---+      +---+
    | 1 | ---> | 2 | ---> | 3 |
    +---+      +---+      +---+

(def new-list (conj my-list 0))

              +-----------------------------+
    +---+     | +---+      +---+      +---+ |
    | 0 | --->| | 1 | ---> | 2 | ---> | 3 | |
    +---+     | +---+      +---+      +---+ |
              +-----------------------------+

2

他の回答で述べられたことに加えて、いわゆるユニークな型をサポートするCleanプログラミング言語に言及したいと思います。私はこの言語を知りませんが、ユニークな型が何らかの「破壊的な更新」をサポートすると思います。

つまり、状態を更新するセマンティクスは、関数を適用して古い値から新しい値を作成することですが、一意性制約により、コンパイラーは古い値が参照されないことを知っているため、データオブジェクトを内部で再利用できます新しい値が生成された後、プログラムでそれ以上。

詳細については、クリーンホームページやこのウィキペディアの記事をご覧ください。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.