Quelle est la procédure courante utilisée lorsque les compilateurs saisissent statiquement des expressions «complexes» de vérification?


23

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 aest 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 1ou '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?


8
Il existe un moyen simple et évident de vérifier le type d'une expression. Vous feriez mieux de nous dire ce qui vous fait appeler cela "désagréable".
gnasher729

12
La méthode habituelle est la "deuxième méthode": le compilateur déduit le type d'expression complexe à partir des types de ses sous-expressions. C'était le point principal de la sémantique dénotationnelle et de la plupart des systèmes de types créés à ce jour.
Joker_vD

5
Les deux approches peuvent produire un comportement différent: l'approche descendante 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équence a = 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.
Hagen von Eitzen

3
Notez que l'image de votre AST ne correspond pas à votre expression int a = 1 + 2 - 3 * 4 - 5mais àint a = 5 - ((4*3) - (1+2))
Basile Starynkevitch

22
Vous pouvez "exécuter" l'expression sur les types plutôt que sur les valeurs; int + intdevient par exemple int.

Réponses:


14

La récursivité est la réponse, mais vous descendez dans chaque sous-arbre avant de gérer l'opération:

int a = 1 + 2 - 3 * 4 - 5

sous forme d'arbre:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

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:

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> descendre en lhs

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> déduire a. aest connu pour être int. Nous sommes de retour dans le assignnœud maintenant:

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> descendre dans le rhs, puis dans le lhs des opérateurs internes jusqu'à ce que l'on frappe quelque chose d'intéressant

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

-> déduire le type de 1, qui est int, et retourner au parent

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

-> allez dans le rhs

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

-> déduire le type de 2, qui est int, et retourner au parent

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

-> déduire le type de add(int, int), qui est int, et retourner au parent

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

-> descendre dans le rhs

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

etc., jusqu'à ce que vous vous retrouviez avec

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

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


43

Quelle est la méthode généralement utilisée lorsqu'un compilateur vérifie des expressions de type avec de nombreux opérateurs et opérandes.

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:

  • toutes vos variables comme asont 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 avec auto.
  • toutes les valeurs littérales comme 1, 2ou 'c'ont un type inhérent: un littéral int a toujours un type int, un littéral caractère a toujours un type char,….
  • les fonctions et les opérateurs ne sont pas surchargés, par exemple l' +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ée a: intà la table.

  • La saisie des feuilles est le cas de base de récursivité trivial: le type de littéraux comme 1est déjà connu, et le type de variables comme apeut ê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 * 3et 1 + 2sont tapés intparce que 4& 3et 1& 2ont été précédemment tapés intet vos règles de frappe disent que la somme ou le produit de deux int-s est un int, 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 .


2
Comment le C ++ 11 n'est-il autopas 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. Avec autovous, déterminez simplement le type du côté droit et vous avez terminé.
nwp

3
@nwp L'idée générale des définitions de variables C ++ auto, C # varet 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 exemple int i = f(&i). Si le type de iest déduit, l'algorithme ci-dessus échouera: vous devez connaître le type de ipour déduire le type de i. Au lieu de cela, vous auriez besoin d'une inférence de type HM complète avec des variables de type.
amon

13

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:

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

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 par x == 0car 1/2est évalué comme une expression int.


6
Presque vrai pour C, où il +n'est pas traité comme des appels de fonction (car il a un typage différent pour doubleet pour les intopérandes)
Basile Starynkevitch

2
@BasileStarynkevitch: Il est mis en œuvre comme une série de fonctions surchargées: 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é.
Mooing Duck

3
@aschepler Personne ne suggérait qu'au niveau de la source et de la spécification, C avait en fait des fonctions surchargées ou des fonctions d'opérateur
cat

1
Bien sûr que non. Il suffit de souligner que dans le cas d'un analyseur C, un "appel de fonction" est quelque chose d'autre que vous auriez besoin de traiter, qui n'a en fait pas grand chose en commun avec les "opérateurs en tant qu'appels de fonction" comme décrit ici. En fait, en C, déterminer le type de f(a,b)est un peu plus facile que de déterminer le type de a+b.
aschepler

2
Tout compilateur C raisonnable a plusieurs phases. Près de l'avant (après le préprocesseur), vous trouverez l'analyseur, qui crée un AST. Ici, il est assez clair que les opérateurs ne sont pas des appels de fonction. Mais dans la génération de code, vous ne vous souciez plus de la construction du langage qui a créé un nœud AST. Les propriétés du nœud lui-même déterminent la façon dont le nœud est traité. En particulier, + peut très bien être un appel de fonction - cela se produit généralement sur les plates-formes avec des calculs en virgule flottante émulés. La décision d'utiliser des mathématiques FP émulées se produit lors de la génération de code; aucune différence AST préalable n'est nécessaire.
MSalters

6

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.


6

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.

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

Pendant la phase d'analyse du côté droit, l'analyseur récupère 1, sait que c'est un int, puis analyse +et stocke cela comme un "nom de fonction non résolu", puis il analyse le 2, sait que c'est un int, 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 +en int 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.

char* ptr = itoa(3);

Ici, l'arbre est:

    char* itoa(int)
     /           \
  ptr(char*)      3

4

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:

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

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.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.