2. Comprendre ce qu'il veut dire¶
Après le front-end, le compilateur sait que le programme est bien formé. Il ne sait pas que le t de la ligne 4 est celui que le motif a introduit deux caractères plus tôt, ni que l'ajouter à une chaîne serait une faute.
Deux questions. Ce sont elles qui expliquent que les erreurs de type et les erreurs de portée soient les deux sortes d'erreurs qu'on rencontre vraiment.
À quelle définition renvoie chaque nom ?¶
Considérez :
Que vaut y ? En OCaml, 1. La troisième ligne ne change pas x ; elle crée un nouveau x qui cache l'ancien, et f détient toujours celui qu'il a capturé. C'est facile de se tromper ; ce compilateur s'est trompé, comme le raconte la page suivante.
Y répondre suppose de savoir, en tout point du programme, quels noms sont visibles. Cette structure s'appelle l'arbre des portées. Vous pouvez le regarder :
$ python -m ocaml --names somme.ml # abrégé: le premier des cinq arbres
── vals ──
module top {somme}
binding {l}
case {}
case {q, t}
Le fichier entier lie somme. À l'intérieur de la fonction, l. À l'intérieur du second cas du filtrage et nulle part ailleurs, q et t.
Comment c'est calculé¶
Voici la partie qui vaut le déplacement. src/ocaml/middle/analyze.py, qui produit tout cela, fait 52 lignes, et ne mentionne pas une seule sorte de nœud. Pas de PVar, pas de Case, aucune liste des champs qui introduisent des noms.
Il pose la question, au lieu de la savoir. Les réponses viennent d'un fichier séparé de 42 lignes, middle/grammar.py, qui décrit l'arbre :
ROLES = {
# Un motif est ce qui introduit un nom de valeur.
"PVar": {"name": defines(VALS)},
"PAlias": {"name": defines(VALS)},
"Var": {"name": uses(VALS)},
# Constructeurs, partagés par les variants et les exceptions.
"Variant": {"name": defines(CONS)},
"ExnItem": {"name": defines(CONS)},
...
}
Chaque champ de chaque sorte de nœud reçoit un rôle : celui-ci introduit un nom, celui-là y renvoie, cet autre n'est qu'un sous-arbre. Et une seconde table dit quels nœuds ouvrent une portée, et sur lesquelles de leurs parties :
SCOPES: dict[str, tuple[Scope, ...]] = {
"Fun": (Scope("fun", inside=("params", "body")),),
"Case": (Scope("case", inside=("pattern", "guard", "body")),),
...
}
Lisez la ligne Case ainsi : un cas de match ouvre une portée ; son motif, sa garde et son corps sont tous dedans. Cette seule ligne explique que q et t apparaissent dans le bloc d'un cas et nulle part ailleurs.
De ces deux tables sortent l'arbre des portées, la détection des noms non définis, et le renommage sûr. Un compilateur qui écrirait ces listes dans chaque passe en aurait trois ou quatre à tenir à jour. Le bug classique est qu'un cas manque dans l'une d'elles : un renommeur qui traite les paramètres de fonction mais oublie les cas de match renomme la mauvaise chose, sans rien signaler.
Cette idée, et la bibliothèque qui l'implémente, c'est astero. Ce compilateur en est l'exemple de référence.
Cinq sortes de noms¶
OCaml sépare cinq sortes de noms. La description aussi :
| espace de noms | introduit par | employé par |
|---|---|---|
vals |
une variable de motif | une variable |
cons |
un variant, une exception | Some x, h :: t |
fields |
un champ d'enregistrement | r.x, { x = 1 } |
types |
une déclaration type |
une annotation |
tyvars |
les paramètres d'un type | 'a |
Ce n'est pas du rangement. En OCaml, un champ x et une variable x n'ont aucun rapport, donc une passe qui renomme la variable doit laisser r.x tranquille. Avec un seul espace de noms elle ne le ferait pas, sans que rien s'en plaigne.
Quel type a chaque chose ?¶
On n'écrit jamais de type en OCaml, et pourtant tout en a un. Les déterminer s'appelle l'inférence de types. La méthode est plus simple qu'elle n'en a l'air.
On commence par dire « je ne sais pas » : une inconnue. Puis, chaque fois que le programme se sert d'une valeur, on impose que son type soit compatible avec l'usage, avant de regarder ce à quoi les inconnues sont contraintes.
Pour somme :
sommeprend un argumentl, donc elle est?1 -> ?2pour deux inconnues.match l with [] -> ...impose quelsoit une liste de quelque chose :?1 = ?3 list.- Un cas rend
0, donc?2 = int. - L'autre rend
t + somme q. Comme+est l'addition entière,test unint; ettvient du motift :: q, donc?3 = int.
Mis bout à bout : somme : int list -> int. C'est ce qu'affiche le compilateur, au terme exactement de ce raisonnement.
Rendre deux types compatibles s'appelle l'unification : c'est le cœur de middle/unify.py. Il faut aussi savoir refuser, car c'est de là que viennent vos messages d'erreur :
Celui-ci surprend. let rec f x = f demanderait à f un type qui se contient lui-même, indéfiniment. C'est le test d'occurrence ; sans lui, le compilateur bouclerait sans jamais se plaindre.
Deux subtilités qui attrapent tout le monde¶
let id x = x reçoit 'a -> 'a et peut ensuite servir à int ici et à string là. Cela s'appelle le polymorphisme de let : une fonction devient réutilisable sans que vous écriviez quoi que ce soit.
let r = ref [], non. Essayez dans le bac à sable :
Vous obtenez '_weak1 list ref, pas 'a list ref. Si c'était 'a, on pourrait mettre un int dans la case à un endroit et en relire une string à un autre, ce qui ferait mentir le système de types. Ce comportement est la restriction aux valeurs, et le vrai OCaml fait de même. Le '_weak1 de votre message d'erreur n'est pas une bizarrerie : c'est le compilateur qui dit « un type précis, et je ne sais pas encore lequel ».
Où la dérivation s'arrête¶
La moitié « noms » de cette page est dérivée d'une description de 42 lignes. La moitié « types » est un millier de lignes écrites à la main, que rien ne dérive.
C'est la limite de l'idée. Une déclaration peut dire où des noms sont introduits et où des portées s'ouvrent. Elle ne peut pas dire ce qu'est l'unification. middle/infer.py, unify.py et prelude.py sont, pour cette raison, la plus grosse partie du compilateur.
Vérifier contre le vrai¶
Un moteur d'inférence qui est d'accord avec lui-même ne prouve rien. Donc, là où un vrai OCaml est installé, les tests lancent ocamlc -i, qui affiche les types que le vrai compilateur infère, et comparent ligne à ligne :
$ python -m ocaml --types corpus/tree.ml
val insert : 'a -> 'a tree -> 'a tree
val height : 'a tree -> int
val min_elt : 'a tree -> 'a
val to_list : 'a tree -> 'a list
val summarise : int list -> stats
Chacune de ces lignes doit coïncider avec ce que ocamlc -i dit du même fichier.
Ensuite : l'exécuter, deux fois.