En fonction du langage

La programmation fonctionnelle

Maintenant que nous savons ce qu’est une fonction et comment ce concept est traduit en assembleur, il est temps de nous aventurer vers des contrées plus théoriques. Comme la programmation orientée objet place le concept d’objet au cœur de la manière d’organiser son code, la programmation fonctionnelle fait la même chose avec le concept de fonction. Si la programmation orientée objet est facile à comprendre car ses notions sont relativement terre à terre, la programmation fonctionnelle est très fortement liée à une approche plus mathématique de la programmation; il faut donc revoir beaucoup de ses a priori sur la programmation lorsque l’on passe de l’un à l’autre. Je présenterais ici quelques une des caractéristiques communes à tous les langages fonctionels; néanmoins il faut savoir que la programmation n’est pas un tout unifié, et que par exemple il existe des différences fondamentales entre OCaml et Haskell, que je n’expliciterai pas pour le moment.

Curryfication

Commençons par redéfinir le concept de fonction. Ici, la fonction est mathématique : elle prend un élément d’un ensemble et le transforme en un élément d’un autre ensemble. Les ensembles dont on parle sont précisément les types, qui prennent un tout autre sens qu’en programmation orientée objet. Prenons un exemple : comment, en programmation fonctionnelle, écrire une fonction de plusieurs variables comme l’addition? Voilà la réponse en OCaml :

val add : int -> int -> int
let add a b = a + b

Arrêtons nous un moment pour déchiffrer cette nouvelle syntaxe obscure. Commençons par val add : int -> int -> int. val veut dire que l’on déclare le type d’une variable, ici add. Le type est ce qui suit :, c’est à dire int -> int -> int. La flèche -> désigne une fonction, mais pourquoi le type de add possède deux flèches alors que c’est une unique fonction? Ajoutons des parenthèses qui sont ici implicites : le type devient : int -> (int -> int). Ceci signifie que la fonction prend en argument un entier et renvoie… une autre fonction! Et oui, en programmation fonctionnelle une fonction n’est pas qu’un segment de code, c’est aussi un objet que l’on peut manipuler, stocker et appliquer à sa guise. Ainsi, appliquer add à 1 noté add 1 (on noterait add(1) en mathématiques) renvoie une autre fonction, à laquelle on peut par exemple appliquer 2 : (add 1) 2 (ou (add(1))(2) en notation mathématique), pour enfin se voir renvoyer 3.

Ainsi, une fonction à plusieurs arguments peut être décomposée en une suite de fonctions imbriquées les unes dans les autres. On peut donner à la fonction tous les arguments nécessaires en une fois afin de se voir retourner le résultat final, ou bien l’on peut appliquer la fonction partiellement avec une partie des arguments, puis stocker la fonction retournée jusqu’à ce que le reste des arguments soit disponibles, plus tard dans l’exécution du programme. Le processus de transformation d’une conjonction d’arguments en plusieurs fonctions emboîtées s’appelle la curryfication, en l’honneur du mathématicien Haskell Curry.

En programmation orientée objet, les données sont stockées dans les objets et modifiées par les méthodes. Néanmoins, une méthode ne peut pas changer la classe d’un objet; la programmation fonctionnelle adopte donc une vision plus dynamique et flexible de la transformation des données. Et c’est précisément la curryfication qui permet de stocker un état d’exécution à l’intérieur d’une fonction devenue « objet ». Cet outil est très bien adapté à des situations où il faut réagir à des événements extérieurs, comme des entrées de l’utilisateur ou des requêtes réseau. Javascript, qui tourne dans les navigateurs Internet, peut être qualifié de langage fonctionnel, et ses dernières versions se rapprochent de plus en plus d’une vraie philosophie fonctionnelle. En attendant, il est possible de développer des applications tournant à l’intérieur du navigateur en Elm, dérivé de Haskell qui offre un paradigme très intéressant et très adapté au contexte du Web.

Somme de types et filtrage par motif

Toutes puissantes que soient les fonctions, il est néanmoins toujours nécessaire d’organiser les données que l’on passe en arguments à celles-ci. Les langages fonctionnels offrent, comme les langages orientés objets, le moyen d’agréger les données à l’aide de tuples (paire, triplets) ou de structures où chaque champ est nommé (comme un objet, mais sans les méthodes). Mais comment la programmation fonctionnelle gère-t-elle les systèmes de classes et d’héritage de la programmation orientée objet? L’idée principale est de proposer un nouvelle façon de définir une structure de donnée, qui reflète la disjonction de cas. Par exemple, si l’on veut que notre fonction prenne en argument une machine à café à poudre ou une machine à café à dosette, il suffit de créer un type machine à café définit précisément comme l’un ou l’autre :

type machine_a_cafe =
  | Poudre of volume_cafe * quantite_poudre
  | Dosette of volume_cafe * nombre_dosettes

Cette simple déclaration de type remplace deux classes et une classe abstraite ou une interface, qu’il aurait fallu déclarer en programmation orientée objet. Ce type « l’un ou l’autre » est appelé type somme pour des raisons qui seront expliquées dans un futur billet. Mais maintenant, comment implémenter la fonction boire_cafe qui diminue le volume de café? En programmation orientée objet, il faut implémenter deux fonctions, une dans chaque classe. En programmation fonctionnelle, il en suffit d’une! Mais pour l’implémenter, il nous faut utiliser une autre caractéristique des langages fonctionnels, le filtrage par motif ou pattern matching en anglais. Voilà ce que cela donne :

let boire_cafe (machine : machine_a_cafe) : machine_a_cafe =
  match machine with
    | Poudre(vol_cafe,poudre) -> Poudre(vol_cafe - 1,poudre)
    | Dosette(vol_cafe,dosettes) -> Dosette(vol_cafe - 1,dosettes)

Expliquons le code ci-desssus. On définit une fonction qui prend une machine_a_cafe et qui renvoie une machine_a_cafe possédant moins de café dans sa réserve. Le code de la fonction consiste en cet étrange notation match machine with : ce qui suit consiste à définir ce que doit faire la fonction dans chacun des options que peut être une machine_a_cafe. Ainsi on a l’option Poudre(vol_cafe,poudre) qui décrit complètement une machine à café à poudre; dans cette option on renvoie (après le ->) bien une nouvelle machine à café à poudre avec un peu moins de café. En plus de séparer les différentes possibilités, on voit que le filtrage par motif nous donne accès aux données à l’intérieur de chaque possibilité, nous évitant d’avoir à accéder à des champs d’objets ou de définir des variables intermédiaires.

Le filtrage par motif associé au type somme est un outil extrêmement puissant qui simplifie énormément de situations où la programmation orientée objet nous force à être verbeux; le code en langage fonctionnel est donc généralement plus concis et puissant. Cependant, la concision se fait parfois au détriment de la lisibilité; en effet contrairement à la programmation orientée objet en fonctionnel il n’est pas obligatoire de déclarer tous les types de ses variables grâce à l’inférence de types, concept qui sera détaillé plus tard. Mais ne pas déclarer ses types peut rendre le code assez cryptique, par exemple lorsque l’on a des fonctions comme :

let f_2 a c x y = match y with
  | A(c') -> c' f_2
  | B -> x a

Immutabilité

Un autre aspect frappant des langages fonctionnel est l’immutabilité par défaut des variables. En effet, puisque les fonctions transforment les valeurs, il n’est plus question de changer la valeur d’une case mémoire. Ceci a plusieurs avantages : premièrement, le développeur n’a pas à gérer de pointeurs, évitant ainsi tous les bugs et erreurs classiques qui leur sont associée. Deuxièmement, puisque le développeur ne peut pas modifier les objets en mémoire, cela permet au compilateur de s’arranger pour mettre en commun le plus possible entre objets. Par exemple, supposons que la fonction ajouter_un prenne une liste d’entiers en arguments et renvoie une nouvelle liste semblable à la précédente, mais où le premier nombre a été incrémenté de 1. Et bien cette nouvelle liste partagera en mémoire avec l’ancienne tous les éléments sauf le premier; le compilateur ayant détecté que les éléments suivants restent inchangés. Ainsi, restreindre le développeur concernant la manière d’accéder à la mémoire autorise des optimisations bienvenues. Mais loin d’être une source de frustration, l’immutabilité permet de développer une autre manière de penser la programmation.

Par exemple, imaginons que nous voulons ajouter 1 à tous les éléments d’une liste (ou d’un tableau) appelé A de taille n. En C, on utilise une boucle :

for (i = 0 ; i < n ; i++ ) {
    A[i] += 1;
}

Mais en OCaml, pas question de modifier la mémoire directement; plutôt, on va utiliser une fonction spéciale, List.map, qui a le même effet qu’une boucle. Cette fonction prend en argument le tableau sur lequel on va opérer, mais également le corps de la boucle qui devient, bien sûr, une fonction. Voilà ce que ça donne en OCaml:

let new_A = List.map (fun x -> x + 1) A

Cette expression retourne une nouvelle liste dont tous les éléments ont été incrémentés de 1. Cette approche fonctionnelle des structures de données est de plus en plus populaire en traitement de données massives (Spark) et pour l’écriture de programmes concurrents car elle permet de raisonner à plus haut niveau sans tomber dans les pièges de la manipulation de pointeurs.

Conclusion

Ainsi, les langages fonctionnels ne sont pas l’outil ultime qui résout tous les problèmes des développeurs, mais plutôt un paradigme ancré dans le raisonnement mathématique qui offre une approche plus logique et équilibrée de la manipulation de données, résultant en du code en général moins verbeux que la programmation orientée objet. La majorité des langages fonctionnels modernes se basent sur un système de types très développé, ce qui permet au compilateur de prouver beaucoup de choses sur le code et de fournir beaucoup d’aide au développeur pour corriger ses erreurs; compiler sans erreurs en OCaml ou en Haskell est un gage sérieux de justesse du code, même si cela n’évite pas toujours le débogage traditionnel. Cependant, les abstractions présentées ici sont très haut-niveau et entraînent une pénalité en terme de performance lors de la traduction vers l’assembleur, comme l’obligation d’utiliser un GC. Un compromis très intéressant entre performance et système de types fort est le langage Rust, auquel je consacrerais sûrement un futur billet.

Pour aller plus loin

  • Une initiation à la programmation fonctionnelle en OCaml, d’une quarantaine de page (en français).
  • Une application Web permettant d’essayer OCaml dans son navigateur.