Bagaimana kami di IntelliJ IDEA mencari ekspresi lambda

Ketik Hirarki di IntelliJ IDEA Fitur penting dari setiap IDE adalah pencarian dan navigasi melalui kode. Salah satu opsi pencarian Java yang sering digunakan adalah untuk mencari semua implementasi dari antarmuka ini. Seringkali fungsi seperti itu disebut Hirarki Tipe dan terlihat seperti gambar di sebelah kanan.


Iterasi melalui semua kelas proyek ketika memanggil fungsi ini tidak efisien. Anda dapat menyimpan hierarki kelas penuh ke indeks pada waktu kompilasi, karena kompiler tetap membuatnya. Kami melakukan ini jika kompilasi dimulai oleh IDE itu sendiri dan tidak didelegasikan, misalnya, di Gradle. Tetapi ini hanya berfungsi jika tidak ada yang berubah dalam modul setelah kompilasi. Tetapi dalam kasus umum, kode sumber adalah sumber informasi yang paling relevan, dan indeks dibuat berdasarkan kode sumber.


Menemukan pewaris langsung adalah tugas sederhana jika kita tidak berurusan dengan antarmuka fungsional. Saat mencari implementasi dari antarmuka Foo , Anda perlu menemukan semua kelas di mana ada implements Foo , dan antarmuka di mana ada extends Foo , serta kelas anonim dari formulir new Foo(...) {...} . Untuk melakukan ini, cukup membangun pohon sintaksis dari setiap file proyek terlebih dahulu, menemukan konstruksi yang sesuai dan menambahkannya ke indeks.


Tentu saja, ada sedikit kehalusan di sini: mungkin Anda mencari antarmuka com.example.goodcompany.Foo , tetapi di suatu tempat org.example.evilcompany.Foo sebenarnya digunakan. Apakah mungkin untuk memasukkan nama lengkap dari antarmuka induk dalam indeks? Ada kesulitan dengan ini. Misalnya, file tempat antarmuka digunakan mungkin terlihat seperti ini:


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

Melihat hanya pada file, kita tidak bisa mengerti apa nama sebenarnya Foo . Anda harus melihat isi beberapa paket. Dan setiap paket dapat didefinisikan di beberapa tempat (misalnya, dalam beberapa file jar). Pengindeksan akan memakan waktu lama jika, dalam menganalisis file ini, kita harus melakukan resolusi penuh karakter. Tetapi masalah utamanya bukan ini, tetapi indeks yang dibangun di atas file MyFoo.java tidak hanya bergantung pada itu, tetapi juga pada file lain. Lagipula, kita dapat mentransfer deskripsi antarmuka Foo , misalnya, dari paket org.example.foo ke paket org.example.bar , dan tidak mengubah apa pun di file MyFoo.java , dan nama lengkap Foo akan berubah.


Indeks dalam IntelliJ IDEA hanya bergantung pada konten dari satu file. Di satu sisi, ini sangat nyaman: indeks yang berkaitan dengan file tertentu menjadi tidak valid ketika file ini berubah. Di sisi lain, ini memberlakukan batasan besar pada apa yang dapat ditempatkan dalam indeks. Sebagai contoh, itu tidak bisa dipercaya menyimpan nama lengkap dari kelas induk dalam indeks. Tetapi, pada prinsipnya, ini tidak begitu menakutkan. Saat menanyakan hierarki jenis, kita dapat menemukan semua yang sesuai dengan nama pendek, dan kemudian untuk file-file ini melakukan resolusi karakter yang jujur โ€‹โ€‹dan menentukan apakah itu benar-benar cocok untuk kita. Dalam kebanyakan kasus, tidak akan ada terlalu banyak karakter tambahan, dan pemeriksaan seperti itu akan cukup cepat.


Hirarki Antarmuka Fungsional di IntelliJ IDEA Situasi berubah secara dramatis ketika kelas yang keturunannya kita cari adalah antarmuka fungsional. Kemudian, selain pewaris eksplisit dan anonim, kami mendapatkan ekspresi lambda dan tautan metode. Apa yang sekarang dimasukkan ke dalam indeks, dan apa yang harus dihitung langsung pada pencarian?


Misalkan kita memiliki antarmuka fungsional:


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

Ada berbagai ekspresi lambda dalam kode. Sebagai contoh:


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

Artinya, kita dapat dengan cepat menyaring hanya lambda yang memiliki jumlah parameter yang salah atau jelas jenis pengembalian yang salah, misalnya void versus non-void. Biasanya tidak mungkin untuk menentukan jenis pengembalian lebih tepat. Katakan, dalam lambda s -> list.add(s) untuk ini, Anda perlu menyelesaikan list karakter dan add , dan, mungkin, memulai prosedur inferensi jenis lengkap. Semua ini panjang dan akan membutuhkan pengikatan pada isi file lain.


Kami beruntung jika antarmuka fungsional kami membutuhkan lima argumen. Tetapi jika hanya membutuhkan satu argumen, filter seperti itu akan meninggalkan sejumlah besar lambda tambahan. Lebih buruk lagi dengan referensi metode. Pada prinsipnya, kemunculan referensi apa pun terhadap suatu metode tidak dapat dikatakan dengan cara apa pun apakah itu sesuai atau tidak.


Mungkin Anda harus melihat-lihat lambda untuk memahami sesuatu? Ya, terkadang berhasil. Sebagai contoh:


 //        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)}; 

Dalam semua kasus ini, nama pendek dari antarmuka fungsional yang sesuai dapat ditemukan dari file saat ini dan dimasukkan ke dalam indeks di sebelah ekspresi fungsional, baik itu lambda atau referensi metode. Sayangnya, dalam proyek nyata, kasus-kasus ini mencakup sebagian kecil dari semua lambda. Dalam sebagian besar kasus, lambda digunakan sebagai argumen untuk metode:


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

Manakah dari tiga lambda ini yang bisa bertipe StringConsumer ? Jelas bagi programmer bahwa tidak ada. Karena jelas bahwa di sini kami memiliki rantai API Stream, dan hanya ada antarmuka fungsional dari pustaka standar, tipe kami tidak bisa ada di sana.


Namun, IDE jangan sampai dibohongi, tetapi harus memberikan jawaban yang akurat. Bagaimana jika list tersebut bukan java.util.List sama sekali, dan list.stream() tidak mengembalikan java.util.stream.Stream sama sekali? Untuk melakukan ini, Anda harus menyelesaikan simbol list , yang, seperti yang kita ketahui, tidak dapat dilakukan dengan andal hanya berdasarkan konten file saat ini. Dan bahkan jika kita menginstalnya, pencarian tidak boleh diletakkan pada implementasi perpustakaan standar. Mungkin kita secara khusus dalam proyek ini mengganti kelas java.util.List dengan kita sendiri? Pencarian harus menanggapi ini. Yah, tentu saja, lambda digunakan tidak hanya dalam aliran standar, ada banyak metode lain di mana mereka ditransfer.


Hasilnya, ternyata kami dapat meminta indeks untuk daftar semua file Java yang menggunakan lambdas dengan jumlah parameter yang diperlukan dan jenis pengembalian yang valid (pada kenyataannya, kami hanya melacak empat opsi: void, non-void, boolean, dan apa saja). Lalu apa? Untuk masing-masing file ini, buat pohon PSI lengkap (apakah seperti pohon parse, tetapi dengan resolusi karakter, inferensi tipe, dan hal-hal pintar lainnya) dan jalankan inferensi tipe jujur โ€‹โ€‹untuk lambda? Kemudian dalam proyek besar Anda tidak akan menunggu daftar semua implementasi antarmuka, bahkan jika hanya ada dua dari mereka.


Ternyata kita perlu melakukan langkah-langkah berikut:


  • Tanyakan indeksnya (murah)
  • Membangun PSI (mahal)
  • Jenis cetak lambda (sangat mahal)

Dalam Java versi 8 dan yang lebih baru, ketik inferensi adalah operasi yang sangat mahal. Dalam rantai panggilan yang kompleks, Anda dapat memiliki banyak parameter wildcard generik, yang nilainya perlu ditentukan menggunakan prosedur marah yang dijelaskan dalam bab 18 dari spesifikasi. Ini dapat dilakukan di latar belakang untuk file yang sedang diedit, tetapi akan sulit untuk melakukan ini untuk ribuan file yang belum dibuka.


Di sini, bagaimanapun, Anda dapat memotong sudut sedikit: dalam kebanyakan kasus kita tidak perlu tipe final. Jika hanya lambda tidak diteruskan ke metode yang mengambil parameter generik di tempat ini, kita bisa menyingkirkan langkah terakhir dari penggantian parameter. Katakanlah, jika kita menyimpulkan tipe lambda java.util.function.Function<T, R> , kita tidak dapat menghitung nilai parameter substitusi T dan R : dan jadi jelas apakah akan mengembalikannya ke hasil pencarian atau tidak. Meskipun ini tidak akan berfungsi ketika memanggil metode seperti ini:


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

Metode ini bisa disebut seperti ini: doSmth(Runnable.class, () -> {}) . Maka jenis lambda akan ditampilkan sebagai T , dan Anda harus mengganti pula. Tapi ini kasus yang jarang terjadi. Karena itu, ternyata menghemat, tetapi tidak lebih dari 10%. Masalahnya tidak terpecahkan secara mendasar.


Gagasan lain: jika inferensi tipe yang tepat kompleks, maka mari buat kesimpulan perkiraan. Biarkan ini bekerja hanya pada tipe kelas yang terhapus dan tidak mengurangi set batasan, seperti yang tertulis dalam spesifikasi, tetapi cukup ikuti rantai panggilan. Selama tipe yang dihapus tidak termasuk parameter generik, maka semuanya baik-baik saja. Misalnya, ambil aliran dari contoh di atas dan tentukan apakah lambda terakhir mengimplementasikan StringConsumer kami:


  • list variabel -> ketik java.util.List
  • List.stream() - List.stream() tipe java.util.stream.Stream
  • Stream.filter(...) โ†’ ketik java.util.stream.Stream , kami bahkan tidak melihat argumen filter , apa bedanya
  • Stream.map(...) - Stream.map(...) tipe java.util.stream.Stream , sama
  • Metode Stream.forEach(...) โ†’ ada metode seperti itu, parameternya adalah tipe Consumer , yang, jelas, bukan StringConsumer .

Yah, mereka melakukannya tanpa inferensi tipe penuh. Namun, dengan pendekatan sederhana seperti itu, mudah untuk mengalami metode kelebihan beban. Jika kami tidak memulai inferensi jenis sepenuhnya, maka Anda tidak dapat memilih versi kelebihan beban yang benar. Meskipun tidak, kadang-kadang mungkin jika jumlah parameter metode berbeda. Sebagai contoh:


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

Di sini kita dapat dengan mudah memahaminya


  • Ada dua metode CompletableFuture.supplyAsync , tetapi satu mengambil satu argumen dan yang kedua mengambil dua, jadi pilihlah yang membutuhkan dua. Ini mengembalikan CompletableFuture .
  • Metode thenRunAsync juga dua, dan dari mereka Anda juga dapat memilih salah satu yang mengambil satu argumen. Parameter yang sesuai bertipe Runnable , yang berarti bukan StringConsumer .

Jika beberapa metode menerima jumlah parameter yang sama, atau beberapa memiliki sejumlah variabel parameter dan juga terlihat cocok, maka Anda harus melacak semua opsi. Namun seringkali ini juga tidak menakutkan. Sebagai contoh:


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

  • new StringBuilder() jelas menciptakan java.lang.StringBuilder . Untuk desainer, kami masih mengizinkan tautan, tetapi inferensi tipe kompleks tidak diperlukan di sini. Bahkan jika ada new Foo<>(x, y, z) , kami tidak menampilkan nilai-nilai parameter khas, kami hanya tertarik pada Foo .
  • Ada StringBuilder.append metode StringBuilder.append yang mengambil satu argumen, tetapi semuanya mengembalikan tipe java.lang.StringBuilder , jadi tidak masalah apa pun jenis foo dan bar .
  • Metode StringBuilder.chars satu dan mengembalikan java.util.stream.IntStream .
  • Metode IntStream.forEach satu dan menerima tipe IntConsumer .

Bahkan jika beberapa opsi tetap ada di suatu tempat, Anda dapat melacak semuanya. Misalnya, jenis lambda yang diteruskan ke ForkJoinPool.getInstance().submit(...) mungkin Runnable atau Callable , tetapi jika kita mencari sesuatu yang ketiga, kita masih dapat membuang lambda itu.


Situasi yang tidak menyenangkan terjadi ketika suatu metode mengembalikan parameter generik. Kemudian prosedur rusak dan Anda harus menjalankan inferensi tipe penuh. Namun, kami mendukung satu kasing. Itu muncul dengan baik di perpustakaan StreamEx saya, yang memiliki kelas abstrak AbstractStreamEx<T, S extends AbstractStreamEx<T, S>> berisi metode seperti S filter(Predicate<? super T> predicate) . Biasanya orang bekerja dengan kelas tertentu StreamEx<T> extends AbstractStreamEx<T, StreamEx<T>> . Dalam hal ini, Anda dapat melakukan penggantian parameter tipe dan mengetahui bahwa S = StreamEx .


Nah, dalam banyak kasus kami menyingkirkan inferensi tipe yang sangat mahal. Tapi kami tidak melakukan apa pun dengan pembangunan PSI. Sayang memilah file menjadi lima ratus baris hanya untuk mengetahui bahwa lambda di baris 480 tidak sesuai dengan kueri kami. Mari kita kembali ke aliran kita:


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

Jika list adalah variabel lokal, parameter metode atau bidang dalam kelas saat ini, maka sudah pada tahap pengindeksan kita dapat menemukan deklarasi dan menetapkan bahwa nama pendek dari tipe tersebut adalah
List Dengan demikian, dalam indeks untuk lambda terakhir kita dapat memasukkan informasi berikut:


Tipe lambda ini adalah tipe parameter dari metode forEach dari satu argumen, dipanggil pada hasil metode map dari satu argumen, dipanggil pada hasil dari metode filter dari satu argumen, dipanggil pada hasil dari metode stream dari argumen nol, dipanggil pada objek dari daftar tipe.

Semua informasi ini tersedia di file saat ini, yang berarti dapat ditempatkan dalam indeks. Selama pencarian, kami meminta indeks untuk informasi seperti itu tentang semua lambda dan mencoba mengembalikan tipe lambda tanpa membangun PSI. Pertama, Anda harus melakukan pencarian global untuk kelas dengan List nama pendek. Tentu saja, kita tidak hanya akan menemukan java.util.List , tetapi juga java.awt.List atau sesuatu dari kode proyek pengguna. Selanjutnya kami akan menyerahkan semua kelas ini ke prosedur yang sama dari resolusi tipe yang tidak akurat yang kami gunakan sebelumnya. Seringkali kelas tambahan sendiri dengan cepat disaring. Sebagai contoh, di java.awt.List tidak ada metode stream , oleh karena itu dikecualikan lebih lanjut. Tetapi bahkan jika sesuatu yang berlebihan ada bersama kita sampai akhir dan kita menemukan beberapa kandidat untuk jenis lambda kita, ada peluang bagus bahwa mereka semua tidak akan sesuai dengan permintaan pencarian, dan kita masih akan menghindari membangun PSI penuh.


Ada kemungkinan bahwa pencarian global akan terlalu mahal (ada banyak kelas List dalam proyek), baik awal rantai tidak diperbolehkan dalam konteks satu file (katakanlah ini adalah bidang kelas induk), atau rantai akan pecah di suatu tempat, karena metode mengembalikan parameter generik. Maka kami tidak menyerah segera dan mencoba lagi untuk memulai dengan pencarian global pada metode rantai berikutnya. Misalnya, untuk map.get(key).updateAndGet(a -> a * 2) , pernyataan berikut masuk ke dalam indeks:


Tipe lambda adalah tipe satu-satunya parameter dari metode updateAndGet , dipanggil pada hasil metode get dengan satu parameter, dipanggil pada objek tipe Map .

Mari kita beruntung dan dalam proyek ini hanya ada satu jenis Map - java.util.Map . Itu memang memiliki metode get(Object) , tapi sayangnya ia mengembalikan parameter generik V Lalu kita lepaskan rantai dan lihat secara global untuk metode updateAndGet dengan satu parameter (tentu saja menggunakan indeks). AtomicInteger , hanya ada tiga metode dalam proyek ini, di kelas AtomicInteger , AtomicLong dan AtomicReference dengan parameter tipe IntUnaryOperator , LongUnaryOperator dan UnaryOperator . Jika kami mencari jenis lain, maka kami menemukan bahwa lambda ini tidak cocok dan PSI tidak dapat dibangun.


Anehnya, ini adalah contoh nyata dari fitur yang, seiring waktu, itu sendiri mulai bekerja lebih lambat. Misalnya, Anda mencari implementasi antarmuka fungsional, hanya ada tiga di antaranya dalam proyek, dan IntelliJ IDEA mencari mereka selama sepuluh detik. Dan Anda ingat betul bahwa tiga tahun lalu ada juga tiga, Anda juga mencarinya, tetapi kemudian lingkungan memberi jawaban dalam dua detik pada mesin yang sama. Dan proyek Anda, meskipun besar, telah berkembang dalam tiga tahun, mungkin sebesar lima persen. Tentu saja, Anda mulai membenci apa yang dikacaukan oleh para pengembang ini sehingga IDE mulai sangat lambat. Tangan untuk merobek programmer yang malang ini.


Dan mungkin kita tidak mengubah apa pun. Mungkin pencariannya sama dengan tiga tahun lalu. Hanya tiga tahun yang lalu Anda baru saja beralih ke Java 8, dan Anda telah, katakanlah, seratus lambda dalam proyek Anda. Dan sekarang kolega Anda mengubah kelas anonim menjadi lambda, mulai aktif menggunakan aliran atau menghubungkan semacam perpustakaan reaktif, sebagai akibat dari lambdas itu menjadi bukan seratus, tetapi sepuluh ribu. Dan sekarang, untuk menggali tiga lambda yang diperlukan, IDE harus dicari seratus kali lebih banyak.


Saya berkata "mungkin" karena, tentu saja, kami kembali ke pencarian ini dari waktu ke waktu dan mencoba mempercepatnya. Tapi di sini Anda harus mendayung bahkan tidak melawan arus, tetapi naik air terjun. Kami mencoba, tetapi jumlah lambda dalam proyek tumbuh sangat cepat.

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


All Articles