"JVM tidak mendukung optimisasi panggilan balik, jadi saya memprediksi banyak tumpukan yang meledak"
Siapa pun yang mengatakan ini (1) tidak memahami pengoptimalan panggilan-ekor, atau (2) tidak memahami JVM, atau (3) keduanya.
Saya akan mulai dengan definisi panggilan ekor dari Wikipedia (jika Anda tidak menyukai Wikipedia, berikut ini alternatifnya ):
Dalam ilmu komputer, panggilan ekor adalah panggilan subrutin yang terjadi di dalam prosedur lain sebagai tindakan terakhirnya; itu dapat menghasilkan nilai kembali yang kemudian segera dikembalikan oleh prosedur panggilan
Dalam kode di bawah ini, panggilan untuk bar()
adalah panggilan ekor dari foo()
:
private void foo() {
// do something
bar()
}
Optimasi panggilan ekor terjadi ketika implementasi bahasa, melihat panggilan ekor, tidak menggunakan doa metode normal (yang menciptakan bingkai tumpukan), tetapi malah membuat cabang. Ini adalah pengoptimalan karena bingkai tumpukan memerlukan memori, dan ini membutuhkan siklus CPU untuk mendorong informasi (seperti alamat pengirim) ke bingkai, dan karena pasangan panggilan / kembali diasumsikan membutuhkan lebih banyak siklus CPU daripada lompatan tanpa syarat.
TCO sering diterapkan pada rekursi, tetapi itu bukan satu-satunya penggunaannya. Juga tidak berlaku untuk semua rekursi. Kode rekursif sederhana untuk menghitung faktorial, misalnya, tidak dapat dioptimalkan ekor-panggilan, karena hal terakhir yang terjadi dalam fungsi adalah operasi multiplikasi.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Untuk menerapkan optimasi panggilan ekor, Anda memerlukan dua hal:
- Platform yang mendukung percabangan selain panggilan subtroutine.
- Penganalisa statis yang dapat menentukan apakah optimasi panggilan ekor dimungkinkan.
Itu dia. Seperti yang telah saya catat di tempat lain, JVM (seperti arsitektur Turing-complete lainnya) memiliki goto. Itu kebetulan memiliki kebersamaan tanpa syarat , tetapi fungsi dapat dengan mudah diimplementasikan menggunakan cabang bersyarat.
Bagian analisis statis adalah yang rumit. Dalam satu fungsi, tidak ada masalah. Misalnya, inilah fungsi Scala rekursif ekor untuk menjumlahkan nilai dalam List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Fungsi ini berubah menjadi bytecode berikut:
public int sum(int, scala.collection.immutable.List);
Code:
0: aload_2
1: invokevirtual #63; //Method scala/collection/immutable/List.isEmpty:()Z
4: ifeq 9
7: iload_1
8: ireturn
9: iload_1
10: aload_2
11: invokevirtual #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
14: invokestatic #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
17: iadd
18: aload_2
19: invokevirtual #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
22: checkcast #59; //class scala/collection/immutable/List
25: astore_2
26: istore_1
27: goto 0
Perhatikan goto 0
di bagian akhir. Sebagai perbandingan, fungsi Java yang setara (yang harus menggunakan a Iterator
untuk meniru perilaku memecah daftar Scala menjadi kepala dan ekor) berubah menjadi bytecode berikut. Perhatikan bahwa dua operasi terakhir sekarang merupakan invoke , diikuti oleh pengembalian eksplisit dari nilai yang dihasilkan oleh doa rekursif.
public static int sum(int, java.util.Iterator);
Code:
0: aload_1
1: invokeinterface #64, 1; //InterfaceMethod java/util/Iterator.hasNext:()Z
6: ifne 11
9: iload_0
10: ireturn
11: iload_0
12: aload_1
13: invokeinterface #70, 1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
18: checkcast #25; //class java/lang/Integer
21: invokevirtual #74; //Method java/lang/Integer.intValue:()I
24: iadd
25: aload_1
26: invokestatic #43; //Method sum:(ILjava/util/Iterator;)I
29: ireturn
Optimasi panggilan ekor dari fungsi tunggal sepele: compiler dapat melihat bahwa tidak ada kode yang menggunakan hasil dari panggilan, sehingga dapat menggantikan Invoke dengan goto
.
Di mana hidup menjadi sulit adalah jika Anda memiliki banyak metode. Instruksi percabangan JVM, tidak seperti pada prosesor tujuan umum seperti 80x86, terbatas pada metode tunggal. Ini masih relatif mudah jika Anda memiliki metode pribadi: kompiler bebas untuk mengikutsertakan metode-metode yang sesuai, sehingga dapat mengoptimalkan panggilan ekor (jika Anda bertanya-tanya bagaimana ini bisa bekerja, pertimbangkan metode umum yang menggunakanswitch
untuk mengontrol perilaku). Anda bahkan dapat memperluas teknik ini ke beberapa metode publik di kelas yang sama: kompiler inline badan metode, menyediakan metode jembatan publik, dan panggilan internal berubah menjadi lompatan.
Tapi, model ini rusak ketika Anda mempertimbangkan metode publik di kelas yang berbeda, terutama mengingat antarmuka dan classloader. Kompilator level sumber tidak memiliki cukup pengetahuan untuk mengimplementasikan optimisasi panggilan balik. Namun, tidak seperti implementasi "bare-metal", * JVM (memang memiliki informasi untuk melakukan ini, dalam bentuk kompiler Hotspot (setidaknya, kompiler ex-Sun tidak). Saya tidak tahu apakah itu benar-benar melakukan optimisasi panggilan ekor, dan curiga tidak, tetapi itu bisa .
Yang membawa saya ke bagian kedua dari pertanyaan Anda, yang saya akan ulangi sebagai "haruskah kita peduli?"
Jelas, jika bahasa Anda menggunakan rekursi sebagai satu-satunya primitif untuk iterasi, Anda peduli. Tetapi, bahasa yang membutuhkan fitur ini dapat mengimplementasikannya; satu-satunya masalah adalah apakah kompiler untuk bahasa tersebut dapat menghasilkan kelas yang dapat memanggil dan dipanggil oleh kelas Java sewenang-wenang.
Di luar kasus itu, saya akan mengundang downvotes dengan mengatakan bahwa itu tidak relevan. Sebagian besar kode rekursif yang pernah saya lihat (dan saya telah bekerja dengan banyak proyek grafik) tidak dapat dioptimalkan . Seperti faktorial sederhana, ia menggunakan rekursi untuk membangun keadaan, dan operasi ekor adalah kombinasi.
Untuk kode yang tail-call dioptimalkan, seringkali mudah untuk menerjemahkan kode itu ke dalam bentuk yang dapat diubah. Sebagai contoh, sum()
fungsi yang saya tunjukkan sebelumnya dapat digeneralisasi sebagai foldLeft()
. Jika Anda melihat sumbernya , Anda akan melihat bahwa itu sebenarnya diimplementasikan sebagai operasi berulang. Jörg W Mittag memiliki contoh mesin negara diimplementasikan melalui panggilan fungsi; ada banyak implementasi mesin negara yang efisien (dan dapat dipelihara) yang tidak bergantung pada pemanggilan fungsi yang diterjemahkan ke dalam lompatan.
Saya akan selesai dengan sesuatu yang sangat berbeda. Jika Anda Google jalan dari catatan kaki di SICP, Anda mungkin berakhir di sini . Saya pribadi menemukan bahwa tempat yang jauh lebih menarik daripada mengganti kompiler saya JSR
dengan JUMP
.