À 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.