La búsqueda de códigos y la navegación son características importantes de cualquier IDE. En Java, una de las opciones de búsqueda más utilizadas es buscar todas las implementaciones de una interfaz. Esta característica a menudo se denomina Jerarquía de tipos, y se parece a la imagen de la derecha.
Es ineficiente iterar sobre todas las clases de proyecto cuando se invoca esta característica. Una opción es guardar la jerarquía de clases completa en el índice durante la compilación, ya que el compilador la construye de todos modos. Hacemos esto cuando el IDE ejecuta la compilación y no se delega, por ejemplo, a Gradle. Pero esto solo funciona si no se ha cambiado nada en el módulo después de la compilación. En general, el código fuente es el proveedor de información más actualizado, y los índices se basan en el código fuente.
Encontrar hijos inmediatos es una tarea simple si no estamos tratando con una interfaz funcional. Al buscar implementaciones de la interfaz Foo
, necesitamos encontrar todas las clases que tienen implements Foo
e interfaces que tienen extends Foo
, así como new Foo(...) {...}
clases anónimas new Foo(...) {...}
. Para hacer esto, es suficiente construir de antemano un árbol de sintaxis de cada archivo de proyecto, encontrar las construcciones correspondientes y agregarlas a un índice. Sin embargo, aquí hay una complejidad: es posible que esté buscando la interfaz com.example.goodcompany.Foo
, mientras que en realidad se usa org.example.evilcompany.Foo
. ¿Podemos poner el nombre completo de la interfaz principal en el índice de antemano? Puede ser complicado Por ejemplo, el archivo donde se usa la interfaz puede verse así:
Al mirar el archivo solo, es imposible saber cuál es el nombre completo y calificado de Foo
. Tendremos que analizar el contenido de varios paquetes. Y cada paquete se puede definir en varios lugares del proyecto (por ejemplo, en varios archivos JAR). Si realizamos una resolución de símbolo adecuada al analizar este archivo, la indexación llevará mucho tiempo. Pero el problema principal es que el índice creado en MyFoo.java
también dependerá de otros archivos. Podemos mover la declaración de la interfaz de Foo
, por ejemplo, del paquete org.example.foo
paquete org.example.bar
, sin cambiar nada en el archivo MyFoo.java
, pero el nombre completo de Foo
cambiará.
En IntelliJ IDEA, los índices dependen solo del contenido de un solo archivo. Por un lado, es muy conveniente: el índice asociado con un archivo específico deja de ser válido cuando se cambia el archivo. Por otro lado, impone restricciones importantes sobre lo que se puede incluir en el índice. Por ejemplo, no permite que los nombres completos de las clases primarias se guarden de manera confiable en el índice. Pero, en general, no es tan malo. Al solicitar una jerarquía de tipos, podemos encontrar todo lo que coincida con nuestra solicitud con un nombre corto, y luego realizar la resolución de símbolo adecuada para estos archivos y determinar si eso es lo que estamos buscando. En la mayoría de los casos, no habrá demasiados símbolos redundantes y la verificación no llevará mucho tiempo.
Sin embargo, las cosas cambian cuando la clase cuyos hijos buscamos es una interfaz funcional. Luego, además de las subclases explícitas y anónimas, habrá expresiones lambda y referencias de métodos. ¿Qué ponemos en el índice ahora y qué se evaluará durante la búsqueda?
Supongamos que tenemos una interfaz funcional:
@FunctionalInterface public interface StringConsumer { void consume(String s); }
El código contiene diferentes expresiones lambda. Por ejemplo:
() -> {}
Significa que podemos filtrar rápidamente las lambdas que tienen un número inapropiado de parámetros o un tipo de retorno claramente inapropiado, por ejemplo, nulo en lugar de nulo. Por lo general, es imposible determinar el tipo de retorno con mayor precisión. Por ejemplo, en s -> list.add(s)
tendrá que resolver list
y add
, y, posiblemente, ejecutar un procedimiento de inferencia de tipo regular. Lleva tiempo y depende del contenido de otros archivos.
Tenemos suerte si la interfaz funcional toma cinco argumentos. Pero si solo se necesita uno, el filtro mantendrá una gran cantidad de lambdas innecesarias. Es aún peor cuando se trata de referencias de métodos. Por lo que parece, uno no puede decir si una referencia de método es adecuada o no.
Para aclarar las cosas, podría valer la pena mirar lo que rodea a la lambda. A veces funciona. Por ejemplo:
En todos estos casos, el nombre corto de la interfaz funcional correspondiente puede determinarse a partir del archivo actual y puede colocarse en el índice junto a la expresión funcional, ya sea una referencia lambda o de método. Desafortunadamente, en proyectos de la vida real, estos casos cubren un porcentaje muy pequeño de todas las lambdas. En la mayoría de los casos, las lambdas se usan como argumentos de método:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
¿Cuál de las tres lambdas puede contener StringConsumer
? Obviamente, ninguno. Aquí tenemos una cadena Stream API que solo presenta interfaces funcionales de la biblioteca estándar, no puede tener el tipo personalizado.
Sin embargo, el IDE debería poder ver a través del truco y darnos una respuesta exacta. ¿Qué list.stream()
si list
no es exactamente java.util.List
y list.stream()
devuelve algo diferente de java.util.stream.Stream
? Luego tendremos que resolver 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 hacemos, la búsqueda no debería depender de la implementación de la biblioteca estándar. ¿Qué sucede si en este proyecto en particular hemos reemplazado java.util.List
con una clase propia? La búsqueda debe tener esto en cuenta. Y, naturalmente, las lambdas se usan no solo en transmisiones estándar: hay muchos otros métodos a los que se pasan.
Como resultado, podemos consultar en el índice 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 buscamos cuatro opciones: void, non-void, boolean y cualquiera) Y que sigue? ¿Necesitamos construir un árbol PSI completo (una especie de árbol de análisis con resolución de símbolo, inferencia de tipos y otras características inteligentes) para cada uno de estos archivos y realizar una inferencia de tipo adecuada para lambdas? Para un gran proyecto, tomará años obtener la lista de todas las implementaciones de interfaz, incluso si solo hay dos de ellas.
Entonces, debemos seguir los siguientes pasos:
- Consultar índice (no es costoso)
- Construir PSI (costoso)
- Inferir tipo lambda (muy costoso)
Para Java 8 y versiones posteriores, la inferencia de tipos es una operación extremadamente costosa. En una cadena de llamadas compleja, puede haber muchos parámetros genéricos de sustitución, cuyos valores deben determinarse utilizando el procedimiento de golpe fuerte descrito en el Capítulo 18 de la especificación. Para el archivo actual, esto se puede hacer en segundo plano, pero procesar miles de archivos sin abrir de esta manera sería una tarea costosa.
Aquí, sin embargo, es posible cortar esquinas ligeramente: en la mayoría de los casos, no necesitamos el tipo de concreto. A menos que un método acepte un parámetro genérico donde se le pasa el lambda, se puede evitar el paso final de sustitución del parámetro. Si hemos inferido el tipo de lambda java.util.function.Function<T, R>
, no tenemos que evaluar los valores de los parámetros de sustitución T
y R
: ya está claro si incluir el lambda en los resultados de búsqueda o no Sin embargo, no funcionará para un método como este:
static <T> void doSmth(Class<T> aClass, T value) {}
Este método se puede llamar con doSmth(Runnable.class, () -> {})
. Entonces el tipo lambda se inferirá como T
, aún se requiere sustitución. Sin embargo, este es un caso raro. De hecho, podemos ahorrar algo de tiempo de CPU aquí, pero solo alrededor del 10%, por lo que esto no resuelve el problema en su esencia.
Alternativamente, cuando la inferencia de tipo precisa es demasiado complicada, puede hacerse aproximada. A diferencia de lo que sugiere la especificación, deje que funcione solo en los tipos de clase borrados y no reduzca el conjunto de restricciones, sino simplemente siga una cadena de llamadas. Mientras el tipo borrado no incluya parámetros genéricos, todo está bien. Consideremos la secuencia del ejemplo anterior y determinemos si la última lambda implementa StringConsumer
:
- variable de
list
-> tipo java.util.List
List.stream()
→ tipo java.util.stream.Stream
Stream.filter(...)
-> tipo java.util.stream.Stream
, no tenemos que considerar argumentos de filter
- de manera similar,
Stream.map(...)
→ tipo java.util.stream.Stream
Stream.forEach(...)
→ dicho método existe, su parámetro tiene el tipo de Consumer
, que obviamente no es StringConsumer
.
Y así es como podríamos hacer sin inferencia de tipo regular. Con este enfoque simple, sin embargo, es fácil encontrar métodos sobrecargados. Si no realizamos una inferencia de tipo adecuada, no podemos elegir el método correcto sobrecargado. Sin embargo, a veces es posible: si los métodos tienen un número diferente de parámetros. Por ejemplo:
CompletableFuture.supplyAsync(Foo::bar, myExecutor).thenRunAsync(s -> list.add(s));
Aquí podemos ver eso:
- Hay dos métodos
CompletableFuture.supplyAsync
; el primero toma un argumento y el segundo toma dos, así que elegimos el segundo. Devuelve CompletableFuture
. - También hay dos métodos
thenRunAsync
, y podemos elegir de manera similar el que tome un argumento. El parámetro correspondiente tiene el tipo Runnable
, lo que significa que no es StringConsumer
.
Si varios métodos toman el mismo número de parámetros o tienen un número variable de parámetros pero parecen apropiados, tendremos que buscar a través de todas las opciones. A menudo no es tan aterrador:
new StringBuilder().append(foo).append(bar).chars().forEach(s -> list.add(s));
new StringBuilder()
obviamente crea java.lang.StringBuilder
. Para los constructores, todavía resolvemos la referencia, pero aquí no se requiere inferencia de tipos complejos. Incluso si hubiera un new Foo<>(x, y, z)
, no inferiríamos los valores de los parámetros de tipo ya que solo Foo
nos interesa.- Hay muchos métodos
StringBuilder.append
que toman un argumento, pero todos devuelven el tipo java.lang.StringBuilder
, por lo que no nos importan los tipos de foo
y bar
. - Hay un método
StringBuilder.chars
, y devuelve java.util.stream.IntStream
. - Hay un único método
IntStream.forEach
, y toma el tipo IntConsumer
.
Incluso si quedan varias opciones, puede seguirlas todas. Por ejemplo, el tipo lambda pasado a ForkJoinPool.getInstance().submit(...)
puede ser Runnable
o Callable
, y si estamos buscando otra opción, aún podemos descartar este lambda.
Las cosas empeoran cuando el método devuelve un parámetro genérico. Entonces el procedimiento falla y debe realizar una inferencia de tipo adecuada. Sin embargo, hemos apoyado un caso. Se muestra bien en mi biblioteca StreamEx, que tiene una clase abstracta AbstractStreamEx<T, S extends AbstractStreamEx<T, S>>
que contiene métodos como el S filter(Predicate<? super T> predicate)
. Por lo general, las personas trabajan con una StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>>
concreta StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>>
. En este caso, puede sustituir el parámetro de tipo y descubrir que S = StreamEx
.
Así es como nos hemos librado de la costosa inferencia de tipos en muchos casos. Pero no hemos hecho nada con la construcción de PSI. Es decepcionante haber analizado un archivo con 500 líneas de código solo para descubrir que el lambda en la línea 480 no coincide con 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, un parámetro de método o un campo en la clase actual, ya en la etapa de indexación, podemos encontrar su declaración y establecer que el nombre de tipo corto es List
. En consecuencia, podemos poner la siguiente información en el índice de la última lambda:
Este tipo lambda es un tipo de parámetro de un método forEach
que toma un argumento, llamado el resultado de un método de map
que toma un argumento, llamado el resultado de un método de filter
que toma un argumento, llamado el resultado de un método de stream
que toma cero argumentos, invocados en un objeto List
.
Toda esta información está disponible en el archivo actual y, por lo tanto, se puede colocar en el índice. Mientras buscamos, solicitamos dicha información sobre todas las lambdas del índice e intentamos restaurar el tipo de lambda sin construir un PSI. Primero, tendremos que realizar una búsqueda global de clases con el nombre corto de List
. Obviamente, encontraremos no solo java.util.List
sino también java.awt.List
o algo del código del proyecto. A continuación, todas estas clases pasarán por el mismo procedimiento de inferencia de tipo aproximado que usamos antes. Las clases redundantes a menudo se filtran rápidamente. Por ejemplo, java.awt.List
no tiene método de stream
, por lo tanto, se excluirá. Pero incluso si queda algo redundante y encontramos varios candidatos para el tipo lambda, es probable que ninguno de ellos coincida con la consulta de búsqueda, y seguiremos evitando construir una PSI completa.
La búsqueda global podría resultar demasiado costosa (cuando un proyecto contiene demasiadas clases de List
), o el comienzo de la cadena no podría resolverse en el contexto de un archivo (por ejemplo, es un campo de una clase principal), o el la cadena podría romperse ya que el método devuelve un parámetro genérico. No nos rendiremos e intentaremos comenzar de nuevo con la búsqueda global del próximo método de la cadena. Por ejemplo, para la map.get(key).updateAndGet(a -> a * 2)
, la siguiente instrucción va al índice:
Este tipo lambda es el tipo del parámetro único de un método updateAndGet
, invocado en el resultado de un método get
con un parámetro, invocado en un objeto Map
.
Imagina que tenemos suerte, y el proyecto solo tiene un tipo de Map
java.util.Map
. Tiene un método get(Object)
, pero, desafortunadamente, devuelve un parámetro genérico V
Luego descartaremos la cadena y buscaremos el método updateAndGet
con un parámetro globalmente (usando el índice, por supuesto). Y nos complace descubrir que solo hay tres métodos de este tipo en el proyecto: en las AtomicInteger
, AtomicLong
y AtomicReference
con los tipos de parámetros IntUnaryOperator
, LongUnaryOperator
y UnaryOperator
, respectivamente. Si estamos buscando otro tipo, ya hemos descubierto que este lambda no coincide con la solicitud, y no tenemos que construir la PSI.
Sorprendentemente, este es un buen ejemplo de una característica que funciona más lentamente con el tiempo. Por ejemplo, cuando busca implementaciones de una interfaz funcional y solo tiene tres de ellas en su proyecto, IntelliJ IDEA tarda diez segundos en encontrarlas. Recuerde que hace tres años su número era el mismo, pero el IDE le proporcionó los resultados de búsqueda en solo dos segundos en la misma máquina. Y aunque su proyecto es enorme, solo ha crecido un cinco por ciento en estos años. Es razonable comenzar a quejarse de lo que los desarrolladores de IDE han hecho mal para hacerlo tan terriblemente lento.
Si bien podríamos no haber cambiado nada en absoluto. La búsqueda funciona igual que hace tres años. El caso es que hace tres años, simplemente cambiaste a Java 8 y solo tenías cien lambdas en tu proyecto. Por ahora, sus colegas han convertido las clases anónimas en lambdas, han comenzado a usar secuencias o alguna biblioteca reactiva. Como resultado, en lugar de cien lambdas, hay diez mil. Y ahora, para encontrar los tres necesarios, el IDE tiene que buscar entre cien veces más opciones.
Dije "podríamos" porque, naturalmente, volvemos a esta búsqueda de vez en cuando e intentamos acelerarla. Pero es como remar el arroyo, o más bien subir la cascada. Nos esforzamos mucho, pero la cantidad de lambdas en los proyectos sigue creciendo muy rápido.