Teil 10: Packages, Imports und Sichtbarkeiten

Wir haben jetzt zwar schon einen Teil von Scalas objektorientierten Fähigkeiten kennen gelernt, aber noch fehlen uns noch ein paar Möglichkeiten unseren Code vernünftig zu strukturieren. Scala bietet ein sehr mächtiges Modul- und Packaging-System an, das nur wenige Wünsche offen lassen sollte.

Für diesen Teil des Tutorials werde ich von der REPL auf scalac umsteigen. Die REPL unterstützt das interpretieren von modularisiertem Code nicht, weshalb wir gar keine andere Möglichkeit haben als zum Compiler zu wechseln. Das ist aber nicht schlimm, lernen wir so doch alle Eigenschaften von Scala kennen. Wenn wir nur auf Interpreterbasis arbeiten, müssen wir neben einem fehlenden Modulsystem leider auch noch mit anderen Einschränkungen leben, was uns jetzt aber nicht weiter interessieren soll. Selbst wenn wir aber mit scalac arbeiten, hindert uns das nicht nebenher einzelne Codeteile mit der REPL zu übersetzen und sie so schnell und effizient auf ihr Funktionieren zu testen.

Es steht euch frei in nächster Zeit mit einem Editor und Konsole oder direkt mit einer IDE zu arbeiten. Unter den Links hab ich die bekanntesten IDEs aufgelistet, sucht euch euren Liebling aus.

Bevor wir loslegen noch ein paar Worte zu scalac. Der Compiler übersetzt Scala-Code in JVM-Bytecode oder CLR-CIL code. Die Unterstützung für Letzteren hat die Scala Community aber ein wenig aus den Augen verloren, weshalb es zu empfehlen ist auf der JVM zu arbeiten um alle Features auch benutzen zu können. Ich werde im Weiteren auch nicht auf die CLR eingehen und mich stattdessen nur auf die JVM konzentrieren.

Scala ist prinzipiell vollkommen kompatibel mit Java. Bis auf ein paar wenige Ausnahmen kann man in Scala alles machen was man in Java auch machen kann. Man kann Java-Bibliotheken ansprechen und mit vielen Frameworks und Tools arbeiten, die ursprünglich nur für Java gedacht waren.

Um ein Scala-Programm von der JVM starten lassen zu können benötigen wir einen Einsprungpunkt. Wie bei vielen Programmiersprachen üblich ist das in Scala eine Methode namens main:

object Main {
  def main(args: Array[String]) {
    println("Hello World")
  }
}

Wir müssen den Code in eine Datei mit der Endung scalaplatzieren. Der Name der Datei ist egal, ich empfehle aber sie so zu nennen wie die Klasse, die die Datei beinhaltet.

Übersetzen können wir den Code ganz einfach mit

scalac <name_of_file>

und ausführen mit

scala <name_of_object>

Ein Beispiel:

$ scalac Main.scala
$ scala Main
Hello World

Wie ihr beim Ausführen von scalac sicherlich merkt dauert das einfach nur ewig. Das liegt wieder daran, dass zuerst die JVM gestartet werden muss. Um die Kompilierungsgeschwindigkeit zu erhöhen empfiehlt sich beim Arbeiten auf der Konsole der Einsatz von fsc, dem Fast Scala Compiler:

$ fsc Main.scala
$ scala Main
Hello World

Bei erneuter Kompilierung erhalten wir deutlich schneller ein Ergebnis.

Wenn wir den Code mit scala ausführen wollen ist es essentiell wichtig, dass wir den Namen des object eingeben, das die main-Methode beinhaltet. Die Dateiendung class darf nicht angegeben werden. Unabhängig vom Namen der Quelldatei generiert scalac Dateien mit Namen der benötigten Klassen. Je nach dem wie viel Code wir schreiben kann es also schon mal sein, dass auf eine Scala-Datei dutzende Bytecode-Dateien kommen.

Seit Scala 2.9 wird noch eine weitere Möglichkeit unterstützt Scala-Code mit scalac zu starten:

object Main extends App {
  println("Hello World")
}

Hier erbt unser object vom object App, das eine main-Methode beinhaltet. Falls wir nicht auf die main-Methode angewiesen sind erlaubt uns die Erweiterung von App den auszuführenden Code direkt in den Rumpf des object zu schreiben. Aber kommen wir nun zum eigentlichen Inhalt des Artikels.

Imports

Das import-Schlüsselwort haben wir schon kennen gelernt. Wir können es überall im Code platzieren, auch innerhalb von Klassen und Methoden und so den Wirkungsbereich des Imports einschränken. Wollen wir einen Import in der kompletten Datei erlauben, müssen wir sie direkt zu Beginn der Datei aufschreiben.

import scala.math.sqrt

object Main extends App {
  println(sqrt(1234))
}

Die einfachste import-Anweisung erstellt man, indem man einfach den Namen der zu importierenden Einheit angibt. Wollen wir mehrere Dinge aus dem gleichen Package importieren stellt uns Scala drei Schreibweisen zur Verfügung:

// 1
import scala.math.sqrt
import scala.math.pow

// 2
import scala.math.sqrt, scala.math.pow

// 3
import scala.math.{ sqrt, pow }

Um uns Schreibarbeit zu ersparen dürfen wir Importe mit einem Komma trennen oder sie mit geschweiften Klammern gruppieren. Die letzte Möglichkeit hat den Vorteil, dass wir bei gleichen Packagenamen nicht mehr den kompletten Pfad angeben müssen, sondern nur noch die zu importierenden Einheit. Wir haben auch die Möglichkeit alle Inhalt eines Package zu importieren:

import scala.math._

Der Unterstrich dient hier als Wildcard-Symbol. Gefällt uns der Name eines importierten Inhalts nicht oder treten Namenskonflikte auf können wir sogar temporäre Umbenennungen vornehmen:

import scala.math.{ sqrt => squareRoot }

Das =>-Symbol erlaubt uns einen anderen Namen für den Import auszuwählen. Innerhalb des Sichtbereichs des Imports können wir fortan die Methode sqrt nur noch mit dem Identifier squareRoot ansprechen. Zu beachten ist noch, dass die Umbenennung zwingend in geschweiften Klammern stehen muss, wir dürfen aber innerhalb dieses Blocks beliebig viele Umbenennungen vornehmen, wir müssen sie nur wieder mit einem Komma trennen:

import scala.math.{ sqrt => squareRoot, pow => mathpow }

Wir können aber nicht nur etwas importieren, wir können den Import auch wieder aufheben. Das macht vor allem dann Sinn wenn wir alle Inhalte eines Package mit dem Unterstrich importieren aber einzelne Member eben nicht haben wollen:

import java.lang.reflect._
object Main {
  def main(args: Array[String]) {}
}

Dieser Code würde nicht kompilieren. Warum? Die Reflection-Library von Java besitzt ebenfalls eine Klasse Array, die die Scala Klasse überschreibt. Es zählt immer nur der Name des zuletzt importierten Members und da scala.Array vor dem Java Pendant importiert wird können wir nicht mehr darauf zugreifen. Da java.lang.reflect.Array nicht parametisiert ist bekommen wir vom Compiler eine Fehlermeldung – falls es parametisiert wäre würde der Code anstandslos kompilieren. Er würde aber nicht ausgeführt werden können weil die main-Methode eine andere Signatur besitzt als von scala (dem Tool) erwartet.

Wir können nun scala.Array mit vollem Namen adressieren, oder aber wir importieren java.lang.reflect.Array gar nicht erst:

import java.lang.reflect.{ Array => _, _ }

Wenn wir bei einer Umbenennung einen Unterstrich verwenden heißt das, dass wir den Member fortan nicht mehr ansprechen können. Der zweite Unterstrich heißt weiterhin „importiere alles“.

Nun gibt es noch ein paar Sachen über Imports, über die wir Bescheid wissen sollten. So ist es möglich nicht nur Member, sondern auch Packages zu importieren:

scala> import scala.collection.mutable
import scala.collection.mutable

scala> val xs = mutable.Buffer(1, 2, 3)
xs: scala.collection.mutable.Buffer[Int] = ArrayBuffer(1, 2, 3)

scala> xs += 4
res10: xs.type = ArrayBuffer(1, 2, 3, 4)

Dies ermöglicht uns einen Typ direkt als veränderlich zu kennzeichnen, ohne dass wir ihn umbenennen (z.B. in MutableBuffer) oder beim kompletten Package-Namen nennen müssten.

Wissenswert ist auch noch, dass alle Member aus den Packages scala, scala.Predef und java.lang automatisch importiert werden und zwar als die ersten Imports überhaupt. Sie müssen deshalb nicht extra importiert werden und da scala ein Package ist, das importiert wird, müssen wir es auch nicht unbedingt angeben:

import collection.mutable
import math.sqrt

Besonders wichtig ist es, zu wissen, dass in Scala alle Importe relativ und nicht absolut sind. Das bedeutet, dass wir einen Import abhängig vom Vorherigen machen:

// 1
import scala.collection.{ mutable, immutable }
import mutable.Buffer
import immutable.SortedSet

// 2
import scala.collection.JavaConversions
import JavaConversions._

Wie zu erkennen, genügt es, einen Member eines Packages oder einer Klasse direkt anzusprechen wenn wir zuvor bereits die nötigen Importe getätigt haben. Das ist vor allem dann nützlich wenn wir auf mehrere Packages unterschiedlichen Namens zugreifen möchten ohne alle mit vollem Namen zu adressieren (wie bei 1 zu sehen). Ebenso nützlich ist das wenn wir nicht nur eine Klasse/ein Object importieren möchten, sondern auch dessen Member (wie bei 2). Die Nützlichkeit dieser beiden Featuers verliert mit besserer IDE-Unterstützung ein wenig an Bedeutung, da die IDE die Importe für uns erledigen kann. Dennoch wird es immer den ein oder anderen Anwendungsfall geben bei dem es nützlich ist auf dieses Feature zurückgreifen zu können.

Sobald eine import-Anweisung innerhalb eines Packages nicht mehr gefunden werden kann wird wieder vom root-Pfad ausgegangen und von dort importiert. Dies geschieht beim Wechsel zwischen 1 und 2. Manchmal kann es aber zu Konflikten kommen wenn ein Unterpackage gleich heißt wie ein höher liegendes:

package de {
  package bar {
    package de {
      package num {}
    }
  }
  package foo {}
}

import de.bar.de
import de.num
import de.foo // error

Der letzte Import würde nicht erkannt werden, da der Compiler versucht das Package de.foo über de.bar.de.foo zu finden was fehl schlägt. Hierfür bietet Scala das _root_-Package welches eine absolute Adressierung erlaubt:

import _root_.de.foo

Um diesen Konflikt zu vermeiden empfiehlt es sich, allen Packages möglichst einzigartige Namen zu geben. Packagenamen wie scala oder math sollte man grundsätzlich nicht verwenden, da die Standardpackages so heißen und es deshalb nur Probleme geben kann.

Packages

Wir haben nun kennen gelernt wie wir Packages importieren, schauen wir uns also an wie wir eigene erstellen können.

package test.hello.world

Die Package-Anweisung muss die erste Anweisung einer Datei sein, sie kommt also noch vor den Imports. Eingeleitet wird eine Package-Deklaration mit dem Schlüsselwort package. Danach folgt das oder folgen die Packages, getrennt durch einen Punkt. Im obigem Beispiel würden wir die Packages test, hello und world erstellen. Dabei gilt, dass die Packages ineinander verschachtelt sind. D.h. dass sich hello innerhalb von test befindet und world befindet sich innerhalb von hello. Wollen wir auf ein Member in world zugreifen müssen wir es über den kompletten Pfad ansprechen:

import test.hello.world

Der Scala-Compiler mappt die Package-Struktur bei der Kompilierung auf das Verzeichnissystem, was bedeutet, dass er die Verzeichnishierarchie test/hello/world erzeugen und alle Member von world dort ablegen würde. Die Quelldateien selbst müssen sich dagegen nicht ebenso zwingend in der selben Hierarchie befinden. Es wäre möglich alle Dateien in ein Verzeichnis zu platzieren und dem Compiler die Erstellung der Pfade zu überlassen. Da der Compiler aber nur die Pfade für unseren Bytecode erstellt, würden wir damit erhebliche Übersichtlichkeitsmängel hinnehmen, besonders bei größeren Programmen. Ich empfehle also für jedes erstellte Package auch ein eigenes Verzeichnis anzulegen. Wenn wir mit einer IDE arbeiten geschieht dies sowieso automatisch – wir brauchen uns darum also gar nicht kümmern.

Neben dem Trennen von Packages über die Punktnotation können wir die Packages auch mit geschweiften Klammern ineinander verschachteln:

package test {
  package hello {
    package world {
      package bar {}
    }
  }
  package foo {}
}

Die beiden Notationen unterscheiden sich aber ein wenig bezüglich automatischer Imports. In erstgenannter Notation wird ein Package-Member ins Package test.hello.world integriert und alle Member aus world werden automatisch importiert. Bei der verschachtelten Notation sieht das mit der Notation allerdings ein wenig anders aus. Hier werden alle Member aus allen explizit angegebenen Packages importiert.

Wir können also folgenden Code ohne Probleme übersetzen:

package test {
  package hello {
    package world {
      package bar {
        class A {
          new B // B is implicitly imported
        }
      }
      class B
    }
  }
  package foo {}
}

Dieser dagegen würde Fehler verursachen, da der Compiler die Klasse B nicht auflösen kann:

package test {}
package test.foo {}
package test.hello {}
package test.hello.world {
  class B
}
package test.hello.world.bar {
  class A {
    new B // error, no implicit import
  }
}

Erst durch den Import von B können wir den Fehler beheben:

class A {
  import test.hello.world._
  new B
}

Das gleiche macht auch der Compiler: einen impliziten Import aller Member aller höher liegenden Packages. Wir können auch mehrerer solcher Package-Anweisungen zu Beginn unserer Datei schreiben damit wir benötigte Imports nicht mehr explizit angeben müssen:

package test.hello
package world

// implicit import of test.hello._
// implicit import of test.hello.world._
// members of test are NOT visible

// class A is in package test.hello.world
class A

Die Frage ist jetzt welche Schreibweise man bevorzugen sollte? Die verschachtelte Schreibweise hat den Nachteil, dass sie mehr Schreibarbeit erfordert und die Übersichtlichkeit stören kann wenn mehrere Klassen, in einer Datei zusammengefasst werden. Wenn die jeweiligen Klassen nicht direkt zusammen gehören macht es mehr Sinn sie auf verschiedene Dateien zu verteilen um die Übersichtlichkeit zu bewahren. Mehrfache Package-Anweisungen ohne verschachtelte Notation machen aber dann Sinn wenn auf Member einer äußeren Package-Schicht zugegriffen werden muss und man keine Imports angeben möchte.

Scala unterstützt noch sogenannte Package Objects. Diese ermöglichen den Zugriff auf die Member eines Objects indem man einfach nur das Package importiert:

// file test/hello/package.scala
package test.hello
package object world {
  def printHelloWorld() { println("hello world") }
}

// file any/test/Test.scala
package any.test
import test.hello.world._
object Test extends App {
  printHelloWorld()
}

Im Object Test können wir über einen ganz normal aussehenden Package-Import, die Methode printHelloWorld importieren. Hätten wir die Methode innerhalb des Packages test.hello.world in einem weiteren Object platziert, müssten wir, zusätzlich zu den Membern aus dem Package, auch noch das Object importieren, was mehr Arbeitsaufwand bedeuten würde (und wir wollen ja nicht arbeiten ;)).

Zur Übersichtlichkeit empfehle ich, ein Package Object immer mit package.scala zu benennen, dann findet man es auch gleich wenn man es mal suchen sollte.

Besonders zu beachten ist, dass ein Package Object eine weitere Package-Anweisung ist, d.h. wir müssen auf die richtige Packageumgebung achten. In obigen Beispiel liegt das package in test.hello und nicht in test.hello.world! Würden wir es in Letzterem platzieren müssten wir es über test.hello.world.world._ importieren, was nicht der Sinn der Sache wäre.

Anmerkung: Mit der Anweisung

import test.hello.world.`package`._

ist es möglich auch nur das Package Object zu importieren (Backticks beachten). Das dürften wir aber wohl nie wollen, da es ja gerade der Sinn eines Package Objects ist nicht explizit importiert zu werden.

Sichtbarkeiten

Kommen wir zum letzten Teil dieses Kapitels, den Sichtbarkeiten oder auch Zugriffsmodifizierern. In Scala gibt es drei mögliche Arten von Sichtbarketien: öffentlich, privat und geschützt. Die erste Variante ist der Standard und erfordert deshalb keine Anweisung. Jeder Member, egal ob Object, Klasse, Methode oder Variable, die ohne einen Zugriffsmodifizierer deklariert wurden, sind von Haus aus öffentlich. Wir können sie dann von jedem beliebigen Package aus ohne Einschränkungen aufrufen – wir müssen sie nur importieren. Die private Sichtbarkeitsstufe wird durch das Schlüsselwort private erstellt. Das bedeutet, dass der deklarierte Member fortan nur noch innerhalb des deklarierten Bereichs sichtbar ist.

class A {
  private def x = ...
  def y = ...
}

Außerhalb von A könnten wir auf y zugreifen nicht aber auf x. Einzige Ausnahme bildet das Companion Object von A. Existiert eines, kann es auf die privaten Member von A zugreifen. Wird eine Klasse oder ein Object mit private gekennzeichnet, dann ist es nur innerhalb des jeweiligen Packages sichtbar.

Der letzte Zugriffsmodifizierer, protected, besagt, dass ein Member nur innerhalb des deklarierten Bereichs und aller erbenden Bereiche sichtbar ist.

class A {
  protected def x = ...
}
class B extends A {
  // x is accessible
}

Alle erbenden Klassen genießen das Privileg auf die mit protected markierten Member zugreifen zu dürfen. Das Companion Object bildet wie immer eine Ausnahme. In diesem Fall dürfen sogar zwei Companion Objects auf xzugreifen, das von A und das von B:

class A {
  protected def x = 0
}

object A {
  new A().x
}

class B extends A {
  x
}

object B {
  new B().x
}

Eine Klasse oder ein Object mit protected zu kennzeichnen macht keinen Sinn, es sei denn es wurde innerhalb einer anderen Klasse/eines anderen Objects deklariert.

protected class A // senseless, same as private
class B {
  protecded class C // ok
}

Innere Klassen oder Objekt werden ganz normal mitvererbt wenn sie mit protected gekennzeichnet wurden.

Sollten die drei Modifizierer mal nicht ausreichen gibt es noch eine sogenannte Package-Sichtbarkeit. Diese können wir erreichen indem wir hinter ein private oder ein protected in eckigen Klammern einen Package-Namen schreiben:

package test.hello.world
private[world] class A
// both means the same
private class A

Das Angeben eines Package-Namens bedeutet, dass der Member innerhalb des Packages und aller Unterpackages sichtbar ist. In obigem Beispiel kann man die Package-Angabe weglassen, da sie für das gleiche Package gilt in der die Klasse sowieso schon sichtbar ist. Das ganze ist dann nützlich wenn wir ein Member einem höher liegenden Package sichtbar machen wollen:

package test.hello.world
private[hello] class A

Auf die Klasse könnte jetzt zusätzlich noch von allen Membern aus dem Package hello und all dessen Unterpackages zugegriffen werden. Package-Angaben funktionieren sowohl bei private als auch bei protected, nicht aber bei öffentlichen Membern. Dort macht es auch keinen Sinn, da sowieso schon jeder darauf zugreifen kann.

Als Package-Angabe darf auch die this-Referenz, angegeben werden. Sie besagt dass auf einen Member nur vom gleichen Objeckt aus zugegriffen werden darf.

class A {
  private[this] def x = 0

  def y = new A().x // error
}

object A {
  new A().x // error
}

Dies stellt eine Möglichkeit dar ein Feld selbst vor dem Companion Object abzuschotten. Es macht aber wohl nicht viel Sinn dem Companion Object den Zugriff zu verbieten, wichtiger dürfte es in Verbindung mit veränderlichen Daten werden:

class IntContainer(e: Int) {
  private var elem = e

  def incSame() = {
    elem += 1
    this
  }

  def incNew() = {
    val c = new IntContainer(elem)
    c.elem += 1
    c
  }
}

Außerhalb der Klasse gibt es keine Möglichkeit auf elem zuzugreifen. Innerhalb von incNew könnten aber Leichtsinnsfehler beim Ändern des Feldes passieren. Würden wir das Feld mit private[this] sichern würde der Code nicht mehr kompilieren.
Das Beispiel mag ein wenig an den Haaren herbeigezogen sein, wenn wir aber irgendwann mit Varianzen arbeiten werden wir ein Beispiel kennen lernen bei dem es Sinn macht Attribute einer Klasse nach außen hin komplett abzuschotten.

Rational goes private

Es wird nun Zeit unser eben erlerntes Wissen auf unsere Klasse Rational anzuwenden. Stecken wir die Klasse erst in ein Package, z.B.:

package extendedmath
object Rational ...
class Rational ...

Als nächsten wollen wir alle Member nach außen hin abschotten, auf die man von außen nicht zugreifen können soll. Das wären die Methode gcd und das Feld g:

// in class Rational
private val g = gcd(numerator, denominator)
private def gcd(a: Int, b: Int): Int = ...

Weiterhin könnte es nützlich sein wenn wir die Erzeugung eines Rational-Objektes direkt über den Konstruktor verbieten würden. Wir setzten also auch ihn auf private:

class Rational private(numerator: Int, denominator: Int) ...

Da wir von außen nun nicht mehr auf den Konstruktor zugreifen können, können wir auch den optionalen Parameter wieder entfernen.

scala> Rational(7, 12)
res3: Rational = 7/12

scala> res3.g
<console>:13: error: value g in class Rational cannot be accessed in Rational
              res3.g
                   ^

scala> new Rational(5)
<console>:13: error: constructor Rational in class Rational cannot be accessed in object $iw
              new Rational(5)
              ^

Sieht doch gut aus, oder? Da es nun nicht mehr möglich ist direkt auf den Konstruktor zuzugreifen können wir die Überprüfung des Nenners und die Kürzung des Bruchs auch in das Companion Object verschieben. Das sieht dann so aus:

object Rational {

  def apply(numerator: Int, denominator: Int = 1) = {
    require(denominator != 0)

    val g = gcd(numerator, denominator)
    new Rational(numerator / g, denominator / g)
  }

  private 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))
  }
}

class Rational private(val n: Int, val d: Int) {

  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
}

Und zum testen ob es funktioniert:

scala> Rational(1599, 2091)
res9: Rational = 13/17

scala> res9 + Rational(5, 7)
res10: Rational = 176/119

scala> Rational(3) + Rational(5) * Rational(4)
res11: Rational = 23/1

Sogar die Operatorpriorität wurde korrekt erkannt, wieso habe ich hier erklärt. Ob es nun sinnvoll ist, Überprüfungen von Attributen und etwaige Initialisierungsroutinen innerhalb eines Klassenrumpfes oder im Companion Object zu machen kommt wohl ganz auf den Anwendungsfall an. Bei uns würde beides gehen und keine der Vorgehensweisen hätte einen entscheidenden Vor- oder Nachteil.

Advertisements

1 comment so far

  1. Lumiha on

    Hallo Simon,

    finde ich wieder ein sehr gelungen Tutorial-Teil. Kann man auch immer wieder mal referenzieren.

    Gut finde ich auch, so details, wie das package object referenziert werden kann oder wie man einen Konstruktor privat macht.

    Danke für die Mühe,

    Lutz


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: