Auteur(s) du livre : Dave Thomas
Éditeur : The Pragmatic Programmers
978-1-68050-299-2
Publié en 2018
Première rédaction de cet article le 6 août 2019
Ce livre présente le langage de programmation Elixir, un langage, pour citer la couverture, « fonctionnel, parallèle, pragmatique et amusant ».
Elixir est surtout connu dans le monde des programmeurs Erlang, car il réutilise la machine virtuelle d'Erlang pour son exécution, et reprend certains concepts d'Erlang, notamment le parallélisme massif. Mais sa syntaxe est très différente et il s'agit bien d'un nouveau langage. Les principaux logiciels libres qui l'utilisent sont Pleroma (c'est via un travail sur ActivityPub que j'étais venu à Pleroma et donc à Elixir) et CertStream.
Comme tous les livres de cet éditeur, l'ouvrage est très concret, avec beaucoup d'exemples. Si vous voulez vous lancer, voici un exemple, avec l'interpréteur iex :
% iex Erlang/OTP 22 [erts-10.4.4] [source] [64-bit] [smp:1:1] [ds:1:1:10] [async-threads:1] [hipe] Interactive Elixir (1.8.2) - press Ctrl+C to exit (type h() ENTER for help) iex(1)> "Hello" "Hello" iex(2)> 2 + 2 4
Mais on peut aussi bien sûr mettre du Elixir dans un fichier et l'exécuter :
% cat > test.exs IO.puts 2+2 % elixir test.exs 4
Elixir a évidemment un mode Emacs, elixir-mode (site de référence sur Github). On peut l'utiliser seul, ou bien intégré dans l'environnement général alchemist. J'ai utilisé MELPA pour installer elixir-mode. Une fois que c'est fait, on peut se lancer dans les exercices du livre.
Quelles sont les caractéristiques essentielles d'Elixir ? Comme indiqué sur la couverture du livre, Elixir est fonctionnel, parallèle, pragmatique et amusant. Voici quelques exemples, tirés du livre ou inspirés par lui, montrés pour la plupart en utilisant l'interpréteur iex (mais Elixir permet aussi de tout mettre dans un fichier et de compiler, chapitre 1 du livre).
Contrairement à un langage impératif classique, les variables ne sont pas modifiées (mais on peut lier une variable à une nouvelle valeur, donc l'effet est proche de celui d'un langage impératif) :
iex(1)> toto = 42 42 iex(2)> toto 42 iex(3)> toto = 1337 1337 iex(4)> ^toto = 1 ** (MatchError) no match of right hand side value: 1 (stdlib) erl_eval.erl:453: :erl_eval.expr/5 (iex) lib/iex/evaluator.ex:249: IEx.Evaluator.handle_eval/5 (iex) lib/iex/evaluator.ex:229: IEx.Evaluator.do_eval/3 (iex) lib/iex/evaluator.ex:207: IEx.Evaluator.eval/3 (iex) lib/iex/evaluator.ex:94: IEx.Evaluator.loop/1 (iex) lib/iex/evaluator.ex:24: IEx.Evaluator.init/4 iex(4)> ^toto = 1337 1337
Dans les deux derniers tests, le caret avant le nom de la variable indique qu'on ne veut pas que la variable soit redéfinie (chapitre 2 du livre).
Elixir compte sur le pattern matching (chapitre 2 du livre) et sur les fonctions beaucoup plus que sur des structures de contrôle comme le test ou la boucle. Voici la fonction qui calcule la somme des n premiers nombres entiers. Elle fonctionne par récurrence et, dans un langage classique, on l'aurait programmée avec un test « si N vaut zéro, c'est zéro, sinon c'est N plus la somme des N-1 premiers entiers ». Ici, on utilise le pattern matching :
iex(1)> defmodule Test do ...(1)> def sum(0), do: 0 ...(1)> def sum(n), do: n + sum(n-1) ...(1)> end iex(2)> Test.sum(3) 6
(On ne peut définir des functions nommées que dans un module, d'où
le defmodule
.)
Naturellement, comme dans tout langage fonctionnel, on peut
passer des fonctions en paramètres (chapitre 5 du livre). Ici, la traditionnelle
fonction map
(qui est dans le module standard
Enum
)
prend en paramètre une fonction anonyme qui multiplie par deux :
iex(1)> my_array = [1, 2, 8, 11] [1, 2, 8, 11] iex(2)> Enum.map my_array, fn x -> x*2 end [2, 4, 16, 22]
Cela marche aussi si on met la fonction dans une variable :
iex(3)> my_func = fn x -> x*2 end #Function<7.91303403/1 in :erl_eval.expr/5> iex(4)> Enum.map my_array, my_func [2, 4, 16, 22]
Notez qu'Elixir a une syntaxe courante mais moins claire pour les programmeurs et programmeuses venu·e·s d'autres langages, si on veut définir une courte fonction :
iex(5)> Enum.map my_array, &(&1*2) [2, 4, 16, 22]
(Et notez aussi qu'on ne met pas forcément de parenthèses autour des appels de fonction.)
Évidemment, Elixir gère les types de données de base (chapitre 4) comme les entiers, les booléens, les chaînes de caractères… Ces chaînes sont en Unicode, ce qui fait que la longueur n'est en général pas le nombre d'octets :
iex(1)> string = "Café" "Café" iex(2)> byte_size(string) 5 iex(3)> String.length(string) 4
Il existe également des structures de
données, comme le
dictionnaire (ici, le dictionnaire a deux
élements, nom
et
ingrédients
, le deuxième ayant pour valeur une
liste) :
defmodule Creperie do def complète do %{nom: "Complète", ingrédients: ["Jambon", "Œufs", "Fromage"]} end
À propos de types, la particularité la plus exaspérante d'Elixir (apparemment héritée d'Erlang) est le fait que les listes de nombres sont imprimées comme s'il s'agissait de chaînes de caractères, si ces nombres sont des codes ASCII plus ou moins imprimables :
iex(2)> [78, 79, 78] 'NON' iex(3)> [78, 79, 178] [78, 79, 178] iex(4)> [78, 79, 10] 'NO\n' iex(5)> [78, 79, 7] 'NO\a' iex(6)> [78, 79, 6] [78, 79, 6]
Les explications complètes sur cet agaçant problème figurent dans la documentation des charlists. Pour afficher les listes de nombres normalement, plusieurs solutions :
IEx.configure
inspect: [charlists: false]
dans
~/.iex.exs
.IO.inspect
, lui
ajouter un second argument, charlists: :as_lists
.[78, 79, 78] ++ [0]
sera affiché
[78, 79, 78, 0]
et pas NON
.À part cette particularité pénible, tout cela est classique dans les langages fonctionnels. Ce livre va nous être plus utile pour aborder un concept central d'Elixir, le parallélisme massif (chapitre 15). Elixir, qui hérite en cela d'Erlang, encourage les programmeuses et les programmeurs à programmer en utilisant un grand nombre d'entités s'exécutant en parallèle, les processus (notons tout de suite qu'un processus Elixir, exécuté par la machine virtuelle sous-jacente, ne correspond pas forcément à un processus du système d'exploitation). Commençons par un exemple trivial, avec une machine à café et un client :
defmodule CoffeeMachine do def make(sugar) do IO.puts("Coffee done, sugar is #{sugar}") end end CoffeeMachine.make(true) spawn(CoffeeMachine, :make, [false]) IO.puts("Main program done")
Le premier appel au sous-programme
CoffeeMachine.make
est classique, exécuté
dans le processus principal. Le second appel lance par contre un
nouveau processus, qui va exécuter
CoffeeMachine.make
avec la liste d'arguments
[false]
.
Les deux processus (le programme principal et celui qui fait le café) sont ici séparés, aucun message n'est échangé. Dans un vrai programme, on demande en général un minimum de communication et de synchronisation. Ici, le processus parent envoie un message et le processus fils répond (il s'agit de deux messages séparés, il n'y a pas de canal bidirectionnel en Elixir, mais on peut toujours en bâtir, et c'est ce que font certaines bibliothèques) :
defmodule CoffeeMachine do def make(sugar) do pid = self() # PID pour Process IDentifier IO.puts("Coffee done by #{inspect pid}, sugar #{sugar}") end def order do pid = self() receive do {sender, msg} -> send sender, {:ok, "Order #{msg} received by #{inspect pid}"} end end end pid = spawn(CoffeeMachine, :order, []) IO.puts("Writing to #{inspect pid}") send pid, {self(), "Milk"} receive do {:ok, message} -> IO.puts("Received \"#{message}\"") {_, message} -> IO.puts("ERROR #{message}") after 1000 -> # Timeout after one second IO.puts("No response received in time") end
On y notera :
after
pour ne pas attendre
éternellement),spawn
crée un processus complètement
séparé du processus parent. Mais on peut aussi garder un lien avec
le processus créé, avec spawn_link
, qui lie
le sort du parent à celui du fils (si une exception se produit
dans le fils, elle tue aussi le parent) ou
spawn_monitor
, qui transforme les exceptions
ou la terminaison du fils en un message envoyé au parent :
defmodule Multiple do def newp(p) do send p, {self(), "I'm here"} IO.puts("Child is over") # exit(:boom) # exit() would kill the parent # raise "Boom" # An exception would also kill it end end child = spawn_link(Multiple, :newp, [self()])
Et avec spawn_monitor
:
defmodule Multiple do def newp(p) do pid = self() send p, {pid, "I'm here"} IO.puts("Child #{inspect pid} is over") # exit(:boom) # Parent receives termination message containing :boom # raise "Boom" # Parent receives termination message containing a # tuple, and the runtime displays the exception end end {child, _} = spawn_monitor(Multiple, :newp, [self()])
Si on trouve les processus d'Elixir et leurs messages de trop bas niveau, on peut aussi utiliser le module Task, ici un exemple où la tâche et le programme principal ne font pas grand'chose à part dormir :
task = Task.async(fn -> Process.sleep Enum.random(0..2000); IO.puts("Task is over") end) Process.sleep Enum.random(0..1000) IO.puts("Main program is over") IO.inspect(Task.await(task))
Vous pouvez trouver un exemple plus réaliste, utilisant le parallélisme pour lancer plusieurs requêtes réseau en évitant qu'une lente ne bloque une rapide qui suivrait, dans cet article.
Les processus peuvent également être lancés sur une autre machine du réseau, lorsqu'une machine virtuelle Erlang y tourne (chapitre 16 du livre). C'est souvent présenté par les zélateurs d'Erlang ou d'Elixir comme un bon moyen de programmer une application répartie sur l'Internet. Mais il y a deux sérieux bémols :
Bref, cette technique de création de programmes répartis est à réserver aux cas où toutes les machines virtuelles tournent dans un environnement très fermé, complètement isolé de l'Internet public. Autrement, si on veut faire de la programmation répartie, il ne faut pas compter sur ce mécanisme.
Passons maintenant à des exemples où on utilise justement le réseau. D'abord, un serveur echo (je me suis inspiré de cet article). Echo est normalisé dans le RFC 862. Comme le programme est un peu plus compliqué que les Hello, world faits jusqu'à présent, on va commencer à utiliser l'outil de compilation d'Elixir, mix (chapitre 13 du livre, il existe un court tutoriel en français sur Mix).
Le plus simple, pour créer un projet qui sera géré avec mix, est :
% mix new echo
Le nouveau projet est créé, on peut l'ajouter à son VCS favori, puis aller dans le répertoire du projet et le tester :
% cd echo % mix test
Les tests ne sont pas très intéressants pour l'instant, puisqu'il
n'y en a qu'un seul, ajouté automatiquement par Mix. Mais ça
prouve que notre environnement de développement
marche. Maintenant, on va écrire du vrai code, dans
lib/echo.ex
(vous pouvez voir le résultat complet).
Les points à noter :
:gen_tcp
.:gen_tcp.accept
, la fonction
loop_acceptor
lance un processus séparé
puis s'appelle elle-même. Comme souvent dans les langages
fonctionnels, on utilise la
récursion là où, dans beaucoup de
langages, on aurait utilisé une boucle. Cette appel récursif ne
risque pas de faire déborder la pile,
puisque c'est un appel terminal.serve
utilise l'opérateur
|>
, qui sert à emboiter deux fonctions,
le résultat de l'une devenant l'argument de l'autre (comme le
tube pour le
shell
Unix.) Et elle s'appelle elle-même, comme
loop_acceptor
, pour traiter tous les
messages envoyés.write_line
est définie en
utilisant le pattern matching (deux
définitions possibles, une pour le cas normal, et une pour le
cas où la connexion TCP est
fermée.):gen_tcp.recv
, et donc
read_line
, renvoie {:error,
:reset}
? Aucune définition de
write_line
ne prévoit ce cas.) Mais cette
négligence provient en partie du d'un style de développement
courant en Elixir : on ne cherche pas à faire des serveurs qui
résistent à tout, ce qui complique le code (la majorité du
programme servant à gérer des cas rares). On préfère faire
tourner le programme sous le contrôle d'un superviseur (et
l'environnement OTP fournit tout ce qu'il
faut pour cela, cf. chapitres 18 et 20 dans le livre), qui
redémarrera le programme le cas échéant. Au premier atelier
Elixir que j'avais suivi, au BreizhCamp, le
formateur, Nicolas Savois, m'avait dit que mon code était trop
robuste et que je vérifiait trop de choses. Avec Elixir, c'est
Let
it crash, et le superviseur se chargera du
reste. (Dans mon serveur echo, je n'ai pas utilisé de
superviseur, mais j'aurais dû.)
Et pour lancer le serveur ? Un programme
start-server.exs
contient simplement :
import Echo Echo.accept(7)
(7 étant le port standard d'echo) Et on le démarre ainsi :
% sudo mix run start-server.exs 18:54:47.410 [info] Accepting connections on port 7 ... 18:55:10.807 [info] Serving #Port<0.6>
La ligne « Serving #Port » est affichée lorsqu'un client apparait. Ici, on peut tester avec telnet :
% telnet thule echo Trying 10.168.234.1... Connected to thule. Escape character is '^]'. toto toto ^] telnet> quit Connection closed.
Ou bien avec un client echo spécialisé, comme echoping :
% echoping -v thule ... Trying to send 256 bytes to internet address 10.168.234.1... Connected... TCP Latency: 0.000101 seconds Sent (256 bytes)... Application Latency: 0.000281 seconds 256 bytes read from server. Estimated TCP RTT: 0.0001 seconds (std. deviation 0.000) Checked Elapsed time: 0.000516 seconds
Et si on veut faire un serveur HTTP,
parce que c'est quand même plus utile ? On peut utiliser le même
gen_tcp
comme dans l'exemple qui figure au début de cet
article. Si vous voulez tester, le code est en http-serve.exs
, et ça se lance avec :
% elixir server.exs 15:56:29.721 [info] Accepting connections on port 8080 ...
On peut alors l'utiliser avec un client comme
curl, ou bien un
navigateur visitant
http://localhost:8080/
. Mais ce serveur
réalisé en faisant presque tout soi-même est très limité (il ne
lit pas le chemin de l'URL, il ne traite
qu'une requête à la fois, etc) et, en pratique, la plupart des
programmeurs vont s'appuyer sur des cadriciels existants comme Phoenix ou Plug. Par défaut, tous les deux utilisent le
serveur HTTP Cowboy, écrit en
Erlang (cf. le site Web de
l'entreprise qui développe Cowboy, et la documentation.) Pour avoir des exemples concrets, regardez cet
article ou bien, avec Plug, celui-ci.
Mix permet également de gérer les dépendances d'une application (les bibliothèques dont on a besoin), via Hex, le système de gestion de paquetages commun à Erlang et Elixir (chapitre 13 du livre). Voyons cela avec une bibliothèque DNS, Elixir DNS. On crée le projet :
% mix new test_dns * creating README.md * creating .formatter.exs * creating .gitignore * creating mix.exs * creating lib * creating lib/test_dns.ex * creating test * creating test/test_helper.exs * creating test/test_dns_test.exs ... % mix test Compiling 1 file (.ex) Generated test_dns app .. Finished in 0.04 seconds 1 doctest, 1 test, 0 failures
On modifie le fichier mix.exs
, créé par Mix,
pour y ajouter la bibliothèque qu'on veut, avec son numéro de
version minimal :
# Run "mix help deps" to learn about dependencies. defp deps do [ {:dns, "~> 2.1.2"}, ] end
Et on peut alors installer les dépendances. (Pour utiliser Hex, sur Debian, il faut
installer le paquetage erlang-inets
, sinon on obtient
(UndefinedFunctionError) function :inets.stop/2 is
undefined (module :inets is not available).)
% mix deps.get Could not find Hex, which is needed to build dependency :dns Shall I install Hex? (if running non-interactively, use "mix local.hex --force") [Yn] y * creating /home/stephane/.mix/archives/hex-0.20.1
Le message Could not find Hex [...] Shall I install Hex n'apparaitra que la première fois. Après :
% mix deps.get Resolving Hex dependencies... Dependency resolution completed: New: dns 2.1.2 socket 0.3.13 * Getting dns (Hex package) * Getting socket (Hex package)
Notez qu'Hex a également installé la dépendance de la dépendance
(dns
dépend de socket
.) On
peut maintenant exécuter nos programmes :
% cat example.exs IO.inspect(DNS.resolve("github.com", :a)) % mix run ./example.exs {:ok, [{140, 82, 118, 4}]}
En conclusion, je trouve le livre utile. Il est en effet très pratique, très orienté vers les programmeuses et programmeurs qui veulent avoir du code qui tourne dès les premiers chapitres. Concernant le langage, je réserve mon opinion tant que je n'ai pas écrit de vrais programmes avec.
Qui, d'ailleurs, écrit des vrais programmes avec Elixir ? Parmi les logiciels connus :
Si vous cherchez des ressources supplémentaires sur Elixir, voici quelques idées :
https://hexdocs.pm/elixir/
, une adresse à
retenir.Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)
Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)