After release 0.6 brought the merger of funktionale and Kategory, we received lots of feedback about the rougher edges of the library. Working on these changes meant completely breaking binary compatibility with older versions of the library, and not providing a great migration path other than going through your current code and fixing the changes. We understand this will disrupt your codebase in the short term, although it is for the better in the long term to help early adopters and future newcomers. We're thinking now about what release 1.0 will look like and how many library-wide changes needed to happen. We feel that we should make any breaking changes as early as possible.
With that out of the way, let's talk about the pain points we've addressed in this release:
- Type Theory oriented naming of some of the constructs, like
ev()
,-HK
, or-Kind
- The magic around annotation processing and global typeclass lookup
- How to seamlessly use typeclasses in your existing code
Let's address each individually.
The Renamening
The names were originally taken by the authors due to familiarity with the theory, and we acknowledge they would limit adoption. To improve the current situation, several maintaners sat down and did multiple passes on renaming these concepts that sound alien to newcomers. We're happy to present the final result:
-
We're suffixing existing class wrappers with
K
instead ofKW
. Our wrapper forList
isListK
,Observable
becomesObservableK
... you get the idea. To wrap any available type we provide globalk()
functions for each. Note that after release of Kotlin 1.3 we'll make these wrappers intoinline data class
so they'll have 0 overhead. -
A container or kind is any class that has one generic parameter and implements the empty interface
Kind<F, A>
for their respectiveFor-
tag class. SoListK<A>
implementsKind<ForListK, A>
. To cast fromKind<ForListK, A>
toListK<A>
you have to use the functionfix()
that's available at a global scope. -
To refer to a container (a.k.a. kind), you'll use a tag class with the prefix
For-
. This way you will define aMonad<ForListK>
, aTraverse<ForEither>
, or evenAsync<ForObservableK>
. Note that some typeclasses don't require using tag classes, for exampleEq<ListK<A>>
, orShow<ListK<A>>
, although they're easy to spot once you fail to implement them. -
We provide a typealias for
Kind<F, A>
using the name of the container suffixed by-Of
. So, aOptionOf<A>
is an alias forKind<ForOption, A>
that you can safely cast toOption<A>
usingfix()
. -
The constructor for a single value
pure
is now calledjust
, which aligns better with existing Kotlin libraries. -
MonadSuspend
is nowMonadDefer
. The functionsuspend
is going to be added to the Kotlin Standard Library, so we needed to rename to avoid conflicts.
Some examples in context:
sealed class Option<A>
: OptionOf<A> { ... }
fun fetchUser(ME: MonadError<ForOption, Throwable>, id: UserId)
: Option<User>
fun OptionOf<A>.ifNoneReturn(f: () -> Option<A>)
: Option<A> {
val fixed: Option<A> = fix()
return fixed.fold(f, ::Some)
}
When working with generic and abstract functions that use typeclasses it's usual to have -Of
returns you need to fix()
. Because of that, as a rule of thumb we recommend declaring extension functions on typealiases -Of
because they will work with both Option
and OptionOf
. For return types you can always use Option<A>
, 'cause math :D
val optionA: Option<Name> = Option.monad().binding {
val company = lookupCompany("1").bind()
company.name
}.fix()
val optionB = Option.monad().binding {
lookupCompany("1").bind()
}.ifNoneReturn { Option.just(Company("Default")) }
.map { it.name }
Adios, global typeclass lookups
Ever since we started developing Arrow we wanted to emulate global lookup for typeclasses, the same way Haskell does it and Scala implements using implicits.
Raul concocted a smart solution using reified types and map lookups, similar to how other languages do it under the hood. Sadly, due to type erasure and other limitations the implementation was not safe. You could request an instance of a typeclass that couldn't exist and crash at runtime — this was not acceptable for the philosophy of the library. Another problem was that this lookup could not be used in places like interface methods or open classes. It also had issues keeping track of typeclasses in external libraries. Lastly, it was tied to the JVM, which would limit any future KotlinJS and KotlinNative versions of the lib. In short, it did not scale!
In version 0.7 we have removed all traces of global lookup. That includes all calls to the global lookup using a method with the typeclass name. So inlined reified functions like applicative()
, monad()
, monoid()
and the others are just gone from the codebase. You can get individual typeclasses from the companion object of the datatype that implements them, like Option.monad()
or Int.monoid()
.
So, what have we replaced global lookups with? As KEEP-87 is still under consideration by Jetbrains until proven by a proof of concept, it requires vast amounts of work in the compiler and the roadmap is still unknown. It'll be months, maybe even a year, until it is completed if it's only a handful of Arrow maintainers involved. It's been known in the community that the compiler is a largely undocumented, ad-hoc codebase, and we're just a few mavericks. Any help from experienced compiler engineers is heavily appreciated, so ask your friends!
Meanwhile, the Arrow maintainers decided for a transitional solution. Using scoped extension functions and some methods in the standard library, there is a solution that is both idiomatic and ergonomic. It is what's been named the Typeclassless
technique, described in this blog series.
A classless rewrite
All typeclasses in Arrow have been rewritten to behave more like Kotlin DSLs. As you know, a typeclass is a set of functionalities defined for a type or a constructor For-
. So, all methods inside a typeclass will have one of two shapes:
-
Constructor: functions that create a new
Kind<F, A>
from a value, a function, an error... Examples arejust
,async
,defer
orbinding
. -
Extensions: they add new functionality to
Kind<F, A>
so they're now defined as extension functions.
All typeclass objects can now be run
to access their corresponding functions and helpers.
fun fetchUser(ME: MonadError<ForOption, Throwable>, id: UserId)
: Option<User> =
ME.run {
just(id)
.flatMap(::validate)
.flatMap { fetchForDatabase(it) }
.handleError { User.NoUser }
}
You can still call the constructors directly over the typeclass object, including access to Monad Comprehensions using binding
.
fun fetchUser(ME: MonadError<ForOption, Throwable>, id: UserId)
: Option<User> =
ME.bindingCatch {
try {
val validId = validate(id).bind()
fetchForDatabase(validId).bind()
} catch (t: Throwable) {
ME.just(User.NoUser).bind()
}
}
These changes open the door to a refactor that makes every function an extension function over the typeclasses they require, effectively removing one or multiple parameter calls from every function.
fun <F> MonadError<F, Throwable>.fetchUserGeneric(id: UserId)
: Kind<F, User> =
bindingCatch {
val validId = validate(id).bind()
fetchForDatabase(validId).bind()
}.handleError { User.NoUser }
fun <F> MonadError<F, Throwable>.fetchAllUsers(ids: List<UserId>)
: List<Kind<F, User>> =
ids.map { fetchUserGeneric(it) }
fun <F, TC> TC.requestAllUsers(id: List<UserId>)
: Kind<F, List<User>>
where TC: MonadError<F, Throwable>, TC: Foldable<ForListK> =
fetchAllUsers(id).k().sequence(this)
So now you can request all users for any implementation of MonadError
, but you don't have to commit to it until you're atop of you call chain!
fun MonadError<F, Throwable>.rauObject() =
object : MonadError<F, Throwable> by this,
Foldable<ForListK> by ListK.foldable() {}
fun main(args: Array<String>) {
Option.monadError().rauObject().requestAllUsers(args).fix()
// Option<List<User>>
IO.monadError().rauObject().requestAllUsers(args).fix()
// IO<List<User>>
ObservableK.monadError().rauObject().requestAllUsers(args).fix()
// ObservableK<List<User>>
}
Mindblowing, eh? If you'd like to know more about the Typeclassless
encoding and how to do Dependency Injection with it, you can read the blog series on pacoworks or the dependency injection page of the Arrow docs.
Generic Metaprogramming
One of the new modules, which is still experimental, is arrow-generic
. This module focuses on code generation of tranformations between types with the same structure.
For the first release you can annotate any data class with @product
to get a set of functions to allow lossless conversion between your data class, tuples, and heterogeneous lists.
@product
data class Account(val balance: Int, val available: Int) {
companion object
}
Account(1000, 900).tupled()
// Tuple2(a=1000, b=900)
Tuple2(1000, 900).toAccount()
// Account(balance=1000, available=900)
Account(1000, 900).toHList()
// HCons(head=1000, tail=HCons(head=900, tail=arrow.generic.HNil@3b307f1c))
We use these transformations to enable new use cases, for example to automatically generate instances for some typeclasses like Eq
, Monoid
, and Show
; and smart constructors using Applicative
like shown below.
val maybeBalance: Option<Int> = Option(1000)
val maybeAvailable: Option<Int> = Option(900)
Option.applicative().mapToAccount(maybeBalance, maybeAvailable)
// Some(Account(balance=1000, available=900))
Looking forward to 0.8
While these are the major changes for version 0.7, there are still some things we would like to tackle on the next version. Some of the improvements we have in mind are:
- A refactor of
binding
to allow non-blocking operations - More Optics! More Laws! More Typeclasses!
- Removing all remaining deprecated APIs
- Improving the codata stack (coreader, cofree, comonad comprehensions...)
- Making the
arrow-recursion
package stable
Meanwhile, you can contact us on Gitter and the #arrow channel in the Kotlinlang Slack. See you there!