Remarque: Lorsque j'ai utilisé "complexe" dans le titre, je veux dire que l'expression a de nombreux opérateurs et opérandes. Non pas que l'expression elle-même soit complexe.
J'ai récemment travaillé sur un simple compilateur pour l'assemblage x86-64. J'ai terminé la partie frontale principale du compilateur - lexer et analyseur - et je suis maintenant capable de générer une représentation d'arbre de syntaxe abstraite de mon programme. Et comme mon langage sera tapé statiquement, je passe maintenant à la phase suivante: taper le code source. Cependant, je suis arrivé à un problème et je n'ai pas pu le résoudre raisonnablement moi-même.
Prenons l'exemple suivant:
L'analyseur de mon compilateur a lu cette ligne de code:
int a = 1 + 2 - 3 * 4 - 5
Et l'a converti en AST suivant:
=
/ \
a(int) \
-
/ \
- 5
/ \
+ *
/ \ / \
1 2 3 4
Il doit maintenant taper vérifier l'AST. il commence par le premier type de vérification de l' =
opérateur. Il vérifie d'abord le côté gauche de l'opérateur. Il voit que la variable a
est déclarée comme un entier. Il doit donc maintenant vérifier que l'expression de droite s'évalue en un entier.
Je comprends comment cela pourrait être fait si l'expression n'était qu'une seule valeur, comme 1
ou 'a'
. Mais comment cela pourrait-il être fait pour les expressions avec plusieurs valeurs et opérandes - une expression complexe - comme celle ci-dessus? Pour déterminer correctement la valeur de l'expression, il semble que le vérificateur de type devrait réellement exécuter l'expression elle-même et enregistrer le résultat. Mais cela semble évidemment aller à l'encontre du but de séparer les phases de compilation et d'exécution.
La seule autre façon dont j'imagine que cela pourrait être fait est de vérifier récursivement la feuille de chaque sous-expression dans l'AST et de vérifier que tous les types de feuille correspondent au type d'opérateur attendu. Donc, en commençant par l' =
opérateur, le vérificateur de type scannerait alors tous les AST du côté gauche et vérifierait que les feuilles sont toutes des entiers. Il répéterait alors ceci pour chaque opérateur dans la sous-expression.
J'ai essayé de faire des recherches sur le sujet dans ma copie de "The Dragon Book" , mais il ne semble pas entrer dans les détails, et réitère simplement ce que je sais déjà.
Quelle est la méthode habituelle utilisée lorsqu'un compilateur vérifie les expressions avec de nombreux opérateurs et opérandes? Certaines des méthodes que j'ai mentionnées ci-dessus sont-elles utilisées? Sinon, quelles sont les méthodes et comment fonctionneraient-elles exactement?
la source
double a = 7/2
essaierait d'interpréter le côté droit comme double, donc d'essayer d'interpréter le numérateur et le dénominateur comme double et de les convertir si nécessaire; en conséquencea = 3.5
. Le bas vers le haut effectuerait une division entière et ne se convertirait qu'à la dernière étape (affectation)a = 3.0
.int a = 1 + 2 - 3 * 4 - 5
mais àint a = 5 - ((4*3) - (1+2))
int + int
devient par exempleint
.Réponses:
La récursivité est la réponse, mais vous descendez dans chaque sous-arbre avant de gérer l'opération:
sous forme d'arbre:
L'inférence du type se produit en marchant d'abord du côté gauche, puis du côté droit, puis en manipulant l'opérateur dès que les types d'opérandes sont déduits:
-> descendre en lhs
-> déduire
a
.a
est connu pour êtreint
. Nous sommes de retour dans leassign
nœud maintenant:-> descendre dans le rhs, puis dans le lhs des opérateurs internes jusqu'à ce que l'on frappe quelque chose d'intéressant
-> déduire le type de
1
, qui estint
, et retourner au parent-> allez dans le rhs
-> déduire le type de
2
, qui estint
, et retourner au parent-> déduire le type de
add(int, int)
, qui estint
, et retourner au parent-> descendre dans le rhs
etc., jusqu'à ce que vous vous retrouviez avec
Que l'affectation elle-même soit également une expression avec un type dépend de votre langue.
Le point important à retenir: pour déterminer le type de n'importe quel nœud opérateur dans l'arborescence, il vous suffit de regarder ses enfants immédiats, qui doivent déjà avoir un type qui leur est attribué.
la source
Lisez les pages Web sur le système de type et l' inférence de type et sur le système de type Hindley-Milner , qui utilise l' unification . Lisez également la sémantique dénotationnelle et la sémantique opérationnelle .
La vérification de type peut être plus simple si:
a
sont explicitement déclarées avec un type. C'est comme C ou Pascal ou C ++ 98, mais pas comme C ++ 11 qui a une inférence de type avecauto
.1
,2
ou'c'
ont un type inhérent: un littéral int a toujours un typeint
, un littéral caractère a toujours un typechar
,….+
opérateur a toujours un type(int, int) -> int
. C a une surcharge pour les opérateurs (+
fonctionne pour les types entiers signés et non signés et pour les doubles) mais pas de surcharge des fonctions.Sous ces contraintes, un algorithme de décoration de type AST récursif ascendant pourrait suffire (cela ne concerne que les types , pas les valeurs concrètes, donc c'est une approche à la compilation):
Pour chaque étendue, vous conservez un tableau pour les types de toutes les variables visibles (appelées l'environnement). Après une déclaration
int a
, vous ajouteriez l'entréea: int
à la table.La saisie des feuilles est le cas de base de récursivité trivial: le type de littéraux comme
1
est déjà connu, et le type de variables commea
peut être recherché dans l'environnement.Pour taper une expression avec un opérateur et des opérandes selon les types précédemment calculés des opérandes (sous-expressions imbriquées), nous utilisons la récursivité sur les opérandes (nous tapons donc d'abord ces sous-expressions) et suivons les règles de typage liées à l'opérateur .
Donc, dans votre exemple,
4 * 3
et1 + 2
sont tapésint
parce que4
&3
et1
&2
ont été précédemment tapésint
et vos règles de frappe disent que la somme ou le produit de deuxint
-s est unint
, et ainsi de suite pour(4 * 3) - (1 + 2)
.Lisez ensuite le livre Types et langages de programmation de Pierce . Je recommande d'apprendre un tout petit peu d' Ocaml et λ-calcul
Pour des langues plus typées dynamiquement (comme Lisp), lisez aussi Lisp In Small Pieces de Queinnec
Lisez aussi le livre de Scott sur les langages de programmation
BTW, vous ne pouvez pas avoir un code de frappe indépendant de la langue, car le système de type est une partie essentielle de la sémantique de la langue .
la source
auto
pas plus simple? Sans cela, vous devez déterminer le type sur le côté droit, puis voir s'il existe une correspondance ou une conversion avec le type sur le côté gauche. Avecauto
vous, déterminez simplement le type du côté droit et vous avez terminé.auto
, C #var
et Go:=
est très simple: tapez check le côté droit de la définition. Le type résultant est le type de la variable sur le côté gauche. Mais le diable est dans les détails. Par exemple, les définitions C ++ peuvent être auto-référentielles, vous pouvez donc vous référer à la variable déclarée sur le rhs, par exempleint i = f(&i)
. Si le type dei
est déduit, l'algorithme ci-dessus échouera: vous devez connaître le type dei
pour déduire le type dei
. Au lieu de cela, vous auriez besoin d'une inférence de type HM complète avec des variables de type.En C (et franchement la plupart des langages typés statiquement basés sur C), chaque opérateur peut être considéré comme un sucre syntaxique pour un appel de fonction.
Ainsi, votre expression peut être réécrite comme:
Ensuite, la résolution de surcharge démarre et décide que chaque fonction est du type
(int, int)
ou(const int&, const int&)
.De cette façon, la résolution de type est facile à comprendre et à suivre et (plus important encore) facile à implémenter. Les informations sur les types ne circulent que dans un sens (des expressions internes vers l'extérieur).
C'est la raison pour laquelle cela
double x = 1/2;
se traduira parx == 0
car1/2
est évalué comme une expression int.la source
+
n'est pas traité comme des appels de fonction (car il a un typage différent pourdouble
et pour lesint
opérandes)operator+(int,int)
,operator+(double,double)
,operator+(char*,size_t)
, etc. L'analyseur a juste pour garder une trace de laquelle on est sélectionné.f(a,b)
est un peu plus facile que de déterminer le type dea+b
.En vous concentrant sur votre algorithme, essayez de le changer de bas en haut. Vous connaissez les variables et constantes de type pf; balisez le nœud portant l'opérateur avec le type de résultat. Laissez la feuille déterminer le type d'opérateur, également l'opposé de votre idée.
la source
C'est en fait assez facile, tant que vous pensez
+
qu'il s'agit d'une variété de fonctions plutôt que d'un concept unique.Pendant la phase d'analyse du côté droit, l'analyseur récupère
1
, sait que c'est unint
, puis analyse+
et stocke cela comme un "nom de fonction non résolu", puis il analyse le2
, sait que c'est unint
, puis le renvoie dans la pile. Le+
nœud de fonction connaît maintenant les deux types de paramètres, il peut donc résoudre le+
enint operator+(int, int)
, donc maintenant il connaît le type de cette sous-expression, et l'analyseur continue sur sa bonne voie.Comme vous pouvez le voir, une fois l'arbre entièrement construit, chaque nœud, y compris les appels de fonction, connaît ses types. Ceci est essentiel car il permet des fonctions qui renvoient des types différents de leurs paramètres.
Ici, l'arbre est:
la source
La base de la vérification de type n'est pas ce que fait le compilateur, c'est ce que le langage définit.
En langage C, chaque opérande a un type. "abc" a le type "tableau de caractères const". 1 a le type "int". 1L a le type "long". Si x et y sont des expressions, il existe des règles pour le type de x + y et ainsi de suite. Le compilateur doit donc évidemment suivre les règles du langage.
Sur les langues modernes comme Swift, les règles sont beaucoup plus compliquées. Certains cas sont simples comme en C. Dans d'autres cas, le compilateur voit une expression, a été informé au préalable du type que l'expression devrait avoir et détermine les types de sous-expressions en fonction de cela. Si x et y sont des variables de types différents et qu'une expression identique est affectée, cette expression peut être évaluée d'une manière différente. Par exemple, l'attribution de 12 * (2/3) affectera 8,0 à un double et 0 à un int. Et vous avez des cas où le compilateur sait que deux types sont liés et détermine quels types ils sont basés sur cela.
Exemple rapide:
imprime "8.0, 0".
Dans l'affectation x = 12 * (2/3): Le côté gauche a un type Double connu, donc le côté droit doit avoir le type Double. Il n'y a qu'une seule surcharge pour l'opérateur "*" renvoyant Double, et c'est Double * Double -> Double. Par conséquent, 12 doivent avoir le type Double, ainsi que 2 / 3. 12 prend en charge le protocole "IntegerLiteralConvertible". Double a un initialiseur prenant un argument de type "IntegerLiteralConvertible", donc 12 est converti en Double. Les 2/3 doivent être de type Double. Il n'y a qu'une surcharge pour l'opérateur "/" renvoyant Double, et c'est Double / Double -> Double. 2 et 3 sont convertis en Double. Le résultat 2/3 est 0,6666666. Le résultat de 12 * (2/3) est de 8,0. 8.0 est affecté à x.
Dans l'affectation y = 12 * (2/3), y sur le côté gauche a le type Int, donc le côté droit doit avoir le type Int, donc 12, 2, 3 sont convertis en Int avec le résultat 2/3 = 0, 12 * (2/3) = 0.
la source