Default-Methoden, Teil 2, Default-Methoden zur Entwicklung von Bausteinen nutzen

Bevor wir zu nächsten Punkt kommen, müssen wir noch einmal inne halten und uns fragen, was denn das Kernkonzept der objektorientierten Programmierung ist. Wohl ohne zu Zögern können wir Klassen und Kapselung nennen. Klassen und Klassenbeziehungen das Gerüst jedes Java-Programms. Schauen wir uns Vererbung noch einmal genauer an, so wissen wir, das Unterklassen Spezialisierungen sind, und das Liskovsche Substitutionsprinzip gilt: Falls ein Typ gefordert ist, können wir auch einen Untertyp übergeben. So sollte perfekte Vererbung aussehen: Eine Unterklasse sollte das Verhalten spezialisieren, aber nicht einfach von einer Klasse erben, weil sie nützliche Funktionalität hat. Aber warum eigentlich nicht? Ein Problem ist, das uns die Einfachvererbung nur eine einzige Oberklasse erlaubt. Wenn eine Klasse so etwas Nützliches wie Logging anbietet, und unsere Klasse davon erbt, kann sie nicht gleichzeitig von einer anderen Klasse erben, um zum Beispiel Zustände in Konfigurationsdaten festzuhalten. Das Problem bei der „Funktionalitätsvererbung“ ist also, dass wir uns nur einmal festlegen können. Wenn eine Klasse eine gewisse Funktionalität einfach braucht, woher soll sie denn dann kommen, wenn nicht aus der Oberklasse? Eigentlich gibt es hier nur eine naheliegende Variante: Die Klasse greift auf andere Objekte zurück per Delegation. Das ist interessant, aber auch nicht optimal, insbesondere gilt dann nicht die ist-eine-Art-von-Beziehung. Falls das nicht gewünscht ist, ist das in Ordnung, doch wenn über diesen Typ eine Abstraktion läuft, ist das ungünstig.

Ein Dilemma. Gut wäre eine Technik, die einen Programmbaustein in eine Klasse setzen kann. Im Grunde so etwas wie Mehrfachvererbung, aber doch anders, weil die Bausteine nicht als komplette Typen auftreten – der Baustein selbst ist nur ein Implantat und alleine uninteressant. Auch ein Objekt kann von diesem Baustein-Typ nicht erzeugt werden.

Am ehesten sind die Bausteine mit abstrakten Klassen vergleichbar, doch das wären Klassen und Nutzer könnten nur einmal von diesem Baustein erben. Mit Java 8 gibt es aber eine ganz neue Möglichkeit, und zwar mit den erweiterten Schnittstelle: Sie bilden die Bausteine, von denen Klassen Funktionalität bekommen können. Andere Programmiersprachen bieten so etwas Ähnliches und das Konzept wird dort Mixin oder Trait genannt.[1] Diese Bausteine sind nützlich, denn so lässt sich ein Algorithmus in eine extra Compilationseinheit setzen und leichter wiederverwenden. Ein Beispiel.

Nehmen wir zwei erweiterte Schnittelle an: PersistentPreference und Logged. Die erste erweiterte Schnittstelle soll mit store() Schlüssel/Werte-Paare in die zentrale Konfiguration schreiben und get() soll sie auslesen:

import java.util.prefs.Preferences;

interface PersistentPreference {

default void store( String key, String value ) {

  Preferences.userRoot().put( key, value );

}

default String get( String key ) {

  return Preferences.userRoot().get( key, "" );

}

}

Die zweite erweiterte Schnittstelle ist Logged und bietet drei kompakte Logger-Methoden:

import java.util.logging.*;

interface Logged {

default void error( String message ) {

  Logger.getLogger( getClass().getName() ).log( Level.SEVERE, message );

}

default void warn( String message ) {

  Logger.getLogger( getClass().getName() ).log( Level.WARNING, message );

}

default void info( String message ) {

  Logger.getLogger( getClass().getName() ).log( Level.INFO, message );

}

}

Eine Klasse kann diese Bausteine nun einbauen:

class Player implements PersistentPreference, Logged {

// …

}

Die Methoden sind nun Teil vom Player und können auch von Unterklassen überschrieben werden. Als Aufgabe für den Leser bleibt, die Implementierung von store() im Player zu verändern, dass der Schlüssel immer mit „player.“ beginnt. Die Frage, die Leser beantworten sollten ist, ob store() von Player auf das store() von der erweiterten Schnittstelle zugreifen kann.

Default-Methoden weiter gedacht

Für diese Bausteine, also die erweiterten Schnittstellen, gibt es viele Anwendungsfälle. Da die Java- Bibliothek schon an die 20 Jahre als ist, würden heute einige Typen anders aussehen. Dass sich Objekte mit equals() vergleichen lassen können, könnte heute zum Beispiel in einer erweiterten Schnittstelle stehen, etwa so: interface Equals { boolean equals( Object that ) default { return this == that; } }. So müsste java.lang.Object die Methode nicht für alle vorschreiben, wobei das sicherlich jetzt kein Nachteil ist. Natürlich gilt das gleiche auf für die hashCode()-Methode, die heutzutage aus einer erweiterten Schnittstelle Hashable stammen könnte.

Und java.lang.Number ist ein weiters Beispiel. Die abstrakte Basisklasse für Werte-repräsentierende Objekte deklariert die abstrakten Methoden doubleValue(), floatValue(), intValue(), longValue() und die konkreten Methoden byteValue() und shortValue(). Bisher erben AtomicInteger, AtomicLong, BigDecimal, BigInteger, Byte, Double, Float, Integer, Long, Short von dieser Oberklasse. Auch diese Funktionalität ließe sich mit einer erweiterten Schnittstelle umsetzen.

Da Schnittstellen auch Generics haben können, werden Default-Methoden noch vielseitiger. Baustein können auch andere Bausteine erweitern, da eine Schnittstelle andere Schnittstellen extenden kann. Es ist dabei egal, ob die die Schnitten erweitert sind oder nicht.

Zustand in den Bausteinen?

Nicht jeder wünschenswerte Baustein ist mit erweiterten Schnittstellen möglich. Ein Grund ist, dass die Schnittstellen keinen Zustand einbringen können. Einen Baustein für einen Container können wir nicht so einfach implementieren, da ein Container Kinder verwaltet, und hierfür ist eine Objektvariable für den Zustand nötig. Schnittstellen haben nur statische Variablen und die sind für alle sichtbar und selbst wenn die Schnittstelle eine modifizierbare Datenstruktur referenzieren würde, würde jeder Nutzer des Container-Bausteins von den Veränderungen betroffen sein. Da es keinen Zustand gibt, existieren auch für Schnittstellen keine Konstruktoren und folglich auch nicht für solche Bausteine. Denn wo es keinen Zustand gibt, gib es nichts zu initialisieren. Wenn eine Default-Methode einen Zustand benötigt, müssen sie selbst diesen Zustand erfragen. Wie das geht zeigt folgendes Beispiel.

Repräsentiert eine Klasse eine Menge von Objekten, die sich sortieren lassen können, können wir einen Baustein Sortable mit einer Methode sort() realisieren. Allerdings muss die Implementierung irgendwie an die Daten kommen und hier kommt der Trick ins Spiel: Zwar ist sort() eine Default-Methode, doch die erweiterte Schnittstelle besitzt Methoden, die die Klasse implementieren muss, die dem Sortierer die Daten geben. Im Quellcode sieht das so aus:

Teil 1:

import java.util.*;

interface Sortable<T extends Comparable> {

  T[] getValues();

  void setValues( T[] values );

  default void sort() {

    T[] values = getValues();

    Arrays.sort( values );

    setValues( values );

  };

}

Damit sort() an die Daten kommt, erwartet Sortable von den implementieren Klassen eine Methode getValues(). Und damit die Daten nach dem Sortieren wieder zurückgeschrieben werden können, eine zweite Methode setValues(…). Der Clou ist, das die Klasse, die später Sortable realisieren wird, mit den beiden Methoden dem Sortierer Zugriff auf den Daten gewährt – allerdings auch jedem anderem Stück Code da die Methoden öffentlich sind. Da bleibt ein Geschmäckle.

Ein Nutzer vor Sortable soll RandomValues sein; die Klasse erzeugt intern Zufallszahlen.

Teil 2:

class RandomValues implements Sortable<Integer>

{

  private List<Integer> values = new ArrayList<>();

  public RandomValues() {

    Random r = new Random();

    for ( int i = r.nextInt( 20 ) + 1; i > 0; i– )

    values.add( r.nextInt(10000) );

  }

  @Override public Integer[] getValues() {

    return values.toArray( new Integer[values.size()] );

  }

  @Override public void setValues( Integer[] values ) {

    this.values.clear();

   Collections.addAll( this.values, values );

  }

}

Damit sind die Typen vorbereitet und ein Demo schließt das Beispiel ab:

Teil 3:

public class SortableDemo {

  public static void main( String[] args ) {

    RandomValues r = new RandomValues();

    System.out.println( Arrays.toString( r.getValues() ) );

    r.sort();

    System.out.println( Arrays.toString( r.getValues() ) );

  }

}

Aufgerufen kommt auf die Konsole zum Beispiel:

[2732, 4568, 4708, 4302, 4315, 5946, 2004]

[2004, 2732, 4302, 4315, 4568, 4708, 5946]

So interessant diese Möglichkeit auch ist, ein Problem wurde schon angesprochen: Jede Methode in einer Schnittstelle ist public, ob sie nun eine abstrakte oder Default-Methode ist. Es wäre schön, wenn die Datenzugriffsmethoden nicht öffentlich sein würden, aber das geht nicht.

Wo wir gerade bei der Sichtbarkeit sind. Gibt es im Default-Code Code-Duplizierung, so kann der gemeinsame Code bisher nicht in private Methoden ausgelagert werden, da es private Operationen in Schnittstellen nicht gibt. Allerdings läuft gerade ein Test, ob so etwas eingeführt werden soll.

Warnung!

Natürlich lässt sich mit Rumgetrickse ein Speicherort finden, der Exemplarzustände speichert. Es lässt sich zum Beispiel in der Schnittstelle ein Assoziativspeicher referenzieren, der eine this-Instanz mit einem Objekt assoziiert. Ein Container-Baustein, der mit add() Objekte in eine Liste setzt und sie mit iterable() herausgibt, könnte so aussehen:

interface ListContainer<T> {

Map<Object, List<Object>> $ = new HashMap<>();

default void add( T e ) {

  if ( ! $.containsKey( this ) )

   $.put( this, new ArrayList<Object>() );

$.get( this ).add( e );

}

default public Iterable<T> iterable() {

  if ( ! $.containsKey( this ) )

   return Collections.emptyList();

  return (Iterable<T>) $.get( this );

}

}

Nicht nur die öffentliche Konstante $ ist ein Problem, sondern auch, dass es ein großartiges doppeltes Speicherloch ist. Ein Exemplar der Klasse, die diese erweitert Schnittstelle nutzt, kann nicht so einfach entfernt werden, denn in der Sammlung ist noch eine Referenz auf das Objekt, die das Garbage Collection verhindert. Und selbst wenn dieses Objekt weg wäre, hätten wir noch all die referenzierten Kinder der Sammlung in der Map. Und das Problem ist nicht wirklich zu lösen, und hier müsste tief mit schwachen Referenzen in die Java-Voodoo-Kiste gegriffen werden. Alles in allem, keine gute Idee und Java-Chefentwickler Brian Goetz macht auch klar: „Please don’t encourage techniques like this. There are a zillion "clever" things you can do in Java, but shouldn’t. We knew it wouldn’t be long before someone suggested this, and we can’t stop you. But please, use your power for good, and not for evil. Teach people to do it right, not to abuse it.”[2] Daher: Es ist eine schöne Spielerei, aber Zustand sollte eine Aufgabe der abstrakten Basisklassen oder vom Delegate sein.

Zusammenfassung

Was wir in den letzten Beispielen gemacht haben war, ein Standardverhalten in Klassen einzubauen, ohne das dabei der Zugriff auf die einmalige Basisklasse nötig war und ohne das die Klasse an Hilfsklassen delegiert. In dieser Arbeitsweise können Unterklassen in jedem Fall die Methoden überschreiben und spezialisieren. Wie haben es also mit üblichen Klassen zu tun und mit erweiterten Schnittstellen, die nicht selbst eigenständige Entitäten bilden. In der Praxis wird es immer Fälle geben, in denen für eine Umsetzung eines Problems entweder eine abstrakte Klasse oder eine erweiterte Schnittstelle in Frage kommt. Wir sollten und dann noch einmal an die Unterschiede erinnern: Eine abstrakten Klasse kann Methoden aller Sichtbarkeiten haben und sie auch final setzen, sodass sie nicht mehr überschrieben werden können. Eine Schnittstelle dagegen ist mit puren virtuellen und öffentlichen Methoden darauf ausgelegt, dass eben die Implementierung überschrieben werden kann.


[1] Siehe etwa http://scg.unibe.ch/archive/papers/Scha02aTraitsPlusGlue2002.pdf.

[2] http://mail.openjdk.java.net/pipermail/lambda-dev/2012-July/005166.html

Ähnliche Beiträge

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert