Missverständnisse über rein funktionale Sprachen?

39

Ich stoße oft auf folgende Aussagen / Argumente:

  1. Reine funktionale Programmiersprachen lassen keine Nebenwirkungen zu (und sind daher in der Praxis wenig nützlich, da jedes nützliche Programm Nebenwirkungen hat, z. B. wenn es mit der Außenwelt interagiert).
  2. Reine funktionale Programmiersprachen erlauben es nicht, ein Programm zu schreiben, das den Status beibehält (was das Programmieren sehr umständlich macht, da in vielen Anwendungen der Status benötigt wird).

Ich bin kein Experte für funktionale Sprachen, aber hier ist, was ich bisher über diese Themen verstanden habe.

In Bezug auf Punkt 1 können Sie in rein funktionalen Sprachen mit der Umgebung interagieren, müssen jedoch den Code (die Funktionen), der Nebenwirkungen hervorruft, explizit markieren (z. B. in Haskell durch monadische Typen). Soweit ich weiß, sollte es auch möglich sein, nach Nebenwirkungen zu rechnen (Daten destruktiv zu aktualisieren) (mit monadischen Typen?), Auch wenn dies nicht die bevorzugte Arbeitsweise ist.

In Bezug auf Punkt 2 können Sie, soweit ich weiß, den Zustand darstellen, indem Sie Werte durch mehrere Rechenschritte (in Haskell wiederum unter Verwendung monadischer Typen) verteilen. Ich habe jedoch keine praktischen Erfahrungen damit, und mein Verständnis ist ziemlich vage.

Sind die beiden obigen Aussagen in irgendeiner Weise richtig oder sind sie nur Missverständnisse über rein funktionale Sprachen? Wenn es sich um Missverständnisse handelt, wie sind sie entstanden? Könnten Sie einen (möglicherweise kleinen) Codeausschnitt schreiben, der die idiomatische Methode von Haskell veranschaulicht, um (1) Nebenwirkungen und (2) eine Berechnung mit state zu implementieren?

Giorgio
quelle
7
Ich denke, das meiste hängt davon ab, was Sie als "reine" funktionale Sprache bezeichnen.
jk.
@jk: Um das Problem der Definition 'reiner' funktionaler Sprachen zu vermeiden, nehmen Sie Reinheit im Sinne von Haskell an (was genau definiert ist). Unter welchen Bedingungen eine funktionale Sprache als 'rein' betrachtet werden kann, kann das Thema einer zukünftigen Frage sein.
Giorgio
Beide Antworten enthalten viele klarstellende Ideen, und es fiel mir schwer, die zu akzeptieren. Ich habe mich entschlossen, die Antwort von sepp2k aufgrund der zusätzlichen Pseudocode-Beispiele zu akzeptieren.
Giorgio

Antworten:

26

Für die Zwecke dieser Antwort definiere ich "rein funktionale Sprache" als eine funktionale Sprache, in der Funktionen referenziell transparent sind, dh der mehrfache Aufruf derselben Funktion mit denselben Argumenten führt immer zu denselben Ergebnissen. Dies ist meines Erachtens die übliche Definition einer rein funktionalen Sprache.

Reine funktionale Programmiersprachen lassen keine Nebenwirkungen zu (und sind daher in der Praxis wenig nützlich, da jedes nützliche Programm Nebenwirkungen hat, z. B. wenn es mit der Außenwelt interagiert).

Der einfachste Weg, um referenzielle Transparenz zu erreichen, wäre in der Tat, Nebenwirkungen zu verbieten, und es gibt tatsächlich Sprachen, in denen dies der Fall ist (meistens domänenspezifische). Es ist jedoch sicherlich nicht der einzige Weg und die meisten rein funktionalen Allzwecksprachen (Haskell, Clean, ...) lassen Nebenwirkungen zu.

Zu sagen, dass eine Programmiersprache ohne Nebenwirkungen in der Praxis wenig nützlich ist, ist meiner Meinung nach nicht wirklich fair - sicherlich nicht für domänenspezifische Sprachen, aber selbst für Mehrzwecksprachen kann eine Sprache durchaus nützlich sein, ohne Nebenwirkungen zu verursachen . Vielleicht nicht für Konsolenanwendungen, aber ich denke, dass GUI-Anwendungen ohne Nebenwirkungen implementiert werden können, zum Beispiel im funktionalen reaktiven Paradigma.

In Bezug auf Punkt 1 können Sie mit der Umgebung in rein funktionalen Sprachen interagieren, aber Sie müssen den Code (Funktionen), der sie einführt, explizit markieren (z. B. in Haskell durch monadische Typen).

Das ist ein bisschen zu einfach. Nur ein System zu haben, in dem nebenwirkende Funktionen als solche gekennzeichnet werden müssen (ähnlich wie die Konstanz in C ++, aber mit allgemeinen Nebenwirkungen), reicht nicht aus, um referenzielle Transparenz zu gewährleisten. Sie müssen sicherstellen, dass ein Programm eine Funktion niemals mehrmals mit denselben Argumenten aufrufen und unterschiedliche Ergebnisse erzielen kann. Sie könnten das entweder tun, indem Sie Dinge wie machenreadLineSeien Sie etwas, das keine Funktion ist (das ist es, was Haskell mit der IO-Monade macht), oder Sie könnten es unmöglich machen, nebenwirkende Funktionen mehrmals mit demselben Argument aufzurufen (das ist es, was Clean tut). Im letzteren Fall würde der Compiler sicherstellen, dass Sie jedes Mal, wenn Sie eine Nebenwirkungsfunktion aufrufen, ein neues Argument eingeben und jedes Programm ablehnen, bei dem Sie dasselbe Argument zweimal an eine Nebenwirkungsfunktion übergeben.

Reine funktionale Programmiersprachen erlauben es nicht, ein Programm zu schreiben, das den Status beibehält (was das Programmieren sehr umständlich macht, da in vielen Anwendungen der Status benötigt wird).

Auch hier könnte eine rein funktionale Sprache einen veränderlichen Zustand sehr wohl verbieten, aber es ist sicherlich möglich, rein zu sein und noch einen veränderlichen Zustand zu haben, wenn Sie sie auf die gleiche Weise implementieren, wie ich oben mit Nebenwirkungen beschrieben habe. Wirklich veränderlicher Zustand ist nur eine andere Form von Nebenwirkungen.

Das heißt, funktionale Programmiersprachen entmutigen definitiv den veränderlichen Zustand - vor allem die reinen. Und ich denke nicht, dass das Programmieren umständlich ist - ganz im Gegenteil. Manchmal (aber nicht allzu oft) kann ein veränderlicher Zustand nicht vermieden werden, ohne an Leistung oder Klarheit zu verlieren (weshalb Sprachen wie Haskell über Funktionen für einen veränderlichen Zustand verfügen), aber meistens ist dies möglich.

Wenn es sich um Missverständnisse handelt, wie sind sie entstanden?

Ich denke, viele Leute lesen einfach "eine Funktion muss dasselbe Ergebnis liefern, wenn sie mit denselben Argumenten aufgerufen wird" und schließen daraus, dass es nicht möglich ist, so etwas readLineoder Code zu implementieren , der einen veränderlichen Zustand beibehält. Sie sind sich also einfach nicht der "Cheats" bewusst, mit denen rein funktionale Sprachen diese Dinge einführen können, ohne die referentielle Transparenz zu brechen.

Auch ein veränderlicher Zustand ist in funktionalen Sprachen stark entmutigend, so dass es kein großer Sprung ist anzunehmen, dass er in rein funktionalen überhaupt nicht zulässig ist.

Könnten Sie einen (möglicherweise kleinen) Codeausschnitt schreiben, der die idiomatische Methode von Haskell veranschaulicht, um (1) Nebenwirkungen und (2) eine Berechnung mit state zu implementieren?

Hier ist eine Anwendung in Pseudo-Haskell, die den Benutzer nach einem Namen fragt und ihn begrüßt. Pseudo-Haskell ist eine Sprache, die ich gerade erfunden habe und die Haskells IO-System hat, aber konventionellere Syntax, beschreibendere Funktionsnamen und keine do-Notation verwendet (da dies nur davon ablenken würde, wie genau die IO-Monade funktioniert):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

Der Hinweis hier ist, dass readLinees sich um einen Wert vom Typ IO<String>und composeMonadeine Funktion handelt, die ein Argument vom Typ IO<T>(für einen bestimmten Typ T) und ein anderes Argument, das eine Funktion ist, die ein Argument vom Typ Tannimmt und einen Wert vom Typ IO<U>(für einen bestimmten Typ U) zurückgibt . printist eine Funktion, die eine Zeichenfolge akzeptiert und einen Wert vom Typ zurückgibt IO<void>.

Ein Wert vom Typ IO<A>ist ein Wert, der eine bestimmte Aktion "codiert", die einen Wert vom Typ erzeugt A. composeMonad(m, f)Erzeugt einen neuen IOWert, der die Aktion von mgefolgt von der Aktion von codiert. f(x)Dabei xhandelt es sich um den Wert, der durch Ausführen der Aktion von erzeugt wird m.

Der veränderbare Zustand würde folgendermaßen aussehen:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Hier mutableVariableist eine Funktion, die einen beliebigen Wert annimmt Tund a erzeugt MutableVariable<T>. Die Funktion getValuenimmt MutableVariableund gibt eine zurück IO<T>, die ihren aktuellen Wert erzeugt. setValueNimmt ein MutableVariable<T>und ein Tund gibt ein zurück IO<void>, das den Wert festlegt. composeVoidMonadist dasselbe wie mit der composeMonadAusnahme, dass das erste Argument ein Argument ist IO, das keinen sinnvollen Wert erzeugt, und das zweite Argument eine andere Monade ist, keine Funktion, die eine Monade zurückgibt.

In Haskell gibt es etwas syntaktischen Zucker, der diese ganze Tortur weniger schmerzhaft macht, aber es ist immer noch offensichtlich, dass der veränderbare Zustand etwas ist, das die Sprache nicht wirklich von Ihnen verlangt.

sepp2k
quelle
Tolle Antwort, viele Ideen zu klären. Sollte die letzte Zeile des Code-Snippets den Namen verwenden counter, dh increaseCounter(counter)?
Giorgio
@ Giorgio Ja, das sollte es. Fest.
19.
1
@Giorgio Eine Sache, die ich in meinem Beitrag vergessen habe, ist, dass die von zurückgegebene E / A-Aktion maindiejenige ist, die tatsächlich ausgeführt wird. Anders als die Rückgabe einer E / A von maindort gibt es keine Möglichkeit, IOAktionen auszuführen (ohne schrecklich böse Funktionen zu verwenden, die unsafein ihrem Namen enthalten sind).
sepp2k
OKAY. scarfridge erwähnte auch zerstörerische IOWerte. Ich habe nicht verstanden, ob er sich auf Pattern Matching bezieht, dh auf die Tatsache, dass man Werte eines algebraischen Datentyps dekonstruieren kann, aber man kann Pattern Matching nicht verwenden, um dies mit IOWerten zu tun .
Giorgio
16

IMHO sind Sie verwirrt, weil es einen Unterschied zwischen einer reinen Sprache und einer reinen Funktion gibt . Beginnen wir mit der Funktion. Eine Funktion ist rein, wenn sie (bei gleicher Eingabe) immer den gleichen Wert zurückgibt und keine beobachtbaren Nebenwirkungen verursacht. Typische Beispiele sind mathematische Funktionen wie f (x) = x * x. Betrachten Sie nun eine Implementierung dieser Funktion. Es wäre in den meisten Sprachen rein, auch in solchen, die im Allgemeinen nicht als reine Funktionssprachen gelten, z. B. ML. Sogar eine Java- oder C ++ - Methode mit diesem Verhalten kann als rein betrachtet werden.

Was ist also eine reine Sprache? Streng genommen könnte man erwarten, dass Sie mit einer reinen Sprache keine Funktionen ausdrücken können, die nicht rein sind. Nennen wir dies die idealistische Definition einer reinen Sprache. Ein solches Verhalten ist sehr wünschenswert. Warum? Das Schöne an einem Programm, das nur aus reinen Funktionen besteht, ist, dass Sie die Funktionsanwendung durch ihren Wert ersetzen können, ohne die Bedeutung des Programms zu ändern. Das macht es sehr einfach, über Programme nachzudenken, denn sobald Sie das Ergebnis kennen, können Sie vergessen, wie es berechnet wurde. Die Reinheit kann es dem Compiler auch ermöglichen, bestimmte aggressive Optimierungen durchzuführen.

Was ist, wenn Sie einen internen Zustand benötigen? Sie können den Zustand in einer reinen Sprache simulieren, indem Sie den Zustand vor der Berechnung als Eingabeparameter und den Zustand nach der Berechnung als Teil des Ergebnisses hinzufügen. Stattdessen Int -> Boolbekommst du sowas Int -> State -> (Bool, State). Sie machen die Abhängigkeit einfach explizit (was in jedem Programmierparadigma als gute Praxis angesehen wird). Übrigens gibt es eine Monade, mit der sich solche Zustandsnachahmungsfunktionen besonders elegant zu größeren Zustandsnachahmungsfunktionen kombinieren lassen. Auf diese Weise können Sie definitiv den Status in einer reinen Sprache "aufrechterhalten". Aber Sie müssen es explizit machen.

Heißt das, ich kann mit der Außenwelt interagieren? Schließlich muss ein nützliches Programm mit der realen Welt interagieren, um nützlich zu sein. Aber Input und Output sind offensichtlich nicht rein. Das Schreiben eines bestimmten Bytes in eine bestimmte Datei ist möglicherweise zum ersten Mal in Ordnung. Wenn Sie dieselbe Operation jedoch ein zweites Mal ausführen, wird möglicherweise ein Fehler zurückgegeben, da der Datenträger voll ist. Offensichtlich gibt es keine reine Sprache (im idealistischen Sinne), die in eine Datei schreiben kann.

Wir stehen also vor einem Dilemma. Wir wollen hauptsächlich reine Funktionen, aber einige Nebenwirkungen sind unbedingt erforderlich und diese sind nicht rein. Eine realistische Definition einer reinen Sprache wäre nun, dass es Mittel geben muss, um die reinen Teile von den anderen Teilen zu trennen. Der Mechanismus muss sicherstellen, dass keine unreine Operation in die Reinteile eindringt.

In Haskell erfolgt dies mit dem IO-Typ. Sie können ein E / A-Ergebnis nicht zerstören (ohne unsichere Mechanismen). Sie können E / A-Ergebnisse daher nur mit Funktionen verarbeiten, die im E / A-Modul selbst definiert sind. Glücklicherweise gibt es sehr flexible Kombinatoren, mit denen Sie ein E / A-Ergebnis nehmen und in einer Funktion verarbeiten können, solange diese Funktion ein anderes E / A-Ergebnis zurückgibt. Dieser Kombinator heißt bind (oder >>=) und hat den Typ IO a -> (a -> IO b) -> IO b. Wenn Sie dieses Konzept verallgemeinern, gelangen Sie zur Monadenklasse, und IO ist zufällig eine Instanz davon.

Narbenkühlschrank
quelle
4
Ich verstehe nicht wirklich, wie Haskell (der eine Funktion mit unsafeihrem Namen ignoriert ) Ihrer idealistischen Definition nicht entspricht. Es gibt keine unreinen Funktionen in Haskell (wieder ignorieren unsafePerformIOund co.).
sepp2k
4
readFileund writeFilegibt IObei gleichen Argumenten immer den gleichen Wert zurück. Also zB die beiden Codefragmente let x = writeFile "foo.txt" "bar" in x >> xund writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"machen das selbe.
sepp2k
3
@AidanCully Was meinst du mit "IO-Funktion"? Eine Funktion, die einen Wert vom Typ zurückgibt IO Something? In diesem Fall ist es durchaus möglich, eine E / A-Funktion zweimal mit demselben Argument aufzurufen: putStrLn "hello" >> putStrLn "hello"- Hier müssen beide Aufrufe putStrLndasselbe Argument haben. Das ist natürlich kein Problem, denn wie ich bereits sagte, führen beide Aufrufe zu demselben E / A-Wert.
sepp2k
3
@scarfridge Auswerten writeFile "foo.txt" "bar"kann keinen Fehler verursachen, da das Auswerten des Funktionsaufrufs die Aktion nicht ausführt. Wenn Sie in meinem vorherigen Beispiel sagen, dass die Version mit letnur eine Möglichkeit hat, einen E / A-Fehler zu verursachen, während die Version ohne letzwei Möglichkeiten hat, liegen Sie falsch. Beide Versionen bieten zwei Möglichkeiten für einen E / A-Fehler. Da die letVersion den Aufruf writeFilenur einmal letauswertet, während die Version ohne zweimal auswertet, können Sie sehen, dass es egal ist, wie oft die Funktion aufgerufen wird. Es ist nur wichtig, wie oft die resultierende ...
sepp2k
6
@AidanCully Der "Monadenmechanismus" übergibt keine impliziten Parameter. Die putStrLnFunktion akzeptiert genau ein Argument vom Typ String. Wenn Sie mir nicht glauben, schauen Sie sich seine Art: String -> IO (). Es werden sicherlich keine Argumente vom Typ verwendet IO- es wird ein Wert dieses Typs erzeugt.
sepp2k