Introduction aux Fonctions d'ordre supérieur avec Kotlin

Kotlin, de par son approche simple et concise du développement, s'appuie sur certains principes de programmation fonctionnelle et en particulier sur les Higher-Order Functions (HOF pour les intimes). Pour rappel, ou pour celles et ceux qui découvrent le sujet, les fonctions d’ordre supérieur sont des fonctions qui prennent une ou plusieurs fonctions en entrée et/ou qui produisent une fonction en sortie.

Normalement, en seulement une phrase d’introduction, le sujet de la programmation fonctionnelle a déjà rebuté une (grande) partie du public de cet article… La programmation fonctionnelle a trop souvent été expliquée par des mathématiciens (c’est eux qui l’ont inventée).
Nous avons tous le souvenir d’un prof de maths en amphi qui fait l’appel le premier jour et qui est capable de saluer dans les couloirs toute la promo le lendemain :
“Bonjour M·me [Insert ici ton nom et ton prénom ] !”
et intérieurement, on se dit :
“Non, je ne deviendrai pas comme ca, je veux faire de la pratique, pas seulement de la théorie…”
Et à voix haute :
”Bonjour M … heu … Jaumet” [Le nom a été changé]

Plus tard, bien après cet épisode, nous nous sommes orientés vers le C/Java/Python en laissant sur le bas côté de notre scolarité les langages fonctionnels chers à nos professeurs de math-info comme Haskell ou OCaml en se disant qu’il est quand même temps d’apprendre à utiliser Spring Boot/Pandas !

Ne tombons pas dans le travers de l'approche théorique sans cas d’utilisation réel et commençons par étudier le fonctionnement d’une HOF, Nous reviendrons sur les bases théoriques ensuite.

Les HOF dans Android

Google ajoute de nombreuses HOF dans Android que vous utilisez sans forcément vous en rendre compte. Une partie d’entre elles sont amenées par Android KTX (https://developer.android.com/kotlin/ktx). Pour reprendre leurs mots, l’idée est de “Fournir des utilitaires Kotlin concis et idiomatiques à Jetpack, à la plateforme Android et aux autres APIs”. En somme, faciliter la vie du développeur.
Le lien spécifié ci-dessus fournit un exemple d’utilisation de KTX. On nous montre comment on peut utiliser plus simplement l’API SharedPreferences, qui sert à stocker simplement dans son application des couples clé / valeur.

On passe alors de (sans KTX) :

sharedPreferences
        .edit()  // create an Editor
        .putBoolean("key", value)
        .apply() // write to disk asynchronously

à (grâce à KTX) :

// SharedPreferences.edit extension function signature from Android KTX - Core
// inline fun SharedPreferences.edit(
//         commit: Boolean = false,
//         action: SharedPreferences.Editor.() -> Unit)

// Commit a new value asynchronously
sharedPreferences.edit { putBoolean("key", value) }

// Commit a new value synchronously
sharedPreferences.edit(commit = true) { putBoolean("key", value) }

On profite ici d’une manière de développer qui ressemble plus à du Kotlin, via l’utilisation d’une closure. Pour notre sujet, la partie qui nous intéresse se trouve dans l’implémentation de cette fonction edit :

inline fun SharedPreferences.edit(
   commit: Boolean = false,
   action: SharedPreferences.Editor.() -> Unit
) {
   val editor = edit()
   action(editor)
   if (commit) {
       editor.commit()
   } else {
       editor.apply()
   }
}

Vous l’aurez deviné : cette fonction est une HOF. Le paramètre action est une fonction qui prend en paramètre un SharedPreferences.Editor.(). Ainsi, le code appelant va pouvoir se concentrer sur l’action à réaliser dans cette base de données clé / valeur (putBoolean). On force surtout que l’action terminale soit ou un commit() ou un apply(), sans quoi les modifications ne seraient pas sauvegardées !

On peut se demander si on peut quand même chaîner les modifications. La réponse est oui !
Puisque putBoolean est un SharedPreferences.Editor qui lui-même retourne un SharedPreferences.Editor, on pourrait très bien écrire :

sharedPreferences.edit { 
     putBoolean("key", true) 
     putBoolean("key2", false)
     putInt("key3", 1) 
}

On voit très bien dans cet exemple que les HOF permettent de factoriser des comportements autour d’un traitement. Cela rend le code plus lisible. Le développeur se concentre ainsi sur la logique métier, pas sur les contraintes techniques. On diminue aussi le nombre de copier-coller, ce qui va mener à un code de meilleure qualité et qui sera plus facile à tester.

Les HOF en pratique

Essayons de profiter des avantages des HOF vus dans l’exemple ci-dessus pour notre base de code.
Nous allons repartir d’un contexte Android. Si vous avez déjà développé des apps, vous avez sûrement déjà dû faire des appels réseaux ou en base de données. La plupart de ces traitements résultent soit par la restitution de la valeur (Succès), soit par une exception (Erreur). Dans un contexte Kotlin, la capture d’exception se fait via l’encapsulation de notre code dans un bloc try / catch. Bloc qui est potentiellement à recréer pour chaque appel que vous avez à faire. C’est une première problématique: la répétition de code. La seconde, c’est que Kotlin n’a pas le concept de checked exception. Le compilateur ne nous avertit donc pas si un bloc de code peut lever une exception. La bonne pratique est alors d’utiliser des classes qui représentent des résultats de traitement. Voyons plutôt :

fun <T> wrapInResource(block: () -> T): Resource<T> {
   return try {
       block().let {
           Resource.Success(it)
       }
   } catch (e: Exception) {
       Timber.e(e)
       Resource.Error(e)
   }
}

Cette fonction d’ordre supérieure un peu particulière prend un bloc de code qui retourne un type générique T. Ce bloc de code est exécuté dans un try / catch. Deux issues sont possibles :

  • Succès. Auquel cas on retourne un Resource.Success
  • Exception. Auquel cas on log l’erreur avec Timber et on retourne un Resource.Error

A l’utilisation :

wrapInResource {
   val fichierId = getStringValue(FichierDao.FICHIER_ID)
   val fichierDocument = getMapValue(FichierDao.FICHIER_ALIAS)

   LocalFichier(
       fichierId,
       fichierDocument
   )
}

Le wrapInResource retourne un Resource<LocalFichier> qui est soit de type de Success, soit de type Error. Comment peut-on en être sûr ? Eh bien c’est grâce au concept de sealed class. Ce modifier de classe permet d’assurer que Resource ne sera étendable qu’au sein du fichier qui la définit :

sealed class Resource<T> {
   data class Success<T>(val data: T) : Resource<T>()
   data class Error<T>(val error: Throwable) : Resource<T>()
}

Une Resource.Success contient une data, qui est le résultat du traitement. Une Resource.Error contient une error qui est le Throwable (ie: l’exception) associé au traitement. Grâce à ce procédé, nous serons obligés de vérifier que notre résultat est de type Resource.Success pour accéder à la data associée, ce qui contribue à la robustesse du code!

Quid de la théorie ?

La programmation fonctionnelle apporte de belles promesses. Mais, le prix à payer est l’apprentissage d’un nouveau paradigme basé sur des concepts mathématiques. Elle est parfois complexe à manipuler pour des développeurs habitués à la programmation impérative, en particulier si notre dernière interaction avec un professeur de mathématiques ne date pas d’hier !
Pour faire plaisir à ce bon vieux M. Jaumet, je vous propose de vous arrêter sur un concept qui vous permettra peut-être d'éviter les pièges classiques : Les fonctions pures

Une fonction pure est une fonction :

  • Qui renvoie toujours le même résultat pour une même liste d’arguments
  • Qui est sans effet de bord. Une fonction à effet de bord est une fonction
  • Qui modifie une donnée à l'extérieur de son scope. Les effets de bords sont très utilisés en programmation, pour la gestion des Entrées/Sorties par exemple ou la modification d’un état par exemple.

C’est ici qu’apparaissent les premières difficultés :

  • Comment écrire un serveur web qui écrit dans une base de données avec des fonctions pures ?
  • Comment modifier un attribut d’une instance ?

C’est bien sûr possible, mais ces problèmes simples peuvent devenir complexes en programmation fonctionnelle. Si ce dernier point peut facilement être contourné avec l’utilisation du pattern update-as-you-copy (une sorte de copy-on-write). Le premier nécessite l'utilisation de concepts spécifiques à la programmation fonctionnelle. Une solution pragmatique est de voir les HOF comme les commandes d’un pipeline de commandes linux, où seules les fonctions aux extrémités ont des effets de bord. C’est ce que nous avons fait dans notre exemple avec wrapInResource, qui encapsule le point d’entrée avant de venir appliquer nos traitements.

Conclusion

On retiendra les bénéfices suivants :

  • Les fonctions pures sont faciles à appréhender, rien ne se passe en dehors de leur scope.
  • Les fonctions pures facilitent les tests unitaires, en étant faciles à mocker.
  • Les fonctions pures peuvent être parallélisées et distribuées facilement.
  • Les programmes sont plus faciles à lire, en séparant la logique métier du code lié à la gestion des éléments techniques.
  • Les signatures des fonctions sont remplies d’informations.

Si la bascule peut être complexe à aborder, les transformations dans la manière de programmer se répercutent positivement dans tout le code qu’on écrit ensuite. Les choix faits par les créateurs de Kotlin permettent de facilement créer des HOF, sans avoir à connaître l'état de l’art de la programmation fonctionnelle. Etat de l’art que les plus curieux peuvent retrouver dans le projet Arrow-kt (https://arrow-kt.io/), une librairie qui apporte pléthore d’autres concepts que les fonctions d’ordre supérieur !

Article co-écrit avec Vincent Treve, Architecte Data et Solution chez Ippon Technologies