Como posso determinar com segurança o tipo de uma variável que é declarada usando var em tempo de design?

109

Estou trabalhando em um recurso de conclusão (intellisense) para C # no emacs.

A ideia é que, se um usuário digitar um fragmento e, em seguida, solicitar a conclusão por meio de uma combinação de teclas específica, o recurso de conclusão usará o reflexo do .NET para determinar as conclusões possíveis.

Fazer isso requer que o tipo de coisa que está sendo completada seja conhecido. Se for uma string, há um conjunto conhecido de métodos e propriedades possíveis; se for um Int32, tem um conjunto separado e assim por diante.

Usando o semantic, um pacote de lexer / parser de código disponível no emacs, posso localizar as declarações de variáveis ​​e seus tipos. Dado isso, é simples usar reflexão para obter os métodos e propriedades no tipo e, em seguida, apresentar a lista de opções ao usuário. (Ok, não é muito simples de fazer no emacs, mas usando a capacidade de executar um processo do PowerShell dentro do emacs , torna-se muito mais fácil. Eu escrevo um assembly .NET personalizado para fazer reflexão, carrego no PowerShell e, em seguida, elisp rodando dentro O emacs pode enviar comandos para o PowerShell e ler as respostas, via comint. Como resultado, o emacs pode obter os resultados da reflexão rapidamente.)

O problema chega quando o código usa varna declaração do que está sendo concluído. Isso significa que o tipo não está especificado explicitamente e a conclusão não funcionará.

Como posso determinar com segurança o tipo real usado, quando a variável é declarada com a varpalavra - chave? Só para ficar claro, não preciso determinar isso em tempo de execução. Quero determiná-lo em "Tempo de design".

Até agora, tenho estas ideias:

  1. compilar e invocar:
    • extraia a declaração de declaração, por exemplo, `var foo =" um valor de string ";`
    • concatenar uma instrução `foo.GetType ();`
    • compilar dinamicamente o fragmento C # resultante em um novo assembly
    • carregue o assembly em um novo AppDomain, execute o framgment e obtenha o tipo de retorno.
    • descarregar e descartar o conjunto

    Eu sei fazer tudo isso. Mas soa terrivelmente pesado para cada solicitação de conclusão no editor.

    Acho que não preciso de um novo AppDomain sempre. Eu poderia reutilizar um único AppDomain para várias montagens temporárias e amortizar o custo de configurá-lo e desmontá-lo em várias solicitações de conclusão. Isso é mais um ajuste da ideia básica.

  2. compilar e inspecionar IL

    Basta compilar a declaração em um módulo e inspecionar o IL para determinar o tipo real inferido pelo compilador. Como isso seria possível? O que eu usaria para examinar o IL?

Alguma ideia melhor por aí? Comentários? sugestões?


EDITAR - pensando mais sobre isso, compilar e invocar não é aceitável, porque a invocação pode ter efeitos colaterais. Portanto, a primeira opção deve ser descartada.

Além disso, acho que não posso assumir a presença do .NET 4.0.


ATUALIZAÇÃO - A resposta correta, não mencionada acima, mas gentilmente apontada por Eric Lippert, é implementar um sistema de inferência de tipo de fidelidade total. É a única maneira de determinar com segurança o tipo de uma var em tempo de design. Mas também não é fácil de fazer. Como não tenho ilusões de que quero tentar construir tal coisa, peguei o atalho da opção 2 - extrair o código de declaração relevante, compilá-lo e, em seguida, inspecionar o IL resultante.

Isso realmente funciona, para um subconjunto razoável dos cenários de conclusão.

Por exemplo, suponha que nos fragmentos de código a seguir, o? é a posição em que o usuário pede a conclusão. Isso funciona:

var x = "hello there"; 
x.?

A conclusão percebe que x é uma String e fornece as opções apropriadas. Ele faz isso gerando e compilando o seguinte código-fonte:

namespace N1 {
  static class dmriiann5he { // randomly-generated class name
    static void M1 () {
       var x = "hello there"; 
    }
  }
}

... e então inspecionando o IL com reflexão simples.

Isso também funciona:

var x = new XmlDocument();
x.? 

O mecanismo adiciona as cláusulas using apropriadas ao código-fonte gerado, para que ele seja compilado corretamente e, então, a inspeção IL é a mesma.

Isso também funciona:

var x = "hello"; 
var y = x.ToCharArray();    
var z = y.?

Significa apenas que a inspeção IL precisa encontrar o tipo da terceira variável local, em vez da primeira.

E isto:

var foo = "Tra la la";
var fred = new System.Collections.Generic.List<String>
    {
        foo,
        foo.Length.ToString()
    };
var z = fred.Count;
var x = z.?

... que é apenas um nível mais profundo que o exemplo anterior.

Mas, o que não funciona é a conclusão de qualquer variável local cuja inicialização dependa em qualquer ponto de um membro da instância ou do argumento do método local. Gostar:

var foo = this.InstanceMethod();
foo.?

Nem sintaxe LINQ.

Terei que pensar sobre o quão valioso essas coisas são antes de considerar abordá-las através do que é definitivamente um "design limitado" (palavra educada para hack) para conclusão.

Uma abordagem para abordar o problema com dependências em argumentos de método ou métodos de instância seria substituir, no fragmento de código que é gerado, compilado e então analisado por IL, as referências a essas coisas por vars locais "sintéticos" do mesmo tipo.


Outra atualização - conclusão em vars que dependem de membros da instância, agora funciona.

O que fiz foi interrogar o tipo (via semântica) e, em seguida, gerar membros substitutos sintéticos para todos os membros existentes. Para um buffer C # como este:

public class CsharpCompletion
{
    private static int PrivateStaticField1 = 17;

    string InstanceMethod1(int index)
    {
        ...lots of code here...
        return result;
    }

    public void Run(int count)
    {
        var foo = "this is a string";
        var fred = new System.Collections.Generic.List<String>
        {
            foo,
            foo.Length.ToString()
        };
        var z = fred.Count;
        var mmm = count + z + CsharpCompletion.PrivateStaticField1;
        var nnn = this.InstanceMethod1(mmm);
        var fff = nnn.?

        ...more code here...

... o código gerado que é compilado, para que eu possa aprender com a saída IL o tipo do var nnn local, fica assim:

namespace Nsbwhi0rdami {
  class CsharpCompletion {
    private static int PrivateStaticField1 = default(int);
    string InstanceMethod1(int index) { return default(string); }

    void M0zpstti30f4 (int count) {
       var foo = "this is a string";
       var fred = new System.Collections.Generic.List<String> { foo, foo.Length.ToString() };
       var z = fred.Count;
       var mmm = count + z + CsharpCompletion.PrivateStaticField1;
       var nnn = this.InstanceMethod1(mmm);
      }
  }
}

Todos os membros da instância e do tipo estático estão disponíveis no código do esqueleto. Compila com sucesso. Nesse ponto, determinar o tipo de var local é simples por meio do Reflection.

O que torna isso possível é:

  • a capacidade de executar o PowerShell no emacs
  • o compilador C # é muito rápido. Na minha máquina, leva cerca de 0,5s para compilar um assembly na memória. Não é rápido o suficiente para a análise entre pressionamentos de tecla, mas rápido o suficiente para suportar a geração sob demanda de listas de conclusão.

Ainda não examinei o LINQ.
Isso será um problema muito maior porque o lexer / analisador semântico que o emacs tem para C #, não "faz" o LINQ.

Cheeso
fonte
4
O tipo de foo é descoberto e preenchido pelo compilador por meio de inferência de tipo. Suspeito que os mecanismos sejam totalmente diferentes. Talvez o mecanismo de inferência de tipos tenha um gancho? No mínimo, eu usaria 'inferência de tipo' como uma tag.
George Mauer
3
Sua técnica de fazer um modelo de objeto "falso" que tem todos os tipos, mas nenhuma da semântica dos objetos reais, é boa. Foi assim que fiz o IntelliSense para JScript no Visual InterDev naquela época; fazemos uma versão "falsa" do modelo de objeto do IE que tem todos os métodos e tipos, mas nenhum dos efeitos colaterais, e então executamos um pequeno interpretador sobre o código analisado em tempo de compilação para ver que tipo retorna.
Eric Lippert

Respostas:

202

Posso descrever para você como fazemos isso com eficiência no IDE C # "real".

A primeira coisa que fazemos é executar um passe que analisa apenas as coisas de "nível superior" no código-fonte. Ignoramos todos os corpos de método. Isso nos permite construir rapidamente um banco de dados de informações sobre quais namespaces, tipos e métodos (e construtores, etc) estão no código-fonte do programa. Analisar cada linha de código em cada corpo de método levaria muito tempo se você estivesse tentando fazer isso entre as teclas.

Quando o IDE precisa descobrir o tipo de uma expressão específica dentro de um corpo de método - digamos que você digitou "foo". e precisamos descobrir quais são os membros de foo - fazemos a mesma coisa; pulamos tanto trabalho quanto podemos razoavelmente.

Começamos com uma passagem que analisa apenas as declarações de variáveis ​​locais dentro desse método. Quando executamos essa passagem, fazemos um mapeamento de um par de "escopo" e "nome" para um "determinador de tipo". O "determinador de tipo" é um objeto que representa a noção de "Posso descobrir o tipo deste local se precisar". Determinar o tipo de local pode ser caro, por isso queremos adiar esse trabalho se for necessário.

Agora temos um banco de dados construído lentamente que pode nos dizer o tipo de cada local. Então, voltando ao "foo". - descobrimos em qual declaração a expressão relevante está e, em seguida, executamos o analisador semântico apenas nessa declaração. Por exemplo, suponha que você tenha o corpo do método:

String x = "hello";
var y = x.ToCharArray();
var z = from foo in y where foo.

e agora precisamos descobrir que foo é do tipo char. Construímos um banco de dados que contém todos os metadados, métodos de extensão, tipos de código-fonte e assim por diante. Construímos um banco de dados que possui determinantes de tipo para x, y e z. Analisamos a declaração que contém a expressão interessante. Começamos transformando-o sintaticamente para

var z = y.Where(foo=>foo.

Para descobrir o tipo de foo, devemos primeiro saber o tipo de y. Então, neste ponto, perguntamos ao determinador de tipo "qual é o tipo de y"? Em seguida, ele inicia um avaliador de expressão que analisa x.ToCharArray () e pergunta "qual é o tipo de x"? Temos um determinador de tipo para o que diz "Preciso procurar" String "no contexto atual". Não há nenhum tipo String no tipo atual, então examinamos o namespace. Também não está lá, então examinamos as diretivas using e descobrimos que existe um "using System" e que System tem um tipo String. OK, então esse é o tipo de x.

Em seguida, consultamos os metadados de System.String para o tipo de ToCharArray e ele diz que é um System.Char []. Super. Portanto, temos um tipo para y.

Agora perguntamos "System.Char [] tem um método Where?" Não. Portanto, examinamos as diretivas de uso; já pré-computamos um banco de dados contendo todos os metadados para métodos de extensão que poderiam ser usados.

Agora dizemos "OK, há dezoito dúzias de métodos de extensão chamados Onde no escopo, algum deles tem um primeiro parâmetro formal cujo tipo é compatível com System.Char []?" Então, começamos uma rodada de testes de conversibilidade. No entanto, os métodos de extensão Where são genéricos , o que significa que temos que fazer inferência de tipo.

Eu escrevi um mecanismo de inferência de tipo especial que pode lidar com fazer inferências incompletas do primeiro argumento para um método de extensão. Rodamos o tipo inferrer e descobrimos que existe um método Where que leva um IEnumerable<T>, e que podemos fazer uma inferência de System.Char [] para IEnumerable<System.Char>, então T é System.Char.

A assinatura deste método é Where<T>(this IEnumerable<T> items, Func<T, bool> predicate), e sabemos que T é System.Char. Também sabemos que o primeiro argumento entre parênteses para o método de extensão é um lambda. Portanto, iniciamos um inferrer de tipo de expressão lambda que diz "o parâmetro formal foo é assumido como System.Char", use esse fato ao analisar o resto do lambda.

Agora temos todas as informações de que precisamos para analisar o corpo do lambda, que é "foo.". Procuramos o tipo de foo, descobrimos que, de acordo com o fichário lambda, é System.Char e pronto; exibimos informações de tipo para System.Char.

E fazemos tudo, exceto a análise de "nível superior" entre as teclas . Essa é a parte realmente complicada. Na verdade, escrever todas as análises não é difícil; está tornando-o rápido o suficiente para que você possa fazer isso na velocidade de digitação que é a parte realmente complicada.

Boa sorte!

Eric Lippert
fonte
8
Eric, obrigado pela resposta completa. Você abriu meus olhos um pouco. Para o emacs, eu não pretendia produzir um mecanismo dinâmico entre teclas que competisse com o Visual Studio em termos de qualidade de experiência do usuário. Por um lado, por causa da latência de ~ 0,5s inerente ao meu projeto, a facilidade baseada em emacs é e permanecerá somente sob demanda; nenhuma sugestão de digitação antecipada. Por outro lado - implementarei o suporte básico de var locais, mas ficarei feliz em fazer pontaria quando as coisas ficarem complicadas ou quando o gráfico de dependência exceder um certo limite. Ainda não tenho certeza de qual é esse limite. Obrigado novamente.
Cheeso de
13
Sinceramente, me confunde que tudo isso possa funcionar de forma tão rápida e confiável, particularmente com expressões lambda e inferência de tipo genérico. Na verdade, fiquei bastante surpreso na primeira vez que escrevi uma expressão lambda e o Intellisense sabia o tipo do meu parâmetro quando pressionei., Embora a instrução ainda não estivesse completa e eu nunca tenha especificado explicitamente os parâmetros genéricos dos métodos de extensão. Obrigado por esta pequena espiada na magia.
Dan Bryant
21
@Dan: Eu vi (ou escrevi) o código-fonte e me surpreende que ele funcione também. :-) Há algumas coisas cabeludas lá.
Eric Lippert de
11
Os caras do Eclipse provavelmente fazem isso melhor porque são mais incríveis do que o compilador C # e a equipe IDE.
Eric Lippert
23
Não me lembro de ter feito esse comentário estúpido. Isso nem faz sentido. Eu devia estar bêbado. Desculpe.
Tomas Andrle
15

Posso dizer aproximadamente como o Delphi IDE funciona com o compilador Delphi para fazer o intellisense (código de insight é como o Delphi o chama). Não é 100% aplicável ao C #, mas é uma abordagem interessante que merece consideração.

A maior parte da análise semântica em Delphi é feita no próprio analisador. As expressões são digitadas à medida que são analisadas, exceto em situações em que isso não é fácil - nesse caso, a análise antecipada é usada para descobrir o que é pretendido e, então, essa decisão é usada na análise.

A análise é em grande parte descendente recursiva LL (2), exceto para expressões, que são analisadas usando precedência de operador. Uma das coisas distintas sobre o Delphi é que ele é uma linguagem de passagem única, portanto, as construções precisam ser declaradas antes de serem usadas, portanto, nenhuma passagem de nível superior é necessária para trazer essas informações.

Essa combinação de recursos significa que o analisador tem praticamente todas as informações necessárias para a compreensão do código para qualquer ponto onde for necessário. A maneira como funciona é a seguinte: o IDE informa o lexer do compilador da posição do cursor (o ponto onde o insight do código é desejado) e o lexer transforma isso em um token especial (é chamado de token kibitz). Sempre que o analisador encontrar esse token (que pode estar em qualquer lugar), ele saberá que este é o sinal para enviar de volta todas as informações que possui ao editor. Ele faz isso usando um longjmp porque é escrito em C; o que ele faz é notificar o chamador final sobre o tipo de construção sintática (isto é, contexto gramatical) em que o ponto de kibitz foi encontrado, bem como todas as tabelas simbólicas necessárias para aquele ponto. Então, por exemplo, se o contexto está em uma expressão que é um argumento para um método, podemos verificar as sobrecargas do método, olhar para os tipos de argumento e filtrar os símbolos válidos apenas para aqueles que podem resolver para esse tipo de argumento (isso reduz em um muito material irrelevante no menu suspenso). Se estiver em um contexto de escopo aninhado (por exemplo, após um "."), O analisador terá devolvido uma referência ao escopo e o IDE pode enumerar todos os símbolos encontrados nesse escopo.

Outras coisas também são feitas; por exemplo, corpos de método são ignorados se o token kibitz não estiver em seu intervalo - isso é feito de forma otimista e revertido se ele ignorou o token. O equivalente aos métodos de extensão - ajudantes de classe em Delphi - tem um tipo de cache versionado, então sua pesquisa é razoavelmente rápida. Mas a inferência de tipo genérico do Delphi é muito mais fraca do que a do C #.

Agora, à questão específica: inferir os tipos de variáveis ​​declaradas com varé equivalente à maneira como Pascal infere o tipo de constantes. Vem do tipo da expressão de inicialização. Esses tipos são construídos de baixo para cima. Se xfor do tipo Integere yfor do tipo Double, então x + yserá do tipo Double, porque essas são as regras da linguagem; etc. Você segue essas regras até que tenha um tipo para a expressão completa no lado direito, e esse é o tipo que você usa para o símbolo à esquerda.

Barry Kelly
fonte
7

Se você não quer ter que escrever seu próprio analisador para construir a árvore de sintaxe abstrata, você pode usar os analisadores de SharpDevelop ou MonoDevelop , ambos de código aberto.

Daniel Plaisted
fonte
4

Os sistemas Intellisense normalmente representam o código usando uma árvore de sintaxe abstrata, que permite resolver o tipo de retorno da função atribuída à variável 'var' mais ou menos da mesma maneira que o compilador. Se você usar o VS Intellisense, você notará que ele não fornecerá o tipo de var até que você termine de inserir uma expressão de atribuição válida (resolvível). Se a expressão ainda for ambígua (por exemplo, ela não pode inferir totalmente os argumentos genéricos da expressão), o tipo var não será resolvido. Este pode ser um processo bastante complexo, pois talvez seja necessário entrar bem fundo em uma árvore para resolver o tipo. Por exemplo:

var items = myList.OfType<Foo>().Select(foo => foo.Bar);

O tipo de retorno é IEnumerable<Bar>, mas para resolver isso é necessário saber:

  1. myList é do tipo que implementa IEnumerable.
  2. Existe um método de extensão OfType<T>que se aplica a IEnumerable.
  3. O valor resultante é IEnumerable<Foo>e há um método de extensão Selectque se aplica a isso.
  4. A expressão lambda foo => foo.Barpossui o parâmetro foo do tipo Foo. Isso é inferido pelo uso de Select, que leva a Func<TIn,TOut>e, como TIn é conhecido (Foo), o tipo de foo pode ser inferido.
  5. O tipo Foo possui uma propriedade Bar, que é do tipo Bar. Sabemos que Select retorna IEnumerable<TOut>e TOut pode ser inferido do resultado da expressão lambda, portanto, o tipo de item resultante deve ser IEnumerable<Bar>.
Dan Bryant
fonte
Certo, pode ficar bem profundo. Estou confortável em resolver todas as dependências. Só de pensar nisso, a primeira opção que descrevi - compilar e invocar - é absolutamente inaceitável, porque invocar código pode ter efeitos colaterais, como atualizar um banco de dados, e isso não é algo que um editor deve fazer. Compilar está ok, invocar não. No que diz respeito à construção do AST, acho que não quero fazer isso. Na verdade, quero transferir esse trabalho para o compilador, que já sabe como fazer. Quero poder pedir ao compilador que me diga o que desejo saber. Eu só quero uma resposta simples.
Cheeso de
O desafio de inspecioná-lo na compilação é que as dependências podem ser arbitrariamente profundas, o que significa que você pode precisar construir tudo para que o compilador gere o código. Se você fizer isso, acho que você pode usar os símbolos do depurador com o IL gerado e combinar o tipo de cada local com seu símbolo.
Dan Bryant
1
@Cheeso: o compilador não oferece esse tipo de análise de tipo como um serviço. Espero que no futuro isso aconteça, mas sem promessas.
Eric Lippert de
sim, acho que esse é o caminho a percorrer - resolva todas as dependências e, em seguida, compile e inspecione o IL. @Eric, bom saber. Por enquanto, se eu não pretendo fazer a análise completa de AST, devo recorrer a um hack sujo para produzir este serviço usando as ferramentas existentes. Por exemplo, compilar um fragmento de código construído de forma inteligente e, em seguida, usar ILDASM (ou semelhante) programaticamente para obter a resposta que procuro.
Cheeso de
4

Já que você tem como alvo o Emacs, pode ser melhor começar com o pacote CEDET. Todos os detalhes que Eric Lippert já cobre no analisador de código na ferramenta CEDET / Semantic for C ++. Há também um analisador C # (que provavelmente precisa de um pouco de TLC), portanto, as únicas partes ausentes estão relacionadas ao ajuste das partes necessárias para C #.

Os comportamentos básicos são definidos em algoritmos centrais que dependem de funções sobrecarregáveis ​​que são definidas por idioma. O sucesso do mecanismo de conclusão depende de quanto ajuste foi feito. Com c ++ como guia, obter suporte semelhante ao C ++ não deve ser tão ruim.

A resposta de Daniel sugere o uso do MonoDevelop para fazer parsing e análise. Este poderia ser um mecanismo alternativo em vez do analisador C # existente ou poderia ser usado para aumentar o analisador existente.

Eric
fonte
Certo, eu conheço o CEDET e estou usando o suporte C # no diretório contrib para semântica. Semantic fornece a lista de variáveis ​​locais e seus tipos. Um mecanismo de conclusão pode verificar essa lista e oferecer as escolhas certas ao usuário. O problema é quando a variável é var. A Semantic o identifica corretamente como var, mas não fornece inferência de tipo. Minha pergunta era especificamente sobre como resolver isso . Também investiguei a possibilidade de conectar à conclusão CEDET existente, mas não consegui descobrir como. A documentação do CEDET não está ... ah ... completa.
Cheeso de
Comentário lateral - o CEDET é admiravelmente ambicioso, mas achei difícil de usar e estender. Atualmente, o analisador trata "namespace" como um indicador de classe em C #. Eu não conseguia nem descobrir como adicionar "namespace" como um elemento sintático distinto. Fazer isso impediu todas as outras análises sintáticas e não consegui descobrir por quê. Eu já expliquei a dificuldade que tive com a estrutura de conclusão. Além desses problemas, existem costuras e sobreposições entre as peças. Por exemplo, a navegação faz parte da semântica e do senador. CEDET parece atraente, mas no final ... é muito pesado para se comprometer.
Cheeso de
Cheeso, se você deseja obter o máximo das partes menos documentadas do CEDET, sua melhor aposta é tentar a lista de mala direta. É fácil para as perguntas se aprofundar em áreas que ainda não foram bem desenvolvidas, portanto, são necessárias algumas iterações para encontrar boas soluções ou para explicar as existentes. Para C # em particular, uma vez que não sei nada sobre ele, não haverá respostas simples e únicas.
Eric
2

É um problema difícil de fazer bem. Basicamente, você precisa modelar a especificação / compilador da linguagem através da maior parte da lexing / parsing / typechecking e construir um modelo interno do código-fonte que você pode então consultar. Eric o descreve em detalhes para C #. Você sempre pode baixar o código-fonte do compilador F # (parte do F # CTP) e dar uma olhada service.fsipara ver a interface exposta do compilador F # que o serviço de linguagem F # consome para fornecer intellisense, dicas para tipos inferidos, etc. uma sensação de uma possível 'interface' se você já tiver o compilador disponível como uma API para chamar.

A outra rota é reutilizar os compiladores como estão, conforme você está descrevendo, e então usar reflexão ou examinar o código gerado. Isso é problemático do ponto de vista de que você precisa de 'programas completos' para obter uma saída de compilação de um compilador, enquanto ao editar o código-fonte no editor, muitas vezes você só tem 'programas parciais' que ainda não analisam, não todos os métodos já foram implementados, etc.

Resumindo, acho que a versão de 'baixo orçamento' é muito difícil de fazer bem, e a versão 'real' é muito, muito difícil de fazer bem. (Onde 'difícil' aqui mede 'esforço' e 'dificuldade técnica'.)

Brian
fonte
Sim, a versão de "baixo orçamento" tem algumas limitações claras. Estou tentando decidir o que é "bom o suficiente" e se posso cumprir esse bar. Em minha própria experiência de dogfooding o que tenho até agora, torna a escrita C # dentro do emacs muito mais agradável.
Cheeso de
0

Para a solução "1", você tem um novo recurso no .NET 4 para fazer isso de forma rápida e fácil. Portanto, se você puder converter seu programa para .NET 4, será sua melhor escolha.

Softlion
fonte