Versi pendek:
Agar gaya penugasan tunggal berfungsi dengan baik di Jawa, Anda perlu (1) semacam infrastruktur ramah-abadi, dan (2) dukungan tingkat kompiler atau runtime untuk eliminasi panggilan ekor.
Kami dapat menulis banyak infrastruktur, dan kami dapat mengatur berbagai hal untuk menghindari pengisian tumpukan. Tetapi selama setiap panggilan membutuhkan frame stack, akan ada batasan berapa banyak rekursi yang dapat Anda lakukan. Simpan iterables Anda kecil dan / atau malas, dan Anda seharusnya tidak memiliki masalah besar. Setidaknya sebagian besar masalah yang akan Anda hadapi tidak membutuhkan pengembalian sejuta hasil sekaligus. :)
Perhatikan juga, karena program harus benar-benar memengaruhi perubahan yang terlihat agar layak untuk dijalankan, Anda tidak dapat membuat semuanya berubah. Anda dapat, bagaimanapun, menjaga sebagian besar barang-barang Anda sendiri tidak berubah, menggunakan subset kecil dari perumpamaan penting (stream, misalnya) hanya pada titik-titik kunci tertentu di mana alternatif akan terlalu berat.
Versi panjang:
Sederhananya, program Java tidak dapat sepenuhnya menghindari variabel jika ingin melakukan sesuatu yang layak dilakukan. Anda dapat menampungnya , dan dengan demikian membatasi mutabilitas hingga tingkat yang sangat besar, tetapi desain bahasa dan API yang sangat - bersama dengan kebutuhan untuk akhirnya mengubah sistem yang mendasarinya - membuat kekekalan total menjadi tidak mungkin.
Java dirancang sejak awal sebagai bahasa imperatif , berorientasi objek .
- Bahasa imperatif hampir selalu bergantung pada variabel yang bisa berubah dari beberapa jenis. Mereka cenderung mendukung iterasi daripada rekursi, misalnya, dan hampir semua konstruksi berulang - bahkan
while (true)
dan for (;;)
! - sangat tergantung pada variabel di suatu tempat yang berubah dari iterasi ke iterasi.
- Bahasa berorientasi objek cukup banyak membayangkan setiap program sebagai grafik objek yang saling mengirim pesan, dan dalam hampir semua kasus, merespons pesan-pesan itu dengan memutasikan sesuatu.
Hasil akhir dari keputusan desain tersebut adalah bahwa tanpa variabel yang dapat berubah, Java tidak memiliki cara untuk mengubah keadaan apa pun - bahkan sesuatu yang sederhana seperti mencetak "Halo dunia!" ke layar melibatkan aliran output, yang melibatkan menempel byte dalam buffer yang bisa berubah .
Jadi, untuk semua tujuan praktis, kita dibatasi untuk membuang variabel dari kode kita sendiri . OK, kita bisa melakukan itu. Hampir. Pada dasarnya yang kita butuhkan adalah mengganti hampir semua iterasi dengan rekursi, dan semua mutasi dengan panggilan rekursif mengembalikan nilai yang diubah. seperti itu ...
class Ints {
final int value;
final Ints tail;
public Ints(int value, Ints rest) {
this.value = value;
this.tail = rest;
}
public Ints next() { return this.tail; }
public int value() { return this.value; }
}
public Ints take(int count, Ints input) {
if (count == 0 || input == null) return null;
return new Ints(input.value(), take(count - 1, input.next()));
}
public Ints squares_of(Ints input) {
if (input == null) return null;
int i = input.value();
return new Ints(i * i, squares_of(input.next()));
}
Pada dasarnya, kita membangun daftar tertaut, di mana setiap node adalah daftar itu sendiri. Setiap daftar memiliki "kepala" (nilai saat ini) dan "ekor" (sublist yang tersisa). Sebagian besar bahasa fungsional melakukan sesuatu yang mirip dengan ini, karena itu sangat bisa diterima untuk kekekalan efisien. Operasi "berikutnya" hanya mengembalikan buntut, yang biasanya diteruskan ke tingkat berikutnya dalam setumpuk panggilan rekursif.
Sekarang, ini adalah versi yang sangat disederhanakan dari barang ini. Tapi itu cukup bagus untuk menunjukkan masalah serius dengan pendekatan ini di Jawa. Pertimbangkan kode ini:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Meskipun kami hanya membutuhkan 25 int untuk hasilnya, squares_of
tidak tahu itu. Ini akan mengembalikan kuadrat dari setiap angka di integers
. Rekursi 20 juta level menyebabkan masalah yang cukup besar di Jawa.
Lihat, bahasa fungsional Anda biasanya melakukan keanehan seperti ini, memiliki fitur yang disebut "eliminasi panggilan ekor". Apa itu artinya, ketika kompiler melihat tindakan terakhir kode adalah untuk memanggil dirinya sendiri (dan mengembalikan hasilnya jika fungsi tidak batal), ia menggunakan bingkai stack panggilan saat ini alih-alih mengatur yang baru dan melakukan "lompatan" sebagai gantinya dari "panggilan" (sehingga ruang stack yang digunakan tetap konstan). Singkatnya, ia berjalan sekitar 90% dari jalan menuju mengubah rekursi ekor menjadi iterasi. Itu bisa berurusan dengan milyaran int tanpa meluap tumpukan. (Ini pada akhirnya masih kehabisan memori, tetapi merakit daftar milyaran int akan tetap mengacaukan Anda dengan memori pada sistem 32-bit.)
Java tidak melakukan itu, dalam banyak kasus. (Itu tergantung pada kompiler dan runtime, tetapi implementasi Oracle tidak melakukannya.) Setiap panggilan ke fungsi rekursif memakan memori stack. Gunakan terlalu banyak, dan Anda mendapatkan stack overflow. Meluap tumpukan semua tetapi menjamin kematian program. Jadi kita harus memastikan untuk tidak melakukannya.
Satu semi-solusi ... evaluasi malas. Kami masih memiliki batasan tumpukan, tetapi mereka dapat dikaitkan dengan faktor-faktor yang memiliki kendali lebih besar. Kami tidak harus menghitung satu juta ints hanya untuk mengembalikan 25. :)
Jadi mari kita bangun beberapa infrastruktur evaluasi malas. (Kode ini sudah diuji beberapa waktu lalu, tapi saya sudah memodifikasinya sejak itu; baca idenya, bukan kesalahan sintaks. :))
// Represents something that can give us instances of OutType.
// We can basically treat this class like a list.
interface Source<OutType> {
public Source<OutType> next();
public OutType value();
}
// Represents an operation that turns an InType into an OutType.
// Note, these can be the same type. We're just flexible like that.
interface Transform<InType, OutType> {
public OutType appliedTo(InType input);
}
// Represents an action (as opposed to a function) that can run on
// every element of a sequence.
abstract class Action<InType> {
abstract void doWith(final InType input);
public void doWithEach(final Source<InType> input) {
if (input == null) return;
doWith(input.value());
doWithEach(input.next());
}
}
// A list of Integers.
class Ints implements Source<Integer> {
final Integer value;
final Ints tail;
public Ints(Integer value, Ints rest) {
this.value = value;
this.tail = rest;
}
public Ints(Source<Integer> input) {
this.value = input.value();
this.tail = new Ints(input.next());
}
public Source<Integer> next() { return this.tail; }
public Integer value() { return this.value; }
public static Ints fromArray(Integer[] input) {
return fromArray(input, 0, input.length);
}
public static Ints fromArray(Integer[] input, int start, int end) {
if (end == start || input == null) return null;
return new Ints(input[start], fromArray(input, start + 1, end));
}
}
// An example of the spiff we get by splitting the "iterator" interface
// off. These ints are effectively generated on the fly, as opposed to
// us having to build a huge list. This saves huge amounts of memory
// and CPU time, for the rather common case where the whole sequence
// isn't needed.
class Range implements Source<Integer> {
final int start, end;
public Range(int start, int end) {
this.start = start;
this.end = end;
}
public Integer value() { return start; }
public Source<Integer> next() {
if (start >= end) return null;
return new Range(start + 1, end);
}
}
// This takes each InType of a sequence and turns it into an OutType.
// This *takes* a Transform, rather than just *implementing* Transform,
// because the transforms applied are likely to be specified inline.
// If we just let people override `value()`, we wouldn't easily know what type
// to return, and returning our own type would lose the transform method.
static class Mapper<InType, OutType> implements Source<OutType> {
private final Source<InType> input;
private final Transform<InType, OutType> transform;
public Mapper(Transform<InType, OutType> transform, Source<InType> input) {
this.transform = transform;
this.input = input;
}
public Source<OutType> next() {
return new Mapper<InType, OutType>(transform, input.next());
}
public OutType value() {
return transform.appliedTo(input.value());
}
}
// ...
public <T> Source<T> take(int count, Source<T> input) {
if (count <= 0 || input == null) return null;
return new Source<T>() {
public T value() { return input.value(); }
public Source<T> next() { return take(count - 1, input.next()); }
};
}
(Perlu diingat bahwa jika ini benar-benar layak di Jawa, kode setidaknya seperti di atas sudah akan menjadi bagian dari API.)
Sekarang, dengan infrastruktur yang tersedia, agak sepele untuk menulis kode yang tidak perlu variabel yang dapat diubah dan setidaknya stabil untuk jumlah input yang lebih kecil.
public Source<Integer> squares_of(Source<Integer> input) {
final Transform<Integer, Integer> square = new Transform<Integer, Integer>() {
public Integer appliedTo(final Integer i) { return i * i; }
};
return new Mapper<>(square, input);
}
public void example() {
final Source<Integer> integers = new Range(0, 1000000000);
// and, as for the author's "bet you can't do this"...
final Source<Integer> squares = take(25, squares_of(integers));
// Just to make sure we got it right :P
final Action<Integer> printAction = new Action<Integer>() {
public void doWith(Integer input) { System.out.println(input); }
};
printAction.doWithEach(squares);
}
Ini sebagian besar berfungsi, tetapi masih cenderung untuk menumpuk kelebihan. Coba take
2 miliar int dan lakukan beberapa tindakan pada mereka. : P Pada akhirnya akan mengeluarkan pengecualian, setidaknya hingga 64+ GB RAM menjadi standar. Masalahnya adalah, jumlah memori program yang dicadangkan untuk tumpukannya tidak terlalu besar. Biasanya antara 1 dan 8 MiB. (Anda dapat meminta yang lebih besar, tetapi tidak masalah seberapa banyak yang Anda minta - Anda menelepon take(1000000000, someInfiniteSequence)
, Anda akan mendapatkan pengecualian.) Untungnya, dengan evaluasi yang malas, titik lemah ada di area yang dapat kita kontrol lebih baik . Kita hanya harus berhati-hati tentang berapa banyak kita take()
.
Masih akan ada banyak masalah yang ditingkatkan, karena penggunaan tumpukan kami meningkat secara linear. Setiap panggilan menangani satu elemen dan meneruskan sisanya ke panggilan lain. Sekarang saya berpikir tentang hal itu, ada satu trik yang bisa kita tarik yang mungkin memberi kita sedikit lebih banyak ruang kepala: mengubah rantai panggilan menjadi pohon panggilan. Pertimbangkan sesuatu yang lebih seperti ini:
public <T> void doSomethingWith(T input) { /* magic happens here */ }
public <T> Source<T> workWith(Source<T> input, int count) {
if (count < 0 || input == null) return null;
if (count == 0) return input;
if (count == 1) {
doSomethingWith(input.value());
return input.next();
}
return (workWith(workWith(input, count/2), count - count/2);
}
workWith
pada dasarnya memecah pekerjaan menjadi dua bagian, dan menetapkan masing-masing setengah untuk panggilan lain untuk dirinya sendiri. Karena setiap panggilan mengurangi ukuran daftar kerja menjadi setengah daripada satu, ini harus skala logaritma daripada linear.
Masalahnya adalah, fungsi ini membutuhkan input - dan dengan daftar yang ditautkan, mendapatkan panjang memerlukan melintasi seluruh daftar. Namun itu mudah dipecahkan; hanya tidak peduli berapa banyak entri ada. :) Kode di atas akan berfungsi dengan sesuatu seperti Integer.MAX_VALUE
hitungan, karena null tetap menghentikan pemrosesan. Hitungannya sebagian besar ada sehingga kami memiliki alas yang kuat. Jika Anda mengantisipasi memiliki lebih dari Integer.MAX_VALUE
entri dalam daftar, maka Anda dapat memeriksa workWith
nilai kembali - itu harus nol pada akhirnya. Kalau tidak, kambuh.
Perlu diingat, ini menyentuh elemen sebanyak yang Anda katakan. Itu tidak malas; ia melakukan hal itu dengan segera. Anda hanya ingin melakukannya untuk tindakan - yaitu, hal-hal yang tujuan utamanya adalah untuk diterapkan sendiri ke setiap elemen dalam daftar. Seperti yang saya pikirkan sekarang, menurut saya urutannya akan jauh lebih rumit jika dijaga tetap linier; seharusnya tidak menjadi masalah, karena sekuens tidak menyebut diri mereka sendiri - mereka hanya membuat objek yang memanggil mereka lagi.