Perché i programmi usano stack di chiamate, se è possibile incorporare le chiamate di funzione nidificate?


33

Perché il compilatore non deve prendere un programma come questo:

function a(b) { return b^2 };
function c(b) { return a(b) + 5 };

e convertilo in un programma come questo:

function c(b) { return b^2 + 5 };

eliminando così la necessità del computer di ricordare l'indirizzo di ritorno di c (b)?

Suppongo che lo spazio su disco rigido e la RAM necessari per memorizzare il programma e supportarne la compilazione (rispettivamente) siano il motivo per cui utilizziamo gli stack di chiamate. È corretto?


30
Guarda cosa succede se lo fai su un programma con dimensioni significative. In particolare, le funzioni sono chiamate da più di un posto.
user253751

10
Inoltre, a volte il compilatore non sa quale funzione viene chiamata! Esempio sciocco:window[prompt("Enter function name","")]()
user253751

26
Come si implementa function(a)b { if(b>0) return a(b-1); }senza uno stack?
pjc50,

8
Dov'è la relazione con la programmazione funzionale?
mastov,

14
@ pjc50: è ricorsivo di coda, quindi il compilatore lo traduce in un ciclo con un mutabile b. Tuttavia, non tutte le funzioni ricorsive possono eliminare la ricorsione, e anche quando la funzione può in linea di principio, il compilatore potrebbe non essere abbastanza intelligente da farlo.
Steve Jessop,

Risposte:


75

Questo si chiama "inline" e molti compilatori lo fanno come strategia di ottimizzazione nei casi in cui ha senso.

Nel tuo esempio particolare, questa ottimizzazione risparmierebbe spazio e tempo di esecuzione. Ma se la funzione fosse chiamata in più punti del programma (non insolito!), Aumenterebbe la dimensione del codice, quindi la strategia diventa più dubbia. (E ovviamente se una funzione si chiamasse direttamente o indirettamente, sarebbe impossibile incorporarla, da allora il codice diventerebbe infinito.)

E ovviamente è possibile solo per funzioni "private". Le funzioni esposte per i chiamanti esterni non possono essere ottimizzate, almeno non nelle lingue con collegamento dinamico.


7
@Blrfl: i compilatori moderni in realtà non hanno più bisogno di definizioni nell'intestazione; possono essere integrati in tutte le unità di traduzione. Ciò richiede tuttavia un linker decente. Le definizioni nei file di intestazione sono una soluzione alternativa per i linker stupidi.
Salterio,

3
"Le funzioni esposte per i chiamanti esterni non possono essere ottimizzate" - la funzione deve esistere, ma qualsiasi sito di chiamata dato ad esso (o nel proprio codice, o se hanno la fonte, i chiamanti esterni) può essere integrato.
Casuale 832

14
Caspita, 28 voti positivi per una risposta che non menziona nemmeno il motivo per cui è impossibile inserire tutto: la ricorsione.
mastov,

3
@R ..: LTO è l'ottimizzazione del tempo LINK, non l'ottimizzazione del tempo LOAD.
Salterio,

2
@immibis: Ma se lo stack esplicito viene introdotto dal compilatore, lo stack è lo stack di chiamate.
user2357112 supporta Monica il

51

Ci sono due parti alla tua domanda: perché avere più funzioni (invece di sostituire le chiamate di funzione con la loro definizione) e perché implementare quelle funzioni con stack di chiamate invece di allocare staticamente i loro dati da qualche altra parte?

Il primo motivo è la ricorsione. Non solo il tipo "oh facciamo una nuova funzione per ogni singolo elemento in questo elenco", anche il tipo modesto in cui hai forse due chiamate di una funzione attive contemporaneamente, con molte altre funzioni tra di loro. È necessario mettere le variabili locali su uno stack per supportare questo, e non è possibile incorporare funzioni ricorsive in generale.

Quindi c'è un problema per le librerie: non sai quali funzioni verranno chiamate da dove e con quale frequenza, quindi una "libreria" non potrebbe mai essere realmente compilata, spedita a tutti i client in un comodo formato di alto livello che sarà quindi integrato nell'applicazione. A parte altri problemi, perdi completamente il collegamento dinamico con tutti i suoi vantaggi.

Inoltre, ci sono molti motivi per non incorporare le funzioni anche quando è possibile:

  1. Non è necessariamente più veloce. L'impostazione del frame dello stack e l'abbattimento sono forse una dozzina di istruzioni a ciclo singolo, per molte funzioni di grandi dimensioni o loop che non rappresentano nemmeno lo 0,1% del tempo di esecuzione.
  2. Potrebbe essere più lento. La duplicazione del codice ha dei costi, ad esempio, metterà più pressione nella cache delle istruzioni.
  3. Alcune funzioni sono molto grandi e chiamate da molti luoghi, inserendole ovunque aumenta il binario ben oltre ciò che è ragionevole.
  4. I compilatori spesso hanno difficoltà con funzioni molto grandi. A parità di altre condizioni, una funzione di dimensione 2 * N richiede più di 2 * T di tempo in cui una funzione di dimensione N richiede T di tempo.

1
Sono sorpreso dal punto 4. Qual è il motivo?
Jacques B

12
@JacquesB Molti algoritmi di ottimizzazione sono quadratici, cubici o anche tecnicamente NP completi. L'esempio canonico è l'allocazione dei registri, che è NP-completa per analogia con la colorazione del grafico. (Di solito i compilatori non tentano una soluzione esatta, ma solo un paio di euristiche molto scadenti vengono eseguite in tempo lineare.) Molte ottimizzazioni semplici con un solo passaggio richiedono prima passaggi di analisi superlineare, come tutto ciò che dipende dal dominio nei flussi di controllo (generalmente n log n time con n blocchi di base).

2
"Hai davvero due domande qui" No, non lo so. Solo uno: perché non trattare una chiamata di funzione come un semplice segnaposto che il compilatore potrebbe, ad esempio, sostituire con il codice della funzione chiamata?
moonman239,

4
@ moonman239 Poi le tue parole mi hanno buttato via. Tuttavia, la tua domanda può essere scomposta come faccio nella mia risposta e penso che sia una prospettiva utile.

16

Le pile ci consentono di aggirare elegantemente i limiti imposti dal numero finito di registri.

Immagina di avere esattamente 26 "registri az" globali (o anche di avere solo i registri delle dimensioni di 7 byte del chip 8080) E ogni funzione che scrivi in ​​questa app condivide questo elenco piatto.

Un inizio ingenuo sarebbe quello di allocare i primi pochi registri alla prima funzione, e sapendo che ci sono voluti solo 3, inizia con "d" per la seconda funzione ... Ti esaurisci rapidamente.

Invece, se hai un nastro metaforico, come il turing machine, potresti avere ciascuna funzione che avvia una "chiamata un'altra funzione" salvando tutte le variabili che sta usando e inoltra () il nastro, e quindi la funzione di chiamata può confondersi con altrettante si registra come vuole. Al termine della chiamata, restituisce il controllo alla funzione genitore, che sa dove catturare l'output della chiamata, se necessario, e quindi riproduce il nastro all'indietro per ripristinarne lo stato.

Il frame delle chiamate di base è proprio questo, e viene creato e rilasciato da sequenze di codice macchina standardizzate che il compilatore inserisce attorno alle transizioni da una funzione all'altra. (È da tanto tempo che devo ricordare i miei frame stack C, ma puoi leggere in vari modi i doveri di chi lascia cadere ciò che su X86_calling_conventions .)

(La ricorsione è fantastica, ma se avessi mai dovuto destreggiarti tra i registri senza uno stack, apprezzeresti davvero le pile.)


Suppongo che lo spazio su disco rigido e la RAM necessari per memorizzare il programma e supportarne la compilazione (rispettivamente) siano il motivo per cui utilizziamo gli stack di chiamate. È corretto?

Mentre oggi possiamo integrarci di più ("maggiore velocità" è sempre buona; "meno kb di assembly" significa molto poco in un mondo di flussi video) Il limite principale è nella capacità del compilatore di appiattirsi attraverso determinati tipi di schemi di codice.

Ad esempio, oggetti polimorfici - se non conosci l'unico e solo tipo di oggetto che ti verrà consegnato, non puoi appiattirti; devi guardare la vtable delle caratteristiche dell'oggetto e chiamare attraverso quel puntatore ... banale da fare in fase di esecuzione, impossibile da incorporare in fase di compilazione.

Una moderna toolchain può felicemente incorporare una funzione polimorficamente definita quando ha appiattito abbastanza il / i chiamante / i per sapere esattamente quale sapore di obj è:

class Base {
    public: void act() = 0;
};
class Child1: public Base {
    public: void act() {};
};
void ActOn(Base* something) {
    something->act();
}
void InlineMe() {
    Child1 thingamabob;
    ActOn(&thingamabob);
}

in quanto sopra, il compilatore può scegliere di continuare a allineare staticamente, da InlineMe a tutto ciò che è dentro act (), né la necessità di toccare alcun vtables in fase di esecuzione.

Ma qualsiasi incertezza su quale sapore dell'oggetto lo lascerà come una chiamata a una funzione discreta, anche se alcune altre invocazioni della stessa funzione sono sottolineate.


11

Casi che tale approccio non è in grado di gestire:

function fib(a) { if(a>2) return fib(a-1)+fib(a-2); else return 1; }

function many(a) { for(i = 1 to a) { b(i); };}

Ci sono linguaggi e piattaforme con pile limitate o nessuna chiamata. I microprocessori PIC hanno uno stack hardware limitato a un numero compreso tra 2 e 32 voci . Questo crea vincoli di progettazione.

COBOL vieta la ricorsione: https://stackoverflow.com/questions/27806812/in-cobol-is-it-possible-to-recursively-call-a-paragraph

Imporre un divieto di ricorsione significa che è possibile rappresentare staticamente l'intero callgraph del programma come DAG. Il compilatore potrebbe quindi emettere una copia di una funzione per ogni posizione da cui viene chiamata con un salto fisso anziché un ritorno. Nessuno stack richiesto, solo più spazio per il programma, potenzialmente abbastanza per sistemi complessi. Ma per i piccoli sistemi embedded ciò significa che puoi garantire di non avere un overflow dello stack in fase di esecuzione, il che sarebbe una cattiva notizia per il tuo reattore nucleare / turbina a reazione / controllo dell'acceleratore dell'auto ecc.


12
Il tuo primo esempio è la ricorsione di base e hai ragione lì. Ma il tuo secondo esempio sembra essere un ciclo for che chiama un'altra funzione. La funzione di allineamento è diversa rispetto allo srotolamento di un loop; la funzione può essere allineata senza srotolare il loop. O ho perso qualche dettaglio sottile?
jpmc26,

1
Se il tuo primo esempio è destinato a definire la serie Fibonacci, è sbagliato. (Manca una fibchiamata.)
Paŭlo Ebermann il

1
Mentre proibire la ricorsione significa che l'intero grafico delle chiamate può essere rappresentato come un DAG, ciò non significa che si possa elencare l'elenco completo delle sequenze di chiamate nidificate in una ragionevole quantità di spazio. In un mio progetto per un microcontrollore con 128 KB di spazio di codice, ho commesso l'errore di chiedere un grafico di chiamata che includesse tutte le funzioni che potevano influire sul requisito massimo di RAM dei parametri e che il grafico di chiamata fosse oltre un concerto. Un grafico di chiamata completo sarebbe stato anche più lungo, e questo era per un programma che si adattava a 128 KB di spazio di codice.
supercat,

8

Volete allineare le funzioni e la maggior parte dei compilatori ( ottimizzatori ) lo stanno facendo.

Si noti che inline richiede che la funzione chiamata sia nota (ed è efficace solo se quella chiamata funzione non è troppo grande), poiché concettualmente sta sostituendo la chiamata con la riscrittura della funzione chiamata. Quindi generalmente non è possibile incorporare una funzione sconosciuta (ad esempio un puntatore a funzione - e che include funzioni di librerie condivise collegate dinamicamente -, che è forse visibile come metodo virtuale in alcune vtable ; ma alcuni compilatori potrebbero talvolta ottimizzare attraverso tecniche di devirtualizzazione ). Naturalmente non è sempre possibile incorporare funzioni ricorsive (alcuni compilatori intelligenti potrebbero utilizzare una valutazione parziale e in alcuni casi essere in grado di incorporare funzioni ricorsive).

Nota anche che l'inline, anche quando è facilmente possibile, non è sempre efficace: tu (in realtà il tuo compilatore) potresti aumentare così tanto le dimensioni del codice che le cache della CPU (o predittore di diramazione ) funzionerebbero in modo meno efficiente e ciò farebbe funzionare il tuo programma Più lentamente.

Mi sto concentrando un po 'sullo stile di programmazione funzionale , dal momento che hai taggato la tua domanda in quanto tale.

Si noti che non è necessario disporre di alcun stack di chiamate (almeno nel senso macchina dell'espressione "stack di chiamate"). Potresti usare solo l'heap.

Quindi, dai un'occhiata alle continuazioni e leggi di più sullo stile di passaggio di continuazione (CPS) e sulla trasformazione di CPS (intuitivamente, potresti usare le chiusure di continuazione come "frame di chiamata" reificati allocati nell'heap e sono un po 'come imitare uno stack di chiamate; allora hai bisogno di un efficiente garbage collector ).

Andrew Appel ha scritto un libro Compilando con continuazioni e una vecchia raccolta di rifiuti di carta può essere più veloce dell'allocazione delle pile . Vedi anche il documento di A.Kennedy (ICFP2007) Compilazione con continuazioni, continua

Consiglio anche di leggere il libro Lisp In Small Pieces di Queinnec , che contiene diversi capitoli relativi alla continuazione e alla compilazione.

Si noti inoltre che alcuni linguaggi (ad es. Brainfuck ) o macchine astratte (ad es. OISC , RAM ) non hanno alcun servizio di chiamata ma sono ancora completi di Turing , quindi non è necessario (in teoria) alcun meccanismo di chiamata di funzione, anche se è estremamente conveniente. A proposito, alcune vecchie architetture di set di istruzioni (ad es. IBM / 370 ) non hanno nemmeno uno stack di chiamate hardware o un'istruzione di chiamata push machine (l'IBM / 370 aveva solo un'istruzione di macchina Branch e Link )

Alla fine, se l'intero programma (comprese tutte le librerie necessarie) non presenta alcuna ricorsione, è possibile memorizzare l'indirizzo di ritorno (e le variabili "locali", che stanno diventando statiche) di ciascuna funzione in posizioni statiche. I primi compilatori di Fortran77 lo facevano all'inizio degli anni '80 (quindi i programmi compilati non utilizzavano alcun stack di chiamate in quel momento).


2
È molto discutibile se CPS non ha "stack di chiamate". Non è nello stack , la regione mistica della RAM ordinaria che ha un po 'di supporto hardware attraverso %especc., Ma mantiene comunque la contabilità equivalente su uno stack di spaghetti dal nome appropriato in un'altra regione di RAM. L'indirizzo di ritorno, in particolare, è essenzialmente codificato nella continuazione. E, naturalmente, le continuazioni non sono più veloci (e mi sembra che questo sia ciò a cui OP stava arrivando) rispetto a non effettuare chiamate tramite l'inline.

I vecchi documenti di Appel affermavano (e dimostravano con benchmarking) che CPS può essere veloce come avere uno stack di chiamate.
Basile Starynkevitch,

Sono scettico di ciò, ma a prescindere non è quello che ho affermato.

1
In realtà, questo era sulla workstation MIPS di fine anni '80. Probabilmente, la gerarchia della cache sui PC attuali renderebbe le prestazioni leggermente diverse. Ci sono stati diversi documenti che hanno analizzato le affermazioni di Appel (e in effetti, sulle macchine attuali, l'allocazione dello stack potrebbe essere leggermente più veloce - di alcune percentuali - rispetto alla raccolta dei rifiuti accuratamente realizzata)
Basile Starynkevitch,

1
@Gilles: Molti nuovi core ARM come Cortex M0 e M3 (e probabilmente altri come M4) hanno il supporto dello stack hardware per cose come la gestione degli interrupt. Inoltre, il set di istruzioni Thumb include un sottoinsieme limitato delle istruzioni STRM / STRM che include STRMDB R13 con qualsiasi combinazione di R0-R7 con / senza LR e LDRMIA R13 di qualsiasi combinazione di R0-R7 con / senza PC, che tratta efficacemente R13 come puntatore di stack.
supercat,

8

L'inclinazione (sostituzione di chiamate di funzione con funzionalità equivalente) funziona bene come strategia di ottimizzazione per piccole funzioni semplici. L'overhead di una chiamata di funzione può essere efficacemente scambiato con una piccola penalità nella dimensione del programma aggiunto (o in alcuni casi, nessuna penalità).

Tuttavia, funzioni di grandi dimensioni che a loro volta chiamano altre funzioni potrebbero portare a un'enorme esplosione delle dimensioni del programma se tutto fosse integrato.

Il punto centrale delle funzioni richiamabili è facilitare un riutilizzo efficiente, non solo da parte del programmatore, ma dalla macchina stessa e che include proprietà come memoria ragionevole o footprint su disco.

Per quello che vale: puoi avere funzioni richiamabili senza uno stack di chiamate. Ad esempio: IBM System / 360. Quando si programma in linguaggi come FORTRAN su quell'hardware, il contatore del programma (indirizzo di ritorno) verrebbe salvato in una piccola sezione di memoria riservata appena prima del punto di ingresso della funzione. Permette funzioni riutilizzabili, ma non consente la ricorsione o il codice multi-thread (un tentativo di chiamata ricorsiva o rientrante comporterebbe la sovrascrittura di un indirizzo di ritorno salvato in precedenza).

Come spiegato da altre risposte, le pile sono cose buone. Facilitano la ricorsione e le chiamate multi-thread. Mentre qualsiasi algoritmo codificato per utilizzare la ricorsione potrebbe essere codificato senza fare affidamento sulla ricorsione, il risultato può essere più complesso, più difficile da mantenere e può essere meno efficiente. Non sono sicuro che un'architettura senza stack possa supportare il multi-threading.

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.