Mengurai ekspresi lambda di Jawa

gambar


Dari seorang penerjemah: LambdaMetafactory mungkin adalah salah satu mekanisme Java 8. yang paling diremehkan Kami baru-baru ini menemukannya, tetapi sudah menghargai kemampuannya. Versi 7.0 dari kerangka kerja CUBA meningkatkan kinerja dengan menghindari panggilan reflektif untuk menghasilkan ekspresi lambda. Salah satu aplikasi dari mekanisme ini dalam kerangka kerja kami adalah pengikatan penangan event aplikasi dengan anotasi, tugas umum, analog dari EventListener dari Spring. Kami percaya bahwa pengetahuan tentang prinsip-prinsip LambdaFactory dapat berguna di banyak aplikasi Java, dan kami segera berbagi terjemahan ini dengan Anda.


Pada artikel ini, kami akan menunjukkan beberapa trik yang kurang dikenal ketika bekerja dengan ekspresi lambda di Java 8 dan keterbatasan ekspresi ini. Target pembaca artikel ini adalah pengembang senior Java, peneliti dan pengembang toolkit. Hanya Java API publik yang akan digunakan tanpa com.sun.* Dan kelas internal lainnya, sehingga kodenya portabel di antara berbagai implementasi JVM.


Kata Pengantar


Ekspresi Lambda muncul di Java 8 sebagai cara untuk menerapkan metode anonim dan,
dalam beberapa kasus, sebagai alternatif untuk kelas anonim. Pada level bytecode, ekspresi lambda digantikan oleh invokedynamic . Instruksi ini digunakan untuk membuat implementasi antarmuka fungsional dan satu-satunya metode mendelegasikan panggilan ke metode aktual, yang berisi kode yang didefinisikan dalam tubuh ekspresi lambda.


Misalnya, kami memiliki kode berikut:


 void printElements(List<String> strings){ strings.forEach(item -> System.out.println("Item = %s", item)); } 

Kode ini akan dikonversi oleh kompiler Java menjadi sesuatu yang mirip dengan:


 private static void lambda_forEach(String item) { // Java  System.out.println("Item = %s", item); } private static CallSite bootstrapLambda(Lookup lookup, String name, MethodType type) { // //lookup =  VM //name = "lambda_forEach",  VM //type = String -> void MethodHandle lambdaImplementation = lookup.findStatic(lookup.lookupClass(), name, type); return LambdaMetafactory.metafactory(lookup, "accept", MethodType.methodType(Consumer.class), //  - MethodType.methodType(void.class, Object.class), //  Consumer.accept    lambdaImplementation, //     - type); } void printElements(List<String> strings) { Consumer<String> lambda = invokedynamic# bootstrapLambda, #lambda_forEach strings.forEach(lambda); } 

Instruksi invokedynamic dapat secara kasar direpresentasikan sebagai kode Java seperti itu:


 private static CallSite cs; void printElements(List<String> strings) { Consumer<String> lambda; //begin invokedynamic if (cs == null) cs = bootstrapLambda(MethodHandles.lookup(), "lambda_forEach", MethodType.methodType(void.class, String.class)); lambda = (Consumer<String>)cs.getTarget().invokeExact(); //end invokedynamic strings.forEach(lambda); } 

Seperti yang Anda lihat, LambdaMetafactory digunakan untuk membuat CallSite yang menyediakan metode pabrik yang mengembalikan penangan untuk metode target. Metode ini mengembalikan implementasi antarmuka fungsional menggunakan invokeExact . Jika ada variabel yang ditangkap dalam ekspresi lambda, maka invokeExact menerima variabel-variabel ini sebagai parameter aktual.


Di Oracle JRE 8, metafactory secara dinamis menghasilkan kelas Java menggunakan ObjectWeb Asm, yang menciptakan kelas yang mengimplementasikan antarmuka fungsional. Bidang tambahan dapat ditambahkan ke kelas yang dibuat jika ekspresi lambda menangkap variabel eksternal. Yang ini terlihat seperti kelas anonim Java, tetapi ada perbedaan berikut:


  • Kelas anonim dihasilkan oleh kompiler Java.
  • Kelas untuk mengimplementasikan ekspresi lambda dibuat oleh JVM pada saat run time.



Implementasi metafactory tergantung pada vendor dan versi JVM




Tentu saja, invokedynamic tidak hanya digunakan untuk ekspresi lambda di Jawa. Ini terutama digunakan ketika menjalankan bahasa dinamis di lingkungan JVM. Mesin JavaScript Nashorn , yang dibangun di Jawa, memanfaatkan instruksi ini secara intensif.


Selanjutnya, kita akan fokus pada kelas LambdaMetafactory dan kemampuannya. Selanjutnya
Bagian dari artikel ini mengasumsikan bahwa Anda mengerti betul bagaimana metode metafactory bekerja dan apa itu MethodHandle


Trik dengan ekspresi lambda


Pada bagian ini, kami akan menunjukkan cara membangun lambda dinamis untuk digunakan dalam tugas sehari-hari.


Pengecualian dan lambda yang diperiksa


Bukan rahasia lagi bahwa semua antarmuka fungsional yang ada di Jawa tidak mendukung pengecualian yang diperiksa. Keuntungan dari pengecualian yang diperiksa dibandingkan yang biasa adalah perdebatan yang sudah berlangsung lama (dan masih panas).


Tetapi bagaimana jika Anda perlu menggunakan kode dengan pengecualian diperiksa di dalam ekspresi lambda dalam kombinasi dengan Java Streams? Misalnya, Anda perlu mengonversi daftar string ke daftar URL seperti ini:


 Arrays.asList("http://localhost/", "https://github.com").stream() .map(URL::new) .collect(Collectors.toList()) 

Pengecualian yang dapat dibuang dideklarasikan dalam konstruktor URL (String) , sehingga tidak dapat digunakan secara langsung sebagai referensi metode di kelas Functiion .


Anda akan berkata: "Tidak, mungkin jika Anda menggunakan trik ini di sini":


 public static <T> T uncheckCall(Callable<T> callable) { try { return callable.call(); } catch (Exception e) { return sneakyThrow(e); } } private static <E extends Throwable, T> T sneakyThrow0(Throwable t) throws E { throw (E)t; } public static <T> T sneakyThrow(Throwable e) { return Util.<RuntimeException, T>sneakyThrow0(e); } //   //return s.filter(a -> uncheckCall(a::isActive)) // .map(Account::getNumber) // .collect(toSet()); 

Ini adalah hack yang kotor. Dan inilah alasannya:


  • Blok try-catch digunakan.
  • Pengecualian dilemparkan lagi.
  • Penggunaan tipe erasure yang kotor di Jawa.

Masalahnya dapat diselesaikan dengan cara yang lebih "legal", menggunakan pengetahuan tentang fakta-fakta berikut:


  • Pengecualian yang diperiksa hanya dikenali pada tingkat kompiler Java.
  • Bagian throws hanya metadata untuk metode tanpa nilai semantik di tingkat JVM.
  • Pengecualian yang diperiksa dan normal tidak dapat dibedakan pada tingkat bytecode di JVM.

Solusinya adalah dengan membungkus metode Callable.call dalam metode tanpa bagian throws :


 static <V> V callUnchecked(Callable<V> callable){ return callable.call(); } 

Kode ini tidak dikompilasi karena metode Callable.call menyatakan pengecekan pengecualian di bagian throws . Tetapi kita dapat menghapus bagian ini menggunakan ekspresi lambda yang dibangun secara dinamis.


Pertama kita perlu mendeklarasikan antarmuka fungsional yang tidak memiliki bagian throws .
tetapi siapa yang akan dapat mendelegasikan panggilan ke Callable.call :


 @FunctionalInterface interface SilentInvoker { MethodType SIGNATURE = MethodType.methodType(Object.class, Callable.class);//  INVOKE <V> V invoke(final Callable<V> callable); } 

Langkah kedua adalah membuat implementasi antarmuka ini menggunakan LambdaMetafactory dan mendelegasikan panggilan metode SilentInvoker.invoke ke metode Callable.call . Seperti disebutkan sebelumnya, bagian throws diabaikan pada level bytecode, sehingga metode SilentInvoker.invoke dapat memanggil metode Callable.call tanpa mendeklarasikan pengecualian:


 private static final SilentInvoker SILENT_INVOKER; final MethodHandles.Lookup lookup = MethodHandles.lookup(); final CallSite site = LambdaMetafactory.metafactory(lookup, "invoke", MethodType.methodType(SilentInvoker.class), SilentInvoker.SIGNATURE, lookup.findVirtual(Callable.class, "call", MethodType.methodType(Object.class)), SilentInvoker.SIGNATURE); SILENT_INVOKER = (SilentInvoker) site.getTarget().invokeExact(); 

Ketiga, kami menulis metode pembantu yang memanggil Callable.call tanpa menyatakan pengecualian:


 public static <V> V callUnchecked(final Callable<V> callable) /*no throws*/ { return SILENT_INVOKER.invoke(callable); } 

Sekarang Anda dapat menulis ulang streaming tanpa masalah dengan pengecualian yang dicentang:


 Arrays.asList("http://localhost/", "https://dzone.com").stream() .map(url -> callUnchecked(() -> new URL(url))) .collect(Collectors.toList()); 

Kode ini dikompilasi tanpa masalah karena callUnchecked tidak menyatakan pengecualian yang diperiksa. Selain itu, memanggil metode ini dapat digarisbawahi menggunakan caching inline monomorfik , karena hanya satu kelas di seluruh JVM yang mengimplementasikan antarmuka SilentOnvoker


Jika implementasi Callable.call melempar pengecualian pada saat run time, maka itu akan Callable.call oleh fungsi panggilan tanpa masalah:


 try{ callUnchecked(() -> new URL("Invalid URL")); } catch (final Exception e){ System.out.println(e); } 

Terlepas dari kemungkinan metode ini, Anda harus selalu mengingat rekomendasi berikut:




Sembunyikan pengecualian yang dicentang dengan callUnchecked hanya jika Anda yakin kode yang dipanggil tidak akan membuang pengecualian apa pun




Contoh berikut menunjukkan contoh pendekatan ini:


 callUnchecked(() -> new URL("https://dzone.com")); // URL        MalformedURLException 

Implementasi penuh dari metode ini ada di sini , ini adalah bagian dari proyek open source SNAMP .


Bekerja dengan Getters and Setters


Bagian ini akan berguna bagi mereka yang menulis serialisasi / deserialisasi untuk berbagai format data seperti JSON, Thrift, dll. Selain itu, ini bisa sangat berguna jika kode Anda sangat bergantung pada refleksi untuk Getters and Setters di JavaBeans.


Seorang pengambil yang dideklarasikan di JavaBean adalah metode bernama getXXX tanpa parameter dan tipe data kembali selain void . Setter yang dideklarasikan di JavaBean adalah metode bernama setXXX , dengan satu parameter dan mengembalikan void . Dua notasi ini dapat direpresentasikan sebagai antarmuka fungsional:


  • Getter dapat diwakili oleh kelas Function , di mana argumennya adalah nilai dari this .
  • Setter dapat diwakili oleh kelas BiConsumer , di mana argumen pertama adalah this , dan yang kedua adalah nilai yang diteruskan ke Setter.

Sekarang kita akan membuat dua metode yang dapat mengubah setiap pengambil atau penyetel menjadi ini
antarmuka fungsional. Dan tidak masalah bahwa kedua antarmuka adalah generik. Setelah menghapus tipe
tipe data nyata adalah Object . Pengecoran tipe kembali dan argumen otomatis dapat dilakukan menggunakan LambdaMetafactory . Selain itu, perpustakaan Guava akan membantu dengan caching ekspresi lambda untuk getter dan setter yang sama.


Langkah pertama: buat cache untuk getter dan setter. Kelas Metode API Refleksi mewakili pengambil atau penyetel nyata dan digunakan sebagai kunci.
Nilai cache adalah antarmuka fungsional yang dibangun secara dinamis untuk pengambil atau penyetel tertentu.


 private static final Cache<Method, Function> GETTERS = CacheBuilder.newBuilder().weakValues().build(); private static final Cache<Method, BiConsumer> SETTERS = CacheBuilder.newBuilder().weakValues().build(); 

Kedua, kita akan membuat metode pabrik yang membuat instance dari antarmuka fungsional berdasarkan referensi ke pengambil atau penyetel.


 private static Function createGetter(final MethodHandles.Lookup lookup, final MethodHandle getter) throws Exception{ final CallSite site = LambdaMetafactory.metafactory(lookup, "apply", MethodType.methodType(Function.class), MethodType.methodType(Object.class, Object.class), //signature of method Function.apply after type erasure getter, getter.type()); //actual signature of getter try { return (Function) site.getTarget().invokeExact(); } catch (final Exception e) { throw e; } catch (final Throwable e) { throw new Error(e); } } private static BiConsumer createSetter(final MethodHandles.Lookup lookup, final MethodHandle setter) throws Exception { final CallSite site = LambdaMetafactory.metafactory(lookup, "accept", MethodType.methodType(BiConsumer.class), MethodType.methodType(void.class, Object.class, Object.class), //signature of method BiConsumer.accept after type erasure setter, setter.type()); //actual signature of setter try { return (BiConsumer) site.getTarget().invokeExact(); } catch (final Exception e) { throw e; } catch (final Throwable e) { throw new Error(e); } } 

Konversi tipe otomatis antara argumen Object tipe dalam antarmuka fungsional (setelah penghapusan tipe) dan tipe nyata argumen dan nilai pengembalian dicapai dengan menggunakan perbedaan antara samMethodType dan instantiatedMethodType (masing-masing argumen ketiga dan kelima dari metode metafactory). Jenis instance metode yang dibuat - ini adalah spesialisasi metode yang menyediakan implementasi ekspresi lambda.


Ketiga, kami akan membuat fasad untuk pabrik-pabrik ini dengan dukungan untuk caching:


 public static Function reflectGetter(final MethodHandles.Lookup lookup, final Method getter) throws ReflectiveOperationException { try { return GETTERS.get(getter, () -> createGetter(lookup, lookup.unreflect(getter))); } catch (final ExecutionException e) { throw new ReflectiveOperationException(e.getCause()); } } public static BiConsumer reflectSetter(final MethodHandles.Lookup lookup, final Method setter) throws ReflectiveOperationException { try { return SETTERS.get(setter, () -> createSetter(lookup, lookup.unreflect(setter))); } catch (final ExecutionException e) { throw new ReflectiveOperationException(e.getCause()); } } 

Informasi metode yang diperoleh dari instance kelas Method menggunakan Java Reflection API dapat dengan mudah dikonversi ke MethodHandle . Ingatlah bahwa metode instance kelas selalu memiliki argumen pertama tersembunyi yang digunakan untuk meneruskan this ke metode ini. Metode statis tidak memiliki parameter seperti itu. Misalnya, tanda tangan aktual dari metode Integer.intValue() terlihat seperti int intValue(Integer this) . Trik ini digunakan dalam penerapan pembungkus fungsional kami untuk getter dan setter.


Dan sekarang saatnya untuk menguji kodenya:


 final Date d = new Date(); final BiConsumer<Date, Long> timeSetter = reflectSetter(MethodHandles.lookup(), Date.class.getDeclaredMethod("setTime", long.class)); timeSetter.accept(d, 42L); //the same as d.setTime(42L); final Function<Date, Long> timeGetter = reflectGetter(MethodHandles.lookup(), Date.class.getDeclaredMethod("getTime")); System.out.println(timeGetter.apply(d)); //the same as d.getTime() //output is 42 

Pendekatan ini dengan getter dan setter yang di-cache dapat digunakan secara efektif dalam perpustakaan serialisasi / deserialisasi (seperti Jackson) yang menggunakan getter dan setter selama serialisasi dan deserialisasi.




Memanggil antarmuka fungsional dengan implementasi yang dihasilkan secara dinamis menggunakan LambdaMetaFactory signifikan lebih cepat daripada memanggil melalui Java Reflection API




Versi lengkap kode dapat ditemukan di sini , ini adalah bagian dari perpustakaan SNAMP .


Keterbatasan dan bug


Pada bagian ini, kita akan melihat beberapa bug dan batasan yang terkait dengan ekspresi lambda di kompiler Java dan JVM. Semua batasan ini dapat direproduksi dalam OpenJDK dan Oracle JDK dengan javac versi 1.8.0_131 untuk Windows dan Linux.


Membuat ekspresi lambda dari penangan metode


Seperti yang Anda ketahui, ekspresi lambda dapat dibangun secara dinamis menggunakan LambdaMetaFactory . Untuk melakukan ini, Anda perlu mendefinisikan handler - kelas MethodHandle , yang mengindikasikan implementasi dari satu-satunya metode yang didefinisikan dalam antarmuka fungsional. Mari kita lihat contoh sederhana ini:


 final class TestClass { String value = ""; public String getValue() { return value; } public void setValue(final String value) { this.value = value; } } final TestClass obj = new TestClass(); obj.setValue("Hello, world!"); final MethodHandles.Lookup lookup = MethodHandles.lookup(); final CallSite site = LambdaMetafactory.metafactory(lookup, "get", MethodType.methodType(Supplier.class, TestClass.class), MethodType.methodType(Object.class), lookup.findVirtual(TestClass.class, "getValue", MethodType.methodType(String.class)), MethodType.methodType(String.class)); final Supplier<String> getter = (Supplier<String>) site.getTarget().invokeExact(obj); System.out.println(getter.get()); 

Kode ini setara dengan:


 final TestClass obj = new TestClass(); obj.setValue("Hello, world!"); final Supplier<String> elementGetter = () -> obj.getValue(); System.out.println(elementGetter.get()); 

Tetapi bagaimana jika kita mengganti penangan metode yang menunjuk ke getValue dengan penangan yang mewakili bidang pengambil:


 final CallSite site = LambdaMetafactory.metafactory(lookup, "get", MethodType.methodType(Supplier.class, TestClass.class), MethodType.methodType(Object.class), lookup.findGetter(TestClass.class, "value", String.class), //field getter instead of method handle to getValue MethodType.methodType(String.class)); 

Kode ini seharusnya, seperti yang diharapkan, berfungsi karena findGetter mengembalikan penangan yang menunjuk ke bidang pengambil dan memiliki tanda tangan yang benar. Tetapi, jika Anda menjalankan kode ini, Anda akan melihat pengecualian berikut:


 java.lang.invoke.LambdaConversionException: Unsupported MethodHandle kind: getField 

Menariknya, pengambil untuk bidang berfungsi dengan baik jika kita menggunakan MethodHandleProxies :


 final Supplier<String> getter = MethodHandleProxies .asInterfaceInstance(Supplier.class, lookup.findGetter(TestClass.class, "value", String.class) .bindTo(obj)); 

Perlu dicatat bahwa MethodHandleProxies bukan cara yang baik untuk secara dinamis membuat ekspresi lambda, karena kelas ini hanya membungkus MethodHandle di kelas proxy dan mendelegasikan pemanggilanHandler.invoke ke MethodHandle.invokeWithArguments . Pendekatan ini menggunakan Java Reflection dan sangat lambat.


Seperti yang ditunjukkan sebelumnya, tidak semua penangan metode dapat digunakan untuk membuat ekspresi lambda saat runtime.




Hanya beberapa jenis penangan metode yang dapat digunakan untuk secara dinamis membuat ekspresi lambda.




Inilah mereka:


  • REF_invokeInterface: dapat dibuat menggunakan Lookup.findVirtual untuk metode antarmuka
  • REF_invokeVirtual: dapat dibuat menggunakan Lookup.findVirtual untuk metode virtual kelas
  • REF_invokeStatic: dibuat menggunakan Lookup.findStatic untuk metode statis
  • REF_newInvokeSpecial: dapat dibuat menggunakan Lookup.findConstructor untuk konstruktor
  • REF_invokeSpecial: dapat dibuat menggunakan Lookup.findSpecial
    untuk metode pribadi dan penjilidan awal dengan metode virtual kelas

Jenis penangan lain akan LambdaConversionException kesalahan LambdaConversionException .


Pengecualian Umum


Bug ini terkait dengan kompiler Java dan kemampuan untuk menyatakan pengecualian umum di bagian throws . Contoh kode berikut menunjukkan perilaku ini:


 interface ExtendedCallable<V, E extends Exception> extends Callable<V>{ @Override V call() throws E; } final ExtendedCallable<URL, MalformedURLException> urlFactory = () -> new URL("http://localhost"); urlFactory.call(); 

Kode ini harus dikompilasi karena konstruktor dari kelas URL melempar MalformedURLException . Tapi itu tidak dikompilasi. Pesan kesalahan berikut ditampilkan:


 Error:(46, 73) java: call() in <anonymous Test$CODEgt; cannot implement call() in ExtendedCallable overridden method does not throw java.lang.Exception 

Tetapi, jika kita mengganti ekspresi lambda dengan kelas anonim, maka kode tersebut mengkompilasi:


 final ExtendedCallable<URL, MalformedURLException> urlFactory = new ExtendedCallable<URL, MalformedURLException>() { @Override public URL call() throws MalformedURLException { return new URL("http://localhost"); } }; urlFactory.call(); 

Berikut dari ini:




Ketik inferensi untuk pengecualian umum tidak berfungsi dengan benar dalam kombinasi dengan ekspresi lambda




Keterbatasan Tipe Parameterisasi


Anda dapat membuat objek generik dengan beberapa batasan tipe menggunakan tanda & : <T extends A & B & C & ... Z> .
Metode penentuan parameter generik ini jarang digunakan, tetapi dengan cara tertentu memengaruhi ekspresi lambda di Jawa karena beberapa batasan:


  • Setiap jenis kendala, kecuali yang pertama, harus berupa antarmuka.
  • Versi murni dari kelas dengan generik seperti itu hanya memperhitungkan kendala tipe pertama dari daftar.

Keterbatasan kedua mengarah pada perilaku kode yang berbeda pada waktu kompilasi dan pada saat runtime, ketika pengikatan ke ekspresi lambda terjadi. Perbedaan ini dapat ditunjukkan dengan menggunakan kode berikut:


 final class MutableInteger extends Number implements IntSupplier, IntConsumer { //mutable container of int value private int value; public MutableInteger(final int v) { value = v; } @Override public int intValue() { return value; } @Override public long longValue() { return value; } @Override public float floatValue() { return value; } @Override public double doubleValue() { return value; } @Override public int getAsInt() { return intValue(); } @Override public void accept(final int value) { this.value = value; } } static <T extends Number & IntSupplier> OptionalInt findMinValue(final Collection <T> values) { return values.stream().mapToInt(IntSupplier::getAsInt).min(); } final List <MutableInteger> values = Arrays.asList(new MutableInteger(10), new MutableInteger(20)); final int mv = findMinValue(values).orElse(Integer.MIN_VALUE); System.out.println(mv); 

Kode ini benar sekali dan berhasil dikompilasi. Kelas MutableInteger memenuhi batasan tipe T generik:


  • MutableInteger mewarisi dari Number .
  • MutableInteger mengimplementasikan IntSupplier .

Tetapi kode akan macet dengan pengecualian saat runtime:


 java.lang.BootstrapMethodError: call site initialization exception at java.lang.invoke.CallSite.makeSite(CallSite.java:341) at java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:307) at java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:297) at Test.minValue(Test.java:77) Caused by: java.lang.invoke.LambdaConversionException: Invalid receiver type class java.lang.Number; not a subtype of implementation type interface java.util.function.IntSupplier at java.lang.invoke.AbstractValidatingLambdaMetafactory.validateMetafactoryArgs(AbstractValidatingLambdaMetafactory.java:233) at java.lang.invoke.LambdaMetafactory.metafactory(LambdaMetafactory.java:303) at java.lang.invoke.CallSite.makeSite(CallSite.java:302) 

Ini terjadi karena pipa JavaStream hanya menangkap tipe murni, yang, dalam kasus kami, adalah kelas Number dan itu tidak mengimplementasikan antarmuka IntSupplier . Masalah ini dapat diperbaiki dengan secara eksplisit mendeklarasikan tipe parameter dalam metode yang terpisah, digunakan sebagai referensi ke metode:


 private static int getInt(final IntSupplier i){ return i.getAsInt(); } private static <T extends Number & IntSupplier> OptionalInt findMinValue(final Collection<T> values){ return values.stream().mapToInt(UtilsTest::getInt).min(); } 

Contoh ini menunjukkan inferensi tipe yang salah dalam kompiler dan runtime.




Menangani beberapa batasan tipe parameter umum bersamaan dengan menggunakan ekspresi lambda pada waktu kompilasi dan pada saat runtime tidak konsisten



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


All Articles