Codesuche und Navigation sind wichtige Merkmale jeder IDE. In Java ist eine der häufig verwendeten Suchoptionen die Suche nach allen Implementierungen einer Schnittstelle. Diese Funktion wird häufig als Typhierarchie bezeichnet und sieht genauso aus wie das Bild rechts.
Es ist ineffizient, alle Projektklassen zu durchlaufen, wenn diese Funktion aufgerufen wird. Eine Möglichkeit besteht darin, die gesamte Klassenhierarchie während der Kompilierung im Index zu speichern, da der Compiler sie trotzdem erstellt. Wir tun dies, wenn die Kompilierung von der IDE ausgeführt und beispielsweise nicht an Gradle delegiert wird. Dies funktioniert jedoch nur, wenn nach der Kompilierung im Modul nichts geändert wurde. Im Allgemeinen ist der Quellcode der aktuellste Informationsanbieter, und die Indizes basieren auf dem Quellcode.
Es ist eine einfache Aufgabe, unmittelbare Kinder zu finden, wenn es sich nicht um eine funktionale Schnittstelle handelt. Bei der Suche nach Implementierungen der Foo
Schnittstelle müssen alle Klassen gefunden werden, die implements Foo
haben implements Foo
und Schnittstellen, die extends Foo
, sowie new Foo(...) {...}
anonyme new Foo(...) {...}
-Klassen. Dazu reicht es aus, im Voraus einen Syntaxbaum für jede Projektdatei zu erstellen, die entsprechenden Konstrukte zu finden und sie einem Index hinzuzufügen. Hier gibt es jedoch eine Schwierigkeit: Möglicherweise suchen Sie nach der Schnittstelle com.example.goodcompany.Foo
, während org.example.evilcompany.Foo
tatsächlich verwendet wird. Können wir den vollständigen Namen der übergeordneten Schnittstelle im Voraus in den Index einfügen? Es kann schwierig sein. Die Datei, in der die Schnittstelle verwendet wird, sieht beispielsweise folgendermaßen aus:
Wenn Sie nur die Datei betrachten, ist es unmöglich zu sagen, wie der tatsächliche vollqualifizierte Name von Foo
lautet. Wir müssen uns den Inhalt mehrerer Pakete ansehen. Und jedes Paket kann an mehreren Stellen im Projekt definiert werden (z. B. in mehreren JAR-Dateien). Wenn wir bei der Analyse dieser Datei eine korrekte Symbolauflösung durchführen, nimmt die Indizierung viel Zeit in Anspruch. Das Hauptproblem ist jedoch, dass der auf MyFoo.java
Index auch von anderen Dateien abhängt. Wir können die Deklaration der Foo
Schnittstelle beispielsweise vom Paket org.example.foo
in das Paket org.example.bar
, ohne etwas in der Datei org.example.bar
zu ändern, aber der vollständig qualifizierte Name von Foo
ändert sich.
In IntelliJ IDEA hängen Indizes nur vom Inhalt einer einzelnen Datei ab. Einerseits ist es sehr praktisch: Der einer bestimmten Datei zugeordnete Index wird ungültig, wenn die Datei geändert wird. Auf der anderen Seite gibt es erhebliche Einschränkungen für das, was in den Index aufgenommen werden kann. Beispielsweise können die vollständig qualifizierten Namen der übergeordneten Klassen nicht zuverlässig im Index gespeichert werden. Aber im Allgemeinen ist es nicht so schlimm. Wenn wir eine Typhierarchie anfordern, können wir alles, was unserer Anfrage entspricht, durch einen Kurznamen finden und dann die richtige Symbolauflösung für diese Dateien durchführen und feststellen, ob dies das ist, wonach wir suchen. In den meisten Fällen gibt es nicht zu viele redundante Symbole und die Überprüfung dauert nicht lange.
Die Dinge ändern sich jedoch, wenn die Klasse, deren Kinder wir suchen, eine funktionale Schnittstelle ist. Zusätzlich zu den expliziten und anonymen Unterklassen gibt es dann Lambda-Ausdrücke und Methodenreferenzen. Was setzen wir jetzt in den Index ein und was soll bei der Suche ausgewertet werden?
Nehmen wir an, wir haben eine funktionale Schnittstelle:
@FunctionalInterface public interface StringConsumer { void consume(String s); }
Der Code enthält verschiedene Lambda-Ausdrücke. Zum Beispiel:
() -> {}
Dies bedeutet, dass wir Lambdas schnell herausfiltern können, die eine unangemessene Anzahl von Parametern oder einen eindeutig unangemessenen Rückgabetyp haben, z. B. void statt non void. Es ist normalerweise unmöglich, den Rückgabetyp genauer zu bestimmen. In s -> list.add(s)
Sie beispielsweise list
auflösen und add
und möglicherweise eine reguläre Typinferenzprozedur ausführen. Es braucht Zeit und hängt vom Inhalt anderer Dateien ab.
Wir haben Glück, wenn die Funktionsschnittstelle fünf Argumente enthält. Wenn jedoch nur einer benötigt wird, hält der Filter eine große Anzahl unnötiger Lambdas. Es ist noch schlimmer, wenn es um Methodenreferenzen geht. Übrigens kann man nicht sagen, ob eine Methodenreferenz geeignet ist oder nicht.
Um die Dinge klar zu machen, könnte es sich lohnen, einen Blick auf die Umgebung des Lambda zu werfen. Manchmal funktioniert es. Zum Beispiel:
In all diesen Fällen kann der Kurzname der entsprechenden Funktionsschnittstelle aus der aktuellen Datei ermittelt und in den Index neben dem Funktionsausdruck eingefügt werden, sei es ein Lambda oder eine Methodenreferenz. Leider decken diese Fälle in realen Projekten einen sehr kleinen Prozentsatz aller Lambdas ab. In den meisten Fällen werden Lambdas als Methodenargumente verwendet:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
Welches der drei Lambdas kann StringConsumer
enthalten? Offensichtlich keine. Hier haben wir eine Stream-API-Kette, die nur funktionale Schnittstellen aus der Standardbibliothek enthält und keinen benutzerdefinierten Typ haben kann.
Die IDE sollte jedoch in der Lage sein, den Trick zu durchschauen und uns eine genaue Antwort zu geben. Was ist, wenn list
nicht genau java.util.List
ist und list.stream()
etwas anderes als java.util.stream.Stream
zurückgibt? Dann müssen wir die list
auflösen, was bekanntlich nicht nur auf der Grundlage des Inhalts der aktuellen Datei zuverlässig durchgeführt werden kann. Und selbst wenn wir dies tun, sollte die Suche nicht auf der Implementierung der Standardbibliothek beruhen. Was ist, wenn wir in diesem speziellen Projekt java.util.List
durch eine eigene Klasse ersetzt haben? Die Suche muss dies berücksichtigen. Und natürlich werden Lambdas nicht nur in Standardströmen verwendet: Es gibt viele andere Methoden, an die sie übergeben werden.
Infolgedessen können wir den Index nach einer Liste aller Java-Dateien abfragen, die Lambdas mit der erforderlichen Anzahl von Parametern und einem gültigen Rückgabetyp verwenden (tatsächlich suchen wir nur nach vier Optionen: void, non-void, boolean und beliebig). Und was kommt als nächstes? Müssen wir für jede dieser Dateien einen vollständigen PSI-Baum (eine Art Analysebaum mit Symbolauflösung, Typinferenz und anderen intelligenten Funktionen) erstellen und eine korrekte Typinferenz für Lambdas durchführen? Bei einem großen Projekt wird es ewig dauern, bis die Liste aller Schnittstellenimplementierungen angezeigt wird, auch wenn es nur zwei davon gibt.
Wir müssen also die folgenden Schritte ausführen:
- Fragen Sie den Index (nicht teuer)
- PSI erstellen (teuer)
- Infer Lambda-Typ (sehr teuer)
Für Java 8 und höher ist die Typinferenz eine äußerst kostspielige Operation. In einer komplexen Aufrufkette kann es viele generische Substitutionsparameter geben, deren Werte mithilfe des in Kapitel 18 der Spezifikation beschriebenen Hard-Hitting-Verfahrens ermittelt werden müssen. Für die aktuelle Datei kann dies im Hintergrund erfolgen, aber die Verarbeitung von Tausenden ungeöffneten Dateien auf diese Weise wäre eine teure Aufgabe.
Hier ist es jedoch möglich, Ecken leicht zu schneiden: In den meisten Fällen benötigen wir den Betontyp nicht. Sofern eine Methode keinen generischen Parameter akzeptiert, an den das Lambda übergeben wird, kann der letzte Schritt der Parametersubstitution vermieden werden. Wenn wir auf den Lambda-Typ java.util.function.Function<T, R>
, müssen wir die Werte der Substitutionsparameter T
und R
nicht auswerten: Es ist bereits klar, ob das Lambda in die Suchergebnisse aufgenommen werden soll oder nicht. Bei einer Methode wie dieser funktioniert dies jedoch nicht:
static <T> void doSmth(Class<T> aClass, T value) {}
Diese Methode kann mit doSmth(Runnable.class, () -> {})
aufgerufen werden. Dann wird der Lambda-Typ als T
, eine Substitution ist noch erforderlich. Dies ist jedoch ein seltener Fall. Wir können hier tatsächlich etwas CPU-Zeit sparen, aber nur etwa 10%, so dass das Problem im Wesentlichen nicht gelöst wird.
Wenn die genaue Typinferenz zu kompliziert ist, kann sie alternativ angenähert werden. Lassen Sie es anders als in der Spezifikation vorgeschlagen nur für die gelöschten Klassentypen funktionieren und reduzieren Sie die Einschränkungen nicht, sondern folgen Sie einfach einer Aufrufkette. Solange der gelöschte Typ keine generischen Parameter enthält, ist alles in Ordnung. Betrachten wir den Stream aus dem obigen Beispiel und bestimmen Sie, ob das letzte Lambda StringConsumer
implementiert:
- Listenvariable -> Typ
java.util.List
List.stream()
-Methode → Typ java.util.stream.Stream
Stream.filter(...)
-Methode -> Typ java.util.stream.Stream
, filter
müssen nicht berücksichtigt filter
- In ähnlicher Weise ist die
Stream.map(...)
-Methode → java.util.stream.Stream
Typ Stream.forEach(...)
-Methode → Eine solche Methode existiert, ihr Parameter hat den Consumer
Typ, der offensichtlich nicht StringConsumer
.
Und so könnten wir ohne reguläre Typinferenz auskommen. Mit diesem einfachen Ansatz ist es jedoch leicht, auf überladene Methoden zu stoßen. Wenn wir keine ordnungsgemäße Typinferenz durchführen, können wir nicht die richtige überladene Methode auswählen. Manchmal ist es jedoch möglich: Wenn Methoden eine andere Anzahl von Parametern haben. Zum Beispiel:
CompletableFuture.supplyAsync(Foo::bar, myExecutor).thenRunAsync(s -> list.add(s));
Hier können wir das sehen:
- Es gibt zwei
CompletableFuture.supplyAsync
Methoden. Der erste nimmt ein Argument und der zweite zwei, also wählen wir den zweiten. Es gibt CompletableFuture
. - Es gibt auch zwei
thenRunAsync
Methoden, und wir können auf ähnliche Weise die auswählen, die ein Argument thenRunAsync
. Der entsprechende Parameter hat Runnable
Typ Runnable
, Runnable
er ist kein StringConsumer
.
Wenn mehrere Methoden dieselbe Anzahl von Parametern verwenden oder eine variable Anzahl von Parametern haben, aber angemessen aussehen, müssen wir alle Optionen durchsuchen. Oft ist es nicht so beängstigend:
new StringBuilder().append(foo).append(bar).chars().forEach(s -> list.add(s));
new StringBuilder()
erstellt offensichtlich java.lang.StringBuilder
. Für Konstruktoren lösen wir die Referenz weiterhin auf, aber eine komplexe Typinferenz ist hier nicht erforderlich. Selbst wenn es new Foo<>(x, y, z)
gäbe, würden wir die Werte der Typparameter nicht ableiten, da nur Foo
für uns von Interesse ist.- Es gibt viele
StringBuilder.append
Methoden, die ein Argument annehmen, aber alle geben den Typ java.lang.StringBuilder
, sodass uns die Typen foo
und bar
egal sind. - Es gibt eine
StringBuilder.chars
Methode, die java.util.stream.IntStream
zurückgibt. - Es gibt eine einzige
IntStream.forEach
Methode, die IntConsumer
Typ IntConsumer
.
Auch wenn noch mehrere Optionen verfügbar sind, können Sie alle verfolgen. Beispielsweise kann der an ForkJoinPool.getInstance().submit(...)
Lambda-Typ ForkJoinPool.getInstance().submit(...)
Runnable
oder Callable
. Wenn wir nach einer anderen Option suchen, können wir dieses Lambda dennoch verwerfen.
Es wird schlimmer, wenn die Methode einen generischen Parameter zurückgibt. Dann schlägt die Prozedur fehl und Sie müssen eine ordnungsgemäße Typinferenz durchführen. Wir haben jedoch einen Fall unterstützt. Es ist in meiner StreamEx-Bibliothek gut dargestellt, die eine AbstractStreamEx<T, S extends AbstractStreamEx<T, S>>
Extraktionsklasse AbstractStreamEx<T, S extends AbstractStreamEx<T, S>>
enthält, die Methoden wie den S filter(Predicate<? super T> predicate)
. Normalerweise arbeiten Leute mit einer konkreten StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>>
-Klasse. In diesem Fall können Sie den Typparameter ersetzen und feststellen, dass S = StreamEx
.
Auf diese Weise haben wir in vielen Fällen die kostspielige Typinferenz beseitigt. Aber wir haben nichts mit dem Aufbau von PSI gemacht. Es ist enttäuschend, eine Datei mit 500 Codezeilen analysiert zu haben, um festzustellen, dass das Lambda in Zeile 480 nicht mit unserer Abfrage übereinstimmt. Kommen wir zurück zu unserem Stream:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
Wenn list
eine lokale Variable, ein Methodenparameter oder ein Feld in der aktuellen Klasse ist, die sich bereits in der Indizierungsphase befindet, können wir ihre Deklaration finden und feststellen, dass der Kurztypname List
. Dementsprechend können wir die folgenden Informationen in den Index für das letzte Lambda aufnehmen:
Dieser Lambda-Typ ist ein Parametertyp einer forEach
Methode, die ein Argument verwendet, das für das Ergebnis einer map
Methode aufgerufen wird, die ein Argument verwendet, und das Ergebnis einer filter
, die ein Argument verwendet, das für das Ergebnis einer stream
Methode aufgerufen wird Dies erfordert keine Argumente, die für ein List
Objekt aufgerufen werden.
Alle diese Informationen sind aus der aktuellen Datei verfügbar und können daher in den Index aufgenommen werden. Während der Suche fordern wir solche Informationen zu allen Lambdas aus dem Index an und versuchen, den Lambda-Typ wiederherzustellen, ohne einen PSI zu erstellen. Zuerst müssen wir eine globale Suche nach Klassen mit dem kurzen List
. Offensichtlich finden wir nicht nur java.util.List
sondern auch java.awt.List
oder etwas aus dem Projektcode. Als nächstes durchlaufen alle diese Klassen das gleiche ungefähre Typinferenzverfahren, das wir zuvor verwendet haben. Redundante Klassen werden oft schnell herausgefiltert. Zum Beispiel hat java.awt.List
keine stream
Methode, daher wird sie ausgeschlossen. Aber selbst wenn etwas Redundantes übrig bleibt und wir mehrere Kandidaten für den Lambda-Typ finden, ist es wahrscheinlich, dass keiner von ihnen mit der Suchabfrage übereinstimmt, und wir werden dennoch vermeiden, ein vollständiges PSI zu erstellen.
Die globale Suche kann sich als zu kostspielig herausstellen (wenn ein Projekt zu viele List
enthält), oder der Anfang der Kette kann nicht im Kontext einer Datei (z. B. eines Felds einer übergeordneten Klasse) oder der Datei aufgelöst werden Kette könnte brechen, wenn die Methode einen generischen Parameter zurückgibt. Wir werden nicht aufgeben und versuchen, mit der globalen Suche nach der nächsten Methode der Kette von vorne zu beginnen. Für die map.get(key).updateAndGet(a -> a * 2)
geht die folgende Anweisung zum Index:
Dieser Lambda-Typ ist der Typ des einzelnen Parameters einer updateAndGet
Methode, der als Ergebnis einer get
Methode mit einem Parameter aufgerufen wird, der für ein Map
Objekt aufgerufen wird.
Stellen Sie sich vor, wir haben Glück und das Projekt hat nur einen Kartentyp - java.util.Map
. Es hat zwar eine get(Object)
-Methode, gibt aber leider einen generischen Parameter V
Dann verwerfen wir die Kette und suchen global nach der updateAndGet
Methode mit einem Parameter (natürlich unter Verwendung des Index). Wir freuen uns, dass das Projekt nur drei solcher Methoden enthält: in den Klassen AtomicInteger
, AtomicLong
und AtomicReference
mit den Parametertypen IntUnaryOperator
, LongUnaryOperator
bzw. UnaryOperator
. Wenn wir nach einem anderen Typ suchen, haben wir bereits festgestellt, dass dieses Lambda nicht mit der Anforderung übereinstimmt, und wir müssen den PSI nicht erstellen.
Überraschenderweise ist dies ein gutes Beispiel für eine Funktion, die mit der Zeit langsamer arbeitet. Wenn Sie beispielsweise nach Implementierungen einer funktionalen Schnittstelle suchen und nur drei davon in Ihrem Projekt haben, dauert es zehn Sekunden, bis IntelliJ IDEA sie findet. Sie erinnern sich, dass ihre Nummer vor drei Jahren dieselbe war, aber die IDE lieferte Ihnen die Suchergebnisse in nur zwei Sekunden auf demselben Computer. Und obwohl Ihr Projekt riesig ist, ist es in diesen Jahren nur um fünf Prozent gewachsen. Es ist vernünftig, darüber zu meckern, was die IDE-Entwickler falsch gemacht haben, um es so schrecklich langsam zu machen.
Während wir vielleicht gar nichts geändert haben. Die Suche funktioniert genauso wie vor drei Jahren. Die Sache ist, dass Sie vor drei Jahren gerade auf Java 8 umgestiegen sind und nur hundert Lambdas in Ihrem Projekt hatten. Inzwischen haben Ihre Kollegen anonyme Klassen in Lambdas verwandelt, Streams oder eine reaktive Bibliothek verwendet. Infolgedessen gibt es anstelle von hundert Lambdas zehntausend. Und jetzt muss die IDE hundertmal mehr Optionen durchsuchen, um die drei erforderlichen zu finden.
Ich sagte "wir könnten", weil wir natürlich von Zeit zu Zeit zu dieser Suche zurückkehren und versuchen, sie zu beschleunigen. Aber es ist, als würde man den Bach oder besser den Wasserfall hinauf rudern. Wir bemühen uns sehr, aber die Anzahl der Lambdas in Projekten wächst sehr schnell.