Come vengono implementati i generici in un moderno compilatore?


15

Ciò che intendo qui è come passare da un modello T add(T a, T b) ...al codice generato? Ho pensato ad alcuni modi per raggiungere questo obiettivo, memorizziamo la funzione generica in un AST come Function_Nodee quindi ogni volta che la usiamo memorizziamo nel nodo della funzione originale una copia di se stesso con tutti i tipi Tsostituiti con i tipi che sono in uso. Ad esempio add<int>(5, 6), memorizzerà una copia della funzione generica adde sostituirà tutti i tipi T nella copia con int.

Quindi sarebbe simile a:

struct Function_Node {
    std::string name; // etc.
    Type return_type;
    std::vector<std::pair<Type, std::string>> arguments;
    std::vector<Function_Node> copies;
};

Quindi potresti generare codice per questi e quando visiti un Function_Nodedove l'elenco di copie copies.size() > 0, invochi visitFunctionsu tutte le copie.

visitFunction(Function_Node& node) {
    if (node.copies.size() > 0) {
        for (auto& node : nodes.copies) {
            visitFunction(node);
        }
        // it's a generic function so we don't want
        // to emit code for this.
        return;
    }
}

Funzionerebbe bene? In che modo i compilatori moderni affrontano questo problema? Penso che forse un altro modo per farlo sarebbe quello di poter iniettare le copie nell'AST in modo che attraversi tutte le fasi semantiche. Ho anche pensato che forse potresti generarli in una forma immediata come Rust's MIR o Swifts SIL per esempio.

Il mio codice è scritto in Java, gli esempi qui sono C ++ perché è un po 'meno prolisso per gli esempi - ma il principio è sostanzialmente la stessa cosa. Anche se potrebbero esserci alcuni errori perché è appena scritto a mano nella casella della domanda.

Nota che intendo un compilatore moderno come in quale sia il modo migliore per affrontare questo problema. E quando dico generici non intendo come generici Java dove usano la cancellazione del tipo.


In C ++ (altri linguaggi di programmazione hanno generici, ma ciascuno di essi lo implementa in modo diverso), è fondamentalmente un gigantesco sistema macro a tempo di compilazione. Il codice effettivo viene generato utilizzando il tipo sostituito.
Robert Harvey,

Perché non digitare la cancellazione? Tieni presente che non è solo Java che lo fa, e non è una cattiva tecnica (a seconda delle tue esigenze).
Andres F.

@AndresF. Penso che dato il modo in cui funziona la mia lingua, non funzionerebbe bene.
Jon Flow,

2
Penso che dovresti chiarire di che tipo di generici stai parlando. Ad esempio, i modelli C ++, i generici C # e i generici Java sono tutti molto diversi tra loro. Dici che non intendi i generici Java, ma non dici cosa intendi.
svick,

2
Questo ha davvero bisogno di concentrarsi sul sistema di una lingua per evitare di essere troppo ampio
Daenyth,

Risposte:


14

Come vengono implementati i generici in un moderno compilatore?

Ti invito a leggere il codice sorgente di un compilatore moderno se desideri sapere come funziona un compilatore moderno. Vorrei iniziare con il progetto Roslyn, che implementa compilatori C # e Visual Basic.

In particolare attiro la tua attenzione sul codice nel compilatore C # che implementa i simboli di tipo:

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Symbols

e potresti anche voler esaminare il codice per le regole di convertibilità. C'è molto che riguarda la manipolazione algebrica di tipi generici.

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Binder/Semantics/Conversions

Ho cercato di rendere quest'ultimo facile da leggere.

Ho pensato ad alcuni modi per raggiungere questo obiettivo, memorizziamo la funzione generica in un AST come Function_Node e quindi ogni volta che la usiamo archiviamo nel nodo della funzione originale una copia di se stessa con tutti i tipi T sostituiti con i tipi che vengono utilizzati.

Stai descrivendo modelli , non generici . C # e Visual Basic hanno generici reali nei loro sistemi di tipi.

In breve, funzionano così.

  • Iniziamo stabilendo regole per ciò che costituisce formalmente un tipo in fase di compilazione. Ad esempio: intè un tipo, un parametro type Tè un tipo, per qualsiasi tipo X, anche il tipo di array X[]è un tipo e così via.

  • Le regole per i generici prevedono la sostituzione. Ad esempio, class C with one type parameternon è un tipo. È un modello per creare tipi. class C with one type parameter called T, under substitution with int for T è un tipo.

  • Le regole che descrivono le relazioni tra i tipi - compatibilità al momento dell'assegnazione, come determinare il tipo di un'espressione e così via - sono progettate e implementate nel compilatore.

  • Un linguaggio bytecode che supporta tipi generici nel suo sistema di metadati è progettato e implementato.

  • In fase di esecuzione il compilatore JIT trasforma il bytecode in codice macchina; è responsabile della costruzione del codice macchina appropriato data una specializzazione generica.

Quindi, ad esempio, in C # quando dici

class C<T> { public void X(T t) { Console.WriteLine(t); } }
...
var c = new C<int>(); 
c.X(123);

quindi il compilatore verifica che in C<int>, l'argomento intsia una valida sostituzione Te genera di conseguenza metadati e bytecode. In fase di esecuzione, il jitter rileva che a C<int>viene creato per la prima volta e genera dinamicamente il codice macchina appropriato.


9

La maggior parte delle implementazioni dei generici (o meglio: polimorfismo parametrico) usano la cancellazione del tipo. Questo semplifica notevolmente il problema della compilazione di codice generico, ma funziona solo per i tipi inscatolati: poiché ogni argomento è effettivamente un puntatore opaco, abbiamo bisogno di un meccanismo di invio VTable o simile per eseguire operazioni sugli argomenti. In Java:

<T extends Addable> T add(T a, T b) { … }

può essere compilato, controllato per tipo e chiamato allo stesso modo di

Addable add(Addable a, Addable b) { … }

tranne che i generici forniscono al verificatore del tipo molte più informazioni sul sito di chiamata. Queste informazioni extra possono essere gestite con variabili di tipo , specialmente quando si deducono tipi generici. Durante il controllo del tipo, ogni tipo generico può essere sostituito con una variabile, chiamiamolo $T1:

$T1 add($T1 a, $T1 b)

La variabile type viene quindi aggiornata con più fatti man mano che vengono conosciuti, fino a quando non può essere sostituita con un tipo concreto. L'algoritmo di verifica del tipo deve essere scritto in modo da accogliere queste variabili di tipo anche se non sono ancora state risolte in un tipo completo. Nella stessa Java questo può essere fatto facilmente poiché il tipo di argomenti è spesso noto prima che sia necessario conoscere il tipo di chiamata di funzione. Un'eccezione notevole è un'espressione lambda come argomento di funzione, che richiede l'uso di tali variabili di tipo.

Molto più tardi, un ottimizzatore potrebbe generare un codice specializzato per un certo insieme di argomenti, questo sarebbe effettivamente una sorta di allineamento.

Un VTable per argomenti di tipo generico può essere evitato se la funzione generica non esegue alcuna operazione sul tipo, ma le passa solo a un'altra funzione. Ad esempio, la funzione Haskell call :: (a -> b) -> a -> b; call f x = f xnon dovrebbe racchiudere l' xargomento. Tuttavia, ciò richiede una convenzione di chiamata che può passare attraverso i valori senza conoscerne le dimensioni, il che essenzialmente lo limita ai puntatori.


Il C ++ è molto diverso dalla maggior parte delle lingue in questo senso. Una classe o una funzione basata su modelli (qui parlerò solo delle funzioni basate su modelli) non è richiamabile in sé. Invece, i template dovrebbero essere intesi come una meta-funzione di compilazione in tempo che restituisce una funzione reale. Ignorando per un momento l'inferenza dell'argomento template, l'approccio generale si riduce a questi passaggi:

  1. Applicare il modello agli argomenti del modello forniti. Ad esempio, chiamare template<class T> T add(T a, T b) { … }as add<int>(1, 2)ci darebbe la funzione effettiva int __add__T_int(int a, int b)(o qualunque approccio di manipolazione del nome venga utilizzato).

  2. Se il codice per quella funzione è già stato generato nell'unità di compilazione corrente, continua. Altrimenti, genera il codice come se una funzione int __add__T_int(int a, int b) { … }fosse stata scritta nel codice sorgente. Ciò comporta la sostituzione di tutte le occorrenze dell'argomento template con i suoi valori. Questa è probabilmente una trasformazione AST → AST. Quindi, eseguire il controllo del tipo sull'AST generato.

  3. Compilare la chiamata come se fosse stato il codice sorgente __add__T_int(1, 2).

Si noti che i modelli C ++ hanno un'interazione complessa con il meccanismo di risoluzione del sovraccarico, che non voglio descrivere qui. Si noti inoltre che questa generazione di codice rende impossibile avere un metodo basato su modelli che è anche virtuale - un approccio basato sulla cancellazione di tipi non soffre di questa sostanziale restrizione.


Cosa significa questo per il tuo compilatore e / o lingua? Devi pensare attentamente al tipo di generici che vuoi offrire. La cancellazione del tipo in assenza di inferenza del tipo è l'approccio più semplice possibile se si supportano i tipi in box. La specializzazione dei modelli sembra abbastanza semplice, ma di solito comporta la modifica del nome e (per più unità di compilazione) una duplicazione sostanziale nell'output, poiché i modelli sono istanziati nel sito di chiamata, non nel sito di definizione.

L'approccio che hai mostrato è essenzialmente un approccio modello simile al C ++. Tuttavia, i modelli specializzati / istanziati vengono archiviati come "versioni" del modello principale. Questo è fuorviante: non sono gli stessi concettualmente e diverse istanze di una funzione possono avere tipi selvaggiamente diversi. Ciò complicherà le cose a lungo termine se si consente anche il sovraccarico delle funzioni. Invece, avresti bisogno di una nozione di un set di sovraccarico che contiene tutte le possibili funzioni e modelli che condividono un nome. Ad eccezione della risoluzione del sovraccarico, è possibile considerare diversi modelli istanziati completamente separati l'uno dall'altro.

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.