Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

C1N2 – Aller un peu plus loin en Elixir

2-aller-plus-loin.livemd

C1N2 – Aller un peu plus loin en Elixir

Mix.install([
  {:kino, "~> 0.16.1"},
  {:pythonx, "~> 0.4.2"},
  {:kino_pythonx, "~> 0.1.0"}
])
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []

Introduction

Dans le notebook précédent, nous nous étions concentrés sur les caractéristiques communes à Elixir et Python, afin de vous accompagner dans la transition d’un langage vers l’autre.

Évidemment cette comparaison a ses limites: si les deux langages étaient totalement équivalents, nous ne serions pas en train d’apprendre Elixir !

Ce nouveau Notebook a deux objectifs:

  • explorer les bases du langage d’une façon plus approfondie: lister les différents types, découvrir les conteneurs de données (listes, etc.)
  • commencer à illustrer les différences – syntaxiques mais pas que – entre Elixir et Python, afin de vous rendre plus efficaces dans votre pratique de la programmation

Les types de base

Tous les langages de programmation, quels qu’ils soient, proposent un certain nombre de type de données qu’ils peuvent manipuler : chaînes de caractères, nombre, tableaux etc.

Elixir ne fait pas exception, et le but de cette section est donc de vous présenter ces types, dont certains vous seront familiers, et d’autres constitueront une nouveauté.

Les types numériques

Comme évoqué dans notre précédent notebook, Elixir propose deux types destinés à stocker des valeurs numériques:

  • le type integer correspont à des entiers 64 bits
  • le type float correspond à de nombre à virgule flottant 64 bits (dits aussi “double précision”)

Il est possible de tester très facilement le type d’une valeur – ou d’une variable – via des fonctions dédiées:

is_float(3.14)
is_integer(42)
# Fonctionne avec les integer ou les floats
IO.puts( is_number(3.14) )
IO.puts( is_number(42) )

Elixir supporte également différents formats de saisie pour ces valeurs, telles que:

  • la notation avec exposant pour les flottants:
    1.0e10 # 👉 10000000000.0
  • la notation avec des séparateurs “_” pour les longs entiers (ou flottants)
    1_000_000_000    # 👉 1000000000
    1_000_000_000.42 # 👉 1000000000.42
  • les notations binaires, octales (base 8) ou hexadécimale (base 16) pour les entiers:
    0b110011 # 👉 52  (en binaire)
    0o444    # 👉 292 (en octal)
    0xff     # 👉 255 (en hexa)

Toutes les opérations arithmétiques classiques sont supportées:

  • la multiplication avec *
  • la division avec /
  • l’addition avec +
  • la soustraction avec -

De plus, Elixir supporte l’opérateur d’exponentiation ** comme Python.

Autres fonctions mathématiques

Elixir est généralement moins bien équipé que Python en ce qui concerne le calcul mathématique: en effet, le focus du langage (les sytèmes distributés) n’est pas le même que celui de Python et cela se ressent au niveau des fonctions disponibles.

Tout n’est pas pas perdu cependant ! Il existe en effet deux solutions pour manipuler des objets mathématiques en Elixir:

  1. utiliser les fonctionnalités issues de Erlang, et en particulier son module :math qui donne les principales fonctions usuelles, par ex. les fonctions trigonométriques
  2. utiliser Nx, qui est “le numpy d’Elixir” et que nous aborderons plus tard dans ce cours

Voici un exemple d’utilisation du module :math d’Erlang, vous remarquerez tout de suite que la “syntaxe d’appel est différente”.

En effet: le nom du module Erlang est un “atome” Elixir – une notion que nous aborderons plus loin dans ce cours – commençant par :. C’est cette différence qui nous permet de différencier les appels à la “librairie standard Elixir” des appels au sous-système Erlang.

Vous pouvez donc évaluer la cellule suivante:

:math.cos(44)

ce qui est équivalent, en Erlang (ici pour votre culture, pas la peine de mémoriser ça !) à:

math:cos(44).

Les atomes

Nous les avons évoqués à plusieurs reprises, il est temps de définir ce qu’est un atome: c’est une constante nommée du langage.

Qu’est-ce que ça peut bien vouloir dire ?

Il est parfois nécessaire, dans un programme, de définir certaines constantes pour qualifier des valeurs particulières:

  • la constante true (équivalente au True Python) quand un test est vrai
  • la constante false (équivalente au False Python) quand un test est faux
  • la constante nil (équivalente au None Python) et qui marque une absence de valeur

toutes ces constantes sont appelées des atomes en Elixir:

is_atom(true)
is_atom(false)
is_atom(nil)

La différence avec Python, c’est qu’Elixir nous permet de définir nos propres constantes pour qualifier certaines valeurs de nos programmes.

Typiquement, les fonctions d’Elixir – comme IO.puts/1 que nous avons déjà rencontrée – renvoient souvent :ok en cas de succès. Cette constant :ok est un atome:

is_atom(:ok)

et vous pouvez facilement définir vos propres atomes: il suffit de nommer la constante en commençant par : suivi d’une suite de lettres contigües, éventuellement séparées par des _:

:je_suis_un_atome
:moi_aussi
:idem

Quel est l’intérêt de ce nouveau type ?

L’intérêt de ce type est multiple:

  • définir des “valeurs de référence” qui ont un rôle particulier dans mon programme: pensez à l’atome :ok que nous avons évoqué et qui marque le succès d’un appel de fonction. De la même façon de nombreuses fonctions renvoient :error en cas d’erreur
  • par rapport à utiliser une chaîne de caractères comme on le ferait en Python, on gagne en perfomance: en effet Elixir garantit que chaque atome identique (par ex. “tous les :ok) pointent vers la même adresse mémoire, rendant ainsi la comparaison très rapide

Autre types d’atomes

Il existe également d’autres types d’atomes:

  • tous les noms de modules elixir sont des atomes
  • on peut créer des atomes avec des espaces ou d’autres caractères en utilisant la syntaxe: :"un atome avec des espaces"
# le nom du module IO est un atome
is_atom(IO)
# c'est également valable pour les modules "emboités" (dont nous parlerons plus tard)
defmodule RootModule do
  defmodule NestedModule do
  end
end

is_atom(RootModule.NestedModule)
is_atom( :"atome avec des espaces et des emojis 🚀" )

Les conteneurs: bases

Elixir dispose d’une bibliothèque de conteneurs, très semblables à ceux dont on dispose en Python. Comme à l’accoutumée dans ce cours, nous travaillerons en mettant en parallèle les deux langages afin de faciliter la compréhension des notions exposées.

Les tuples

Il s’agit d’un groupement de valeurs, éventuellement de natures différentes. Pour rappel, voici la façon dont les tuples se déclarent en Python:

my_tuple = (1, "a", 3.14)
print(f"{my_tuple=}")

# affichage des différentes valeurs
print(f"{my_tuple[0]=}")
print(f"{my_tuple[1]=}")
print(f"{my_tuple[2]=}")

En Elixir, la syntaxe est subtilement différente:

  • on utilisera des crochets { et } )plutôt que les parenthèses utilisées en Python)
  • l’operateur elem permet de récupérer la valeur disponible à un index donné
my_tuple = {1, "a", 3.14}
IO.inspect(my_tuple, label: "my_tuple")

IO.puts("Valeurs contenues dans le tuple:")
IO.inspect( elem(my_tuple, 0) )
IO.inspect( elem(my_tuple, 1) )
IO.inspect( elem(my_tuple, 2) )

💡 Elixir propose également une syntaxe dite de “pattern matching” que nous explorerons ensemble plus tard dans ce notebook.

Les listes

Il s’agit probablement du conteneur qui est le plus différent entre Python en Elixir, et ce pour une bonne raison:

  • les “listes Python” ne sont pas des listes ! Le type list de Python, que vous connaissez, contient en réalité un tableau de valeurs contigues en mémoire
  • le type “liste” d’Elixir (d’Erlang en réalité) représente une véritable liste chaînée, c’est-à-dire une structure de données où les valeurs sont reliées entre elles par des “pointeurs” en mémoire
  • la liste Elixir est immutable, comme tout ce qui est en mémoire en Elixir d’ailleurs

Tout cela doit vous sembler bien abstrait pour l’instant mais nous allons illustrer ces concepts et ils feront sens.

La liste Elixir est une liste chaînée

Commençons par définir une liste Elixir très simple: ce ne sera pas difficile vu que la syntaxe est la même qu’en Python:

# Version Python
my_list = [1, 2, 3, 4]
my_list

en python, nous aurions une liste d’éléments contigüe en mémoire:

block-beta
    l>"my_list"]
    block:B
        1 2 3 4
    end
    l-->B
# Version Elixir
my_list = [1, 2, 3, 4]

Cette fois-ci, nous avons créé une séquence d’objets en mémoire qui se référencent les uns les autres:

block-beta
    l>"my_list"]
    block:B
        aa["1"]
        block:C
            bb["2"]
            block:D
                cc["3"]
                dd["4"]
                cc-->dd
            end
            bb-->D
        end
        aa-->C
    end
    l-->B

la liste [1, 2, 3, 4] est composée en méroire:

  • de la valeur 1, suivie de la liste [2, 3, 4]
  • la valeur [2, 3, 4] est elle-même composée de la valeur 2, suivie de la liste [3, 4]
  • la valeur [3, 4] est elle-même composée de la valeur 3, suivie de la liste [4]
  • qui est elle-même composée de la valeur 4 sans liste suivante (nil)

on peut d’ailleurs reconstituer notre liste grâce à l’opérateur | qui permet d’ajouter une valeur au début d’une liste:

# on part d'une liste vide
l_vide = []
dbg(l_vide)

# on lui ajoute la valeur "4" au début
l_une_valeur = [ 4 | l_vide ]
dbg(l_une_valeur)

# on ajoute "3" au début de la liste suivante
l_deux_valeurs = [ 3 | l_une_valeur ]
dbg(l_deux_valeurs)

# etc. etc.
[
  1 | [
    2 | l_deux_valeurs
  ]
]

De cette construction découle un profil de perfomance très différent entre Elixir et Python:

  • en Elixir, il est rapide d’ajouter en début de liste et lent d’ajouter à la fin de la liste
  • en Python c’est l’inverse !
  • en Elixir il est rapide de parcourir la liste dans l’ordre mais lent d’accéder à un élément quelconque par son indice
  • en Python les deux sont perfomants (“random access”)

Mais alors, pourquoi utilise-t-on cette approche compliquée dans les structures de données en Elixir ?

L’explication se trouve – en partie – dans le paragraphe suivant:

Caractère immutable de la mémoire en Elixir

Une des différences principales entre Elixir et Python, c’est qu’on ne peut pas modifier les valeurs qui ont été mises en mémoire. En effet, on peut ré-assigner une variable dans notre code, mais la valeur en mémoire que référence cette variable n’est pas modifiable.

Prenons, encore une fois, un exemple en Python pour comparer:

# une liste Python d'exemple
l = [1, 2, 3, 4]
l[1] = 44 # 👈 on modifie notre liste
l

Dans cet exemple, nous avons créé une liste en mémoire et l’avons modifiée, rien de spectaculaire.

Ré-écrions cet exemple en Elixir:

l = [1, 2, 3, 4]
List.replace_at(l, 1, 44)
l

Nous constatons que la valeur de l n’a pas changé !

En effet, une fois définir en mémoire, il n’est pas possible de modifier la valeur de cette liste.

Mais alors qu’a bien pu faire l’appel à List.replace_at/3 ?

En réalité, cet appel n’a pas modifié notre liste, mais a créé une nouvelle liste qui reprend les valeurs de la liste précédente et modifie uniquement la valeur de rang 1. Pour que notre code fonctionne, nous aurions donc dû ré-assigner la variable l avec le résultat de l’appel à replace_at comme suit (concentrez-vous, le changement est subtil !):

l = [1, 2, 3, 4]
l = List.replace_at(l, 1, 44)
l

Cette approche présente plusieurs avantages, et s’intégre bien avec les structures de données présentes dans Elixir:

  • le côté immutable simplifie énormément le fonctionnement “concurrent” du code: beaucoup de bugs des systèmes dits “parallèles” sont dûs à des accès concurrents à la mémoire, c-à-d. quand une partie du programme essaye de lire une information qu’une autre partie du programme essaye de modifier en même temps. En rendant la memoire non-modifiable, Elixir élimine toute une catégorie de bugs potentiels
  • les listes chaînées utilisées par Elixir permettent de ré-utiliser une partie des données lors des modifications. En effet, comme la mémoire n’est jamais modifiée il n’y a aucun risque à référencer une partie d’une autre liste dans une nouvelle liste afin d’économiser de la mémoire.

Dans le cas présenté ci-desssu, lorsqu’on modifie le deuxième élément, la fin de la liste ([3, 4]) pourra être ré-utilisée comme fin de la nouvelle liste modifiée:

block-beta
columns 2
    l>"my_list"]
    block:B
        aa["1"]
        block:C
            bb["2"]
            block:D
                cc["3"]
                dd["4"]
                cc-->dd
            end
            bb-->D
        end
        aa-->C
    end
    block:XB
        Xaa["1"]
        block:XC
            Xbb["44"]
            Xbb-->D
        end
        Xaa-->XC
    end
    l-->XB

Opérateurs sur les listes

Comme nous l’avons abordé rapidement ci-dessus, il est facile de rajouter un éléments au début d’une liste:

liste_incomplete = [ 2, 3, 4 ]
dbg(liste_incomplete)

liste_complete = [ 1 | liste_incomplete ]
dbg(liste_complete)

Il est également possible de concaténer deux listes avec l’opérateur ++:

[1, 2] ++ [3, 4]

Ce qui permettra, au besoin, d’ajouter un élément à une liste (attention ce n’est pas très perfomant !):

# on ajoute 3 à la liste en l'englobant dans une liste intermédiaire et en appelant `++`
[1, 2] ++ [3]

Les tableaux associatifs

Ce sont l’équivalent des dict Python, et leur syntaxe est très différente: voici ci-dessous un exemple en Python et un exemple équivalent en Elixir (j’ai cherché un nom de mineur typique 🤷).

en Python:

# Rappel: un dictionnaire Python
gonzague = {
  "prenom": "Gonzague",
  "nom": "de Saint-Louis",
  "age": 20
}

print( f"l'élève {gonzague["prenom"]} {gonzague["nom"]} a {gonzague["age"]} ans" )

en Elixir:

# avec un dictionnaire Elixir
gonzague = %{
  "prenom" => "Gonzague",
  "nom" => "de Saint-Louis",
  "age" => 20
}

IO.puts( "l'élève #{gonzague["prenom"]} #{gonzague["nom"]} a #{gonzague["age"]} ans" )

Les différences syntaxiques sont donc les suivantes:

  • on utilise les délimiteurs %{ et } pour encadrer le tableau associatif (et non { et } comme en Python, qui en Elixir sont utilisés pour les tuples)
  • on sépare les clés des valeurs avec la “flèche” =>

Il existe également des raccourcis syntaxiques au cas où l’on utilise des atomes en guise de clés, démonstration:

gonzague = %{
  prenom: "Gonzague",
  nom: "de Saint-Louis",
  age: 20
}

"l'élève #{gonzague.prenom} #{gonzague.nom} a #{gonzague.age} ans"

on remarque alors que:

  • les clés sont écrites avec “le : collé à la fin. Cette syntaxe est en fait équivalente à écrire:
    %{
      :prenom => "Gonzague",
      :nom => "de Saint-Louis",
      :age => 20
    }
  • on accède aux différentes valeurs avec une syntaxe . qui est également un raccourci:
    gonzague.prenom
    est équivalent à
    gonzague[:prenom]

Bonus: syntaxe de mise à jour

Il existe une syntaxe spécifique pour mettre à jour les champs d’une Map Elixir: il suffit de:

  • créer un bloc entre %{ et }
  • mettre en premier la valeur précédente
  • puis le symbole |
  • puis les clés/valeurs à mettre à jour

exemple:

ernest = %{
  # Ernest a tous les champs de gonzague, à l'exception du prénom
  gonzague | prenom: "Ernest"
}

Pattern-matching simple

Une des principales caractéristiques d’Elixir, en tant que langage, est la prévalence de la notion de “pattern matching” dans le langage. Nous allons commencer à explorer cette notion, à travers des exemples simples puis de plus en plus complexes.

Opérateur =

Nous avons vu plus tôt que nous pouvions assigner une variable avec une syntaxe identique à celle de Python:

number_of_the_beast = 666

Ce que nous n’avons pas évoqué par contre, c’est que l’opérateur = d’Elixir n’est pas un opérateur d’assignation mais un opérateur de “matching”: il essaye de vaire correspondre son membre de gauche avec son membre de droite. Dans notre cas ci-dessus:

  • à gauche nous avions la variable number_of_the_beast
  • à droite le nombre 666

et il a assigné la valeur à la variable pour satisfaire l’égalité.

mais nous aurions parfaitement pu écrire (ce qui n’aurait aucun sens en Python):

666 = 666

là où cette syntaxe devient plus intéressante, c’est lorsqu’on a une des valeurs plus complexes (des conteneurs typiquement) à gauche et à droite, par ex. un tuple:

{a, b, c} = {1, 2, 3}

dbg(a)
dbg(b)
dbg(c)

si vous avez bien suivi vos cours de Python, vous devriez me rétorque qu’on peut faire à peu près la même chose en Python:

(a, b, c) = (1, 2, 3)

print(f"{a=}")
print(f"{b=}")
print(f"{c=}")

mais Elixir pousse le concept beaucoup plus loin:

  • on peut avoir non seulement des variables mais aussi des valeurs à gauche
  • on peut également mapper des dictionnaires

exemples:

# avec un Tuple
{1, a, 2} = {1, 44, 2}
dbg(a)

# avec une liste
[3, c, d] = [3, 4, 5]
dbg(c)
dbg(d)

# avec un dictionnaire
%{
  "nom" => nom,
  "prenom" => prenom
} = %{
  "nom" => "Smith",
  "prenom" => "John"
}
dbg(prenom)
dbg(nom)

Pattern matching sur les listes chainées

Il est possible de faire du pattern-matching en utilisant l’opérateur | sur les listes:

ex_list = [1, 2, 3, 4]

[a | rest] = ex_list
dbg(a)
dbg(rest)

Échec de pattern-matcing

⚠️ ATTENTION si Elixir n’est pas capable de trouver une correspondance entre les éléments à gauche et à droite du =, cela génèrera une erreur de type MatchError:

# Ici nous n'avons pas le même nombre de termes à gauche et à droite !
[a, 1] = [2, 3, 4]

(Quelques) Structure conditionnelles

Nous avons tenu jusqu’ici sans évoquer le if, c’est un exploit 🤣

Il est temps de vous présenter les structures conditionnelles en Elixir: certaines sont connues, d’autres vous surprendrons. Dans tous les cas, vous devriez avoir absorbé suffisemment d’éléments de syntaxe pour comprendre les exemples suivants.

Le if (et son cousin unless)

Comme la plupart des langages, Elixir dispose d’une structure de contrôle if. Sa syntaxe est proche de celle de Python, avec comme différence principale la présence de blocs définis entre do et end:

# faites varier la valeur de a
a = 100

if a > 10 do
  IO.puts "a est grand"
  true # valeur retournée par le if = dernière expression de ce bloc
else
  IO.puts "a est petit"
  false # valeur retournée par le else = dernière expression de ce bloc
end

💡 Vous remarquerez que le if retourne toujours une valeur, qui est la valeur de la dernière expression évaluée dans le bloc. Il est donc possible de récupérer cette valeur dans une variable:

was_big = if a > 10 do
  true
else
  false
end

dbg(was_big)

comme évoqué il existe une construction unless qui est équivalent à un “if not”:

unless a <= 10 do
  IO.puts "a n'est pas petit"
else
  IO.puts "a n'est pas pas petit"
end

Le cond

Le cond est la construction Elixir qui remplace le elif de Python: elle permet d’enchaîner les tests les uns après les autres, jusqu’à qu’une des branches soit vraie.

Soit une valeur de référence:

a = 60

Partons d’un exemple en Python:

if a < 10:
  print("a est tout petit")
elif a < 50:
  print("a est de taille moyenne")
elif a < 90:
  print("a est de grande taille")
else:
  print("a est de immense")

nous pouvons le ré-écire en Elixir comme suit:

cond do
  a < 10 ->
    "a est tout petit"

  a < 50 ->
    "a est de taille moyenne"

  a < 90 ->
    "a est de grande taille"

  # une condition finale à "true" (toujours vraie) tient lieu de "else"
  true ->
    "a est immense"
end

on remarque donc la syntaxe du cond:

  • il s’agit d’une série de sous-blocs (eux même à l’intérieur du bloc doend)
  • chaque expression commence par une condition (a < 10, a < 50 etc.)
  • suivie d’une flèche ->
  • suivie du code à évaluer dans le cas où la condition renvoie true

vous pouvez faire varier la valeur de référence afin d’observer les changements et de confirmer un comportement identique à celui de Python

Le case

Une autre construction intéressante est le case qui est semblable au switch que l’on trouve dans certains langages de programmation (mais pas en Python). Cette construction permet de comparer une unique valeur à une série de valeur possible, tout en faisant du pattern-matching:

case a do
  # uniquement si a == 0
  0 ->
    "a est nul"

  # on récupère la valeur de a dans la variable local "val"
  val ->
    "a vaut #{val}"
end

cette construction n’a pas l’air très puissante en soi, mais sa capacité à faire du pattern-matching la rend très puissante, par ex.:

# faites varier tab !
tab = [ 1, 2, 3 ]

case tab do
  # si tab est une liste de 3 éléments qui commence par 1 et se termine par 3
  [ 1, x, 3 ] ->
    "x vaut #{x}"

  # si tab est une autre liste de 3 elements
  [ a, b, c] ->
    a + b + c

  # dans tous les autres cas (`_` est une variable "poubelle")
  _ ->
    "pas le format attendu !"
end

Exercices

Nous avons introduit beaucoup de notions d’un coup ci-dessous, il est temps de mettre tout ça en pratique à travers quelques exercices (simples 😊).

Exercice 1 – pair ou impair

Complétez le code ci-dessous, afin que la fonction retourne “pair” si le nombre passé en argument est pair, ou “impair” si ce n’est pas le cas:

defmodule TP2Exercice1 do

  @doc """
  Détermine si un nombre est pair ou impair

  ## Exemples

    iex> TP2Exercice1.pair_ou_impair(0)
    "pair"

    iex> TP2Exercice1.pair_ou_impair(7)
    "impair"
  """


  def pair_ou_impair(n) do
    cond do
      # Votre code ici
      true -> "TODO"
    end
  end

end

Exercice 2 – Somme des éléments d’une liste

Dans cet exercice, nous allons écrire notre première fonction récursive. L’idée est d’utiliser un case couplé au pattern-matching sur les listes afin de calculer la somme des éléments d’une liste.

💡 Rappel: une fonction est dite récusrive si elle s’appelle elle-même.

defmodule TP2Exercice2 do

  @doc """
  Calcule la somme des éléments d'une liste.

  ## Exemples

    iex> TP2Exercice2.somme_liste([])
    0

    iex> TP2Exercice2.somme_liste([42])
    42

    iex> TP2Exercice2.somme_liste([1, 2, 3])
    6
  """
  def somme_liste(l) do
    case l do
      # À compléter
      _ -> 0
    end
  end

end

Vous êtes désormais familier de la syntaxe Elixir. Évidemment, il vous faudra continuer à pratiquer le langage pour acquérir de réelles compétences de programmation, mais soyez fiers de vous !

La suite vous attend dans le notebook sur les fonctions, à très vite.