Introducing the Cyclops Monad API
Java 8 Introduced three Monads to the JDK. They are Stream, Optional and CompletableFuture. Despite doing different things, all three expose very similar Apis. Optional and Stream both have map / flatMap / filter and a creational ‘of’ method. CompletableFuture offers equivalent methods, but calls them by different names. So in the JDK we have 3 classes which implement the same conceptual API, but without an actual Interface to define it or allow reuse.
!NEW! Updated article covering v2 of the cyclops Monad API
Cyclops doesn’t introduce an interface (it’s too late, the classes are part of the JDK without one), but we do introduce a simple wrapper class to manage the common API / abstraction.
Cyclops is on GitHub! (Article Continued below)
AnyM
Cyclops AnyM can wrap any Monad type and provides a common API for accessing functionality of the wrapped Monad, regardless of it’s type.
The companion class AnyMonads provides static creational methods for common JDK Monad types.
AnyM<String> monad = AnyMonads.anyM(Stream.of(“hello”,”world”));
We can now perform typical monadic operations on our newly wrapped monad.
monad.map(String::toUpperCase);
The same code will work regardless of whether we have wrapped a Stream, Optional, CompletableFuture, LazySeq, Try, FeatureToggle or any other Monad. E.g.
AnyM<String> monad = AnyMonads.anyM(Optional.of(“hello”));
monad.map(String::toUpperCase);
We can write the code once, and depending on the Monad provided, we will upper case all values in a collection / stream, upper case successful or non null results, or upper case the result of an asynchronous operation.
The important fact, is that our wrapped Monad handles Strings, and our map function makes Strings upper case.
This offers up the opportunity for code resuse, we can write methods that accept AnyM rather than Stream, Optional or Try.
flatMap
flatMap for AnyM accepts a function that returns an AnyM, which implies that it is possible to flatMap across types using AnyM.
The API for java.util.Stream, won’t let us return a java.util.Optional from a flatMap operation.
This will not compile
Stream.of(1,2,null,3,4).flatMap(Optional::ofNullable);
But this will
AnyM.of(Stream.of(1,2,null,3,4))
.flatMapOptional(Optional::ofNullable);
A Stream with no values is an empty Stream (Stream.of()) and an Optional with no value is an empty Optional (Optional.empty()). The OptionalComprehender in Cyclops will convert from an empty Optional to an empty Stream. Optionals with values will be converted to Streams of one value. The Stream flatMap method will automatically flatten in those nested Streams into a single Stream.
The result of this operation will be
[1,2,3,4]
Cyclops uses the JDK Service Loader mechanism to identify classes that implement Comprehender. This makes it easy to add your own.
Converting other types to Monads
AnyM.of(Stream.of("http://my.endpoint.csv"))
.liftAndbind(URL::new);
Cyclops also uses the same mechanism to register MonadicConverter implementations. These take a non-Monad type (such as an InputStream, File, Collection, String etc) and convert it into a Monad when certain methods are invoked. liftAndBind allows non-Monads to be returned in the bind method and will automatically convert them into Monads for processing if a MonadicConverter for the type has been registered.
The code above will result in a Stream of text lines from urls specified in the Stream.
SequenceM
Any AnyM can be converted to a SequenceM which behaves exactly like a JDK Stream (with more operators). AnyM to SequenceM conversion takes place by one of two operators.
asSequence
AnyM#asSequence takes the current wrapped value in the Monad in AnyM and treats it as a sequence. So
anyM(Arrays.asList(1,2,3,4)).asSequence();
Becomes a sequence of [1,2,3,4]. By contrast
anyM(Optional.of(Arrays.asList(1,2,3,4))).asSequence();
Becomes a sequence of one item an ArrayList [[1,2,3,4]].
toSequence
To convert a nested sequential structure (such as an Optional containing a List) into a simpler more flattened structure use toSequence() instead.
anyM(Optional.of(Arrays.asList(1,2,3,4))).toSequence();
Becomes a sequence of [1,2,3,4].
Wrap any Monad, but why?
AnyM will allow you to wrap any Monad type (such as Stream, Optional, CompletableFuture or any other Java Monad implementation).
For more generic code
You can do this via
AnyM<Integer> monad = anyM(Stream.of(1,2,3,4));
And the first thing to note, is that this looks a bit clunky. It’s the cause of that clunkiness which is likely the reason Java doesn’t have a Monad interface.
Do we need Higher Kinded Types?
In Java, we can’t define generics on generics (for example we can’t define a new List type : List<T<U>>), so we can’t separately capture the fact that we are working with both Streams and Integers. But, as we shall see, the fact that we are working with Stream is probably less important than the fact we are working with Integers — so let’s make this simpler.
AnyM<Integer> monad = AnyM.of(Stream.of(1,2,3,4));
That looks a lot better. And we can perform a whole host of operations on our Monad, including very many operations particularly useful for Stream that weren’t included in the JDK. Let’s map to a String
AnyM<String> monadStrings = monad.map(i->"num"+i);
It’s a simple example, but we could use the same code to convert an Optional, Try, Either, CompletableFuture etc that contained (an) Integer(s) to String(s).
For more power
Higher level functionality
Once we have an abstraction across all Monad types, we can begin to program at a higher level. We can write functions once for those types, and reuse them for example.
liftM
LiftM allows functionality from Monads to be injected into pre-existing methods that do not use Monads at all! Stream can be used to inject looping behaviour or iteration. Optional can be used to inject null handling, Try error handling and CompletableFuture async execution.
Let’s take a simple example, we have an existing divide method, but do not check for divide by zero errors.
private Integer divide(Integer a, Integer b){
return a/b;
}
Let’s liftM that method so that it can accept and return Monads.
BiFunction<AnyM<Integer>, AnyM<Integer>,AnyM<Integer>> divide = Monads.liftM2(this::divide);
Eeek! The method signature is a little bit ugly, with a lot of boiler plate generics. We can simplify things a lot here with Lombok’s val keyword.
Making Java 8 less verbose
val divide = Monads.liftM2(this::divide);
NB In practice this can be avoided by chaining methods, the result of actually calling the lifted method is much less
But if you really want to declare it as a local variable another possibility is to define a type Val for the method or class you are working on such as
<Val extends BiFunction<AnyM<Integer>, AnyM<Integer>, AnyM<Integer>>>
Then we can write
Val divide = (Val) Monads.liftM2(this::divide);
and access divide with the correct type info.
Adding Streaming and error handling
If we call our method with a Try and a Stream as input parameters, we will get back a Try containing an error or a List of results
Try try = Try.of(20, ArithmeticException.class);
Stream stream = Stream.of(0,4,1,2,3);AnyM<Integer> result = divide.apply(anyM(try),anyM(stream));
The result of executing this function will be a Try with divide by Zero error. On the other hand in this case
Try try = Try.of(20, ArithmeticException.class);
Stream stream = Stream.of(4,1,2,3);AnyM<Integer> result = divide.apply(anyM(try),anyM(stream));
We will get a Try with a List of results.