Como os genéricos são implementados em um compilador moderno?

15

O que quero dizer aqui é como vamos de algum modelo T add(T a, T b) ...para o código gerado? Pensei em algumas maneiras de conseguir isso: armazenamos a função genérica em um AST Function_Nodee, a cada vez que a usamos, armazenamos no nó da função original uma cópia de si mesma com todos os tipos Tsubstituídos pelos tipos que são sendo usado. Por exemplo add<int>(5, 6), armazenará uma cópia da função genérica adde substituirá todos os tipos T na cópia por int.

Então, seria algo como:

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

Em seguida, você poderá gerar código para esses itens e, quando visitar um local Function_Nodeonde a lista de cópias copies.size() > 0, invoque visitFunctionem todas as cópias.

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;
    }
}

Isso funcionaria bem? Como os compiladores modernos abordam esse problema? Acho que talvez outra maneira de fazer isso seria injetar as cópias no AST para que ele percorra todas as fases semânticas. Também pensei que talvez você pudesse gerá-los de uma forma imediata, como o RIR de Rust ou o Swifts SIL, por exemplo.

Meu código é escrito em Java, os exemplos aqui são C ++ porque é um pouco menos detalhado para exemplos - mas o princípio é basicamente a mesma coisa. Embora possa haver alguns erros, porque está escrito à mão na caixa de perguntas.

Note que quero dizer o compilador moderno, como é a melhor maneira de abordar esse problema. E quando digo genéricos, não quero dizer como genéricos Java, onde eles usam apagamento de tipo.

Jon Flow
fonte
Em C ++ (outras linguagens de programação têm genéricos, mas cada uma implementa de maneira diferente), é basicamente um sistema macro gigante em tempo de compilação. O código real é gerado usando o tipo substituído.
Robert Harvey
Por que não digitar apagamento? Lembre-se de que não é apenas o Java que faz isso, e não é uma técnica ruim (dependendo dos seus requisitos).
Andres F.
@AndresF. Eu acho que, dada a maneira como minha linguagem funciona, não daria certo.
Jon Fluxo
2
Eu acho que você deveria esclarecer que tipo de genéricos você está falando. Por exemplo, modelos C ++, C # genéricos e Java genéricos são todos muito diferentes um do outro. Você diz que não quer dizer genéricos Java, mas não diz o que quer dizer.
svick
2
Isso realmente precisa se concentrar em sistema de uma língua para evitar ser excessivamente ampla
Daenyth

Respostas:

14

Como os genéricos são implementados em um compilador moderno?

Convido você a ler o código fonte de um compilador moderno, se desejar saber como um compilador moderno funciona. Eu começaria com o projeto Roslyn, que implementa os compiladores C # e Visual Basic.

Em particular, chamo a atenção para o código no compilador C # que implementa símbolos de tipo:

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

e você também pode procurar no código regras de conversibilidade. Há muita coisa relacionada à manipulação algébrica de tipos genéricos.

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

Tentei muito facilitar a leitura do último.

Pensei em algumas maneiras de conseguir isso: armazenamos a função genérica em um AST como Function_Node e, cada vez que a usamos, armazenamos no nó da função original uma cópia de si mesma com todos os tipos T substituídos pelos tipos que estão sendo usados.

Você está descrevendo modelos , não genéricos . C # e Visual Basic possuem genéricos reais em seus sistemas de tipos.

Resumidamente, eles funcionam assim.

  • Começamos estabelecendo regras para o que formalmente constitui um tipo em tempo de compilação. Por exemplo: inté um tipo, um parâmetro de tipo Té um tipo, para qualquer tipo X, o tipo de matriz X[]também é um tipo e assim por diante.

  • As regras para genéricos envolvem substituição. Por exemplo, class C with one type parameternão é um tipo. É um padrão para fazer tipos. class C with one type parameter called T, under substitution with int for T é um tipo.

  • As regras que descrevem os relacionamentos entre tipos - compatibilidade na atribuição, como determinar o tipo de uma expressão etc. - são projetadas e implementadas no compilador.

  • Uma linguagem de bytecode que suporta tipos genéricos em seu sistema de metadados é projetada e implementada.

  • Em tempo de execução, o compilador JIT transforma o bytecode em código de máquina; é responsável por construir o código de máquina apropriado, dada uma especialização genérica.

Por exemplo, em C # quando você diz

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

o compilador verifica se C<int>, em , o argumento inté uma substituição válida para Te gera metadados e código de bytes de acordo. No tempo de execução, o tremor detecta que um C<int>está sendo criado pela primeira vez e gera o código de máquina apropriado dinamicamente.

Eric Lippert
fonte
9

A maioria das implementações de genéricos (ou melhor: polimorfismo paramétrico) usa apagamento de tipo. Isso simplifica bastante o problema de compilar código genérico, mas funciona apenas para tipos in a box: como cada argumento é efetivamente um ponteiro opaco, precisamos de uma VTable ou de um mecanismo de expedição semelhante para executar operações nos argumentos. Em Java:

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

pode ser compilado, verificado por tipo e chamado da mesma maneira que

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

exceto que os genéricos fornecem ao verificador de tipo muito mais informações no site de chamada. Essas informações extras podem ser tratadas com variáveis ​​de tipo , especialmente quando tipos genéricos são inferidos. Durante a verificação de tipo, cada tipo genérico pode ser substituído por uma variável, vamos chamá-lo $T1:

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

A variável de tipo é então atualizada com mais fatos à medida que se tornam conhecidos, até que possa ser substituída por um tipo concreto. O algoritmo de verificação de tipo deve ser escrito de forma a acomodar essas variáveis ​​de tipo, mesmo que ainda não tenham sido resolvidas para um tipo completo. No próprio Java, isso geralmente pode ser feito facilmente, já que o tipo de argumento geralmente é conhecido antes que o tipo de chamada de função precise ser conhecido. Uma exceção notável é uma expressão lambda como argumento de função, que requer o uso de tais variáveis ​​de tipo.

Muito mais tarde, um otimizador pode gerar código especializado para um determinado conjunto de argumentos; isso seria efetivamente um tipo de inlining.

Uma VTable para argumentos de tipo genérico pode ser evitada se a função genérica não executar nenhuma operação no tipo, mas apenas as passar para outra função. Por exemplo, a função Haskellcall :: (a -> b) -> a -> b; call f x = f x não precisaria encaixar o xargumento. No entanto, isso requer uma convenção de chamada que possa passar por valores sem saber seu tamanho, o que basicamente a restringe a ponteiros de qualquer maneira.


C ++ é muito diferente da maioria dos idiomas a esse respeito. Uma classe ou função modelada (discutirei apenas funções modeladas aqui) não é exigível por si só. Em vez disso, os modelos devem ser entendidos como uma meta-função em tempo de compilação que retorna uma função real. Ignorando a inferência do argumento do modelo por um momento, a abordagem geral se resume a estas etapas:

  1. Aplique o modelo aos argumentos do modelo fornecidos. Por exemplo, chamando template<class T> T add(T a, T b) { … }comoadd<int>(1, 2) nos daria a função real int __add__T_int(int a, int b)(ou qualquer outra abordagem usada para identificar nomes).

  2. Se o código para essa função já tiver sido gerado na unidade de compilação atual, continue. Caso contrário, gere o código como se uma funçãoint __add__T_int(int a, int b) { … } tivesse sido gravada no código-fonte. Isso envolve substituir todas as ocorrências do argumento do modelo por seus valores. Provavelmente esta é uma transformação AST → AST. Em seguida, execute a verificação de tipo no AST gerado.

  3. Compile a chamada como se o código-fonte tivesse sido __add__T_int(1, 2).

Observe que os modelos C ++ têm uma interação complexa com o mecanismo de resolução de sobrecarga, que eu não quero descrever aqui. Observe também que essa geração de código torna impossível um método de modelo virtual também - uma abordagem baseada em apagamento de tipo não sofre com essa restrição substancial.


O que isso significa para o seu compilador e / ou idioma? Você precisa pensar cuidadosamente sobre o tipo de genéricos que deseja oferecer. O apagamento de tipo na ausência de inferência de tipo é a abordagem mais simples possível se você oferecer suporte a tipos in a box. A especialização de modelos parece bastante simples, mas geralmente envolve a troca de nomes e (para várias unidades de compilação) uma duplicação substancial na saída, pois os modelos são instanciados no site de chamada, não no site de definição.

A abordagem que você mostrou é essencialmente uma abordagem de modelo semelhante ao C ++. No entanto, você armazena os modelos especializados / instanciados como "versões" do modelo principal. Isso é enganador: eles não são os mesmos conceitualmente e instâncias diferentes de uma função podem ter tipos totalmente diferentes. Isso complicará as coisas a longo prazo se você também permitir sobrecarga de função. Em vez disso, você precisaria de uma noção de um conjunto de sobrecarga que contenha todas as funções e modelos possíveis que compartilhem um nome. Exceto para resolver a sobrecarga, você pode considerar diferentes modelos instanciados como completamente separados um do outro.

amon
fonte