Suche
Scala Schritt für Schritt

Scala Tutorial Teil 4: Type Classes und Polymorphismus

Johannes Dienst

© Shutterstock.com / Aquir

Als Multiparadigmensprache besitzt Scala neben Objektorientierung auch ausgeprägte Merkmale einer funktionalen Sprache. Dadurch wird sie für viele Einsatzgebiete interessant. In diesem Tutorial lernen Sie Schritt für Schritt die Programmierung mit Scala und lernen so ihre Vorteile kennen – Viel Spaß!

In diesem Teil des Tutorials geht es um Type Classes und deren Verwendung. Type Classes sind vor allem aus Haskell bekannt. Das Grundproblem, das damit gelöst werden kann, ist Ad-hoc-Polymorphismus. Bei dieser Art der Vererbung möchte man einschränken, auf welche Typen eine bestimmte Operation angewendet werden kann. Warum das überhaupt wichtig ist, und welche Vorteile sich daraus ergeben, wird an einem konkreten Beispiel in unserem Beispielprojekt „Die Bibliothek“ gezeigt.

JVM-Sprache Scala: Die Tutorial-Serie

Polymorphismus klassisch

Bei der Recherche zu Polymorphismus fällt auf, dass die Definitionen nicht einheitlich sind und wahrscheinlich jeder Entwickler ein anderes Verständnis davon hat. Der Begriff ist so unscharf wie jener der funktionalen Programmierung. Nichtsdestotrotz werden wir für diesen Teil des Tutorials eine gemeinsame Grundlage schaffen, damit keine Verwirrung entsteht.
Ein zentrales Konzept der Programmierung – sowohl funktional als auch objektorientiert – ist Polymorphismus. Einige Arten von Polymorphie sind exklusiv für OOP geschaffen, wie das Überschreiben von Methoden, oder in beiden Paradigmen vorhanden, wie das Überladen von Funktionen/Methoden. Für die weitere Diskussion soll eine Unterteilung in vier Arten gemacht werden:

  • Inklusionspolymorphie: OOP arbeitet mit der Ableitung von Klassen aus Interfaces/Oberklassen. Überschreibt eine Methode in einer tieferliegenden Hierarchiestufe eine Methode aus einer Oberklasse, so nennt man diese Methode polymorph. Ohne Glaubenskriege anstoßen zu wollen, gehen wir auch davon aus, dass Methoden mit gleichem Namen, aber unterschiedlicher Parameteranzahl in diese Kategorie fallen.
  • Parametrischer Polymorphismus: Diesen Typ gibt es sowohl in OOP als auch in funktionalen Sprachen. Hier wird auch gerne von Generics oder Templates gesprochen. Darunter fallen generische Argumente bei Klassen, Funktionen und Rückgabetypen. Dadurch werden Funktionen oder Klassen für beliebige Typen anwendbar: List[T] kann jeden beliebigen Typen T enthalten. Den parametrischen Polymorphismus erkennt man daran, dass eine Klasse/Funktion/Methode für jeden Typ eine identische Implementierung besitzt.
  • Ad-hoc-Polymorphie: Die Definition der Ad-hoc-Polymorphie ist schwierig und variiert, je nachdem welche Quelle herangezogen wird. In diesem Tutorial ist damit gemeint, dass Operationen nur für bestimmte Typen zur Verfügung stehen und für deren Untertypen. Als praktisches Beispiel kann damit die Benutzung einer Funktion auf bestimmte Untertypen eingeschränkt werden. Das ist in Scala durch Vererbung mit Traits möglich oder mit Type Classes. Diese beschreiben, welche Struktur eine Klasse hat. Jedoch ohne das Problem, dass bei der Benutzung ein Overhead entsteht. Außerdem können die Methoden, welche die Struktur einer Klasse ausmachen, sehr unterschiedlich implementiert werden. Als Beispiel eignet sich der Operator +, der beim Typ Int eine Addition bewirkt und beim Typ String eine Konkatenation. Man bezeichnet die Ad-hoc-Polymorphie in manchen Quellen auch als Überladung.
  • Universeller Polymorphismus: Ist als Überbegriff zu verstehen und ist das Gegenteil des Ad-hoc-Polymorphismus. Es schränkt die Typen nicht ein, z. B.  bei generischen Argumenten, Funktionen und Rückgabetypen. Das führt dazu, dass die Implementierung einer Funktion generell für jeden Typ identisch ist.

Ad-hoc-Polymorphie mit Traits

Unsere Bibliothek kann im Moment Objekte vom Typ Book enthalten. Jedoch gibt es keine Möglichkeit festzustellen, wie viele Exemplare denn noch nicht ausgeliehen wurden. Das soll geändert werden, indem Book und EBook um eine Klassenvariable quantity erweitert werden. Damit lässt sich dann schnell feststellen, wie viele Bücher noch vorhanden sind.

class Book (val title: String, author: String, var isbn10: Long, var quantity: Int = 1) {….}

class EBook(
  title: String,
  val author: String,
  isbn10: Long = -1,
  val formats: List[String] = List[String](),
  quantity: Int = 1
  )
  extends Book(title, author, isbn10, quantity) {...}

Um herauszufinden, ob ein bestimmtes Buch noch vorrätig ist, muss man also die verfügbare Anzahl in der Bibliothek abfragen. Da nicht jedes mögliche Medium durch Book abgedeckt wird – Zeitungen oder Zeitschriften z. B. nicht – braucht man einen übergeordneten Typ. Dieser wird im Trait Publication untergebracht.

trait Publication[T] {
  def getQuantity: Int
}

Um diesen Trait mit einem Typen benutzen zu können, kann man das Adapter-Muster verwenden, indem man entsprechende Case-Klassen erstellt. In diesem Fall jeweils eine Klasse für Book und EBook.

case class PublicationBook(x: Book) extends Publication[Book] {
  def getQuantity = x.quantity
}
case class PublicationEBook(x: EBook) extends Publication[EBook] {
  def getQuantity = x.quantity
}

Damit alle Bücher gefunden werden können, die verfügbar sind, wird noch eine Funktion programmiert, die alle Publikationen herausfiltert deren Anzahl größer als 0 ist:

def findAvailable[T](xs: List[Publication[T]]): List[Publication[T]] = {
  xs.filter(p => p.getQuantity > 0)
}

Deren Verwendung ist leider etwas umständlich, da Objekte den zugehörigen Adapter benutzen müssen, damit der Compiler beim Aufruf von findAvailable(…) den richtigen Typ erkennt. Das Beispiel erstellt zuerst drei neue Objekte vom Typ Book und fügt diese in eine Liste ein. Dabei werden Adapter verwendet, damit beim Aufruf von findAvailable(…) kein Kompilierungsfehler auftritt.

val book0 = new Book("Clean Code", "Uncle Bob", 3826655486L)
val book1 = new Book("Code Complete 2", "Steve McConnell", 735619670L, 0)
val book2 = new Book("The Pragmatic Programmer", "Andrew Hunt", 3446223096L, 0)

val books = List(PublicationBook(book0),
  PublicationBook(book1), PublicationBook(book2))
println(findAvailable(books))

Mit dem Adapter-Muster ist es sehr leicht möglich, neue Arten von Publikationen in das bestehende System einzubinden. Dazu muss einfach eine weitere passende Case-Klasse hinzugefügt werden. Damit ist das Open-Closed-Prinzip erfüllt, nachdem eine Erweiterung bestehenden Code unberührt lassen soll. Nachteile sind, dass die Benutzung sehr umständlich ist, da immer der entsprechende Wrapper um das eigentliche Objekt verwendet werden muss. Außerdem werden dadurch sehr viele Objekte erzeugt, was die Performanz beeinträchtigen kann.

Implicits

Bevor wir zu Type Classes übergehen, sollte das Prinzip der Implicits verstanden werden. Diese bilden einen integralen Bestandteil von Scala und erleichtern die Programmierarbeit erheblich. Ein erstes praktisches Beispiel ist die Konvertierung von einem Datentyp zu einem anderen: Int zu String. Explizit z. B. so:

scala> val aInt = 42
aInt: Int = 42

scala> aInt.toString
res0: String = 42

scala> def printString(s: String) = println(s)
printString: (s: String)Unit

scala> printString(aInt.toString)
42

scala> printString(aInt)
:14: error: type mismatch;
 found   : Int
 required: String
       printString(aInt)

Das ist natürlich umständlich, da vor jedem Aufruf mit einem Int explizit toString aufgerufen werden muss. Zum Glück bietet Scala mit Implicits eine elegantere Lösung:

scala> implicit def intToString(i: Int) = i.toString 
warning: there was one feature warning; re-run with -feature for details
intToString: (i: Int)String

scala> printString(aInt)
42

Was ist hier passiert? Scala erkennt, dass der Datentyp Int nicht mit dem von printString(s: String) übereinstimmt und sucht deshalb nach einer impliziten Methode, die den passenden Rückgabetyp bei passendem Argumenttyp besitzt. Dafür wird der aktuelle Scope herangezogen und dort die Funktion intToString(i: Int) gefunden. Wie Scala den aktuellen Scope bestimmt, ist mitunter kompliziert. Deswegen verweise ich an dieser Stelle auf diese Quelle. Neben impliziten Konversionen können auch implizite Parameter verwendet werden. Zuerst soll eine Funktion sayNumber(num: Int) implementiert werden, die einen impliziten Parameter num erhält und diesen einfach ausgibt:

scala> def sayNumber(implicit num: Int) = println(num)
sayNumber: (implicit num: Int)Unit

Diese wirft beim Aufruf ohne Argumente einen Fehler, da ihr kein passender Parameter übergeben wurde. Genauer gesagt: Die Fehlermeldung sagt auch aus, dass kein impliziter Parameter im aktuellen Scope gefunden werden konnte:

scala> sayNumber
:14: error: could not find implicit value for parameter num: Int
       sayNumber
       ^

Das lässt sich aber leicht ändern, indem ein implizites Value vom richtigen Typ angelegt wird:

scala> implicit val answer = 42
answer: Int = 42

Ruft man nun sayNumber ohne Parameter auf, so kann der Parameter num mit dem impliziten Value besetzt werden und der Code wirft keinen Fehler:

scala> sayNumber
42

Im nächsten Beispiel soll noch das Prinzip von mehreren Parameterlisten eingeführt werden, da es im folgenden Abschnitt benutzt wird. Scala unterstützt für eine Funktion oder Methode mehrere Parameterlisten. Das erlaubt an den richtigen Stellen kompakteren ausdrucksstärkeren Code. Die einzelnen Parameter können in der Funktion jedoch wie gewohnt verwendet werden (einige Vorteile davon, siehe hier). Als Beispiel dient eine nicht ganz ernst gemeinte Funktion:

scala> def sayMeaning(num: Int)(implicit mean: String) = println(s"$num $mean")
sayMeaning: (num: Int)(implicit mean: String)Unit

scala> sayMeaning(27)
:15: error: could not find implicit value for parameter mean: String
       sayMeaning(27)
                 ^

Wie man sieht, verhält sich die Funktion analog zum bereits vorgestellten ersten Beispiel. Mit einem impliziten Value lässt sie sich dann auch ausführen:

scala> implicit val meaning = "42"
str: String = 42

scala> sayMeaning(27)
27 42

Ad-hoc-Polymorphismus mit Type Classes

Jetzt ist es an der Zeit sich anzusehen, wie Type Classes helfen können, das initiale Problem eleganter zu lösen. Das eigentliche Problem war, dass eine gemeinsame Oberklasse für alle Veröffentlichungen geschaffen werden sollte, mit dem Ziel, feststellen zu können, ob ein gewünschter Titel verfügbar ist. Dabei soll es aber so implementiert werden, dass nicht alle Publikationstypen eine entsprechende Case-Klasse als Adapter brauchen, der von diesem Trait erben muss. Der objektorientierte Ansatz führt aber genau dazu. Das hat zur Folge, dass die Benutzung viele Objekte erzeugt und insgesamt umständlich wird.

Im letzten Abschnitt wurde gezeigt, wie Implicits benutzt werden können, um ohne weiteres Zutun Parameter im Scope zu finden oder Datentypen automatisch zu konvertieren. Das Ziel ist es, findAvailable(xs: List[T]) typsicher zu implementieren. Das heißt, dass für alle unterstützten Typen der Aufruf findAvailable(xs: List[T]) ohne Wrapper möglich ist, was das Ziel von Ad-hoc-Polymorphismus darstellt. Dafür muss zuerst der Trait Publication modifiziert werden, sodass getQuantity(x: T): Int ein Objekt vom Typ T übergeben wird.

trait Publication[T] {
  def getQuantity(x: T): Int
}

Anschließend werden im Companion-Object implizite Objects für die neue Publikation Magazine und für Book erstellt. Der Ort ist wichtig, da beim Aufruf von findAvailable(xs: List[T]) das Companion-Object im Scope liegt und deshalb dort vom Compiler nach Implicits gesucht wird. So können Default-Implicits für unsere Bibliothek mit ausgeliefert werden:

object Publication {
  implicit object PublicationMagazine extends Publication[Magazine] {
    def getQuantity(m: Magazine): Int = m.quantity
  }

  implicit object PublicationBook extends Publication[Book] {
    def getQuantity(b: Book): Int = b.quantity
  }
}

Die impliziten Objects sehen ähnlich aus, wie die Case-Klassen für den gleichen Einsatzzweck. Der Unterschied ist, dass es zu jedem Zeitpunkt nur eine Instanz des Objects gibt, was das Performanzproblem behebt. Fügen wir das letzte Puzzleteil ein, um zu erklären, was passiert:

def findAvailable[T](xs: List[T])(implicit ev: Publication[T]): List[T] = {
  xs.filter(m => ev.getQuantity(m) > 0)
}

Der Funktion findAvailable[T](xs: List[T]) wird eine Liste eines bestimmten Typs T übergeben. Aufgrund dieses Typs wird der implizite Parameter ev gesucht, der vom Typ Publication[T] ist. Der Aufruf mit einer Liste von Objekten vom Typ Book findAvailable(books) führt also zu folgender Ausprägung von findAvailable[T](xs: List[T]]):

findAvailable[Book](books)(implicit ev: Publication[Book]): List[Book = {
  xs.filter(m => ev.getQuantity(m) > 0)
}

Da für den Typ Publication[Book] ein entsprechendes Object im Scope vorhanden ist, wird ev vom Compiler dafür herangezogen. Deswegen steht auch die Methode getQuantity(b: Book) zur Verfügung. Der Vollständigkeit halber folgt noch eine andere Schreibweise für diese Funktion:

def findAvailable[T: Publication](xs: List[T]): List[T] = {
  xs.filter(m => implicitly[Publication[T]].getQuantity(m) > 0)
}

Hier wird der Typ der Liste explizit auf Publication eingeschränkt, was wieder die Ad-hoc-Polymorphie gewährleistet. Dadurch muss dann auch im Rumpf der Funktion mit implicitly auf das passende Object zugegriffen werden, um die richtige getQuantity(…) aufzurufen. Man beachte, dass hier die Typsicherheit vom Compiler gewährleistet wird. Ein nicht von Publication unterstützter Typ wird zur Entwicklungszeit erkannt. Das immer wiederkehrende Ausschreiben von implicitly kann vermieden werden, indem man eine apply-Methode im Companion-Object von Publication implementiert:

def apply[A: Publication]: Publication[A] = implicitly[Publication[A]]

Dadurch kann der Compiler beim Aufruf von

Publication[T].getQuantity(m)

T durch Typinferenz mit dem richtigen Typ besetzen und das entsprechende implizite Object heranziehen. Sehen wir uns in den nächsten Abschnitten die Vorteile von Type Classes genauer an. Es wird sichergestellt, dass nur erlaubte Typen benutzt werden können. Dabei können leicht Defaultimplementierungen mitgeliefert werden, die nicht verändert werden müssen, wenn neue Typen ergänzt werden. Damit wird das Open-Closed-Prinzip nachdrücklich unterstützt. Sollen zum Beispiel der Typ EBook ebenfalls aufgenommen werden, kann ein implizites Object ergänzt werden. Da getQuantity(…) für jeden Typ anders implementiert werden kann, liefert es in diesem Fall immer 1 zurück. Das trägt dem Umstand Rechnung, dass EBooks nicht wirklich verliehen, sondern kopiert werden:

implicit object PublicationEbook extends Lib.Publication[EBook] {
  def getQuantity(eb: EBook): Int = 1
}

Ein weiterer Vorteil ist, dass die Defaultimplementierungen bei Bedarf überschrieben werden können. Dazu ziehen wir die Type Class scala.math.Ordering aus dem Scala-API heran. Diese gibt durch ein implizites Object für Int vor, dass eine Liste von Ints aufsteigend sortiert wird:

scala> println(List(27, 42, 7, 9, 3).sorted)
List(3, 7, 9, 27, 42)

Dieses Verhalten kann mit einem eigenen impliziten Object überschrieben werden, da es vom Compiler aufgrund des Scopes vor der Defaultimplementierung herangezogen wird.

scala> implicit object Int extends Ordering[Int] {
      def compare(x: Int, y: Int) =
        if (x < y) 1 else if (x == y) 0 else -1 } defined object Int scala> println(List(27, 42, 7, 9, 3).sorted)
List(42, 27, 9, 7, 3)

Fazit

Die Bibliothek sollte um die Fähigkeit erweitert werden, verfügbare Bücher aufzufinden. Um dieses Feature zu implementieren und dabei die leichte Erweiterbarkeit zu garantieren, sollte ein neuer Trait Publication eingeführt werden. Dabei stellte sich grundsätzlich die Frage, wie der entstehende Polymorphismus gehandhabt wird. Da es unterschiedliche Definitionen und Arten von Polymorphismus gibt, wurde zuerst eine Diskussionsgrundlage erarbeitet.

Im Grunde genommen gibt es vier Arten von Polymorphismus. Inklusionspolymorphismus ist vor allem in der objektorientierten Programmierung zu finden, wenn Unterklassen von Oberklassen/Interfaces ableiten und Methoden überschreiben. Auch die Überladung von Methoden mit gleichem Namen aber unterschiedlicher Parameteranzahl fällt darunter. Unter parametrischen Polymorphismus fällt der Einsatz von generischen Argumenten bei Methoden, Parametern und Rückgabetypen. Diese Art zeichnet sich dadurch aus, dass eine identische Methode für unterschiedliche Typen verwendet werden kann. Unter universellem Polymorphismus versteht man, dass Typen nicht eingeschränkt werden. So kann z. B. eine List[T] für jeden beliebigen Typ ausgeprägt werden. Dem entgegen steht der Ad-hoc-Polymorphismus der den Einsatz der Typen auf eine bestimmte Art begrenzt und mit dem die anfangs besprochene Funktionalität umgesetzt werden soll. Er wird auch als Überladung bezeichnet. Ad-hoc-Polymorphismus lässt sich grundsätzlich mit dem Adapter-Muster implementieren.

Mit einem Trait Publication und abgeleiteten Case-Klassen für alle Ausprägungen einer Publication sollte das Open-Closed-Prinzip berücksichtigt werden. Dabei stellte sich heraus, dass über das Companion-Object von Publication durchaus auch Defaultimplementierungen für bekannte Publikationsarten wie Book mitgeliefert werden können. Mit zusätzlichen Case-Klassen für neue Arten von Publikationen ist eine Erweiterung leicht durchführbar und das sogar ohne bestehenden Code zu ändern. Ein Nachteil des Adapter-Musters ist jedoch, dass jedes Objekt in ein zusätzliches Adapter-Objekt verpackt werden muss. Das ist auf Dauer nervig und kostet unnötig Performanz.

Als Nächstes wurden Implicits von Scala vorgestellt. Im Scala-API selbst wird von Implicits reger Gebrauch gemacht. Implizite Objects und Funktionen können vom Compiler herangezogen werden, wenn eine Methode Parameter enthält, die mit dem Schlüsselwort implicit gekennzeichnet sind. Fehlt ein solcher Parameter beim Aufruf, wird vom Compiler der Scope nach einem passenden Objekt durchsucht. Das stellt zur Kompilierungszeit sicher, dass die Typsicherheit gewahrt bleibt.

Mit diesem Wissen über Implicits kann das Problem der Publication eleganter gelöst werden. Dazu erstellt man sogenannte Type Classes, indem implizite Objects für jede konkrete Ausprägung des Traits Publication im Companion-Object von Publication platziert werden. Mit einem impliziten Parameter kann der Compiler dann das passende Object auswählen, je nachdem, um welchen Untertyp von Publication es sich handelt. Dieser Ansatz ähnelt dem Ansatz mit Traits, hat aber entscheidende Vorteile. Der Compiler nimmt dem Entwickler das Suchen der richtigen Type Class ab und stellt zusätzlich die Typsicherheit sicher. Außerdem werden nicht unnötig viele Objekte erzeugt, was den Overhead gering hält. Noch dazu können die Defaultimplementierungen leicht überschrieben werden, indem man eine eigene Implementierung bereitstellt.

Das Projekt ist auf GitHub verfügbar. Mit git checkout tags/part-four erhält man den abschließenden Stand dieses Tutorials.

Geschrieben von
Johannes Dienst
Johannes Dienst
Johannes Dienst ist Clean Coder aus Leidenschaft bei der MULTA MEDIO Informationssysteme AG. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java, Python und JavaScript-Applikationen. Twitter: @JohannesDienst
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.