AP – Elixir – AP2

Construire notre langage

Un souci, une expérience, une nouvelle représentation

AP2 · jeudi 29 octobre 2026

jeudi 29 octobre 2026
AP – Elixir – AP2

Un programme qui lit des programmes

let rate = 0.9
let discounted = fn(total) { total * rate }
let price = 12.0
let quantity = 3.0
let total = price * quantity
case total < 30.0 {
  True -> total
  False -> discounted(total)
}

Ce texte est écrit dans notre langage cible. En Elixir, nous écrivons l'interpréteur qui le lit et calcule sa valeur.

jeudi 29 octobre 2026
AP – Elixir – AP2

Un langage se lit en trois niveaux, comme une phrase

Le chat dort se découpe en mots, se groupe en sujet et verbe, puis prend un sens ; 1.0 + 2.0 * 3.0 devient cinq tokens, un arbre, puis la valeur 7.0.

Le lexer découpe les mots, le parser construit la phrase, l'évaluateur en calcule le sens.

jeudi 29 octobre 2026
AP – Elixir – AP2

Python montre ses trois étapes

Pour 1 + 2 * x, tokenize affiche cinq tokens, ast un arbre BinOp dont la multiplication est un enfant, dis cinq instructions de la machine de Python.

Essayez sur un fichier : python3 -m tokenize calcul.py, puis -m ast, puis -m dis.

jeudi 29 octobre 2026
AP – Elixir – AP2

Cinq étapes, cinq petits carnets

Carnet Ce que nous gardons Ce que cela permet
01 une valeur courante calculer au fil de la lecture
02 un arbre respecter priorités et parenthèses
03 un environnement nommer des valeurs, appeler une fonction
04 un contexte capturé retrouver les noms de la définition
05 des branches distinctes ne calculer que la branche choisie

Le carnet 01 lit les mots, le 02 la phrase ; les carnets 03 à 05 enrichissent le sens. Chaque étape part d'un programme qui met la précédente en défaut.

jeudi 29 octobre 2026
AP – Elixir – AP2

Comment suivre la séance

Chaque étape suit le même rythme : quelques slides de cours, un atelier dans Livebook, puis une reprise ensemble.

  • Ouvrez le carnet au moment de la consigne « À vous » ; la page AP2 du site donne ses slides et ses repères.
  • Les contrats sont donnés, les fonctions sont à écrire : un exemple montre la forme, vous écrivez les cas analogues.
  • D'une étape à l'autre, recopiez vos propres modules dans les cellules indiquées.

Juste avant chaque atelier, une slide « Elixir pour cet atelier » rappelle les formes dont le carnet a besoin. Pour relire : poly, chapitre 5.

jeudi 29 octobre 2026
AP – Elixir – AP2

01 · Quelle règle donne ce résultat ?

1.0 + 2.0 * 3.0

Première règle : lire de gauche à droite et appliquer chaque opération au résultat déjà obtenu.

Quel résultat obtenons-nous ? Que ferions-nous de (1.0 + 2.0) ?

Carnet 01 · Calcul direct · poly, chapitre 5

jeudi 29 octobre 2026
AP – Elixir – AP2

Découper le texte en tokens

"12.0 + 3.0"

[{:number, 12.0}, :plus, {:number, 3.0}]

C'est le niveau lexical : un token est un « mot » du langage, un nombre, un opérateur ou une parenthèse.

Le lexer découpe le texte en tokens ; les espaces disparaissent à cette étape.

jeudi 29 octobre 2026
AP – Elixir – AP2

Une valeur courante suffit pour ce premier essai

Le calcul direct accumule 1.0, puis 3.0, puis 9.0 ; il ne conserve pas les opérations.

Que doivent contenir les paramètres du prochain appel récursif ?

jeudi 29 octobre 2026
AP – Elixir – AP2

Le calculateur rend une valeur ou une raison

run("8.0 - 3.0 - 2.0")   →  {:ok, 3.0}
run("1.0 / 0.0")         →  {:error, :division_by_zero}
run("1.0 +")             →  {:error, …}
run("1.0 2.0")           →  {:error, …}

ArithmeticOps.apply/3 est fournie : elle calcule une opération ou rend une erreur.
Vous écrivez le parcours, DirectCalculator.calculate/1.

jeudi 29 octobre 2026
AP – Elixir – AP2

Elixir pour cet atelier · 01

defp scan(<<space, rest::binary>>, acc) when space in [32, 9, 10, 13],
  do: scan(rest, acc)
defp scan("+" <> rest, acc), do: scan(rest, [:plus | acc])
defp scan(source, acc) do
  with {:ok, number, rest} <- DecimalLiteral.read(source),
    do: scan(rest, [{:number, number} | acc])
end
  • defp : une fonction privée au module ; when … in … : une garde qui affine le motif.
  • <<space, rest::binary>> sépare le premier octet du reste ; "+" <> rest reconnaît un préfixe.
  • with motif <- étape do … end : si l'étape réussit, la suite ; sinon l'erreur est rendue telle quelle.
jeudi 29 octobre 2026
AP – Elixir – AP2

À vous · 01 · Calcul immédiat

Ouvrez 01 · Calcul direct. Vous avez 18 min.

Complétez le lexer, puis écrivez le calculateur et faites passer ses vérifications.

Tracez les valeurs courantes de 8.0 - 3.0 - 2.0.

Cherchez une entrée qui révèle la limite de notre règle.

jeudi 29 octobre 2026
AP – Elixir – AP2

Reprise · Qu'avons-nous perdu ?

La valeur 9.0 ne dit plus où étaient + et *.
Pour changer le groupement après coup, il faudrait relire l'entrée.

Conservons donc le calcul lui-même comme une donnée.

Un calculateur sans arbre peut gérer priorités et parenthèses ; notre première règle ne le fait pas.

jeudi 29 octobre 2026
AP – Elixir – AP2

02 · Le groupement change le résultat

1.0 + 2.0 * 3.0      vaut 7.0
(1.0 + 2.0) * 3.0    vaut 9.0
8.0 - 3.0 - 2.0      vaut 3.0

Nous voulons garder les opérations et leurs relations, avant de les exécuter.

Carnet 02 · Arbres · poly, chapitre 5

jeudi 29 octobre 2026
AP – Elixir – AP2

L'arbre rend le groupement visible

Deux AST pour 1.0 + 2.0 * 3.0 et (1.0 + 2.0) * 3.0.

Une opération est un nœud, ses opérandes sont ses enfants, un nombre est une feuille ; la racine est l'opération faite en dernier.

jeudi 29 octobre 2026
AP – Elixir – AP2

Évaluer, c'est parcourir l'arbre

Les valeurs des feuilles remontent ; 2.0 fois 3.0 est calculé avant l'addition.

Les enfants sont évalués avant leur parent : c'est un parcours en postordre.

jeudi 29 octobre 2026
AP – Elixir – AP2

Comment lire une règle de grammaire ?

liste := nombre ("," nombre)*
signe := "+" | "-"
  • := se lit « est formé de » ; un mot entre guillemets s'écrit tel quel, un mot nu renvoie à une autre règle.
  • | sépare deux possibilités ; ( … )* répète le groupe zéro fois ou davantage.

1.0, 2.0, 3.0 est-elle une liste ? Et 1.0, ?

jeudi 29 octobre 2026
AP – Elixir – AP2

Notre grammaire range les priorités en niveaux

expression := sum
sum        := product (("+" | "-") product)*
product    := unary (("*" | "/") unary)*
unary      := "-" unary | primary
primary    := number | "(" expression ")"

Une somme est faite de produits : 2.0 * 3.0 est lu en entier avant que la somme reprenne la main.

Backus et Naur décrivent ALGOL 60 avec cette notation en 1960 ; chaque règle deviendra une fonction de lecture.

jeudi 29 octobre 2026
AP – Elixir – AP2

Le parser suit les niveaux de priorité

La somme demande des produits et chaque lecteur rend un arbre avec les tokens restants.

Une fonction de lecture par règle, qui appelle les règles qu'elle contient : c'est la descente récursive.

jeudi 29 octobre 2026
AP – Elixir – AP2

À priorité égale, construisons à gauche

Le parser ajoute chaque soustraction autour de l'arbre précédent, comme enfant gauche.

8.0 - 3.0 - 2.0 représente (8.0 - 3.0) - 2.0.

jeudi 29 octobre 2026
AP – Elixir – AP2

Priorité et associativité sont des choix de langage

Pour -2 ** 2, Python met la négation à la racine et obtient -4, Excel et Elixir mettent la puissance à la racine et obtiennent 4 ; pour 2 ** 3 ** 2, Python groupe à droite et obtient 512, Excel et Elixir groupent à gauche et obtiennent 64.

La grammaire choisit l'arbre, et l'arbre la valeur. Excel écrit =-2^2 et =2^3^2.

jeudi 29 octobre 2026
AP – Elixir – AP2

Un lecteur rend l'arbre et le reste

read([{:number, 4.0}, :rparen])  →  {:ok, {:number, 4.0}, [:rparen]}
parse(tokens)                    →  {:ok, tree}, si tout est lu

Le lecteur de ( … ) consomme lui-même la parenthèse fermante.

parse/1, fourni, refuse 1.0 2.0 et (1.0 + 2.0 ; vous écrivez les lecteurs.

jeudi 29 octobre 2026
AP – Elixir – AP2

Elixir pour cet atelier · 02

# 1.0 + 2.0 * 3.0
{:binary, :plus, {:number, 1.0},
  {:binary, :multiply, {:number, 2.0}, {:number, 3.0}}}
  • Une feuille {:number, n}, un nœud {:binary, op, gauche, droite} : une clause d'eval par sorte de nœud.
  • parse/1, fourni, distingue par un case une lecture complète d'un reste en trop.
  • def eval(tree, env \\ []), sans corps, donne une valeur par défaut au second argument.
  • with enchaîne plusieurs étapes et s'arrête à la première erreur.
jeudi 29 octobre 2026
AP – Elixir – AP2

À vous · 02 · Construire et lire des arbres

Ouvrez 02 · Arbres. Vous avez 23 min.

Recopiez vos modules, puis écrivez l'évaluation d'un arbre écrit à la main.

Écrivez ensuite vos lecteurs : nombres et +, puis *, puis les parenthèses.

Gardez un test qui distingue les deux groupements.

jeudi 29 octobre 2026
AP – Elixir – AP2

Reprise · Le même arbre peut servir autrement

Pour 1.0 + 2.0 * 3.0, montrez l'arbre et l'ordre des calculs.

Pourrions-nous aussi compter ses nœuds, l'afficher ou produire des instructions ?

L'évaluation est désormais séparée de la lecture du texte.

jeudi 29 octobre 2026
AP – Elixir – AP2

Un programme est une donnée : Lisp, 1960

John McCarthy décrit Lisp par une fonction eval(expression, environnement), elle-même écrite en Lisp.

Nos tuples jouent le rôle de ses listes : (+ 1.0 (* 2.0 3.0)) et {:binary, :plus, …} disent la même chose.

Elixir aussi : quote do: 1 + 2 * 3 rend {:+, …, [1, {:*, …, [2, 3]}]}.

Un interpréteur est une fonction ordinaire sur cette donnée. Le compilateur d'AP3 en sera une autre.

McCarthy, « Recursive Functions of Symbolic Expressions and Their Computation by Machine », CACM, 1960 · Elixir, « Quote and unquote ».

jeudi 29 octobre 2026
AP – Elixir – AP2

Pause · 10 min

Sauvegardez vos deux premiers carnets.

Nous reprenons avec des noms et des fonctions.

jeudi 29 octobre 2026
AP – Elixir – AP2

03 · Un nom a besoin d'un contexte

let x = 2.0
{ let x = 10.0 x + 1.0 } + x

Quelle valeur de x sert à chaque endroit ?

Carnet 03 · Contexte et fonctions · poly, chapitre 5

jeudi 29 octobre 2026
AP – Elixir – AP2

La portée d'une liaison se lit dans le texte

Le cadre violet couvre la suite du premier let, où x vaut 2.0 ; le cadre doré couvre la suite du let du bloc, où x vaut 10.0. Le bloc vaut 11.0, le programme 13.0.

Une liaison associe un nom à une valeur ; sa portée est la suite du let qui la crée. L'environnement rassemble les liaisons visibles à un endroit.

jeudi 29 octobre 2026
AP – Elixir – AP2

Une nouvelle liaison masque l'ancienne

Une nouvelle liaison est ajoutée en tête après évaluation dans l'ancien environnement.

La recherche prend la première liaison du bon nom ; l'ancienne reste dans la liste, intacte.

jeudi 29 octobre 2026
AP – Elixir – AP2

Une fonction garde son corps pour plus tard

let inc = fn(x) { x + 1.0 }
inc(2.0 + 2.0)

x est le paramètre, 2.0 + 2.0 l'argument ; le nombre de paramètres est l'arité.

Le corps n'est calculé qu'à l'appel. Dans cette première version, il ne voit que ses paramètres.

jeudi 29 octobre 2026
AP – Elixir – AP2

Les arguments deviennent les paramètres

L'argument 2.0 + 2.0 vaut 4.0 ; le corps est évalué avec la liaison x égale 4.0.

L'argument est calculé chez l'appelant, puis le corps est évalué avec x lié à sa valeur.

jeudi 29 octobre 2026
AP – Elixir – AP2

Un nœud pour chaque nouvelle question

Nœud Ce que fait l'évaluateur
{:name, name} chercher la liaison visible
{:let, name, value, body} évaluer value, puis body avec la liaison en tête
{:fn, parameters, body} rendre {:function, parameters, body}, sans calcul
{:call, function, arguments} évaluer fonction et arguments, lier, évaluer le corps

Ces nœuds se testent sur des arbres écrits à la main ; leur lecture depuis le texte est un prolongement.

jeudi 29 octobre 2026
AP – Elixir – AP2

Elixir pour cet atelier · 03

env = [{"x", 10.0}, {"x", 2.0}]     # la liaison la plus récente en tête
[{"y", 4.0} | env]                  # ajouter une liaison, sans modifier env
Enum.zip(["a", "b"], [3.0, 4.0])    # [{"a", 3.0}, {"b", 4.0}]
  • Dans fetch([{name, value} | _rest], name), le même nom écrit deux fois exige deux valeurs égales.
  • length/1 compte les éléments ; vérifiez les longueurs avant Enum.zip/2, qui s'arrête à la liste la plus courte.
  • {:function, …} est une donnée de notre langage, pas une fonction Elixir fn x -> … end.
jeudi 29 octobre 2026
AP – Elixir – AP2

À vous · 03 · Contexte et fonctions

Ouvrez 03 · Contexte et fonctions. Vous avez 18 min.

Recopiez vos modules, puis écrivez Bindings.fetch/2 et les nœuds name et let.

Tracez les environnements du masquage, puis ceux de increment(4.0).

Ajoutez fn et l'appel ; la lecture depuis le texte reste un prolongement.

jeudi 29 octobre 2026
AP – Elixir – AP2

Reprise · D'où viendrait offset ?

let offset = 2.0
let add_offset = fn(x) { x + offset }
add_offset(3.0)

offset n'est ni un paramètre ni une liaison du corps : c'est une variable libre.

Notre première version répond {:error, {:undefined_name, "offset"}}.

jeudi 29 octobre 2026
AP – Elixir – AP2

04 · Quel offset le corps doit-il lire ?

let offset = 2.0
let add_offset = fn(x) { x + offset }
let offset = 100.0
add_offset(3.0)

Souhaitons-nous 5.0 ou 103.0 ? Quelle règle expliquera notre choix ?

Carnet 04 · Fermetures · poly, chapitre 5

jeudi 29 octobre 2026
AP – Elixir – AP2

Deux règles ont existé

Règle Quel offset ? Qui l'a choisie
Dynamique : le contexte de l'appel 100.0 Premiers Lisp, Emacs Lisp longtemps, shell bash
Lexicale : le contexte de la définition 2.0 ALGOL 60, Scheme (1975), puis Python, Elixir…

Peter Landin nomme fermeture la donnée qui conserve ce contexte, en 1964.

Landin, « The Mechanical Evaluation of Expressions », 1964 · Sussman et Steele, « Scheme: An Interpreter for Extended Lambda Calculus », 1975 · Bash, « Shell Functions ».

jeudi 29 octobre 2026
AP – Elixir – AP2

Une fermeture garde son contexte de définition

La fermeture capture offset égal à 2.0 et l'appel ajoute x égal à 3.0 malgré offset égal à 100.0 chez l'appelant.

Une fermeture réunit les paramètres, le corps et l'environnement capturé à la définition.

jeudi 29 octobre 2026
AP – Elixir – AP2

L'argument se calcule chez l'appelant

let offset = 2.0
let add_offset = fn(x) { x + offset }
let offset = 100.0
add_offset(offset)

L'argument vaut 100.0 chez l'appelant ; le corps lit son offset capturé, 2.0. Résultat : 102.0.

jeudi 29 octobre 2026
AP – Elixir – AP2

Une fonction peut fabriquer une fonction

Deux appels de fn(x) { fn(y) { x + y } } capturent séparément 2.0 et 10.0.

let add_two = make_adder(2.0) puis add_two(5.0) donne 7.0.

jeudi 29 octobre 2026
AP – Elixir – AP2

Capturer la variable ou sa valeur ?

>>> fonctions = [lambda: i for i in range(3)]
>>> [f() for f in fonctions]
[2, 2, 2]

En Python, les trois fermetures partagent la même variable i, lue à l'appel : la boucle l'a laissée à 2.

Notre langage, comme Elixir, capture des liaisons immuables : let offset = 100.0 en ajoute une, sans toucher à l'ancienne.

FAQ de Python, « Why do lambdas defined in a loop with different values all return the same result? »

jeudi 29 octobre 2026
AP – Elixir – AP2

Elixir pour cet atelier · 04

{:closure, ["x"], body, [{"offset", 2.0}]}   # la valeur d'une fonction
[{"x", 3.0}] ++ [{"offset", 2.0}]            # [{"x", 3.0}, {"offset", 2.0}]
  • À la définition, capturez l'environnement courant dans la valeur : {:closure, paramètres, corps, capturé}.
  • À l'appel, évaluez les arguments chez l'appelant, puis le corps dans le capturé, les paramètres placés devant avec ++.
  • """ ouvre une chaîne sur plusieurs lignes, commode pour écrire un programme entier.
jeudi 29 octobre 2026
AP – Elixir – AP2

À vous · 04 · Fermer le contexte

Ouvrez 04 · Fermetures. Vous avez 18 min.

Recopiez vos modules et changez la création et l'appel d'une fonction.

Testez add_offset(3.0), puis add_offset(offset), puis deux fonctions fabriquées.

Expliquez pourquoi leurs environnements ne se mélangent pas.

jeudi 29 octobre 2026
AP – Elixir – AP2

Reprise · Qu'est-ce qui doit survivre ?

Dans make_adder(2.0), l'appel est terminé.
Pourtant, la fonction rendue doit encore connaître x.

Montrez les trois morceaux de sa fermeture et le résultat de son prochain appel.

jeudi 29 octobre 2026
AP – Elixir – AP2

05 · Choisir un calcul, sans faire l'autre

case 2.0 < 3.0 {
  True -> 7.0
  False -> 1.0 / 0.0
}

Pouvons-nous obtenir 7.0 sans provoquer la division par zéro ?

Carnet 05 · Contrôle · poly, chapitre 5

jeudi 29 octobre 2026
AP – Elixir – AP2

La branche se choisit avant son évaluation

Case True choisit 7.0 et ne visite pas la division par zéro de la branche False.

Un case évalue sa condition, puis une seule branche : il ne calcule pas d'abord tous ses enfants.

jeudi 29 octobre 2026
AP – Elixir – AP2

Une fonction ordinaire suffirait-elle ?

choose(True, 7.0, 1.0 / 0.0)

Nos appels calculent tous leurs arguments avant d'entrer dans le corps : c'est l'évaluation stricte.

Ici, la division échoue avant que choose puisse choisir.

jeudi 29 octobre 2026
AP – Elixir – AP2

Stricte ou paresseuse : un choix de langage

Langage Forme à part Ce qui n'est pas calculé
C a && b b, quand a est faux
Python x and y, a if c else b y quand x est faux, la branche écartée
Elixir if, && : des macros, réécrites en case la branche écartée
Haskell (1990) toute expression : il est paresseux ce dont la valeur n'est jamais demandée

Un langage strict, comme le nôtre, réserve quelques formes au choix ; un langage paresseux retarde tout.

Norme C, § 6.5.13 · Python, « Boolean operations » · Kernel.if/2 · Hudak et al., « A History of Haskell », 2007.

jeudi 29 octobre 2026
AP – Elixir – AP2

Elixir pour cet atelier · 05

def eval({:case, condition, yes, no}, env) do
  case eval(condition, env) do
    {:ok, true} -> eval(yes, env)
    {:ok, false} -> eval(no, env)
    …
  end
end
  • Le case d'Elixir écrit le nôtre ; :lt et :eq s'ajoutent aux opérations, comme :plus.
  • Lire le texte, en prolongement : les comparaisons au-dessus de la somme, case en primaire ; au lexer, -> avant -, == avant =.
  • assert_in_delta compare deux flottants avec une tolérance.
jeudi 29 octobre 2026
AP – Elixir – AP2

À vous · 05 · Contrôler le calcul

Ouvrez 05 · Contrôle. Vous avez 18 min.

Ajoutez booléens, comparaisons et case à votre évaluateur.

Testez une erreur dans la branche inactive, puis la fonction choose.

Si le cœur fonctionne, reliez vos lecteurs et lancez le programme final depuis son texte.

jeudi 29 octobre 2026
AP – Elixir – AP2

Reprise · Notre programme combine les cinq étapes

let rate = 0.9
let discounted = fn(total) { total * rate }
let price = 12.0
let quantity = 3.0
let total = price * quantity
case total < 30.0 {
  True -> total
  False -> discounted(total)
}

Suivez un token, un nœud, une liaison, une capture, puis la branche choisie.

jeudi 29 octobre 2026
AP – Elixir – AP2

À quel niveau le souci apparaît-il ?

Les erreurs de lexing, parsing et évaluation correspondent à des informations différentes.

Un mot inconnu, une phrase mal formée, un sens impossible : un refus expliqué renseigne autant qu'un résultat juste.

jeudi 29 octobre 2026
AP – Elixir – AP2

Facultatif · Et si les formules formaient un graphe ?

Le graphe relie price et quantity à total puis à final, avec rate ; un cycle est signalé à part.

Un graphe de dépendances permet de chercher un ordre de calcul et de détecter un cycle.

jeudi 29 octobre 2026
AP – Elixir – AP2

Conservez votre cheminement

Gardez vos carnets dans AP2/, avec vos essais et vos explications.

Je préciserai les étapes à terminer, le point d'arrêt et les prolongements
selon notre rythme ; tout le découpage n'est pas un travail imposé.

Rendu unique AP1–AP3 le 6 décembre à 23 h 59. Reprise orale en AP4.

AP3 s'ouvrira par un quiz de dix minutes sur cette séance : relisez le chapitre 5 du poly, « Construire un langage ». Ses chapitres 1 à 4 restent là si un bout de syntaxe Elixir vous manque.

jeudi 29 octobre 2026

À dire : Bonjour à tous. Mardi, vous avez écrit une calculatrice à pile qui lisait des tokens ; aujourd'hui, nous allons plus loin et nous écrivons un programme qui lit des programmes. Grammaire, arbre, environnement, fermeture : nous rencontrerons ces notions une par une, chacune juste avant le carnet qui s'en sert. Commençons par regarder où nous voulons arriver. Repères : ouverture de 7 min avec les cinq slides suivantes. Pas de quiz en AP2, la séance suit AP1 de deux jours. La culture informatique prime sur la quantité de code : chaque étape commence par un souci, puis chacun écrit son essai.

À dire : Voici le programme que votre interpréteur saura exécuter à la fin de la séance. Il n'est pas écrit en Elixir : c'est notre propre petit langage, que j'appellerai le langage cible. Elixir est le langage dans lequel nous écrivons l'outil, qu'on appelle un interpréteur, c'est-à-dire un programme qui reçoit ce texte et en calcule la valeur. Prenez quelques secondes : que vaut ce programme d'après vous ? Avant de construire quoi que ce soit, demandons-nous comment nous lisons nous-mêmes un tel texte. Repères : réponse attendue 32.4, car 36.0 dépasse le seuil et la remise s'applique. C'est le programme final du carnet 05, vérifié avec la référence. Ne pas expliquer fn ni case maintenant : on montre la cible, pas la syntaxe.

À dire : Pour comprendre une phrase, vous faites trois choses sans y penser. D'abord, vous reconnaissez des mots : « chta » n'en est pas un. Ensuite, vous les groupez selon une grammaire : « chat le dort » est fait de vrais mots, mais ce n'est pas une phrase. Enfin, vous lui donnez un sens. Une phrase bien construite peut d'ailleurs ne rien vouloir dire : le linguiste Noam Chomsky en a donné un exemple célèbre, « d'incolores idées vertes dorment furieusement ». Un programme se lit de la même façon, en trois niveaux qui portent des noms savants : lexical pour les mots, syntaxique pour la phrase, sémantique pour le sens. Chaque niveau aura sa brique : le lexer, le parser et l'évaluateur. Ce découpage n'est pas une invention de notre cours : Python vous le montre, si vous le lui demandez. Repères : 1 min 30. La phrase de Chomsky vient de Syntactic Structures (1957) : grammaticale, mais dénuée de sens. Les trois niveaux structurent la séance : le carnet 01 lit les mots, le 02 la phrase, les 03 à 05 enrichissent le sens ; les refus de chaque niveau reviennent au bilan. Ne pas entrer dans la hiérarchie de Chomsky.

À dire : Python fait ce travail chaque fois que vous lancez un script, et sa bibliothèque standard vous laisse regarder chaque étape. Le module tokenize affiche les mots : un nombre, un opérateur, un nom. Le module ast affiche l'arbre ; remarquez que la multiplication est sous l'addition, la priorité est déjà dans la forme. Le module dis montre enfin ce que Python fait de cet arbre : il ne le parcourt pas directement, il le traduit en instructions pour sa propre machine. Regardez leur ordre : les opérandes d'abord, l'opération ensuite. C'est exactement ce que nous ferons en AP3. Aujourd'hui, nous écrivons nos trois briques à nous : le lexer, le parser et l'évaluateur. Voici le chemin. Repères : 1 min. Extraits de CPython 3.13 ; dis affiche aussi RESUME, POP_TOP et RETURN_CONST, et ses instructions changent d'une version à l'autre. Le x évite un piège : avec 1 + 2 * 3, dis ne montre plus que la constante 7, car le compilateur a déjà fait le calcul. python3 -m ast existe depuis Python 3.9. Documentation : docs.python.org/3/library/tokenize.html, ast.html et dis.html.

À dire : Voici le chemin, et il suit nos trois niveaux. Nous commençons par la solution la plus simple, un calcul au fil de la lecture, et nous la garderons tant qu'elle suffit. Dès qu'un programme la met en défaut, nous changeons de représentation : un arbre, puis un environnement, puis un contexte capturé, puis un choix de branche. Chaque étape a son carnet Livebook, et chaque carnet part de votre code de l'étape précédente. Chacun de ces mots viendra au moment où il servira. Voyons comment la séance va se dérouler. Repères : montrer la page AP2 du site et la liste des cinq carnets, pas leur code. Le carnet 06, sur les graphes, est facultatif après le socle. Les sessions Livebook ne partagent pas leur mémoire : chaque carnet reçoit les modules personnels du précédent dans les cellules indiquées.

À dire : Le rythme est toujours le même : je présente le souci et le mécanisme en quelques minutes, vous écrivez dans le carnet, puis nous reprenons ensemble ce que vous avez trouvé. Les carnets vous donnent les contrats, c'est-à-dire ce que chaque fonction reçoit et rend, et un exemple de la forme attendue ; à vous d'écrire les cas analogues. Une fonction inachevée accompagnée d'une trace juste vaut mieux qu'un code recopié : je regarderai vos traces et vos explications autant que vos tests. Dernier point pratique : chaque carnet est une session séparée, donc vous recopierez vos propres modules d'une étape à l'autre. Commençons par une question très simple. Repères : durées des notes indicatives, à moduler en direct. Pour une forme Elixir oubliée : chapitre 2 du poly pour case et les motifs, chapitre 4 pour les clauses et la récursion. Ne pas fixer maintenant le périmètre du rendu.

À dire : Première étape, et première règle, volontairement naïve : nous lisons de gauche à droite, et chaque opération s'applique tout de suite au résultat déjà obtenu. Avec cette règle, que vaut ce programme ? Prenez dix secondes avant de répondre. … Eh oui, 9.0 et non 7.0 : la règle ignore la priorité de la multiplication. Pour cette première version, nous refusons aussi les parenthèses. Ce n'est pas une fatalité, c'est un choix pour l'expérience : nous allons voir jusqu'où cette règle nous mène. Mais d'abord, comment un programme lit-il ce texte ? Repères : atelier 01, 30 min en tout, dont 7 min de cours. Laisser répondre avant de dire 9.0. Ne pas affirmer qu'un calcul sans arbre est incapable de gérer priorités et parenthèses : c'est notre algorithme qui est limité.

À dire : Avant de calculer quoi que ce soit, il faut découper le texte en mots : c'est le niveau lexical. Ces mots s'appellent des tokens, et la brique qui découpe s'appelle un lexer. Vous en avez déjà écrit un : le lecteur de tokens du carnet 4 d'AP1 faisait ce travail, avec un accumulateur. Ici, un nombre devient un tuple qui porte sa valeur, `{:number, 12.0}`, alors qu'un opérateur devient un simple atome, `:plus`. Dans le carnet, la lecture d'un nombre et deux clauses du lexer sont fournies ; vous ajoutez les autres symboles par analogie. Reste à décider ce que le calculateur fait de cette liste. Repères : DecimalLiteral.read/1 est fourni, avec au moins un chiffre de chaque côté du point ; 1.0e2 est refusé. ArithmeticInput.lex/1 rend {:ok, tokens} ou {:error, raison}. Pas d'entiers ni de puissance. Les élèves écrivent les clauses de -, *, /, ( et ), pas une regex.

À dire : Voici la règle en action sur notre exemple. Le calculateur garde deux choses : les tokens qui restent à lire et la valeur déjà calculée. Après `+ 2.0`, il ne reste que 3.0 ; après `* 3.0`, il reste 9.0, et plus aucune trace des opérations. À votre avis, que doit recevoir l'appel récursif suivant ? … Le reste à lire et le résultat courant : c'est le couple « reste à traiter, travail déjà fait » de vos accumulateurs d'AP1. Voyons maintenant ce que le carnet attend précisément. Repères : faire proposer valeur courante, opérateur et tokens restants. Ne pas fournir la récurrence complète : le carnet demande de la concevoir.

À dire : Voici le contrat du carnet. Le calculateur rend `{:ok, nombre}` quand toute l'entrée respecte notre règle, et `{:error, raison}` sinon : c'est ce que j'appellerai un résultat étiqueté. Les opérations elles-mêmes vous sont fournies, et la division par zéro y rend déjà une erreur ; votre travail est le parcours de la liste. Regardez les deux dernières lignes : `1.0 +` est incomplet, et `1.0 2.0` commence bien mais ne finit pas bien. Un début correct ne suffit donc pas, toute l'entrée doit être lue. Avant d'ouvrir le carnet, voyons les formes Elixir que vous y croiserez. Repères : run/1 enchaîne le lexer et calculate/1 ; ces lignes sont un contrat, pas du code cible. Propager {:error, :division_by_zero} telle que rendue par ArithmeticOps. Les élèves choisissent leurs fonctions auxiliaires et leurs autres raisons d'erreur. Les parenthèses et le moins unaire sont refusés à cette étape.

À dire : Ces lignes viennent du lexer fourni ; vous n'avez pas à les écrire, mais vous devez pouvoir les lire. `defp` déclare une fonction privée, et la garde `when space in [32, 9, 10, 13]` n'accepte que les octets des blancs : l'espace, la tabulation et les deux fins de ligne. Le motif `"+" <> rest` reconnaît un préfixe, comme en AP1. Reste `with`, que vous croiserez dans tous les carnets : lisez-le comme « si cette étape réussit, continue avec son résultat ; sinon, rends l'erreur telle quelle ». À vous maintenant. Repères : defp et when ont été vus en AP1 ; with apparaît ici dans le cours. L'accumulateur garde les tokens à l'envers et Enum.reverse/1 les remet en ordre une seule fois. Ne pas détailler la syntaxe binaire : AP3 y reviendra avec les octets.

À dire : Ouvrez le carnet 01 et sauvegardez-le tout de suite dans votre dossier AP2. Commencez par les clauses du lexer, puis dessinez l'état pour `8.0 - 3.0 - 2.0` avant d'écrire le calculateur. Quand vos vérifications passent, cherchez une entrée qui révèle la limite de la règle : gardez-la, nous en parlerons à la reprise. Si vous bloquez, le carnet propose trois indices gradués. Vous avez dix-huit minutes. Repères : 18 min, présentation interrompue, consigne laissée affichée. Trace attendue : 8.0, puis 5.0, puis 3.0. Les rapides enrichissent leurs contre-exemples ; ne pas dévoiler l'arbre. Faire écrire des fonctions entières, pas des trous.

À dire : Montrez-moi vos contre-exemples. … Vous l'avez vu, `1.0 + 2.0 * 3.0` donne 9.0, et une fois ce 9.0 obtenu, plus rien ne dit où se trouvaient le plus et le fois. Pour changer de groupement, il faudrait relire tout le texte. Attention, je ne dis pas qu'un calcul sans arbre est incapable de respecter les priorités : il existe des algorithmes à piles qui le font très bien. Notre choix est autre : garder le calcul lui-même comme une donnée, que l'on pourra parcourir, afficher ou transformer. C'est l'objet de l'étape 02. Repères : reprise de 5 min. Faire montrer deux essais d'élèves, dont un refus. Ne pas raconter que tout parser doit produire un arbre.

À dire : Nous voulons maintenant les résultats habituels : 7.0 pour le premier, parce que la multiplication passe avant l'addition, et 9.0 pour le deuxième, parce que les parenthèses imposent un autre groupement. Le troisième rappelle une convention qu'on oublie souvent : à priorité égale, on groupe à gauche, `(8.0 - 3.0) - 2.0`. Pour chacun, quelle opération est faite en dernier ? Gardez votre réponse : elle va devenir la racine d'un arbre. Repères : atelier 02, 39 min en tout, dont 11 min de cours, la plus longue introduction de la séance. Distinguer la priorité, * avant +, et l'associativité, à gauche à priorité égale.

À dire : Voici la représentation que nous allons garder : un arbre, dessiné la tête en bas comme toujours en informatique. Chaque opération est un nœud, ses deux opérandes sont ses enfants, et un nombre est une feuille, sans enfant. La racine, en haut, est l'opération faite en dernier : l'addition à gauche, la multiplication à droite. Remarquez que les parenthèses n'apparaissent nulle part : elles ont seulement décidé de la forme de l'arbre. C'est pour cela qu'on parle d'arbre de syntaxe abstraite, ou AST : les détails d'écriture, espaces et parenthèses, ont disparu. C'est le niveau syntaxique, la phrase rangée en arbre. Comment en tirer une valeur ? Repères : faire nommer la racine de chaque arbre avant de passer à la suite. Aucun calcul pendant la construction de l'arbre. Sa forme en tuples Elixir vient avec les formes de l'atelier, juste avant la consigne.

À dire : Une fois l'arbre construit, l'évaluer devient simple. Pour obtenir la valeur d'une addition, il faut d'abord les valeurs de ses deux enfants ; les valeurs remontent donc des feuilles vers la racine. Dans quel ordre visite-t-on les nœuds ici ? … 1.0, 2.0, 3.0, la multiplication, puis l'addition : chaque nœud après ses enfants, c'est ce qu'on appelle un parcours en postordre. Dans le carnet, la clause des nombres est fournie, et vous écrivez celle des opérations : une clause par sorte de nœud. Reste une question : qui fabrique cet arbre à partir des tokens ? C'est le parser, la brique du milieu, et pour savoir comment ranger, il suit une grammaire, c'est-à-dire la liste des règles qui disent quels assemblages de tokens sont permis. Prenons le temps de lire cette notation sur un petit exemple. Repères : l'évaluateur reçoit déjà un environnement, qu'il transmet sans le lire ; il servira à l'étape 03. Plus tard, case ne visitera pas tous ses enfants. Tester avec une soustraction : 8.0 + 3.0 ne révèle pas une inversion des enfants, 8.0 - 3.0 la montre. Trois briques, trois responsabilités testables séparément : le carnet 02 commence par l'évaluateur, sur des arbres écrits à la main.

À dire : Une grammaire est une suite de règles, une par ligne. Lisons la première à voix haute : « une liste est formée d'un nombre, suivi, zéro, une ou plusieurs fois, d'une virgule et d'un nombre ». Le symbole `:=` se lit « est formé de » ; ce qui est entre guillemets s'écrit tel quel, alors qu'un mot sans guillemets, comme `nombre`, renvoie à une autre règle. La barre verticale sépare des possibilités : un signe est un plus ou un moins. Enfin, l'étoile placée après des parenthèses veut dire « ce groupe, répété zéro fois ou davantage » ; ce n'est pas une multiplication. Alors, `1.0, 2.0, 3.0` est-elle une liste ? … Oui : un nombre, puis deux fois une virgule et un nombre. Et `1.0,` ? Non, car une virgule doit toujours être suivie d'un nombre. Vous savez maintenant lire une grammaire : voyons la nôtre. Repères : 2 min. Cette notation vient de Backus et Naur, pour décrire ALGOL 60, d'où son nom de BNF ; l'étoile et les parenthèses de groupement sont une extension courante. Le rapport d'origine écrivait ::= et mettait les noms de règles entre chevrons ; nous gardons :=, comme le carnet et le poly. La règle liste n'appartient pas à notre langage. Faire valider ou refuser une entrée par un ou deux élèves.

À dire : Voici la grammaire de l'étape 02, celle du carnet. Lisez la ligne `sum` : une somme est un produit, suivi zéro ou plusieurs fois d'un plus ou d'un moins et d'un autre produit. Un produit, lui, est fait d'opérandes séparés par des fois ou des divisions. La priorité est donc inscrite dans la profondeur : comme une somme est faite de produits, et non l'inverse, `2.0 * 3.0` est lu en entier comme un seul terme de la somme, et la multiplication se retrouve sous l'addition dans l'arbre. Regardez la dernière ligne : entre parenthèses, on recommence une expression entière, ce qui rend la grammaire récursive. Chaque règle va devenir une fonction de lecture ; regardons ces fonctions s'appeler. Repères : 2 min, histoire comprise. Hoare et son équipe écrivent un compilateur ALGOL par descente récursive au début des années 1960 ; Wirth la popularise avec Pascal. Le moins unaire est un niveau à part, qui vient en dernier dans le carnet. LANGAGE.md donne la grammaire complète, avec comparaisons, let et appels : les élèves n'en ont besoin qu'aux étapes 03 et 05.

À dire : Lisez les flèches comme des appels de fonctions. Le lecteur de somme appelle le lecteur de produit pour obtenir son premier terme, `1.0`, et celui-ci s'arrête devant le `+`, qui n'est pas de son niveau. Après le `+`, la somme le rappelle, et cette fois le lecteur de produit absorbe `2.0 * 3.0` en entier. Chaque lecteur rend deux choses : l'arbre qu'il a construit et les tokens qu'il n'a pas consommés, ici la parenthèse fermante. Cette méthode, une fonction par règle qui descend vers les règles plus prioritaires, s'appelle la descente récursive. Il reste un détail à régler : que faire de deux moins à la suite ? Repères : le schéma décrit les rôles, pas une API à recopier ligne à ligne. Sa « fermeture » désigne la parenthèse fermante, pas la fermeture de l'étape 04 : le préciser. Les niveaux de comparaison viendront à l'étape 05.

À dire : Avec deux soustractions, le lecteur de somme construit d'abord `8.0 - 3.0`, puis il prend tout cet arbre comme enfant gauche du moins suivant. L'accumulateur de la boucle n'est plus une valeur, comme au carnet 01, mais un arbre qui grandit vers la gauche. Que se passerait-il si le lecteur rappelait toute la somme à droite du premier moins ? … Nous obtiendrions `8.0 - (3.0 - 2.0)`, soit 7.0 au lieu de 3.0 : une associativité à droite. Priorité, associativité : ces conventions vous semblent peut-être évidentes, mais les langages ne s'accordent pas tous. Repères : le prochain nœud prend l'arbre courant comme enfant gauche et un nouveau produit comme enfant droit.

À dire : Prenons un opérateur que notre langage n'a pas, la puissance. Que vaut moins deux au carré ? … En mathématiques, on élève au carré, puis on prend l'opposé : −4. Python fait de même. Excel, lui, répond 4 : sa documentation place la négation avant la puissance. Et Elixir, le langage avec lequel nous travaillons, répond 4 aussi ! À droite, deux puissance trois puissance deux : les mathématiques lisent la tour depuis le haut, deux puissance neuf, soit 512, et Python groupe à droite comme elles. Excel et Elixir groupent à gauche : huit au carré, soit 64. Aucun n'a tort, chacun a écrit sa grammaire, et c'est elle qui décide de la forme de l'arbre. Notre langage a fait ses choix, lui aussi : la soustraction groupée à gauche, le moins unaire plus prioritaire que le produit. Voyons comment le carnet vous demande de les respecter. Repères : 1 min 30. Python 3.13 : -2 ** 2 vaut -4 et 2 ** 3 ** 2 vaut 512 ; la référence du langage dit que la puissance lie plus fort qu'un opérateur unaire à sa gauche et groupe de droite à gauche (docs.python.org/3/reference/expressions.html, « The power operator » et « Operator precedence »). Elixir 1.18 : -2 ** 2 vaut 4 et 2 ** 3 ** 2 vaut 64 ; dans hexdocs.pm/elixir/operators.html, les opérateurs unaires précèdent ** et ** est associatif à gauche. Excel n'a pas été exécuté : la page Microsoft « Opérateurs de calcul et ordre de priorité dans Excel » (support.microsoft.com/fr-fr/excel/calculation-operators-and-precedence-in-excel) place la négation avant l'exposant et traite les opérateurs de même priorité de gauche à droite. Notre langage n'a pas de puissance : ne pas en ajouter.

À dire : Voici le contrat des lecteurs du carnet. `read/1` lit une expression et rend l'arbre lu avec les tokens restants, comme `Integer.parse` rendait le reste d'une chaîne en AP1 ; ici, la parenthèse reste à traiter par celui qui l'attend. `parse/1` est plus exigeant : il veut un reste vide, si bien que `1.0 2.0` est refusé alors que son début est lisible. Qui doit consommer la parenthèse fermante, d'après vous ? … Le lecteur qui a consommé la parenthèse ouvrante : il lit une expression entière, puis exige la fermante. Dans le carnet, vous commencez par les nombres et le plus, puis le fois, puis les parenthèses. Avant de vous lancer, quelques formes Elixir. Repères : la paire arbre et reste est le point central de la descente récursive. Ces lignes sont un contrat, pas du code cible. Un lecteur limité aux nombres et à une opération suffit pour expliquer le reste ; priorités et parenthèses peuvent se poursuivre ensuite.

À dire : En Elixir, notre arbre n'est rien d'autre qu'un tuple imbriqué. Le premier élément dit de quel nœud il s'agit, `:number` pour une feuille, `:binary` pour une opération, et les éléments suivants portent l'opérateur, puis l'enfant gauche et l'enfant droit, qui sont eux-mêmes des arbres. Essayez d'écrire de tête l'arbre de `(1.0 + 2.0) * 3.0`. … La racine devient `:multiply`, et son enfant gauche est l'addition. Pour l'évaluateur, écrivez une clause par sorte de nœud, comme vous écriviez une clause par forme de liste. Le `parse/1` fourni utilise le même `case` qu'en AP1 pour distinguer une lecture complète, des tokens en trop et une erreur. La ligne `def eval(tree, env \\ [])`, sans corps, donne une valeur par défaut au second argument, et Elixir l'exige à part dès qu'une fonction a plusieurs clauses. Enfin, `with` accepte plusieurs étapes à la suite : pratique pour évaluer deux enfants avant d'appliquer l'opération. À vous. Repères : réponse attendue {:binary, :multiply, {:binary, :plus, {:number, 1.0}, {:number, 2.0}}, {:number, 3.0}}. Ces tuples nous suivront jusqu'à la compilation d'AP3. L'évaluateur ressemble à Totals.sum du carnet 3 d'AP1, une clause par forme et un appel récursif par enfant. &product/1 et reader.(tokens) sont expliqués dans le carnet pour qui factorise ses lecteurs ; ce n'est pas obligatoire.

À dire : Ouvrez le carnet 02 et recopiez vos modules du carnet 01 dans les cellules indiquées, sauf le calculateur direct. Commencez par l'évaluateur, sur les arbres écrits à la main : il ne dépend pas du parser. Ensuite seulement, écrivez les lecteurs, un niveau de grammaire à la fois. Si le temps manque, un lecteur de nombres et d'une opération suffit pour comprendre le contrat de l'arbre et du reste ; les priorités et les parenthèses peuvent se terminer après la séance. Vous avez vingt-trois minutes. Repères : 23 min, présentation interrompue. Prioriser l'évaluation d'un arbre et le contrat arbre et reste. Ne pas distribuer de version complète pour faire artificiellement terminer.

À dire : Qui veut nous montrer son arbre pour `1.0 + 2.0 * 3.0`, et l'ordre dans lequel les valeurs remontent ? … Si votre premier arbre était faux, montrez-nous aussi la correction : c'est souvent le plus instructif. Remarquez que l'évaluateur ne sait rien du texte, il ne voit que l'arbre. Nous pourrions donc écrire d'autres parcours du même arbre : compter ses nœuds, le réafficher avec toutes ses parenthèses, ou même produire des instructions pour une machine, ce que nous ferons en AP3. Cette idée a une longue histoire. Repères : reprise de 5 min. Faire présenter un arbre faux puis corrigé. Le comptage des nœuds est un prolongement court possible. Ne pas juger la compréhension au seul nombre de clauses terminées.

À dire : En 1960, John McCarthy décrit le langage Lisp par une fonction `eval`, écrite elle-même en Lisp, qui reçoit un programme sous forme de liste. Nos tuples jouent exactement le rôle de ses listes. Et Elixir fait de même avec votre code : `quote` vous rend l'arbre de l'expression, en tuples, avec l'opérateur, des métadonnées et la liste des enfants ; c'est sur ces tuples que travaillent les macros, que nous croiserons à l'étape 05. Une fois le programme devenu une valeur, toutes les fonctions sur les valeurs s'appliquent à lui : l'évaluer, l'afficher, le compiler, le typer. Regardez aussi la signature de McCarthy : son `eval` prend une expression et un environnement. Cet environnement, c'est ce qui nous manque pour donner un sens aux noms ; nous l'ajouterons juste après la pause. Repères : 1 min, en fin de reprise. Ne pas écrire de Lisp au tableau : la parenthèse préfixe sert seulement de comparaison. Avec Elixir 1.18, quote do: 1 + 2 * 3 rend {:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, {:*, [context: Elixir, imports: [{2, Kernel}]], [2, 3]}]} : les points de suspension de la slide abrègent ces métadonnées. Pas de macros à écrire.

À dire : Dix minutes de pause. Sauvegardez vos deux carnets avant de vous lever. En revenant, nous donnerons des noms aux valeurs, et le contexte de McCarthy entrera dans notre évaluateur. Repères : 10 min. Ne pas ajouter de démonstration pendant cette plage.

À dire : Notre langage gagne des noms. `let x = 2.0` lie le nom `x` à la valeur 2.0 pour la suite du programme. Entre accolades, un bloc a ses propres liaisons et rend sa dernière expression. Alors, quel `x` sert à chaque endroit, et que vaut ce programme ? … Gardez votre réponse. Pour la trouver, vous avez regardé où chaque `x` est écrit, et tenu, sans le dire, une liste de noms et de valeurs : c'est ce que l'interpréteur doit faire aussi. Vérifions sur un schéma. Repères : atelier 03, 30 min en tout, dont 7 min de cours. Résultat 13.0. Faire souligner les trois lectures de x. Le bloc ne renvoie pas un environnement modifié.

À dire : Trois mots, que nous emploierons jusqu'à la fin du cours. Une liaison associe un nom à une valeur : `let x = 2.0` lie `x` à 2.0. La portée d'une liaison, c'est la partie du programme où elle est visible : la suite du `let`, ici tout le cadre violet. Dans le bloc, un second `let x` ouvre un cadre doré, à l'intérieur du premier : là, et seulement là, `x` vaut 10.0. Pour savoir quelle valeur lit chaque `x`, il suffit donc de regarder dans quel cadre il est écrit : le bloc vaut 11.0, et le programme 13.0. Enfin, l'environnement rassemble les liaisons visibles à un endroit : c'est le second argument de l'`eval` de McCarthy. Comment l'interpréteur tient-il cette liste quand un même nom est lié deux fois ? Repères : 1 min. Le nœud est {:let, nom, valeur, suite}, dans le tableau des nœuds de cette étape ; le nom reste une chaîne, ne jamais convertir les noms saisis en atomes. La valeur se calcule dans l'environnement courant, la suite dans l'environnement enrichi. Le bloc ne renvoie pas d'environnement modifié. Le mot « lexicale » viendra à l'étape 04 : lire le texte suffit à trouver la liaison.

À dire : Nous rangeons l'environnement dans une liste, la liaison la plus récente en tête. Pour lire `x`, on parcourt la liste et on prend la première liaison qui porte ce nom. Ici, le second `let x` calcule d'abord `x + 3.0` avec l'ancien `x`, puis ajoute `x : 5.0` en tête : on dit qu'il masque l'ancienne liaison. Celle-ci n'a pourtant été ni effacée ni modifiée ; elle est simplement plus loin dans la liste. C'est ce qui permet, à la sortie d'un bloc, de retrouver le `x` extérieur. Passons maintenant aux fonctions. Repères : la notation du schéma abrège des couples dont le nom reste une chaîne ; résultat du schéma 20.0. Bindings.fetch/2 rend {:ok, valeur} ou {:error, {:undefined_name, nom}}.

À dire : Une fonction de notre langage s'écrit `fn(x) { … }`. Distinguons bien deux mots : le paramètre est le nom écrit dans la définition, ici `x`, et l'argument est l'expression écrite à l'appel, ici `2.0 + 2.0`. Le nombre de paramètres s'appelle l'arité, et un appel doit fournir exactement autant d'arguments. Point important : définir `inc` ne calcule pas `x + 1.0`, puisque `x` n'a pas encore de valeur ; on garde le corps tel quel, sous forme d'arbre, jusqu'à l'appel. Pour cette première version, le corps ne verra que ses paramètres ; nous verrons à la reprise ce que ce choix nous coûte. Repères : résultat 5.0. Une fonction du langage cible n'est pas une fonction Elixir : c'est la donnée {:function, parameters, body} à cette étape. Les fonctions qui ont une variable libre feront volontairement échouer cette première version.

À dire : Suivons l'appel. L'argument `2.0 + 2.0` est d'abord calculé dans l'environnement de l'appelant : il vaut 4.0. Puis on construit un environnement pour le corps, avec la liaison `x : 4.0`, et on évalue le corps dedans, ce qui donne 5.0. Avant tout cela, l'appel vérifie deux choses : que la valeur appelée est bien une fonction, et que le nombre d'arguments correspond à l'arité. Voici, nœud par nœud, ce que le carnet vous demande. Repères : à l'étape 03, l'environnement du corps part des paramètres seuls, sans capture : version provisoire, pour découvrir les variables libres. Erreurs du carnet : {:arity, attendu, reçu} et :not_a_function.

À dire : Voici le contrat du carnet 03, une ligne par nœud ajouté. Le cas du nom délègue la recherche à `Bindings.fetch/2`, que vous écrivez d'abord : c'est une récursion sur une liste, comme en AP1, avec trois cas. Pour `let`, attention à l'ordre : la valeur se calcule dans l'environnement courant, la suite dans l'environnement enrichi. Pour `fn`, on ne calcule rien. Pour l'appel, commencez avec un seul paramètre, puis généralisez. Le parser n'est pas nécessaire, puisque les vérifications utilisent des arbres écrits à la main. Voyons les quelques formes Elixir dont vous aurez besoin. Repères : erreurs attendues {:undefined_name, nom}, {:arity, attendu, reçu} et :not_a_function ; les erreurs d'arguments remontent. let x = x + 1.0 n'est pas récursif : le x de droite est la liaison précédente. Ne pas donner un parser complet caché dans une cellule de démarrage.

À dire : L'environnement est une liste de couples, liaison la plus récente en tête ; en ajouter une, c'est construire une nouvelle liste, l'ancienne restant intacte. Pour la recherche, le carnet vous donne une astuce : un même nom écrit deux fois dans un motif ne correspond que si les deux valeurs sont égales. Pour l'appel, `Enum.zip/2` associe paramètres et valeurs deux à deux, mais il s'arrête à la plus courte des deux listes : vérifiez donc l'arité avant, avec `length/1`. Dernier point, pour ne pas vous perdre : notre `{:function, …}` est un tuple, que votre interpréteur exécute lui-même, sans rapport avec un `fn` d'Elixir. À vous. Repères : Bindings.fetch/2 est la récursion d'AP1 : clause [] pour l'erreur, clause de tête qui correspond, clause [_ | rest] sinon. Pour évaluer les arguments l'un après l'autre et s'arrêter à la première erreur, une petite récursion avec accumulateur suffit.

À dire : Ouvrez le carnet 03 et recopiez vos modules du carnet 02. Si vous voulez d'abord comprendre, commencez par une trace sur papier : les liaisons visibles avant, pendant et après le bloc, puis les valeurs des arguments et l'environnement du corps d'une fonction. Ensuite, écrivez la recherche des liaisons, puis `let`, puis les fonctions. Allez jusqu'à la dernière cellule, même si votre appel n'est pas fini : elle montre un souci dont nous parlerons à la reprise. Vous avez dix-huit minutes. Repères : 18 min. Prioriser la recherche des liaisons et la trace d'un appel ; plusieurs arguments et les lecteurs de let, fn et des appels peuvent attendre. Aider avec une trace d'environnement avant de montrer une clause complète.

À dire : Regardons la dernière cellule du carnet. Dans le corps de `add_offset`, le nom `offset` n'est ni un paramètre ni une liaison créée par le corps : on dit que c'est une variable libre. Notre fonction ne garde que ses paramètres et son corps, donc à l'appel, `offset` est introuvable. Que faudrait-il ajouter à la valeur de la fonction pour que ce programme rende 5.0 ? … Il faudrait qu'elle se souvienne des liaisons visibles là où elle a été définie. Avant de l'écrire, il y a un choix à faire, et l'étape 04 commence par lui. Repères : reprise de 5 min. Ne pas suggérer de consulter automatiquement l'environnement de l'appelant : le contre-exemple suivant permet de choisir explicitement la portée lexicale.

À dire : Voici le programme qui nous oblige à choisir. Entre la définition de `add_offset` et son appel, `offset` a été lié une seconde fois, à 100.0. Votez : 5.0 ou 103.0 ? … Les deux réponses se défendent, et des langages ont existé avec chacune. 103.0 suppose qu'on cherche `offset` là où la fonction est appelée ; 5.0, là où elle a été écrite. Donnons un nom à ces deux règles. Repères : atelier 04, 30 min en tout, dont 7 min de cours. Notre contrat retient le lieu de définition, résultat 5.0. Ne pas présenter l'autre choix comme un bug universel.

À dire : La portée dynamique cherche les noms dans le contexte de l'appel : elle est simple à programmer, mais une fonction change alors de sens selon qui l'appelle. Les premiers Lisp l'ont utilisée, Emacs Lisp longtemps, et votre terminal s'en sert encore : dans bash, une fonction voit les variables locales de la fonction qui l'appelle. La portée lexicale cherche les noms là où la fonction est écrite : c'est le choix d'ALGOL 60, puis de Scheme, et aujourd'hui de presque tous les langages, Python et Elixir compris. C'est le nôtre. En clair, une fonction se souvient de l'endroit où elle a été définie, et il suffit de lire le texte pour savoir quel `offset` elle utilise. Pour cela, sa valeur doit emporter ce contexte avec elle ; Landin a donné un nom à cette valeur, une fermeture. Repères : 1 min. Les premiers Lisp ont rencontré ce souci sous le nom de funarg problem. Emacs 24.1, en 2012, ajoute la portée lexicale en option, fichier par fichier. Bash, vérifié : f() { echo "$x"; } appelée depuis g() { local x=interieur; f; } affiche interieur ; le manuel écrit « The shell uses dynamic scoping to control a variable's visibility within functions ». Une fonction anonyme Elixir capture les variables visibles à sa définition.

À dire : Voici ce que nous voulons. Au moment de la définition, la fonction capture l'environnement courant, où `offset` vaut 2.0. Ensuite, l'appelant peut bien lier `offset` à 100.0 : la liste capturée n'a pas changé, puisque nos environnements sont immuables. À l'appel, le corps est évalué dans l'environnement capturé, enrichi de `x : 3.0`, et le résultat vaut 5.0. Une question se pose alors : si l'argument lui-même contient `offset`, lequel prend-on ? Repères : seule modification de représentation centrale entre les étapes 03 et 04 : {:function, …} devient {:closure, parameters, body, captured}. Le corps reste un arbre. L'environnement capturé n'est pas une copie mutable à tenir à jour.

À dire : Changeons seulement la dernière ligne : l'argument est maintenant `offset`. Il y a deux recherches du même nom, à deux moments. L'argument appartient au programme de l'appelant : il se calcule chez l'appelant, où `offset` vaut 100.0, et cette valeur devient celle de `x`. Le `offset` du corps, lui, se cherche dans le contexte capturé, où il vaut 2.0. Le résultat est donc 102.0. Montrez du doigt, sur l'écran, les deux environnements : c'est exactement ce que votre appel devra distinguer. Repères : eval(argument, appelant), puis eval(corps, paramètres placés devant captured). Cet exemple révèle une implémentation qui évaluerait les arguments dans l'environnement de définition. Demander quelle liaison gagne si un paramètre porte le nom d'une variable capturée.

À dire : Une fermeture est une valeur comme une autre : on peut la rendre comme résultat d'une fonction. Ici, `make_adder` vaut `fn(x) { fn(y) { x + y } }`. L'appel `make_adder(2.0)` est terminé depuis longtemps quand on appelle `add_two(5.0)`, et pourtant il faut encore connaître `x` : c'est la fermeture rendue qui l'a emporté. Avec `make_adder(10.0)`, nous obtenons une autre fermeture, même corps, autre contexte, et 15.0 au lieu de 7.0. Mais que capture une fermeture, au juste : une variable, ou sa valeur ? Repères : écrire oralement let make_adder = fn(x) { fn(y) { x + y } }. Aucune pile d'appels Elixir n'est conservée : seule la liste capturée survit.

À dire : Python donne à cette question une réponse qui surprend. Cette liste contient trois fonctions sans paramètre, fabriquées par une boucle, et chacune rend `i`. Que rendent-elles ? … 2, 2 et 2 ! Python a bien une portée lexicale, mais ses fermetures capturent la variable `i` elle-même, une case que la boucle modifie, et non sa valeur du moment. À l'appel, la boucle est finie et `i` vaut 2. Dans notre langage, cela ne peut pas arriver : un `let` ne modifie jamais une liaison, il en ajoute une nouvelle devant. Capturer l'environnement revient donc à figer ses valeurs. Elixir fait de même : la même boucle, écrite avec `for`, rend 0, 1 et 2. Voyons ce que la capture change dans votre évaluateur. Repères : 1 min. Vérifié avec Python 3.13 ; en Elixir 1.18, for i <- 0..2, do: fn -> i end donne des fonctions qui rendent 0, 1 et 2. Le correctif usuel en Python est lambda i=i: i, qui recopie la valeur dans un paramètre. Le même souci fait répondre 103.0 à notre programme offset écrit en Python, avec def et deux affectations : la portée reste lexicale, mais la variable a changé. Ne pas présenter Python comme un langage à portée dynamique.

À dire : Par rapport au carnet 03, seuls deux algorithmes changent : celui qui crée la valeur d'une fonction et celui qui l'appelle. À la définition, on range l'environnement courant dans la valeur, qui devient `{:closure, paramètres, corps, capturé}`. À l'appel, les arguments se calculent chez l'appelant, comme avant, mais le corps s'évalue dans l'environnement capturé, avec les liaisons des paramètres placées devant. Une seule opération Elixir s'ajoute pour cela, `++`, qui met deux listes bout à bout. Pourquoi devant ? Parce qu'un paramètre doit masquer un nom capturé de même nom, et notre recherche prend la première liaison. Tout le reste de l'évaluateur est inchangé, et le parser aussi. Le carnet écrit enfin ses programmes entre triples guillemets, sur plusieurs lignes ; notre lexer traite les fins de ligne comme des blancs. À vous. Repères : le carnet garde les erreurs de l'étape 03 : arité, valeur non appelable, erreur d'argument. Une fermeture ne rend pas let récursif : le nom de la fonction n'est pas encore lié quand sa valeur est construite. ++ recopie la liste de gauche, sans importance ici puisque les listes de paramètres sont courtes.

À dire : Ouvrez le carnet 04 et recopiez `ArithmeticOps`, `Bindings` et `Interpreter`. Avant de coder, écrivez sur papier les environnements de définition et d'appel du premier programme. Changez ensuite vos deux clauses, et faites passer les vérifications dans l'ordre : la portée lexicale, l'argument chez l'appelant, puis `make_adder`. Le travail ressemble volontairement à celui de l'étape 03, puisque vous modifiez votre propre code. Vous avez dix-huit minutes. Repères : 18 min. Ne pas fournir le remplacement complet. Les fermetures sont dans le socle conceptuel, pas reléguées aux rapides.

À dire : Qui nous montre les trois morceaux de la fermeture rendue par `make_adder(2.0)` ? … Les paramètres, `["y"]`, le corps `x + y` sous forme d'arbre, et l'environnement capturé, où `x` vaut 2.0. L'appel de `make_adder` est fini, mais son environnement survit dans cette valeur, et nulle part ailleurs. Notre langage sait désormais calculer, nommer, appeler et garder un contexte. Il lui manque une chose : décider quel calcul faire. Repères : reprise de 5 min. Ne pas confondre environnement capturé, pile d'appels et pile de calcul. Fonctions nommées récursives hors socle : let ne se voit pas lui-même à sa définition.

À dire : Dernière étape. Notre langage reçoit des booléens, `True` et `False`, des comparaisons, `<` et `==`, qui comparent deux nombres, et une forme de choix, `case`, avec exactement deux branches. La condition vaut ici `True`. Que rend ce programme, et la division par zéro pose-t-elle un souci ? … Nous voulons 7.0 : la branche fausse est écrite, elle doit être correcte pour la grammaire, mais elle ne doit pas être calculée. Attention, ce `case` n'est pas celui d'Elixir : il ne connaît ni motifs ni gardes, il choisit seulement entre deux branches selon un booléen. Repères : atelier 05, 29 min en tout, dont 6 min de cours. Les comparaisons prennent deux nombres ; aucune conversion implicite vers un booléen. True et False du langage cible deviennent true et false en Elixir.

À dire : Comparez avec l'évaluation des opérations : pour un plus, on évalue toujours les deux enfants, puis on calcule. Pour un `case`, l'ordre est différent : on évalue la condition, on choisit un sous-arbre, et on n'évalue que lui. La division par zéro est bien présente dans l'arbre, le parser l'a construite, mais l'évaluateur ne la visite jamais. Pourrait-on obtenir le même effet avec une fonction ordinaire ? Repères : une erreur de syntaxe dans une branche inactive reste une erreur. Une condition non booléenne rend {:error, :expected_boolean}.

À dire : Imaginons une fonction `choose` qui contiendrait un `case` dans son corps. À l'appel, que se passe-t-il ? Rappelez-vous l'étape 03 : on calcule d'abord tous les arguments, puis on entre dans le corps. La division par zéro est donc calculée avant même que `choose` regarde sa condition, et l'appel échoue. Cette règle, qui calcule les arguments avant l'appel, s'appelle l'évaluation stricte ; c'est celle de notre langage, d'Elixir et de Python. Voilà pourquoi `case` doit être une forme à part, avec sa propre règle d'évaluation. Repères : choose n'est pas prédéfini, c'est une hypothèse, testée en AST dans le carnet : {:error, :division_by_zero}. Python, vérifié avec la version 3.13 : def choose(c, a, b): return a if c else b, puis choose(True, 7.0, 1.0 / 0.0), lève ZeroDivisionError. Ne pas confondre évaluation stricte et typage statique.

À dire : Ce choix n'est pas universel, et chaque langage a dû le trancher. En C, `a && b` ne calcule pas `b` quand `a` est faux : le résultat est déjà connu, on parle de court-circuit. Python fait de même avec `and`, `or` et son `if` en une ligne : `False and 1 / 0` rend `False`, sans erreur. Elixir est strict, comme nous ; pour que `if` et `&&` n'exécutent qu'une branche, il en fait des macros, réécrites en `case` avant l'exécution. Retenez seulement que `if` n'est pas une fonction en Elixir. Haskell, enfin, a fait le choix inverse : il n'évalue une expression que lorsque sa valeur est demandée, on dit qu'il est paresseux, et notre `choose` y rendrait 7.0. Voyons ce que le carnet attend. Repères : 1 min. Vérifié : compilé avec gcc, 0 && boom() n'appelle pas boom ; en Python 3.13, False and 1 / 0 vaut False et 7.0 if True else 1.0 / 0.0 vaut 7.0. ALGOL 60 proposait déjà l'appel par nom, où l'argument est recalculé à chaque usage. Ne pas citer SI() d'Excel : Microsoft ne documente pas l'ordre d'évaluation de ses arguments. Les élèves n'écriront pas de macros.

À dire : Voici le contrat du carnet 05, en deux temps. D'abord l'évaluateur, sur des arbres écrits à la main : un cas pour les booléens, les comparaisons `:lt` et `:eq` ajoutées aux opérations par analogie avec le plus, puis le `case`. Pour l'écrire, le `case` d'Elixir est l'outil qu'il faut : une branche par forme du résultat de la condition. Les points de suspension sont à vous : que faire d'une condition qui n'est pas un booléen, et d'une erreur dans la condition ? Ensuite, si vous avez le temps, la lecture depuis le texte. Vous savez lire une grammaire : les comparaisons forment un niveau au-dessus de la somme, donc elles sont moins prioritaires, et `case` devient une nouvelle forme de primaire. Au lexer, reconnaissez `->` avant `-`, et `==` avant `=`, sinon le symbole long ne serait jamais reconnu. Le programme final se vérifie avec `assert_in_delta`, qui compare deux flottants à une tolérance près : nos nombres sont des flottants binaires, et `0.1 + 0.2` ne tombe pas juste. À vous. Repères : condition non booléenne : {:error, :expected_boolean} ; erreur dans la condition : propagée. Évaluation des AST d'abord, intégration du texte en prolongement guidé ; LANGAGE.md donne la grammaire complète. Guider l'extension de primary et des niveaux de comparaison sans recopier les lecteurs de l'étape 02. Un nœud case a trois enfants ; deux sont visités. Le parallèle entre les deux case est volontaire ; le nôtre n'a que True et False.

À dire : Ouvrez le carnet 05 et recopiez vos modules du carnet 04. Commencez par les arbres écrits à la main : booléens, comparaisons, puis le `case`, avec une division par zéro cachée dans la branche inactive. Testez aussi `choose`, et expliquez par écrit pourquoi elle échoue. Si votre évaluateur est prêt, étendez vos lecteurs : la dernière cellule lit et exécute le programme que je vous ai montré en ouvrant la séance. Vous avez dix-huit minutes. Repères : 18 min. Exiger une explication de la branche visitée, même si l'extension de grammaire n'est pas terminée. Le corrigé privé contient la chaîne complète mais ne doit pas remplacer les modules personnels. Pour les plus rapides, la fin du carnet propose trois prolongements facultatifs : les branches `True` et `False` dans un ordre quelconque, sans changer l'arbre ; un `case` sur les nombres, avec un cas par défaut `_` obligatoire, donc sans contrôle d'exhaustivité, nouveau nœud `{:switch, …}` hors socle ; enfin sa traduction en formes du socle, du sucre syntaxique, par une passe `Sugar.desugar` guidée.

À dire : Revenons au programme du début. Qui l'a fait tourner depuis le texte ? … Suivons-le ensemble : le lexer en fait des tokens, le parser un arbre de `let` imbriqués, l'environnement accumule `rate`, `discounted`, `price`, `quantity` puis `total`, la fermeture `discounted` a capturé `rate`, et le `case` choisit la branche `False`, car 36.0 n'est pas inférieur à 30.0. Résultat : 32.4, à une tolérance près. Chacune des cinq étapes a servi. Repères : reprise de 5 min. Résultat {:ok, 32.4} avec la référence ; le carnet compare avec assert_in_delta. Faire suivre un token, un nœud, une liaison, une capture et la branche choisie.

À dire : Pour finir, revenons à nos trois niveaux et regardons les refus. Chaque brique connaît des erreurs différentes : le lexer bute sur un mot inconnu, comme ce symbole @ ; le parser sur une phrase mal formée, comme une parenthèse qui manque ; l'évaluateur sur un sens impossible, un nom inconnu ou une division par zéro. Quand votre programme échoue, demandez-vous d'abord quelle brique pouvait le savoir. Un refus bien expliqué vous renseigne autant qu'un résultat juste. Repères : bilan de 5 min avec les deux slides suivantes. « Fermeture manquante » désigne ici la parenthèse fermante. Contrats précis de la référence dans LANGAGE.md ; les petites versions des carnets ont leurs propres contrats et n'ont pas tous les contrôles dès le départ.

À dire : Pour celles et ceux qui veulent aller plus loin, le carnet 06 change de modèle : des formules nommées qui dépendent les unes des autres, sans ordre fixé. On cherche alors un ordre de calcul, et on détecte un cycle. Ce carnet est facultatif et n'a besoin d'aucun de vos modules. Repères : réserve pour les rapides, valorisable dans le rendu. Formules simultanées, distinctes des let séquentiels de notre langage. Deux ensembles sont utiles pour les cycles : en cours de visite et terminés. Ne pas lancer un sixième atelier obligatoire.

À dire : Gardez vos carnets dans votre dossier AP2, avec vos essais et vos explications : un code inachevé accompagné d'une trace juste compte. Je vous préciserai les étapes à terminer pour le rendu du cycle, le 6 décembre. Mardi, AP3 s'ouvrira par un quiz de dix minutes sur cette séance : relisez le chapitre 5 du poly, il suffit pour répondre. Puis nous repartirons de vos arbres pour les compiler vers une machine. Repères : quiz d'AP3 : douze questions à choix unique sur l'interpréteur, corrigées ensemble dans la foulée. Sauvegarder, commiter et pousser une première version. Le carnet graphes reste facultatif et peut valoriser le rendu.