Arrow : Le compagnon fonctionnel de la bibliothèque standard Kotlin

Arrow est une famille de bibliothèques dotée de nombreuses fonctionnalités qui rendent la programmation fonctionnelle plus accessible à tous.‌‌

La bibliothèque standard de Kotlin comporte déjà un grand nombre de fonctions et de types qui prennent en charge un certain degré de programmation fonctionnelle, comme map ou Result. La bonne question à se poser est donc : Pourquoi avons-nous besoin d'une bibliothèque comme Arrow ? Qu'est-ce qu'elle apporte de plus à Kotlin que ce que le langage nous donne déjà ?

L'équipe des créateurs de Kotlin tente d’avoir un langage très pragmatique et donc tente de n’inclure que le minimum de fonctionnalités dans la bibliothèque standard. Un bon exemple de cette approche est la bibliothèque Kotlinx Coroutines qui est spécifiquement gardée en dehors de la bibliothèque standard. Arrow utilise la même approche et ajoute des fonctions et types qui ne sont pas disponibles en standard, mais qui complètent ce qui est disponible dans Kotlin. Ce sont souvent des fonctionnalités que l’on peut trouver dans des langages fonctionnels, mais absentes de Kotlin et qui sont adaptées dans le style idiomatique de Kotlin. Arrow est donc faite pour les développeurs qui veulent avoir une approche plus fonctionnelle que ce qui est possible par défaut avec Kotlin.

Arrow est composé de 4 bibliothèques modulaires principales qui complètent la bibliothèque standard, le compilateur kotlin et la bibliothèque coroutines et que je vais détailler.

Arrow Core

Arrow core complète la bibliothèque standard en introduisant de nouveaux types de données comme Either ou Validated et de nouvelles abstractions comme les expressions de calcul (computation expressions). Ces abstractions nous permettent de supprimer les callbacks et d'utiliser un style impératif pour nos structures de données imbriquées et nos types complexes.

suspend fun prepareLunch(): Either<CookingException, Salad> =
    either<CookingException, Salad> {
        val lettuce = takeFoodFromRefrigerator().bind()
        val knife = getKnife().bind()
        prepare(knife, lettuce).bind()
    }

println(prepareLunch())

Pour préparer la salade nous avons besoin de plusieurs fonctions qui peuvent elles-mêmes échouer. Si l’une d’entre elles nous renvoie une erreur, le bloc de calcul entier s'arrête et nous donne l’erreur associée. Ceci est fait par la fonction bind. Pour que cela fonctionne les fonctions de blocs doivent renvoyer des erreurs de type ou sous-type CookingException.

Cela permet dès la compilation de voir si notre programme gère correctement les cas d’exception qui peuvent survenir.

Pour plus d’informations sur cette façon de gérer les erreurs vous pouvez aller voir la page https://arrow-kt.io/docs/patterns/error_handling/

Il y aussi dans Arrow core des types de données qui permettent de modéliser plus correctement nos modèles métier. Par exemple, le type NonEmptyList (Nel en raccourci) garantit que la liste comporte au moins un élément. Ce qui permet dans notre modèle d’avoir une Nel et par la suite d'éviter des vérifications pour savoir si la liste est vide et ainsi simplifier le code. Une commande sans article n’a pas vraiment de sens.

data class Order(val items: NonEmptyList<Item>) 

Arrow FX

Arrow FX est un compagnon fonctionnel de Kotlinx Coroutines en complétant son API avec des opérateurs fonctionnels et des types de données bien connus pour faciliter la composition de programmes asynchrones concurrents et de streaming.

val audrey = parZip({ "Audrey" }, { company("Arrow") })
 { name, company -> Employee(name, company) }
val pepe   = parZip({  "Pepe"  }, { company("Arrow") })
 { name, company -> Employee(name, company) }
val candidates = listOf(audrey, pepe)
val employees = candidates.parTraverse { hire(it) }

Dans notre cas, parZip permet de lancer en parallèle les deux fonctions pour récupérer le nom et l’entreprise de l’employé et de les combiner en retour pour créer un employé.

‌‌parTraverse va lancer en parallèle la fonction hire sur tous les employés de la liste de candidats.

suspend fun loser(): Unit =
  never() // Never wins

suspend fun winner(): Int {
  sleep(5.milliseconds)
  return 5
}

suspend fun main(): Unit {
  val res = raceN({ loser() }, { winner() })

  println(res)
}

raceN permet de lancer plusieurs fonctions en parallèle, de récupérer le résultat de la plus rapide et d’annuler les autres.

Dans ces exemples, il est facile de voir comment Arrow FX rend le lancement de fonctions en parallèle très simple.

Arrow FX est très complet et voici un aperçu des possibilités disponibles.‌

Arrow Optics

Une bibliothèque facilitant l'accès, la composition et la transformation de structures de données immuables profondément imbriquées.

val john =
    Employee(
        name = "Audrey Tang",
        company = Company(
            name = "Arrow",
            address = Address(
                city = "Functional city",
                street = Street(
                    number = 42,
                    name = "lambda street"))))

Dans ce code, john est une instance immuable de la classe Employee.

Imaginons que je souhaite mettre en majuscule le nom de la rue. Voici comment le faire avec en Kotlin standard.

val modified = john.copy(
        company = john.company?.copy(
            address = john.company.address.copy(
                street = john.company.address.street.copy(
                    name = john.company.address.street.name.uppercase()
                )
            )
        )
)

Cette complexité supplémentaire nous empêche de voir la vraie intention de ce code qui est de modifier le nom de la rue.

‌‌À l’aide du DSL de Arrow Optics il est possible d’en créer une nouvelle instance légèrement modifiée avec la fonction modify.

val modified = Employee.company.address.street.name.modify(john, String::toUpperCase) 

Ce code Employee.company.address.street.name est généré par le plugin de Optics pour les data classes et les sealed classes qui ont l’annotation @optics avec un plugin de compilateur qui utilise Arrow Meta.

Arrow Meta

Arrow Meta est une bibliothèque pour écrire des plugins pour le compilateur Kotlin. Elle est utilisée par Arrow pour fournir certaines fonctionnalités, mais est également disponible pour les utilisateurs.

Un exemple fourni par Arrow est la possibilité d’avoir des types raffinés (refined-types). Un type raffiné est un type doté d'un prédicat qui est supposé être valable pour tout élément du type raffiné.

Le plugin refined-types permet de détecter davantage d'erreurs au moment de la compilation.

Par exemple avec une définition de la valeur d’un dé à 20 faces :

@JvmInline
value class D20 private constructor(val value: Int) {
  companion object : Refined<Int, D20>(::D20, {
    ensure((it in 1..20) to "$it should be in the closed range of 1..20 to be a valid d20 number")
  })
}


D20(18) // compile
D20(30) // compilation error: 30 should be in the closed range of 1..20 to be a valid D20 number

En définissant une simple classe avec la règle associée, Arrow Meta est capable de modifier le comportement du compilateur Kotlin et de nous empêcher d’instancier des objets qui n’obéissent pas aux règles comme un dé 20 avec une valeur de 30.

Il y a bien sûr plus de possibilités avec Arrow Meta comme vu plus haut avec la librairie Optics mais dans cet exemple, nous pouvons déjà nous rendre compte de l’utilité d’avoir des plugins de compilateur.

Conclusion

Il y a beaucoup de fonctionnalités différentes dans Arrow mais vous pouvez apprendre à les découvrir petit à petit. Il y a eu un gros effort au niveau de la sortie de la 1.0 pour rendre la bibliothèque compatible avec Kotlin multiplatform. Donc, où que vous soyez, plus d’excuses pour ne pas utiliser Arrow ! En espérant que vous allez y jeter un œil ici https://arrow-kt.io/.

‌‌Si vous souhaitez apprendre ou si vous avez des questions sur Arrow, ne soyez pas timides et rejoignez-nous sur Kotlin Slack dans le channel #arrow. Vous pouvez également contribuer en open-source en suivant le guide contributing !