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

Elixir Exercism I

docs/elixir-exercism-01.livemd

Elixir Exercism I

Mix.install([
  {:benchee, "~> 1.0"}
])

Basics

Elixir est un langage a typage dynamique. Le type d’une variable est déterminé à l’exécution du code.

l’opérateur = (match) permet de lier (bind) une valeur à une variable. Exercism est plutôt très concis sur le sujet. Pour qui n’a pas un peu d’expérience en matière de langages fonctionnels, l’information peu paraître un peu nébuleuse. Nous allons essayer d’étoffer cette notion.

Dans un langage non fonctionnel où les données sont mutables, on conçoit en générale une variable comme un espace pouvant accueillir une valeur.
L’expression a = 12 sous-entend que a est un conteneur ou un espace dans lequel on enregiste la valeur 12. L’opérateur = est conçu comme un opérateur d’assignation d’une valeur de la variable. On peut ultérieurement altérer la valeur de la variable : a = a + 1

Dans un langage fonctionnel où les données sont immutables (ne peuvent être modifiées), on conçoit en général une variable comme un label associé à une valeur (semblable à la démarche mathématique).
Dans ce cadre, l’expression a = 12 est conçue comme le fait d’attacher le nom a à la valeur 12. Il est à noter que de nombreux langages fonctionnels accepteront une opération telle que a = a + 1 qui peut sembler similaire en apparence. Cependant dans le cadre d’un langage tel que Elixir, une nouvelle valeur égale à la valeur de a + 1 est crée et se voir assigner le nom a. L’ancienne valeur de a est détruite (ou mise à disposition du garbage collector).

Dans le cadre d’Elixir, l’opérateur = va encore au delà de l’attachement d’une valeur, il s’agit en réalité d’un opérateur qui effectue un pattern matching avec la valeur à droite du signe =. On ne va pa rentrer dans le détail à ce stade mais l’expression suivante devrait donner une idée de ce concept:

[a, b] = [1, 2]
IO.puts("a = #{a}, b = #{b}")

Modules

Les modules constituent les éléments de base d’organisation du code Elixir.

  • Un module est visible par tous les autres modules.
  • Un module est défini par defmodule.
defmodule MyModule do
  z = 42
  z
end

Named Functions

On parle de fonctions nommées par opposition aux fonctions anonymes. Cela sera sous-entendu dans la suite de la sections.
Toutes les fonctions doivent être définies dans un module

  • les fonctions publiques sont défines avec def.
  • les fonctions privées sont définies avec defp.
  • la valeur de la dernière expression d’une fonction est retournée implicitement. Il n’y a pas d’instruction return.
  • les fonctions courtes peuvent être écrites sur une ligne avec une syntaxe spécifique.
defmodule Functions do
  def increment(n) do
    n + 1
  end

  def call_private_increment(n), do: private_increment(n)

  defp private_increment(n) do
    n + 1
  end

  def short_increment(n), do: n + 1
end

IO.puts(
  "increment(5): #{Functions.increment(5)}, short_increment(6): #{Functions.short_increment(6)} "
)
  • La fonction private_increment n’est pas accessible à l’extérieur du module Functions.

  • A l’intérieur d’un module, la fonction peut être invoquée directement par son nom.

  • A l’extérieur du module, la fonction est invoquée précédée du nom du module (ie: Functions.increment(5))

  • l’arité d’une fonction est un concept important en Elixir. l’arité indique le nombre d’argument d’arguments ou paramètres d’une fonction.

La fonction add suivante est une fonction d’arité 3. L’arité d’une fonction est généralement indiqué de la façon suivante: add/3

defmodule Arity do
  def add(x, y, z), do: x + y + z
end

Naming Conventions

Les noms de modules utilise la convention PascalCase (comme les classes en javascript). Un module doit commencer par une lettre (majuscule) et peut contenir des lettres (A-Z|a-z), des nombres (0-9) ou encore des caractères de soulignement (_).

Les noms de variables et de fonctions utilisent la conventions snake_case. les noms de variables ou fonctions doivent commencer avec un underscore ou une lettre minuscule (a-z), et peuvent contenir des lettres, des nombres, des underscore et peuvent se terminer avec un un point d’exclamtion ou d’interrogation.

  • le point d’interrogation est généralement utiliser pour indiquer qu’une fonction retourne une valeur booléene (def even?(n), do: rem(n,2) == 0).
  • le point d’exclamation indiquera en générale une fonction succeptible de lever une erreur.

Integers

Les entiers sont des nombres écrits avec un ou plusieurs chiffres. Vous pouvez effectuer les principales opérations mathématiques sur ces nombres.

Strings

Les chaines littérales sont des séquences de caractères entourées par des guillemets.

string = "This is a string"

Standard library

La documentation de la librairie standard est disponible en ligne at hexdocs.pm/elixir.

  • la plupart des types ont un module associé (ie: Integer, Float, String, Typle, List)

Le module Kernel est un module particulier:

  • il est importé automatiquement
  • ses fonctions peuvent être utilisées sans être préfixées par le nom du module.
  • il fournit un ensemble de fonctions considérées comme essentielles.

Code comments

Les lignes de commentaires sont précédées par un #.

# ceci est un commentaire
a = 42

En savoir plus

About Booleans

Elixir représente les valeurs vrai et faux avec le type booléen. Il y a seulement deux valeurs: true et false. Ces valeurs peuvent être combinées avec des opérateurs booléens (and/2, or/2, not/1)

true_variable = true and true
false_variable = true and false

true_variable = false or true
false_variable = false or false

true_variable = not false
false_variable = not true

Les opérateurs [and/2][strict-and], [or/2][strict-or] et [not/1][strict-not] sont strinctement booléens. Ils nécessitent que leur premier argument soit un booléen. Il y a également des opérateurs booléens équivalents qui fonctionnent avec n’importe quel type d’argument ([&&/2][and], [||/2][or], and [!/1][not]).

Les opérateurs booléens utilisent une évaluation avec court-circuit ce qui veut dire que l’évaluation s’arrête dès que le résultat est connu. true or whatever() donnera toujours true indépendemment de la valeur retournée par whatever(), whatever n’a pas besoin d’être évalué.

Attention à l’order de priorité des opérateurs. Le mieux est d’utiliser des parenthèses pour éviter toute ambiguité

not true and false
not (true and false)

Quand une fonction returne une valeur booléenne, [par convention][naming], on termine en général cette fonction par un point d’interrogation (?).

defmodule MyModuleBool do
  def either_true?(a, b), do: a or b
end

Noter cependant que les fonctions qui sont compatibles avec les clauses guard (guard clauses) suivent la convention Erlang et sont préfixées par is_ (ie: is_even) et ne se terminent pas par un point d’exclamation.

About Integers

Elixir propose deux types de nombres - les entiers et les flottants (integers and floats).

Des fonctions utiles pour manipuler les entiers sont disponibles dna le module [Integer][integer].

Integer.digits(123)

Les Big integers (mais aussi les flottants) sont en général formattés en séparant les groupes de 3 chiffres par un caractères de soulignement.

1_000_000

la taille maximum d’un nombre en Elixir est seulement limitée par la mémoire disponible car élixir utilise une [arithmétique multiprécision][arbitrary-precision-arithmetic].

Elixir supporte aussi des notations spécifiques pour saisir des [binary, octal, and hexadecimal integers][integers-in-other-bases].

0b0100
0o555
0xFF

Comparaison

Les entiers et les flottants peuvent être considérés comme égaux ([==][kernel-equal]) s’ils ont la même valeur. Cependant, comme leurs types sont différents, ils ne sont pas strictement égaux ([===][kernel-strictly-equal]).

1 == 1.0
1 === 1.0

About floating points

Les nombres flottants sont des nombres avec un ou plusieurs chiffres après la virgule. Ils utilisent un format double-précision sur 64 bits.

float = 3.45

Elixir supporte également la notation scientifique:

1.25e-2

Erreurs d’arrondi

Floats are infamous for their rounding errors. Les flottants sont tristement célèbres pour les erreurs d’arrondi

0.1 + 0.2

Ces problématiques ne sont pas spécifiques à Elixir et touchent tous les langages de programmation. Un système d’une base donnée ne peut exprimer des fractions exactes que si le dénominateur est un facteur premier de la base. En binaire, la base utilisée par les ordinateurs, seuls 1/2, 1/4 et 1/8 peuvent être exprimés correctement. Les autres fractions ne sont que des approximations.

# 3/4
Float.ratio(0.75)
# 3/5
Float.ratio(0.6)

Ce problème est expliqué plus en détail à 0.30000000000000004.com.

Comparaisons

Comme déjà indiqué dans la section consacrée aux entier, les entiers et les flottants peuvent être considérés comme égaux (==) s’ils ont la même valeur. Cependant, comme leurs types sont différents, ils ne sont pas strictement égaux (===).

1 == 1.0
1 === 1.0

Conversion

Les entiers et les flottants peuvent être utilisés dans une même expression. Utiliser un floattant dans une expression implique que le résultat sera un flottant.

2 * 3
2 * 3.0

Avec les fonctions du module Float, les nombres flottants peuvent être arrondis (Float.round), arrondis à la valeur supérieure (Float.ceil), ou arrondis à la valeur inférieure (Float.floor).
La valeur retournée par ces fonctions rest un nombre flottant. Pour obtenir un entier, il convient d’utiliser les fonctions équivalentes du module Kernel (round, ceil, floor).

une autre méthode pour changer un flottant en entier est de tronquer sa partie décimale avec la fonction trunc.

Float.ceil(5.2)
ceil(5.2)
trunc(5.2)

Anonymous functions - Fonctions anonymes

Les fonctions anonymes sont communément utilisées avec Elixir, pour elles-mêmes, comme valeurs de retour et comme arguments dans les fonctions d’ordre supérieur (higher order functions) telles que Enum.map/2:

Enum.map([1, 2, 3], fn n -> n + 1 end)

Comme dans tous les langages fonctionnels de ma connaissance, les fonctions en Elixir son traitées comme des citoyens de premère classe (first class citizens):

  • les fonctions nommées (def) et anonymes peuvent être assignées à des variables.
  • les fonctions nommées et anonymes peuvent être passées comme des données en tant qu’argumment et valeur de retour.
  • les fonctions anonmymes peuvent être créées dynamiquement.

Les fonctions anonymes sont créées avec le mot-clé fn et invoquées avec un point (.). Pour être honnête, je ne suis pas vraiment fan de cette distinction. Voici une discussion sur le sujet sur StackOverflow.

function_variable = fn n -> n + 1 end
function_variable.(1)

Les fonctions anonymes peuvent être créées avec le raccourci de capture &.

  • Le & initial declare le début de l’expression capturée.

  • &1, &2, etc. indiquent les arguments de la fonction anonyme.

    # Instead of:
    a = fn x, y -> abs(x) + abs(y) end
    
    # We can write:
    b = &(abs(&1) + abs(&2))
    
    a.(-1, 2) == b.(-1, 2)
  • L’opérateur de capture & peut aussi être utilisé pour capturer une fonction nommée eixtantes

    # Instead of:
    a = fn a, b -> a <= b end
    
    # We can capture the function using its name and arity:
    b = &amp;<=/2

Variables assigned inside of an anonymous function are not accessible outside of it: Les fonctions anonymes en Elixir sont des closures. Elles peuvent capturer les variables qui sont dans la portée de la fonction lorsque celle-ci est définie.

y = 2

square = fn ->
  x = 3
  x * y
end

square.()
# => 6

Lists

Les listes (Lists) font partie des types de base d’Elixir pour contenir une collection de valeurs. Les listes sont immuables. Tout opération qui modifie une liste retoure une nouvelle list. Les listes implémentent le protocole Enumerable qui permet l’utilisation des fonctions des modules Enum et Stream.

En Elixir, les listes sont implémentées comme des listes chaînées et non comme des tableaux (comme par exemple en python) utilisant un espace contigüe. L’accès à un élément d’une liste se fait de manière séquentielle et nécessite donc un temps O(n) car il peut être nécessaire de parcourir toute la liste pour accéder à un élément (le dernier).

Les listes peuvent être écrite en forme littérale, en notation “head-tail” (qui utilise |, l’opérateur cons) ou encore une combinaison des deux.

# Literal Form
[]
[1]
[1, 2, 3]
# Head-tail Notation
[]
# same as [1]
[1 | []]
# same as [1, 2, 3]
[1 | [2 | [3 | []]]]
# Mixed
# same as [1, 2, 3]
[1 | [2, 3]]

Il peut y avoir plus d’un élément avant l’opérateur | (cons).

# Multiple prepends
[1, 2, 3 | [4, 5]]

La notation Heat-tail peut être utilisée pour ajouter des éléments en début de liste liste.

list = [2, 1]

[3, 2, 1] == [3 | list]

ajouter des éléments à une liste de manière itérative est considéré comme un anti-pattern. Ajouter un élément nécessite de parcourir toute la liste et d’ajouter l’élément à la fin. ainsi ajouter un élément à chaque itération impliquerait une complexité de temps O(n*m).

On peut obtenir le même résultat de manière plus efficace en préfixant les éléments à la liste inversée et en inversant à nouveau la liste résulante. Ajouter un élément en tête de liste est une opération rapide qui demande un temps constant O(1).

# Appending to the end of a list (potentially slow)
[1, 2, 3] ++ [4] ++ [5] ++ [6]
# Prepend to the start of a list (faster, due to the nature of linked lists)
[6 | [5 | [4 | [3, 2, 1]]]]
# then reverse!

Il y a de nombreuses fonctions pour les listes dans le module Kernel:

  • hd/1 retourne la tête de la liste(head) – le premier élément de la liste.
  • tl/1 retourne la queue (tail) de la liste – la liste moins le premier élément.
  • length/1 retourne le nombre d’éléments dans la liste.
  • in/2 retourne une valeur booléenne indiquant si l’élément est un élément de la liste.

Un module dédié aux listes est également disponible.

Les listes peuvent contenir n’importe quel type de donnéee et un mélange de différents types:

list = [1, :a, 2.0, "string"]

Atoms

Le concept d’atome peut sembler un peu étrange initialement. C’est un type de donnée qui représente une constant nommée.

On peut utiliser les atomes quand on veut exprimer un jeu de constantes pour nommer des choses de manière lisible.

An atom is defined by its name, prefixed by a colon: Un atome est défini par son nom précédé par deux points (:) .

:an_atom

De nombreuses fonctions dans la librairie standard d’Elixir retourne un atome pour annoter le résultat

Enum.fetch([1], 0)
Enum.fetch([1], 2)

Ils sont également utilisés comme clés dans des Maps:

user = %{name: "John", status: :active}

Les atomes sont représentés en interne par un entier qui pointe sur une entrée d’une table qui qui contient les atomes à proprement parler. Ainsi la comparaison d’atomes est opérée sur des entiers et est donc très rapide.

Créer des atomes dynamiquement à patir de données fournies par l’utilisateur est généralement considéré comme un anti-pattern car l’espace réservé pour les atomes est limité. Cette opération pourrait donc échouer.

Cond

cond/1 est une structure de contrôle qui permet d’évaluer plusieurs conditions successives de manière similaire à une succession de elseif dans d’autres langages.

x=11
cond do
  x > 10 -> :this_might_be_the_way
  x > 15 -> :or_that_might_be_the_way
end

Les conditions sont évaluées dans l’ordre et le résultat correspondra à celui de la première condition vraie.
Si aucune condition n’est vraie, une exception CondClauseError est levée. Pour éviter cette situation, on ajoute en général une clause true qui sera toujours vraie et permettra de retourner une valeur par défaut.

x=11
cond do
  x > 10 -> :this_might_be_the_way
  x > 15 -> :or_that_might_be_the_way
  true -> :this_is_the_default_way
end

cond est généralement utilisé quand il y a plus de deux conditions et que chaque condition implique des variables différentes.

Si toutes les conditions s’appuient sur la même variable, une structure de contrôle case peut être plus adapté.

S’il n’y a que deux branches logiques l’utilisation de if est plus adaptée.