Teil 9: Klassen und Objekte

Die bisher angesprochenen Themen waren zwar alle ganz nett, erlauben uns aber noch nicht viel mehr als nur mit der REPL zu arbeiten. Wir könnten vielleicht schon ein paar kleine Skripte schreiben, aber das ist nicht Scalas Stärke. Gehen wir also ein wenig weiter zum Arbeiten mit Objekten.

Klassen

Das Erste was wir zum Erstellen von Objekten benötigen ist eine Klasse. Wie aus vielen anderen Programmiersprachen schon bekannt, benötigen wir dafür das class-Schlüsselwort:

class Person

So fertig, wir haben unser ersten Objekt. Nur können wir damit leider noch nicht viel anfangen. Fügen wir dem Objekt also ein paar Attribute hinzu:

class Person(name: String, age: Int)

Das sieht doch mal interessant aus. Und es ist sehr kompakt. Was wir hier gemacht haben ist nichts anderes, als dass wir der Klasse Person einen Konstruktor verpasst haben, der als Parameter einen String und einen Int erwartet. Der Konstruktor einer Klasse wird in Scala immer mit runden Klammern direkt hinter dem Klassennamen notiert. Falls wir keine runde Klammern erstellen kreiert der Scala-Compiler für uns einen Default-Konstruktor, der keine Parameter erwartet.

Scalas „way of life“ lautet dabei immer: Erzeuge so wenig Overhead wie möglich. Wir dürfen also sowohl Runde als auch geschweifte Klammern, die den Klassenrumpf aufnehmen, weglassen wenn sie keinen Inhalt haben.

Wir wollen jetzt aber endlich ein Objekt haben mit dem wir etwas anfangen können. Dafür müssen wir nicht mal viel ändern:

class Person(val name: String, val age: Int)

Das val vor dem Attributnamen weist den Scala-Compiler an, unser Attribut zu einem Feld der Klasse zu machen, auf das man von außerhalb zugreifen kann:

scala> class Person(val name: String, val age: Int)
defined class Person

scala> val p = new Person("Franz", 35)
p: Person = Person@2bec5408

scala> p.name
res37: String = Franz

scala> p.age
res38: Int = 35

scala> p.name = "Hugo"
:9: error: reassignment to val
       p.name = "Hugo"
              ^

Was wir hier erzeugt haben ist ein unveränderliches Objekt. Wir können es einmal erstellen, aber nie mehr ändern. Wollen wir dagegen die Möglichkeit haben die Felder eines Objekts nach der Instanziierung noch zu ändern müssen wir lediglich das val gegen ein var eintauschen:

scala> class Person(var name: String, var age: Int)
defined class Person

scala> val p = new Person("Franz", 35)
p: Person = Person@29a430a0

scala> p.name = "Hugo"
p.name: String = Hugo

In diesem Fall wäre aber davon abzuraten die Variable veränderbar zu halten, da wir sonst mit jeder Änderung eines Feldes auch das jeweils andere Feld ändern müssten (andere Personen haben ja wohl nicht das gleiche Alter).
Was hier unter der Haube vorgeht ist eigentlich ganz einfach. Das was wir in anderen Programmiersprachen von Hand erledigen müssen erledigt für uns der Scala-Compiler. Er erzeugt für die Klasse ein Feld und je nach dem ob wir das Feld veränderlich halten wollen generiert er uns noch einen Getter und einen Setter. In Scala wird den Gettern und Settern üblicherweise kein get und kein set vorangestellt (anders als bei Java, bei dem dies die übliche Vorgehensweise wäre). Beim Getter wäre es damit zu begründen, dass in Scala sowieso alles einen Wert zurück gibt – Scala Unit funktioniert anders wie Javas void. Beim Setter dagegen wissen wir anhand der runden Klammern ja, dass eine Operation Seiteneffekte produzieren kann. Der Getter trägt deshalb auch keine Klammern und kann auch nicht mit Klammern aufgerufen werden:

scala> p.name()
:10: error: not enough arguments for method apply: (index: Int)Char in class StringOps.
Unspecified value parameter index.
              p.name()
                    ^

Was die Fehlermeldung in Verbindung mit der Methode apply genau zu bedeuten hat werde ich bald erklären. Es wird Zeit, dass wir anfangen unserer Klasse eigene Methoden zu geben, damit wir ein wenig mehr damit machen können als nur ihre Felder anzuschauen. Ich möchte von dem Personen-Beispiel weg und hin zu einem Beispiel aus der Mathematik gehen: Wir wollen mit Scala rationale Zahlen verarbeiten können, was von Haus aus nicht geht. Also erweitern wir die Sprache eben kurzerhand damit.

Rational Zahlen besitzen einen Nenner und einen Zähler:

class Rational(numerator: Int, denominator: Int)

Wenn wir ein Objekt dieser Klasse erzeugen, dann haben wir leider das Problem, dass die REPL uns nur unverständliches Zeug ausgibt:

scala> new Rational(3, 5)
res42: Rational = Rational@1b1bcdee

Was wir hier sehen ist der Klassenname gefolgt von einem @-Zeichen und der hexadezimalen Schreibweise des HashCodes des Objekts.

Für alle die mit Java bzw. mit der JVM noch nichts zu tun hatten: Der HashCode gibt die Attribute eines Objektes umgewandelt zu einem Int zurück. Wie genau diese Umwandlung vonstatten geht soll jetzt irrelevant sein. Wichtig ist mehr, dass der HashCode für jedes Objekt möglichst einzigartig ist und sich nicht ändert wenn sich das Objekt auch nicht ändert.

Wir können die Ausgabe ändern indem wir die Methode toString überschreiben. Sie wird von der Methode, die für die Konsolenausgabe verantwortlich ist aufgerufen und legt also fest mit was sich unser Objekt auf der Konsole schmücken darf.

class Rational(numerator: Int, denominator: Int) {
  override def toString = numerator+"/"+denominator
}

scala> new Rational(3, 5)
res44: Rational = 3/5

Die Ausgabe sieht doch gleich viel besser aus. Das override gibt an, dass unsere Methode, eine gleichnamige Methode aus dem Vaterobjekt überschreibt. Aber was ist unser Vaterobjekt? Das ist Object, eine Klasse der JVM, die oberste Klasse überhaupt. Jedes Objekt in Scala – auch Any – ist vom Typ Object. In ihm sind Methoden wie toString und hashCode definiert.

Auf das nächste, auf das wir achten wollen, ist, ob unser Konstruktor auch mit korrekten Daten initialisiert wird. Was würde z.B. passieren wenn wir im Nenner eine null angeben würden? Den Bruch `3/0` gibt es ja überhaupt nicht. Um dies zu unterbinden stellt uns Scala die require-Methoden bereit:

class Rational(numerator: Int, denominator: Int) {
  require(denominator != 0)

  override def toString = numerator+"/"+denominator
}

scala> new Rational(3, 0)
java.lang.IllegalArgumentException: requirement failed

In Scala gehört jeglicher Code innerhalb des Klassenrumpfes zum Konstruktor und wird deshalb ausgeführt sobald der Konstruktor aufgerufen wird. Da dies immer dann der Fall ist wenn ein neues Objekt instanziiert wird kann unser `require` auch sofort überprüfen ob die Attribute korrekt gesetzt wurden.

Als nächstes wollen wir schauen, dass wir nur gekürzte Brüche aufnehmen. Ein Bruch wie 5/9 ist doch viel einfacher zu lesen wie 1265/2277, oder? Kürzen können wir unseren Bruch mit dem größten gemeinsamen Teiler (ggT) der beiden Zahlen:

class Rational(numerator: Int, denominator: Int) {
  require(denominator != 0)

  val g = gcd(numerator, denominator)
  val n = numerator / g
  val d = denominator / g

  override def toString = n+"/"+d

  def gcd(a: Int, b: Int): Int = {
    import scala.math.abs
    def loop(a: Int, b: Int): Int = if (b == 0) a else loop(b, a%b)
    loop(abs(a), abs(b))
  }
}

Bevor ich erkläre was hier alles neu dazugekommen ist wollen wir uns erst anschauen ob der Code das macht was er tun soll:

scala> new Rational(1265, 2277)
res50: Rational = 5/9

Da, er macht es, das ist schon mal toll. Nun zu den ganzen Erklärungen. Die Methode gcd prüft ob ein ggT existiert und gibt diesen zurück. Danach kürzen wir den Nenner und Zähler aus dem Konstruktor. Da die beiden Attribute ohne ein val oder var deklariert wurden, stehen sie außerhalb der Klasse nicht mehr zur Verfügung. Wir müssen uns also nicht mehr weiter darum kümmern. Die drei neuen Felder g, n und d dagegen wurden mit val deklariert und können nun von außerhalb der Klasse frei eingesehen werden. Das ist aber nicht weiter schlimm, da sie unveränderlich sind.

Anmerkung: Der Scala-Compiler erzeugt auch bei Feldern, die außerhalb des Konstruktors erzeugt wurden Getter und Setter. Wenn wir also den Feldnamen aufrufen greifen wir nicht direkt auf das Attribut sondern auf dessen Getter zu.

Geändert wurde noch die Methode toString. Sie gibt nun die neuen Werte zurück und nicht die, die im Konstruktor angegeben wurden. In der Methode gcd finden wir etwas, das wir so noch nicht gesehen haben: ein import-Statement in einer Methode. In Scala ist es prinzipiell möglich überall ein import-Statement anzugeben. Wenn wir es in der Methode machen, dann gilt der import auch nur für die Methode und nicht für außerhalb. Die Tailrekursive-Methode, die den ggT berechnet sollten wir bereits kennen.

Wir haben unserer Klasse jetzt unsere erste eigene Methode hinzugefügt, machen wir doch gleich weiter. Der Übersichtlichkeit wegen werde ich ab jetzt nur noch die Änderungen und Neuerungen an der Klasse Rational niederschreiben.

// in Rational
def add(r: Rational) = new Rational(n*r.d + r.n*d, d*r.d)

scala> val r1 = new Rational(2, 9)
r1: Rational = 2/9

scala> val r2 = new Rational(4, 9)
r2: Rational = 4/9

scala> r1.add(r2)
res51: Rational = 2/3

Die Methode add erzeugt einen neuen Bruch, anstatt die Felder des bereits bestehenden zu ändern. Das würde auch gar nicht gehen, da sie ja mit val deklariert wurden. Besonders toll anzusehen ist, dass unser neuer Bruch auch gleich gekürzt wird. Aber Moment einmal. Können wir in Scala denn nicht jedes Zeichen als Methodenname benutzen? Wieso verwenden wir nicht +? Würde doch viel besser aussehen.

// in Rational
def + (r: Rational) = new Rational(n*r.d + r.n*d, d*r.d)

scala> r1 + r2
res52: Rational = 2/3

Stimmt, sieht echt gut aus.

Ob man „Operatorüberladung“ jetzt gut findet oder nicht muss man selbst entscheiden. Ich finde es ein mächtiges Werkzeug, das in den richtigen Händen sehr zum Codeverständnis beitragen kann und werde es deshalb auch ausgiebig nutzen, wenn es angebracht ist. Falls das jemand anders sieht steht es ihm frei den Methoden „normale“ Namen zu geben.
Weiterhin möchte ich noch anmerken, dass man in Scala nicht von Operatorüberladung spricht, da es keine wirklichen Operatoren gibt. Jedes Zeichen (bis auf die von der Sprache reservierten) kann in einem Identifier genutzt werden, weshalb keine Unterscheidung zwischen den „normalen“ und den Sonderzeichen gibt.

Das können wir für die anderen Operationen wiederholen:

// in Rational
def - (r: Rational) = new Rational(n*r.d - r.n*d, d*r.d)
def * (r: Rational) = new Rational(n*r.n, d*r.d)
def / (r: Rational) = new Rational(n/r.n, d/r.d)

scala> r2 - r1
res53: Rational = 2/9

scala> r1 * r2
res54: Rational = 8/81

scala> r2 / r1
res55: Rational = 2/1

Wir haben es geschafft. Wir haben unsere erste eigene Klasse erstellt, mit der wir sogar etwas anfangen können. Für den Anfang ist das schon mal gar nicht schlecht. Aber es gibt noch so viel was wir alles in unsere Klasse einbauen können.

In Scala gibt es übrigens eine Operatorpriorität, obwohl es keine Operatoren im klassischen Sinne gibt. Bei Eingabe folgender Gleichung erhalten wir auch das korrekte Ergebnis:

scala> 4+3*2
res20: Int = 10

Scala parst den Ausdruck als

scala> 4+(3*2)
res21: Int = 10

Die Priorität einer Methode hängt vom ersten Zeichen der Methode ab. Wie die genaue Reihenfolge ist könnt ihr hier nachschlagen.

Hilfskonstruktoren

Was z.B. noch störend ist, ist das wir unsere Klasse immer mit einen Zähler und auch einem Nenner initialisieren müssen. Eine rationale Zahl muss aber nicht unbedingt ein Bruch sein, zumindest müssen wir sie nicht so schreiben. 123 ist leichter verständlich als 123/1. Um unsere Klasse mit nur einem Zähler zu initialisieren benötigen wir einen zweiten Konstruktor. In Scala spricht man bei mehrfach vorhandenen Konstruktoren von Hilfskonstruktoren (engl.: auxiliary constructor), da sie an sich nichts anderes machen als den Standardkonstruktor aufzurufen. Erzeugen können wir einen Hilfskonstruktor mit def this(…):

class Rational(numerator: Int, denominator: Int) {
  ...
  def this(numerator: Int) = this(numerator, 1)
  ...
}

scala> val r1 = new Rational(5)
r1: Rational = 5/1

Die erste Anweisung, die in einem Hilfskonstruktor getätigt werden muss ist der Aufruf eines anderes Konstruktors. Das kann der Standardkonstruktor aber auch ein anderen Hilfskonstruktor sein. Innerhalb dieser Konstruktoren haben wir also keine Möglichkeit die Parameter vor Objekterzeugung zu überprüfen. Dies sollte innerhalb des Klassenrumpfes oder im companion object erledigt werden und sonst nirgends. Wir werden später noch auf das companion object zurückkommen.
Neben einem Hilfskonstruktor besteht auch die Möglichkeit einfach ein benanntes Argument einzuführen:

class Rational(numerator: Int, denominator: Int = 1) { ... }

scala> val r1 = new Rational(5)
r1: Rational = 5/1

Das macht den Code ein wenig kürzer und übersichtlicher. Da wir mit unserem Hilfskonstruktor sowieso nicht viel anfangen können, empfehle ich, benannte Argumente vorzuziehen.

object

Wenn wir auf das Kapitel über Collections zurückbilcken fällt uns auf, dass wir unsere Objekte stets ohne das new-Schlüsselwort instanziiert haben. Tatsächlich können wir viele Collections gar nicht mit new instanziieren:

scala> new Seq(1, 2, 3)
:8: error: trait Seq is abstract; cannot be instantiated
              new Seq(1, 2, 3)
              ^

Wie kommt das? Was hier vor sich geht ist der Aufruf des companion objects immer dann wenn wir das new zur Objektinstanziierung auslassen.

Das companion object stellt eine Möglichkeit dar auf den Kontext einer Klasse zuzugreifen ohne von ihr ein Objekt instanziieren zu müssen. Wie das aussehen könnte sehen wir hier:

object Rational {
  def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
}

Wenn wir den Code in der REPL ausführen, bekommen wir erst mal eine Warnung:

scala> object Rational {
     |   def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
     | }
defined module Rational
warning: previously defined class Rational is not a companion to object Rational.
Companions must be defined together; you may wish to use :paste mode for this.

Ein object ist erst dann das companion object einer Klasse wenn es den gleichen Namen trägt und wenn es in der gleichen Datei definiert wurde wie die Klasse. Den gleichen Namen hat es, aber wir haben keine Datei in der sich die Klasse Rational befindet. Diese haben wir erst wenn wir unseren Code später mit scalac übersetzen lassen wollen. Also machen wir das was uns die REPL vorschlägt. Den Code im paste-Modus einfügen. Haben wir das getan sollten wir folgenden Code ausführen können:

scala> Rational(5)
res0: Rational = 5/1

Kommen wir also nun zu den Erklärungen was da alles vor sich geht. Das object-Schlüsselwort unterscheidet sich insofern vom class-Schlüsselwort als dass wir dort alle definierten Inhalte ohne Objektinstanziierung aufrufen dürfen:

object Test {
  def printHello() {
    println("hello")
  }
}

scala> Test.printHello
hello

Das erklärt also schon einmal weshalb wir das new bei Rational nicht mehr benötigen. Es erklärt aber noch nicht wie wir trotzdem ein Rational-Objekt erstellen können. Wenn wir uns die apply-Methode anschauen sehen wir, dass sie diese Objektinstanziierung für uns erledigt:

scala> Rational.apply(5)
res13: Rational = 5/1

Aber wieso können wir die Methode auch einfach auslassen? Die Antwort darauf lautet: Compiler-Magic. Die apply-Methode kann aufgerufen werden ohne dass man ihren Namen aufrufen muss. Der Compiler prüft ob eine apply-Methode zum Namensraum von class oder object gehört und ruft diese automatisch auf wenn nach dem Namen runde Klammern folgen. Ein Beispiel:

Wir haben:

AnyClass(1, 2, 3)
AnyClass()

Der Compiler macht daraus:

AnyClass.apply(1, 2, 3)
AnyClass.apply()

Jetzt wissen wir auch was passiert wenn wir eine Collection ohne new erzeugen:

scala> List(1, 2, 3)
res16: List[Int] = List(1, 2, 3)

scala> List.apply(1, 2, 3)
res17: List[Int] = List(1, 2, 3)

Das gleiche passiert übrigens wenn wir versuchen auf ein Element einer Collection zuzugreifen:

scala> res16(0)
res18: Int = 1

scala> res16.apply(0)
res19: Int = 1

Der einzige Unterschied ist, dass verschiedene apply-Methoden aufgerufen werden. Beim ersten Mal wird die apply-Methode des companion objects von List aufgerufen, beim zweiten Mal ist die apply-Methode der Klasse List dran. Neben der apply-Methode gibt es noch eine update- und eine unapply-Methode, die ebenfalls nicht explizit aufgerufen werden müssen. Der Compiler stellt uns für alle drei Methoden syntaktischen Zucker zur Verfügung. Was die beiden letztgenannten machen kann uns momentan egal sein, da wir sie noch nicht benötigen. Ich werde später darauf eingehen.

Falls ein object zu einem compnion object einer Klasse wird, wird die Klasse zur companion class des objects. Die beiden gehen eine Art Bindung ein, die es dem companion object erlaubt auf die nicht öffentlichen Felder der companion class zugreifen zu dürfen.

Zum Schluss noch den kompletten Code von Rational. Einzige Änderung ist, dass ich innerhalb der Klasse alle new entfernt habe:

object Rational {
  def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
}

class Rational(numerator: Int, denominator: Int = 1) {
  require(denominator != 0)

  val g = gcd(numerator, denominator)
  val n = numerator / g
  val d = denominator / g

  def + (r: Rational) = Rational(n*r.d + r.n*d, d*r.d)
  def - (r: Rational) = Rational(n*r.d - r.n*d, d*r.d)
  def * (r: Rational) = Rational(n*r.n, d*r.d)
  def / (r: Rational) = Rational(n/r.n, d/r.d)

  override def toString = n+"/"+d

  def gcd(a: Int, b: Int): Int = {
    import scala.math.abs
    def loop(a: Int, b: Int): Int = if (b == 0) a else loop(b, a%b)
    loop(abs(a), abs(b))
  }
}
Advertisements

4 comments so far

  1. Alexander Tyuryaev on

    zwischen sind und stehen muss ein Komma.

  2. Stefan on

    die / – Operation verletzt manchmal das require, z.B. bei:
    val r1 = new Rational(2, 9)
    val r2 = new Rational(4, 9)
    val r3= r1 + r2 // -> r3: Rational = 2/3
    r3 / r1 // -> java.lang.IllegalArgumentException: requirement failed
    // at scala.Predef$.require(Predef.scala:221)
    Grund: Int-Divisionen führen oft zu 0 (statt 0.333)
    Besser wäre also die Division zu definieren als:
    def / (r: Rational) = new Rational(n*r.d, d*r.n)
    dann gibts nur Multiplikationen

  3. Sascha Haßler on

    Hallo,

    der Link kurz vor dem Abschnitt gekennzeichnet mit „hier“ funktioniert nicht, da nach „https“ der Doppelpunkt fehlt. 😉

    Danke für die Arbeit!
    P.s: Falls Interesse besteht, ich bastel mir gerade eine Latex Version..


Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: