Kleine Änderung, große Wirkung

Checkpoint Java: Typed Constructors räumen API-Implementierungen auf

Sven Ruppert

© Shutterstock / Suttha Burawonk

 

In Java ist es vorgesehen, Abstraktionen mit Interfaces vorzunehmen. Schon in einer der ersten Vorlesungen zum Thema Java ist das ein Thema. In meinen Projekten sehen ich immer wieder, das interne Implementierungsklassen zwei oder mehr Interfaces eines API implementieren. Das ist an sich nichts schlimmes, kann aber zu unschönen Konstrukten führen. Abhilfe schaffen Typed Constructors.

Gehen wir davon aus, dass wir zwei Interfaces haben: ServiceA und ServiceB.

public interface Service_A {
  String doWork_A();
}

public interface Service_B {
  String doWork_B();
}

In der Implementierung des Frameworks gibt es nun eine Klasse ServiceInternImpl, die diese beiden Interfaces implementiert.

public class ServiceImplInternal implements Service_A, Service_B {
  @Override
  public String doWork_A() {
    return "A";
  }

  @Override
  public String doWork_B() {
    return "B";
  }
}

Innerhalb des Frameworks ist das nicht von Bedeutung. An gegebener Stelle wird diese Implementierung intern verwendet. Nur außerhalb möchte man nicht auf diese Implementierung expliziet casten, da nicht sichergestellt ist, dass die Implementierung auf Dauer so bestehen bleibt. Es gibt also kein Interface, das von beiden Interfaces erbt und damit diese zusammenführt. Nun soll es eine Methode geben, die einen Daten-Holder erzeugt, basierend auf Methodenrückgabewerten der Methoden beider Interfaces.

public static class DataHolder_A {
    private String a; //doWork_A()
    private String b; //doWork_B()
    
    public DataHolder_A(final String a, final String b) {
      this.a = a;
      this.b = b;
    }
  }

Das führt dann zu Transformationen, wie dieses Beispiel zeigt:

final List<Service_A> service_A_list = getServices(); //auf ein Interface reduziert

final List<DataHolder_A> list = service_A_list.stream()
        .map(a -> {
          final Service_B b = (Service_B) a;
          return new DataHolder_A(a.doWork_A(), b.doWork_B());
        })
        .collect(Collectors.toList());

Das ist nicht sonderlich elegant. Was kann man also machen? Der erste Schritt ist, dass man z. B. die Liste transformiert, die man vom Framework bekommt. Das lässt sich als eine map()-Stufe bei der Stream-Verarbeitung realisieren.

    final List<DataHolder_A> collect = service_A_list
        .stream()
        .map(e -> (Service_A & Service_B) e)
        .map(e -> new DataHolder_A(e.doWork_A(), e.doWork_B()))
        .collect(Collectors.toList());

Hier kann man ausnutzen, dass man in einem Schritt auf eine beliebige Anzahl Interfaces casten kann: e -> (Service_A & Service_B) e. Aber warum einen Cast durchführen?

Sehen wir uns mal den DataHolder_A an. Hier ist das Ziel, die Ergebnisse beide Methoden zu halten. Von Methoden ist bekannt, das man den Typ T in der Methode selbst deklarieren kann. Wenden wir dieses auf unsere Transformation an, bekommen wir eine Methode mit der folgenden Signatur:

  private <T extends Service_A & Service_B> List<T> transform(List<Service_A> services) {
    return services.stream()
        .map(e -> (Service_A & Service_B) e)
        .map(value -> (T) value)
        .collect(Collectors.toList());
}

Wichtig an dieser Stelle ist:

<T extends Service_A & Service_B> List<T>

Nun ist es nur noch ein kleiner Schritt. Wenn man in diesem Fall einen Konstruktor wie eine Methode versteht, kann man auch hier ein T definieren. Der Vorteil ist, dass dies nicht auf Klassenebene passieren muss. Ändern wir nun unseren DataHolder wie folgt:

public class DataHolder_B {

    private String a;
    private String b;

    public <T extends Service_A & Service_B> DataHolder_B(T input) {
      this.a = input.doWork_A();
      this.b = input.doWork_B();
    }
  }

Nun kann man die Transformation recht einfach formulieren.

    final List<Service_A> service_A_list = getServices(); //auf ein Interface reduziert   
    final List<DataHolder_B> collect = service_A_list
        .stream()
        .map(e -> (Service_A & Service_B) e)
        .map(DataHolder_B::new)
        .collect(Collectors.toList());

Wenn man das ein wenig formaler formuliert, bekommen wir das nachfolgende Beispiel. Hiermit ist auch sichergestellt, dass wir nie eine ungültige Kombination verwenden oder Implementierungsklassen außerhalb des Frameworks sichtbar werden. Sollte sich die Implementierung innerhalb des Frameworks in der Zusammensetzung der Interfaces verändern, wird dieses direkt beim kompilieren sichtbar.

public class Main {
  interface Service_A { String doWork_A(); }
  interface Service_B { String doWork_B(); }
  interface Service_C { String doWork_C(); }
  interface Service_D { String doWork_D(); }
  
  public static class Impl_A implements Service_A , Service_B{
    public String doWork_A() { return "A"; }
    public String doWork_B() { return "B"; }
  }
  public static class Impl_B implements Service_C , Service_D{
    public String doWork_C() { return "C";}
    public String doWork_D() { return "D";}
  }

  public static class DataHolder{
    String a;
    String b;

    public DataHolder(final String a, final String b) {
      this.a = a;
      this.b = b;
    }

    public <T extends Service_A & Service_B> DataHolder(T value) {
      this.a = value.doWork_A();
      this.b = value.doWork_B();
    }
    public <T extends Service_C & Service_D> DataHolder(T value) {
      this.a = value.doWork_C();
      this.b = value.doWork_D();
    }
  }

  public static void main(String[] args) {
    new DataHolder("A","B");
    new DataHolder(new Impl_A());
    new DataHolder(new Impl_B());
  } 
}

Aber manchmal trifft man auch Fälle an, in denen es zu einem Interface mehrere Implementierungen gibt. Dann kommt man manchmal in folgende Situation:

  public interface Service_A {
    String doWork_A();
  }

  public static class Service_A_Impl_A implements Service_A {
    @Override
    public String doWork_A() {
      return null;
    }
  }

  public static class Service_A_Impl_B implements Service_A {
    @Override
    public String doWork_A() {
      return null;
    }
  }


  public interface Service_B {
    String doWork_B();
  }

  public static class Service_B_Impl_A implements Service_B {
    @Override
    public String doWork_B() {
      return null;
    }
  }

  public static class Service_B_Impl_B implements Service_B {
    @Override
    public String doWork_B() {
      return null;
    }
  }

Nun ist es leider so, dass die Kombinationen der Ergebnisse nur dann fachlich richtig sind, wenn
jeweils die A-Implementierung oder B-Implementierung von beiden genommen worden ist. Ja, so etwas gibt es wirklich. Das heißt, es gibt gültige und ungültige Kombinationen der Implementierungen.

  //the only valid combinations
  // Service_A_Impl_A && Service_B_Impl_A
  // Service_A_Impl_B && Service_B_Impl_B

  // not allowed
  // Service_A_Impl_A && Service_B_Impl_B
  // Service_A_Impl_B && Service_B_Impl_A

Das wiederum führte zu Konstruktionen wie diesem:

  //not nice
  public static class DataHolder_AB {
    private String a;
    private String b;

    //not secure
    public DataHolder_AB(final Service_A service_a, final Service_B service_b) {
      a = service_a.doWork_A();
      b = service_b.doWork_B();
    }

    //not secure
    public DataHolder_AB(String a, String b) {
      this.a = a;
      this.b = b;
    }

    //not nice
    public DataHolder_AB(final Service_A_Impl_A service_a, final Service_B_Impl_A service_b) {
      a = service_a.doWork_A();
      b = service_b.doWork_B();
    }

    //not nice
    public DataHolder_AB(final Service_A_Impl_B service_a, final Service_B_Impl_B service_b) {
      a = service_a.doWork_A();
      b = service_b.doWork_B();
    }
  }

Wenn man damit konfrontiert ist und es nicht erlaubt ist die Klasse DataHolder selbst
zu typisieren – also kein DataHolder<...> erlaubt ist –, kann man eventuell auch hier mit Typed Constructors nachhelfen.

  //no generics on class level
  public static class DataHolder {

    //not secure
    //public <A extends Service_A, B extends Service_B> DataHolder(A serviceA, B serviceB) {}

    //ok
    public <A extends Service_A_Impl_A, B extends Service_B_Impl_A> DataHolder(A serviceA, B serviceB) {}
    public <A extends Service_A_Impl_B, B extends Service_B_Impl_B> DataHolder(A serviceA, B serviceB) {}

  }

In der Verwendung sind dann nur Kombinationen erlaubt, die hier explizit als Konstruktor vorgegeben sind. Diese Schreibweise ist wesentlich kürzer und übersichtlicher, wenn man es mit mehreren Kombinationen zu tun hat.

Fazit

Wenn man mit alten und stark gewachsenen Softwaresystemen zu kämpfen hat, kommen einem die merkwürdigsten Dinge entgegen. Leider kann man nicht immer die gesamte Architektur verbessern, auch wenn es sinnvoll und eine gute Investition aus technischer Sicht wäre. In solchen Fällen kann man aber mit kleinen Verbesserungen manchmal doch dafür sorgen, dass einiges in die statische Semantik kommt und der Compiler dabei hilft (fachliche) Fehler zu finden.

Die Sourcen sind auf GitHub zu finden. Bei Fragen oder Anregungen: Twitter @SvenRuppert oder E-Mail an sven.ruppert@gmail.com.

Happy Coding!

Checkpoint Java

In dieser Kolumne klopft der Autor Sven Ruppert (Vaadin) Java auf alltägliche Probleme ab. Er gibt hilfreiche Tipps und Tricks, wie Entwickler gängige Stolperfallen vermeiden und klareren Code schreiben können. Einen besonderen Blick wirft er auf die neuen Möglichkeiten von Funktionaler und Reaktiver Programmierung. Alle Teile der Kolumne Checkpoint Java finden sich hier.

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Schreibe einen Kommentar

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