Diaporama de référence du mini-projet, à lire en autonomie avec les quatre carnets guidés et le squelette mini-typeur. Les paliers bronze, argent et or correspondent au carnet 1, aux carnets 2 et 3, puis au carnet 4 ; ils décrivent l’ordre des idées, pas un barème.
Lancement. L’interpréteur produit 32.4 (le tarif 36.0, remisé de dix pour cent), le typeur produit Float sans faire ce calcul. Nous conservons les mêmes AST qu’en AP2, mais les notebooks partent d’arbres manuels : un parser terminé n’est pas requis. L’objectif est de comprendre comment des règles locales donnent une information globale.
Lancement. Donner la notation de fonction avant les algorithmes d’unification. Le nom des types ne fait pas partie des valeurs du langage cible. Distinguer syntaxe cible fn(x){...} et notation de types fn(Float) → Bool. Un type est une donnée du programme Elixir que nous écrivons.
Les inconnues permettent de travailler sans annotations : c’est l’inférence de types, qui déduit les types au lieu de les lire. Présenter Hindley–Milner comme le système que nous construirons dans notre petit langage pur : pas de sous-typage ni de surcharge des opérateurs, c-à-d. qu’un opérateur n’a qu’un seul type. Chaque carnet reprend les modules personnels du précédent ; le code final se reporte dans le squelette.
Annoncer en cours le palier choisi et le point d’arrêt. Les carnets et le squelette sont deux supports de la même progression, pas deux devoirs. Les commandes sont cumulatives : --include or seul laisserait les tests argent exclus. Les formes Elixir rappelées sont case, with, les maps et MapSet ; quand un bout de syntaxe manque en route, le poly et son aide-mémoire les reprennent, sans être un prérequis.
Carnet 1, première partie. Donner un seul exemple de règle détaillée puis faire dériver les autres. True == False est refusé dans notre langage. Une égalité surchargée accepterait plusieurs types pour le même opérateur ; ce choix évite ses contraintes supplémentaires.
Carnet 01, aperçu de la suite seulement. Le notebook 01 travaille les nombres, booléens, opérations et case ; noms et let arrivent à l’étape 03. Faire distinguer la nature des deux informations, sans demander d’implémenter leur environnement maintenant. Les types sont des données Elixir : :float et :bool.
Carnet 01. Les deux branches doivent donner le même type quelle que soit la valeur du test. Nous ne réduisons pas le programme pendant l’inférence pour supprimer les branches mortes. L’AST est syntaxiquement correct : le souci ne vient ni du lexer ni du parser.
Le parcours renvoie {:ok, type} ou une erreur explicite. L’élève écrit les clauses analogues, pas un trou dans une grande fonction visit déjà fournie. Les erreurs gardent assez de contexte pour expliquer la règle qui échoue.
Même structure que l'évaluateur d'AP2, mais le résultat est un type, pas une valeur. Le with … else … permet d'expliquer la règle qui échoue. Les élèves écrivent les clauses analogues : négation, comparaisons, case.
Les exemples du langage cible sont associés à des AST compacts écrits à la main. Écrire toutes les clauses peut être dense : le raisonnement et les contre-exemples priment sur la couverture mécanique de chaque opérateur.
reprise de 5 min. Faire nommer ce que les règles garantissent : catégories de valeurs cohérentes pour les opérateurs, les branches puis les appels. Ne pas promettre absence de toute erreur d’exécution ou terminaison. Analyser la valeur zéro demanderait une autre propriété.
Carnet 2. fn motive l’inconnue ; son inférence viendra au carnet 03. Toutes les occurrences de t0 décrivent une même inconnue à résoudre. Un paramètre ne change pas arbitrairement de type à chaque lecture.
Carnet 02. Visiter un AST et unifier ses contraintes sont deux tâches séparables. Nous écrivons d’abord le solveur sur des types manuels. La référence résout au fil du parcours, sans conserver nécessairement une liste de toutes les contraintes.
Carnet 02. Représentations {:var, id}, :float, :bool, {:fun, args, result}. Exemple de substitution : %{0 => {:var, 1}, 1 => :float}. Ne pas confondre remplacement d’une variable de type et valeur d’un paramètre à l’exécution. L’occurs-check empêche les chaînes cycliques.
Carnet 02. Tester l’égalité avant l’occurs-check, sinon unify(t0, t0) serait refusé à tort. Après chaque unification, transmettre la nouvelle substitution au couple suivant. Float et Bool restent distincts. Cette table donne les décisions de l’algorithme ; le code reste à écrire.
Carnet 02. Masquer la dernière ligne et faire prédire la substitution. Après t0 = Float, la contrainte t0 = t1 devient Float = t1. Unifier seulement les paramètres, oublier le résultat ou résoudre chaque paire avec une substitution séparée perdrait une information essentielle.
L’occurs-check descend dans tous les paramètres et le résultat, après résolution. L’auto-application sera générée par le visiteur de l’étape 03 ; ici, donner la paire de types comme contre-exemple du solveur. L’égalité t0 = t0 réussit avant cette vérification.
La substitution est une map persistante : chaque unify rend une nouvelle map, aucune mutation. L'occurs-check parcourt le type résolu ; un MapSet évite les doublons si l'on collecte les inconnues.
Contrat unify(a, b, sub) → {:ok, sub} ou erreur. Ce sont de vrais algorithmes courts à construire, pas une comparaison isolée à compléter. Le solveur se teste indépendamment du typeur de l’étape 01.
reprise de 5 min. Premier résultat Float ; second conflit Float/Bool après résolution. Les identifiants et la forme intermédiaire de la map peuvent varier, mais les types résolus doivent exprimer la même information. Ne pas exiger une normalisation canonique de la substitution.
Carnet 3. t0 est une variable de type, pas un type concret appelé t0. Le premier type est aussi général que le permet l’identité. Les inconnues d’un exemple sont indépendantes de celles des autres.
Carnet 03. L’état transmet le compteur fresh et la substitution. Plusieurs paramètres reçoivent des inconnues distinctes ; l’arité zéro existe aussi dans la référence. Le corps peut imposer Float. Les paramètres resteront monomorphes quand let deviendra polymorphe.
Carnet 03. Ne pas attendre de connaître le résultat pour former le type d’appel : créer une inconnue fraîche puis unifier. Le paramètre f pourrait rendre Bool ou Float ; rien n’impose davantage ici. Les appels ne sont pas curryfiés : le nombre d’arguments doit correspondre.
Carnet 03. Le parser de référence accepte cette fonction parenthésée puis appelée. Le TP utilise son AST pour éviter une dépendance au parser. Remplacer 4.0 par True révèle Float = Bool ; deux arguments révèlent une erreur d’arité.
Les noms suivent la portée lexicale ; la substitution et le compteur traversent les sous-arbres. Les environnements du parent et du corps restent distincts. La substitution doit aussi être appliquée au résultat final.
Trois informations circulent : env suit la portée (comme en AP2), state traverse tout le parcours. map_reduce est un reduce qui garde aussi la liste des résultats ; une récursion explicite convient aussi.
MiniTypes.infer(ast) rend {:ok, type}. Les élèves relient eux-mêmes les mécanismes, sans recevoir tout visit puis un trou de contrainte. let reste monomorphe à cette étape pour faire apparaître la question suivante.
reprise de 5 min. Ce programme est valide avec le polymorphisme de l’étape 04, mais refusé par la version monomorphe de 03. Faire produire Float = Bool. Le paramètre x conserve un type unique pendant l’inférence du corps.
Carnet 4. Lire ∀a comme « pour tout type a ». Un schéma n’est pas un type any. Seules les inconnues quantifiées sont remplacées ; les autres restent partagées avec l’environnement.
Carnet 04. Première instance fn(t1) → t1, puis t1 = Float. Deuxième instance fn(t2) → t2, puis t2 = Bool. Une variable répétée du schéma reçoit la même inconnue fraîche dans une instance. Garder le schéma dans l’environnement, sans le remplacer par sa première instance.
Carnet 04. Les variables libres d’un schéma excluent celles déjà quantifiées. À droite, supposons que f ait reçu le type fn(t0) → t1 par un usage : ses variables figurent dans le type de g et dans l’environnement. Résoudre aussi l’environnement avant la différence, sinon une liaison indirecte peut être généralisée à tort.
Carnet 04. Lire le programme du schéma. let peut généraliser ; les paramètres ne le font pas. Représenter un paramètre par un schéma avec une liste vide de variables quantifiées. L’alias g conserve ce partage, contrairement à let id = fn(x) { x }.
Carnet 04. Contrats generalize(type, env, sub) → {ids, type} et instantiate(scheme, state) → {type, state}, sans donner leur implémentation. La substitution appliquée à un schéma protège ses variables quantifiées. Notre let non récursif n’est pas visible dans sa propre valeur.
Carte de la référence : visit, constrain, unify, resolve, generalize, instantiate. Les élèves peuvent employer d’autres petits auxiliaires ; identifier la responsabilité plutôt que chercher tous ces noms mécaniquement. MiniTypes.infer rend un type final résolu.
Tout est résolu avant la différence d'ensembles. L'instanciation remplace de façon cohérente : deux occurrences du même identifiant reçoivent la même inconnue fraîche.
Le principal risque est une généralisation excessive. Les contre-exemples sont centraux ; dessiner les ensembles libres avant de montrer du code à un élève bloqué. Il s’agit d’un vrai polymorphisme let, pas d’un redémarrage du typeur à chaque appel.
reprise de 5 min. Résultat fn(t) → t. Ajouter x + 1.0 impose Float. Ce type est le plus général dans notre système Hindley–Milner, pas dans tous les langages possibles. À la lecture de keep, le schéma est instancié.
Types Float, Bool et fonctions, sans égalité polymorphe ni collection. Aucune restriction de valeur à ajouter dans ce langage pur. Distinguer les garanties de notre système, une analyse plus précise et les propriétés d’exécution.
Pour aller plus loin, hors paliers. Une paire ajoute un constructeur de type à resolve, unify et aux variables libres : bon exemple d’ajouts analogues. La récursion monomorphe dans son propre corps est un défi ultérieur, distinct du polymorphisme de let.
Trois réponses attendues : un calcul simple ; case True { True -> 1.0 False -> False } ; 1.0 / 0.0. Cette comparaison fait un bon paragraphe de README du projet.
Une explication du partage des inconnues compte davantage qu’une implémentation recopiée. Le compte rendu se rédige au fil du travail : commandes, trace de contraintes, occurs-check, différence entre paramètre et let polymorphe, comparaison des trois moteurs. Le projet noté se poursuit à la maison ; périmètre, barème et échéance seront précisés avant AP9.