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

C1N3 – Approfondissement sur les fonctions

3-les-fonctions.livemd

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 doend, 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:

  1. nous avons écrit deux fois la même fonction
  2. dans la permière “version” de la fonction nous avons remplacé la variable n par la valeur constante 0

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 de fact(0), cette dernière ne serait jamais appelée: car fact(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 doend par un argument do:
  • et on place une virgule , avant ce do:

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ée fact/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 lambdas 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 fonction Enum.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 doend

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


Nous avons terminé nous tour des principaux éléments de syntaxe d’Elixir.

Vous êtes désormais fin prêts à aborder le TP final.