Como nós, no IntelliJ IDEA, procuramos expressões lambda

Hierarquia de tipos no IntelliJ IDEA Um recurso importante de qualquer IDE é a pesquisa e a navegação pelo código. Uma das opções de pesquisa Java usadas com freqüência é procurar todas as implementações dessa interface. Freqüentemente, essa função é chamada de Hierarquia de Tipos e se parece com a figura à direita.


A repetição de todas as classes de um projeto ao chamar esta função é ineficiente. Você pode salvar a hierarquia completa da classe no índice em tempo de compilação, pois o compilador a constrói de qualquer maneira. Fazemos isso se a compilação for iniciada pelo próprio IDE e não delegada, por exemplo, em Gradle. Mas isso só funciona se nada mudou no módulo após a compilação. Porém, no caso geral, os códigos-fonte são a fonte de informação mais relevante e os índices são criados nos códigos-fonte.


Encontrar herdeiros imediatos é uma tarefa simples se não estamos lidando com uma interface funcional. Ao procurar implementações da interface Foo , você precisa encontrar todas as classes onde há implements Foo , e interfaces onde existem extends Foo , bem como classes anônimas no formato new Foo(...) {...} . Para fazer isso, basta construir a árvore de sintaxe de cada arquivo de projeto com antecedência, encontrar as construções correspondentes e adicioná-las ao índice.


Obviamente, há uma leve sutileza aqui: talvez você esteja procurando a interface com.example.goodcompany.Foo , mas em algum lugar org.example.evilcompany.Foo é realmente usado. É possível colocar previamente o nome completo da interface pai no índice? Existem dificuldades com isso. Por exemplo, o arquivo em que a interface é usada pode ter esta aparência:


 // MyFoo.java import org.example.foo.*; import org.example.bar.*; import org.example.evilcompany.*; class MyFoo implements Foo {...} 

Observando apenas o arquivo, não conseguimos entender qual é o nome completo real Foo . Você precisa examinar o conteúdo de vários pacotes. E cada pacote pode ser definido em vários locais (por exemplo, em vários arquivos jar). A indexação levará muito tempo se, ao analisar esse arquivo, tivermos que fazer uma resolução completa do personagem. Mas o principal problema não é isso, mas o índice criado no arquivo MyFoo.java dependerá não apenas dele, mas também de outros arquivos. Afinal, podemos transferir a descrição da interface Foo , por exemplo, do pacote org.example.foo para o pacote org.example.bar e não MyFoo.java nada no arquivo MyFoo.java , e o nome completo do Foo será alterado.


Os índices no IntelliJ IDEA dependem apenas do conteúdo de um único arquivo. Por um lado, isso é muito conveniente: o índice relacionado a um arquivo específico se torna inválido quando esse arquivo é alterado. Por outro lado, isso impõe grandes restrições sobre o que pode ser colocado no índice. Por exemplo, ele não pode armazenar de forma confiável os nomes completos das classes pai no índice. Mas, em princípio, isso não é tão assustador. Ao consultar a hierarquia de tipos, podemos encontrar tudo o que se adequa ao nome abreviado e, em seguida, esses arquivos executam uma resolução honesta do personagem e determinam se ele realmente nos convém. Na maioria dos casos, não haverá muitos caracteres extras e essa verificação será bastante rápida.


Hierarquia de interface funcional no IntelliJ IDEA A situação muda drasticamente quando a classe cujos descendentes estamos procurando é uma interface funcional. Então, além de herdeiros explícitos e anônimos, obtemos expressões lambda e links de métodos. O que agora colocar em um índice e o que calcular diretamente na pesquisa?


Suponha que tenhamos uma interface funcional:


 @FunctionalInterface public interface StringConsumer { void consume(String s); } 

Existem diferentes expressões lambda no código. Por exemplo:


 () -> {} //   :   (a, b) -> a + b //   :   s -> { return list.add(s); //   :   } s -> list.add(s); //    

Ou seja, podemos filtrar rapidamente apenas as lambdas que têm o número errado de parâmetros ou obviamente o tipo de retorno errado, por exemplo, nulo versus não nulo. Geralmente, é impossível determinar o tipo de retorno com mais precisão. Digamos, em lambda s -> list.add(s) para isso, você precisa resolver a list caracteres e add e, possivelmente, iniciar um procedimento completo de inferência de tipo. Tudo isso é longo e exigirá fixação no conteúdo de outros arquivos.


Temos sorte se nossa interface funcional receber cinco argumentos. Mas se for necessário apenas um argumento, esse filtro deixará um grande número de lambdas extras. Pior ainda com referências a métodos. Em princípio, a aparência de qualquer referência a um método não pode ser dita de forma alguma se é adequada ou não.


Talvez você deva procurar ao redor da lambda para entender alguma coisa? Sim, às vezes funciona. Por exemplo:


 //        Predicate<String> p = s -> list.add(s); //      IntPredicate getPredicate() { return s -> list.add(s); } //      SomeType fn; fn = s -> list.add(s); //     foo((SomeFunctionalType)(s -> list.add(s))); //     Foo[] myLambdas = {s -> list.add(s), s -> list.remove(s)}; 

Em todos esses casos, o nome abreviado da interface funcional correspondente pode ser encontrado no arquivo atual e colocado no índice ao lado da expressão funcional, seja uma lambda ou uma referência de método. Infelizmente, em projetos reais, esses casos cobrem uma fração muito pequena de todas as lambdas. Na grande maioria dos casos, o lambda é usado como argumento para um método:


 list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s)); 

Qual destas três lambdas pode ser do tipo StringConsumer ? É claro para o programador que nenhum. Como é óbvio que aqui temos a cadeia da API de fluxo e há apenas interfaces funcionais da biblioteca padrão, nosso tipo não pode estar lá.


No entanto, o IDE não deve se deixar enganar, deve fornecer uma resposta precisa. E se list não é java.util.List e list.stream() não retorna java.util.stream.Stream ? Para fazer isso, você deve resolver o símbolo da list , que, como sabemos, não pode ser feito com segurança apenas com base no conteúdo do arquivo atual. E mesmo que a tenhamos instalado, a pesquisa não deve ser feita na implementação da biblioteca padrão. Talvez nós especificamente neste projeto tenha substituído a classe java.util.List pela nossa? A pesquisa deve responder a isso. Bem, é claro, lambdas são usadas não apenas em fluxos padrão, existem muitos outros métodos para onde são transferidos.


Como resultado, verifica-se que podemos consultar o índice para obter uma lista de todos os arquivos Java que usam lambdas com o número necessário de parâmetros e um tipo de retorno válido (na verdade, rastreamos apenas quatro opções: void, non-void, boolean e any). E depois o que? Para cada um desses arquivos, crie uma árvore PSI completa (é como uma árvore de análise, mas com resolução de caracteres, inferência de tipo e outras coisas inteligentes) e execute honestamente a inferência de tipo para lambda? Em um projeto grande, você não esperará uma lista de todas as implementações de interface, mesmo que haja apenas duas delas.


Acontece que precisamos executar as seguintes etapas:


  • Pergunte ao índice (barato)
  • Crie um PSI (caro)
  • Imprimir tipo lambda (muito caro)

No Java versão 8 e posterior, a inferência de tipo é uma operação incrivelmente cara. Em uma cadeia complexa de chamadas, você pode ter muitos parâmetros curinga genéricos, cujos valores precisam ser determinados usando o procedimento furioso descrito no capítulo 18 da especificação. Isso pode ser feito em segundo plano para o arquivo atual sendo editado, mas será difícil fazer isso para milhares de arquivos não abertos.


Aqui, no entanto, você pode cortar um pouco a esquina: na maioria dos casos, não precisamos do tipo final. Se apenas lambda não for passado para um método que aceita um parâmetro genérico nesse local, podemos nos livrar da última etapa da substituição de parâmetro. Digamos, se deduzimos o tipo lambda java.util.function.Function<T, R> , não podemos calcular os valores dos parâmetros de substituição T e R : e, portanto, fica claro se é necessário retorná-lo ao resultado da pesquisa ou não. Embora isso não funcione ao chamar um método como este:


 static <T> void doSmth(Class<T> aClass, T value) {} 

Este método pode ser chamado assim: doSmth(Runnable.class, () -> {}) . Em seguida, o tipo lambda será exibido como T e você terá que substituí-lo de qualquer maneira. Mas este é um caso raro. Portanto, acaba economizando, mas não mais que 10%. O problema não está fundamentalmente resolvido.


Outra idéia: se a inferência exata de tipo é complexa, vamos fazer uma conclusão aproximada. Deixe que ele funcione apenas em tipos de classes apagados e não reduza o conjunto de restrições, conforme escrito na especificação, mas siga simplesmente a cadeia de chamadas. Desde que o tipo apagado não inclua parâmetros genéricos, tudo estará bem. Por exemplo, pegue o fluxo do exemplo acima e determine se o último lambda implementa nosso StringConsumer :


  • Variável de list -> tipo java.util.List
  • List.stream() - List.stream() tipo java.util.stream.Stream
  • Stream.filter(...) → digite java.util.stream.Stream , nem olhamos para os argumentos do filter , qual é a diferença
  • Stream.map(...) - Stream.map(...) tipo java.util.stream.Stream , da mesma forma
  • O Stream.forEach(...) → existe esse método, seu parâmetro é do tipo Consumer , que, obviamente, não é StringConsumer .

Bem, eles fizeram sem uma inferência de tipo completa. Com uma abordagem tão simples, no entanto, é fácil executar métodos sobrecarregados. Se não iniciarmos completamente a inferência de tipo, você não poderá selecionar a versão sobrecarregada correta. Embora não seja, às vezes é possível se o número de parâmetros do método for diferente. Por exemplo:


 CompletableFuture.supplyAsync(Foo::bar, myExecutor).thenRunAsync(s -> list.add(s)); 

Aqui podemos entender facilmente que


  • Existem dois métodos CompletableFuture.supplyAsync , mas um recebe um argumento e o segundo, dois; portanto, escolha o que recebe dois. Retorna um CompletableFuture .
  • thenRunAsync métodos thenRunAsync também thenRunAsync dois e, a partir deles, você pode escolher da mesma forma aquele que recebe um argumento. O parâmetro correspondente é do tipo Runnable , o que significa que não é StringConsumer .

Se vários métodos aceitarem o mesmo número de parâmetros, ou alguns tiverem um número variável de parâmetros e também parecerem adequados, será necessário acompanhar todas as opções. Mas muitas vezes isso também não é assustador. Por exemplo:


 new StringBuilder().append(foo).append(bar).chars().forEach(s -> list.add(s)); 

  • new StringBuilder() obviamente cria java.lang.StringBuilder . Para os designers, ainda permitimos o link, mas a inferência de tipo complexa não é necessária aqui. Mesmo que houvesse new Foo<>(x, y, z) , não exibimos os valores dos parâmetros típicos, estamos interessados ​​apenas em Foo .
  • Existem StringBuilder.append métodos StringBuilder.append que usam um argumento, mas todos retornam o tipo java.lang.StringBuilder , portanto, não importa que tipo foo e bar .
  • O método StringBuilder.chars um e retorna java.util.stream.IntStream .
  • O método IntStream.forEach um e aceita o tipo IntConsumer .

Mesmo que várias opções permaneçam em algum lugar, você pode acompanhar todas elas. Por exemplo, o tipo de lambda passado para ForkJoinPool.getInstance().submit(...) pode ser Runnable ou Callable , mas se estamos procurando algo terceiro, ainda podemos descartar esse lambda.


Uma situação desagradável ocorre quando um método retorna um parâmetro genérico. Em seguida, o procedimento é interrompido e você deve executar a inferência de tipo completa. No entanto, apoiamos um caso. Ele aparece bem na minha biblioteca StreamEx, que possui uma classe AbstractStreamEx<T, S extends AbstractStreamEx<T, S>> contendo métodos como o S filter(Predicate<? super T> predicate) . Normalmente, as pessoas trabalham com uma classe específica StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>> . Nesse caso, você pode executar a substituição do parâmetro type e descobrir que S = StreamEx .


Bem, em muitos casos, nos livramos de uma inferência de tipo muito cara. Mas não fizemos nada com a construção do PSI. É uma pena analisar um arquivo em quinhentas linhas apenas para descobrir que o lambda na linha 480 não se encaixa em nossa consulta. Vamos voltar ao nosso fluxo:


 list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s)); 

Se list é uma variável local, parâmetro de método ou campo na classe atual, então já no estágio de indexação podemos encontrar sua declaração e estabelecer que o nome abreviado do tipo é
List Assim, no índice do último lambda, podemos colocar as seguintes informações:


O tipo desse lambda é o tipo de parâmetro do método forEach de um argumento, chamado no resultado do método de map de um argumento, chamado no resultado do método de filter de um argumento, chamado no resultado do método de stream partir de zero argumentos, chamado em um objeto do tipo List .

Toda essa informação está disponível no arquivo atual, o que significa que pode ser colocada no índice. Durante a pesquisa, solicitamos ao índice essas informações sobre todas as lambdas e tentamos restaurar o tipo lambda sem criar um PSI. Primeiro, você terá que fazer uma pesquisa global por classes com o nome abreviado List . Obviamente, encontraremos não apenas java.util.List , mas também java.awt.List ou algo do código do projeto do usuário. Além disso, enviaremos todas essas classes para o mesmo procedimento de resolução de tipo impreciso que usamos anteriormente. Frequentemente, as próprias aulas extras são filtradas rapidamente. Por exemplo, em java.awt.List não há método de stream , portanto ele é excluído ainda mais. Mas mesmo que algo supérfluo esteja conosco até o fim e encontrarmos vários candidatos para o tipo de nossa lambda, há boas chances de que todos eles não se ajustem à consulta de pesquisa, e ainda assim evitaremos criar um PSI completo.


É possível que a pesquisa global seja muito cara (existem muitas classes de List no projeto), ou o início da cadeia não é permitido no contexto de um único arquivo (por exemplo, este é o campo da classe pai) ou a cadeia será interrompida em algum lugar porque o método retorna um parâmetro genérico. Então não desistimos imediatamente e tentamos novamente começar com uma pesquisa global sobre o próximo método de encadeamento. Por exemplo, para a cadeia map.get(key).updateAndGet(a -> a * 2) , a seguinte instrução foi inserida no índice:


O tipo de lambda é o tipo do único parâmetro do método updateAndGet , chamado no resultado do método get com um parâmetro, chamado no objeto do tipo Map .

Sejamos sortudos e no projeto existe apenas um tipo de Map - java.util.Map . Ele possui um método get(Object) , mas infelizmente retorna o parâmetro genérico V Em seguida, updateAndGet a cadeia e procuramos globalmente o método updateAndGet com um parâmetro (usando o índice, é claro). AtomicInteger , existem apenas três métodos no projeto, nas AtomicInteger , AtomicLong e AtomicReference com parâmetros do tipo IntUnaryOperator , LongUnaryOperator e UnaryOperator , respectivamente. Se estamos procurando por outro tipo, descobrimos que esse lambda não se encaixa e o PSI não pode ser construído.


Surpreendentemente, esse é um exemplo vívido de um recurso que, com o tempo, começa a funcionar mais lentamente. Por exemplo, você está procurando a implementação de uma interface funcional, existem apenas três delas no projeto, e o IntelliJ IDEA as procura por dez segundos. E você se lembra muito bem de que três anos atrás havia também três deles, você também os procurava, mas o ambiente deu uma resposta em dois segundos na mesma máquina. E seu projeto, embora enorme, cresceu em três anos, talvez em cinco por cento. Obviamente, você começa a se ressentir com razão do que esses desenvolvedores mexeram com o fato de o IDE ter começado a desacelerar tanto. Mãos para arrancar esses infelizes programadores.


E talvez não tenhamos mudado nada. Talvez a pesquisa funcione da mesma forma que há três anos. Apenas três anos atrás, você acabou de mudar para o Java 8 e tinha, digamos, cem lambdas em seu projeto. E agora seus colegas transformaram classes anônimas em lambdas, começaram a usar ativamente fluxos ou conectaram algum tipo de biblioteca reativa, como resultado das lambdas tornou-se não cem, mas dez mil. E agora, para desenterrar as três lambdas necessárias, o IDE deve ser pesquisado cem vezes mais.


Eu disse "talvez" porque, é claro, voltamos a essa pesquisa periodicamente e tentamos acelerá-la. Mas aqui você tem que remar nem mesmo contra a corrente, mas até a cachoeira. Tentamos, mas o número de lambdas em projetos está crescendo muito rapidamente.

Source: https://habr.com/ru/post/pt444052/


All Articles