Concepts fonctionnels
un petit bilan
Avec la redécouverte des langages fonctionnels, poussés notamment par le besoin de parallélisme sur les architectures multi-cœur et les problématiques de passage à l’échelle, différent concepts, inconnus du développeur non familiarisé avec l’approche fonctionnelle, deviennent populaires. On entend ainsi de plus en plus parler de lambda, closure, voire même de partial ou currying.
Le but ici n’est que de présenter ces différents concepts de manière succincte, sans forcément en illustrer toute la puissance, mais pour permettre de s’y retrouver un peu dans ce jargon. Que les puristes me pardonnent donc les raccourcis et simplifications.
Lambda ¶
Les lambda, dont le nom est hérité du λ-calcul, sont un des fondements (au moins théorique) des langages fonctionnels. Si l’on fait abstraction du sucre syntaxique, ce n’est ni plus ni moins qu’une fonction anonyme.
Dans le contexte des langages fonctionnel, les fonctions sont des valeurs comme les autres, et peuvent donc être affectées à des variables (ou plutôt des symboles), passées en paramètre ou être retournées par d’autres fonctions (on parle de fonctions d’ordre supérieur). C’est ce que l’on appelle des valeurs de première classe (sans lien avec l’objet, mais plutôt avec les trains).
L’avantage d’une lambda
par rapport à une fonction « normale » est qu’elle est crée à la demande, avec
une portée locale, et peut être récupérée par le garbage collector
lorsqu’elles ne sont plus nécessaires. Ceci est particulièrement utile avec les
paradigmes de programmation fonctionnelle, qui font une grande utilisation des
fonctions d’ordre supérieur (composition, travail sur les listes tels que
map
, filter
et reduce
), ainsi qu’en
programmation événementielle, où l’on affecte directement une fonction au
handler de l’événement. Dans la plupart des cas, ces fonctions sont à « usage
unique », et il est donc plus simple de les définir par lambda.
Dans certains langages, une fonction lambda doit être
fonctionnellement pure,
c’est-à-dire retourner une valeur qui ne dépend que de ses paramètres d’entrée
et ne pas avoir d’effet de bord. L’application de la fonction peut donc
toujours être remplacé par le résultat sans changer la sémantique du programme
(c’est ce que l’on appelle la transparence référentielle).
Dans ces cas là, le mot-clé
return
est omis, et l’application de la fonction est en fait
considérée comme une expression, ce qui apporte un sucre syntaxique: la
fonction est écrite comme une fonction mathématique (ceci n’est d’ailleurs pas
limité au lambda dans les langages purement fonctionnels).
Les extraits de code suivants comparent la définition d’une fonction normale avec la définition par lambda.
Exemples d’utilisation
Map
Événements
Une autre utilisation des lambda, c’est justement les closures.
Closure ¶
Formellement, une closure, ou clôture, ou fermeture, est le fait de « capturer » des références à des variables, ou des valeurs, extérieures directement dans la définition d’une fonction.
Cela prend en général la forme d’une fonction d’ordre supérieur, qui retourne une fonction dont la définition va dépendre des valeurs données, le plus souvent grâce à une lambda.
Dans les exemples précédents, la variable a
est close
dans la définition de la fonction anonyme retournée. Un appel à
adder
retourne donc une fonction, crée pour l’occasion, qui sera
utilisable comme toute fonction.
La fonction adder
permet donc de créer une famille de fonctions, paramétrées par
a
.
On peut remarquer qu’il n’est pas nécessaire de récupérer la fonction retournée
dans une variable, on peut l’utiliser directement comme
adder(21)(21)
.
De ce point de vue, les fermetures sont en quelque sorte le dual de l’objet, dans lequel on capture du comportement (les méthodes) au sein de la structure de donnée. Ici, on capture des données (les valeurs closes) au sein du traitement. Dans les deux cas, ces constructions permettent de définir une famille d’éléments de traitement paramétrés par leur état. Plutôt que de créer une nouvelle instance d’une classe, la closure vas donc créer une nouvelle fonction à chaque appel (fabrique de fonction).
C’est d’ailleurs l’approche souvent utilisée pour simuler des closures dans les langages n’en disposant pas : on crée une classe callable (souvent en inner class) dont les attributs représentent les valeurs closes, comme illustré ci-dessous en Python, qui est équivalent à la closure précédente.
À noter que même si on utilise classiquement les fonctions anonymes pour créer des fermetures, cela n’est pas nécessaire si l’on dispose de fonction internes. Encore une fois, en python, cela donne:
Exemple d’utilisation
Un exemple classique est en programmation événementielle pour créer une famille de handlers facilement.
Ici, on dispose d’une fonction prenant un message en paramètre et
l’affichant. On ne peut l’utiliser telle qu’elle comme handler, puisque
ces fonctions prennent un unique argument, l’événement. On crée donc la
fermeture handleWithMessage
, qui prend le message à afficher en
paramètre, et nous retourne une fonction utilisable (qui prend un événement et
affiche le message). Le message est clôt dans la définition de cette fonction
(au sens strict, c’est la fonction retournée qui est une closure, pas
handleWithMessage
qui est plutôt une fabrique).
Application partielle ¶
L’utilisation des fermetures nous amène naturellement à l’application partielle de fonctions (ou partialisation).
Une application partielle consiste à fixer un ou plusieurs arguments d’une fonction polyadique. On obtient ainsi une nouvelle fonction, acceptant d’autant moins de paramètres. Cela revient donc à créer une fermeture d’une fonction existante sur un ou plusieurs de ses arguments.
Dans les langages acceptant des paramètres indéfinis, il est facile de créer une fonction de partialisation générique. Dans les autres cas, il faut définir une fonction de partialisation par arité de fonction, ce qui peut vite être fastidieux (ici, pour des fonction binaires).
Dans les langages acceptant les paramètres nommés, il est possible de
partialiser n’importe quel paramètre, et pas uniquement le premier. Python
dispose d’ailleur d’une fonction de partialisation
(functools.partial
, recodée ici pour la démonstration),
ainsi que Golo (:bindWith
).
Les plus habitués aux langages fonctionnels auront remarqué que pour
partialiser facilement une fonction, on peut réduire la liste des arguments
avec la fontion de partialisation. Par exemple en python, on peut écrire
superadd = reduce(partialize, [14,14], add3)
.
Je reviendrais sur les réductions (reduce), ainsi que les map vue plus haut (map, reduce, ça vous rappelle un truc ?), dans un autre article.
Curryfication ¶
L’exemple Caml (et Haskell) est purement didactique, car ce langage permet directement de faire une application partielle sur n’importe quelle fonction. Le simple fait d’appeler une fonction avec moins de paramètres que nécessaire retourne une application partielle.
L’addition en Caml étant une fonction comme les autres (mais infixée), on peut même écrire cela plus simplement, comme dans le dernier exemple.
Dans Caml, mais aussi les autres langages ML et leurs dérivés,
Haskell et bien d’autres, les fonctions sont dites
auto-curryfiées. Ainsi, une fonction de plusieurs paramètre est en
fait définie en interne comme une fonction d’un seul paramètre, retournant une
fonction d’un seul paramètre, retournant une fonction d’un seul paramètre, etc.
D’où la signature de l’addition: add : int → int → int
, qui peut
s’écrire add : int → (int → int)
(une fonction prenant un entier
et retournant une fonction prenant un entier et retournant un entier).
Curryfier une fonction polyadique (du mathématicien américain Haskell Curry, qui a aussi donné son nom au langage fonctionnel), c’est donc la transformer en un enchaînement de fonctions monadiques, ou plus simplement, la rendre automatiquement partialisable.
Cela revient donc à définir notre fonction comme une série de lambda imbriquées, chacune étant une fermeture sur le paramètre de la précédente.
Ainsi, il n’est plus nécessaire de partialiser explicitement ces fonctions, puisque l’invocation avec un nombre insuffisant de paramètre donnera une fonction partielle.
Encore une fois, il est possible d’écrire une fonction d’auto-curryfication, c’est-à-dire une fonction prenant en paramètre une fonction et en retournant une version curryfiée (que je laisse en exercice au lecteur).
Pour conclure
J’espère que ces concepts sont désormais plus clairs. Si vous avez des questions ou des remarques, n’hésitez pas.
Je continuerais probablement à affiner cet article, notamment en ajoutant des exemples dans d’autres langages ; j’aime assez ce genre d’exercice, qui aide à voir les concepts au-delà de la syntaxe et des spécificités du langage.
J’ai fait une suite à cet article, présentant une simulation de ces concepts en Java.
Références
Si vous voulez creuser un peu les langages présentés ici…
- Teach Yourself Scheme in Fixnum Days
- The Scheme Programming Language
- The Golo Programming Language
- Python
- Javascript
- O’Caml
- Developing Applications With Objective Caml
- The OCaml system
- The Haskell Programming Language
- Real World Haskell
Mises à jour
- : post initial
- : ajout d’extraits de code Golo
- :
- ajout de la remarque diverses;
- ajout de quelques exemples Golo;
- modification de la partie sur les applications partielles;
- section de références;
- ajout de Scheme;
- ajout de check en début pour sélectionner les langages affichés.
- : traduction anglaise
- : ajout d’exemples en Haskell
- : mise en ligne de la présentation de l’APIHour