Ein wichtiges Merkmal jeder IDE ist die Suche und Navigation durch den Code. Eine der häufig verwendeten Java-Suchoptionen ist die Suche nach allen Implementierungen dieser Schnittstelle. Oft wird eine solche Funktion als Typhierarchie bezeichnet und sieht aus wie auf dem Bild rechts.
Das Durchlaufen aller Klassen eines Projekts beim Aufrufen dieser Funktion ist ineffizient. Sie können die vollständige Klassenhierarchie zur Kompilierungszeit im Index speichern, da der Compiler sie trotzdem erstellt. Wir tun dies, wenn die Kompilierung von der IDE selbst gestartet und nicht delegiert wird, z. B. in Gradle. Dies funktioniert jedoch nur, wenn sich nach der Kompilierung im Modul nichts geändert hat. Im allgemeinen Fall sind Quellcodes jedoch die relevanteste Informationsquelle, und Indizes basieren auf Quellcodes.
Sofortige Erben zu finden ist eine einfache Aufgabe, wenn es sich nicht um eine funktionale Schnittstelle handelt. Wenn Sie nach Implementierungen der Foo
Schnittstelle suchen, müssen Sie alle Klassen finden, in denen implements Foo
, und Schnittstellen, in denen extends Foo
, sowie anonyme Klassen der Form new Foo(...) {...}
. Dazu reicht es aus, den Syntaxbaum jeder Projektdatei im Voraus zu erstellen, die entsprechenden Konstruktionen zu finden und sie dem Index hinzuzufügen.
Natürlich gibt es hier eine leichte Subtilität: Vielleicht suchen Sie nach der Schnittstelle com.example.goodcompany.Foo
, aber irgendwo wird tatsächlich org.example.evilcompany.Foo
verwendet. Ist es möglich, den vollständigen Namen der übergeordneten Schnittstelle in den Index aufzunehmen? Es gibt Schwierigkeiten damit. Die Datei, in der die Schnittstelle verwendet wird, sieht beispielsweise folgendermaßen aus:
Wenn wir nur die Datei betrachten, können wir nicht verstehen, wie der wirkliche vollständige Name Foo
lautet. Sie müssen sich den Inhalt mehrerer Pakete ansehen. Und jedes Paket kann an mehreren Stellen definiert werden (z. B. in mehreren JAR-Dateien). Die Indizierung wird lange dauern, wenn wir bei der Analyse dieser Datei die vollständige Auflösung des Zeichens vornehmen müssen. Das Hauptproblem ist jedoch nicht einmal das, sondern dass der auf der Datei MyFoo.java
erstellte Index nicht nur davon abhängt, sondern auch von anderen Dateien. Schließlich können wir die Beschreibung der Foo
Schnittstelle beispielsweise vom Paket org.example.foo
in das Paket org.example.bar
und nichts in der Datei MyFoo.java
ändern, und der vollständige Name von Foo
ändert sich.
Indizes in IntelliJ IDEA hängen nur vom Inhalt einer einzelnen Datei ab. Dies ist zum einen sehr praktisch: Der Index für eine bestimmte Datei wird ungültig, wenn sich diese Datei ändert. Auf der anderen Seite bedeutet dies große Einschränkungen für die Platzierung im Index. Beispielsweise können die vollständigen Namen der übergeordneten Klassen nicht zuverlässig im Index gespeichert werden. Aber im Prinzip ist das nicht so beängstigend. Wenn wir die Typhierarchie abfragen, können wir alles finden, was zum Kurznamen passt, und dann für diese Dateien eine ehrliche Auflösung des Zeichens durchführen und feststellen, ob es wirklich zu uns passt. In den meisten Fällen gibt es nicht zu viele zusätzliche Zeichen, und eine solche Überprüfung ist recht schnell.
Die Situation ändert sich dramatisch, wenn die Klasse, deren Nachkommen wir suchen, eine funktionale Schnittstelle ist. Zusätzlich zu expliziten und anonymen Erben erhalten wir dann Lambda-Ausdrücke und Methodenlinks. Was ist nun in einen Index aufzunehmen und was direkt bei der Suche zu berechnen?
Angenommen, wir haben eine funktionale Schnittstelle:
@FunctionalInterface public interface StringConsumer { void consume(String s); }
Der Code enthält verschiedene Lambda-Ausdrücke. Zum Beispiel:
() -> {}
Das heißt, wir können schnell nur diejenigen Lambdas filtern, die die falsche Anzahl von Parametern oder offensichtlich den falschen Rückgabetyp haben, z. B. void versus non-void. Es ist normalerweise unmöglich, den Rückgabetyp genauer zu bestimmen. s -> list.add(s)
, in lambda s -> list.add(s)
Sie dazu die s -> list.add(s)
auflösen und eine vollständige Inferenzprozedur add
und möglicherweise starten. All dies ist lang und erfordert das Befestigen des Inhalts anderer Dateien.
Wir haben Glück, wenn unsere funktionale Schnittstelle fünf Argumente enthält. Wenn jedoch nur ein Argument erforderlich ist, hinterlässt ein solcher Filter eine große Anzahl zusätzlicher Lambdas. Noch schlimmer mit Methodenreferenzen. Grundsätzlich kann das Auftreten eines Verweises auf eine Methode in keiner Weise gesagt werden, ob sie geeignet ist oder nicht.
Vielleicht sollten Sie sich im Lambda umsehen, um etwas zu verstehen? Ja, 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 Teil aller Lambdas ab. In den allermeisten Fällen wird Lambda als Argument für eine Methode verwendet:
list.stream() .filter(s -> StringUtil.isNonEmpty(s)) .map(s -> s.trim()) .forEach(s -> list.add(s));
Welches dieser drei Lambdas kann vom Typ StringConsumer
? Dem Programmierer ist klar, dass keine. Da es offensichtlich ist, dass wir hier die Stream-API-Kette haben und es nur funktionale Schnittstellen aus der Standardbibliothek gibt, kann unser Typ nicht vorhanden sein.
Die IDE sollte sich jedoch nicht täuschen lassen, sondern eine genaue Antwort geben. Was ist, wenn list
nicht java.util.List
ist und list.stream()
java.util.stream.Stream
überhaupt nicht list.stream()
? Dazu müssen Sie das Listensymbol auflösen, was bekanntlich nicht nur auf der Grundlage des Inhalts der aktuellen Datei zuverlässig möglich ist. Und selbst wenn wir es installiert haben, sollte die Suche nicht auf die Implementierung der Standardbibliothek gerichtet sein. Vielleicht haben wir in diesem Projekt speziell die Klasse java.util.List
durch unsere eigene ersetzt? Die Suche muss darauf reagieren. Natürlich werden Lambdas nicht nur in Standardströmen verwendet, es gibt auch viele andere Methoden, mit denen sie übertragen werden.
Als Ergebnis stellt sich heraus, dass wir den Index nach einer Liste aller Java-Dateien abfragen können, die Lambdas mit der erforderlichen Anzahl von Parametern und einem gültigen Rückgabetyp verwenden (tatsächlich verfolgen wir nur vier Optionen: void, non-void, boolean und any). Und was dann? Erstellen Sie für jede dieser Dateien einen vollständigen PSI-Baum (ähnelt er einem Analysebaum, jedoch mit Zeichenauflösung, Typinferenz und anderen intelligenten Dingen) und führen Sie die Typinferenz für Lambda ehrlich aus? Dann werden Sie in einem großen Projekt nicht auf eine Liste aller Schnittstellenimplementierungen warten, selbst wenn es nur zwei davon gibt.
Es stellt sich heraus, dass wir die folgenden Schritte ausführen müssen:
- Fragen Sie den Index (billig)
- Erstellen Sie ein PSI (teuer)
- Druck Lambda-Typ (sehr teuer)
In Java Version 8 und höher ist die Typinferenz eine wahnsinnig teure Operation. In einer komplexen Aufrufkette können Sie über viele generische Platzhalterparameter verfügen, deren Werte mithilfe des in Kapitel 18 der Spezifikation beschriebenen Verfahrens ermittelt werden müssen. Dies kann im Hintergrund für die aktuell bearbeitete Datei erfolgen, dies ist jedoch für Tausende ungeöffneter Dateien schwierig.
Hier können Sie jedoch die Ecke etwas kürzen: In den meisten Fällen benötigen wir den endgültigen Typ nicht. Wenn nur Lambda nicht an eine Methode übergeben wird, die an dieser Stelle einen generischen Parameter verwendet, können wir den letzten Schritt der Parametersubstitution entfernen. Angenommen, wir haben den Lambda-Typ java.util.function.Function<T, R>
, können wir die Werte der Substitutionsparameter T
und R
nicht berechnen: und es ist klar, ob wir ihn zum Suchergebnis zurückgeben sollen oder nicht. Obwohl dies beim Aufrufen einer Methode wie dieser nicht funktioniert:
static <T> void doSmth(Class<T> aClass, T value) {}
Diese Methode kann folgendermaßen aufgerufen werden: doSmth(Runnable.class, () -> {})
. Dann wird der Lambda-Typ als T
angezeigt, und Sie müssen ihn trotzdem ersetzen. Dies ist jedoch ein seltener Fall. Daher stellt sich heraus, zu sparen, aber nicht mehr als 10%. Das Problem ist nicht grundlegend gelöst.
Eine andere Idee: Wenn die genaue Typinferenz komplex ist, lassen Sie uns eine ungefähre Schlussfolgerung ziehen. Lassen Sie es nur für gelöschte Klassentypen funktionieren und reduzieren Sie nicht die Einschränkungen, wie in der Spezifikation angegeben, sondern folgen Sie einfach der Aufrufkette. Solange der gelöschte Typ keine generischen Parameter enthält, ist alles in Ordnung. Nehmen Sie zum Beispiel den Stream aus dem obigen Beispiel und stellen Sie fest, ob das letzte Lambda unseren StringConsumer
implementiert:
- Listenvariable -> Typ
java.util.List
List.stream()
-Methode - List.stream()
Typ java.util.stream.Stream
Stream.filter(...)
-Methode → Stream.filter(...)
java.util.stream.Stream
. Wir sehen uns nicht einmal die filter
. Was ist der Unterschied Stream.filter(...)
Stream.map(...)
-Methode - Stream.map(...)
java.util.stream.Stream
Typ, ähnlich- Die
Stream.forEach(...)
-Methode → es gibt eine solche Methode, deren Parameter vom Consumer
Typ ist, der offensichtlich nicht StringConsumer
.
Nun, sie haben auf eine vollständige Typinferenz verzichtet. Mit einem so einfachen Ansatz ist es jedoch leicht, auf überladene Methoden zu stoßen. Wenn wir die Typinferenz nicht vollständig starten, können Sie nicht die richtige überladene Version auswählen. Obwohl dies nicht der Fall ist, ist es manchmal möglich, wenn sich die Anzahl der Methodenparameter unterscheidet. Zum Beispiel:
CompletableFuture.supplyAsync(Foo::bar, myExecutor).thenRunAsync(s -> list.add(s));
Hier können wir das leicht verstehen
- Es gibt zwei
CompletableFuture.supplyAsync
Methoden, aber eine verwendet ein Argument und die zweite zwei. Wählen Sie also diejenige aus, die zwei verwendet. Es wird eine CompletableFuture
. thenRunAsync
Methoden thenRunAsync
ebenfalls zwei, und aus ihnen können Sie auf ähnliche Weise diejenige auswählen, die ein Argument thenRunAsync
. Der entsprechende Parameter ist vom Typ Runnable
, Runnable
es handelt sich nicht um StringConsumer
.
Wenn mehrere Methoden dieselbe Anzahl von Parametern akzeptieren oder einige eine variable Anzahl von Parametern haben und auch geeignet aussehen, müssen Sie alle Optionen im Auge behalten. Aber oft ist das auch nicht beängstigend. Zum Beispiel:
new StringBuilder().append(foo).append(bar).chars().forEach(s -> list.add(s));
new StringBuilder()
erstellt offensichtlich java.lang.StringBuilder
. Für Designer erlauben wir den Link weiterhin, aber eine komplexe Typinferenz ist hier nicht erforderlich. Selbst wenn es new Foo<>(x, y, z)
gäbe, zeigen wir nicht die Werte typischer Parameter an, wir interessieren uns nur für Foo
.- Es gibt
StringBuilder.append
Methoden, die ein Argument annehmen, aber alle geben den Typ java.lang.StringBuilder
, sodass es keine Rolle spielt, welcher Typ foo
und bar
. - Die
StringBuilder.chars
Methode StringBuilder.chars
eine und gibt java.util.stream.IntStream
. - Die
IntStream.forEach
Methode IntStream.forEach
eine und akzeptiert den IntConsumer
Typ.
Auch wenn mehrere Optionen irgendwo verbleiben, können Sie sie alle verfolgen. Beispielsweise kann der an ForkJoinPool.getInstance().submit(...)
Lambda-Typ ForkJoinPool.getInstance().submit(...)
Runnable
oder Callable
. Wenn wir jedoch nach etwas Drittem suchen, können wir dieses Lambda trotzdem verwerfen.
Eine unangenehme Situation tritt auf, wenn eine Methode einen generischen Parameter zurückgibt. Dann bricht die Prozedur zusammen und Sie müssen eine vollständige Typinferenz ausführen. Wir haben jedoch einen Fall unterstützt. Es wird gut in meiner StreamEx-Bibliothek angezeigt, die eine abstrakte Klasse hat. AbstractStreamEx<T, S extends AbstractStreamEx<T, S>>
enthält Methoden wie S filter(Predicate<? super T> predicate)
. Normalerweise arbeiten Leute mit einer bestimmten Klasse. StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>>
. In diesem Fall können Sie die Ersetzung des Typparameters durchführen und feststellen, dass S = StreamEx
.
Nun, in vielen Fällen haben wir eine sehr teure Typinferenz beseitigt. Aber wir haben nichts mit dem Aufbau des PSI gemacht. Es ist eine Schande, eine Datei in fünfhundert Zeilen zu analysieren, um herauszufinden, dass das Lambda in Zeile 480 nicht zu unserer Abfrage passt. 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, können wir bereits in der Indizierungsphase die Deklaration finden und feststellen, dass der Kurzname des Typs lautet
List
Dementsprechend können wir im Index für das letzte Lambda die folgenden Informationen einfügen:
Der Typ dieses Lambdas ist der Parametertyp der forEach
Methode aus einem Argument, der auf das Ergebnis der map
Methode aus einem Argument aufgerufen wird, auf das Ergebnis der filter
aus einem Argument aufgerufen wird, auf das Ergebnis der stream
Methode aus Null-Argumenten aufgerufen wird und auf ein Objekt vom Typ List
aufgerufen wird.
Alle diese Informationen sind in der aktuellen Datei verfügbar, dh sie können in den Index aufgenommen werden. Während der Suche fragen wir den Index nach solchen Informationen über alle Lambdas und versuchen, den Lambda-Typ wiederherzustellen, ohne einen PSI zu erstellen. Zuerst müssen Sie eine globale Suche nach Klassen mit dem Kurznamen List
. Natürlich finden wir nicht nur java.util.List
, sondern auch java.awt.List
oder etwas aus dem Code des Benutzerprojekts. Ferner werden wir alle diese Klassen dem gleichen Verfahren der ungenauen Typauflösung unterwerfen, das wir zuvor verwendet haben. Oft werden zusätzliche Klassen selbst schnell herausgefiltert. In java.awt.List
gibt es beispielsweise keine stream
Methode, daher wird sie weiter ausgeschlossen. Aber selbst wenn bis zum Ende etwas Überflüssiges bei uns ist und wir mehrere Kandidaten für den Typ unseres Lambda finden, besteht eine gute Chance, dass sie nicht alle in die Suchanfrage passen, und wir werden dennoch vermeiden, ein vollständiges PSI zu erstellen.
Es ist möglich, dass die globale Suche zu teuer ist (es gibt viele Listenklassen im Projekt). Entweder ist der Beginn der Kette im Kontext einer einzelnen Datei nicht zulässig (z. B. ist dies das Feld der übergeordneten Klasse), oder die Kette wird irgendwo unterbrochen, weil die Methode einen generischen Parameter zurückgibt. Dann geben wir nicht sofort auf und versuchen erneut, mit einer globalen Suche nach der nächsten Verkettungsmethode zu beginnen. Für die map.get(key).updateAndGet(a -> a * 2)
wurde beispielsweise die folgende Anweisung in den Index aufgenommen:
Der Lambda-Typ ist der Typ des einzigen Parameters der updateAndGet
Methode, der für das Ergebnis der get
Methode mit einem Parameter aufgerufen wird, der für das Objekt vom Typ Map
aufgerufen wird.
Lassen Sie uns Glück haben und im Projekt gibt es nur einen Map
- java.util.Map
. Es hat zwar eine get(Object)
-Methode, gibt aber leider den generischen Parameter V
Dann lassen wir die Kette fallen und suchen global nach der updateAndGet
Methode mit einem Parameter (natürlich unter Verwendung des Index). AtomicInteger
gibt es im Projekt nur drei solcher Methoden in den AtomicReference
AtomicInteger
, AtomicLong
und AtomicReference
mit Parametern vom Typ IntUnaryOperator
, LongUnaryOperator
bzw. UnaryOperator
. Wenn wir nach einem anderen Typ suchen, haben wir festgestellt, dass dieses Lambda nicht passt und PSI nicht gebaut werden kann.
Überraschenderweise ist dies ein anschauliches Beispiel für ein Merkmal, das im Laufe der Zeit selbst langsamer zu arbeiten beginnt. Sie suchen beispielsweise nach der Implementierung einer funktionalen Schnittstelle, es gibt nur drei davon im Projekt, und IntelliJ IDEA sucht zehn Sekunden lang nach ihnen. Und Sie erinnern sich sehr gut daran, dass es vor drei Jahren auch drei von ihnen gab, Sie haben auch nach ihnen gesucht, aber dann gab die Umgebung innerhalb von zwei Sekunden auf derselben Maschine eine Antwort. Und Ihr Projekt ist zwar riesig, aber in drei Jahren gewachsen, vielleicht um fünf Prozent. Natürlich ärgern Sie sich zu Recht darüber, was diese Entwickler vermasselt haben, dass die IDE so schrecklich langsamer wurde. Hände, um diese unglücklichen Programmierer abzureißen.
Und vielleicht haben wir überhaupt nichts geändert. Vielleicht funktioniert die Suche genauso wie vor drei Jahren. Noch vor drei Jahren sind Sie auf Java 8 umgestiegen und hatten beispielsweise hundert Lambdas in Ihrem Projekt. Und jetzt haben Ihre Kollegen anonyme Klassen in Lambdas verwandelt, Streams aktiv genutzt oder eine Art reaktive Bibliothek verbunden. Aufgrund von Lambdas wurden es nicht einhundert, sondern zehntausend. Und jetzt muss die IDE hundertmal mehr durchsucht werden, um die drei notwendigen Lambdas auszugraben.
Ich sagte "vielleicht", weil wir natürlich von Zeit zu Zeit auf diese Suche zurückkommen und versuchen, sie zu beschleunigen. Aber hier muss man nicht einmal gegen den Bach rudern, sondern den Wasserfall hinauf. Wir versuchen es, aber die Anzahl der Lambdas in Projekten wächst sehr schnell.