Nota: Quando usei "complexo" no título, quero dizer que a expressão possui muitos operadores e operandos. Não que a expressão em si seja complexa.
Recentemente, estive trabalhando em um compilador simples para montagem de x86-64. Eu terminei o front end principal do compilador - o lexer e o analisador - e agora sou capaz de gerar uma representação da Árvore de Sintaxe Abstrata do meu programa. E como meu idioma será digitado estaticamente, agora estou fazendo a próxima fase: verificar o código-fonte. No entanto, cheguei a um problema e não fui capaz de resolvê-lo razoavelmente.
Considere o seguinte exemplo:
O analisador do meu compilador leu esta linha de código:
int a = 1 + 2 - 3 * 4 - 5
E o converteu no seguinte AST:
=
/ \
a(int) \
-
/ \
- 5
/ \
+ *
/ \ / \
1 2 3 4
Agora ele deve digitar check the AST. começa pelo primeiro tipo, verificando o =
operador. Primeiro, verifica o lado esquerdo do operador. Ele vê que a variável a
é declarada como um número inteiro. Portanto, agora deve verificar se a expressão do lado direito é avaliada como um número inteiro.
Entendo como isso poderia ser feito se a expressão fosse apenas um único valor, como 1
ou 'a'
. Mas como isso seria feito para expressões com vários valores e operandos - uma expressão complexa - como a acima? Para determinar corretamente o valor da expressão, parece que o verificador de tipos precisaria executar a própria expressão e registrar o resultado. Mas isso obviamente parece derrotar o objetivo de separar as fases de compilação e execução.
A única outra maneira que imagino que isso possa ser feito é verificar recursivamente a folha de cada subexpressão no AST e verificar se todos os tipos de folha correspondem ao tipo de operador esperado. Então, começando com o =
operador, o verificador de tipos examinaria todos os AST do lado esquerdo e verificaria se as folhas são inteiras. Em seguida, repetiria isso para cada operador na subexpressão.
Eu tentei pesquisar o tópico na minha cópia de "O Livro do Dragão" , mas ele não parece entrar em muitos detalhes e simplesmente reitera o que eu já sei.
Qual é o método usual usado quando um compilador é do tipo que verifica expressões com muitos operadores e operandos? Algum dos métodos mencionados acima foi usado? Caso contrário, quais são os métodos e como exatamente eles funcionam?
fonte
double a = 7/2
tentaria interpretar o lado direito como duplo; portanto, tentaria interpretar numerador e denominador como duplo e convertê-los se necessário; como resultadoa = 3.5
. O bottom-up executaria divisão inteira e converteria somente na última etapa (atribuição), portantoa = 3.0
.int a = 1 + 2 - 3 * 4 - 5
, mas paraint a = 5 - ((4*3) - (1+2))
int + int
torna-seint
.Respostas:
A recursão é a resposta, mas você desce em cada subárvore antes de manipular a operação:
à forma de árvore:
A inferência do tipo acontece primeiro andando pelo lado esquerdo, depois pelo lado direito e, em seguida, manipulando o operador assim que os tipos dos operandos forem inferidos:
-> desça para lhs
-> inferir
a
.a
é conhecido por serint
. Estamos de volta aoassign
nó agora:-> desça em rhs, depois nos lhs dos operadores internos até encontrarmos algo interessante
-> inferir o tipo de
1
, que éint
, e retornar ao pai-> entre no rhs
-> inferir o tipo de
2
, que éint
, e retornar ao pai-> inferir o tipo de
add(int, int)
, que éint
, e retornar ao pai-> desça para o rhs
etc., até você acabar com
Se a atribuição em si também é uma expressão com um tipo depende do seu idioma.
A dica importante: para determinar o tipo de qualquer nó do operador na árvore, você só precisa examinar seus filhos imediatos, que precisam ter um tipo já atribuído a eles.
fonte
Leia as páginas sobre sistema de tipos e inferência de tipos e sobre o sistema de Hindley-Milner , que usa a unificação . Leia também sobre semântica denotacional e semântica operacional .
A verificação de tipo pode ser mais simples se:
a
são explicitamente declaradas com um tipo. Isso é como C ou Pascal ou C ++ 98, mas não como C ++ 11, que possui alguma inferência de tipoauto
.1
,2
ou'c'
tem um tipo inerente: um int literal sempre tem o tipoint
, um caractere literal sempre tem o tipochar
, ....+
operador sempre tem tipo(int, int) -> int
. C possui sobrecarga para operadores (+
funciona para tipos inteiros assinados e não assinados e para duplos), mas não sobrecarrega funções.Sob essas restrições, um algoritmo de decoração do tipo AST recursivo de baixo para cima pode ser suficiente (isso se preocupa apenas com tipos , não com valores concretos, portanto, é uma abordagem em tempo de compilação):
Para cada escopo, você mantém uma tabela para os tipos de todas as variáveis visíveis (chamadas de ambiente). Após uma declaração
int a
, você adicionaria a entradaa: int
à tabela.A digitação de folhas é o caso básico da recursão trivial: o tipo de literal como
1
já é conhecido e o tipo de variável comoa
pode ser consultado no ambiente.Para digitar uma expressão com algum operador e operando de acordo com os tipos previamente calculados dos operandos (subexpressões aninhadas), usamos recursão nos operandos (para que digitemos primeiro essas subexpressões) e siga as regras de digitação relacionadas ao operador .
Portanto, no seu exemplo,
4 * 3
e1 + 2
são digitadosint
porque4
&3
e1
&2
foram digitados anteriormenteint
e suas regras de digitação dizem que a soma ou o produto de doisint
-s é umint
e assim por diante(4 * 3) - (1 + 2)
.Leia o livro Tipos e idiomas de programação de Pierce . Eu recomendo aprender um pouquinho de Ocaml e λ-calculus
Para linguagens de tipo mais dinâmico (como Lisp), leia também o Lisp In Small Pieces do Queinnec
Leia também o livro Pragmático de linguagens de programação de Scott
BTW, você não pode ter um código de digitação independente de idioma, porque o sistema de tipos é uma parte essencial da semântica do idioma .
fonte
auto
não é mais simples? Sem ele, você precisa descobrir o tipo no lado direito e ver se há uma correspondência ou conversão com o tipo no lado esquerdo. Comauto
você, basta descobrir o tipo do lado direito e pronto.auto
, C #var
e Go:=
é muito simples: digite check no lado direito da definição. O tipo resultante é o tipo da variável no lado esquerdo. Mas o diabo está nos detalhes. Por exemplo, as definições de C ++ podem ser autorreferenciais, portanto você pode se referir à variável que está sendo declarada no rhs, por exemploint i = f(&i)
. Se o tipo dei
for inferido, o algoritmo acima falhará: você precisa saber o tipo dei
inferir o tipo dei
. Em vez disso, você precisaria de inferência de tipo no estilo HM com variáveis de tipo.Em C (e francamente na maioria das linguagens de tipo estaticamente baseadas em C), todo operador pode ser visto como um açúcar sintático para uma chamada de função.
Portanto, sua expressão pode ser reescrita como:
Em seguida, a resolução de sobrecarga entrará em ação e decidirá que todas as funções são do tipo
(int, int)
ou(const int&, const int&)
.Dessa forma, a resolução do tipo é fácil de entender e seguir e (mais importante) fácil de implementar. As informações sobre os tipos fluem apenas de uma maneira (das expressões internas para fora).
Essa é a razão pela
double x = 1/2;
qual resultaráx == 0
porque1/2
é avaliada como uma expressão int.fonte
+
não é tratado como chamadas de função (uma vez que tem tipagem diferente paradouble
e paraint
operandos)operator+(int,int)
,operator+(double,double)
,operator+(char*,size_t)
, etc. O analisador só tem que manter o controle de qual deles está selecionado.f(a,b)
é um pouco mais fácil do que descobrir o tipo dea+b
.Focalizando seu algoritmo, tente alterá-lo para baixo. Você conhece o tipo pf variáveis e constantes; identifique o nó que carrega o operador com o tipo de resultado. Deixe a folha determinar o tipo de operador, também o oposto da sua ideia.
fonte
Na verdade, é bastante fácil, desde que você pense
+
que é uma variedade de funções e não um conceito único.Durante o estágio de análise do lado direito, o analisador recupera
1
, sabe que é umint
, depois analisa+
e armazena isso como um "nome de função não resolvido", depois analisa2
, sabe que é umint
e, em seguida, retorna essa pilha. O+
nó da função agora conhece os dois tipos de parâmetros, portanto pode resolver o+
intoint operator+(int, int)
, então agora conhece o tipo dessa subexpressão e o analisador continua em seu caminho alegre.Como você pode ver, uma vez que a árvore esteja totalmente construída, cada nó, incluindo as chamadas de função, conhece seus tipos. Isso é fundamental porque permite funções que retornam tipos diferentes dos seus parâmetros.
Aqui, a árvore é:
fonte
A base para a verificação de tipo não é o que o compilador faz, é o que a linguagem define.
Na linguagem C, todo operando tem um tipo. "abc" tem o tipo "array of const char". 1 tem o tipo "int". 1L tem o tipo "long". Se x e y são expressões, existem regras para o tipo de x + y e assim por diante. Portanto, o compilador obviamente tem que seguir as regras da linguagem.
Em idiomas modernos como Swift, as regras são muito mais complicadas. Alguns casos são simples como em C. Outros casos, o compilador vê uma expressão, já foi informado de antemão que tipo a expressão deve ter e determina os tipos de subexpressões com base nisso. Se xey forem variáveis de tipos diferentes e uma expressão idêntica for designada, essa expressão poderá ser avaliada de uma maneira diferente. Por exemplo, atribuir 12 * (2/3) atribuirá 8,0 a um Duplo e 0 a um Int. E você tem casos em que o compilador sabe que dois tipos estão relacionados e descobre quais tipos eles são baseados nisso.
Exemplo rápido:
imprime "8.0, 0".
Na atribuição x = 12 * (2/3): o lado esquerdo tem um tipo conhecido Double, portanto o lado direito deve ter o tipo Double. Há apenas uma sobrecarga para o operador "*" retornando Double, e é Double * Double -> Double. Portanto, 12 deve ter o tipo Double, assim como 2 / 3. 12 suporta o protocolo "IntegerLiteralConvertible". Double possui um inicializador que aceita um argumento do tipo "IntegerLiteralConvertible", portanto, 12 é convertido em Double. 2/3 deve ter o tipo Double. Há apenas uma sobrecarga para o operador "/" retornando Double, e isso é Double / Double -> Double. 2 e 3 são convertidos para o dobro. O resultado de 2/3 é 0,66666666. O resultado de 12 * (2/3) é 8,0. 8.0 é atribuído a x.
Na atribuição y = 12 * (2/3), y no lado esquerdo tem o tipo Int, portanto o lado direito deve ter o tipo Int, para que 12, 2, 3 sejam convertidos em Int com o resultado 2/3 = 0, 12 * (2/3) = 0.
fonte