Zwar erscheinen beide in den Fragen genannten Aspekte als Formen des Nichtdeterminismus, doch unterscheiden sie sich sowohl in ihrer Arbeitsweise als auch in ihren Zielen erheblich. Daher muss jede Antwort notwendigerweise in zwei Teile geteilt werden.
Auswertungsreihenfolge
Haskell schreibt im Wesentlichen aus zwei Gründen keine bestimmte Ausführungsreihenfolge für die Bewertung von Thunks vor.
- Erstens ist Haskell eine rein funktionale Sprache, sodass Sie garantiert referenzielle Transparenz haben (wenn Sie nicht mit
unsafePerformIO
& co. Herumspielen ). Dies bedeutet, dass die Auswertung eines Ausdrucks, z. B. zu f x
demselben Ergebnis führt, unabhängig davon, wie oft er ausgewertet wird und in welchem Teil des Programms er ausgewertet wird (unter der Annahme f
und x
Bindung an dieselben Werte in den betrachteten Bereichen von) Kurs). Das Mandatieren einer bestimmten Ausführungsreihenfolge hätte daher keinen Zweck , da eine Änderung keine beobachtbaren Auswirkungen auf das Ergebnis des Programms hätte. In dieser Hinsicht ist dies nicht wirklich eine Form des Nichtdeterminismus, zumindest keine Form des Beobachtbaren Erstens, da die verschiedenen möglichen Ausführungen des Programms alle semantisch äquivalent sind.
Das Ändern der Ausführungsreihenfolge kann sich jedoch auf die Leistung des Programms auswirken
, und es ist von grundlegender Bedeutung, dem Compiler die Freiheit zu lassen, die Reihenfolge nach Belieben zu manipulieren, um die erstaunliche Leistung zu erzielen, die ein Compiler wie GHC beim Kompilieren eines so hohen Werts erzielen kann. Level-Sprache. Stellen Sie sich als Beispiel eine klassische Stream-Fusion-Transformation vor:
map f . map g = map (f.g)
Diese Gleichheit bedeutet einfach, dass das Anwenden von zwei Funktionen auf eine Liste mit map
dem gleichen Ergebnis hat wie das einmalige Anwenden der Zusammensetzung der beiden Funktionen. Dies gilt nur aufgrund der referenziellen Transparenz und ist eine Art Transformation, die der Compiler immer ausführen kannbewerben, egal was. Wenn eine Änderung der Ausführungsreihenfolge der drei Funktionen Auswirkungen auf das Ergebnis des Ausdrucks hätte, wäre dies nicht möglich. Auf der anderen Seite kann das Kompilieren in der zweiten Form anstelle der ersten eine enorme Auswirkung auf die Leistung haben, da die Erstellung einer temporären Liste vermieden wird und die Liste nur einmal durchlaufen wird. Die Tatsache, dass GHC eine solche Transformation automatisch anwenden kann, ist eine direkte Folge der referenziellen Transparenz und der nicht festgelegten Ausführungsreihenfolge und einer der Schlüsselaspekte der großartigen Leistung, die Haskell erzielen kann.
- Haskell ist eine faule Sprache. Dies bedeutet, dass ein bestimmter Ausdruck nicht ausgewertet werden muss, es sei denn, sein Ergebnis wird tatsächlich benötigt, und dies könnte auch niemals sein. Faulheit ist ein manchmal umstrittenes Merkmal, und einige andere funktionale Sprachen vermeiden es oder beschränken es, sich anzumelden, aber im Kontext von Haskell ist es ein Schlüsselmerkmal in der Art und Weise, wie die Sprache verwendet und gestaltet wird. Laziness ist ein weiteres leistungsstarkes Tool in den Händen des Optimierers des Compilers und ermöglicht vor allem das einfache Zusammenstellen von Code .
Um zu sehen, was ich unter einfacher Komposition verstehe, betrachten Sie ein Beispiel, wenn Sie eine Funktion haben producer :: Int -> [Int]
, die eine komplexe Aufgabe zum Berechnen einer Liste von Daten aus einem Eingabeargument ausführt und consumer :: [Int] -> Int
eine andere komplexe Funktion ist, die ein Ergebnis aus einer Liste von berechnet Eingabedaten. Sie haben sie separat geschrieben, getestet, sehr sorgfältig optimiert und isoliert in verschiedenen Projekten verwendet. Jetzt kommt es im nächsten Projekt vor, dass Sie consumer
auf das Ergebnis von zurückgreifen müssenproducer
. In einer nicht faulen Sprache kann dies nicht optimal sein, da es der Fall sein kann, dass die kombinierte Aufgabe am effizientesten implementiert werden kann, ohne eine temporäre Listenstruktur zu erstellen. Um eine optimierte Implementierung zu erhalten, müssten Sie die kombinierte Aufgabe von Grund auf neu implementieren, erneut testen und erneut optimieren.
In haskell ist dies nicht erforderlich, und das Aufrufen der Zusammensetzung der beiden Funktionen consumer . producer
ist vollkommen in Ordnung. Der Grund dafür ist, dass das Programm nicht das gesamte Ergebnis von producer
vor der Weitergabe erstellen muss consumer
. Sobald consumer
das erste Element seiner Eingabeliste benötigt wird, wird der entsprechende Code von producer
so weit wie nötig ausgeführt, um ihn zu erstellen, und nicht mehr. Wenn das zweite Element benötigt wird, wird es berechnet. Wenn ein Element von nicht benötigt wird consumer
, wird es überhaupt nicht berechnet, wodurch nutzlose Berechnungen effektiv gespeichert werden. Die Ausführung von consumer
undproducer
wird effektiv verschachtelt, wodurch nicht nur die Speichernutzung der Zwischenlistenstruktur vermieden wird, sondern möglicherweise auch nutzlose Berechnungen vermieden werden, und die Ausführung würde wahrscheinlich der handgeschriebenen kombinierten Version ähneln, die Sie selbst selbst schreiben mussten. Das habe ich mit Komposition gemeint . Sie hatten zwei gut getestete und performante Code-Teile und konnten diese zusammenstellen, um kostenlos einen gut getesteten und performanten Code zu erhalten.
Nichtdeterministische Monaden
Die Verwendung von nicht deterministischem Verhalten, das von den Listenmonaden und ähnlichen bereitgestellt wird, ist stattdessen völlig anders. Hier geht es nicht darum, dem Compiler Mittel zur Optimierung Ihres Programms zur Verfügung zu stellen, sondern darum, Berechnungen, die von Natur aus nicht deterministisch sind, klar und präzise auszudrücken.
Ein Beispiel dafür, was ich meine, ist die Schnittstelle der Data.Boolean.SatSolver
Bibliothek. Es bietet einen sehr einfachen DPLL SAT-Solver, der in Haskell implementiert ist. Wie Sie vielleicht wissen, müssen Sie zur Lösung des SAT-Problems eine Zuordnung von Booleschen Variablen finden, die einer Booleschen Formel entspricht. Es kann jedoch mehr als eine solche Zuweisung geben, und je nach Anwendung muss möglicherweise eine von ihnen gefunden oder über alle iteriert werden. Daher haben viele Bibliotheken zwei verschiedene Funktionen wie getSolution
und getAllSolutions
. Diese Bibliothek hat stattdessen nur eine Funktion solve
mit folgendem Typ:
solve :: MonadPlus m => SatSolver -> m SatSolver
Hier ist das Ergebnis ein SatSolver
Wert, der in eine Monade eines nicht angegebenen Typs eingeschlossen ist, der jedoch auf die Implementierung der MonadPlus
Typklasse beschränkt ist. Diese Typklasse ist diejenige, die die Art von Nichtdeterminismus darstellt, die von der Listenmonade bereitgestellt wird, und tatsächlich sind Listen Instanzen. Alle Funktionen, die mit SatSolver
Werten arbeiten, geben ihre Ergebnisse in eine MonadPlus
Instanz zurück. Angenommen, Sie haben die Formel p || !q
und möchten sie lösen, indem Sie die Ergebnisse einschränken, die q
true setzen. Dann wird Folgendes verwendet (Variablen werden nummeriert, anstatt durch den Namen identifiziert zu werden):
expr = Var 1 :||: Not (Var 2)
task :: MonadPlus m => m SatSolver
task = do
pure newSatSolver
assertTrue expr
assertTrue (Var 2)
Beachten Sie, wie die Monadeninstanz und die Do-Notation alle Details auf niedriger Ebene maskieren, wie die Funktionen die SatSolver
Datenstruktur verwalten , und dass wir unsere Absicht klar ausdrücken können.
Wenn Sie nun alle Ergebnisse erhalten möchten , verwenden Sie sie einfach solve
in einem Kontext, in dem das Ergebnis eine Liste sein muss. Im Folgenden werden alle Ergebnisse auf dem Bildschirm gedruckt (vorausgesetzt, es gibt eine Show
Instanz für SatSolver
, die nicht vorhanden ist, aber verzeihen Sie mir diesen Punkt).
main = sequence . map print . solve task
Listen sind jedoch nicht die einzigen Instanzen von MonadPlus
. Maybe
ist eine andere Instanz. Wenn Sie also nur eine Lösung benötigen , egal welche, können Sie sie einfach so verwenden, solve
als ob sie einen Maybe SatSolver
Wert zurückgibt:
main = case solve task of
Nothing -> putStrLn "No solution"
Just result -> print result
Nehmen wir nun an , dass Sie haben zwei Aufgaben so gebaut, task
und task2
, und Sie wollen eine Lösung entweder eine erhalten. Wieder einmal kommt alles zusammen, damit wir unsere bereits vorhandenen Bausteine zusammensetzen können:
combinedTask = task <|> task2
Dabei <|>
handelt es sich um eine binäre Operation, die von der Alternative
Typklasse bereitgestellt wird , die eine Superklasse von ist MonadPlus
. Dies lässt uns erneut unsere Absicht klar zum Ausdruck bringen und Code ohne Änderungen wiederverwenden. Der Nichtdeterminismus wird klar im Code ausgedrückt und nicht unter den Details begraben, wie der Nichtdeterminismus tatsächlich implementiert wird. Ich schlage vor, dass Sie sich die Kombinatoren ansehen, die auf der Alternative
Typklasse aufbauen , um weitere Beispiele zu erhalten.
Die nichtdeterministischen Monaden wie Listen sind nicht nur eine Möglichkeit, nette Übungen auszudrücken, sondern bieten auch eine Möglichkeit, eleganten und wiederverwendbaren Code zu entwerfen, der die Absicht bei der Implementierung von Aufgaben, die von Natur aus nicht deterministisch sind, klar zum Ausdruck bringt.