Wrapper für Method-Chaining


#1

Ich schreibe gern Code, der Method-Chaining (a.k.a. fluent style) verwendet. Leider macht da Java nicht immer mit, sei es durch veränderliche Objekte, sei es durch den unreflektierten Einsatz von static-Methoden. Ich habe mal einen Wrapper (technisch gesehen die Identity-Monade) gebastelt, der das Chaining auch in diesen Fällen erlaubt, und durch die Möglichkeit, zusätzliche Argumente anzugeben, auch die Verwendung von Methoden-Referenzen erleichtert.

Mal ein bisschen Nonsense-Code:

int i = Id.of("sdfkjsdlfj")
      .call2(1, String::substring)
      .call13("(%s---%d)", 42, String::format)
      .peek(System.out::println)
      .map(String::length)
      .map(AtomicInteger::new)
      .peek2(3, AtomicInteger::getAndAdd)
      .map(AtomicInteger::intValue)
      .get();

Die Zahlen geben immer an, wo die zusätzlichen Argumente beim Aufruf plaziert werden.

Falls jemand damit rumspielen will, hier die Implementierung:

import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;

public final class Id<A> {
    private final A value;

    private Id(A value) {
        this.value = value;
    }

    public static <A> Id<A> of(A value) {
        return new Id<>(value);
    }

    public A get() {
        return value;
    }

    public <B> Id<B> map(Function<? super A, ? extends B> fn) {
        return of(fn.apply(value));
    }

    public <B> Id<B> flatMap(Function<? super A, Id<B>> fn) {
        return fn.apply(value);
    }

    public Id<A> peek(Consumer<? super A> consumer) {
        consumer.accept(value);
        return this;
    }

    public <B> Id<A> peek1(B b, BiConsumer<? super B, ? super A> consumer) {
        consumer.accept(b, value);
        return this;
    }

    public <B> Id<A> peek2(B b, BiConsumer<? super A, ? super B> consumer) {
        consumer.accept(value, b);
        return this;
    }

    public <B, C> Id<C> call1(B b, BiFunction<B, A, C> fn) {
        return of(fn.apply(b, value));
    }

    public <B, C> Id<C> call2(B b, BiFunction<A, B, C> fn) {
        return of(fn.apply(value, b));
    }

    public <B, C, D> Id<D> call12(B b, C c, TriFunction<B, C, A, D> fn) {
        return of(fn.apply(b, c, value));
    }
    public <B, C, D> Id<D> call13(B b, C c, TriFunction<B, A, C, D> fn) {
        return of(fn.apply(b, value, c));
    }
    public <B, C, D> Id<D> call23(B b, C c, TriFunction<A, B, C, D> fn) {
        return of(fn.apply(value, b, c));
    }

    public interface TriFunction<A, B, C, D> {
        D apply(A a, B b, C c);
    }
}

Denkt ihr, das sowas praxistauglich ist?


#2

Nun… ist Haskell praxistauglich? :stuck_out_tongue_winking_eye:

Die Antwort ist bei sowas meistens “Naja, es kommt drauf an…”. Oft unterscheiden sich diese Antworten nur darin, wie nachdenklich und langgezogen das "Naaajaaaaah… " ist :smiley:

Ein direkter Vergleich zwischen dem geposteten Code, und dem, was man ohne diese Klasse schreiben müßte, könnte helfen. Was genau das ist (d.h. was der Code macht), könnte man natürlich rausfinden, aber … das ist ein wichtiger Punkt: Das ist ““schwierig””.

Die Tatsache, dass man komplexe Operationen oft auf einen “algebraisch minimalen Satz” von Unteroperationen abbilden kann, kann dazu führen, dass man das tut, und damit den gleichen konzeptuellen Fehler begeht, wie jemand, der schreibt

void process(int array[]) {
    for (int i=0; i<array.length; i++) array[i] += 3;
    for (int i=0; i<array.length; i++) array[i] *= 14
    for (int i=0; i<array.length; i++) array[i] += foo(array[i]);
    for (int i=0; i<array.length; i++) array[i] -= 24;
}

statt

void process(int array[]) {
    for (int i=0; i<array.length; i++) array[i] = computeOurResult(array[i]);
}

(mit einer passend benannten und dokumentierten computeOurResult-Methode)

In diesem Sinne könnte sowas wie

int i = Stream.of("sdfkjsdlfj")
  .map(toFormattedString(42))
  .peek(System.out::println)
  .map(toAtomicStringLength())
  .get();

deutlich nachvollziehbarer und wartbarer sein. (Davon, dass sowas wie call1 und call und call13 und call3 optisch so ähnlich aussehen, dass man sich über andere Namen Gedanken machen sollte, mal ganz abgesehen).

Aber es gibt sicher Fälle, wo das nicht so sinnvoll ist, und man gerade diese kleinen Building-Blocks will. Wenn man calls hat wie

int a = streamA.map(foo()).map(bar()).map(baz()).get();
int b = streamB.map(bar()).map(foo()).map(baz()).get();
int c = streamC.map(baz()).map(foo()).map(bar()).get();

könnte man die drei unterschiedlichen mapper-Reihenfolgen natürlich in jeweils einen zusammenfassen, aber … da trifft einen irgendwann die kombinatorische Explosion.

Die Möglichkeit, dass man damit zur Laufzeit flexibler Operationen zusammenbauen kann, könnte man noch erwähnen…

Id<T> appendOperation() {
    return id.call13(
        valueFromTextField(), 
        valueFromSpinner(), 
        functionSelectedInComboBox());
}

aber das dürfte ein eher seltener Use-Case sein, und würde vermutlich ganz andere, drängendere Fragen aufwerfen.


Etwas technischer: Es könnte sich, wenn man das “systematisch” ausbauen wollte, natürlich anbieten, das Id zu einem interface zu machen, das Stream extendet. Aber ich gehe davon aus, dass der Schnipsel nur zur Verdeutlichung der Idee war.

Im Moment bin ich nicht ganz fokussiert, aber bei den call--Methoden habe ich kurz eine Augenbraue gehoben: :face_with_raised_eyebrow: Erstens, weil TriFunction ja nicht Teil der Standard-API ist, und zweitens, wichtiger: Falls damit “algebraisch minimale Building-Blocks” angeboten werden sollen, sollte dann nicht die arity der Funktionen mehr oder weniger “egal” werden, dadurch, dass man häßliche Sachen ja schönfinkeln kann?

Sinnvoll wäre letzteres natürlich nur, wenn man die passenden Function1 bis Function10 und Tuple1 bis Tuple10 anbieten würde. Aber dann geht es schnell in die Richtung von diesen beiden hier:


Ansonsten denke ich, dass so eine “Utility-Klasse” durchaus sinnvoll sein kann, wenn sie selbst als “Building-Block-Generator” angesehen wird. Wenn man also (grob rumgesponnen) eine Klasse hat, die irgendwelche Stream-Database-Queries raushauen muss, und der Kunde jeden Tag mit neuen Requirements um die Ecke kommt, dann kann es sinnvoll sein, die Id package-private daneben zu legen und sowas zu machen wie

class DB {
    Result queryFromLastMonth() { return Id.of(db).call(whatever ).get(); }
    Result queryFromLastWeek()  { return Id.of(db).call(fooobar  ).get(); }
    Result queryFromYesterday() { return Id.of(db).call(thePolice).get(); }
}

weil man damit schnell und flexibel neue Queries bauen kann, und nicht für jede mapper-Function eine eigene Klasse erstellen muss - wobei ich immernoch denke, dass sowas wie createMyMapperForFoo ein sehr gutes Verhältnis zwischen Schreibaufwand und Lesbarkei (bzw. impliziter Dokumentation) hat.

Damit, das ganze public irgendwo anzubieten, würde ich zögern, aufgrund der ganzen oben angedeuteten Fragen, die das aufwirft…