Introduction à Elixir

Publié le 23 novembre 2017 par Nicolas Cavigneaux | elixir

Cet article est publié sous licence CC BY-NC-SA

Origines d’Elixir

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…

Quelques exemples de petites structures qui l’utilisent

  • WhatsApp qui est sûrement l’exemple le plus connu avec des pics à 2 millions de connexions simultanées sur un seul serveur
  • Backend de chat de Facebook
  • Amazon pour SimpleDB
  • Heroku
  • Yahoo Delicious
  • Ericsson
  • World Of Warcraft
  • League of Legends

Difficile d’avoir des doutes sur la robustesse de la VM Erlang quand on voit les acteurs qui l’utilisent !

Pourquoi Elixir

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, …).

Du Ruby sur la VM Erlang ?

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.

Et Erlang dans tout ça ?

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.

Embrasser le fonctionnel

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.

Ok ! Ça ressemble à quoi ?

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

Variables

iex> name = "Synbioz"
"Synbioz"

Booléens

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

Atoms

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

Chaînes

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.

Listes

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.

Tuples

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.

Fonctions anonymes

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 procs et aux lamdas, 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.

Modules et fonctions nommées

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

Pattern matching

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.

Chaînage des appels

« 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.

Gestion de projet et dépendances

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 documentation comme citoyen de première zone

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é.

Modèle de concurrence

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.

Pour résumer

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 à :

  • la syntaxe intuitive
  • la communauté très sympa, réactive et motivée
  • le modèle de concurrence ultra-robuste
  • la puissance de Mix
  • la documentation comme citoyen de première classe
  • la mise en place de systèmes distribués
  • OTP qui mérite à lui seul plusieurs blog posts tant il a à offrir (gestion d’état, découverte de services, détection de défaillance, modification de code en production à la volée…)

Ressources

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.