Blog

Funktionales Refactoring in Java

Seit der Veröffentlichung von Java 8 hat ein wenig funktionale Eleganz Einzug in die Sprache gefunden, worüber bereits ausgiebig berichtet wurde. Letzteres gilt leider nicht für die Möglichkeiten dieser neuen Sprachelemente zur Verbesserung der Struktur, Lesbarkeit und Wartbarkeit bereits existierender Software. Dieser Artikel erklärt an praktischen Beispielen mögliche funktionale Refactorings.

Die gezeigten Code-Beispiele stammen aus einem ca. zwei Jahre alten Spring-Boot-Projekt. Im Vorfeld wurden bereits einige funktionale Elemente, wie das Streaming-API, benutzt. Erweiterte funktionale Elemente wurden nur spärlich eingesetzt, so dass viel Raum für entsprechende Refactorings bestand.

Wiederholte bedingte Anweisungen

In den meisten Projekten mit großen Domänenmodellen und Entities mit vielen Feldern finden Mappings, Zustandsüberprüfungen oder ähnliche Operationen statt. Diese beinhalten häufig die Ausführung strukturell sehr ähnlicher Überprüfungen für viele Felder. Ohne funktionale Elemente könnte eine entsprechende Methode aussehen wie im folgenden Listing:

public List<String> findChangedFields(Entity existing, Entity updated) {
    List<String> changedFields = new ArrayList<>();
    BigDecimal existingFieldA = existing.getFieldA();
    BigDecimal updatedFieldA = updated.getFieldA();
    if (existingFieldA == null && updatedFieldA != null
        || existingFieldA != null && updatedFieldA == null
        || existingFieldA.compareTo(updatedFieldA) != 0) {
        changedFields.add("FieldA");
    }
    BigDecimal existingFieldB = existing.getFieldB();
    BigDecimal updatedFieldB = updated.getFieldB();
    if (existingFieldB == null && updatedFieldB != null
        || existingFieldB != null && updatedFieldB == null
        || existingFieldB.compareTo(updatedFieldB) != 0) {
        changedFields.add("FieldB");
    }
    // repeat...
    return changedFields;
}
}

Wer solchen oder ähnlichen Code in einem Projekt findet, braucht vermutlich einen Moment, um zu realisieren, dass es sich wiederholt um dieselbe Überprüfung handelt. Auch ohne funktionale Elemente könnte dieser Code verbessert werden, aber erst durch sie ergeben sich wirklich weitreichende Möglichkeiten.

Um expliziter zu machen, was passiert, werden wir zuerst die Überprüfung selbst von den zu prüfenden Feldern separieren. Im ersten Schritt werden wir dazu eine winzige, innere Klasse verwenden, die beide Entity-Variablen im Konstruktor übergeben bekommt. Der einzigen Methode wird dann per Function eine Referenz auf ein Feld übergeben mit der dann die eigentliche Überprüfung ausgeführt wird. Nach diesem ersten Refactoring könnte der obige Code wie folgt aussehen:

public List<String> findChangedFields(Entity existing, Entity updated) {
    List<String> changedFields = new ArrayList<>();
    FieldChecker check = new FieldChecker(existing, updated);
    
    if (check.isChanged(Entity::getFieldA)) {
        changedFields.add("FieldA");
    }
    if (check.isChanged(Entity::getFieldB)) {
        changedFields.add("FieldB");
    }
    // repeat...
    return changedFields;
}

private static class FieldChecker {
    private final Entity existing;
    private final Entity updated;

    // Constructor setting the two fields
    
    private boolean isChanged(Function<Entity, BigDecimal> accessor) {
        BigDecimal existingField = accessor.apply(this.existing);
        BigDecimal updatedField = accessor.apply(this.updated);

        return existingField == null && updatedField != null
            || existingField != null && updatedField == null
            || existingField.compareTo(updatedField) != 0;
    }
}

Die Vorteile dieses Refactorings wären in De-facto-Länge offensichtlicher, wenn 20 statt nur zwei Feldern überprüft würden. Auf jeden Fall ist die Funktionalität der findChangedFields-Methode deutlich expliziter geworden. Falls für andere Datentypen zusätzliche Überprüfungen nötig sind, können sie einfach als zusätzliche Methode in der inneren FieldChecker-Klasse umgesetzt werden.
Aber halt, da geht noch mehr: Jetzt, da das erste Refactoring erfolgt ist und der Code drastisch verkürzt wurde, kann man erkennen, dass eigentlich ein implizites Mapping vonstatten geht: von einem geänderten Feld auf den Namen des Feldes.

Implizite Mappings

„Implizites Mapping“ soll in diesem Kontext heißen, dass nicht explizit hinterlegt ist, was auf welches Ergebnis gemappt wird. Meist sind auch die Mapping-Daten und Logik miteinander verstrickt. Echte funktionale Sprachen sind hingegen gut darin, genau diese Aspekte voneinander zu trennen. Im nächsten Schritt werden wir sehen, ob auch Java dazu in der Lage ist.

Zuerst werden wir ein Mapping mit den entsprechenden Daten erstellen. Da sie sich in diesem Fall während der Laufzeit nicht ändern, werden wir die Map als statische Konstante anlegen und in einem static-Block mit Daten füllen. Letzterer ließe sich mit Bibliotheken wie Vavr oder den ImmutableCollections aus Guava vermeiden, die das Befüllen von Listen und Maps durch Builder ermöglichen.

Das Mapping beinhaltet als Key die Functions, die auf die Felder in der Entity-Klasse verweisen und als Value den Namen des Feldes als String. Das ermöglicht es, einmal die Liste aller Keys zu iterieren und mittels FieldChecker jene zu filtern, deren Inhalt sich geändert hat. Anschließend werden die verbliebenen Functions auf deren Feldnamen gemappt und diese in einer Liste gesammelt. All das kann natürlich ganz elegant mit einem Stream erledigt werden:

private static final Map<Function<Entity, BigDecimal>, String> changedFieldMapping = new HashMap<>();
static {
    changedFieldMapping.put(Entity::getFieldA, "FieldA");
    changedFieldMapping.put(Entity::getFieldB, "FieldB");
}

public List<String> findChangedFields(Entity existing, Entity updated) {
    FieldChecker check = new FieldChecker(existing, updated);
    
    return changedFieldMapping.keySet().stream()
        .filter(check::isChanged)
        .map(changedFieldMapping::get)
        .collect(Collectors.toList());
}
// FieldChecker bleibt unverändert

Jetzt, wo wir die Mapping-Daten extrahiert und explizit gemacht haben, ist in der ursprünglichen Methode sofort sichtbar, was sie tut. Im Gegensatz zum Ausgangscode beschreibt nun nicht mehr der Großteil des Codes, wie es getan wird. Das ist jetzt ausgelagert in der FieldChecker Klasse und im Streaming-API.

An dieser Stelle könnte man einfach aufhören und mit dem Ergebnis zufrieden sein – insbesondere, wenn der FieldChecker Methoden für mehr als nur einen Datentyp bereitstellen würde.

Klassen mit nur einer Methode

Im obigen Beispiel verbleiben wir mit einer inneren Klasse, die (neben dem Konstruktor natürlich) nur eine einzige Methode beinhaltet. Die Motivation für diesen nächsten Refactoring-Schritt beschreibt Jack Diederich im Detail in seinem Vortrag Stop writing classes. Kurz zusammengefasst sagt er: Schreibe keine Klassen, wenn eine Funktion auch reichen würde. In Python ist das natürlich etwas anderes als in Java, da man ohne weiteres Top-Level-Funktionen erstellen kann. In unserer Situation ist das allerdings egal, da wir die Funktion nur im Scope der bereits bestehenden Klasse benötigen. Da die Methode unserer inneren Klasse allerdings zwei Instanzvariablen verwendet, werden diese an einen Lambda-Ausdruck übergeben. Das erspart es, diese bei jedem Methodenaufruf wiederholt zu übergeben.

// mapping bleibt unverändert

public List<String> findChangedFields(Entity existing, Entity updated) {
    
    return changedFieldMapping.keySet().stream()
        .filter(changedFields(existing, updated))
        .map(changedFieldMapping::get)
        .collect(Collectors.toList());
}

private Predicate<Function<Entity, BigDecimal>> changedFields(Entity existing, Entity updated) {
    return accessor -> {
        BigDecimal existingField = accessor.apply(existing);
        BigDecimal updatedField = accessor.apply(updated);

        return existingField == null && updatedField != null
            || existingField != null && updatedField == null
            || existingField.compareTo(updatedField) != 0;
    };
}

Was nach diesem letzten Refactoring übrig bleibt, ist pure, funktionale Freude.Allerdings mag die Signatur der neuen Methode auf den ersten Blick etwas komplex erscheinen. Auf die einzelnen Bestandteile heruntergebrochen bekommen wir ein Prädikat, das eine Function auswertet, die jeweils auf den beiden Entites ausführt wird. Ein Predicate ist also einfach ein Ausdruck, der einen primitiven boolean zurückgibt.

Übermäßige Nutzung von funktionalen Interfaces

Obwohl es viele gute Anwendungsfälle für Lambda-Ausdrücke und funktionale Interfaces gibt, können sie auch übermäßig oder unsachgemäß verwendet werden. Ein Beispiel dafür sind Utility-Klassen, die anstatt Methoden nur noch Konstanten vom Typ Function, Supplier, Consumer oder ähnliches beinhalten. Im ersten Moment mag das nicht weiter schlimm erscheinen, insbesondere wenn die Konstanten hauptsächlich als Parameter für Streams oder ähnliche funktionale APIs verwendet werden. Schlussendlich bietet sich aber auch dort kein wirklicher Mehrwert über das Einsparen eines einzelnen Zeichens hinaus. Wenn aber die Funktionen direkt verwendet werden sollen, muss plötzlich ein zusätzliches .apply(), .consume() oder entsprechendes angefügt werden. Darüber hinaus werden die Signaturen solcher Funktionen etwas länger und unübersichtlicher. Das folgende Beispiel soll dies anschaulich darstellen:

// Funktions-Konstante
public static final Function<User, Credentials> toCredentials = User::getCredentials;

// Konventionelle Methode
public static Credentials getCredentials(User user) {
    return user.getCredentials();
}

// exemplary usage:
void someMethod() {
    // ...
    credentials = users.stream()
        .map(Util.toCredentials)
        .collect(Collectors.toList());

    Util.toCredentials.apply(someUser);
    
    credentials = users.stream()
        .map(Util::getCredentials)
        .collect(Collectors.toList());

    Util.getCredentials(someUser);
}

Zugegebenermaßen ist das insofern ein schlechtes Beispiel, als dass toCredentials/getCredentials ohne weiteres weggelassen werden und die gekapselte Methode ohne Umwege aufgerufen werden könnte. Trotzdem gilt das Argument, dass sowohl Deklaration als auch Aufruf solcher Funktionskonstanten unnötig lang sind.

Fazit

Die „neuen“ funktionalen Bestandteile von Java sind durchaus in der Lage, bestehenden Code zu verbessern und gleichzeitig Schritt für Schritt Konzepte funktionaler Programmiersprachen kennenzulernen. Auf einer abstrakteren Ebene gilt die alte Erkenntnis, dass Neues hilfreich sein kann, aber die übermäßige Nutzung um seiner selbst willen nicht unbedingt zielführend ist.

Über den Autor

Antwort hinterlassen