DSL für Optional (oder andere Monaden)

Ich überlege schon lange, wie man für Optional, List, Stream u.s.w. eine typsichere DSL bauen kann, die insbesondere das gleichzeitige Hantieren mit mehreren dieser Objekte erleichtert. Dabei habe ich mich ein wenig an der do-Notation von Haskell orientiert. Inzwischen kann ich sowas schreiben:

Optional<Integer> a = ...
Optional<String> b = ...
Optional<Integer> result = Optionals.optionals()
            .assign(a).toA()
            .assign(b).toB()
            .withA().andB().map((a, b) -> a + b.length()).toC()
            .withC().map(c -> c*c).read();

Konzeptionell sind A,B,C “Slots”, in die ich Werte packen kann. Ein Slot kann auch typsicher mit einem neuen Wert eines anderen Typs überschrieben werden. Obwohl ich für die Unterstützung A…Z jeweils 26 Versionen von paar Methoden brauchen würde, habe ich wenigstens keine kombinatorische Explosion. Die Schachtelungstiefe mit “and” ist beschränkt, für jedes weitere wird jeweils eine neue Klasse fällig (und man bräuchte 3,4,5…wertige Funktionen). Natürlich sind mehr Methoden möglich, wie flatMap, filter, ifPresent.

Die Fragen an euch sind: Ist das lesbar und nützlich? Oder ist das schon Overkill? Optional ist hier natürlich nur ein Beispiel, so könnte man das Gleiche mit Stream machen, und z.B. zip u.s.w. anbieten.

Ich würde erst einmal über die DSL an sich diskutieren, bevor ich die Implementierung zeige (die recht einfach ist)

Bei sowas ist es schwer, zu erfassen,

  • was derjenige, der das geschrieben hat, sich dabei gedacht hat
  • was derjenige, der das geschrieben hat, damit machen will

Vermutlich müßte ich das nochmal lesen, sacken lassen, philosophieren usw. Aber bis dahin: Die Teile mit assign(a).toA() sehen suspekt aus. Das sieht aus, als wolltest du eine DSL bauen, die die Möglichkeiten bietet, die Java schon bietet - nämlich Variablen (mit unschönen ein-Buchstaben-namen A, B…) zu “verwalten” und denen Werte zuweisen.

Zumindest erschließt sich mir nicht unmittelbar durch Draufschauen der gravierende Vorteil gegenüber sowas hier…:

import java.util.Optional;
import java.util.function.BiFunction;

class Optionals
{
    static <A, B, C> Optional<C> map(
        Optional<? extends A> oa, 
        Optional<? extends B> ob, 
        BiFunction<? super A, ? super B, ? extends C> mapper)
    {
        if (!oa.isPresent() || !ob.isPresent())
        {
            return Optional.empty();
        }
        return Optional.ofNullable(mapper.apply(oa.get(), ob.get()));
    }
}

public class OptionalsTest
{
    public static void main(String[] args)
    {
        Optional<Integer> oa = Optional.of(3);
        Optional<String> ob = Optional.of("foo");
        Integer result = Optionals
            .map(oa, ob, (a, b) -> a + b.length())
            .map(c -> c * c).get();
        System.out.println("Result " + result);
    }
    
}

(das ist dann so gesehen ähnlich zu einem “zip” - schade, dass es das in der Stream-API nicht mehr direkt gibt…)

Erst mal danke für das Feedback!

Einer der Gründe, warum ich an dem DSL arbeite (und warum auch Haskell die do-Notation eingeführt hat), ist, dass sich manuelle “monadische” Verknüpfungen selbst bei einfachen Beispielen noch schlechter lesen lassen. Z.B.:

Optional<String> optA = ...
Optional<Integer> optB = ...
BiFunction<String, Integer, BigInteger> fn = ...
Optional<BigInteger> result = optA.flatMap(a -> optB.map(b -> fn.apply(a,b));

Wer versteht hier, dass einfach nur über optA und optB gleichzeitig gemappt wird?

An eine Lösung wie deine habe ich auch schon gedacht (siehe z.B: https://dgronau.wordpress.com/2019/02/25/optional-im-quadrat/ ), aber zum einen macht man sich damit den “Flow” (a.k.a. das Trainwreck) kaputt, und zum anderen wird es kompliziert, wenn man mit drei oder mehr Variablen hantiert.

Ich schaue mal, was man mit meinem Ansatz noch so herausholen kann.

Nicht direkt spezifisch für die Frage hier, aber auch relevant hier, deswegen: Wenn ich irgendwelche Methoden schreibe, die irgendwas mit Generics machen, dann versuche ich üblicherweise, konsequent PECS anzuwenden. Ich finde, dass das teilweise sehr sperrig aussehen kann, aber erhöht die Generizität teilweise ungemein. Ist die Tatsache, dass du das z.B. in dem verlinkten Blogbeitrag nicht machst, der “Kürze und Lesbarkeit” des Beitrags geschuldet? Oder findest du das einfach unwichtig? Oder gibt es sogar einen Grund (abgesehen von Lesbarkeit), das nicht zu machen?

Als Beispiel, ein Auszug aus der Optional2-Klasse, leicht verändert, um deutlich zu machen, dass die ursprünglichen Methoden (of und map) nicht angewendet werden können, die entsprechenden ...WithBounds-Varianten aber schon:

import java.util.Objects;
import java.util.Optional;
import java.util.function.BiFunction;

class Optional2<A, B>
{
    private final Optional<? extends A> first;
    private final Optional<? extends B> second;

    private Optional2(Optional<? extends A> first, Optional<? extends B> second)
    {
        this.first = Objects.requireNonNull(first);
        this.second = Objects.requireNonNull(second);
    }

    public static <A, B> Optional2<A, B> of(
        Optional<A> first, Optional<B> second)
    {
        return new Optional2<>(first, second);
    }
    
    public static <A, B> Optional2<A, B> ofWithBound(
        Optional<? extends A> first, Optional<? extends B> second)
    {
        return new Optional2<>(first, second);
    }
 
    public <C> Optional<C> map(BiFunction<A, B, C> fn) {
        return first.flatMap(
            a -> second.map(
                b -> fn.apply(a, b)));
    }
    
    public <C> Optional<C> mapWithBounds(
        BiFunction<? super A, ? super B, ? extends C> fn) {
        return first.flatMap(
            a -> second.map(
                b -> fn.apply(a, b)));
    }    
    
}

public class OptionalsTest2
{
    public static void main(String[] args)
    {
        Optional<Integer> i = null;
        Optional<Float> j = null;
        Optional2<Number, Number> resultA = Optional2.of(i, j);
        Optional2<Number, Number> resultB = Optional2.ofWithBound(i, j);
        
        BiFunction<Object, Object, Double> f = (a, b) -> 42.0;
        Optional<Double> valueA = resultB.map(f);
        Optional<Double> valueB = resultB.mapWithBounds(f);
    }
}

Wenn ich z.B. eine Bibliothek schreibe, versuche ich das auch durchzuhalten, aber manchmal wird es einfach “too much”, die Signaturen sind dann wirklich overkill. Ich erinnere mich auch an Fälle, wo es dazu geführt hat, dass ich manchmal bei der Nutzung Generic-Angaben gebraucht habe, wo es vorher ohne ging - habe aber kein konkretes Beispiel parat. Aber im Allgemeinen ist es schon sinnvoll.

Noch schöner wäre natürlich, wenn man wie in Scala und Kotlin die Varianzen schon an der Klasse angeben könnte:

//Kotlin

interface Supplier<out T> {
    fun get(): T
}

interface Consumer<in T> {
    fun consume(t:T)
}

interface Function<in T, out R> {
    fun apply(t: T) : R
}

Ich habe mal einen anderen Weg versucht. Was haltet ihr (nur vom DSL her) von:

Optional<Double> optA = Optional.of(3.0);
Optional<Double> optB = Optional.of(4.0);

Optional<String> result = new Optionals<>(ctx -> {
    Val<Double> $a = ctx.assign(optA);
    Val<Double> $b = ctx.assign(optB);
    Val<Double> $c = ctx.let($a, $b, (a, b) -> Math.sqrt(a*a + b*b));
    Val<String> $s = ctx.let($c, c -> "Seite c ist " + c);
    return ctx.done($s);
}).get();

Ich finde, das sieht im Rahmen von Java’s Möglichkeiten schon recht aufgeräumt aus.