web-dev-qa-db-ja.com

HaskellとSchemeが単一リンクリストを使用するのはなぜですか?

二重にリンクされたリストはオーバーヘッドが最小限(セルごとに1つのポインターのみ)であり、両端に追加して前後に移動することができ、一般的に多くの楽しみがあります。

11

ええと、少し深く見ると、どちらも実際には基本言語の配列も含んでいます。

  • 5番目の改訂されたスキームレポート(R5RS)には、ランダムアクセスの線形時間よりも優れた固定サイズの整数インデックスコレクションである vectortype が含まれています。
  • Haskell 98レポートには 配列タイプ もあります。

ただし、関数型プログラミング命令では、長い間、配列または二重リンクリストよりも単一リンクリストが強調されてきました。実際、かなり強調されすぎている可能性があります。ただし、これにはいくつかの理由があります。

1つ目は、単一リンクリストが最も単純でありながら最も有用な再帰データ型の1つであることです。 Haskellのリスト型に相当するユーザー定義は、次のように定義できます。

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

リストが再帰的なデータ型であることは、リストで機能する関数が一般的に structural recursion を使用することを意味します。 Haskellの用語では、リストコンストラクターでパターンマッチを行い、リストのsubpartで再帰します。これら2つの基本的な関数定義では、変数asを使用してリストの末尾を参照しています。したがって、再帰呼び出しはリストを「下る」ことに注意してください。

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

この手法は、関数がすべての有限リストに対して確実に終了することを保証します。また、問題を解決する優れた手法でもあります。つまり、問題をより単純でより扱いやすいサブパートに自然に分割する傾向があります。

したがって、単一リンクリストはおそらく、関数型プログラミングで非常に重要なこれらの手法を学生に紹介するのに最適なデータ型です。

2番目の理由は、「なぜ単一リンクリストなのか」という理由ではなく、「なぜ二重リンクリストや配列でないのか」という理由です:後者のデータ型では、mutation(変更可能な変数)、関数型プログラミングでは非常に頻繁に回避されます。それが起こるように:

  • Schemeのような熱心な言語では、ミューテーションを使用しないと二重リンクリストを作成できません。
  • Haskellのような遅延言語では、ミューテーションを使用せずに二重リンクリストを作成できます。しかし、そのリストに基づいて新しいリストを作成するときはいつでも、元の構造のすべてではないにしてもほとんどをコピーする必要があります。一方、単一リンクリストでは、「構造共有」を使用する関数を記述できます。新しいリストは、必要に応じて古いリストのセルを再利用できます。
  • 従来、配列を不変の方法で使用した場合、配列を変更するたびに、すべてをコピーする必要がありました。 (ただし、 vector のような最近のHaskellライブラリでは、この問題を大幅に改善する手法が見つかりました)。

3番目の最後の理由は、主にHaskellのような遅延言語に適用されます。遅延シングルリンクリストは、実際には、メモリ内リストよりもiteratorsに似ていることがよくあります。コードがリストの要素を順番に消費していき、それらを破棄する場合、オブジェクトコードは、リストを進めていくときにリストのセルとその内容のみを実体化します。

これは、リスト全体が一度にメモリに存在する必要はなく、現在のセルだけが存在する必要があることを意味します。現在のセルの前のセルはガベージコレクションされます(二重リンクリストでは不可能です)。現在のセルより後のセルは、そこに到達するまで計算する必要はありません。

それだけではありません。 fusion と呼ばれるいくつかの人気のあるHaskellライブラリで使用されているテクニックは、コンパイラがリスト処理コードを分析し、中間リストを見つけるそれらは順番に生成および消費され、その後「捨てられ」ます。この知識があれば、コンパイラーはそれらのリストのセルのメモリー割り当てを完全に排除できます。つまり、Haskellソースプログラムの単一リンクリストは、コンパイル後、実際にはデータ構造ではなくloopに変わる可能性があります。

Fusionは、前述のvectorライブラリが不変配列の効率的なコードを生成するために使用する手法でもあります。同じことが非常に人気のあるbytestring(バイト配列)およびtext(Unicode文字列)ライブラリにも当てはまります。これらのライブラリは、Haskellのそれほど大きくないネイティブStringタイプの代わりとして構築されました(これは[Char]と同じ、文字の単一リンクリスト)。したがって、最近のHaskellでは、フュージョンをサポートする不変の配列型が非常に一般的になる傾向があります。

リストの融合は、単一リンクリストではforwardはできるがbackwardsはできないという事実によって促進されます。これは、関数型プログラミングにおいて非常に重要なテーマをもたらします。データ型の「形状」を使用して計算の「形状」を導出することです。要素を順次処理する場合、単一リンクリストはデータ型であり、構造的な再帰でそれを利用すると、そのアクセスパターンが非常に自然に得られます。 「分割統治」戦略を使用して問題を攻撃する場合、ツリーデータ構造はそれを非常によくサポートする傾向があります。

多くの人々は早い段階で関数型プログラミングワゴンから脱落するため、シングルリンクリストにアクセスできますが、より高度な基本的なアイデアにはアクセスできません。

21
sacundim

それらは不変性でうまく機能するからです。 2つの不変リスト[1, 2, 3][10, 2, 3]があるとします。シングルリンクリストとして表されます。リストの各アイテムは、アイテムとリストの残りへのポインターを含むノードであり、次のようになります。

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

[2, 3]部分がどのように同一であるかを確認しますか?可変データ構造では、一方に新しいデータを書き込むコードがもう一方を使用するコードに影響を与える必要がないため、2つの異なるリストになります。ただし、immutable dataを使用すると、リストの内容が変更されることはなく、コードで新しいデータを書き込むことはできません。したがって、尾を再利用して、2つのリストで構造の一部を共有することができます。

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

2つのリストを使用するコードはリストを変更しないため、1つのリストの変更が他のリストに影響を与えることを心配する必要はありません。これは、リストの前に項目を追加するときに、まったく新しいリストをコピーして作成する必要がないことも意味します。

ただし、[1, 2, 3]および[10, 2, 3]doublyリンクリストとして表すと、次のようになります。

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

今、尾はもはや同一ではありません。最初の[2, 3]は先頭に1へのポインターがありますが、2番目は10へのポインターがあります。さらに、リストの先頭に新しいアイテムを追加する場合は、リストの前の先頭を変更して、新しい先頭を指すようにする必要があります。

複数のヘッドの問題は、各ノードに既知のヘッドのリストを保存させ、新しいリストを作成してそれを変更させることで修正できる可能性がありますが、異なるヘッドを持つリストのバージョンがある場合、そのリストをガベージコレクションサイクルに維持する必要があります。さまざまなコードで使用されているため、寿命が異なります。それは複雑さとオーバーヘッドを追加し、ほとんどの場合それは価値がありません。

14
Jack

@sacundimの答えはほとんどが真実ですが、言語設計と実際の要件についてのトレードオフに関する他のいくつかの重要な洞察もあります。

オブジェクトと参照

これらの言語は通常、バインドされていない動的エクステント(またはCの専門用語ではlifetime、ただし、これらの言語間でobjectsの意味が異なるため、完全に同じではありませんが、デフォルトでは、最初に回避しますクラスの参照(Cのオブジェクトポインターなど)とセマンティックルールの予測できない動作(ISO Cのセマンティクスに関する未定義の動作など)。

さらに、そのような言語での(ファーストクラスの)オブジェクトの概念は保守的に制限されています。デフォルトでは、「位置」プロパティは何も指定および保証されていません。これは、オブジェクトがバインドされていない動的エクステントを持たない一部のALGOLのような言語では完全に異なります(CやC++など)。オブジェクトは基本的に、通常はメモリロケーションと結合された、ある種の「型付きストレージ」を意味します。

オブジェクト内のストレージをエンコードすることには、その生涯を通じて確定的な計算効果を付加できるなど、いくつかの追加の利点がありますが、これは別のトピックです。

データ構造シミュレーションの問題

ファーストクラスの参照がないと、これらのデータ構造の表現の性質とこれらの言語のプリミティブな操作の制限により、シングルリンクリストは多くの従来の(積極的/可変)データ構造を効果的かつ移植性よくシミュレートできません。 (反対に、Cでは、厳密に準拠するプログラムでも、リンクリストを非常に簡単に導出できます。)そして、配列/ベクトルなどの代替データ構造実際には、単一リンクリストと比較して、いくつかの優れたプロパティがあります。だからR5RSは新しい基本操作を導入します。

しかし、ベクトル/配列型と二重にリンクされたリストとでは違いがあります。多くの場合、配列はO(1)アクセス時間の複雑さと少ないスペースオーバーヘッドで想定されます。これは、リストでは共有されない優れたプロパティです。厳密には、どちらもISO Cでは保証されていませんが、ユーザーはほとんど常にそれを期待しており、実際の実装ではこれらの暗黙の保証に明らかに違反することはありません。)OTOH、二重にリンクされたリストは、多くの場合、単一にリンクされたリストよりも両方のプロパティをさらに悪化させますが、逆方向/順方向の反復も配列またはオーバーヘッドがさらに少ないベクトル(整数インデックスと共に)。したがって、二重にリンクされたリストは、一般にパフォーマンスが良くありません。さらに悪いことに、リストの動的メモリ割り当てのキャッシュ効率と待ち時間に関するパフォーマンスは、パフォーマンスよりも致命的に悪いです配下の実装環境(libcなど)によって提供されるデフォルトのアロケーターを使用する場合の配列/ベクトルの場合、そのようなオブジェクトの作成を大幅に最適化する非常に具体的で「賢い」ランタイムがない場合、配列/ベクトルtyp esはリンクリストよりも好まれます。 (たとえば、ISO C++を使用する場合、デフォルトではstd::vectorstd::listよりも優先されるべきであるという警告があります。)したがって、(二重に)リンクされたリストを具体的にサポートする新しいプリミティブを導入することは間違いなくそうではありません。実際に配列/ベクトルデータ構造をサポートするために有益です。

公平を期すために、リストには配列/ベクトルよりも優れた特定のプロパティがいくつかあります。

  • リストはノードベースです。リストから要素を削除しても、他のノードの他の要素への参照は無効になりません。 (これは、一部のツリーまたはグラフのデータ構造にも当てはまります。)OTOH、配列/ベクトルは、無効化されている末尾の位置への参照を作成できます(場合によっては、大規模な再割り当てが行われます)。
  • リストはspliceでO(1)時間。現在の配列での新しい配列/ベクトルの再構築ははるかにコストがかかります。

ただし、これらのプロパティは、組み込みの単一リンクリストのサポートを備えた言語ではそれほど重要ではありません。これは、そのような使用がすでに可能です。まだ違いはありますが、オブジェクトの動的エクステントが必須の言語(通常、ぶら下がっている参照を遠ざけるガベージコレクターがあることを意味します)では、目的によっては無効化の重要性が低くなる場合もあります。したがって、二重にリンクされたリストが勝つ唯一のケースは次のとおりです。

  • 非再配置保証と双方向反復要件の両方が必要です。 (要素アクセスのパフォーマンスが重要で、データのセットが十分に大きい場合は、代わりにバイナリ検索ツリーまたはハッシュテーブルを選択します。)
  • 効率的な双方向スプライス操作が必要です。これはかなりまれです。 (私は、ブラウザーに線形履歴レコードのようなものを実装する場合にのみ要件を満たします。)

不変性とエイリアシング

Haskellのような純粋な言語では、オブジェクトは不変です。 Schemeのオブジェクトは、変更なしで使用されることがよくあります。このような事実により、object interning-オンザフライで同じ値を持つ複数のオブジェクトを暗黙的に共有することで、メモリ効率を効果的に向上させることができます。

これは、言語設計における積極的な高レベルの最適化戦略です。ただし、これには実装の問題が伴います。これは実際に、基礎となるストレージセルに暗黙のエイリアスを導入します。これにより、エイリアシング分析がより困難になります。その結果、たとえファーストクラス以外の参照のオーバーヘッドを排除する可能性が少なくなる可能性があります。 Schemeなどの言語では、変異が完全に排除されない場合、並列処理も妨げられます。ただし、怠惰な言語(サンクによるパフォーマンスの問題が既に発生しています)では問題ありません。

汎用プログラミングの場合、このような言語設計の選択には問題があるかもしれません。しかし、いくつかの一般的な関数型コーディングパターンがあれば、言語はまだうまく機能しているように見えます。

0
FrankHB