Transkript

Advanced Scala – Teil 2

Type Level Programming

In dieser Folge setzen Daniel Westheide und Stefan Tilkov ihre Unterhaltung über fortgeschrittene Konzepte der Programmiersprache Scala fort. Diesmal geht es vor allem um Type Level Programming: logische Programmierung im Scala-Typsystem und generische Programmierung mit der Library Shapeless, aber auch um einen Vergleich zu Clojure, der anderen großen funktionalen Sprache auf der JVM.

Zurück zur Episode

Transkript

Daniel Westheide: Hallo.

Stefan Tilkov: Beim letzen Mal haben wir schon gesprochen über Case Classes und über Implicits und Type Classes und Pattern Matching und allerlei andere spannende Dinge, von denen ich noch nie was gehört habe. Ich freue mich darauf, da direkt nahtlos weiter zu machen: Wir beginnen mit einer wunderschönen, provokativen Aussage. Wir hatten vor kurzer Zeit eine wunderbare Folge zu Prolog mit unserer Kollegin Joy und das hat dich inspiriert zu behaupten, im Scala-Typsystem steckt auch ein Prolog. Mir schwant ganz Furchtbares und ich bin sehr gespannt.

Daniel Westheide: Ja, ich glaube auch, dass du das furchtbar finden wirst. Es ist auch keine Aussage von mir, sondern es gibt da eben tatsächlich Leute, die da schon viel zu gemacht haben und auch Vorträge darüber. Scala ist eben nicht nur eine funktionale und objektorientierte, sondern auch eine logische Programmiersprache, das aber nur im Compiler oder im Typsystem.

Und zwar haben wir implicit values ja schon in der letzen Folge kurz angesprochen. Mit implicit values lassen sich Fakten repräsentieren, während Regeln, wie man sie in Prolog kennt, durch implicit defs repräsentiert werden können. implicit defs haben wir aber noch nicht erklärt: Zusätzlich zu implicit vals kann ich eben auch Implicits als Methode, also als def, definieren und dann können diese impliziten Methoden selber wieder eine implizite Parameterliste haben.

Stefan Tilkov: Ich bin mir nicht sicher, ob ich hier gerade abgehängt werde durch die Verwendung des Begriffs „implizit“: Was ist eine implizite Methode mit einer impliziten Parameterliste? Kannst du mir das noch mal in anderen Worten erklären?

Daniel Westheide: Wenn ich eine Methode habe, die jetzt zum Beispiel eine implizite Instanz einer bestimmten Type Class, sagen wir jetzt mal Ordering, erwartet, dann muss der Scala-Compiler eben schauen, welche Implicits definiert sind. Ein Ordering von Int, zum Beispiel, kann ich ja als implict value definieren. Es gibt aber auch die Möglichkeit, generische Instanzen von Type Classes zu definieren. Zum Beispiel kann ich sagen, für jeden Typ A, für den schon ein Ordering implizit vorhanden ist, möchte ich auch dafür sorgen, dass automatisch auch ein Ordering für Option of A definiert ist.

Stefan Tilkov: Das kann ich mir vorstellen. Wenn ich das in der Begrifflichkeit dieser generischen Beziehung ausdrücken kann, dann muss ich das nicht immer neu definieren. Sonst würde ich ja immer wieder dieselbe Methode schreiben.

Daniel Westheide: Genau. Und dafür muss ich aber eben ein Implicit bereitstellen vom Typ „Option of A“, aber das hat ja einen Constraint: Das ist nur definiert, dieses Implicit, wenn es für A schon ein Ordering gibt. Und deswegen ist dieses Implicit selber abhängig davon, dass eben für A schon ein implicit ordering vorhanden ist und das drückt man ja durch implizite Parameterlisten aus. In diesem Fall ist aber meine Methode aber eben auch selber implizit. Und dadurch kann dann eine andere Methode sagen, ich brauche ein Implicit von einem bestimmten Typ Parameter und dieser Typ Parameter kann dann zum Beispiel ein „Option von Int“ sein.

Stefan Tilkov: Okay. Ich glaube, ich habe es halbwegs verstanden. Und jetzt fangen wir an, den Compiler zu missbrauchen, um Dinge zu tun, die niemand jemals vorgesehen hatte.

Daniel Westheide: Ganz genau. Implicit defs kann man dann eben auch verwenden, um Regeln im Prolog-Stil zu definieren, die dann zusammen mit den Fakten, die als implicit values definiert sind, verarbeitet werden, aber eben alles vom Scala-Compiler.

Stefan Tilkov: Das heißt, was der Scala-Compiler eigentlich tut, ist das, was er immer tut: Er versucht irgendwie das Typsystem glücklich zu machen, das darin enthalten ist, und das macht er, indem er ganz viele Regeln anwendet.

Daniel Westheide: Ganz genau: Er muss ja, wie er das immer tut, Implicits auflösen und das tut er dann eben. Ein relativ einfaches Beispiel: Die Kollegin Joy hat ja auch in dem Prolog-Podcast die Pianoarithmetik erwähnt. Das kann ich eben auch im Scala-Compiler machen. Ich kann ein Trait definieren für natürliche Zahlen und eine Klasse Zero für den Basisfall und eine Klasse Successor, die die nächste Natürliche Zahl repräsentiert. Das würde ich jetzt auch nicht im Detail beschreiben, aber interessant ist eben, dass ich dann auch im Typsystem zur Compilezeit Addition vornehmen kann, indem ich eben eine implicit def habe für die Basisregel, dass die Summe von Null und einer anderen Zahl diese andere Zahl ist und ein zweites implicit def für die Induktion.

Stefan Tilkov: Das klingt außerordentlich praxistauglich.

Daniel Westheide: Viel praxistauglicher ist es natürlich, die Türme von Hanoi im Scala-Compiler lösen zu lassen.

Stefan Tilkov: Das ist noch praxistauglicher.

Daniel Westheide: Wer das gerne nachvollziehen möchte: Ich denke, den Link dazu können wir auch in die Shownotes packen.

Stefan Tilkov: Das heißt, man behauptet jetzt einfach immer, wenn der Scala-Compiler wieder so lange braucht, dass der in Wirklichkeit gerade Türme von Hanoi spielt, oder?

Daniel Westheide: Ganz genau.

Stefan Tilkov: Okay. Gibt es denn auch irgendwelche realen Use-Cases, also irgendetwas, was man wirklich sinnvollerweise mit diesem Trick tun kann?

Daniel Westheide: Ja, durchaus. Und zwar ermöglicht diese Repräsentation von natürlichen Zahlen im Typsystem unter anderem auch das typsichere Indizieren von sogenannten HLists. Jetzt muss ich erst einmal erklären, was eine HList ist: Das ist eine sogenannte heterogene Liste, also eine Liste, in der jedes Element einen anderen Typ haben kann, aber der Typ ist bekannt. Eine normale Liste in Scala, aber auch in den meisten anderen Sprachen, würde, wenn sie verschiedene Typen hat, wahrscheinlich irgendwann zu einer Liste von Object, wenn sie zum Beispiel Strings und Integer enthält.

Das Konzept von Tupeln ist dem jetzt natürlich nicht unähnlich. Da ist ja auch der Typ jedes Feldes bekannt, wenn man eine statische Sprache verwendet. Zum Beispiel: ein Tupel von zwei Elementen String und Int hat dann den Typ Tuple2 von String und Int. Und das Ganze kann ich auch von Tuple3 bis Tuple22 machen in Scala. Das Problem ist dann natürlich, dass die verschiedenen Tupeltypen 2 bis 22 eigentlich keinen gemeinsamen Typ haben, die sind völlig unabhängig voneinander.

Mit dem Datentypen HList kann ich da eben auch über die Länge abstrahieren, es kann also quasi beliebig lange Tupel erzeugen. Das typsichere Indizieren heißt in diesem Fall, dass ich, wenn ich eine HList habe, mir zum Beispiel ein Element an einem bestimmten Index zurückgeben lassen könnte – aber es ist zur Compile-Zeit sichergestellt, dass diese Liste auch mindestens so lang ist wie der Index, den ich angegeben habe.

Stefan Tilkov: Das ist mit Typsicherheit gemeint, verstehe.

Daniel Westheide: HLists sind zum Beispiel in der Library „Shapeless“ implementiert von Miles Sabin. Das ist so die beliebteste Implementierung von HLists in Scala. In der Standard-Library gibt es noch keine HList, auch wenn es immer mal wieder Bestrebungen gab, dort eine einzuführen, weil es immer schon viele verschiedene Implementierungen in diversen Libraries gegeben hat.

Stefan Tilkov: Und die HList in der „Shapeless“-Library, die benutzt auch diesen Trick, um das sichere Indizieren hinzubekommen?

Daniel Westheide: Genau. „Shapeless“ an sich benutzt ganz viele solcher Tricks. Und viele dieser Prolog-im-Typsystem-Patterns finden sich dann eben auch in dieser Library wieder. Manche Sachen, wie Türme von Hanoi, die man dann damit bastelt, sind natürlich reine Spielerei, aber es gibt eben auch durchaus sinnvolle und valide Anwendungsfälle für „Shapeless“ und für die Datentypen, die es bereitstellt.

Stefan Tilkov: Gut, Beweis erbracht, wir haben ein Prolog darin oder zumindest ein regelbasiertes System. Was haben wir als nächstes? Wir haben noch generische Programmierung trotz Case Classes.

Daniel Westheide: Genau. In dem ersten Scala-Podcast mit Tobias Neef gab es ja auch so einen Vergleich von Scala und Clojure in der Hinsicht, dass man in Clojure ja in erster Linie mit Maps, Listen und Vektoren arbeitet und in Scala ständig für alles seine eigenen Case Classes usw. definiert. Und da hattest du dann den Punkt angebracht, dass das dann viel schwieriger ist, generisch zu arbeiten.

Ein Beispiel, das man häufig sieht, ist wahrscheinlich Erzeugung von JSON oder so etwas in der Art. Out-of-the-box kann man mit Case Classes tatsächlich nicht generisch programmieren. Aber auch hier kommt dann gerne wieder die schon erwähnte Library „Shapeless“ zum Einsatz, die mit einer ganzen Menge von fortgeschrittenen Techniken und Compiler-Tricks oder manchmal auch Compiler-Bugs dafür sorgt, dass man generische Programmiertechniken in Scala anwenden kann.

Stefan Tilkov: Das musst du mir noch mal erklären. So wie ich das verstehe, ist es so, dass ich die Wahl habe, wenn ich zum Beispiel so etwas wie JSON verarbeite, dann kann ich entweder implizit oder explizit das Schema irgendwohin schreiben und sagen, mein JSON sieht immer so aus: Das hat immer auf oberster Ebene eine Liste oder einen Vektor und darin sind Objekte und jedes dieser Objekte hat folgende drei Properties und dann noch mal einen Vektor oder ein Array darin, da ist noch mal irgendetwas. So etwas würde ich entweder generisch behandeln, dann ist das, worauf ich das mappe, im Prinzip vielleicht so etwas wie Node, Array, Vector oder Property. Oder ich kann das spezifisch machen, dann wird daraus, wenn ich darin vielleicht Kunden und Bestellungen habe, dann wird daraus eine Case Class Customer und eine Case-Class Order und ein Array von Orders oder so etwas.

Daniel Westheide: Genau.

Stefan Tilkov: Das, was du beschrieben hast, klang jetzt so, als wäre es irgendwo dazwischen.

Daniel Westheide: Was ich hier beschreiben möchte, ist, dass ich eigentlich jede Case Class oder eine Instanz davon auch in einer generischen Form repräsentieren kann mit Hilfe von „Shapeless“. Es ist ja so: Wenn ich zwei verschiedene Case-Classes habe, die beide zwei Felder haben, einmal vom Typ Int und einmal vom Typ String zum Beispiel, dann muss ich jetzt normalerweise meine JSON-Serialisierung dafür zweimal schreiben. Aber eigentlich haben diese beiden Case Classes die gleiche Form, nämlich Int und String. Und darüber kann ich mit „Shapeless“ abstrahieren. „Shapeless“ stellt dafür den Typ Generic bereit, der eben auf die schon erwähnten HLists aufsetzt. Und so kann ich für einen beliebigen Wert, der eine Case Class ist, mir eine generische Repräsentation holen.

Stefan Tilkov: Das wäre dann praktisch eine HList, in der die einzelnen Elemente die Felder der Case-Class sind, die Properties der Case-Class Instanz?

Daniel Westheide: Ganz genau. Das ist die einfachste Form einer generischen Repräsentation von Case Classes. Manchmal ist es natürlich auch interessant, noch die Namen der Felder zu wissen. Auch dafür hat „Shapeless“ eine generische Record-Repräsentation. Wenn man das eben hat, ist es auch möglich, zum Beispiel automatisch für beliebige Case-Classes zur Compile-Zeit einen JSON-Serializer bereitzustellen.

Stefan Tilkov: Was passiert denn jetzt zur Laufzeit? Mir ist klar, wenn ich in meinem Sourcecode Case Classes benutzt habe, dann habe ich den offensichtlich streng getypt, da gibt es keine Type-Errors oder so, weil das ist ja gegen diese Case Classes programmiert. Das ist ja immer der Nachteil, den man offen zugeben muss, wenn man das Ganze mit den generischen Typen macht. Das passiert einem in Clojure zum Beispiel durchaus auch, dass einem Sachen um die Ohren fliegen, weil da doch nicht das findet, was man erwartet hätte. Das passiert zur Compile-Zeit, das heißt, meine Case Classes und der Code, der diese Case Classes benutzt, die passen auf jeden Fall zusammen. Was passiert denn jetzt, wenn zur Laufzeit mein JSON nicht zu den Case Classes passt?

Daniel Westheide: Das kommt jetzt natürlich darauf an, wie der generische Code implementiert ist. Du gehst jetzt auf den Deserialisierungsfall ein von JSON zu meiner Case Class, oder?

Stefan Tilkov: Ja, genau.

Daniel Westheide: Typischerweise ist das in Scala so, dass dann keine Exception geschmissen wird, sondern diese Funktion, die aus beliebigem JSON einen bestimmten Typen macht, die gibt dann ein Option oder ein Either zurück. Ein Either ist ähnlich wie Option, statt des None-Falls würde man aber einen sogenannten Left-Fall bekommen, in dem sich dann Fehlermeldungen befinden können, während der sogenannte Right-Fall dann der Erfolgsfall ist, bei dem ich das bekomme, was ich eigentlich haben möchte.

Stefan Tilkov: Was würde passieren, wenn ich jetzt, sagen wir mal in meinem Code eine Case Class erwarte oder eine Case-Class- Instanz - wie ist da die Terminologie? -, die, wie du gerade gesagt hast, ein Int und ein String enthält. Und in meinem JSON steht auch ein Int und ein String, aber außerdem auch noch ein String und noch ein Float, weil ich das erweitert habe. Funktioniert das dann immer noch?

Daniel Westheide: Du meinst, das JSON wurde erweitert?

Stefan Tilkov: Genau. Der Hintergrund, warum ich das frage, ist, dass mein Code sich ja vielleicht nur für diese beiden Felder interessiert. Da habe ich auch gerne eine statische Typisierung. Das finde ich ja völlig in Ordnung, wenn mein Code dann auch sicher sein kann, dass da wirklich auch ein Int und ein String reingekommen sind und da nicht noch zur Laufzeit prüfen muss, ob das Ganze funktioniert. Was mir nicht gefällt, ist, wenn ich, obwohl mein Code nur von diesen zwei Feldern abhängt, ich trotzdem die Typinformation über zehn Felder in meinen Code einbetten und das aktuell halten muss. Jedes Mal, wenn ich etwas ändere, fliegt mir sonst zur Laufzeit die Deserialisierung auf die Nase und ich muss ständig meinen Code aktualisieren, obwohl sich aus dessen Sicht überhaupt nichts Relevantes geändert hat. Bekomme ich das dann in den Griff?

Daniel Westheide: Ich bin mir nicht genau sicher, wie das tatsächlich funktioniert, wenn man diese „Shapeless“-basierte JSON-Serialisierung und -Deserialisierung verwendet. Ich muss auch sagen, obwohl ich das jetzt hier beschrieben habe, mit dem JSON-Use-Case, bin ich persönlich auch gar kein großer Fan davon, automatisch von Case-Classes zu JSON und andersherum zu serialisieren. Sondern ich denke, dass sollte man grundsätzlich, wenn man etwas anderes als Prototyping macht, sowieso manuell schreiben, diesen JSON-Serialisierungscode. Denn wenn ich jetzt ein Feld in meiner Case-Class umbenenne, möchte ich ja in der Regel auch nicht, dass meine JSON-API sich verändert.

Stefan Tilkov: Ja, ganz eindeutig.

Daniel Westheide: Es sei denn, ich habe noch ein DTO-Layer dazwischen gebaut, aber das erfreut sich ja eigentlich heutzutage nicht mehr so großer Beliebtheit. Die manuelle JSON-Serialisierung ist aber eigentlich in diversen Scala-JSON-Libraries auch gar nicht so furchtbar, finde ich, so viel Boilerplate muss man da jetzt nicht schreiben. Ich denke, es ist nicht viel mehr Code, als man in Clojure schreiben müsste, wenn man von der Map, die man automatisch von der JSON-Library bekommen hat, in sein Domain-Model transformieren möchte.

Stefan Tilkov: Gut. Unsere Punkte haben wir im Prinzip durch. Ich würde aber gerne die Gelegenheit nutzen, wenn wir beide schon einmal miteinander sprechen, dich noch zu zwei tendenziösen Aussageblöcken hinzureißen. Und zwar haben wir jetzt schon ein paar Mal erwähnt oder du hast immer Bezug genommen darauf, dass diese und jene Dinge in Clojure so und so sind. Das ist in der Tat interessant, weil du, bevor du in die Scala-Welt eingetaucht bist, tatsächlich eine ganze Zeit lang professionell Clojure gemacht hast.

Daniel Westheide: Das stimmt.

Stefan Tilkov: Kannst du vielleicht aus deiner Sicht die beiden ein bisschen kontrastieren, durchaus auch mit persönlicher Meinung. Ich unterstelle, du machst lieber Scala als Clojure und mich würde interessieren, warum das so ist. Was genau hat dich von der einen zur anderen Sprache hinbewegt, was gefällt dir daran besser?

Daniel Westheide: Zunächst einmal ein Disclaimer: Mein professioneller Clojure-Einsatz liegt jetzt schon ein paar Jahre zurück. Ich habe zwar auch ein bisschen verfolgt, was danach noch so alles passiert ist, aber richtig professionell habe ich es danach nicht mehr eingesetzt. Das heißt, mein Wissen ist ein bisschen veraltet.

Was ich an Clojure immer sehr gemocht habe, ist, dass es auf mich sehr elegant gewirkt hat, also auch der Code zum Lesen. Was mich aber gestört hat, ist, dass viele Open-Source-Libraries, die man verwendet hat, oft dieses typische achtzig Prozent-Problem hatten. Das heißt, sie waren so zu achtzig Prozent fertig, haben wahrscheinlich genau das gemacht, was der Autor der Bibliothek sich selbst gewünscht hat, waren für uns dann aber in der Praxis eigentlich nicht so gut zu verwenden. Das heißt, wir haben viel Zeit damit verbracht, existierende Libraries zu patchen, zu forken usw. Da weiß ich jetzt nicht, wie sich das verändert hat in den letzten Jahren, ob sich das Ökosystem stabilisiert hat.

Stefan Tilkov: Da müssen wir mal einen unserer aktuellen Clojure-Fans heranholen.

Daniel Westheide: In Scala muss man ja fairerweise sagen, dass es auch vor einigen Jahren noch nicht so stabil war, wie es jetzt ist. Auch die Standard-Library hat sich ja früher noch schneller verändert mit nicht kompatiblen Änderungen. Das hat sich ja mittlerweile geändert und das Ökosystem ist sehr groß, hat viele reife Bibliotheken, kann ich aber, wie gesagt, nicht mit dem aktuellen Stand von Clojure vergleichen.

Stefan Tilkov: Das ist jetzt eher ein Infrastruktur-Argument, oder? Gibt es auch sprachbezogene Dinge? Für mein Gefühl haben die natürlich etwas gemeinsam: Die sind beide funktional, nicht hundert Prozent, aber durchaus mit Ähnlichkeiten in diesem Bereich, aber sonst in jeder anderen Beziehung ja außerordentlich unterschiedlich.

Daniel Westheide: Den Ansatz von Scala möglichst viel schon zur Compile-Zeit sicherzustellen, möglichst viele Fehler abzufangen, den hat man so in Clojure natürlich nicht. Das ist eine Sache, die ich persönlich dann an Scala besser finde. Was ich oft in Clojure hatte, war, dass zur Laufzeit kryptische Exceptions auftraten, wo man dann recht lange suchen musste, was der eigentliche Fehler ist. Das hat mich persönlich dann ein bisschen von Clojure abgehalten, das weiter intensiver zu benutzen.

Andererseits ist es natürlich so, dass ich manche Ansätze in Scala, bei denen extrem viel schon zur Compile-Zeit sichergestellt werden soll, auch für ein bisschen praxisfern halte und da ist Clojure vielleicht ein bisschen pragmatischer.

Stefan Tilkov: Ich bin immer ein bisschen erschrocken vor dieser Komplexität. Das ist jetzt natürlich auch eine total unfaire Betrachtung, weil ich ja keine Ahnung habe. Insofern muss das natürlich komplex aussehen. Das nimmst du nicht so wahr? Also für dich ist das, was der Compiler so tut, durchschaubar und im täglichen Leben eigentlich kein Problem? Wenn man die Patterns einmal kann, weiß man, was man da tut?

Daniel Westheide: Ja. Das ist natürlich so, dass ich jetzt schon ein paar Jahre darin stecke und ich weiß schon, dass das am Anfang ein bisschen knifflig war. Ich glaube, die Einstiegshürde ist auf jeden Fall etwas, was bei Clojure deutlich niedriger ist, und man kommt, glaube ich, schneller zu dem Punkt, an dem man versteht, was man eigentlich tut mit Clojure. Man ist also wahrscheinlich schneller produktiv.

Eine Sache, die ich an Scala auch noch besser finde, sind die Collection-APIs oder die Collection Library. In Clojure habe ich den Eindruck, dass oft viele Funktionen in dieser API relativ chaotisch sind: Manche erwarten vielleicht einen Vektor, manche funktionieren auch mit einer Liste und was sie zurückgeben, ist auch nicht genau der Typ, den man vorher hereingesteckt hat. Da wurde halt in Scala viel Wert darauf gelegt, dass man mit den spezifischen Typen, die man in eine Operation hereingesteckt hat, auch weiter arbeiten kann.

Stefan Tilkov: Hat sich für dich, wenn wir Clojure mal verlassen, durch die Weiterentwicklung von Java etwas an deinem Bild von Scala geändert? Hat Java aufgeholt in Richtung Scala oder findest du die Unterschiede immer noch ohne jede Frage groß genug, dass du sehr viel lieber in Scala programmierst, als du das in Java tun würdest?

Daniel Westheide: Ich finde die Unterschiede durchaus noch groß genug. Java 8 hat einiges aufgeholt, indem es jetzt nativ Lambdas unterstützt und man keine anonymen Klassen mehr schreiben muss. Auch gibt es jetzt diese Default Interfaces, das heißt Interfaces, die auch konkrete Implementierung von Methoden enthalten können. Das ist ja auch eine Sache, die Scala schon deutlich länger hatte mit den Traits. Wenn man jetzt davon absieht, finde ich aber, dass man in Scala immer noch besser funktional programmieren kann oder auch in Clojure, denn so Konzepte wie currying, partial function application, so etwas wird in Java 8 ja immer noch nicht vernünftig unterstützt. Und wenn man jetzt noch ein starkes Typsystem mag, ist Scala immer noch die besser Wahl, würde ich sagen. Denn das Java-Typsystem hat, denke ich, immer noch das Problem, dass es einem zwar viel Schmerz bereitet, aber nicht viel zurückgibt. In Scala bekomme ich wenigstens etwas zurück.

Stefan Tilkov: Wenigstens hast du nicht gesagt, dass man dort keine Schmerzen hat.

Daniel Westheide: In Scala habe ich halt viele Fehler, die ich tatsächlich zur Compile-Zeit schon verhindern kann. In Java habe ich einfach nur viele Typen, die ich überall dranschreiben muss, aber es bringt einem effektiv oft nicht viel, auch weil das ganze Java-Ökosystem auch stark Reflection-basiert ist.

Stefan Tilkov: Gut. Ich danke dir. Ich glaube, wir sind an einem guten Punkt angekommen, auch zeitlich, mit der zweiten Episode sauber zu Ende. Wir packen ganz viele Links in die Shownotes. Ich danke dir für deine Zeit und unseren Hörern fürs Zuhören. Ciao.

Daniel Westheide: Tschüss.