Progettazione del compilatore - Analisi della sintassi

L'analisi della sintassi o l'analisi è la seconda fase di un compilatore. In questo capitolo apprenderemo i concetti di base usati nella costruzione di un parser.

Abbiamo visto che un analizzatore lessicale può identificare i token con l'aiuto di espressioni regolari e regole di pattern. Ma un analizzatore lessicale non può controllare la sintassi di una data frase a causa dei limiti delle espressioni regolari. Le espressioni regolari non possono controllare i token di bilanciamento, come le parentesi. Pertanto, questa fase utilizza la grammatica senza contesto (CFG), riconosciuta dagli automi push-down.

CFG, d'altra parte, è un superset di Regular Grammar, come illustrato di seguito:

Ciò implica che ogni grammatica regolare è anche priva di contesto, ma esistono alcuni problemi che esulano dallo scopo della grammatica regolare. CFG è uno strumento utile per descrivere la sintassi dei linguaggi di programmazione.

Grammatica senza contesto

In questa sezione, vedremo prima la definizione di grammatica libera dal contesto e introdurremo le terminologie utilizzate nella tecnologia di analisi.

Una grammatica senza contesto ha quattro componenti:

  • Un insieme di non-terminals(V). I non terminali sono variabili sintattiche che denotano insiemi di stringhe. I non terminali definiscono insiemi di stringhe che aiutano a definire il linguaggio generato dalla grammatica.

  • Un set di gettoni, noto come terminal symbols(Σ). I terminali sono i simboli di base da cui sono formate le stringhe.

  • Un insieme di productions(P). Le produzioni di una grammatica specificano il modo in cui i terminali e i non terminali possono essere combinati per formare stringhe. Ogni produzione è composta da un filenon-terminal chiamato il lato sinistro della produzione, una freccia e una sequenza di gettoni e / o on- terminals, chiamato il lato destro della produzione.

  • Uno dei non terminali è designato come il simbolo di inizio (S); da dove inizia la produzione.

Le stringhe sono derivate dal simbolo di inizio sostituendo ripetutamente un non terminale (inizialmente il simbolo di inizio) dal lato destro di una produzione, per quel non terminale.

Esempio

Prendiamo il problema del linguaggio palindromo, che non può essere descritto mediante l'espressione regolare. Cioè, L = {w | w = w R } non è una lingua normale. Ma può essere descritto mediante CFG, come illustrato di seguito:

G = ( V, Σ, P, S )

Dove:

V = { Q, Z, N }
Σ = { 0, 1 }
P = { Q → Z | Q → N | Q → ℇ | Z → 0Q0 | N → 1Q1 }
S = { Q }

Questa grammatica descrive il linguaggio palindromo, come ad esempio: 1001, 11100111, 00100, 1010101, 11111, ecc.

Analizzatori di sintassi

Un analizzatore di sintassi o parser prende l'input da un analizzatore lessicale sotto forma di flussi di token. Il parser analizza il codice sorgente (flusso di token) rispetto alle regole di produzione per rilevare eventuali errori nel codice. L'output di questa fase è un fileparse tree.

In questo modo, il parser esegue due attività, ovvero analizzare il codice, cercare errori e generare un albero di analisi come output della fase.

Ci si aspetta che i parser analizzino l'intero codice anche se nel programma sono presenti alcuni errori. I parser utilizzano strategie di ripristino degli errori, che impareremo più avanti in questo capitolo.

Derivazione

Una derivazione è fondamentalmente una sequenza di regole di produzione, al fine di ottenere la stringa di input. Durante l'analisi, prendiamo due decisioni per una forma di input sentenziale:

  • Decidere il non terminale che deve essere sostituito.
  • Decidere la regola di produzione, con la quale verrà sostituito il non terminale.

Per decidere quale non terminale sostituire con la regola di produzione, possiamo avere due opzioni.

Derivazione più a sinistra

Se la forma sentenziale di un input viene scansionata e sostituita da sinistra a destra, viene chiamata derivazione più a sinistra. La forma sentenziale derivata dalla derivazione più a sinistra è chiamata la forma sentenziale a sinistra.

Derivazione più a destra

Se scansioniamo e sostituiamo l'input con regole di produzione, da destra a sinistra, è noto come derivazione più a destra. La forma sentenziale derivata dalla derivazione più a destra è chiamata la forma sentenziale a destra.

Example

Regole di produzione:

E → E + E
E → E * E
E → id

Stringa di input: id + id * id

La derivazione più a sinistra è:

E → E * E
E → E + E * E
E → id + E * E
E → id + id * E
E → id + id * id

Si noti che il non terminale più a sinistra viene sempre elaborato per primo.

La derivazione più a destra è:

E → E + E
E → E + E * E
E → E + E * id
E → E + id * id
E → id + id * id

Parse Tree

Un albero di analisi è una rappresentazione grafica di una derivazione. È conveniente vedere come le stringhe vengono derivate dal simbolo di inizio. Il simbolo di inizio della derivazione diventa la radice dell'albero di analisi. Vediamolo con un esempio tratto dall'ultimo argomento.

Prendiamo la derivazione più a sinistra di a + b * c

La derivazione più a sinistra è:

E → E * E
E → E + E * E
E → id + E * E
E → id + id * E
E → id + id * id

Passo 1:

E → E * E

Passo 2:

E → E + E * E

Passaggio 3:

E → id + E * E

Passaggio 4:

E → id + id * E

Passaggio 5:

E → id + id * id

In un albero di analisi:

  • Tutti i nodi foglia sono terminali.
  • Tutti i nodi interni non sono terminali.
  • L'attraversamento in ordine fornisce la stringa di input originale.

Un albero di analisi descrive l'associatività e la precedenza degli operatori. Il sottoalbero più profondo viene attraversato per primo, quindi l'operatore in quel sottoalbero ha la precedenza sull'operatore che si trova nei nodi padre.

Ambiguità

Si dice che una grammatica G sia ambigua se ha più di un albero di analisi (derivazione sinistra o destra) per almeno una stringa.

Example

E → E + E
E → E – E
E → id

Per la stringa id + id - id, la grammatica precedente genera due alberi di analisi:

Si dice che la lingua generata da una grammatica ambigua sia inherently ambiguous. L'ambiguità grammaticale non è buona per la costruzione di un compilatore. Nessun metodo può rilevare e rimuovere automaticamente l'ambiguità, ma può essere rimossa riscrivendo l'intera grammatica senza ambiguità o impostando e rispettando i vincoli di associatività e di precedenza.

Associatività

Se un operando ha operatori su entrambi i lati, il lato da cui l'operatore prende questo operando è deciso dall'associatività di quegli operatori. Se l'operazione è associativa a sinistra, l'operando verrà preso dall'operatore di sinistra o se l'operazione è associativa a destra, l'operatore di destra prenderà l'operando.

Example

Operazioni come addizione, moltiplicazione, sottrazione e divisione sono associative a sinistra. Se l'espressione contiene:

id op id op id

sarà valutato come:

(id op id) op id

Ad esempio, (id + id) + id

Operazioni come l'elevazione a potenza sono associative rette, ovvero l'ordine di valutazione nella stessa espressione sarà:

id op (id op id)

Ad esempio, id ^ (id ^ id)

Precedenza

Se due operatori diversi condividono un operando comune, la precedenza degli operatori decide quale utilizzerà l'operando. Cioè, 2 + 3 * 4 può avere due alberi di analisi diversi, uno corrispondente a (2 + 3) * 4 e un altro corrispondente a 2+ (3 * 4). Impostando la precedenza tra gli operatori, questo problema può essere facilmente rimosso. Come nell'esempio precedente, matematicamente * (moltiplicazione) ha la precedenza su + (addizione), quindi l'espressione 2 + 3 * 4 sarà sempre interpretata come:

2 + (3 * 4)

Questi metodi riducono le possibilità di ambiguità in una lingua o nella sua grammatica.

Ricorsione a sinistra

Una grammatica diventa ricorsiva a sinistra se ha una "A" non terminale la cui derivazione contiene la "A" stessa come simbolo più a sinistra. La grammatica ricorsiva a sinistra è considerata una situazione problematica per i parser top-down. I parser top-down iniziano ad analizzare dal simbolo Start, che di per sé non è terminale. Quindi, quando il parser incontra lo stesso non terminale nella sua derivazione, diventa difficile per lui giudicare quando interrompere l'analisi del non terminale sinistro e entra in un ciclo infinito.

Example:

(1) A => Aα | β

(2) S => Aα | β 
    A => Sd

(1) è un esempio di ricorsione sinistra immediata, dove A è un qualsiasi simbolo non terminale e α rappresenta una stringa di non terminali.

(2) è un esempio di ricorsione indiretta a sinistra.

Un parser top-down analizzerà prima il LA, che a sua volta produrrà una stringa composta da A stesso e il parser potrebbe entrare in un ciclo per sempre.

Rimozione della ricorsione a sinistra

Un modo per rimuovere la ricorsione a sinistra consiste nell'usare la seguente tecnica:

La produzione

A => Aα | β

viene convertito nelle seguenti produzioni

A => βA'
A'=> αA' | ε

Ciò non influisce sulle stringhe derivate dalla grammatica, ma rimuove la ricorsione a sinistra immediata.

Il secondo metodo consiste nell'utilizzare il seguente algoritmo, che dovrebbe eliminare tutte le ricorsioni a sinistra dirette e indirette.

START

Arrange non-terminals in some order like A1, A2, A3,…, An

   for each i from 1 to n
      {
      for each j from 1 to i-1
         {
         replace each production of form Ai ⟹Aj
         with Ai ⟹ δ1  | δ2 | δ3 |…|  
         where Aj ⟹ δ1 | δ2|…| δn  are current Aj productions
         }
      }
   eliminate immediate left-recursion
   
END

Example

Il set di produzione

S => Aα | β 
A => Sd

dopo aver applicato l'algoritmo di cui sopra, dovrebbe diventare

S => Aα | β 
A => Aαd | βd

e quindi, rimuovere la ricorsione sinistra immediata utilizzando la prima tecnica.

A  => βdA'
A' => αdA' | ε

Ora nessuna delle produzioni ha una ricorsione a sinistra diretta o indiretta.

Factoring sinistro

Se più di una regola di produzione grammaticale ha una stringa di prefisso comune, il parser top-down non può scegliere quale produzione deve prendere per analizzare la stringa in mano.

Example

Se un parser top-down incontra una produzione come

A ⟹ αβ | α | …

Quindi non può determinare quale produzione seguire per analizzare la stringa poiché entrambe le produzioni iniziano dallo stesso terminale (o non terminale). Per rimuovere questa confusione, utilizziamo una tecnica chiamata factoring a sinistra.

Il factoring a sinistra trasforma la grammatica per renderla utile per i parser top-down. In questa tecnica, facciamo una produzione per ogni prefisso comune e il resto della derivazione viene aggiunto da nuove produzioni.

Example

Le produzioni di cui sopra possono essere scritte come

A => αA'
A'=> β |  | …

Ora il parser ha una sola produzione per prefisso che rende più facile prendere decisioni.

Primo e seguito di serie

Una parte importante della costruzione della tabella parser è creare i primi e i seguenti insiemi. Questi insiemi possono fornire la posizione effettiva di qualsiasi terminale nella derivazione. Questo viene fatto per creare la tabella di analisi in cui la decisione di sostituire T [A, t] = α con qualche regola di produzione.

Primo set

Questo set viene creato per sapere quale simbolo di terminale è derivato nella prima posizione da un non terminale. Per esempio,

α → t β

Cioè α deriva t (terminale) nella primissima posizione. Quindi, t ∈ PRIMO (α).

Algoritmo per il calcolo del primo set

Guarda la definizione di PRIMO (α) insieme:

  • se α è un terminale, allora PRIMO (α) = {α}.
  • se α è un non terminale e α → ℇ è una produzione, allora PRIMO (α) = {ℇ}.
  • se α è un non terminale e α → 1 2 3… ne ogni FIRST () contiene t allora t è in FIRST (α).

Il primo set può essere visto come:

Segui Set

Allo stesso modo, calcoliamo quale simbolo terminale segue immediatamente un α non terminale nelle regole di produzione. Non consideriamo cosa può generare il non terminale ma vediamo invece quale sarebbe il prossimo simbolo di terminale che segue le produzioni di un non terminale.

Algoritmo per il calcolo del set di follow:

  • se α è un simbolo di inizio, allora FOLLOW () = $

  • se α è un non terminale e ha una produzione α → AB, allora FIRST (B) è in FOLLOW (A) eccetto ℇ.

  • se α è un non terminale e ha una produzione α → AB, dove B ℇ, allora FOLLOW (A) è in FOLLOW (α).

Follow set può essere visto come: FOLLOW (α) = {t | S * αt *}

Limitazioni degli analizzatori di sintassi

Gli analizzatori di sintassi ricevono i loro input, sotto forma di token, dagli analizzatori lessicali. Gli analizzatori lessicali sono responsabili della validità di un token fornito dall'analizzatore di sintassi. Gli analizzatori di sintassi presentano i seguenti inconvenienti:

  • non può determinare se un token è valido,
  • non può determinare se un token viene dichiarato prima di essere utilizzato,
  • non può determinare se un token è inizializzato prima di essere utilizzato,
  • non può determinare se un'operazione eseguita su un tipo di token è valida o meno.

Questi compiti sono svolti dall'analizzatore semantico, che studieremo in Analisi semantica.