Vsevolod
Posted on July 1, 2023
In the end of the previous chapter, we've stated that Kotlin's Any?
is a proper union type. Also, multiple handy operators and functions to work with Any?
exist as part of the standard library. So, based on these two statements, Kotlin's null
handling has been inferred to be "the best". Today I'm going to review its weaker points. Again, we'll be looking at Haskell and its Maybe
type for inspiration. A lot of language designers do this anyway, why can't we?
Universal Interface
With a risk of sounding like a broken record, I would state that the main power of Maybe
comes from the universal Monad
type class. Maybe
itself may not be the best answer to nullability question, but its interface answers to dozens of other questions at the same time. This leads to a unified usage and thus to more readable and re-usable code. And it's completely reasonable from the OOP developer point of view. We all have heard the popular mantra of "code to interface". And here Haskell does exactly that.
The key function of monad, bind
, has signature m a -> (a -> m b) -> m b
. Rewriting this using Java-like types may look clearer: Monad<A> -> Function<A, Monad<B>> -> Monad<B>
. Or even more Javishly: Monad<B> bind(Monad<A> m, Function<A, Monad<B>> m)
. Does this look familiar? It should. It's a quite popular method among Java codebases, commonly known as flatMap
. But where is the problem with such a popular method?
Let's see 3 core examples of it:
Optional<A>.flatMap(Function<A, Optional<B>>) -> Optional<B>
Stream<A>.flatMap(Function<A, Stream<B>>) -> Stream<B>
CompletableFuture<A>.thenCompose(Function<A, CompletableFuture<B>>) -> CompletableFuture<B>
- I'm cleaning up some type noise for brevity here
- A lot more examples exist in the wild, but to showcase the idea these are enough
-
CompletableFuture
is my favorite: to make matters worse, it doesn't even try to conform to usual naming
Kinds of Types
My OOP training screams to abstract a common parent out of the same type signatures. To achieve this, we need an interface looking like the following one:
interface Monad<A> {
Monad<B> flatMap(Function<A, Monad<B>> f);
}
The problem here is that it's not generic enough. It will return us Monad
s where we would want Optional
s and Stream
s. For example, expression optional.flatMap(...)
will have parent's type of Monad<B>
not child's Optional<B>
. So, after such flatMap
we wouldn't be able to chain Optional
-specific methods anymore (without unholy casts, obviously).
If we go back to Haskell:
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
We'll spot that Haskell declares container itself as a generic type parameter (note the m
in m a
). Can Java do the same? I wasn't able to achieve it. If you succeed, you can destroy the reasoning of this article. Please go ahead.
No point in showing all the different type signature combinations I've tried. They either do not compile, or (as with initial "naive" Monad<A>
) do not return child type. The prettiest example, just to illustrate my thought-flow:
interface Monad<A, C extends Monad<A, C>>
Can be extended by:
sealed interface Maybe<A> extends Monad<A, Maybe<A>> permits Just, Nothing
Which narrows our Monad
parent type to proper Maybe
child type and compiles using Java 17. The problem arises when we want to return Maybe<B>
from Maybe<A>
method. Our Monad.flatMap
could look like the following (note that we're using the same trick of D extends Monad
we used successfully on class level):
<B, D extends Monad<B, D>> D flatMap(Function<A, D> f)
And, consequently, Maybe.flatMap
:
<B> Maybe<B> flatMap(Function<A, Maybe<B>> f)
Which doesn't compile with: flatMap(Function<A, Maybe<B>>)' in 'Maybe' clashes with 'flatMap(Function<A, D>)' in 'Monad';
. Even though Maybe<B>
extends Monad<B, Maybe<B>
, the compiler doesn't substitute D
with it, as it did with C
on class level previously.
Generally speaking, it's a known limitation of the Java compiler (as well as Kotlin's). To get more information on the matter, search for higher-kinded types. There are multiple Java libraries trying to add HKT, but at the moment of writing all of them introduce themselves as "not production-ready" ones. Also, it sounds like HKT should be part of a language itself to be efficient enough. So, unfortunately for now we are stuck with "code to implementation" in Java-world.
Imperative Paradigm
In Haskell, the universal monadic interface allows us to write both declarative and (surprisingly for a purely functional language) imperative code. Let's look at a simple example. Two ways of fetching some entity from some database, populating some metadata from some external API, and then mapping the result to a DTO.
Declarative:
fetchDto id = fetchEntityFromDB id >>= populateMeta <&> toDto
Imperative:
fetchDto id = do
entity <- fetchEntityFromDB id
entityWithMeta <- populateMeta entity
let dto = toDto entityWithMeta
return dto
Which code looks more familiar and thus more readable for Java developers without no functional background (i.e. for an average coder prior to Java 8)? If you are not familiar with Haskell, take your time to comprehend and compare both examples, they are really simple.
So?.. Right you are. The first one! At least, that is what we've got in Java 8. Am I hearing some weak voices for the second approach? Fear no more, you're not alone in this. There are examples of imperative handling of Monad
s on the JVM. Not in Java itself, but Kotlin's suspend/await
lets you write monadic code imperatively, exactly like do
in Haskell (to be fair, similarly to lots of other async/await
implementations). Yes, we are stepping out of our cozy Maybe
-only shell to a wider land of different Monad
s here. But we'll return, promise.
Java 8:
CompletableFuture<Dto> fetchDto(String id) {
return fetchEntityFromDb(id)
.thenCompose(this::populateMeta)
.thenApply(this::toDto);
}
Kotlin:
suspend fun fetchDto(id: String): Dto {
val entity = fetchEntityFromDb(id)
val entityWithMeta = populateMeta(entity)
val dto = toDto(entityWithMeta)
return dto
}
In Kotlin, we can shorten this function to a single line by using extension methods. That's not the point. The point is that we have a choice between imperative and declarative paradigms for asynchronous logic.
That's cool, but let's say that we want to add support for some other monadic type. For example, our beloved T?
. How much code would we need to change in Kotlin?
fun fetchDto(id: String): Dto? {
val entity = fetchEntityFromDb(id)
val entityWithMeta = entity?.let { populateMeta(it) }
val dto = entityWithMeta?.let { toDto(it) }
return dto
}
Huh, not DRY at all! How many lines we would need to change in Haskell?.. Zero, since we aren't using anything implementation-specific. Coding to an interface, remember that one?
The Union Way
As we already know, another mathematically correct nullable type resides in Scala. Does it adhere to the monadic interface? Can we implement one?
Scala lacks an explicit trait for Monad, however has an implicit interface that is used by for-comprehensions, which in turn are extremely similar to Haskell's do
blocks we've already seen. Here is one possible implementation for unionized Maybe
type:
extension[A] (m: A | Null)
def map[B](f: A => B): B | Null = m match
case null => null
case _ => f(m.nn)
def flatMap[B](f: (=> A) => B | Null): B | Null = m match
case null => null
case _ => f(m.nn)
def withFilter(f: A => Boolean): A | Null = m match
case null => null
case _ => if f(m.nn) then m.nn else null
Next is our good ol' sample function:
def fetchDto(id: String) = for {
entity <- fetchEntityFromDb(id)
entityWithMeta <- populateMeta(entity)
dto <- toDto(entityWithMeta)
} yield dto
And guess what? We can change our Maybe
Monad to any other type implementing the same contract and this code will not change.
Let's re-check backward compatibility rules from the previous chapter. In this example, we can narrow down the return type in the signature of the populateMeta
from Entity -> EntityWithMeta | Null
to Entity -> EntityWithMeta
. And as expected, client code is not broken. The same function compiles and works correctly!
This actually leads us to the interesting side effect. We now can work with non-nullable types using monadic pipelines:
val i: Int = 5
val j: Int | Null = i.map(_ - 3)
The one major drawback of our A | Null
type I see is that if we want to make it actually useful, we need to re-invent the wheel (i.e. re-implement all the functionality, like zip
and orElse
, from the Option
type). Can you spot any other problems?
All in all, by this moment we've covered quite an interesting evolutionary path from Java 7 to Scala 3 union types. From a theoretical standpoint, the latter now seem superior to the available alternatives (which doesn't mean that it's ideal, does it?). In the next chapter (if it ever gets published), I'm planning to abstract out the notion of an "absent value" even more. Null
is too specific. "Code to interface", still remember that one?
Posted on July 1, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 20, 2021