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_Node
e, a 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 são sendo usado. Por exemplo add<int>(5, 6)
, armazenará uma cópia da função genérica add
e 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_Node
onde a lista de cópias copies.size() > 0
, invoque visitFunction
em 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.
Respostas:
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.
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 tipoT
é um tipo, para qualquer tipoX
, o tipo de matrizX[]
também é um tipo e assim por diante.As regras para genéricos envolvem substituição. Por exemplo,
class C with one type parameter
nã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
o compilador verifica se
C<int>
, em , o argumentoint
é uma substituição válida paraT
e gera metadados e código de bytes de acordo. No tempo de execução, o tremor detecta que umC<int>
está sendo criado pela primeira vez e gera o código de máquina apropriado dinamicamente.fonte
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:
pode ser compilado, verificado por tipo e chamado da mesma maneira que
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
: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 Haskell
call :: (a -> b) -> a -> b; call f x = f x
não precisaria encaixar ox
argumento. 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:
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 realint __add__T_int(int a, int b)
(ou qualquer outra abordagem usada para identificar nomes).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ção
int __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.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.
fonte