Scala 3: Explizite Implicits
Nach 8 Jahren, 28000 Commits und 7400 Pull-Requests war es am 14. Mai
2021 endlich so weit: Scala 3 wurde veröffentlicht. Neben dem neuen
Compiler „Dotty“ haben es eine neue Syntax sowie einige Neuerungen an
der Sprache in Scala 3 geschafft. In diesem Blogpost der Serie über
interessante Neuerungen werden wir über Implicits sprechen. Implicits
sind eines der Hauptcharakteristika für Scala und kommen in fast allen
Projekten zum Einsatz. Nicht nur deshalb haben sich die meisten
Scala-Programmierer:innen schon über Implicits geärgert: Implicits
sind zu implizit, zu vielseitig und gleichzeitig zu einfach zu
implementieren. Scala 3 versucht nun, die einzelnen Einsatzzwecke von
Implicits explizit zu definieren und damit das Keyword implicit
schließlich loszuwerden.
- Scala 3: Scala im neuen Gewand
- Eins für zwei - Scala 3 Enums
- Scala 3: Über Vereinigungen und Schnittmengen
- Scala 3: Typ-Lambdas
Implicit Conversions
Tauchen wir direkt ein und betrachten einen beliebten
Anwendungsfall von Implicits aus Scala 2: Implicit Conversions. Im
folgenden Codebeispiel wird eine Funktion stringToParrot
als
implizit definiert, um einen String automatisch in einen Papageien
umwandeln zu können.
object Animals {
case class Parrot(sentence: String) {
def say : Unit = println(sentence)
}
implicit def stringToParrot(sentence: String) : Parrot = Parrot(sentence)
"arrrrrr!".say
}
Beim Aufruf der Methode say
versucht der Scala-Compiler durch
in der Umgebung vorhandene Implicit Conversions einen Typ zu
finden, der say
implementiert. In diesem Fall wird mithilfe von
stringToParrot
also ein Papagei erzeugt und die Methode say
des
resultierenden Objekts aufgerufen. Warum ist diese Implementierung
gefährlich?
Ist der Code komplexer als in unserem Beispiel, oder greifen mehrere Implicit Conversions ineinander, ist es oft sehr schwierig, den Code nachzuvollziehen. Viele, die in Scala-Projekten gearbeitet haben, haben bereits mit großen Fragezeichen auf den Code gestarrt und sich beispielsweise gewundert, warum ein String auf magische Weise das Verhalten eines Papageis aufweist. In Scala 2 können Implicit Conversions einfach mit einem Wildcard-Import eingebunden werden und oft ist es ohne sehr gute IDE und lange Suche unmöglich herauszufinden, warum auf einem Objekt Methoden aufrufbar sind, die dieses eigentlich nicht implementiert.
Darüber hinaus wird durch die Syntax selbst nicht sofort ersichtlich, was das eigentliche Vorhaben ist: nämlich eine Konvertierung zu implementieren. Man kann also sagen, Implicit Conversions sind in Scala 2 zu einfach und zu implizit definierbar, gemessen daran, was für Risiken und Probleme sie in Projekten einführen.
Scala 3 führt hierfür einen gesonderten Typ ein:
Conversion
. Dieser wird mit dem neuen Keyword given
als implizit
deklariert und drückt das Vorhaben deutlich expliziter aus:
object Animals:
case class Parrot(sentence: String):
def say : Unit = println(sentence)
given stringToParrotConversion : Conversion[String, Parrot] =
new Conversion[String, Parrot]:
def apply(sentence : String) : Parrot = Parrot(sentence + "!!!!!")
"arrrrrr!".say
Diese neue Syntax soll das Implementieren von impliziten Konvertierungen deutlich bewusster und reflektierter gestalten.
Darüber hinaus kann man Instanzen, die mit given
definiert werden,
nicht mehr einfach nebenbei mit einer Wildcard importieren, sondern
muss dies explizit tun, wie im Folgenden erklärt wird.
Importieren von given
-Instanzen
Wollen wir die Implicit Conversions aus dem Objekt Animals
in einem
anderen Objekt verwenden, müssen wir diese importieren. Das geht in
Scala 2 sehr einfach:
object Main {
import Animals._
"arrrrrr!".say
}
Durch den Wildcard-Import ist jedoch nicht direkt klar, dass die
Konvertierung aus diesem Modul kommt, insbesondere, wenn mehrere
solcher Imports passieren. Will man given
-Instanzen in Scala 3
importieren, muss man dies nun explizit angeben:
object Main:
// importiert die given-Instanz nicht:
// import Animals._
// importiert alle given-Instanzen aus Animal
import Animals.given
"arrrrr!".say
Dies kann und sollte man noch expliziter machen, indem man den
genauen Typ der zu importierenden given
-Instanz definiert:
object Main:
import Animals.Parrot
import Animals.{given Conversion[String, Parrot]}
"arrrrr!".say
Dieses Vorgehen sorgt für eine wesentlich bessere Nachvollziehbarkeit der bereitgestellten Implicits und macht Debuggen um vieles einfacher. Zudem ist das Fehlerpotential durch ungewolltes Importieren verringert.
using in Parametern
Implicit Conversions sind nur ein Anwendungsfall des
implicit
-Keywords. Die Übergabe von impliziten Argumenten in
Funktionen, Methoden und Konstruktoren ist ein beliebtes Mittel, um
Typklassen, Kontext und Dependency-Injection zu realisieren. Die
Definition solcher Objekte funktioniert nun auch über das
given
-Keyword. Implizite Parameter werden mit dem using
-Keyword
annotiert:
object Animals:
case class Parrot(sentence: String):
def say : Unit = println(sentence)
given parrotOrdering : Ordering[Parrot] with
override def compare(parrot1 : Parrot, parrot2 : Parrot) : Int =
-1 * parrot1.sentence.length.compareTo(parrot2.sentence.length)
def doSomethingWithParrots(parrots : List[Parrots])(using ordering : Ordering[Parrot]) = ???
List(Parrot("hello!"), Parrot("bye!")).sortBy {x => x}
Im Beispiel implementieren wir eine Typklasse, die eine Ordnung für
Papageien anhand ihrer Satzlänge definiert. Dazu implementieren wir
das bestehende Trait Ordering
, das wir in vielen Methoden der
Standard-Bibliothek verwenden können, die eine Ordnung für einen
bestimmten Typ erwarten. sortBy
ist eine solche Methode und hat
hier einen im Aufruf nicht sichtbaren zweiten Parameter using
Sorting[...]
. (Anmerkung: Das Keyword with
ist eine Abkürzung für
= new Ordering[Parrot]
. Im vorangegangenen Codebeispiel der Implicit
Conversions haben wir new Conversion[String, Parrot]:
noch
ausgeschrieben.)
Die Definition eines using
-Parameters sehen wir beispielhaft in
doSomethingWithParrots
.
Wollen wir den impliziten Parameter explizit übergeben, müssen wir auch
das using
-Keyword verwenden:
List(Parrot("hello!"), Parrot("bye!")).sortBy({x => x})(using parrotOrdering)
Dies löst einen Syntax-Konflikt, der mit Implicits und
apply
-Funktionen in Scala 2 auftritt:
def someMap(implicit i : Int) : Map[String, Int] = ???
implicit val a : Int = 3
// Erzeugt die Map:
val m = someMap
// Ruft einen Schlüssel ab:
val value = someMap(a)("hello")
// Erzeugt einen Compiler-Fehler
// Der Compiler erwartet hier den impliziten Parameter
val doesntWork = someMap("hello")
Extension Methods
In Scala sind Methoden das gängige Mittel, um Funktionalität für ein Objekt oder eine Klasse bereitzustellen. Manchmal hat man keinen Zugriff auf die Implementierung selbiger oder möchte diese nicht mit anwendungsfallspezifischem Code verwässern und hat daher das Bedürfnis, nachträglich Methoden hinzuzufügen. In Scala 2 gibt es hierzu ein Pattern, das Implicit Conversions verwendet:
object Main {
import Animals.Parrot
implicit class ParrotWithIQ(p : Parrot()) {
def iq() : Int = p.sentence.length
}
Parrot.iq()
}
Zwar verwenden wir hier einen Sonderfall, nämlich implizite Klassen,
jedoch lässt sich das ganze auch mit Konvertierungen, wie beim
stringToParrot
-Beispiel realisieren.
Da bei der Definition dieses Patterns wieder Mechanik über Vorhaben steht, hat Scala 3 sogenannte Extension Methods eingeführt. Das sieht dann so aus:
object Main:
import Animals.Parrot
extension (p: Parrot)
def iq() : Int = p.sentence.length
Parrot("Hello!").iq()
Diese Syntax abstrahiert die Implicit-Logik und macht unser Vorhaben explizit: Wir wollen den Papageien nachträglich erweitern. Dies ist zum Beispiel wichtig in Typklassen, bei denen man durch das Übergeben der Typklasse weitere Methoden für bestimmte Typen bereitstellt. Bei der Implementierung solcher muss in Zukunft keine Implicit Conversion mehr verwendet werden.
Résumé
Auch bei den Implicits macht Scala 3 wieder einiges besser: Durch
explizitere Syntax und schärfere Trennung wird nachvollziehbarer, was
die Intentionen sind. Mögliche Fehlerquellen werden damit
reduziert. Ob dies auch ausreicht, um die Komplexität in großen
Projekten beherrschbar zu halten, wird sich herausstellen. Langfristig
soll das Keyword implicit
aus der Sprache verschwinden.
Warum man mit Scala 3 nicht noch expliziter wurde und nur den halben
Weg gegangen ist, ist unverständlich. Wie bei den Extension Methods,
die nun in der Syntax klar ausgetrennt wurden, hätte man auch bei
Conversions eine explizitere Syntax wählen können. Stattdessen findet
die Unterscheidung auf Typebene statt und syntaktisch werden diese
erneut mit den übrigen given
-Instanzen vermischt. Auch Typklassen
sind in der Implementierung ein wohl definiertes Muster und auch hier
hätte Scala 3 noch expliziter sein dürfen, um diese weiter
abzugrenzen.
Im nächsten Blogpost dieser Reihe werden wir eine weitere spannende Neuerung von Scala 3 unter die Lupe nehmen: Union Types.