In che modo i linguaggi di programmazione definiscono le funzioni?


28

In che modo i linguaggi di programmazione definiscono e salvano funzioni / metodi? Sto creando un linguaggio di programmazione interpretato in Ruby e sto cercando di capire come implementare la dichiarazione di funzione.

La mia prima idea è quella di salvare il contenuto della dichiarazione in una mappa. Ad esempio, se ho fatto qualcosa di simile

def a() {
    callSomething();
    x += 5;
}

Quindi aggiungerei una voce nella mia mappa:

{
    'a' => 'callSomething(); x += 5;'
}

Il problema è che sarebbe diventato ricorsivo, perché avrei dovuto chiamare il mio parsemetodo sulla stringa, che poi avrebbe richiamato di parsenuovo quando si fosse verificato doSomething, e alla fine avrei esaurito lo spazio dello stack.

Quindi, come gestiscono le lingue interpretate?


Oh, e questo è il mio primo post su Programmers.SE, quindi per favore informami se sto facendo qualcosa di sbagliato o questo è fuori tema. :)
Doorknob,

In passato li ho archiviati tutti in linea nei miei token e le chiamate di funzione sono solo salti a un offset specifico (molto simile alle etichette in Assembly). Stai tokenizzando la sceneggiatura? O analizzare le stringhe ogni volta?
Simon Whitehead,

@SimonWhitehead Ho diviso la stringa in token e quindi ho analizzato ciascun token separatamente.
Manopola

3
Se non conosci la progettazione e l'implementazione del linguaggio di programmazione, ti consigliamo di consultare parte della letteratura sull'argomento. Il più popolare è il "Libro dei draghi": en.wikipedia.org/wiki/… , ma ci sono altri testi più concisi che sono anche molto buoni. Ad esempio, l'implementazione dei linguaggi di programmazione di Aarne Ranta può essere ottenuta gratuitamente qui: bit.ly/15CF6gC .
evilcandybag,

1
@ddyer Grazie! Ho cercato su Google un interprete lisp in diverse lingue e questo mi ha davvero aiutato. :)
Doorknob,

Risposte:


31

Sarei corretto supponendo che la tua funzione "analisi" non solo analizza il codice ma lo esegue anche allo stesso tempo? Se si desidera farlo in questo modo, invece di archiviare i contenuti di una funzione nella propria mappa, memorizzare la posizione della funzione.

Ma c'è un modo migliore. Ci vuole un po 'più di sforzo in anticipo, ma produce risultati molto migliori all'aumentare della complessità: utilizzare un albero di sintassi astratto.

L'idea di base è che analizzi il codice solo una volta, mai. Quindi hai un set di tipi di dati che rappresentano operazioni e valori e ne fai un albero, in questo modo:

def a() {
    callSomething();
    x += 5;
}

diventa:

Function Definition: [
   Name: a
   ParamList: []
   Code:[
      Call Operation: [
         Routine: callSomething
         ParamList: []
      ]
      Increment Operation: [
         Operand: x
         Value: 5
      ]
   ]
]

(Questa è solo una rappresentazione testuale della struttura di un ipotetico AST. Probabilmente l'albero reale non sarebbe in forma di testo.) Comunque, analizzi il tuo codice in un AST, e poi esegui direttamente il tuo interprete sull'AST, oppure utilizzare un secondo passaggio ("generazione del codice") per trasformare l'AST in una forma di output.

Nel caso della tua lingua, quello che probabilmente faresti è avere una mappa che associ i nomi delle funzioni alle AST, invece dei nomi delle funzioni alle stringhe delle funzioni.


Va bene, ma il problema è ancora lì: usa la ricorsione. Se lo faccio, finirò lo spazio per lo stack.
Doorknob,

3
@Doorknob: Cosa usa in particolare la ricorsione? Qualsiasi linguaggio di programmazione strutturato a blocchi (che è ogni linguaggio moderno a un livello superiore rispetto a ASM) è intrinsecamente basato sugli alberi e quindi ricorsivo in natura. In quale aspetto specifico sei preoccupato di ottenere overflow dello stack?
Mason Wheeler,

1
@Doorknob: Sì, è una proprietà intrinseca di qualsiasi lingua, anche se è compilata in codice macchina. (Lo stack di chiamate è una manifestazione di questo comportamento.) In realtà sono un collaboratore di un sistema di script che funziona come ho descritto. Unisciti a me in chat su chat.stackexchange.com/rooms/10470/… e discuterò alcune tecniche per un'interpretazione efficiente e minimizzare l'impatto sulla dimensione dello stack con te. :)
Mason Wheeler,

2
@Doorknob: qui non c'è nessun problema di ricorsione perché la chiamata di funzione nell'AST fa riferimento alla funzione per nome , non ha bisogno di un riferimento alla funzione effettiva . Se stavi compilando il codice macchina, alla fine avresti bisogno dell'indirizzo della funzione, motivo per cui la maggior parte dei compilatori effettua più passaggi. Se si desidera disporre di un compilatore a passaggio singolo, sono necessarie "dichiarazioni in avanti" di tutte le funzioni in modo che il compilatore possa assegnare indirizzi in anticipo. I compilatori Bytecode non si preoccupano nemmeno di questo, il jitter gestisce la ricerca dei nomi.
Aaronaught,

5
@Doorknob: è davvero ricorsivo. E sì, se il tuo stack ha solo 16 voci, non riuscirai ad analizzare (((((((((((((((( x ))))))))))))))))). In realtà, le pile possono essere molto più grandi e la complessità grammaticale del codice reale è piuttosto limitata. Certamente se quel codice deve essere leggibile dall'uomo.
MSalters,

4

Non dovresti chiamare parse dopo aver visto callSomething()(suppongo che tu intendessi callSomethingpiuttosto che doSomething). La differenza tra ae callSomethingè che una è una definizione di metodo mentre l'altra è una chiamata di metodo.

Quando vedi una nuova definizione, ti consigliamo di fare dei controlli per assicurarti di poter aggiungere quella definizione, quindi:

  • Controlla se la funzione non esiste già con la stessa firma
  • Accertarsi che la dichiarazione del metodo sia eseguita nel campo di applicazione appropriato (vale a dire che i metodi possono essere dichiarati all'interno di altre dichiarazioni di metodo?)

Supponendo che questi controlli superino, puoi aggiungerlo alla tua mappa e iniziare a controllare i contenuti di quel metodo.

Quando trovi una chiamata di metodo simile callSomething(), devi eseguire i seguenti controlli:

  • Esiste callSomethingnella tua mappa?
  • Viene chiamato correttamente (il numero di argomenti corrisponde alla firma che hai trovato)?
  • Gli argomenti sono validi (se vengono utilizzati nomi di variabili, vengono dichiarati? È possibile accedervi in ​​questo ambito?)?
  • CallSomething può essere chiamato da dove ti trovi (è privato, pubblico, protetto?)?

Se trovi che callSomething()va bene, a questo punto quello che vorresti fare davvero dipende da come desideri affrontarlo. A rigor di termini, una volta che sai che una tale chiamata va bene a questo punto, puoi solo salvare il nome del metodo e gli argomenti senza entrare in ulteriori dettagli. Quando esegui il tuo programma, invocherai il metodo con gli argomenti che dovresti avere in fase di esecuzione.

Se si desidera andare oltre, è possibile salvare non solo la stringa ma un collegamento al metodo effettivo. Questo sarebbe più efficiente, ma se devi gestire la memoria, può diventare confuso. Vorrei raccomandare semplicemente di tenere la stringa all'inizio. Successivamente puoi provare a ottimizzare.

Si noti che tutto questo presuppone che tu abbia lasciato il tuo programma, il che significa che hai riconosciuto tutti i token nel tuo programma e sai quali sono . Questo non vuol dire che sai se hanno ancora senso insieme, che è la fase di analisi. Se non sai ancora quali sono i token, ti suggerisco di concentrarti innanzitutto sull'ottenere tali informazioni.

Spero che aiuti! Benvenuti in Programmers SE!


2

Leggendo il tuo post, ho notato due domande nella tua domanda. Il più importante è come analizzare. Ci sono molti tipi di parser (per esempio ricorsivo discesa parser , LR parser , Packrat parser ) e generatori di parser (per esempio GNU bisonti , ANTLR ) è possibile utilizzare per attraversare un programma testuale "ricorsiva" data una grammatica (esplicita o implicita).

La seconda domanda riguarda il formato di archiviazione per le funzioni. Quando non si esegue la traduzione diretta dalla sintassi , si crea una rappresentazione intermedia del programma, che può essere un albero di sintassi astratto o un linguaggio intermedio personalizzato, al fine di eseguire ulteriori elaborazioni con esso (compilare, trasformare, eseguire, scrivere su un file, ecc.).


1

Da un punto di vista generico, la definizione di una funzione è poco più di un'etichetta, o segnalibro, nel codice. La maggior parte degli altri operatori di loop, scope e condizionali sono simili; sono stand-in per un comando "jump" o "goto" di base nei livelli inferiori di astrazione. Una chiamata di funzione si riduce sostanzialmente ai seguenti comandi di computer di basso livello:

  • Concatena i dati di tutti i parametri, oltre a un puntatore all'istruzione successiva della funzione corrente, in una struttura nota come "call stack frame".
  • Inserire questo frame nello stack di chiamate.
  • Passa all'offset di memoria della prima riga del codice della funzione.

Un'istruzione "return" o simile farà quindi quanto segue:

  • Carica il valore da restituire in un registro.
  • Carica il puntatore sul chiamante in un registro.
  • Pop il frame dello stack corrente.
  • Vai al puntatore del chiamante.

Le funzioni, quindi, sono semplicemente astrazioni in una specifica del linguaggio di livello superiore, che consentono agli esseri umani di organizzare il codice in un modo più gestibile e intuitivo. Se compilato in un assembly o in un linguaggio intermedio (JIL, MSIL, ILX) e sicuramente reso come codice macchina, quasi tutte queste astrazioni scompaiono.

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.