Language feature to support Fluent Syntax for Static Method Calls

Kristofer Karlsson krka at spotify.com
Wed Mar 29 11:43:26 UTC 2023


Thank you all for your well thought out replies!

I've taken some time to think everything through and made some mental
adjustments:

With regards to streams, I definitely agree that .toSet() would not be a
significant improvement over .collect(Collectors.toSet()). That was a
bad example.
Also for controlling parallel streams, since that is typically done at the start
of setting up a stream, it will be a limited problem in practice.
I can't really come up with any other reasonable examples for streams,
so perhaps
that train of thought should be dropped.

For CompletableFuture I think there are definitely some missing methods that
should be added. It is somewhat surprising given that the API is in some ways
very big where many methods do almost exactly the same thing.

Some of that has been fixed already (thinking primarily of
exceptionallyCompose),
but isn't that itself somewhat of a signal that APIs can be imperfect and may
sometimes need to be fixed. I imagine this will not be the last time
that happens.

So, I think the problem is still a problem - but do not have good insights into
the size of the problem. For me it is mostly an inconvenience that leads to
more verbose code, but I've seen other people at my work do the entirely wrong
thing because the right thing to do was not easily available to them in the API.
The impact of such mistakes comes in the form of performance issues
(blocking threads in an environment that relies on asynchronous work
with futures).

Thinking about the solutions to the problem, there are many options that do not
require any changes. Let's look at some of them, using
exceptionallyCompose as an example:

If this method exists (i.e. we're in Java 9+) we can do:

    return someRemoteCall() // step 1
     .thenApply(x -> x + x) // step 2
     .thenCompose(x -> otherRemoteCall(x)) // step 3
     .exceptionallyCompose(e -> retryRemoteCall(e)) // step 4
     .thenApply(x -> transformResponse(x)) // step 5

In Java 8 we can implement it manually roughly like this:

    public static CompletableFuture<T> exceptionallyCompose(
      CompletableFuture<T> future, Function<Throwable,
CompletionStage<T> func) {
      return future
        .thenApply(CompletableFuture::completedFuture)
        .exceptionally(func)
        .thenCompose(Function.identity());
    }

Then we have many different ways we could write the code:

    var f1 = someRemoteCall() // step 1
       .thenApply(x -> x + x) // step 2
       .thenCompose(x -> otherRemoteCall(x)) // step 3
    return exceptionallyCompose(f1, e -> retryRemoteCall(e)) // step 4
       .thenApply(x -> transformResponse(x)) // step 5

Pro: method ordering matches the source code ordering
Con: we need to introduce a variable for this. If this was in a lambda,
we would need to use blocks.

Or we could write it like this:

    return exceptionallyCompose( // step 4
      someRemoteCall() // step 1
       .thenApply(x -> x + x) // step 2
       .thenCompose(x -> otherRemoteCall(x)), // step 3
      e -> retryRemoteCall(e))
       .thenApply(x -> transformResponse(x)) // step 5

The only problem with this is that the method ordering is less intuitive in the
source code. Also, the parameter for exceptionallyCompose is 4 lines
away from the
method itself.

Another alternative is to create a wrapper interface and use it like this:

    return wrapAsFuture2(someRemoteCall()) // step 1
       .thenApply(x -> x + x) // step 2
       .thenCompose(x -> otherRemoteCall(x)) // step 3
       .exceptionallyCompose(e -> retryRemoteCall(e)) // step 4
       .thenApply(x -> transformResponse(x)) // step 5
       .toCompletableFuture()

Here the method order is preserved, but we need to create a full wrapper of
the interface which is an extra bit of boilerplate code.

I did not know about the (new) transform method in String - but that's also a
useful pattern! If that was added to the most important classes and interfaces
in the JDK, that would solve a lot of the concrete issues I think.

With that, the code would look like:

    return someRemoteCall() // step 1
     .thenApply(x -> x + x) // step 2
     .thenCompose(x -> otherRemoteCall(x)) // step 3
     .transform(f -> exceptionallyCompose(f, e -> retryRemoteCall(e))) // step 4
     .thenApply(x -> transformResponse(x)) // step 5

which is not too bad.

If we are talking about language features, I think two other approaches would be
some postfix expression extension operator (previously referred to as thrush).
With something like that, the code would look like:

    return someRemoteCall() // step 1
     .thenApply(x -> x + x) // step 2
     .thenCompose(x -> otherRemoteCall(x)) // step 3
     OP f -> exceptionallyCompose(f, e -> retryRemoteCall(e)) // step 4
     .thenApply(x -> transformResponse(x)) // step 5

Finally, the approach with alternative syntax for static method calls.
If we are concerned with hiding the static method call and it looking
too much like a regular call,
we could do something similar to how method references work (::) - we
would just need some
simple way to make it look distinctly different.

Example using double periods to denote imported static method call:

    return someRemoteCall() // step 1
     .thenApply(x -> x + x) // step 2
     .thenCompose(x -> otherRemoteCall(x)) // step 3
     ..exceptionallyCompose(e -> retryRemoteCall(e)) // step 4
     .thenApply(x -> transformResponse(x)) // step 5

Also, as is pointed out in this email thread, I agree that the fact
that many other languages
have tried to address this problem with some language feature, could
be an indicator
that it is in fact a problem worth solving.

I have concluded that my original idea will not end up being
implemented, but I hope
that there is something simple that can be done to reduce the problem.

Best regards
Kristofer


More information about the amber-dev mailing list