Una característica importante de cualquier IDE es la búsqueda y navegación a través del código. Una de las opciones de búsqueda de Java utilizadas con frecuencia es buscar todas las implementaciones de esta interfaz. A menudo, dicha función se denomina Jerarquía de tipos y se parece a la imagen de la derecha.
Iterar a través de todas las clases de un proyecto cuando se llama a esta función es ineficiente. Puede guardar la jerarquía de clases completa en el índice en tiempo de compilación, ya que el compilador la construye de todos modos. Hacemos esto si el IDE inicia la compilación y no se delega, por ejemplo, en Gradle. Pero esto solo funciona si nada ha cambiado en el módulo después de la compilación. Pero en el caso general, los códigos fuente son la fuente de información más relevante, y los índices se basan en códigos fuente.
Encontrar herederos inmediatos es una tarea simple si no se trata de una interfaz funcional. Al buscar implementaciones de la interfaz Foo
, debe encontrar todas las clases donde hay implements Foo
, e interfaces donde hay extends Foo
, así como clases anónimas de la new Foo(...) {...}
forma new Foo(...) {...}
. Para hacer esto, es suficiente construir de antemano el árbol de sintaxis de cada archivo de proyecto, encontrar las construcciones correspondientes y agregarlas al índice.
Por supuesto, aquí hay una leve sutileza: quizás esté buscando la interfaz com.example.goodcompany.Foo
, pero en algún org.example.evilcompany.Foo
se usa org.example.evilcompany.Foo
. ¿Es posible poner previamente el nombre completo de la interfaz principal en el índice? Hay dificultades con esto. Por ejemplo, el archivo donde se usa la interfaz puede verse así:
Mirando solo el archivo, no podemos entender cuál es el verdadero nombre completo de Foo
. Tienes que mirar el contenido de varios paquetes. Y cada paquete se puede definir en varios lugares (por ejemplo, en varios archivos jar). La indexación llevará mucho tiempo si, al analizar este archivo, tenemos que hacer la resolución completa del personaje. Pero el problema principal ni siquiera es esto, sino que el índice creado en el archivo MyFoo.java
dependerá no solo de él, sino también de otros archivos. Después de todo, podemos transferir la descripción de la interfaz de Foo
, por ejemplo, del paquete org.example.bar
paquete org.example.bar
, y no cambiar nada en el archivo MyFoo.java
, y el nombre completo de Foo
cambiará.
Los índices en IntelliJ IDEA dependen solo del contenido de un solo archivo. Por un lado, esto es muy conveniente: el índice relacionado con un archivo en particular deja de ser válido cuando este archivo cambia. Por otro lado, esto impone grandes restricciones sobre lo que se puede colocar en el índice. Por ejemplo, no puede almacenar de manera confiable los nombres completos de las clases principales en el índice. Pero, en principio, esto no es tan aterrador. Al consultar la jerarquía de tipos, podemos encontrar todo lo que se adapte al nombre corto y luego, para estos archivos, realicemos una resolución honesta del personaje y determinemos si realmente nos conviene. En la mayoría de los casos, no habrá demasiados caracteres adicionales, y dicha verificación será bastante rápida.
La situación cambia dramáticamente cuando la clase cuyos descendientes estamos buscando es una interfaz funcional. Luego, además de los herederos explícitos y anónimos, obtenemos expresiones lambda y enlaces de métodos. ¿Qué hacer ahora en un índice y qué calcular directamente en la búsqueda?
Supongamos que tenemos una interfaz funcional:
@FunctionalInterface public interface StringConsumer { void consume(String s); }
Hay diferentes expresiones lambda en el código. Por ejemplo:
() -> {}
Es decir, podemos filtrar rápidamente solo aquellas lambdas que tienen el número incorrecto de parámetros o, obviamente, el tipo de retorno incorrecto, por ejemplo, nulo frente a no nulo. Por lo general, es imposible determinar el tipo de retorno con mayor precisión. Digamos, en lambda s -> list.add(s)
para esto, necesita resolver la list
caracteres y add
, y, posiblemente, iniciar un procedimiento de inferencia de tipo completo. Todo esto es largo y requerirá una fijación en el contenido de otros archivos.
Tenemos suerte si nuestra interfaz funcional toma cinco argumentos. Pero si solo se necesita un argumento, dicho filtro dejará una gran cantidad de lambdas adicionales. Peor aún con las referencias de métodos. En principio, la aparición de cualquier referencia a un método no se puede decir de ninguna manera si es adecuado o no.
¿Quizás deberías mirar alrededor de la lambda para entender algo? Sí, a veces funciona. Por ejemplo:
En todos estos casos, el nombre corto de la interfaz funcional correspondiente se puede encontrar en el archivo actual y poner en el índice al lado de la expresión funcional, ya sea una referencia lambda o de método. Desafortunadamente, en proyectos reales, estos casos cubren una fracción muy pequeña de todas las lambdas. En la gran mayoría de los casos, lambda se usa como argumento de un método:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
¿Cuál de estas tres lambdas puede ser de tipo StringConsumer
? Está claro para el programador que ninguno. Como es obvio que aquí tenemos la cadena Stream API, y solo hay interfaces funcionales de la biblioteca estándar, nuestro tipo no puede estar allí.
Sin embargo, el IDE no debe dejarse engañar, debe dar una respuesta precisa. ¿Qué list.stream()
si list
no es java.util.List
en absoluto y list.stream()
no devuelve java.util.stream.Stream
? Para hacer esto, debe resolver el símbolo de la list
, que, como sabemos, no se puede hacer de manera confiable solo en función del contenido del archivo actual. E incluso si lo instalamos, la búsqueda no debe establecerse en la implementación de la biblioteca estándar. ¿Tal vez específicamente en este proyecto reemplazamos la clase java.util.List
con la nuestra? La búsqueda debe responder a esto. Bueno, por supuesto, las lambdas se usan no solo en transmisiones estándar, sino que hay muchos otros métodos donde se transfieren.
Como resultado, resulta que podemos consultar el índice para obtener una lista de todos los archivos Java que usan lambdas con el número requerido de parámetros y un tipo de retorno válido (de hecho, solo rastreamos cuatro opciones: vacío, no vacío, booleano y cualquiera). ¿Y luego que? Para cada uno de estos archivos, construya un árbol PSI completo (¿es como un árbol de análisis, pero con resolución de caracteres, inferencia de tipos y otras cosas inteligentes) y honestamente ejecute la inferencia de tipos para lambda? Luego, en un proyecto grande, no esperará una lista de todas las implementaciones de la interfaz, incluso si solo hay dos de ellas.
Resulta que tenemos que hacer los siguientes pasos:
- Pregunta el índice (barato)
- Construir una PSI (costosa)
- Imprimir tipo lambda (muy caro)
En Java versión 8 y posterior, la inferencia de tipos es una operación increíblemente costosa. En una cadena compleja de llamadas, puede tener muchos parámetros genéricos comodín, cuyos valores deben determinarse utilizando el procedimiento furioso descrito en el capítulo 18 de la especificación. Esto se puede hacer en segundo plano para el archivo actual que se está editando, pero será difícil hacerlo para miles de archivos sin abrir.
Aquí, sin embargo, puede reducir un poco la esquina: en la mayoría de los casos no necesitamos el tipo final. Si solo lambda no se pasa a un método que toma un parámetro genérico en este lugar, podemos deshacernos del último paso de la sustitución de parámetros. Digamos, si dedujimos el tipo lambda java.util.function.Function<T, R>
, no podemos calcular los valores de los parámetros de sustitución T
y R
: por lo tanto, está claro si devolverlo al resultado de la búsqueda o no. Aunque esto no funcionará al llamar a un método como este:
static <T> void doSmth(Class<T> aClass, T value) {}
Este método se puede llamar así: doSmth(Runnable.class, () -> {})
. Luego, el tipo lambda se mostrará como T
, y tendrá que sustituirlo de todos modos. Pero este es un caso raro. Por lo tanto, resulta ahorrar, pero no más del 10%. El problema no está resuelto fundamentalmente.
Otra idea: si la inferencia de tipo exacta es compleja, hagamos una conclusión aproximada. Deje que funcione solo en tipos de clases borrados y no reduzca el conjunto de restricciones, como está escrito en la especificación, sino simplemente siga la cadena de llamadas. Mientras el tipo borrado no incluya parámetros genéricos, entonces todo está bien. Por ejemplo, tome la secuencia del ejemplo anterior y determine si la última lambda implementa nuestro StringConsumer
:
list
variables -> tipo java.util.List
List.stream()
- List.stream()
tipo java.util.stream.Stream
Stream.filter(...)
→ escriba java.util.stream.Stream
, ni siquiera miramos los argumentos del filter
, ¿cuál es la diferencia?Stream.map(...)
- Stream.map(...)
tipo java.util.stream.Stream
, de manera similar- El
Stream.forEach(...)
→ existe dicho método, su parámetro es del tipo Consumer
, que, obviamente, no es StringConsumer
.
Bueno, lo hicieron sin una inferencia de tipo completo. Sin embargo, con un enfoque tan simple, es fácil encontrar métodos sobrecargados. Si no comenzamos la inferencia de tipo por completo, entonces no puede seleccionar la versión correcta sobrecargada. Aunque no, a veces es posible si el número de parámetros del método difiere. Por ejemplo:
CompletableFuture.supplyAsync(Foo::bar, myExecutor).thenRunAsync(s -> list.add(s));
Aquí podemos entender fácilmente que
- Hay dos métodos
CompletableFuture.supplyAsync
, pero uno toma un argumento y el segundo toma dos, así que elija el que tome dos. Devuelve un CompletableFuture
. thenRunAsync
métodos thenRunAsync
también thenRunAsync
dos, y de ellos puede elegir de manera similar el que tome un argumento. El parámetro correspondiente es de tipo Runnable
, lo que significa que no es StringConsumer
.
Si varios métodos aceptan el mismo número de parámetros, o algunos tienen un número variable de parámetros y también parecen adecuados, entonces deberá realizar un seguimiento de todas las opciones. Pero a menudo esto tampoco da miedo. Por ejemplo:
new StringBuilder().append(foo).append(bar).chars().forEach(s -> list.add(s));
new StringBuilder()
obviamente crea java.lang.StringBuilder
. Para los diseñadores, todavía permitimos el enlace, pero aquí no se requiere inferencia de tipos complejos. Incluso si hubiera un new Foo<>(x, y, z)
, no mostramos los valores de los parámetros típicos, solo estamos interesados en Foo
.- Hay
StringBuilder.append
métodos StringBuilder.append
que toman un argumento, pero todos devuelven el tipo java.lang.StringBuilder
, por lo que no importa de qué tipo sean foo
y bar
. - El método
StringBuilder.chars
uno y devuelve java.util.stream.IntStream
. - El método
IntStream.forEach
uno y acepta el tipo IntConsumer
.
Incluso si quedan varias opciones en algún lugar, puede rastrearlas todas. Por ejemplo, el tipo de lambda pasado a ForkJoinPool.getInstance().submit(...)
puede ser Runnable
o Callable
, pero si estamos buscando algo tercero, aún podemos descartar ese lambda.
Una situación desagradable ocurre cuando un método devuelve un parámetro genérico. Luego, el procedimiento se descompone y debe ejecutar la inferencia de tipo completo. Sin embargo, apoyamos un caso. Se muestra bien en mi biblioteca StreamEx, que tiene una clase abstracta AbstractStreamEx<T, S extends AbstractStreamEx<T, S>>
contiene métodos como el S filter(Predicate<? super T> predicate)
. Por lo general, las personas trabajan con una clase específica StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>>
. En este caso, puede realizar la sustitución del parámetro de tipo y descubrir que S = StreamEx
.
Bueno, en muchos casos nos libramos de una inferencia de tipos muy costosa. Pero no hicimos nada con la construcción de la ISP. Es una lástima analizar un archivo en quinientas líneas solo para descubrir que el lambda en la línea 480 no se ajusta a nuestra consulta. Volvamos a nuestra transmisión:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
Si list
es una variable local, parámetro de método o campo en la clase actual, entonces ya en la etapa de indexación podemos encontrar su declaración y establecer que el nombre corto del tipo es
List
En consecuencia, en el índice de la última lambda podemos poner la siguiente información:
El tipo de este lambda es el tipo de parámetro del método forEach
de un argumento, invocado en el resultado del método map
de un argumento, invocado en el resultado del método de filter
de un argumento, invocado en el resultado del método stream
de cero argumentos, invocado en un objeto de tipo List
.
Toda esta información está disponible en el archivo actual, lo que significa que se puede colocar en el índice. Durante la búsqueda, solicitamos al índice dicha información sobre todas las lambdas e intentamos restaurar el tipo de lambda sin construir un PSI. Primero tendrá que hacer una búsqueda global de clases con el nombre abreviado List
. Por supuesto, encontraremos no solo java.util.List
, sino también java.awt.List
o algo del código del proyecto del usuario. Además, enviaremos todas estas clases al mismo procedimiento de resolución de tipo impreciso que usamos antes. A menudo, las clases adicionales se filtran rápidamente. Por ejemplo, en java.awt.List
no hay ningún método de stream
, por lo tanto, se excluye aún más. Pero incluso si algo superfluo nos acompaña hasta el final y encontramos varios candidatos para el tipo de nuestro lambda, hay buenas posibilidades de que no encajen en la consulta de búsqueda, y aún así evitaremos construir una PSI completa.
Es posible que la búsqueda global sea demasiado costosa (hay muchas clases de List
en el proyecto), o el comienzo de la cadena no está permitido en el contexto de un solo archivo (por ejemplo, este es el campo de la clase principal), o la cadena se interrumpirá en algún lugar porque el método devuelve un parámetro genérico. Entonces no nos rendimos de inmediato e intentamos nuevamente comenzar con una búsqueda global en el próximo método de encadenamiento. Por ejemplo, para la map.get(key).updateAndGet(a -> a * 2)
, la siguiente instrucción entró en el índice:
El tipo de lambda es el tipo del único parámetro del método updateAndGet
, invocado en el resultado del método get
con un parámetro, invocado en el objeto del tipo Map
.
Seamos afortunados y en el proyecto solo hay un tipo de Map
: java.util.Map
. Tiene un método get(Object)
, pero desafortunadamente devuelve el parámetro genérico V
Luego dejamos caer la cadena y buscamos globalmente el método updateAndGet
con un parámetro (usando el índice, por supuesto). AtomicInteger
, solo hay tres métodos de este tipo en el proyecto, en las AtomicInteger
, AtomicLong
y AtomicReference
con parámetros de tipo IntUnaryOperator
, LongUnaryOperator
y UnaryOperator
, respectivamente. Si estamos buscando otro tipo, descubrimos que este lambda no encaja y que PSI no se puede construir.
Sorprendentemente, este es un vívido ejemplo de una característica que, con el tiempo, comienza a funcionar más lentamente. Por ejemplo, está buscando la implementación de una interfaz funcional, solo hay tres de ellas en el proyecto e IntelliJ IDEA las busca durante diez segundos. Y recuerdas muy bien que hace tres años también había tres de ellos, también los estabas buscando, pero luego el entorno dio una respuesta en dos segundos en la misma máquina. Y su proyecto, aunque enorme, ha crecido en tres años, tal vez en un cinco por ciento. Por supuesto, comienzas a resentirte justamente con lo que estos desarrolladores se equivocaron con que el IDE comenzó a desacelerarse tan terriblemente. Manos para arrancar a estos desafortunados programadores.
Y tal vez no cambiamos nada en absoluto. Tal vez la búsqueda funciona igual que hace tres años. Hace solo tres años, se cambió a Java 8 y tenía, digamos, cien lambdas en su proyecto. Y ahora sus colegas convirtieron las clases anónimas en lambdas, comenzaron a usar secuencias de forma activa o conectaron algún tipo de biblioteca reactiva, como resultado de lambdas se convirtió en no cien, sino diez mil. Y ahora, para desenterrar las tres lambdas necesarias, se debe buscar el IDE cien veces más.
Dije "tal vez" porque, por supuesto, volvemos a esta búsqueda de vez en cuando y tratamos de acelerarla. Pero aquí tienes que remar, ni siquiera contra la corriente, sino hasta la cascada. Lo intentamos, pero el número de lambdas en los proyectos está creciendo muy rápidamente.