Cet article est publié sous licence CC BY-NC-SA
Elixir est un langage compilé. Son code est compilé pour être exécuté sur la VM Erlang. De facto, Elixir hérite de tous les avantages d’Erlang et croyez-moi ils sont nombreux.
Erlang est un langage qui a été conçu spécifiquement pour créer des logiciels massivement scalables, utilisant des fonctionnalités temps-réel et assurant une très haute disponibilité. Oui ça vend du rêve, mais ce n’est pas que du rêve puisque de nombreux domaines l’utilisent avec succès depuis 20 ans. Les télécoms, la banque, l’e-commerce, la messagerie instantanée…
Difficile d’avoir des doutes sur la robustesse de la VM Erlang quand on voit les acteurs qui l’utilisent !
Mais alors pourquoi s’orienter vers Elixir si on a déjà tout ce qu’il faut dans Erlang ?
Et bien quand on vient comme moi d’un langage très lisible et facile d’utilisation comme Ruby, Erlang parait un peu rude…
Elixir apporte cette syntaxe élégante, l’extensibilité, la possibilité d’écrire facilement des DSL et tous les outils auxquels on est habitué quand on vient d’un éco-système semblable à celui de Ruby (build tools, console interactive, …).
Oh non… Pas du tout. Beaucoup on fait l’amalgame au début, mais ce n’est pas du tout l’idée.
Oui effectivement on sent dans la syntaxe et les choix de mots-clés qu’il y a eu une forte influence de Ruby sur le créateur d’Elixir. José Valim, vous voyez de qui il s’agit ? Un des core dev de Rails pendant des années, son travail principal été de rendre Rails thread-safe. Mais très franchement la ressemblance s’arrête juste à ça.
Tous les paradigmes sont différents. Elixir est un langage fonctionnel, on ne pensera donc pas du tout son code de la même façon. Ici pas de classes ou d’héritage, pas de mutation d’état non plus. En fonctionnel on va plutôt avoir des modules isolés qui vont communiquer via des appels de fonctions. Ces fonctions ne vont jamais modifier un état directement mais simplement renvoyer une valeur qui pourra être utilisée ailleurs. On parle de fonctions pures.
Pourquoi faire ça ? Ça ne paraît pas plus simple dit comme ça. Effectivement, mais dès vos premières dizaines lignes de code vous comprendrez les bénéfices du fonctionnel. Une fonction ne modifiera jamais l’état d’une structure que vous lui avez passé, aucune chance donc d’avoir des effets inattendus. Si vous écrivez des fonctions pures, avec les mêmes paramètres en entrée, elles retourneront toujours le même résultat. Ça ne sera pas dépendant d’un état interne d’un objet X ou Y.
De ce fait vos fonctions sont beaucoup plus facilement testables, et plus robustes. Autre gain non négligeable, il devient beaucoup plus facile d’écrire des programmes thread-safe puisque qu’une fonction ne modifie pas directement une structure, impossible de se retrouver dans un autre processus qui touche à la structure et la désynchronise.
C’est une des choses que je trouve géniale avec Elixir, vous avez toujours accès directement et nativement à toutes les primitives Erlang !
Elixir est volontairement un langage avec une API légère pour faciliter sa prise en main. Mais Erlang offre une foultitude de modules et de fonctions très utiles que vous pouvez utiliser directement depuis votre code Elixir. Vous pouvez donc aussi utiliser des bibliothèques Erlang.
Erlang étant un langage plus qu’éprouvé, vous pouvez être sûr que vous trouverez toujours une bibliothèque pour faire ce que vous voulez.
Si vous choisissez de faire de l’Elixir, il faudra désapprendre vos patterns objet, oubliez vos habitudes pour embrasser les paradigmes fonctionnels.
Il n’y a que comme ça que vous pourrez tirer pleinement parti des avantages que le fonctionnel a à offrir.
Quelques exemples à la volée, vous écrirez beaucoup de petites fonctions ayant un but très restreint. Ensuite vous utiliserez intensivement le chaînage d’appels de fonctions, chaque fonction faisant sa petite modification et passant le résultat à la fonction suivante.
Pour les utilisateurs d’Unix, c’est totalement la philosophie des outils de ligne de commande ou chacune est censée être dédiée à une tâche très précise. Chacune de ces commandes va piper son résultat à la suivante pour construire une chaîne de modifications et arriver au résultat final.
Vous utiliserez beaucoup de structures de données. Pas des objets qui embarquent leur état et des méthodes qui permettent de le modifier, mais des structures plus simples contenant juste un état (listes, tuples). Dans la majorité des cas, ces structures ne seront jamais modifiées directement, elles seront passées à une fonction pure qui elle va renvoyer une copie modifiée ou une valeur.
En respectant ces principes, on obtient des fonctions très maintenables, facilement testables, prédictibles et qui ne modifient pas à-tout-va le monde qui les entoure. Grâce à ça on s’évite des bugs très difficiles à traquer.
Voyons maintenant à quoi ressemble Elixir en pratique.
Un outil dont je ne saurais me passer en Ruby est irb. Par chance un équivalent (encore plus puissant en réalité) existe pour Elixir, c’est iex.
Lançons donc une session interactive pour découvrir les types primitifs :
$ iex
iex> name = "Synbioz"
"Synbioz"
iex> true
true
iex> true == false
false
On pourra utiliser des prédicats pour savoir si une valeur est de tel ou tel type :
iex> is_boolean(true)
true
iex> is_boolean("foo")
false
Un atom est une constante dont le contenu est son nom. On peut faire le parallèle avec les symboles en Ruby.
iex> :hello
:hello
iex> :hello == :world
false
La petite surprise c’est que les booléens sont en fait des atoms :
iex> true == :true
true
iex> is_atom(false)
true
iex> is_boolean(:false)
true
En Elixir, les chaînes de caractères, encodées en UTF-8, sont forcément entre guillemets doubles. Ici pas de débat entre guillemets simples et doubles, ils ne servent pas à la même chose !
Les chaînes de caractères supportent l’interpolation de la même façon
qu’en Ruby. En Elixir, une chaîne s’appelle un Binary
:
iex> "hello #{:world}"
"hello world"
iex> is_binary("hello")
true
iex> byte_size("œil")
4
iex> String.length("œil")
3
iex> String.upcase("œil")
"ŒIL"
Lorsque vous utilisez de guillemets simples, vous créez en fait une liste de caractères et non pas un chaîne :
iex> is_binary('foo')
false
iex> is_list('foo')
true
iex(14)> i 'foo'
Term
'foo'
Data type
List
Description
This is a list of integers that is printed as a sequence of characters
delimited by single quotes because all the integers in it represent valid
ASCII characters. Conventionally, such lists of integers are referred to as
"charlists" (more precisely, a charlist is a list of Unicode codepoints,
and ASCII is a subset of Unicode).
Raw representation
[102, 111, 111]
Reference modules
List
Implemented protocols
IEx.Info, Collectable, Enumerable, Inspect, List.Chars, String.Chars
Comme vous pouvez le voir, on utilise ici la commande i
pour obtenir
les informations détaillées à propos de 'foo'
. C’est la meilleure
façon depuis iex d’avoir tous les détails sur un élément qui vous
interpelle.
Les tableaux en Elixir sont des listes chaînées. Ça a son importance pour savoir comment les manipuler de manière optimale.
Elles sont définies assez classiquement avec des crochets.
Rien n’empêche de mixer les types de valeurs contenues dans le tableau.
iex> [1, 2, true, 3]
[1, 2, true, 3]
iex> length([1, 2, 3])
3
On peut évidemment concaténer ou soustraire un tableau à un autre :
iex> [1, 2, 3] ++ [4, 5, 6]
[1, 2, 3, 4, 5, 6]
iex> [1, true, 2, false, 3, true] -- [true, false]
[1, 2, 3, true]
On voit que la soustraction ne va retirer que la première correspondance.
Les manipulations de listes étant monnaie courante en programmation fonctionnelle, on a tout un tas d’outils à notre disposition pour nous simplifier la vie :
iex> list = [1, 2, 3]
iex> hd(list)
1
iex> tl(list)
[2, 3]
Récupérer la tête ou la queue d’une liste sont des opérations ultra-courantes. Très souvent en programmation fonctionnelle, vous utiliserez ou écrirez des fonctions récursives pour effectuer vos traitements. À chaque appel de la fonction récursive vous extrairez donc la tête pour traiter l’élément donné puis vous appellerez à nouveau la fonction récursive en lui passant en paramètre la queue (les éléments restants à traiter) jusqu’à avoir parcouru toute la liste.
En Elixir, les tuples sont créés en utilisant les accolades. Les tuples sont très utilisés comme retour de fonction pour donner du contexte (succès, erreur…).
iex> {:ok, "hello"}
{:ok, "hello"}
iex> tuple_size {:ok, "hello"}
2
Nous le verrons un peu plus loin, mais c’est un des fondements qui permet d’utiliser abondamment le pattern matching.
Contrairement aux listes, les éléments des tuples sont stockés de manière contiguë en mémoire. C’est important de l’avoir en tête car si vous avez besoin de récupérer des éléments par leur index ou de calculer la taille d’un tuple, ce sera toujours plus rapide que de le faire sur une liste.
A contrario si vous devez souvent modifier votre liste / tuple, il est préférable d’avoir recours à une liste où l’opération sera moins coûteuse.
Pour résumer, si votre structure est principalement dédiée à la lecture optez pour un tuple, si par contre la structure va être intensivement modifiée préférez une liste.
En Elixir, il est possible de définir des fonctions anonymes pour les passer en argument à d’autres fonctions ou même pour les stocker dans une variable.
Vous y êtes déjà peut-être habitué en Ruby grâce aux proc
s et aux
lamda
s, mais en Elixir c’est vraiment un élément central de la
structuration des programmes. Par exemple, dans les langages
fonctionnels beaucoup de traitements passent par l’utilisation de map
/ reduce
qui attendent une collection et une fonction qui va
déterminer le traitement à effectuer sur chaque élément.
Voyons quelques exemples :
iex> add = fn(a, b) -> a + b end
#Function<12.99386804/2 in :erl_eval.expr/5>
iex> add.(1, 2)
3
iex> is_function(add)
true
iex> is_function(add, 2)
true
iex> is_function(add, 1)
false
L’appel à notre fonction anonyme stockée dans la variable add
se fait
donc grâce au point suivi des parenthèses contenant les paramètres à
passer à la fonction.
Le point permet de lever l’ambiguïté au cas où une fonction add
existerait déjà dans le scope. add(1)
tentera donc d’appeler une
fonction nommée alors que add.(1)
va faire un appel de méthode sur
la variable locale add
avec en paramètre l’entier 1
.
On vérifie ensuite que notre variable contient bien une fonction, puis
que cette fonction attend deux paramètres, ce qui est notre cas. Si on
demande à is_function
si add
peut prendre un seul argument, on
nous dit que non.
Les fonctions anonymes étant des closures, on pourra s’en servir comme suit dans une autre fonction anonyme :
iex> double = fn(a) -> add.(a, a) end
#Function<6.71889879/1 in :erl_eval.expr/5>
iex> double.(2)
4
Notre fonction add
définie précédemment est utilisée dans la
définition de notre fonction double
qui attend un paramètre a
. Ce
paramètre est directement utilisé en paramètres de add
pour lui
déléguer le calcul.
Pour mémoire une closure est une fonction qui se « souvient » de l’environnement dans lequel elle a été créée, elle capture son « environnement », elle va donc pouvoir utiliser localement des variables définies dans la portée englobante.
Comme vous vous en doutez, il est aussi possible de définir des fonctions nommées. Les fonctions nommées seront forcément déclarées dans un module.
Vous pouvez voir le module comme un espace de nom, un endroit logique où regrouper des fonctions qui opèrent de concert ou sur un même type de donnée.
On va créer une fonction nommée qui est l’équivalent de notre fonction anonyme précédente :
iex> defmodule Math do
...> def sum(a, b) do
...> a + b
...> end
...> end
iex> Math.sum(1, 2)
3
Rien de bien particulier donc. On utilise le mot-clé defmodule
pour
créer un module ayant un nom commençant par une majuscule. Ensuite
dans son bloc (délimité par le do
/ end
) on peut déclarer nos
fonctions nommées grâce au mot-clé def
.
En dernière ligne, on utilise simplement cette fonction. On pourra donc réutiliser cette fonction à travers tout notre code beaucoup plus simplement qu’avec notre version anonyme.
En Elixir, il est possible d’avoir plusieurs fonctions portant le même nom pour le peu qu’elles aient un nombre différent d’arguments. Dans le monde d’Elixir, on dit que les fonctions ont une arity.
Si vous parcourez les documentations d’API de bibliothèques écrites en
Elixir, vous verrez que les fonctions sont toujours nommées comme suit
List.first/1
ce qui nous permet de connaître le nombre d’arguments
attendus.
Notre module aurait donc pu ressembler à :
iex> defmodule Math do
...> def sum(a, b) do
...> a + b
...> end
...>
...> def sum(a, b, c) do
...> a + b + c
...> end
...> end
iex> Math.sum(1, 2)
3
iex> Math.sum(1, 2, 3)
6
La notion de pattern matching est un concept central dans Elixir. Ce n’est pas quelque chose de nouveau mais Elixir en fait un usage intensif et il vous faut maîtriser ce concept si vous souhaitez écrire du code concis et élégant dans ce langage.
En Elixir, on n’assigne pas une valeur à une variable on l’y attache
(bind). Le symbole =
permet de ce fait de faire des
assertions. C’est un concept qui n’existe pas dans les langages
non fonctionnels.
Voyons un exemple :
iex> a = 1
1
iex> 1 = a
1
iex> 2 = a
** (MatchError) no match of right hand side value: 1
Ici l’interpréteur nous dit que la valeur 2
ne correspond pas à la
valeur stockée dans la variable a
.
Vous ne voyez pas l’intérêt ? Continuons avec d’autres exemples alors :
iex> [1, a, 3] = [1, 2, 3]
[1, 2, 3]
iex> a
2
On vient d’attacher la valeur 2
à la variable a
. De la
déstructuration en quelque sorte mais ici la liste de gauche et de
droite sont comparées et l’affection n’a lieu que si les valeurs fixes
correspondent :
[1, a, 3] = [2, 2, 3]
** (MatchError) no match of right hand side value: [2, 2, 3]
Dans l’exemple précédent, le pattern matching n’est pas concluant et l’interpréteur nous le fait savoir. On pourrait en tirer parti pour faire un cas particulier dans notre code.
Le pattern matching reste très souple puisqu’il permet d’ignorer certaines valeurs / paramètres :
iex> [a, _, _] = [1, 2, 3]
[1, 2, 3]
iex> a
1
Il est également possible de réutiliser directement la valeur d’une variable déjà liée :
iex> a = 1
1
iex> [^a, 2, 3] = [1, 2, 3]
[1, 2, 3]
L’opérateur ^
permet donc de dire « attention ne rebind pas cette
variable mais utilise plutôt sa valeur actuelle pour tes
comparaisons ».
Le pattern matching fonctionne également avec les fonctions. On va donc pouvoir définir une fonction simple, facilement testable, par cas de figure plutôt que de gérer tous les cas possibles dans une seule fonction à grand coup de conditions.
Venons-en aux exemples de fonctions basées sur le pattern matching :
iex> defmodule Factorial do
...> def of(0), do: 1
...> def of(x), do: x * of(x-1)
...> end
Vous commencez à comprendre ? On va pouvoir très largement alléger
notre code de ses if
, else
, case
en utilisant le pattern
matching.
Quand on essayera de calculer le factoriel de 0
, on passera donc
dans la première version de notre fonction, elle n’aura qu’à retourner
1
sans se soucier de quoi que ce soit.
Si autre chose que 0
est passé, alors on utilisera la deuxième
définition de notre fonction of
qui fera elle-même un appel récursif
sur la valeur actuelle moins un. Arrivé à zéro on repassera donc dans
notre première version et la récursion s’arrêtera naturellement !
Une fois qu’on a intégré cette notion, le code qu’on produit devient beaucoup plus simple, le nombre de branches conditionnelles diminue et la complexité du code et des tests avec.
Les plus observateurs d’entre vous vont me dire : « Ouais, mais ton code il pue… Tu ne gères pas le cas des nombres négatifs passés à ta fonction. »
Effectivement. Alors allons-y ! Dans Elixir une autre notion vient se greffer au pattern matching, les guards. Ils permettent d’utiliser une signature donnée si et seulement si une condition est respectée.
On pourrait donc modifier notre module de la façon suivante :
iex> defmodule Factorial do
...> def of(0), do: 1
...> def of(x) when x > 0 do
...> x * of(x-1)
...> end
...> end
Désormais notre deuxième signature ne sera utilisée que si le paramètre est supérieur à 0.
« Ok tu me dis que l’idée derrière le fonctionnel et donc d’Elixir c’est de chaîner les appels de fonctions ? Ça va être moche avec des appels comme : »
foo(bar(baz(4,5), 2, 3), 1)
Ou un peu mieux :
baz_res = baz(4, 5)
bar_res = bar(baz_res, 2, 3)
foo_res = foo(bar_res, 1)
« Et c’est difficile à lire ! »
Eh bien je vous réponds oui ! Tout à fait d’accord.
Heureusement ils ont pensé à tout. C’est ici qu’entre en jeu le pipe operator. C’est une façon élégante de chaîner les appels de fonctions. Elixir n’invente rien ici, c’est commun dans les langages fonctionnels de pouvoir faire ça.
Voyons comment on l’écrit en Elixir :
foo_res = baz(4, 5)
|> bar(2,3)
|> foo(1)
Le pipe operator va automatiquement passer le résultat de l’appel de fonction précédent en premier argument de la méthode qu’il appelle ensuite.
Autrement dit, le résultat de l’expression à sa gauche est passé en premier argument à l’expression à sa droite. C’est franchement super agréable à l’utilisation, on s’y fait vite et ça simplifie beaucoup la lecture du code.
Une autre chose qui m’enthousiasme dans l’éco-système Elixir c’est la qualité du tooling qu’on a de base.
Un des outils centraux est Mix
. Pour les rubyistes, voyez ça comme un
Rake
+ Bundler
. Oui rien que ça !
Mix va s’occuper de toute la partie création / compilation / lancement des tests des applications. C’est lui aussi qui va gérer l’ensemble des dépendances de l’appliation.
Et ça ce n’est que la surface de ce que propose cet outil génial. Évidemment c’est extensible, vous pouvez créer vos propres tâches par exemple.
La gestion de la documentation est encore quelque chose qui est extrêmement bien géré de base. Elixir encourage largement à l’écriture de doc.
On peut utiliser du markdown qui sera affiché avec un rendu du meilleur effet quand vous utiliserez les outils livrés avec Elixir.
Encore plus fort, si vous mettez des exemples d’appels de vos méthodes
dans votre documentation, en lançant un mix test
ces exemples seront
joués pour voir s’ils retournent bien les résultats
attendus. Personnellement ça m’a tout simplement bluffé.
Encore un autre point fort d’Elixir (et d’Erlang). Quand on vient du monde Ruby on sait comme gérer la concurrence peut être compliqué. Gérer ses threads correctement pour ne pas avoir des effets de bord tordus dus à la modification de l’état d’un objet à la volée n’est pas une mince affaire !
Elixir utilise un modèle complètement différent. Premièrement l’immutabilité est reine dans la programmation fonctionnelle. Les effets de bord sont proscrits et ça règle déjà une grande partie du problème.
Secondo, pas de threads et de mutex en Elixir, ici on utilise le modèle Acteur. Grosso-modo on va lancer autant de processus enfants que nécessaire — dans la VM Erlang qui sait gérer ça d’une façon des plus efficace et légère — pour distribuer les traitements, et les processus vont communiquer entre eux via des envois de messages.
Aucun processus ne modifiera une information partagée, ils sont isolés. En Ruby un équivalent existe sous la forme de la gem Celluloid.
Ce n’est vraiment qu’un aperçu très loin de représenter l’ensemble des possibilités d’Elixir et sa puissance. Elixir est livré avec un nombre impressionnant de fonctionnalités toutes plus intéressantes les unes que les autres, beaucoup venant de la superbe VM Erlang dont l’efficacité n’est plus à prouver.
Je pense particulièrement à :
Pour finir, je vous laisse quelques liens intéressants qui vous permettront de continuer votre lecture et commencer à jouer avec le langage si vous êtes intrigués :
L’équipe Synbioz,
Libres d’être ensemble.
Nos conseils et ressources pour vos développements produit.