Ist Return-Type- (only) -Polymorphismus in Haskell eine gute Sache?


28

Eine Sache, mit der ich mich in Haskell noch nie so recht auseinandergesetzt habe, ist, wie Sie polymorphe Konstanten und Funktionen haben können, deren Rückgabetyp nicht durch ihren Eingabetyp bestimmt werden kann, wie z

class Foo a where
    foo::Int -> a

Einige der Gründe, die mir nicht gefallen:

Referentielle Transparenz:

"In Haskell gibt eine Funktion bei gleicher Eingabe immer die gleiche Ausgabe zurück", aber stimmt das wirklich? read "3"Bei Verwendung in einem IntKontext wird 3 zurückgegeben, bei Verwendung beispielsweise in einem Kontext wird jedoch ein Fehler (Int,Int)ausgegeben. Ja, Sie können argumentieren, dass readauch ein Typparameter verwendet wird, aber die implizite Verwendung des Typparameters lässt ihn meiner Meinung nach etwas an Schönheit verlieren.

Monomorphismus-Einschränkung:

Eines der nervigsten Dinge an Haskell. Korrigieren Sie mich, wenn ich falsch liege, aber der ganze Grund für den MR ist, dass die Berechnung, die geteilt aussieht, möglicherweise nicht darauf zurückzuführen ist, dass der Typparameter implizit ist.

Typ standardmäßig:

Wieder eines der nervigsten Dinge an Haskell. Passiert zB, wenn Sie das Ergebnis von Funktionen, die in ihrer Ausgabe polymorph sind, an Funktionen übergeben, die in ihrer Eingabe polymorph sind. Korrigieren Sie mich erneut, wenn ich falsch liege, aber dies wäre ohne Funktionen, deren Rückgabetyp nicht durch den Eingabetyp (und die polymorphen Konstanten) bestimmt werden kann, nicht erforderlich.

Meine Frage lautet also (mit der Gefahr, als "Diskussionsfrage" abgestempelt zu werden): Wäre es möglich, eine Haskell-ähnliche Sprache zu erstellen, in der die Typprüfung diese Art von Definitionen nicht zulässt ? Wenn ja, welche Vor- / Nachteile hätte diese Beschränkung?

Ich kann einige unmittelbare Probleme sehen:

Wenn Sie zum Beispiel 2nur den Typ hätten Integer, 2/3würden Sie mit der aktuellen Definition von nicht mehr den Typ überprüfen /. Aber in diesem Fall denke ich, dass Typklassen mit funktionalen Abhängigkeiten Abhilfe schaffen könnten (ja, ich weiß, dass dies eine Erweiterung ist). Außerdem finde ich es viel intuitiver, Funktionen zu haben, die unterschiedliche Eingabetypen annehmen können, als Funktionen, deren Eingabetypen eingeschränkt sind, aber wir übergeben ihnen nur polymorphe Werte.

Das Tippen von Werten gefällt []und Nothingscheint mir eine härtere Nuss zu knacken. Ich habe mir keine gute Möglichkeit überlegt, damit umzugehen.

Antworten:


37

Ich denke tatsächlich, dass Rückgabetyp-Polymorphismus eines der besten Merkmale von Typklassen ist. Nachdem ich es eine Weile benutzt habe, fällt es mir manchmal schwer, zum OOP-Modell zurückzukehren, wo ich es nicht habe.

Betrachten Sie die Kodierung der Algebra. In Haskell haben wir eine Typenklasse Monoid(ignorieren mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Wie können wir dies als Schnittstelle in einer OO-Sprache codieren? Die kurze Antwort lautet: Wir können nicht. Das ist , weil die Art der memptyist (Monoid a) => aauch bekannt als Rückgabetyp Polymorphismus. Die Fähigkeit, Algebra zu modellieren, ist unglaublich nützlich, IMO. *

Sie beginnen Ihren Beitrag mit der Beschwerde über "Referentielle Transparenz". Dies wirft einen wichtigen Punkt auf: Haskell ist eine wertorientierte Sprache. Ausdrücke wie read 3müssen also nicht als Dinge verstanden werden, die Werte berechnen, sondern können auch als Werte verstanden werden. Dies bedeutet, dass das eigentliche Problem nicht der Rückgabetyp-Polymorphismus ist: Es handelt sich um Werte mit polymorphem Typ ( []und Nothing). Wenn die Sprache diese haben soll, muss sie aus Konsistenzgründen wirklich polymorphe Rückgabetypen haben.

Sollte man sagen können, dass []es sich um einen Typ handelt forall a. [a]? Ich glaube schon. Diese Funktionen sind sehr nützlich und vereinfachen die Sprache erheblich.

Wenn Haskell einen Subtyp []hätte, könnte Polymorphismus ein Subtyp für alle sein [a]. Das Problem ist, dass ich keine Möglichkeit kenne, diese zu codieren, ohne dass der Typ der leeren Liste polymorph ist. Überlegen Sie, wie es in Scala gemacht wird (es ist kürzer als in der kanonischen statisch getippten OOP-Sprache Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Auch hier Nil()ist ein Objekt vom Typ Nil[A]**

Ein weiterer Vorteil des Rückkehrtyp-Polymorphismus besteht darin, dass das Einbetten von Curry-Howard viel einfacher wird.

Betrachten Sie die folgenden logischen Sätze:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Wir können diese Sätze in Haskell trivial erfassen:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Fazit: Ich mag den Rückgabetyp-Polymorphismus und denke nur, dass er die referenzielle Transparenz bricht, wenn Sie eine begrenzte Vorstellung von Werten haben (obwohl dies im Fall von Ad-hoc-Typklassen weniger überzeugend ist). Auf der anderen Seite finde ich Ihre Argumente zu MR und tippe zwingend.


*. In den Kommentaren weist ysdx darauf hin, dass dies nicht strikt zutrifft: Wir könnten Typklassen erneut implementieren, indem wir die Algebra als einen anderen Typ modellieren. Wie die Java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Sie müssen dann Objekte dieses Typs mit sich herumreichen. Scala kennt implizite Parameter, mit denen einige, aber meiner Erfahrung nach nicht alle der Mehraufwand für die explizite Verwaltung dieser Dinge vermieden werden. Das Einfügen Ihrer Dienstprogrammmethoden (Factory-Methoden, Binärmethoden usw.) in einen separaten F-Typ erweist sich als äußerst nützliche Methode zum Verwalten von Dingen in einer OO-Sprache, die Generika unterstützt. Trotzdem bin ich mir nicht sicher, ob ich dieses Muster hätte anwenden können, wenn ich keine Erfahrung darin gehabt hätte, Dinge mit Typenklassen zu modellieren, und ich bin mir nicht sicher, ob andere das auch tun.

Es gibt auch Einschränkungen, da es nicht möglich ist, ein Objekt zu erhalten, das die Typenklasse für einen beliebigen Typ implementiert. Sie müssen entweder die Werte explizit übergeben, so etwas wie die Implikate von Scala verwenden oder eine Form der Abhängigkeitsinjektionstechnologie verwenden. Das Leben wird hässlich. Auf der anderen Seite ist es schön, dass Sie mehrere Implementierungen für den gleichen Typ haben können. Etwas kann in mehrfacher Hinsicht ein Monoid sein. Wenn man diese Strukturen separat herumträgt, fühlt sich IMO mathematisch moderner und konstruktiver an. Also, obwohl ich immer noch die Haskell-Methode bevorzuge, habe ich meinen Fall wahrscheinlich übertrieben.

Typenklassen mit Rückkehrtyp-Polymorphismus machen diese Art von Dingen einfach zu handhaben. Das heißt nicht, es ist der beste Weg, es zu tun.

**. Jörg W Mittag weist darauf hin, dass dies in der Scala nicht wirklich kanonisch ist. Stattdessen würden wir der Standardbibliothek etwas ähnlicheres folgen:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Dies nutzt die Unterstützung von Scala für Bottom-Typen sowie für kovariante Typparameter. Also Nilist vom Typ Nilnicht Nil[A]. Zu diesem Zeitpunkt sind wir ziemlich weit von Haskell entfernt, aber es ist interessant festzustellen, wie Haskell den unteren Typ darstellt

undefined :: forall a. a

Das heißt, es ist nicht der Subtyp aller Typen, sondern polymorph (sp) ein Mitglied aller Typen.
Noch mehr Rückkehrtyp-Polymorphismus.


4
"Wie können wir dies als Schnittstelle in einer OO-Sprache kodieren?" Sie könnten erstklassige Algebra verwenden: interface Monoid <X> {X empty (); X anhängen (X, X); } Sie müssen es jedoch explizit übergeben (dies geschieht hinter den Kulissen in Haskell / GHC).
ysdx

@ysdx Das ist wahr. Und in Sprachen, die implizite Parameter unterstützen, kommt man den Typklassen von haskell sehr nahe (wie in Scala). Ich war mir dessen bewusst. Mein Punkt war jedoch, dass dies das Leben ziemlich schwierig macht: Ich muss Behälter verwenden, in denen die "Typenklasse" überall aufbewahrt wird (igitt!). Trotzdem hätte ich in meiner Antwort wahrscheinlich weniger hyperbolisch sein sollen.
Philip JF

+1, tolle Antwort. Ein irrelevanter Trottel: Nilsollte wohl a sein case object, nicht a case class.
Jörg W Mittag

@ Jörg W Mittag Es ist nicht völlig irrelevant. Ich habe bearbeitet, um Ihren Kommentar zu adressieren.
Philip JF

1
Vielen Dank für eine sehr nette Antwort. Es wird wahrscheinlich ein bisschen dauern, bis ich es verstanden habe.
Dainichi

12

Nur um ein Missverständnis festzustellen:

"In Haskell gibt eine Funktion bei gleicher Eingabe immer die gleiche Ausgabe zurück", aber stimmt das wirklich? read "3" gibt 3 zurück, wenn es in einem Int-Kontext verwendet wird, löst aber einen Fehler aus, wenn es in einem (Int, Int) -Kontext verwendet wird.

Nur weil meine Frau einen Subaru fährt und ich einen Subaru fahre, heißt das nicht, dass wir dasselbe Auto fahren. Nur weil es 2 benannte Funktionen gibt, heißt readdas nicht, dass es die gleiche Funktion ist. In der Tat read :: String -> Intwird in der Leseinstanz von Int read :: String (Int, Int)definiert, wobei in der Leseinstanz von (Int, Int) definiert wird. Sie sind also völlig unterschiedliche Funktionen.

Dieses Phänomen ist in Programmiersprachen weit verbreitet und wird üblicherweise als Überlastung bezeichnet .


6
Ich denke, Sie haben den Punkt der Frage irgendwie verpasst. In den meisten Sprachen mit Ad-hoc-Polymorphismus (Überladung) hängt die Auswahl der aufzurufenden Funktion nur von den Parametertypen ab, nicht vom Rückgabetyp. Dies erleichtert das Nachdenken über die Bedeutung von Funktionsaufrufen: Beginnen Sie einfach an den Blättern des Ausdrucksbaums und arbeiten Sie sich "nach oben" vor. In Haskell (und der kleinen Anzahl anderer Sprachen, die eine Überladung mit Rückgabetypen unterstützen) müssen Sie möglicherweise den gesamten Ausdrucksbaum auf einmal betrachten, um die Bedeutung eines winzigen Teilausdrucks herauszufinden.
Laurence Gonsalves

1
Ich denke, Sie haben den Kern der Frage perfekt getroffen. Sogar Shakespeare sagte: "Eine Funktion mit einem anderen Namen würde genauso gut funktionieren." +1
Thomas Eding

@ Laurence Gonsalves - Typinferenz in Haskell ist nicht referenziell transparent. Die Bedeutung von Code kann vom Kontext abhängen, in dem er verwendet wird, da durch Typinferenz Informationen nach innen gezogen werden. Dies ist nicht auf Probleme mit Rückgaben beschränkt. Haskell hat effektiv Prolog in sein Typensystem eingebaut. Dies kann den Code weniger klar machen, hat aber auch große Vorteile. Persönlich denke ich, dass die Art der referenziellen Transparenz am wichtigsten ist, was zur Laufzeit geschieht, da Faulheit ohne sie nicht zu bewältigen wäre.
Steve314

@ Steve314 Ich glaube , ich habe noch eine Situation , um zu sehen , wo der Mangel an referentiell transparent Typinferenz nicht funktioniert der Code weniger deutlich machen. Um über irgendetwas von Komplexität nachzudenken, muss man in der Lage sein, Dinge mental zu "zerlegen". Wenn Sie mir sagen, dass Sie eine Katze haben, denke ich nicht an eine Wolke von Milliarden von Atomen oder einzelnen Zellen. Ebenso partitioniere ich beim Lesen von Code Ausdrücke in ihre Unterausdrücke. Haskell beseitigt dies auf zwei Arten: Inferenz vom Typ "Falsch" und übermäßig komplexe Überladung von Operatoren. Die Haskell-Gemeinde hat auch eine Abneigung gegen Parens, was letztere noch schlimmer macht.
Laurence Gonsalves

1
@LaurenceGonsalves Sie haben Recht, dass die infixFunktion missbraucht werden kann. Dies ist dann aber ein Versagen der Benutzer. OTOH, Restriktivität, wie in Java, ist meiner Meinung nach nicht der richtige Weg. Um dies zu sehen, brauchen Sie nur nach Code zu suchen, der sich mit BigIntegers befasst.
Ingo

7

Ich wünschte wirklich, der Begriff "Rückgabetyp-Polymorphismus" wäre nie entstanden. Es fördert ein Missverständnis dessen, was passiert. Es genügt zu sagen, dass die Beseitigung des "Rückkehrtyp-Polymorphismus" zwar eine äußerst spontane und ausdrucksstarke Änderung darstellt, die in der Frage angesprochenen Probleme jedoch nicht einmal aus der Ferne gelöst werden könnten.

Der Rückgabetyp ist in keiner Weise speziell. Erwägen:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Dieser Code verursacht dieselben Probleme wie "Rückgabetyp-Polymorphismus" und zeigt dieselben Unterschiede zum OOP. Niemand spricht jedoch von "erstem Argument, vielleicht Typ-Polymorphismus".

Die Schlüsselidee ist, dass die Implementierung Typen verwendet, um die zu verwendende Instanz aufzulösen. Die (Laufzeit-) Werte jeder Art haben nichts damit zu tun. In der Tat würde es sogar für Typen funktionieren, die keine Werte haben. Insbesondere haben Haskell-Programme ohne ihre Typen keine Bedeutung. (Ironischerweise macht dies Haskell zu einer Sprache im kirchlichen Stil im Gegensatz zu einer Sprache im Curry-Stil. Ich habe einen Blog-Artikel in den Arbeiten, die dies ausarbeiten.)


'Dieser Code verursacht dieselben Probleme wie "Rückgabetyp-Polymorphismus"'. Nein, tut es nicht. Ich kann mir "foo Nothing" anschauen und feststellen, was für ein Typ es ist. Es ist ein Bool. Ein Blick in den Kontext ist nicht erforderlich.
Dainichi

4
Tatsächlich überprüft der Code nicht den Typ, da der Compiler nicht weiß, awie es im Fall "Rückgabetyp" ist. Auch hier gibt es nichts Besonderes über Rückgabetypen. Wir müssen den Typ aller Unterausdrücke kennen. Überlegen Sie let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins

2
Ganz zu schweigen vom "zweiten Argument Polymorphismus"; Eine Funktion wie Int -> a -> Boolist, durch Curry, tatsächlich Int -> (a -> Bool)und los geht's, Polymorphismus im Rückgabewert wieder. Wenn Sie es überall zulassen, muss es überall sein.
Ryan Reich

4

In Bezug auf Ihre Frage zur referentiellen Transparenz von polymorphen Werten ist Folgendes hilfreich.

Betrachten Sie den Namen 1 . Es kennzeichnet oft verschiedene (aber feste) Objekte:

  • 1 wie in einer Ganzzahl
  • 1 wie in einem Real
  • 1 wie in einer quadratischen Identitätsmatrix
  • 1 wie in einer Identitätsfunktion

Hier ist es wichtig , dass unter jedem beachten Kontext , 1ist ein fester Wert. Mit anderen Worten, jedes Name-Kontext-Paar bezeichnet ein eindeutiges Objekt. Ohne den Kontext können wir nicht wissen, welches Objekt wir bezeichnen. Daher muss der Kontext (falls möglich) abgeleitet oder explizit angegeben werden. Ein netter Vorteil (abgesehen von der praktischen Notation) ist die Fähigkeit, Code in generischen Kontexten auszudrücken.

Da dies jedoch nur eine Notation ist, hätten wir stattdessen die folgende Notation verwenden können:

  • integer1 wie in einer Ganzzahl
  • real1 wie in einem Real
  • matrixIdentity1 wie in einer quadratischen Identitätsmatrix
  • functionIdentity1 wie in einer Identitätsfunktion

Hier erhalten wir Namen, die explizit sind. Dies ermöglicht es uns, den Kontext nur aus dem Namen abzuleiten. Somit wird nur der Name des Objekts benötigt, um ein Objekt vollständig zu bezeichnen.

Haskell-Typenklassen wählten das frühere Notationsschema. Hier ist das Licht am Ende des Tunnels:

readist ein Name, kein Wert (es hat keinen Kontext), aber read :: String -> aist ein Wert (es hat sowohl einen Namen als auch einen Kontext). Somit sind die Funktionen read :: String -> Intund read :: String -> (Int, Int)buchstäblich verschiedene Funktionen. Wenn sie sich also in ihren Eingaben nicht einig sind, wird die referenzielle Transparenz nicht verletzt.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.