C1N3 – Approfondissement sur les fonctions
Mix.install([
{:pythonx, "~> 0.4.2"},
{:kino_pythonx, "~> 0.1.0"},
{:kino, "~> 0.16.1"}
])
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []
Les fonctions et le pattern-matching
Jusqu’à présent, nous avons exploré les fonctions a travers des cas simples, en respectant toujours une même syntaxe:
- la fonction est définie une fois par module
- elle prend (en option) des arguments sous forme de variable
-
son bloc, défini entre les délimiteurs
do
…end
, retourne sa dernière valeur
Cette façon de faire fonctionne très bien, et permet de traiter des cas relativement complexes:
defmodule Factorielle do
def fact(n) do
case n do
0 ->
1
n ->
n * fact(n-1)
end
end
end
Factorielle.fact(4)
Le code ci-dessous, bien que parfaitement valide, n’est pas parfaitement idiomatique, ce cela pour plusieurs raison. Dans les sections ci-dessous, nous allons explorer de nouveaux éléments de syntaxe qui vont nous permettre de simplifier ce code, étape par étape.
Élément de syntaxe n°1 : le pattern matching dans la déclaration de la fonction
Le code ci-dessus, via son case
, réalise une opération de “pattern matching” : c’est-à-dire qu’il tente de faire correspondre la variable n
avec une série de valeur successive (d’abord 0
, puis une valeur quelconque) afin de trouver une correspondance:
Il est en fait possible de réaliser cette opération directement au niveau de la déclaration de la fonction, comme suit:
defmodule FactorielleV2 do
def fact(0) do
1
end
def fact(n) do
n * fact(n-1)
end
end
FactorielleV2.fact(4)
ici nous avons apporté deux modifications:
- nous avons écrit deux fois la même fonction
-
dans la permière “version” de la fonction nous avons remplacé la variable
n
par la valeur constante0
Suite à cette modification, Elixir va désormer évaluer FactorielleV2.fact/1
comme suit:
- il va “tester” les différentes variantes de la fonction, dans l’ordre où elle sont définies (“de haut en bas” dans le code) et exécuter la première fonction pour laquelle les argument “matchent” la valeur passée en argument
- dans le cas d’une récursion – cf. ligne 8 ci-dessus - il re-testera toutes les fonctions dans l’ordre
⚠️ ATTENTION: ce mode de fonctionnement, où Elixir teste nos fonctions jusqu’à en trouver une qui convienne implique plusieurs comportements:
-
si nous avions placé la variante
fact(n)
au dessus defact(0)
, cette dernière ne serait jamais appelée: carfact(n)
“branche toujours”,n
étant une variable libre qui accepte n’importe quelle valeur ! - si aucune des variantes d’une fonction ne “branche”, Elixir génèrera une erreur et le programme s’arrête
Élément de syntaxe #2 : blocs d’une ligne
Quand le bloc ne contient qu’une expression simple, il est possible de l’abréger:
-
on remplace
do
…end
par un argumentdo:
-
et on place une virgule
,
avant cedo:
démonstration:
defmodule FactorielleV3 do
# le bloc do...end a disparu au profix de , do: ...
# 👇 attention à ne pas oublier cette virgule !
def fact(0), do: 1
# 👇 ni celle-là !
def fact(n), do: n * fact(n-1)
end
FactorielleV3.fact(4)
La récursivité terminale
Elixir – comme OCaml… mais pas comme Python – est capable d’optimiser les fonctions récursives qui ont la propriété de récursivité terminale, c’est-à-dire:
- ce sont des fonctions récursives
-
la dernière opérations qu’elles réalisent est soit:
- le renvoi d’une valeur constante
- un appel récursif à elles-même
Question: nos questions ci-dessus (par ex. FactorielleV3.fact/1
) ont-elles cette propriété de récusrivité terminale ?
(réfléchissez un peu avant de scoller pour voir la réponse 😉)
Et bien la réponse est NON !
en effet, la dernière opération de notre fonction fact()
n’est pas de s’appeler elle-même, c’est la multiplication:
Nous allons ré-écrire notre calcul de la factorielle pour utiliser cette récursivité terminale.
Mais avant ça présentons un dernier élément de syntaxe:
Élément de syntaxe #3 : les fonctions privées
En remplaçant def
par defp
lors de la déclaration de fonctions dans nos modules, il est possible de créer des fonctions privées:
- elle sont appelables par les autres fonctions dans le même modules
- mais ne sont pas accessible en dehors du module
Démonstration:
defmodule PublicPrivateDemo do
def public_f do
IO.puts("Je suis une fonction publique")
private_f()
end
# 👇 c'est le "p" de "defp" qui marque la fonction comme privée
defp private_f do
IO.puts("Je suis une fonction privée")
end
end
# ✅ cet appel va fonctionner, et la fonction privée sera invoquée
PublicPrivateDemo.public_f()
# 🛑 l'appel suivant ne fonctionnera pas
PublicPrivateDemo.private_f()
Version “terminal recusrive” de la factorielle
Pour optimiser notre fonction factorielle, nous allons apporter deux modifications à notre code:
-
la fonction
fact/1
délèguera le calcul à une fonction privéefact/2
- cette dernière sera rendue terminal-recusrive en rajoutant un argument “accumulateur”
en pratique:
defmodule FactorielleV4 do
# Notre seule fonction "publique" délègue à la fonction privée
def fact(n), do: fact(n, 1)
# À la fin de la récursion, on retourne l'accumulateur (second arg)
defp fact(0, acc), do: acc
# 👇 le décrément du compteur
defp fact(n, acc), do: fact(n-1, n * acc)
# ↑ 👆 le produit du calcul
# └ on appelle "fact" en dernier !
end
FactorielleV4.fact(4)
Exercice pratique
Écrivez un module simple qui calcule le nième terme de la suite de Fibonacci.
Pour rappel, la suite est définit comme suit:
$$ \begin{aligned} F{0} & =0 \ F{1} & =1 \ F{n+2} & = F{n+1}+F_n \end{aligned} $$
Vous pouvez faire plusieurs variantes:
defmodule TP3Exercice1 do
@doc """
Calcule le n-ième terme de la suite de Fibonacci
## Exemple
iex> TP3Exercice1.fib(2)
1
iex> TP3Exercice1.fib(3)
2
iex> TP3Exercice1.fib(10)
55
"""
def fib(b) do
0 # TODO
end
end
L’opérateur “pipe”
Il est souvent nécessaire d’enchaîner les appels de fonctions les une après les autres, afin d’apporter des modifications successives à une donnée de départ.
Prenons un exemple pratique : imaginons que nous travaillions sur un système de blog. L’utilisateur peut créer des articles, et définir le titre du nouvel article. Puis arrive le moment de la publication et nous souhaiterions générer une adresse “explicite” pour ce post, par ex:
- si l’utilisateur a écrit un post nommé “Mon premier blog-post”
- nous aimerions que l’adresse internet de l’article soit “mon-premier-blog-post”
Pour se faire, nous imaginons un algorithme:
- nous partons du titre et le mettons en minuscue
- puis nous séparons les mots en “coupant” au niveau des espaces
-
enfin nous “regroupons” les morceaux en les relitant avec des tirets
-
En Python:
titre_article = "Mon premier blog post"
# la méthode .join() de ste
"-".join(
titre_article
.lower() # transforme la str en minuscule
.split(" ") # coupe la str en une list de str
)
Essayons maintenant de faire la même chose en Elixir:
titre_article = "Mon premier blog post"
Enum.join(
String.split(
String.downcase(
titre_article
),
" "
),
"-"
)
ce n’est pas extrêmement lisible…
Une alternative possible serait de passer par une variable que l’on ré-assignerait au fur et à mesure des transformations:
titre_article = "Mon premier blog post"
slug = String.downcase(titre_article)
slug = String.split(slug, " ")
slug = Enum.join(slug, "-")
slug
Beaucoup mieux !
Nous voyons là les forces et les faiblesse de l’approche “objet” de Python:
-
il est facile “d’enchaîner” les appels des méthodes de
str
telles que.lower()
et.split()
-
la “chaîne de caractère courante” est passée implicitement : le langage “sait” que nous travaillons sur
titre_article
-
par contre, ça ne fonctionne pas lorsque la fonction à appeler ne fait pas partie des méthodes de
str
: dans le code Python plus haut, nous avons finalement besoin de faire un.join()
mais cette méthode n’est pas disponible, et on ne peut pas “enchaîner l’appel avec.
“
Elixir propose une syntaxe dite de “pipe” |>
qui permet de faire sensiblement la même chose:
- le résultat de la fonction précédente est implicitement passé comme premier argument de la fonction suivante
- on peut mettre n’importe quelle fonctions “à la suite”, contrairement aux objets ou seules mes méthodes de la classe peuvent être “chaînées”
Démonstration:
titre_article = "Mon premier Blog Post"
titre_article
|> String.downcase() # titre_article est passé en argument implicitement
|> String.split(" ") # idem pour le résultat de downcase
|> Enum.join("-") # et de join
BONUS: debug des enchaînements d’opérations
Elixir dispose d’une macro dbg
qui permet d’analyser ce qui se passe à l’intérieur d’une suite d’opérations. Cette fonctionnalité est particulièrement bien intégrée à LiveBook.
Pour l’utiliser il suffit de rajouter une étape de pipe vers dbg()
:
titre_article
|> String.downcase()
|> String.split(" ")
|> Enum.join("-")
|> dbg()
Les fonctions anonymes
Elixir dispose d’un équivalent aux lambda
s de Python: c’est-à-dire une façon de définir une fonction qui n’a pas de nom (et qui n’appartient pas à un module !).
Cette syntaxe se présente comme suit:
-
le mot-clé
fn
- suivi (éventuellement), d’une liste d’arguments
-
suivi d’une “flèche”
->
-
et enfin le mot-clé
end
à la fin de la déclaration
# Exemple de fonction, mise dans la variable ma_f
elixir_anon_f = fn x ->
x+1
end
est équivalent à la déclaration Python:
python_anon_f = lambda x: x+1
on peut vérifier que cette variable contient bien une fonction:
is_function(elixir_anon_f)
et on peut l’invoquer.
⚠️ ATTENTION quand on évoque une fonction anonyme, il faut rajouter un .
avant les parenthèses !
elixir_anon_f.(10)
Quel est l’intérêt de ces fonctions anonymes ?
Les fonctions anonymes sont utiles en tant qu’argument passé à d’autres fonctions, dans l’objectif de spécialiser un comportement.
Par exemple, on pourrait vouloir effectuer une opération “en série” sur tous les éléments d’une liste, comme le ferait map
en Python:
l = [1, 2, 3, 4]
list(
map(
lambda x: x ** 3, # on applique cette fonction "cube"
l # à tous les éléments de l
)
)
Pour réaliser la même chose en Elixir, on ira piocher dans les resources du module Enum
d’Elixir qui permet de travailler sur les collections (et les listes en particulier):
l = [1, 2, 3, 4]
l
|> Enum.map(fn x -> x ** 3 end)
Un petit exercice
Le but de cet exercice est avant tout de vous familiariser avec la documentation d’Enum
en recherchant un moyen de réaliser un petit exemple simple:
- on vous donne les liste des couleurs et des valeurs des cartes à jouer classiques (en anglais, car en français “Roi” et “Reine” commencent par la même lettre…)
- vous devez générer la liste de toutes cartes possbiles en combinant ces deux listes
par ex.: ♠️A pour l’as de pique, ♥️9 pour le neuf de coeur etc.
couleurs = ["♠️", "♥️", "♦️", "♣️"]
valeurs = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
# À vous de jouer !
Le problème de “la boucle for
“
Elixir ne possède pas de boucle for
comme en Python.
Il y a des raisons à cela, et en particulier la mémoire “immutable” ne permet pas certains constructions.
Il y a alors deux façons d’itérer une série de valeurs:
- utiliser la récursion: ça ne vous paraît sans doute pas (encore) naturel mais c’est très commun
-
utiliser le module
Enum
et en particulier la fonctionEnum.each/2
Par exemple:
l = [ 1, 2, 3, 4, 5 ]
# Parcourons les valeurs pour écrire dans la console:
l
|> Enum.with_index()
|> Enum.each(fn {x, index} ->
IO.puts("la valeur n°#{index+1} = #{x}")
end)
# |> dbg()
Les compréhensions de liste
Je vous ai expliqué qu’au paragraphe précédent qu’Elixir n’avait pas de boucle for
: c’est à la fois vrai et faux 😅
Si l’on veut être précis, Elixir n’a pas de “boucle for
“ mais une “macro for
“ et cette fonctionnalité est appelée “compréhension” en Elixir. Ce nom n’est pas choisi au hasard: son utilisation est très proche des compréhensions de listes Python.
Illustration: exécutez la cellulle ci-dessous 👇
for i <- [1, 2, 3, 4] do
i ** 2
end
d’un point de vue syntaxique, la compréhension de liste Elixir se construit comme suit:
-
le mot-clé
for
-
suivi d’une assignation de variable, sous la forme
nom_de_variable <- collection
-
et d’un bloc
do
…end
Cette expression retourne finalement une nouvelle liste où chaque élément est le résultat du bloc qui est exécuté.
bien entendu, la collection peut aussi venir d’une variable: par exemple les couleurs
de cartes définies dans la section précédente:
for c <- couleurs do
"#{c}!"
end
mais bien entendu, les capacités du for
Elixir ne s’arrêtent pas là !
Itération de liste en parallèle
Le for
d’Elixir permet très facilement d’itérer plusieurs listes “en même temps”, par exemple:
for c <- couleurs,
v <- valeurs do
c <> v
end
nous avons résolu l’exercice précédent en trois lignes !
on pourrait même écrire une version encore plus courte, en remplaçant le bloc do
…end
par sa forme abrégée , do: ...
:
all_cards = for c <- couleurs, v <- valeurs, do: c <> v
on peut vérifier au passage qu’on a bien 52 cartes:
length(all_cards)
Filtrage de données
Il est également possible d’intercaler des “tests” dans notre expression for
, afin de filtrer certaines valeurs.
Par exemple on pourrait vouloir un jeu de 32 cartes, c’est-à-dire un jeu sans les cartes de 2 à 6:
less_cards =
for c <- couleurs,
v <- valeurs,
# 👇 je rejette les cas où v est cette la liste
v not in ~w[2 3 4 5 6] do
c <> v
end
length(less_cards)
Nous n’avons fait qu’éffleurer la surface de ce qui est possible avec les compréhensions Elixir. Pour aller plus loin, vous pouvez consulter:
-
la documentation d’Elixir sur le
for
- l’article de Mitchell Hanberg sur le sujet
Nous avons terminé nous tour des principaux éléments de syntaxe d’Elixir.
Vous êtes désormais fin prêts à aborder le TP final.