web-dev-qa-db-fra.com

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

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 compilateur simple pour l'assemblage x86-64. J'ai terminé la partie frontale principale du compilateur - le 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 ma langue sera typée 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 du = 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, telle que 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 le AST et de vérifier que tous les types de la feuille correspondent au type d'opérateur attendu. Donc, en commençant par le =, le vérificateur de type balayerait alors tous les AST) de gauche et vérifierait que les feuilles sont tous des entiers. Il répéterait ensuite 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épète simplement ce que je sais déjà.

Quelle est la méthode habituelle utilisée lorsqu'un compilateur vérifie des expressions de type 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?

23
Christian Dean

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. a est connu pour être int. Nous sommes de retour dans le nœud assign 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

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

14
Simon Richter

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

Lisez les pages wiki sur système de type et inférence de type et sur système de type Hindley-Milner , qui utilise nification . Lisez aussi à propos de sémantique dénotationnelle et sémantique opérationnelle .

La vérification de type peut être plus simple si:

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

Sous ces contraintes, un algorithme de décoration récursif ascendant AST type pourrait suffire (cela ne concerne que les types , pas sur les valeurs concrètes, est donc une approche de 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 1 est déjà connu, et le type de variables comme a 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 et 1 + 2 sont saisis int car 4 & 3 et 1 & 2 ont déjà été saisis int et vos règles de frappe indiquent 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 de Pierce Types et langages de programmation . Je recommande d'apprendre un tout petit peu de Ocaml et λ-calculus

Pour des langues plus typées dynamiquement (comme LISP), lisez aussi Queinnec's LISP In Small Pieces

Lire aussi Scott's Langages de programmation Pragmatique livre

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 langue sémantique .

43

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 en:

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 (int, int) ou (const int&, const int&) type.

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 double x = 1/2; aura pour résultat x == 0 parce que 1/2 est évalué comme une expression int.

13
ratchet freak

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 de l'opérateur, également l'opposé de votre idée.

6
JDługosz

C'est en fait assez simple, tant que vous pensez que + Est une variété de fonctions plutôt qu'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 l'étape d'analyse du côté droit, l'analyseur récupère 1, Sait qu'il s'agit d'un int, puis analyse + Et le stocke en tant que "nom de fonction non résolu", puis il analyse le 2, sait que c'est un int, puis le renvoie dans la pile. Le noeud de fonction + Connaît désormais les deux types de paramètres, il peut donc résoudre le + En int operator+(int, int), maintenant il connaît le type de cette sous-expression et l'analyseur continue sur son chemin joyeux.

Comme vous pouvez le voir, une fois l'arborescence entièrement construite, 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
6
Mooing Duck

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.

4
gnasher729