Introduction à la programmation fonctionnelle avec Elixir

Loïc, membre de la communauté de blogueurs JobProd.
Elixir est un langage de programmation fonctionnelle fonctionnant sur la machine virtuelle Erlang (aka. BEAM).

BEAM permet à Elixir de tirer parti de tous les bénéfices qu’apporte Erlang pour construire des applications concurrentes, distribuées et tolérantes aux pannes avec rechargement de code à chaud (hot code upgrades).

Ruby + Python + Clojure = Elixir

Elixir is everything good about Erlang and none — almost none — of the bad. That’s a bold statement, right? Elixir is what would happen if Erlang, Clojure, and Ruby somehow had a baby and it wasn’t an accident.

Elixir est un langage fonctionnel créé par José Valim. José est un contributeur Rails (le 4ème par le nombre de commits) et créateur de nombreuse gems très utilisées avec le framework (SimpleForm ou Devise par exemple).
Elixir est donc un langage fortement inspiré par Ruby par la syntaxe qui est en très proche. Il en est à l’opposé par le paradigme qu’il apporte (Fonctionnel vs. Orienté Objet)
Mis à part Ruby, José s’est aussi inspiré de plusieurs langages en reprenant leurs meilleures fonctionnalités telles que les doctests, docstrings ou les list comprehensions de Python ou le pipe operator et leiningen de Clojure.
Elixir est donc un concentré de bonnes pratiques et de petites choses qui en font un délice à utiliser tout en changeant radicalement sa façon de programmer.

Installation

L’installation est relativement simple.
Le guide le plus à jour se trouve sur le site officiel du projet. Le langage n’ayant pas encore atteint le stade de la version 1.0, c’est, aujourd’hui, la source la plus fiable.
Note: il est recommandé d’utiliser les packages d’Erlang Solutions installer Erlang plutôt que d’utiliser Homebrew ou tout autre système de gestion de packages.

iex REPL

A l’instar de Python et Python Shell, Ruby et irb, Elixir propose son shell iex (Interactive Elixir).
Dans cette interface en ligne de commande, il est possible de tester rapidement les concepts d’Elixir mais aussi de consulter la documentation complète du langage et de sa librairie standard.
Par exemple, pour obtenir la documentation du module String, il suffit d’utiliser la fonction h en lui passant le nom de la fonction en paramètre.

$ iex iex> h(String)

Cela marche aussi avec les noms de fonctions :

iex> h(String.capitalize)

Bonus, la documentation est colorée, bien présentée avec des doctests d’exemple.

Dans la suite de cet article, les exemples commençant par iex pourront être exécutés dans la console.

Programmation fonctionnelle

Erlang est un langage fonctionnel. Il implémente la plupart des concepts de ce paradigme :

  • Les fonctions sont des données.
  • Les données sont immutables et la transparence référentielle est respectée.
  • La récursion est utilisée plutôt que les boucles itératives.
  • Les assignations sont agrémentées du pattern matching.

Commençons par un petit tour rapide de la syntaxe du langage avant de voir comment on peut tirer parti de ces concepts fonctionnels.

Syntaxe

Définition de fonction

En Elixir, plus d’objet, mais uniquement des fonctions et des données classées dans des modules.
On peut définir une fonction comme suit :

def sum(a, b) do a + b end

Que l’on peut appeler avec :

iex> sum(20, 22) 42

On remarque qu’à l’instar de Ruby, le return est implicite; la dernière expression évaluée est celle retournée par la fonction.

Fonctions anonymes
Les langages fonctionnels font la part belle aux fonctions. Elles peuvent être passées en paramètre d’autres fonctions. Elles peuvent aussi être des valeurs de retour d’autres fonctions (on parle alors d’high order function). Pour ce faire le langage permet de créer des fonctions anonymes.

sum = fn(a, b) -> a + b end

Ces fonctions sont appelables de la manière suivante :

iex> sum.(20,22) 42

Ici, plus de do. En effet, ces fonctions sont souvent définies directement lorsqu’on les passe en paramètre de fonction. Le symbole -> délimite alors le corps de la fonction anonyme.

iex> Enum.map([« Bob », « Patrick », « Gary », « Carlo »], fn(name) -> « Hello, #{name} » end) [« Hello, Bob », « Hello, Patrick », « Hello, Gary », « Hello, Carlo »]

Modules

Les fonctions peuvent être regroupées dans des modules pour les classer.

defmodule Greeter do def greet(name) do IO.puts « Hello, #{name} » end end

Pour appeler une fonction dans un module depuis un autre module, on utilise la syntaxe suivante :

iex> Greeter.greet(« Bob »)

Note : Comme en Ruby, les fonctions définies dans le module Kernel n’ont pas besoin d’être préfixées par le nom du module pour être appelées.

Types de données

Outre les fonctions, un autre élément est fondamental en Elixir, les données.
On retrouve les types de données classiques de la plupart des langages avec quelques subtilités.

iex> 1 # integer iex> 0x1F # integer iex> 1.0 # float iex> :atom # atom / symbol iex> {1,2,3} # tuple iex> [1,2,3] # list iex> <<1,2,3>> # bitstring iex> « hellö » # UTF-8 encoded string

Les string en Erlang sont un peu différentes de celles des autres langages. Elles sont appelées binary et sont représentées comme une liste de bits (bitstring). Les string entourées de doubles quotes sont encodées en UTF-8.

Les chaines entourées de simples quotes ne sont pas des string mais des listes de caractères (charlist).

Les listes sont de vraies listes chainées. Accéder au premier élément n’est pas coûteux, accéder au dernier beaucoup plus car on doit parcourir toute la liste avant.

Les tuples sont stockés de manière contiguë en mémoire. L’accès aux éléments d’un tuple se fait en temps constant.

Il faut donc veiller à bien choisir son type de données en prenant en compte ces aspects.

Liste et tuples peuvent être combinés pour former des Keyword List (que l’on appelle Hash Map ou tableaux associatifs dans d’autres langages).

iex> [{:key1, « value1 »}, {:key2, « value2 »}] [key1: « value1 », key2: « value2 »]

Enfin, les atoms sont l’équivalent des symbols (pour les Rubyists) ou des Keywords (pour les Clojuristes), pour les autres, il s’agit d’identifiants qui sont souvent utilisés comme clé d’une Hash Map. On aurait pu utiliser des strings pour cela, mais les atoms ont l’avantage de n’être allouées qu’une seule fois, ce qui économise de la mémoire.

Opérateurs

Elixir met a disposition les opérateurs classiques des langages de programmation.

Arithmétiques
+, -, *, / sont les opérateurs arithmétiques.
A noter que / correspond à la division exacte. La division entière s’obtient avec Kernel.div

iex> 10 / 3 3.3333333333333335 iex> div(10, 3) 3

Binaires

and # Logical ‘and’, short-circuits or # Logical ‘or’, short-circuits === # A match operator !== # A negative match != # Not equals <= # Less than or equals

Les opérateurs and et or sont fainéants.
On vient de faire un petit tour rapide de la syntaxe de base d’Elixir. Jouons maintenant avec les concepts fonctionnels apportés par le langage.

Pattern Matching

En Erlang, les assignations de variables (au sens C) n’existent pas. Elles sont remplacées par le Pattern Matching qui est une version plus poussée de l’assignation classique.
Commençons par un exemple :

iex> 10 = 10 10

L’opérateur = réalise un pattern matching entre l’expression à sa gauche et celle à sa droite.
Il y a match quand les deux valeurs sont égales comme c’est le cas dans l’exemple précédant. On a une MatchError dans le cas échéant.

iex> 10 = 42 ** (MatchError) no match of right hand side value: 42

En fait, l’opérateur = peut être vu comme l’opérateur mathématique =. Il vérifie que les expressions à gauche et à droite de celui-ci sont équivalentes.
Le pattern matching peut être utilisé à beaucoup d’endroits comme lorsque l’on défini une fonction.
Si nous écrivons la fonction suivante en Ruby par exemple :

def greet(gender, name) if gender == :male puts « Hello, Mr. #{name} » else if gender == :female puts « Hello, Mrs. #{name} » else puts « Hello, #{name} » end end

Celle ci peut s’écrire en Elixir et en utilisant le pattern matching de la manière suivante :

def greet(:male, name) do IO.puts « Hello, Mr. #{name} » end def greet(:female, name) do IO.puts « Hello, Mrs. #{name} » end def greet(_, name) do IO.puts « Hello, #{name} » end

Lors de l’appel de greet, un pattern match va être effectué avec les arguments passés à la fonction.
Si le premier est égal à :male, la première implémentation va être exécutée. S’il est égal à :female, c’est la seconde. Dans tous les autres cas, la dernière implémentation sera exécutée. C’est le sens du _ qui match n’importe quelle valeur.
Le pattern matching permet donc de définir des fonctions à la « manière des mathématiques ».
Par exemple, l’algorithme de la factorielle en Elixir peut s’écrire comme suit :

Module Math do def factorial(0) do 1 end def factorial(n) do n * factorial(n-1) end end

En comparaison avec la définition mathématique

0! = 1 n! = n × (n – 1)!

Par ailleurs, lorsque l’élément à gauche de l’opérateur = est un identifiant de variable (on parle de variable unbound), alors la valeur de l’expression à droite de l’opérateur lui est affectée. C’est une assignation « classique ».

iex> var = 1 1 iex> var 1

Le pattern matching est aussi beaucoup utilisé pour extraire les valeurs d’une liste. On parle alors de destructuring.

iex> [head | tail] = [1, 2, 3, 4] [1, 2, 3, 4] iex> head 1 iex> tail [2, 3, 4]

Ici, on associe le premier élément de la liste [1, 2, 3, 4] à head et le reste de la liste à tail grâce à l’opérateur |.

On peut faire cela aussi avec des tuples par exemple.

iex> {first, _, third} = {:first, :second, :third} {:first, :second, :third} iex> first :first iex> third :third

Immutabilité

L’immutabilité est le second socle fonctionnel d’Elixir. Illustration.

iex> list = [:one, :two, :three] iex> List.delete(list, :two) [:one, :three] iex> list [:one, :two, :three]

Aucune fonction en Elixir ne modifie ses paramètres. C’est le principe de l’immutabilité et il est impossible de le faire. Pour récupérer le résultat de List.delete, il faut obligatoirement assigner le résultat à une variable.
Cependant, Elixir autorise, contrairement à Erlang, le ré-assignement de variables.

iex> a = 1 1 iex> a 1 iex> a = 2 2 iex> a 2

Cela évite d’avoir une profusion à outrance de variables temporaires stockant les résultats intermédiaires d’appels de fonctions.
Pour réaliser un match avec les valeurs des variables, il faut utiliser l’opérateur ^ au début de l’expression pour la différencier d’un assignement.

iex> foo = 1 iex> bar = 2 iex> ^foo = bar ** (MatchError) no match of right hand side value: 2 iex> ^foo = 1 1

L’immutabilité est ce qui permet à Elixir d’être un langage de choix pour écrire des applications concurrentes (plusieurs processus sur plusieurs coeurs). En effet, les processus ne peuvent pas modifier les données d’un autre. Plus besoin de mutex, de sémaphore et autres techniques pour pallier à ces problématiques de partage de ressources. Il n’est plus possible d’avoir des races conditions.

Le fait que nos machines possèdent de plus en plus de coeurs, laisse penser que les langages fonctionnels et le principe de l’immutabilité ne peuvent que gagner en popularité dans un avenir proche.

L’immutabilité à d’autres heureuses implications. Une fonction, retournera toujours le même résultat pour des paramètres donnés. Ce type de fonction est particulièrement aisé à tester.

Récursivité

Oubliez les boucles while ou for, Elixir n’en propose pas ou tout du moins pas tout à fait.

Pour manipuler des collections de données (des listes), on peut déjà se servir du module Enum. On dispose grâce à ce module d’un ensemble de fonctions que l’on retrouve notamment dans le module Ruby du même nom. (each, map, filter, …

Pour illustrer l’usage de la récursivité et comment le pattern matching aide à cela, implémentons notre propre Enum.map.

Enum.map permet d’appliquer une fonction à tous les éléments d’une liste.

Dans l’exemple suivant, on ajoute 1 à tous les éléments de la liste [0, 1, 2, 3] pour former la liste [1, 2, 3, 4].

iex> Enum.map [0, 1, 2, 3], fn(element) -> element + 1 end [1, 2, 3, 4]

Enum.map prend donc deux paramètres : une liste et une fonction.

defmodule MyEnum do def map(list, func) do # … end end

Une liste peut être considérée comme son premier élément suivi d’une liste plus petite. On peut donc utiliser le pattern matching pour extraire la tête (head) et la queue (tail) de la liste.

defmodule MyEnum do def map([head|tail], func) do # … end end

Une fois en possession de la tête et de la queue, il suffit d’appliquer func sur la tête et de rappeler notre map sur la queue : c’est la récursion.

defmodule MyEnum do def map([head|tail], func) do [func.(head)] ++ map(tail, func) end end

En exécutant cette fonction, on va donc consommer la liste petit à petit en appliquant func sur chaque élément et construire la liste finale des résultats.
Si on exécute note map, on se retrouve alors avec l’erreur suivante :

iex(3)> MyEnum.map [1, 2, 3], fn(x) -> x + 1 end ** (FunctionClauseError) no function clause matching in MyEnum.map/2 iex:3: MyEnum.map([], #Function) iex:4: MyEnum.map/2 iex:4: MyEnum.map/2

En effet, notre premier paramètre réduit de taille petit à petit.
Déroulons l’exécution :

1. MyEnum.map([0, 1, 2, 3], func) 2. [func.(0)] ++ MyEnum.map([1, 2, 3], func) 3. [func.(0), func.(1)] ++ MyEnum.map([2, 3], func) 4. [func.(0), func.(1), func.(2)] ++ MyEnum.map([3], func) 5. [func.(0), func.(1), func.(2), func.(3)] ++ MyEnum.map([], func)

On voit donc que MyEnum.map est appelée à la ligne 5 avec les paramètres [] et func.

Le pattern matching échoue donc puisqu’une liste vide [] n’est pas constituée d’une tête et d’une queue. Une FunctionClauseError est donc lancée.

En effet, nous n’avons pas pensé au cas de base dans la récursion.

Un map sur une liste vide retourne, une liste vide. Rajoutons cela à notre exemple.

defmodule MyEnum do def map([head|tail], func) do [func.(head)] ++ map(tail, func) end def map([], _func) do [] end end

Et exécutons notre fonction à nouveau :

iex(3)> MyEnum.map [0, 1, 2, 3], fn(x) -> x + 1 end [1, 2, 3, 4]

Voilà ! On vient de définir notre propre fonction map en utilisant la récursion.

Bien que le code soit très clair, il n’est pas parfait. En effet, il risque de faire exploser la consommation mémoire sur de grandes listes.

Elixir introduit le concept de tail optimisation qui permet de pallier à ce problème. La règle à suivre est de ne pas faire dépendre les calculs intermédiaires des prochains lors de la récursion.

Dans notre exemple, on appelle MyEnum.map dans une expression de concaténation (++). Pour que notre code soit « tail optimisable« , il faut que l’appel récursif soit bien séparé du reste de la fonction c’est–à–dire qu’il soit le dernier appel de la fonction.

Pour arriver à cela, on va devoir utiliser un accumulateur pour stocker les résultats intermédiaires.

defmodule MyEnum do def map(list, func) do do_map(list, func, []) end defp do_map([], _func, acc) do Enum.reverse(acc) end defp do_map([head|tail], func, acc) do do_map(tail, func, acc ++ [func.(head)]) end end

Ce type de code est très classique en Elixir. Il faut s’habituer à réfléchir en récursif, mais les solutions sont souvent concises et élégantes.

Interopérabilité avec Erlang

Elixir n’est pas un transcompilateur (un « CoffeeScript pour Erlang »). Au même titre qu’Erlang, le code Elixir est compilé directement en bytecode BEAM ce qui fait que les deux langages sont quasiment équivalents en terme de performance et interopérables.

Il est possible de faire des appels Erlang depuis Elixir et donc d’utiliser tout l’écosystème Erlang pour développer des applications et notamment le fameux OTP.

OPT signifie Open Telecom Platform. Ce nom fait référence au passé d’Erlang qui a été développé par Ericsson pour ses produits de télécommunication.

Cet ensemble de librairies est très fourni et éprouvé depuis de nombreuses années. Il permet entre autre de gérer la concurrence dans les applications en créant facilement des processus, des serveurs et des superviseurs. Cela permet d’implémenter facilement le modèle de concurrence d’Erlang : le modèle d’acteurs qui est certainement ce qui se fait de mieux à l’heure actuelle.

Pour aller plus loin

On vient tout juste de gratter la surface d’Elixir et d’Erlang. Il reste bien plus à apprendre de ces deux langages :

  • Les macros, qui permettent de manipuler l’arbre syntaxique.
  • La librairie OTP avec ses processus superviseurs et le modèle de concurrence.
  • Le rechargement de code à chaud.

et bien d’autres choses encore.

Etudier un langage fonctionnel n’a que des bénéfices sur votre activité de développeur même si vous faites de l’orienté objet à plein temps. Certains concepts peuvent être réutilisés pour construire des applications de meilleure qualité, plus maintenables, mieux testées et donc potentiellement moins buggées.

Ressources

Les ressources suivantes sont conseillées. Toutes sont en anglais car le langage est encore jeune, certaines sont payantes.

Références

Loïc Minaudier (@lminaudier)

Tout comme Loïc, vous êtes un passionné de développement ?
Alors, que vous soyez simplement curieux de rencontrer des entreprises très techniques et humaines, ou à la recherche de belles opportunités, nous vous invitons à cliquer ci-dessous !

Loic Minaudier

View Comments

Recent Posts

MICI au travail : Le handicap invisible qui révèle des forces insoupçonnées

Les maladies inflammatoires chroniques de l’intestin ou "MICI" sont invisibles, mais leurs impacts sur la…

2 jours ago

Exploiter les NPUs pour de l’IA embarquée dans les applis webs

Depuis l'été, j'ai un Pixel qui intègre à la fois un TPU (Tensor Processing Unit)…

6 jours ago

Qcm saison hiver 2024 : toutes les infos.

On se retrouve dans un nouvel article avec toutes les infos sur cette nouvelle saison…

3 semaines ago

L’inclusion numérique est essentielle.

Pourquoi l’inclusion numérique est essentielle : le point avec Mathieu Froidure. Dans un monde de…

4 semaines ago

Communauté Tech et féminine : Interview avec Helvira de Motiv’her

Elles sont passées où les femmes dans la tech ? Entre le manque de représentation…

1 mois ago