La mémoïzation est une technique qui permet de mémoriser le retour d’une fonction afin d’optimiser le temps d’exécution de ses appels suivants. Cette technique se révèle également très intéressante lorsque l’on veut mémoriser des appels à des resources externes : base de données, API avec rate limiting, etc. Lorsque les données sont représentées sous forme d’arbre et qu’elles sont récupérées à l’aide d’une fonction récursive la mémoïzation peut s’avérer également très intéressante.
Si appliquer la mémoïzation dans le cadre d’une fonction non récursive se révèle assez simple, pour une fonction récursive, il s’agit en revanche, d’une toute autre affaire. Cet article vous propose de découvrir comment on peut tout de même appliquer la mémoïzation à ce type de fonction dans un langage typé très prometteur : Kotlin.
Une fonction pour les mémoïzer (presque) toutes
L’approche fonctionnelle de la mémoïzation passe par la définition d’une fonction memoizer qui prend en paramètre une fonction f sur laquelle on applique la mémoïzation. Il faut bien noter que la fonction f se doit d’être une fonction pure. L’exemple typique de fonction pour lequel la mémorisation est à proscrire est la fonction random. La fonction memoizer, quant à elle, a une responsabilité simple et bien définie : elle vérifie si une valeur a déjà été calculée pour le paramètre donné et le cas échéant retourne cette valeur, sinon elle fait appel à la fonction f et stocke la valeur nouvellement calculée. Pour récupérer ou stocker les valeurs calculées nous pouvons utiliser la méthode computeIfAbsent de la classe Map.
fun memoizer(f: (Int) -> Int): (Int) -> Int { val cache = mutableMapOf<Int, Int>() return { n -> cache.computeIfAbsent(n, f) } } fun square(x: Int) = x * x val memoizedSquare = memoizer(::square) val squareOf5 = memoizedSquare(5) // 25
Comme le montre le code ci-dessus, nous obtenons la fonction memoizedSquare en combinant la fonction square et la fonction memoizer. La fonction square est une fonction très simple. Voyons ce qu’il se produit lorsque l’on applique la même méthode sur une fonction récursive.
Fonction récursive pseudo-mémoïzée
Analysons le cas avec une fonction récursive bien connue : la fonction factorielle.
fun factorial(n: Int): Int = if (n < 2) 1 else n * factorial(n - 1) val pseudoMemoizedFactorial = memoizer(::factorial)
Le problème dans le cas ci-dessus est que la fonction s’invoque elle même – c’est une fonction récursive. Lorsque l’on utilise la fonction pseudoMemoizedFactorial définie précédemment on ne mémorise que le retour du premier appel à la fonction. Tous les résultats obtenus par les appels déclarés dans le corps de la fonction ne sont pas mémorisés !
fun main(args: Array<String>) { println(pseudoMemoizedFactorial(6)) println(pseudoMemoizedFactorial(4)) } // debug: factorial(6) // seul résultat mémorisé pour l'appel pseudoMemoizedFactorial(6) // debug: factorial(5) // debug: factorial(4) // debug: factorial(3) // debug: factorial(2) // debug: factorial(1) 720 // debug: factorial(4) // seul résultat mémorisé pour l'appel pseudoMemoizedFactorial(4) // debug: factorial(3) // debug: factorial(2) // debug: factorial(1) 24
Pour appliquer la technique de mémoïzation de manière fonctionnelle, comme nous l’avons fait avec la fonction square, nous devons trouver un autre moyen. Une solution consisterait à récupérer une référence de la fonction à mémoriser dans le corps de la fonction elle-même ! Nous pourrions ainsi capturer les appels imbriqués de la fonction. Il existe des techniques pour y parvenir : le U-combinator en est une.
Technique du U-Combinator
Observons la technique en JavaScript car l’absence de typage statique en autorise une illustration plus simple.
let U = f => f(f) let factorial = U(h => n => (n < 2) ? 1 : n * h(h)(n - 1))
La variable U est simplement une fonction qui à un argument f associe f(f). Cette construction permet à f de recevoir en argument une référence vers elle même. D’après la définition de U, l’expression de la fonction factorial : U(h => n => X) retourne bien une fonction n => X. Nous pouvons donc écrire la fonction factorial de la manière suivante.
let factorial = n => (n < 2) ? 1 : n * h(h)(n - 1)
Notons bien que h est la fonction passée en paramètre de U, par conséquent, nous pouvons remplacer le premier h par la même fonction que l’on note g => m => (m < 2) ? 1 : m * h(h)(m – 1).
let factorial = n => (n < 2) ? 1 : n * [g => m => (m < 2) ? 1 : m * g(g)(m - 1)](h)(n - 1)
Nous pouvons dans un second temps appliquer le paramètre h à la fonction que l’on vient de remplacer : [g => m => (m < 2) ? 1 : m * g(g)(m – 1)].
let factorial = n => (n < 2) ? 1 : n * [m => (m < 2) ? 1 : m * h(h)(m - 1)](n - 1)
Appliquons finalement le paramètre (n – 1) à la fonction [m => (m < 2) ? 1 : m * h(h)(m – 1)].
let factorial = n => (n < 2) ? 1 : n * (n < 3) ? 1 : (n - 1) * h(h)(n - 2)
Nous pouvons répéter le processus plusieurs fois, nous retombons toujours sur une expression qui se termine par un bloc : h(h)(n – x) avec en plus, la concaténation des appels récursifs qui se développent sous nos yeux.
// Résultat après 2 développements let factorial = n => (n < 2) ? 1 : n * (n < 3) ? 1 : (n - 1) * (n < 4) ? 1 : (n - 2) * h(h)(n - 3) // Résultat après 3 développements let factorial = n => (n < 2) ? 1 : n * (n < 3) ? 1 : (n - 1) * (n < 4) ? 1 : (n - 2) * (n < 5) ? 1 : (n - 3) * h(h)(n - 4) // Résultat après x développements let factorial = n => (n < 2) ? 1 : n * (n < 3) ? 1 : (n - 1) * (n < 4) ? 1 : (n - 2) * (n < 5) ? 1 : (n - 3) * ... * h(h)(n - (x + 1))
On remarquera que les appels h(h), dans le corps de la fonction factorial et des développements ci-dessus, permettent surtout de récupérer une référence de la fonction initiale passée à U et transmettent au passage une référence de h aux appels imbriqués suivants. Cette construction, un peu étrange, permet à la fois de concilier récursivité et référence de la fonction dans le corps de la fonction elle même.
Application dans un langage typé : Kotlin
Pour appliquer le même principe dans un langage typé, c’est un tout petit peu plus verbeux mais pas impossible. Et en Kotlin, cela s’avère même assez concis et très proche de la syntaxe JavaScript.
Définissons tout d’abord le type de la fonction factorielle F : une fonction de Int vers Int. En Kotlin nous pouvons le définir à l’aide d’un typealias.
typealias F = (Int) -> Int
Il nous faut ensuite définir le type d’une fonction qui se reçoit en paramètre et retourne une fonction du type F. Cette définition de type ne peut pas se faire par un typealias, car la définition du type est récursive et le compilateur ne l’autorise pas. En Kotlin nous pouvons en revanche, définir une interface qui étend une fonction qui va du type de l’interface elle-même vers le type F. Soit l’interface Handler ci-dessous.
interface Handler : (Handler) -> F
Ce type est important, il permet une invocation qui correspond à l’invocation h(h) que l’on a vu précédemment en JavaScript et qui retourne le type F – soit la fonction que l’on souhaite mémoriser. Nous pouvons implémenter ce type à l’aide d’un singleton. Avec la syntaxe Object Declaration de Kotlin, cela donne :
object H : Handler { override fun invoke(h: Handler): F = { n -> if (n < 2) 1 else n * h(h)(n - 1) } } val factorialOf5 = H(H)(5) // 120
On remarquera toujours l’appel à h(h) qui permet de référencer la fonction récursive que l’on est en train d’écrire ! La seule différence notable avec la version JavaScript est l’introduction du type intermédiaire Handler, qui évite une définition de type récursive interdite par le compilateur.
Nous remarquons tout de même que l’objet H contient la définition de la fonction factorielle, ce qui n’est pas notre objectif. Voyons maintenant comment nous pouvons séparer la fonction à mémoriser du comportement de la fonction memoizer afin de pouvoir utiliser la technique pour n’importe quelle fonction du type F.
Application plus générique, toujours en Kotlin !
Pour réussir la séparation, l’astuce consiste à introduire une fonction memoizer avec la signature suivante :
fun memoizer(f2f: (F) -> F): F
Cette fonction prend en paramètre une fonction f2f qui va de F vers F et retourne une autre fonction F. On peut, en modifiant légèrement la définition de l’objet H défini précédemment, combiner les appels à f2f avec les appels à l’objet Handler.
fun memoizer(f2f: (F) -> F): F { val h = object : Handler { override fun invoke(h: Handler): F = f2f { n -> h(h)(n) } } return h(h) } val memoizedFactorial = memoizer { f -> { n -> if (n < 2) 1 else n * f(n - 1) } } val factorialOf5 = memoizedFactorial(5) // 120
Remarquons comme la fonction f2f se combine bien à l’objet Handler h et à sa référence h(h) (ligne 4). Remarquons également que l’on retourne une fonction du type F obtenue une fois encore par la combinaison h(h) (ligne 7).
La fonction memoizedFactorial (ligne 10) s’écrit en combinant la fonction memoizer avec une fonction factorielle légèrement différente. En effet, cette version est encapsulée dans une fonction qui reçoit un paramètre f. Ce dernier, de type F, correspond bien au paramètre que l’on souhaitait initialement obtenir afin d’appliquer la mémorisation pour les appels imbriqués en plus du premier appel. Il ne nous reste désormais plus qu’à mettre en cache tous les appels à h(h).
fun memoizer(f2f: (F) -> F): F { val cache = mutableMapOf<Int, Int>() val h = object : Handler { override fun invoke(h: Handler) = f2f { n -> cache.computeIfAbsent(n, h(h)) } } return { n -> cache.computeIfAbsent(n, h(h)) } } val memoizedFactorial = memoizer { f -> { n -> if (n < 2) 1 else n * f(n - 1) } } val factorialOf5 = memoizedFactorial(5) // 120
La mise en cache, à la ligne 6, permet de mémoriser tous les appels récursifs tandis que la mise en cache, à la ligne 9, permet quant à elle de mémoriser le premier appel à la fonction.
Conclusion
Nous avons vu que la technique de memoïzation s’applique bien sur des fonctions non récursives. Pour les fonctions récursives, l’application, bien que possible, implique en revanche d’écrire la fonction d’une manière différente : avec une référence f reçue en paramètre et qui permet à la fois de continuer la récursivité et d’intercepter les appels imbriqués. Il est intéressant de voir que Kotlin, langage statiquement typé, permet une écriture élégante et relativement simple à écrire et à lire !
Nous espérons que cet article – un peu atypique – vous a donné envie de vous intéresser au langage Kotlin et à ses possibilités en termes de concision, sûreté et programmation fonctionnelle.
Le code présenté dans cet article est disponible dans ce dépôt Github, tests à l’appui.
P.S. : La technique présentée a été très inspirée par cet article et par ce commentaire sur StackOverflow. Pour les plus curieux, la technique du U-Combinator présentée brievement dans cet article s’inscrit dans le système formel du lambda-calcul.