Qual è la procedura comune usata quando i compilatori digitano staticamente il controllo delle espressioni "complesse"?


23

Nota: quando ho usato "complesso" nel titolo, intendo che l'espressione ha molti operatori e operandi. Non che l'espressione stessa sia complessa.


Di recente ho lavorato su un semplice compilatore per l'assemblaggio x86-64. Ho terminato il front-end principale del compilatore - il lexer e il parser - e ora sono in grado di generare una rappresentazione ad albero di sintassi astratta del mio programma. E poiché la mia lingua verrà digitata staticamente, sto facendo la fase successiva: digitare il controllo del codice sorgente. Tuttavia, ho riscontrato un problema e non sono stato in grado di risolverlo ragionevolmente da solo.

Considera il seguente esempio:

Il parser del mio compilatore ha letto questa riga di codice:

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

E convertito nel seguente AST:

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

Ora deve digitare controllare l'AST. inizia dal primo tipo controllando l' =operatore. Per prima cosa controlla il lato sinistro dell'operatore. Vede che la variabile aè dichiarata come un numero intero. Quindi ora deve verificare che l'espressione sul lato destro restituisca un numero intero.

Capisco come questo potrebbe essere fatto se l'espressione fosse solo un singolo valore, come 1o 'a'. Ma come si farebbe per espressioni con più valori e operandi - un'espressione complessa - come quella sopra? Per determinare correttamente il valore dell'espressione, sembra che il correttore di tipi debba effettivamente eseguire l'espressione stessa e registrare il risultato. Ma questo sembra ovviamente vanificare lo scopo di separare le fasi di compilazione ed esecuzione.

L'unico altro modo in cui immagino che ciò possa essere fatto è controllare ricorsivamente la foglia di ogni sottoespressione nell'AST e verificare che tutti i tipi di foglia corrispondano al tipo di operatore previsto. Quindi, a partire =dall'operatore, il controllo del tipo eseguirà quindi la scansione di tutti gli AST sul lato sinistro e verificherebbe che i fogli sono tutti numeri interi. Lo ripeterebbe quindi per ciascun operatore nella sottoespressione.

Ho provato a cercare l'argomento nella mia copia di "The Dragon Book" , ma non sembra entrare nei dettagli, e semplicemente ribadisce ciò che già conosco.

Qual è il solito metodo usato quando un compilatore sta controllando il tipo di espressioni con molti operatori e operandi? Sono utilizzati alcuni dei metodi che ho menzionato sopra? In caso contrario, quali sono i metodi e come funzionano esattamente?


8
C'è un modo ovvio e semplice per verificare il tipo di espressione. Faresti meglio a dirci cosa ti rende "disgustoso".
gnasher729,

12
Il solito metodo è il "secondo metodo": il compilatore deduce il tipo di espressione complessa dai tipi delle sue sottoespressioni. Questo era il punto principale della semantica denotazionale e la maggior parte dei sistemi di tipi creati fino ad oggi.
Joker_vD,

5
I due approcci possono produrre comportamenti diversi: l'approccio top-down double a = 7/2 cercherebbe di interpretare il lato destro come doppio, quindi cercherebbe di interpretare il numeratore e il denominatore come doppio e di convertirli se necessario; di conseguenza a = 3.5. Il bottom-up eseguirà la divisione di interi e convertirà solo sull'ultimo passaggio (assegnazione), quindi a = 3.0.
Hagen von Eitzen,

3
Nota che l'immagine del tuo AST non corrisponde alla tua espressione int a = 1 + 2 - 3 * 4 - 5ma aint a = 5 - ((4*3) - (1+2))
Basile Starynkevitch

22
È possibile "eseguire" l'espressione sui tipi anziché sui valori; ad esempio int + intdiventa int.

Risposte:


14

La ricorsione è la risposta, ma si scende in ogni sottostruttura prima di gestire l'operazione:

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

a forma di albero:

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

L'inferenza del tipo avviene prima camminando sul lato sinistro, quindi sul lato destro e quindi gestendo l'operatore non appena si inferiscono i tipi di operandi:

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

-> scendi in lhs

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

-> infer a. aè noto per essere int. Siamo tornati nel assignnodo ora:

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

-> scendiamo in rhs, poi in lhs degli operatori interni fino a quando non colpiamo qualcosa di interessante

(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))

-> deduci il tipo di 1, che è int, e torna al genitore

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

-> vai in rhs

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

-> deduci il tipo di 2, che è int, e torna al genitore

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

-> deduci il tipo di add(int, int), che è int, e torna al genitore

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

-> scendi nel rhs

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

ecc., fino alla fine

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

Se il compito stesso sia anche un'espressione con un tipo dipende dalla tua lingua.

Il takeaway importante: per determinare il tipo di qualsiasi nodo operatore nella struttura, devi solo guardare i suoi figli immediati, che devono già avere un tipo assegnato a loro.


43

Qual è il metodo solitamente usato quando un compilatore sta controllando il tipo di espressioni con molti operatori e operandi.

Leggi WikiPages sul sistema di tipo e di inferenza di tipo e sul sistema di tipo Hindley-Milner , che utilizza l'unificazione . Leggi anche su semantica denotazionale e semantica operativa .

Il controllo del tipo può essere più semplice se:

  • tutte le variabili come asono esplicitamente dichiarate con un tipo. Questo è come C o Pascal o C ++ 98, ma non come C ++ 11 che ha qualche tipo di inferenza con auto.
  • tutti i valori letterali come 1, 2o 'c'hanno un tipo intrinseco: un letterale int ha sempre un tipo int, un letterale di carattere ha sempre un tipo char,….
  • le funzioni e gli operatori non sono sovraccarichi, ad esempio l' +operatore ha sempre il tipo (int, int) -> int. C ha un sovraccarico per gli operatori ( +funziona con tipi interi con segno e senza segno e per i doppi) ma nessun sovraccarico di funzioni.

In base a questi vincoli, potrebbe essere sufficiente un algoritmo di decorazione di tipo AST ricorsivo dal basso verso l'alto (questo si preoccupa solo di tipi , non di valori concreti, quindi è un approccio di compilazione in tempo):

  • Per ogni ambito, si mantiene una tabella per i tipi di tutte le variabili visibili (chiamato ambiente). Dopo una dichiarazione int a, aggiungere la voce a: intalla tabella.

  • La tipizzazione delle foglie è il banale caso di base della ricorsione: il tipo di letterali come 1è già noto e il tipo di variabili come apuò essere cercato nell'ambiente.

  • Per digitare un'espressione con alcuni operatori e operandi in base ai tipi precedentemente calcolati degli operandi (sottoespressioni nidificate), utilizziamo la ricorsione sugli operandi (quindi digitiamo prima queste sottoespressioni) e seguiamo le regole di digitazione relative all'operatore .

Quindi nel tuo esempio, 4 * 3e 1 + 2sono digitati intperché 4& 3e 1& 2sono stati precedentemente digitati inte le tue regole di battitura dicono che la somma o il prodotto di due- ints è un int, e così via (4 * 3) - (1 + 2).

Quindi leggi il libro Tipi e linguaggi di programmazione di Pierce . Consiglio di imparare un po 'di Ocaml e λ-calcolo

Per linguaggi più tipicamente dinamici (tipo Lisp) leggi anche Lisp In Small Pieces di Queinnec

Leggi anche il libro Pragmatics sui linguaggi di programmazione di Scott

A proposito, non puoi avere un codice di digitazione indipendente dalla lingua, perché il sistema di tipi è una parte essenziale della semantica della lingua .


2
In che modo C ++ 11 autonon è più semplice? Senza di esso devi capire il tipo sul lato destro, quindi vedere se c'è una corrispondenza o una conversione con il tipo sul lato sinistro. Con autote basta capire il tipo di lato destro e il gioco è fatto.
nwp,

3
@nwp L'idea generale delle definizioni delle variabili C ++ auto, C # vare Go :=è molto semplice: digitare check sul lato destro della definizione. Il tipo risultante è il tipo della variabile sul lato sinistro. Ma il diavolo è nei dettagli. Ad esempio, le definizioni C ++ possono essere autoreferenziali, quindi è possibile fare riferimento alla variabile dichiarata su rhs, ad es int i = f(&i). Se il tipo di iviene dedotto, l'algoritmo sopra fallirà: è necessario conoscere il tipo di iinferire il tipo di i. Invece, avresti bisogno di un'inferenza di tipo HM completa con variabili di tipo.
Amon,

13

In C (e francamente nella maggior parte dei linguaggi tipicamente statici basati su C) ogni operatore può essere visto come zucchero sintattico per una chiamata di funzione.

Quindi la tua espressione può essere riscritta come:

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

Quindi verrà avviata la risoluzione di sovraccarico e deciderà che ogni funzione è del tipo (int, int)o (const int&, const int&).

In questo modo la risoluzione del tipo è facile da capire e seguire e (cosa ancora più importante) facile da implementare. Le informazioni sui tipi fluiscono solo in 1 modo (dalle espressioni interne verso l'esterno).

Questo è il motivo per cui double x = 1/2;risulterà x == 0perché 1/2viene valutato come espressione int.


6
Quasi vero per C, dove +non viene gestito come le chiamate di funzione (poiché ha una digitazione diversa per doublee per intoperandi)
Basile Starynkevitch

2
@BasileStarynkevitch: E 'implementato come una serie di funzioni sovraccaricate: operator+(int,int), operator+(double,double), operator+(char*,size_t), ecc Il parser deve solo tenere traccia di cui uno è selezionato.
Mooing Duck,

3
@aschepler Nessuno stava suggerendo che a livello di sorgente e di specifica, C avesse effettivamente funzioni sovraccaricate o funzioni operatore
cat

1
Ovviamente no. Solo sottolineando che nel caso di un parser C, una "chiamata di funzione" è qualcos'altro che dovresti affrontare, che in realtà non ha molto in comune con "operatori come chiamate di funzione" come descritto qui. In effetti, in C capire il tipo di f(a,b)è un po 'più facile che capire il tipo di a+b.
aschepler,

2
Qualsiasi compilatore C ragionevole ha più fasi. Vicino al fronte (dopo il preprocessore) trovi il parser, che crea un AST. Qui è abbastanza chiaro che gli operatori non sono chiamate di funzione. Ma nella generazione del codice, non ti interessa più quale costrutto del linguaggio abbia creato un nodo AST. Le proprietà del nodo stesso determinano il modo in cui il nodo viene trattato. In particolare, + potrebbe benissimo essere una chiamata di funzione - questo accade comunemente su piattaforme con matematica a virgola mobile emulata. La decisione di utilizzare la matematica FP emulata avviene nella generazione del codice; non è necessaria alcuna differenza AST.
MSalters il

6

Concentrandoti sul tuo algoritmo, prova a modificarlo dal basso verso l'alto. Conosci il tipo di variabili e costanti pf; contrassegnare il nodo con l'operatore con il tipo di risultato. Lascia che la foglia determini il tipo di operatore, anche il contrario della tua idea.


6

In realtà è abbastanza facile, purché tu pensi +che sia una varietà di funzioni piuttosto che un singolo concetto.

    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

Durante la fase di analisi del lato destro, il parser recupera 1, sa che è un int, quindi analizza +e lo memorizza come "nome di funzione non risolto", quindi analizza il 2, sa che è un inte quindi lo restituisce nello stack. Il +nodo funzione ora conosce entrambi i tipi di parametri, quindi può risolvere +in int operator+(int, int), quindi ora conosce il tipo di questa sottoespressione e il parser continua nel suo modo allegro.

Come puoi vedere, una volta che l'albero è completamente costruito, ogni nodo, comprese le chiamate di funzione, ne conosce i tipi. Questo è fondamentale perché consente funzioni che restituiscono tipi diversi rispetto ai loro parametri.

char* ptr = itoa(3);

Qui l'albero è:

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

4

La base per il controllo del tipo non è ciò che fa il compilatore, è ciò che definisce la lingua.

Nel linguaggio C, ogni operando ha un tipo. "abc" ha il tipo "array di const char". 1 ha il tipo "int". 1L ha il tipo "lungo". Se xey sono espressioni, allora ci sono regole per il tipo di x + y e così via. Quindi il compilatore ovviamente deve seguire le regole della lingua.

Su linguaggi moderni come Swift, le regole sono molto più complicate. Alcuni casi sono semplici come in C. Altri casi, il compilatore vede un'espressione, è stato detto in anticipo quale tipo deve avere l'espressione e determina i tipi di sottoespressioni in base a quello. Se xey sono variabili di tipi diversi e viene assegnata un'espressione identica, tale espressione potrebbe essere valutata in modo diverso. Ad esempio, l'assegnazione di 12 * (2/3) assegnerà 8.0 a un doppio e 0 a un int. E ci sono casi in cui il compilatore sa che due tipi sono correlati e capisce su quali tipi si basano.

Esempio rapido:

var x: Double
var y: Int

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

print (x, y)

stampa "8.0, 0".

Nell'assegnazione x = 12 * (2/3): il lato sinistro ha un tipo noto Double, quindi il lato destro deve avere il tipo Double. Esiste un solo sovraccarico per l'operatore "*" che restituisce Double, ovvero Double * Double -> Double. Pertanto 12 deve avere il tipo Double, così come 2 / 3. 12 supporta il protocollo "IntegerLiteralConvertible". Double ha un inizializzatore che accetta un argomento di tipo "IntegerLiteralConvertible", quindi 12 viene convertito in Double. 2/3 deve avere il tipo Double. Esiste un solo sovraccarico per l'operatore "/" che restituisce Double, ovvero Double / Double -> Double. 2 e 3 vengono convertiti in Double. Il risultato di 2/3 è 0,6666666. Il risultato di 12 * (2/3) è 8,0. 8.0 è assegnato a x.

Nell'assegnazione y = 12 * (2/3), y sul lato sinistro ha il tipo Int, quindi il lato destro deve avere il tipo Int, quindi 12, 2, 3 vengono convertiti in Int con il risultato 2/3 = 0, 12 * (2/3) = 0.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.