Kısa versiyon:
Tek atama stilinin Java'da güvenilir bir şekilde çalışmasını sağlamak için (1) bir tür değişmez dostu altyapıya ve (2) kuyruk çağrısı ortadan kaldırılması için derleyici veya çalışma zamanı düzeyinde desteğe ihtiyacınız vardır.
Altyapının çoğunu yazabiliriz ve yığını doldurmaktan kaçınmak için şeyler düzenleyebiliriz. Ancak, her çağrı bir yığın çerçevesi aldıkça, ne kadar özyineleme yapabileceğiniz konusunda bir sınır olacaktır. Yinelenebilir öğelerinizi küçük ve / veya tembel tutun ve büyük sorunlarınız olmamalıdır. Karşılaşacağınız sorunların en azından bir kerede bir milyon sonuç döndürmesini gerektirmez. :)
Ayrıca, programın çalışmaya değer olması için görünür değişiklikleri gerçekten etkilemesi gerektiğinden, her şeyi değiştirilemez hale getiremezsiniz . Bununla birlikte, temel alternatiflerin küçük bir alt kümesini (örneğin, akarsuları) kullanarak, yalnızca alternatiflerin çok zahmetli olacağı belirli noktalarda kendi eşyalarınızın büyük çoğunluğunu değişmez tutabilirsiniz.
Uzun versiyon:
Basitçe söylemek gerekirse, bir Java programı, yapmaya değer bir şey yapmak istiyorsa değişkenlerden tamamen kaçınamaz. Bunları içerebilir ve böylece değişebilirliği büyük ölçüde kısıtlayabilirsiniz, ancak dilin ve API'nin tasarımı - sonuçta altta yatan sistemi değiştirme ihtiyacıyla - toplam değişmezliği mümkün kılmaz.
Java en başından zorunlu , nesne yönelimli bir dil olarak tasarlanmıştır .
- Zorunlu diller neredeyse her zaman bir tür değişebilir değişkenlere bağlıdır. Örneğin, yinelemeden yinelemeyi ve neredeyse tüm yinelemeli yapıları - hatta
while (true)
ve for (;;)
! - tamamen yinelemeden yinelemeye dönüşen bir değişkene bağımlıdır.
- Nesne yönelimli diller, hemen hemen her programı birbirine mesaj gönderen nesnelerin bir grafiği olarak görürler ve neredeyse tüm durumlarda bu mesajlara bir şeyler değiştirerek yanıt verir.
Bu tasarım kararlarının sonucu, değişken değişkenler olmadan Java'nın hiçbir şeyin durumunu değiştirmenin bir yolu olmadığıdır - hatta "Merhaba dünya!" ekrana olarak bayt yapışma içerir Bir çıkış akımının, içerir kesilebilir tamponu.
Yani, tüm pratik amaçlar için, değişkenleri kendi kodumuzdan çıkarmakla sınırlıyız . Tamam, bunu yapabiliriz. Neredeyse. Temel olarak ihtiyacımız olan şey, neredeyse tüm yinelemeyi özyineleme ile değiştirmek ve yinelenen çağrılarla değiştirilen değeri döndüren tüm mutasyonları değiştirmek. böyle ...
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()));
}
Temel olarak, her bir düğümün kendi içinde bir liste olduğu bağlantılı bir liste oluştururuz. Her liste bir "kafa" (geçerli değer) ve bir "kuyruk" (kalan alt liste) içerir. Çoğu işlevsel dil buna benzer bir şey yapar, çünkü verimli değişmezliğe çok uygundur. Bir "sonraki" işlemi sadece özyinelemeli çağrılar yığını içinde bir sonraki seviyeye geçirilen kuyruğu döndürür.
Şimdi, bu son derece basitleştirilmiş bir versiyonudur. Ancak Java'daki bu yaklaşımda ciddi bir sorun göstermek için yeterince iyi. Bu kodu düşünün:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Sonuç için sadece 25 inte ihtiyacımız olmasına rağmen, squares_of
bunu bilmiyor. Her sayının karesini geri döndürecek integers
. 20 milyon seviyeli derin özyineleme Java'da oldukça büyük sorunlara neden olur.
Gördüğünüz gibi, tipik olarak böyle tuhaflık yapacağınız işlevsel diller, "kuyruk çağrısının kaldırılması" özelliğine sahiptir. Bunun anlamı, derleyici kodun son eyleminin kendisini çağırmasını (ve işlevin geçersiz olmaması durumunda sonucu döndürmesini) gördüğünde, yenisini ayarlamak yerine geçerli çağrının yığın çerçevesini kullanır ve bunun yerine "atlama" yapar bir "çağrı" (böylece yığın alanı sabit kalır). Kısacası, kuyruk tekrarını yinelemeye dönüştürmenin yolunun yaklaşık% 90'ı. Bu milyar ints ile yığını aşmadan baş edebilirdi. (Nihayetinde bellek tükenecekti, ancak bir milyar inçlik bir liste oluşturmak, 32 bitlik bir sistemde sizi yine de hafızaya karıştıracak.)
Java bunu çoğu durumda yapmaz. (Derleyiciye ve çalışma zamanına bağlıdır, ancak Oracle'ın uygulaması bunu yapmaz.) Özyinelemeli bir işleve yapılan her çağrı, yığın çerçevesinin bellek değerini tüketir. Çok fazla tüketirseniz yığın taşması olur. Yığının taşması, programın ölümünü garanti eder. Bu yüzden bunu yapmamaya dikkat etmeliyiz.
Bir yarı geçici çözüm ... tembel değerlendirme. Yığın sınırlamalarımız hala var, ancak bunlar üzerinde daha fazla kontrol sahibi olduğumuz faktörlere bağlanabilir. Sadece 25 dönmek için bir milyon int hesaplamak zorunda değiliz. :)
Şimdi bize tembel bir değerlendirme altyapısı inşa edelim. (Bu kod bir süre önce test edildi, ancak o zamandan beri biraz değiştirdim; sözdizimi hatalarını değil, fikri okuyun. :))
// 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()); }
};
}
(Bu, Java'da gerçekten geçerli olsaydı, en azından yukarıdaki gibi bir kodun zaten API'nın bir parçası olacağını unutmayın.)
Bir altyapı mevcut olduğunda, değişken değişkenlere ihtiyaç duymayan ve en azından daha küçük miktarlarda girdi için kararlı olan kod yazmak oldukça önemsizdir.
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);
}
Bu çoğunlukla işe yarar, ancak taşma yığınlarına hala yatkındır. take
2 milyar int denemek ve onlar üzerinde bir şeyler yapmak. : P En azından 64+ GB RAM standart hale gelene kadar bir istisna atar. Sorun şu ki, bir programın yığını için ayrılan belleğinin miktarı o kadar büyük değil. Genellikle 1 ila 8 MiB arasındadır. (Daha büyük isteyebilir ama hepsi çok ne kadar sormak fark etmez - aramak take(1000000000, someInfiniteSequence)
, sen olacaktır . İstisna olsun) bir alanda biz daha iyi yapamaz Neyse ki, tembel değerlendirmesiyle, zayıf nokta kontrolü . Ne kadar olduğumuza dikkat etmeliyiz take()
.
Yığın kullanımımız doğrusal olarak arttığından, hala ölçeklendirme konusunda birçok sorun yaşayacaktır. Her çağrı bir elemanı ele alır ve geri kalanını başka bir çağrıya iletir. Şimdi bunu düşündüğümde, çekebileceğimiz bir hile var, bu da bizi biraz daha fazla alan yaratabilir: çağrı zincirini bir çağrı ağacına dönüştürün . Bunun gibi bir şey düşünün:
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
temelde işi iki yarıya böler ve her yarıyı kendisine başka bir çağrıya atar. Her çağrı, çalışma listesinin boyutunu bir değil yarı yarıya azalttığından, bu doğrusal olarak değil logaritmik olarak ölçeklendirilmelidir.
Sorun, bu fonksiyon bir giriş istiyor - ve bağlantılı bir listeyle, uzunluk elde etmek tüm listenin çaprazlanmasını gerektirir. Ancak bu kolayca çözülebilir; basitçe umurumda değil kaç tane giriş var. :) Yukarıdaki kod Integer.MAX_VALUE
sayı gibi bir şeyle çalışır , çünkü bir null işlemi yine de durdurur. Sayım çoğunlukla oradadır, bu yüzden sağlam bir taban kasamız var. Integer.MAX_VALUE
Bir listede birden fazla giriş olmasını bekliyorsanız , workWith
dönüş değerini kontrol edebilirsiniz - sonunda boş olmalıdır. Aksi takdirde, geri ödeme.
Unutmayın, bu söylediğiniz kadar öğeye dokunur. Tembel değil; hemen işini yapar. Bunu sadece eylemler için yapmak istersiniz - yani, tek amacı kendisini bir listedeki her öğeye uygulamak olan şeyler. Şu anda düşündüğüm gibi, bana göre diziler doğrusal tutulursa çok daha az karmaşık olurdu; bir sorun olmamalı, çünkü diziler zaten kendilerini çağırmıyorlar - sadece onları tekrar çağıran nesneler yaratıyorlar.