Warum mehr Abstraktionen (und Muscheln) gut für Java wären
Wie vielleicht einige (in diesem Thread) mitbekommen haben, arbeite ich mit haarsträubenden Tricks daran, Java Typen höherer Ordnung und damit haskell-artige Typklassen beizubringen. Es bleibt die Frage: Warum???
Deshalb will ich hier nicht auf die technischen Details eingehen, sondern ein einfaches Beispiel in beiden Sprachen sezieren, und begründen, warum bestimmte Abstraktionen in Haskell (und Scala) auch für Java sinnvoll wären. Zuerst die (bewußt einfach gewählte) Aufgabe: Eine Funktion soll einen Buchstaben und eine Liste von Strings übergeben bekommen, und die jeweiligen Stellen aufsummieren, wo der Buchstabe im String das erste mal vorkommt. Ist ein String dabei, der den Buchstaben gar nicht enthält, soll ein "Fehlerwert" zurückgegeben werden. Ein paar Beispiele, wie die Funktion arbeiten soll:
Zuerst die Java-Implementierung:
Das ist Code, wie wir ihn schon tausendmal geschrieben haben, und ich denke, in Java kann man ihn nicht viel besser schreiben. Innerhalb der Methode werden drei Variablen definiert (sum, s und index). Die ganze Methode wird von einer Schleife dominiert (für die wir netterweise die erweiterte for-Syntax verwenden können). Wir haben eine Zählvariable sum, die dauernd verändert wird (was in Haskell als "purer" Sprache nicht möglich ist). Im "Fehlerfall" springen wir sofort aus der Methode. Das wird häufig kritisiert, und auch ich vermeide es wenn möglich, aber hier scheint es wirklich die eleganteste Lösung zu sein. Die einzige Sache, die wir von der Java-API wiederverwenden, ist die sehr spezielle Methode
Kaum zu glauben, dass eine idiomatische Haskell-Lösung ganz selbstverständlich mit Typklassen (darunter die "berüchtigten" Monaden) hantiert:
Zuerst kommen drei Importe:
Als nächstes kommt der Aufruf von
Zum Summieren einer Liste gibt es schon vorgefertigt eine
So, und jetzt stelle ich mich allen Ernstes hin und sage, diese ganzen Abstraktionen wären irgendwie hilfreich? Nein, ich will jetzt nicht darauf herumreiten, dass die Haskell-Lösung etwas kürzer ist - dann hätte ich die Variante
Als erstes möchte ich feststellen, dass die Typ-Signatur von
Die zweite Beobachtung ist, dass wir vier Funktionen der Haskell-API verwendet haben statt der einen aus der Java-API im Java-Beispiel. Woran liegt das? Ist es nicht ein Widerspruch, dass die sehr allgemeinen Haskell-Funktionen in unserem Beispiel so gut "gepasst" haben? Sind sie nicht zu "generell" dazu? Die Antwort ist, dass sie sich besser kombinieren lassen als in Java:
Es ist auffällig, wie systematisch wir das Problem von rechts nach links immer weiter transformiert haben: Den Index finden, das Ganze auf eine Liste anwenden, der Muschel-Umstülp-Trick, und schließlich die Anwendung von
In Java hatten wir mit 4 Typen zu tun: char, int, String und List. In Haskell waren es erstaunlicherweise auch nur 4: Char, Int, Maybe und List. Nein, ich habe mich nicht verzählt, denn String ist nur ein Synonym für
Java ist nicht Haskell, und viele Konzepte würden einfach in der jeweils anderen Sprache nicht passen. Trotzdem denke ich, dass sich der Blick über den Tellerrand lohnt. Beide Sprachen besitzen Typparameter ("Generics"), aber die Haskell-Version ist deutlich flexibler: Während ich in Java eine Liste von Äpfeln und eine Liste von Autos zu einem Typ "Liste" abstrahieren kann, geht Haskell weiter, und kann eine Liste von Äpfeln und ein Set von Äpfeln zu einem "Irgendwas" von Äpfeln abstrahieren - und so seltsam es klingt, erst diese anscheinend sinnlose Abstraktion ermöglicht das Konzept der Typklassen. Was man damit interessantes anstellen kann, habe ich hier nur ganz, ganz oberflächlich angekratzt. Und deshalb lohnt es sich meiner Meinung nach herumzuexperimentieren, um vielleicht doch ein wenig dieser Power nach Java importieren zu können.
Nachtrag:
Man kann alt sein wie 'ne Kuh, und lernt immer noch dazu! Es gibt tatsächlich noch eine elegantere Haskell-Lösung:
Wie man sieht, erledigt
Deshalb will ich hier nicht auf die technischen Details eingehen, sondern ein einfaches Beispiel in beiden Sprachen sezieren, und begründen, warum bestimmte Abstraktionen in Haskell (und Scala) auch für Java sinnvoll wären. Zuerst die (bewußt einfach gewählte) Aufgabe: Eine Funktion soll einen Buchstaben und eine Liste von Strings übergeben bekommen, und die jeweiligen Stellen aufsummieren, wo der Buchstabe im String das erste mal vorkommt. Ist ein String dabei, der den Buchstaben gar nicht enthält, soll ein "Fehlerwert" zurückgegeben werden. Ein paar Beispiele, wie die Funktion arbeiten soll:
Code:
//Pseudocode
sumOfIndexes('e', List()) -> 0
sumOfIndexes('e', List("one","three")) -> 2+3=5
sumOfIndexes('e', List("one","two","three")) -> -1 //Fehlerfall wegen "two"
1 2 3 4 5 6 7 8 9 10 11 12 public static int sumOfIndexes(char ch, List<String> strings) { int sum = 0; for(String s : strings) { int index = s.indexOf(ch); if (index == -1) { return -1; } else { sum += index; } } return sum; }
Das ist Code, wie wir ihn schon tausendmal geschrieben haben, und ich denke, in Java kann man ihn nicht viel besser schreiben. Innerhalb der Methode werden drei Variablen definiert (sum, s und index). Die ganze Methode wird von einer Schleife dominiert (für die wir netterweise die erweiterte for-Syntax verwenden können). Wir haben eine Zählvariable sum, die dauernd verändert wird (was in Haskell als "purer" Sprache nicht möglich ist). Im "Fehlerfall" springen wir sofort aus der Methode. Das wird häufig kritisiert, und auch ich vermeide es wenn möglich, aber hier scheint es wirklich die eleganteste Lösung zu sein. Die einzige Sache, die wir von der Java-API wiederverwenden, ist die sehr spezielle Methode
indexOf , die nur auf Strings definiert ist.Kaum zu glauben, dass eine idiomatische Haskell-Lösung ganz selbstverständlich mit Typklassen (darunter die "berüchtigten" Monaden) hantiert:
Code:
import Data.List import Control.Monad import Control.Applicative sumOfIndexes :: Char -> [String] -> Maybe Int sumOfIndexes ch strings = sum <$> (sequence $ map (findIndex (ch==)) strings)
Data.List brauchen wir wegen findIndex , Control.Monad wegen sequence und Control.Applicative wegen des seltsamen Operators <$> . Die eigentliche Funktion steht in der letzten Zeile, das darüber ist nur die (optionale) Typ-Signatur. Wie üblich, fangen wir hinten an, das Kauderwelsch zu entziffern. findIndex dient einem ähnlichen Zweck wie indexOf in Java, ist aber gleich in doppelter Hinsicht "genereller": Zum ersten arbeitet es nicht nur auf Strings, sondern auf Listen (praktischerweise sind in Haskell Strings Listen von Chars), und zum zweiten vergleicht es nicht einen Wert, sondern benutzt eine Vergleichsfunktion. In unserem Fall ist das (ch==) , was nichts anderes bedeutet als "muss gleich ch sein", wobei ch natürlich der übergebene Char-Parameter ist. Bemerkenswert ist auch der Rückgabetyp Maybe von findIndex : Findet sich ein passendes Element, wird nicht einfach der Index index, sondern Just <index> zurückgegeben, und im Fehlerfall Nothing - im Prinzip eine Art typsicheres null . Die map -Funktion wendet eine andere Funktion auf jedes Element einer Liste an und gibt die Ergebnisliste zurück. Der Aufruf map (findIndex ('e'==)) ["one","two","three"] würde also die Liste [Just 2, Nothing, Just 3] zurückliefern.Als nächstes kommt der Aufruf von
sequence (der $ dient nur zur Einsparung von Klammern - sind auch so schon genug). Die Funktion sequence hat einen seltsamen Typ: Sie nimmt eine Liste von Monaden eines Typs und liefert eine Monade einer Liste dieses Typs zurück. Häh? Zuerst einmal ist der seltsame Typ Maybe tatsächlich eine Monade. Stellen wir ihn uns wie eine Muschel vor, in der entweder eine Perle ist (dann ist es Just ... ) oder nicht (Nothing ). Eine List können wir uns als Kette vorstellen. Also macht sequence aus einer Kette von Muscheln (mit oder ohne Perlen) eine große Muschel vom Typ "Perlenkette". Aber Vorsicht! Unsere Maybe -Muscheln können etwas enthalten, müssen aber nicht, und die Regel ist grausam: Ein einziges Nothing reicht aus, und auch die große Muschel bleibt leer. Eigentlich logisch: Nothing steht für eine Art Scheitern, ein bisschen so wie eine Exception. Die sequence der Liste [Just 2, Nothing, Just 3] vom letzten mal wäre also einfach Nothing , während [Just 2, Just 7, Just 10] zu Just [2, 7, 10] würde.Zum Summieren einer Liste gibt es schon vorgefertigt eine
sum -Funktion. Leider befindet sich unsere Liste noch innerhalb der mysteriösen Maybe -Muschel, aber dafür gibt es eine Lösung: Der Operator <$> wendet eine Funktion "innerhalb" eines "Gebildes" an. Maybe ist nicht nur eine Monade, sondern auch "so ein Gebilde" (ein Applikativer Funktor, um genau zu sein). Die Regeln kann man sich denken: Aus nichts (Nothing ) wird wieder nichts (Nothing ) , aber wenn es einen Inhalt gibt, wird dieser von der Funktion verarbeitet und das Ergebnis hübsch in ein Just verpackt. Uff, fertig! Wir haben also im Endeffekt eine Funktion, die im "Fehlerfall" ein Nothing zurückgibt, und ansonsten die Indexsumme (in einem Just ).So, und jetzt stelle ich mich allen Ernstes hin und sage, diese ganzen Abstraktionen wären irgendwie hilfreich? Nein, ich will jetzt nicht darauf herumreiten, dass die Haskell-Lösung etwas kürzer ist - dann hätte ich die Variante
sumOfIndexes ch = fmap sum . (mapM . findIndex) (ch==)) präsentiert, die zwar eleganter aussieht, aber schwerer zu verstehen ist. Als erstes möchte ich feststellen, dass die Typ-Signatur von
sumOfIndexes viel zu speziell ist. Der Compiler würde stattdessen sumOfIndexes :: Eq a => a -> [[a]] -> Maybe Int ableiten. Eq a bedeutet einfach nur, dass für den Typ a die Vergleichsfunktion == definiert sein muss (die in Haskell aus der Typklasse Eq kommt). Das ist nur logisch, denn wir verwenden diese Funktion ja auch. Der Rest heißt: Es wird ein Argument vom Typ a und ein weiteres vom Typ Liste von Liste von a erwartet, und ein Maybe Int zurückgegeben. Wir könnten unsere Funktion also auch mit sumOfIndexes 4 [[1,2,4,5],[3,5,6,4]] aufrufen, und würden Just 5 zurückbekommen. Ohne etwas zu tun, haben wir also eine Funktion geschrieben, die mehr kann, als wir eigentlich wollten. Der Hauptgrund dafür ist, dass Haskell-Strings normale Listen sind, und dass wir keine speziell auf Strings zugeschnittene Funktion verwendet haben, sondern eine normale Listenfunktion. Die zweite Beobachtung ist, dass wir vier Funktionen der Haskell-API verwendet haben statt der einen aus der Java-API im Java-Beispiel. Woran liegt das? Ist es nicht ein Widerspruch, dass die sehr allgemeinen Haskell-Funktionen in unserem Beispiel so gut "gepasst" haben? Sind sie nicht zu "generell" dazu? Die Antwort ist, dass sie sich besser kombinieren lassen als in Java:
sum kann nicht auf einer in einem Maybe versteckten Liste arbeiten, aber <$> hat es dazu "hingebogen". map hat aus der für einen String gedachten Funktion findIndex (ch==) eine gemacht, die auf einer ganzen Liste arbeitet. Und sequence hat das Innere einer Struktur nach außen gestülpt und umgekehrt, sonst wäre sum nicht zum Zug gekommen, denn das braucht ja eine Liste von Zahlen und nicht mit Muscheln oder anderem Zeugs.Es ist auffällig, wie systematisch wir das Problem von rechts nach links immer weiter transformiert haben: Den Index finden, das Ganze auf eine Liste anwenden, der Muschel-Umstülp-Trick, und schließlich die Anwendung von
sum mit <$> als kleiner Trittleiter (damit es an die Liste herankommt). Die Java-Version arbeitet nicht nur auf einer viel tieferen Abstraktionsebene, der Code wirkt auch unordentlicher: Wir definieren eine Variable sum . Warum? Weil wir sie mittendrin erhöhen und am Ende zurückgeben. In der Schleife holen wir erst eine Index, speichern das Ergebnis in einer weiteren Variablen, dann kommt ein Test, bei dem wir die Methode eventuell verlassen, und ansonsten wird die Variable sum aufsummiert. Alles passiert irgendwie "gleichzeitig", zumindest muss man alles im Auge behalten: Was bedeutet das -1 nochmal? Ähm, welches von beiden? Die Haskell-Variante definiert keine lokalen Variaben, und benutzt auch keine magischen Werte wie -1.In Java hatten wir mit 4 Typen zu tun: char, int, String und List. In Haskell waren es erstaunlicherweise auch nur 4: Char, Int, Maybe und List. Nein, ich habe mich nicht verzählt, denn String ist nur ein Synonym für
[Char] , also Liste von Chars. Zwei davon (Maybe und List) sind in einer Unzahl von Typklassen vertreten, nicht nur in Applicative und Monad , was in unserem Beispiel von Bedeutung war. Jede Typklasse definiert ein Interface, das aber im Gegensatz zu Java viel besser von seinen "Implementierungen" entkoppelt ist. Eine genaue Beschreibung, was eine Typklasse ist, und wie sie sich von Javas Idee eines Interfaces unterscheidet, würde den Rahmen sprengen - jedenfalls sollte klar geworden sein, dass sich die beiden Konzepte deutlich unterscheiden müssen.Java ist nicht Haskell, und viele Konzepte würden einfach in der jeweils anderen Sprache nicht passen. Trotzdem denke ich, dass sich der Blick über den Tellerrand lohnt. Beide Sprachen besitzen Typparameter ("Generics"), aber die Haskell-Version ist deutlich flexibler: Während ich in Java eine Liste von Äpfeln und eine Liste von Autos zu einem Typ "Liste" abstrahieren kann, geht Haskell weiter, und kann eine Liste von Äpfeln und ein Set von Äpfeln zu einem "Irgendwas" von Äpfeln abstrahieren - und so seltsam es klingt, erst diese anscheinend sinnlose Abstraktion ermöglicht das Konzept der Typklassen. Was man damit interessantes anstellen kann, habe ich hier nur ganz, ganz oberflächlich angekratzt. Und deshalb lohnt es sich meiner Meinung nach herumzuexperimentieren, um vielleicht doch ein wenig dieser Power nach Java importieren zu können.
Nachtrag:
Man kann alt sein wie 'ne Kuh, und lernt immer noch dazu! Es gibt tatsächlich noch eine elegantere Haskell-Lösung:
Code:
import Data.List import Data.Traversable import Control.Applicative sumOfIndexes ch strings = sum <$> traverse (findIndex (ch==)) strings
traverse das "Mappen und Umstülpen" in einem Zug.Kommentare 0







