Pourquoi les langages comme le Haskell, le OCaml, le F# et d’autres encore utilisent la syntaxe intégrant le mot clef let
? Quelle différence y a-t-il donc avec l’allocation des variables dans les langages impératifs ? Dans cet article, je vais tenter de répondre au mieux à ces questions et vous montrer comment étendre ce concept pour finalement présenter les continuations.
Let, deep dive
Dans cette partie, le code va être uniquement en F#. En effet, le Scala n’a pas la syntaxe let.
Pour commencer, définissons maintenant la fonction add qui additionne deux entiers :
let add a b = a+b
En utilisant la curryfication, nous pouvons définir une fonction qui ajoute 2 à un entier :
let add2 = add 2 let c = add2 3
Jusqu’ici, rien de neuf et c vaut 5. Et pourtant nous n’avons pas une seule fois fait usage du mot clef return
. Le concept sous-jacent avec le mot clef return
est que l’on indique ce que le code, une méthode, une fonction doit renvoyer comme valeur.
Dans les langages impératifs, les seules fonctions / méthodes qui n’ont pas besoin de ce mot clef sont celles qui ne retournent rien (void
). Dans le cadre du F# et des langages comme le Haskel ou le OCaml, la fonction retourne la dernière instruction du bloc de code. En fait, les fonctions dans ces langages retournent toujours quelque chose.
Le mot clef let
ici est un binding dans le sens où il lie la valeur de retour d’une expression à une variable qui peut ensuite être utilisée dans une autre expression. En fait, on peut tout à fait écrire en une fois cet enchaînement d’expression en les liant plus fortement.
Par exemple, la définition de la fonction add2 peut s’écrire :
let add2 = let add a b = a + b add 2
D’ailleurs cette syntaxe peut encore être raccourcie :
let add2 = let add a b = a + b in add 2
Il faut noter que l’on a utilisé un nouveau mot clef, le in
. Nous allons détailler un peu cette syntaxe :
let add a b = a + b in add 2
Pour moi, c’est un peu mystique, et j’ai eu beaucoup de difficulté à mettre le doigt sur le pourquoi. En y regardant de plus près, on constate qu’elle permet d’injecter une expression dans une autre. Dans notre exemple, on dit au compilateur :
-
-
- D’abord, prends l’expression :
-
let add a b = a + b
-
-
- Et ensuite, utilise la fonction add dans l’expression :
-
add 2
On obtient alors une fonction de type int -> int
ayant bien l’effet souhaité (ajouter 2 à un nombre).
Faisons un petit point, le mot clef let
permet de lier une variable à une expression. Au runtime, on obtiendra un effet assez proche de la classique assignation dans les programmes impératifs. Cependant, les expressions retournent toujours une valeur et ce qui se trouve à la droite du mot clef est en soit une expression (si vous avez un doute, regardez la définition des expressions lambda par Anatolii).
La syntaxe let
E
in
ContE
va un cran plus loin : elle injecte E
dans la continuation de l’expression contE
.
Ici, on voit que j’ai pas mal employé les termes d’expression à la syntaxe que l’on utilise ici. Voilà pourquoi l’interprétation de ce genre de code peut simplement se voir comme une bêta-réduction.
Le lien avec les fonctions Lambda
En changeant un peu de point de vue, ce que l’on dit ici, c’est que l’on peut réécrire ce code en utilisant uniquement des lambda.
Commençons par la fonction add
. En utilisant la curryfication, on peut l’écrire ainsi :
let add = fun a -> (fun b -> a + b)
Et en appliquant 2 à add
comme précédemment, on obtient le même résultat :
let add2 = fun a -> (fun b -> a + b) 2
Vous aussi, vous avez remarqué ? Cela peut s’écrire de façon beaucoup plus courte. Cependant ici, le but est de reproduire les itérations précédentes, et non de réduire le code.
La « déconstruction » de cette syntaxe, en utilisant uniquement les fonctions lambda, n’est pas franchement étonnante étant donné le rapport qu’il existe entre la programmation fonctionnelle et le Lambda calcul. On pourrait même pousser l’expérience jusqu’à réécrire tout un programme de cette façon. Une expérience qui serait certes peu lisible mais néanmoins possible.
Mais pourquoi finalement cela serait moins lisible ?
Dans la dernière écriture, par exemple, on voit que l’allocation des variables n’est pas le plus important pour faire fonctionner le programme. Le binding a été recréé manuellement en utilisant l’application partielle (curryfication) sur les Lambda. En revanche, ce que l’on perçoit moins bien, c’est comment le programme s’articule, quel est son sens.
Voilà ce que les premières écritures ont d’intéressant : elles décrivent bien comment le programme travaille. Pour moi, c’est parce qu’elle porte une qualité descriptive de ce que font les programmes fonctionnels. En d’autres termes : ce type de programme est plus proche d’une description de ce qu’il faut faire (comment les variables sont créées, comment les instructions s’enchaînent) que d’une suite d’instructions adressée au processeur.
Les continuations
Éclaircir le mot clef let
a été pour moi une véritable avancée dans la compréhension des langages fonctionnels. Mais il est possible d’aller au-delà des aspects que je viens d’évoquer et d’en tirer d’autres enseignements.
Pour cela, il faut que nous changions notre regard sur la forme de la méthode add2
. Nous allons la réécrire en remplaçant les let binding par des fonctions Lambda. On peut par exemple obtenir cette forme :
let add2 = fun a -> (fun b -> a + b) 2
La partie ci-dessous consiste à appliquer une fonction à un argument (et non d’appliquer un argument à une fonction) :
(fun b -> a + b) 2
Pourquoi cette inversion de point de vue ? Voici une autre façon de décrire ce point de vue : l’argument est vu comme l’état courant du programme et la fonction comme la suite à appliquer à notre programme, la continuation.
Avant d’entrer dans des exemples, essayons de motiver un peu ce que l’on veut faire. Et pour cela, employons un petit morceau de code comme on pourrait en trouver en C# :
try { doSomething(a); } catch(Exception e) { log.Error(e.Message); throw e; }
Dans ce morceau de code, c’est la méthode qui a le contrôle sur ce qu’il faut faire dans le try
et en cas d’erreur (dans le catch
).
Dans le cadre des continuations, on passerait en argument une fonction pour décrire quoi faire en cas d’erreur. On peut même passer ce que l’on doit faire (doSomething
dans le code) en argument. L’utilisation de continuations inverse le contrôle et permet à l’appelant de mieux maîtriser le flux.
Soit dit au passage, ce concept de continuations est exactement ce qui est appliqué au Task
en C# avec la méthode ContinueWith et l’utilisation des TaskContinuationOptions.
Nous allons maintenant développer un peu notre exemple en C# en F# et en Scala.
Un exemple de gestion de workflow
On veut écrire un workflow gérant une série de calculs. Dans ce genre de problématique, on souhaite pouvoir avant tout se concentrer sur le code métier, le calcul. Si on pense au flux de données, on peut le voir comme un enchaînement de fonction, disons :
f1 : ‘a-> ‘b, f2 : ‘b->’c et f3 : ‘c-> ‘d
Clairement, on peut définir un flow en enchaînant les trois fonctions. Comment dès lors gérer les erreurs ?
Il faut prévoir une façon générique de gérer les erreurs qui peuvent survenir pour pouvoir se concentrer sur l’écriture de nos fonctions métier. On va donc faire en sorte que les fonctions soient exécutées de façon à récupérer les éventuelles erreurs. On peut alors ré-aiguiller l’erreur vers une fonction conçue pour les gérer.
Par exemple en F# :
let handleFlow arg fn onError = try fn arg with e -> onError e
Et en Scala :
def handleFlow[A, B] (a:A, fn_A=>B, onError_Exception=>B)= { try { fn (a) } catch { case e:Exception => onError (e) } }
Maintenant que nous avons de quoi gérer les erreurs, une autre question se pose, que doit-on retourner en cas d’erreur ? On va utiliser pour cela le type Option qui est parfaitement adapté à notre cas d’utilisation. Nous avons déjà évoqué ce type dans l’article parlant des types fonctionnels. Si le calcul aboutit, on renverra le résultat encapsulé dans un Some
, sinon nous renverrons None
. On obtient le code suivant en F# :
let handleFlow arg fn onError = try match arg with | Some value -> fn value |> Some | None -> None with e -> onError e None
Et en Scala :
def handleFlow[A, B] (a:Option[A], fn_A=>B, onError_Exception=>Unit)= { try { a match { case Some (value) => Some(fn(value)) case None => None } } catch { case e:Exception => { onError e None } } }
Dans ce code, toute la logique de gestion de la donnée dans le “pipeline” de calcul est gérée dans la fonction handleFlow
. C’est ce que nous voulions. Nous pouvons alors maintenant déterminer les fonctions qui vont définir ce “pipeline”. De plus, nous avons délégué la gestion des erreurs à une fonction tierce qui gère la continuité de l’erreur. Cela n’arrête pas pour autant le flux dans le pipeline puisque l’on continuera de protéger la valeur None
.
La fonction métier passée en argument de handleFlow
est elle-même une continuation.
L’intérêt ici est de contenir les effets de bord dans des zones restreintes du code, ce qui facilite la maintenance. Pour moi, il s’agit d’un point important. En effet, la chasse aux effets de bord dans la programmation objet constitue la principale perte de temps lorsque l’on corrige le programme. Bien entendu, la façon dont les effets de bord sont gérés et maintenus ne dépend que des développeurs qui écrivent le code. Même en POO, il est possible de les circonscrire. Mais ici, c’est finalement assez court : on le fait en une seule fonction et on peut s’appuyer sur des types simples préexistants pour maintenir la cohérence.
Voici comment on peut écrire un pipeline assez simple en F# :
let f1 a = a + 2 let f2 b = (float) b let f3 c = if (c = 0.) then failwith "Division by 0." else 1. / c let onError(e : exn) = do printfn "Error: %s" e.Message let partOfFlow fn a = handleFlow a fn onError let flow a = Some a |> partOfFlow f1 |> partOfFlow f2 |> partOfFlow f3 let maybeinvert = flow(0) let maybeinvert2 = flow(-2)
Et en Scala :
def f1 (a:Int) = a+2 def f2 (value:Int) = Int.int2double(value) def f3 (value:Double) = { if (value == 0) throw new Exception("Division by zero.") Else value } def onError[A] (e:Exception) : Unit = println(e.getMessage()) def partOfFlow[A, B] (fn:A=>B, a:Option[A]) = handleFlow (a, fn, onError) def flow (i:Int) = { partOfFlow (f3, (partOfFlow (f2, (partOfFlow (f1, Some(i)))))) } val maybeinvert = flow (0) val maybeinvert2 = flow (-2)
Ainsi, l’exécution avec la valeur 0 retournera Some 0.5
tandis qu’avec la valeur -2, on verra l’erreur s’afficher et on obtiendra la valeur None
.
Nous sommes maintenant capables de gérer simplement le flux de calcul en maîtrisant ce que l’on fait des exceptions. Toutefois, même si cette logique est présentée dans le cadre de la programmation fonctionnelle, l’idée reste exploitable en programmation impérative, pourvu que l’on puisse manipuler des pointeurs de fonction ou, au moins, passer des fonctions en argument des fonctions. Il peut même s’avérer très utile de procéder ainsi.
Il est possible de généraliser cette approche des continuations et de fournir un code un peu plus générique. Je vous en reparlerai dans un prochain article. Je profiterai de cette généralisation du code des continuations pour vous présenter un éléments de langage du F# que je trouve très intéressant, les expressions (vous trouverez une belle présentation ici).
Cet article est largement inspiré d’articles de ce blog. De mon point de vu, il s’agit d’une des meilleures références pédagogiques pour le F#. Si vous y prenez goût au F#, alors je vous recommande vivement ces lectures. Voici les sources des références de cet article :
Bonne lecture !
Pas encore de commentaires