Warum verwenden Haskell und Scheme einfach verknüpfte Listen?

11

Eine doppelt verknüpfte Liste hat nur minimalen Overhead (nur einen weiteren Zeiger pro Zelle) und ermöglicht es Ihnen, an beide Enden anzuhängen, hin und her zu gehen und im Allgemeinen viel Spaß zu haben.

Elliot Gorokhovsky
quelle
Der Listenkonstruktor kann an den Anfang einer einfach verknüpften Liste eingefügt werden, ohne die ursprüngliche Liste zu ändern. Dies ist wichtig für die funktionale Programmierung. Eine doppelt verknüpfte Liste beinhaltet so ziemlich Änderungen, die nicht sehr rein sind.
tp1
3
Denken Sie darüber nach, wie würden Sie überhaupt eine doppelt verknüpfte unveränderliche Liste erstellen? Der nextZeiger des vorherigen Elements muss auf das nächste Element und der prevZeiger des nächsten Elements auf das vorherige Element zeigen. Eines dieser beiden Elemente wird jedoch vor dem anderen erstellt, was bedeutet, dass eines dieser Elemente einen Zeiger haben muss, der auf ein Objekt zeigt, das noch nicht existiert! Denken Sie daran, dass Sie nicht zuerst ein Element, dann das andere erstellen und dann die Zeiger setzen können - sie sind unveränderlich. (Hinweis: Ich weiß, dass es einen Weg gibt, Faulheit auszunutzen, genannt "Tying the Knot".)
Jörg W Mittag
1
Doppelt verknüpfte Listen sind in den meisten Fällen normalerweise nicht erforderlich. Wenn Sie umgekehrt darauf zugreifen müssen, verschieben Sie die Elemente in der Liste auf einen Stapel und legen Sie sie nacheinander für einen O (n) -Umkehralgorithmus ab.
Neil

Antworten:

21

Wenn Sie etwas genauer hinschauen, enthalten beide auch Arrays in der Basissprache:

  • Der 5. überarbeitete Schema Report (R5RS) den Vektor - Typ , die mit fester Größe integer-indizierte Sammlungen mit besser als lineare Zeit für den Direktzugriff.
  • Der Haskell 98-Bericht hat auch einen Array-Typ .

Die funktionale Programmieranweisung hat jedoch lange Zeit einfach verknüpfte Listen gegenüber Arrays oder doppelt verknüpften Listen hervorgehoben. Wahrscheinlich sogar überbetont. Es gibt jedoch mehrere Gründe dafür.

Erstens sind einfach verknüpfte Listen einer der einfachsten und dennoch nützlichsten rekursiven Datentypen. Ein benutzerdefiniertes Äquivalent zum Listentyp von Haskell kann folgendermaßen definiert werden:

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. 

Die Tatsache, dass Listen ein rekursiver Datentyp sind, bedeutet, dass die Funktionen, die mit Listen arbeiten, im Allgemeinen eine strukturelle Rekursion verwenden . In Haskell Bedingungen: Sie Mustererkennung auf der Liste Bauer, und Sie Rekursion auf einem subpart der Liste. In diesen beiden grundlegenden Funktionsdefinitionen verwende ich die Variable as, um auf das Ende der Liste zu verweisen. Beachten Sie also, dass die rekursiven Aufrufe in der Liste "absteigen":

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

Diese Technik garantiert, dass Ihre Funktion für alle endlichen Listen beendet wird, und ist auch eine gute Technik zur Problemlösung - sie teilt Probleme auf natürliche Weise in einfachere, haltbarere Unterabschnitte auf.

Einfach verknüpfte Listen sind daher wahrscheinlich der beste Datentyp, um die Schüler in diese Techniken einzuführen, die für die funktionale Programmierung sehr wichtig sind.

Der zweite Grund ist weniger ein Grund für "Warum einfach verknüpfte Listen" als vielmehr ein Grund für "Warum nicht doppelt verknüpfte Listen oder Arrays": Diese letzteren Datentypen erfordern häufig eine Mutation (modifizierbare Variablen), die sehr häufig funktioniert scheut sich vor. So wie es passiert:

  • In einer eifrigen Sprache wie Scheme können Sie keine doppelt verknüpfte Liste erstellen, ohne Mutation zu verwenden.
  • In einer faulen Sprache wie Haskell können Sie eine doppelt verknüpfte Liste erstellen, ohne eine Mutation zu verwenden. Wenn Sie jedoch eine neue Liste erstellen, die auf dieser Liste basiert, müssen Sie die meisten, wenn nicht die gesamte Struktur des Originals kopieren. Während Sie mit einfach verknüpften Listen Funktionen schreiben können, die "Strukturfreigabe" verwenden, können neue Listen die Zellen alter Listen bei Bedarf wiederverwenden.
  • Wenn Sie Arrays unveränderlich verwendet haben, bedeutete dies traditionell, dass Sie jedes Mal, wenn Sie das Array ändern wollten, das Ganze kopieren mussten. (Neuere Haskell-Bibliotheken wie vectorhaben jedoch Techniken gefunden, die dieses Problem erheblich verbessern).

Der dritte und letzte Grund gilt in erster Linie für faule Sprachen wie Haskell: Faule, einfach verknüpfte Listen ähneln in der Praxis häufig eher Iteratoren als eigentlichen In-Memory-Listen. Wenn Ihr Code die Elemente einer Liste nacheinander verbraucht und sie unterwegs auswirft, materialisiert der Objektcode nur die Listenzellen und ihren Inhalt, wenn Sie die Liste durchgehen.

Dies bedeutet, dass nicht die gesamte Liste gleichzeitig im Speicher vorhanden sein muss, sondern nur die aktuelle Zelle. Zellen vor der aktuellen können durch Müll gesammelt werden (was mit einer doppelt verknüpften Liste nicht möglich wäre). Zellen, die später als die aktuelle sind, müssen erst berechnet werden, wenn Sie dort ankommen.

Es geht noch weiter. In mehreren gängigen Haskell-Bibliotheken, der so genannten Fusion , wird eine Technik verwendet , bei der der Compiler Ihren Listenverarbeitungscode analysiert und Zwischenlisten erkennt, die nacheinander generiert und konsumiert und dann "weggeworfen" werden. Mit diesem Wissen kann der Compiler dann die Speicherzuordnung der Zellen dieser Listen vollständig eliminieren. Dies bedeutet, dass eine einfach verknüpfte Liste in einem Haskell-Quellprogramm nach der Kompilierung möglicherweise tatsächlich in eine Schleife anstelle einer Datenstruktur umgewandelt wird.

Fusion ist auch die Technik, mit der die oben genannte vectorBibliothek effizienten Code für unveränderliche Arrays generiert. Gleiches gilt für die äußerst beliebten Bibliotheken bytestring(Byte-Arrays) und text(Unicode-Strings), die als Ersatz für Haskells nicht sehr guten nativen StringTyp (der [Char]mit einer einfach verknüpften Liste von Zeichen identisch ist ) erstellt wurden. Im modernen Haskell gibt es also einen Trend, bei dem unveränderliche Array-Typen mit Fusionsunterstützung sehr verbreitet sind.

Die Listenfusion wird durch die Tatsache erleichtert, dass Sie in einer einfach verknüpften Liste vorwärts, aber niemals rückwärts gehen können . Dies wirft ein sehr wichtiges Thema in der funktionalen Programmierung auf: Verwenden der "Form" eines Datentyps, um die "Form" einer Berechnung abzuleiten. Wenn Sie Elemente nacheinander verarbeiten möchten, ist eine einfach verknüpfte Liste ein Datentyp, der Ihnen bei Verwendung mit struktureller Rekursion dieses Zugriffsmuster auf ganz natürliche Weise bietet. Wenn Sie eine "Divide and Conquer" -Strategie verwenden möchten, um ein Problem anzugreifen, unterstützen Baumdatenstrukturen dies in der Regel sehr gut.

Viele Leute verlassen den funktionalen Programmierwagen frühzeitig, um sich mit den einfach verknüpften Listen vertraut zu machen, aber nicht mit den fortgeschritteneren zugrunde liegenden Ideen.

Sacundim
quelle
1
Was für eine großartige Antwort!
Elliot Gorokhovsky
14

Weil sie gut mit Unveränderlichkeit arbeiten. Angenommen, Sie haben zwei unveränderliche Listen [1, 2, 3]und [10, 2, 3]. Dargestellt als einfach verknüpfte Listen, bei denen jedes Element in der Liste ein Knoten ist, der das Element und einen Zeiger auf den Rest der Liste enthält, sehen sie folgendermaßen aus:

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

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

Sehen Sie, wie die [2, 3]Portionen identisch sind? Bei veränderlichen Datenstrukturen handelt es sich um zwei verschiedene Listen, da der Code, der neue Daten in eine von ihnen schreibt, keinen Einfluss auf den Code haben muss, der die andere verwendet. Bei unveränderlichen Daten wissen wir jedoch, dass sich der Inhalt der Listen niemals ändern wird und Code keine neuen Daten schreiben kann. So können wir die Schwänze wiederverwenden und die beiden Listen einen Teil ihrer Struktur gemeinsam nutzen:

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

Da Code, der die beiden Listen verwendet, diese niemals mutiert, müssen wir uns nie um Änderungen an einer Liste kümmern, die sich auf die andere auswirken. Dies bedeutet auch, dass Sie beim Hinzufügen eines Elements zur Vorderseite der Liste keine neue Liste kopieren und erstellen müssen.

Wenn Sie jedoch versuchen, [1, 2, 3]und [10, 2, 3]als doppelt verknüpfte Listen darzustellen :

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

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

Jetzt sind die Schwänze nicht mehr identisch. Der erste [2, 3]hat einen Zeiger auf 1am Kopf, der zweite hat einen Zeiger auf 10. Wenn Sie dem Kopf der Liste ein neues Element hinzufügen möchten, müssen Sie außerdem den vorherigen Kopf der Liste mutieren, damit er auf den neuen Kopf verweist.

Das Problem mit mehreren Köpfen könnte möglicherweise behoben werden, indem jeder Knoten eine Liste bekannter Köpfe speichert und die Erstellung neuer Listen dies ändert. Anschließend müssen Sie jedoch daran arbeiten, diese Liste in Garbage Collection-Zyklen zu verwalten, wenn Versionen der Liste unterschiedliche Köpfe haben haben unterschiedliche Lebensdauern, da sie in verschiedenen Codeteilen verwendet werden. Es erhöht die Komplexität und den Overhead und ist es meistens nicht wert.

Jack
quelle
8
Das Teilen von Schwänzen geschieht jedoch nicht, wie Sie implizieren. Im Allgemeinen geht niemand alle Listen im Speicher durch und sucht nach Möglichkeiten, gemeinsame Suffixe zusammenzuführen. Das Teilen geschieht einfach , es fällt aus der Art und Weise heraus, wie die Algorithmen geschrieben sind, z. B. wenn eine Funktion mit einem Parameter an einer Stelle und an einer anderen xskonstruiert wird . 1:xs10:xs
0

Die Antwort von @ sacundim ist größtenteils richtig, aber es gibt auch einige andere wichtige Erkenntnisse zum Kompromiss zwischen Sprachdesigns und praktischen Anforderungen.

Objekte und Referenzen

Diese Sprachen schreiben normalerweise Objekte mit ungebundenen dynamischen Ausmaßen vor (oder nehmen diese an) (oder in Cs Sprache die Lebensdauer , obwohl sie aufgrund der Bedeutungsunterschiede von Objekten zwischen diesen Sprachen nicht exakt gleich sind, siehe unten), wobei erstklassige Referenzen vermieden werden (oder). zB Objektzeiger in C) und unvorhersehbares Verhalten in den semantischen Regeln (zB das undefinierte Verhalten von ISO C in Bezug auf Semantik).

Darüber hinaus ist der Begriff (erstklassiger) Objekte in solchen Sprachen konservativ einschränkend: Es werden standardmäßig keine "lokalen" Eigenschaften angegeben und garantiert. Dies ist in einigen ALGOL-ähnlichen Sprachen, deren Objekte keine ungebundenen dynamischen Ausmaße aufweisen (z. B. in C und C ++), völlig anders, wobei Objekte im Grunde genommen eine Art "typisierten Speicher" bedeuten, der normalerweise mit Speicherorten gekoppelt ist.

Das Codieren des Speichers innerhalb der Objekte bietet einige zusätzliche Vorteile, z. B. das Anhängen deterministischer Recheneffekte während ihrer gesamten Lebensdauer. Dies ist jedoch ein anderes Thema.

Probleme der Datenstruktursimulation

Ohne erstklassige Referenzen können einfach verknüpfte Listen aufgrund der Art der Darstellung dieser Datenstrukturen und der begrenzten primitiven Operationen in diesen Sprachen viele traditionelle (eifrige / veränderbare) Datenstrukturen nicht effektiv und portabel simulieren. (Im Gegenteil, in C können Sie verknüpfte Listen auch in einem streng konformen Programm recht einfach ableiten .) Und solche alternativen Datenstrukturen wie Arrays / Vektoren haben in der Praxis einige überlegene Eigenschaften im Vergleich zu einfach verknüpften Listen. Deshalb führt R 5 RS neue primitive Operationen ein.

Es gibt jedoch Unterschiede zwischen Vektor- / Array-Typen und doppelt verknüpften Listen. Ein Array wird häufig mit einer Komplexität der O (1) -Zugriffszeit und einem geringeren Speicherplatzaufwand angenommen. Dies sind hervorragende Eigenschaften, die von Listen nicht gemeinsam genutzt werden. (Obwohl genau genommen, wird beides nicht durch ISO C garantiert, aber Benutzer erwarten es fast immer und keine praktische Implementierung würde diese impliziten Garantien zu offensichtlich verletzen.) OTOH, eine doppelt verknüpfte Liste macht beide Eigenschaften oft noch schlimmer als eine einfach verknüpfte Liste , während die Rückwärts- / Vorwärtsiteration auch von einem Array oder einem Vektor (zusammen mit ganzzahligen Indizes) mit noch weniger Overhead unterstützt wird. Daher ist eine doppelt verknüpfte Liste im Allgemeinen nicht leistungsfähiger. Noch schlimmer, Die Leistung in Bezug auf die Cache-Effizienz und die Latenz bei der dynamischen Speicherzuweisung von Listen ist katastrophal schlechter als die Leistung für Arrays / Vektoren, wenn der Standardzuweiser verwendet wird, der von der zugrunde liegenden Implementierungsumgebung (z. B. libc) bereitgestellt wird. Ohne eine sehr spezifische und "clevere" Laufzeit, die solche Objekterstellungen stark optimiert, werden Array- / Vektortypen häufig verknüpften Listen vorgezogen. (Bei Verwendung von ISO C ++ gibt es beispielsweise eine Einschränkungstd::vectorsollte std::liststandardmäßig bevorzugt werden.) Daher ist die Einführung neuer Grundelemente zur spezifischen Unterstützung von (doppelt) verknüpften Listen definitiv nicht so vorteilhaft, dass Array- / Vektordatenstrukturen in der Praxis unterstützt werden.

Um fair zu sein, haben Listen immer noch einige spezifische Eigenschaften, die besser sind als Arrays / Vektoren:

  • Listen sind knotenbasiert. Durch das Entfernen von Elementen aus Listen wird der Verweis auf andere Elemente in anderen Knoten nicht ungültig . (Dies gilt auch für einige Baum- oder Diagrammdatenstrukturen.) OTOH, Arrays / Vektoren können Verweise auf die ungültig machende nachfolgende Position enthalten (in einigen Fällen mit massiver Neuzuweisung).
  • Listen können in O (1) Zeit gespleißt werden. Die Rekonstruktion neuer Arrays / Vektoren mit aktuellen ist weitaus kostspieliger.

Diese Eigenschaften sind jedoch nicht allzu wichtig für eine Sprache mit integrierter Unterstützung für einfach verknüpfte Listen, die bereits für eine solche Verwendung geeignet ist. Obwohl es immer noch Unterschiede gibt, kann in Sprachen mit vorgeschriebenen dynamischen Ausmaßen von Objekten (was normalerweise bedeutet, dass ein Garbage Collector die baumelnden Referenzen fernhält) die Invalidierung je nach Absicht auch weniger wichtig sein. Die einzigen Fälle, in denen doppelt verknüpfte Listen gewinnen, können sein:

  • Es sind sowohl Nicht-Neuzuweisungsgarantien als auch bidirektionale Iterationsanforderungen erforderlich. (Wenn die Leistung des Elementzugriffs wichtig ist und der Datensatz groß genug ist, würde ich stattdessen binäre Suchbäume oder Hash-Tabellen wählen.)
  • Effiziente bidirektionale Spleißoperationen sind erforderlich. Dies ist sehr selten. (Ich erfülle die Anforderungen nur für die Implementierung von linearen Verlaufsdatensätzen in einem Browser.)

Unveränderlichkeit und Aliasing

In einer reinen Sprache wie Haskell sind Objekte unveränderlich. Das Objekt des Schemas wird häufig ohne Mutation verwendet. Diese Tatsache ermöglicht es, die Speichereffizienz durch Objektinternierung effektiv zu verbessern - implizite gemeinsame Nutzung mehrerer Objekte mit demselben Wert im laufenden Betrieb.

Dies ist eine aggressive Optimierungsstrategie auf hoher Ebene im Sprachdesign. Dies ist jedoch mit Implementierungsproblemen verbunden. Tatsächlich werden implizite Aliase in zugrunde liegende Speicherzellen eingeführt. Dies erschwert die Aliasing-Analyse. Infolgedessen gibt es wahrscheinlich weniger Möglichkeiten, den Aufwand für nicht erstklassige Referenzen zu beseitigen, selbst Benutzer berühren sie überhaupt nicht. Wenn in Sprachen wie Scheme die Mutation nicht vollständig ausgeschlossen ist, stört dies auch die Parallelität. In einer faulen Sprache (die ohnehin schon Leistungsprobleme durch Thunks hat) ist dies möglicherweise in Ordnung.

Für die allgemeine Programmierung kann eine solche Wahl des Sprachdesigns problematisch sein. Aber mit einigen gängigen funktionalen Codierungsmustern scheinen die Sprachen immer noch gut zu funktionieren.

FrankHB
quelle