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


11

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


リストコンストラクターは、元のリストを変更せずに、単一リンクリストの先頭に挿入できます。これは関数型プログラミングにとって重要です。二重リンクリストにはほとんど変更が含まれますが、あまり純粋ではありません。
tp1 2015

3
考えてみてください。どうすれば二重リンクの不変リストを作成できますか?next前の要素のprevポインタが次の要素を指し、次の要素のポインタが前の要素を指している必要があります。ただし、これらの2つの要素のいずれかが先に作成されます。つまり、これらの要素の1つには、まだ存在しないオブジェクトを指すポインターが必要です。最初に1つの要素を作成し、次にもう1つの要素を作成してからポインタを設定することはできません。これらは不変です。(注: "結び目を結ぶ"と呼ばれる、怠惰を悪用する方法があることを知っています。)
JörgW Mittag

1
ほとんどの場合、二重リンクリストは通常​​不要です。逆にアクセスする必要がある場合は、リストのアイテムをスタックにプッシュし、O(n)逆転アルゴリズムのために1つずつポップします。
Neil、

回答:


21

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

  • 5番目の改訂されたスキームレポート(R5RS)には、ランダムタイプの線形時間よりも優れた固定サイズの整数インデックスコレクションであるベクトルタイプが含まれています。
  • 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. 

リストが再帰的なデータ型であることは、リストで機能する関数が一般に構造な再帰を使用することを意味します。Haskellの用語では、リストコンストラクターでパターンマッチを行い、リストのサブパートで再帰します。これら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番目の理由は、「なぜ単一リンクリストなのか」という理由ではなく、「なぜ二重リンクリストや配列でないのか」という理由です。後者のデータ型では、関数型プログラミングが頻繁に行われる変異(変更可能な変数)を必要とします。遠ざかる。それが起こるように:

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

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

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

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

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

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

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


1
なんて素晴らしい答えでしょう!
Elliot Gorokhovsky

14

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

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

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

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

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

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

しかし、あなたがしようと表した場合[1, 2, 3][10, 2, 3]のように二重にリンクされたリスト:

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

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

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

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


8
ただし、暗黙的にテール共有は行われません。一般に、誰もがメモリ内のすべてのリストを調べて、一般的なサフィックスをマージする機会を探すことはありません。共有が行われるだけで、たとえば、パラメータを持つ関数がある場所と別の場所でxs構築さ1:xsれている場合など、アルゴリズムの記述方法から外れます10:xs

0

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

オブジェクトと参照

これらの言語は通常、バインドされていない動的エクステント(またはCの用語では、これらの言語間でのオブジェクトの意味の違いのため、正確には同じではありませんが、下記のライフタイム)を持つオブジェクトを強制(または想定)し、ファーストクラスの参照(たとえば、Cのオブジェクトポインター)とセマンティックルールの予測できない動作(たとえば、ISO Cのセマンティクスに関する未定義の動作)。

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

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

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

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

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

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

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

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

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

不変性とエイリアシング

Haskellのような純粋な言語では、オブジェクトは不変です。Schemeのオブジェクトは、しばしば変更なしで使用されます。このような事実により、オブジェクトのインターン(実行中に同じ値を持つ複数のオブジェクトを暗黙的に共有する)を使用して、メモリ効率を効果的に向上させることができます

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

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

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