Kurzfassung:
Damit Single-Assignment-Stile in Java zuverlässig funktionieren, benötigen Sie (1) eine unveränderliche Infrastruktur und (2) Unterstützung auf Compiler- oder Laufzeitebene für die Tail-Call-Eliminierung.
Wir können einen Großteil der Infrastruktur schreiben und Dinge arrangieren, um zu vermeiden, dass der Stapel gefüllt wird. Solange jedoch jeder Aufruf einen Stack-Frame benötigt, ist die Anzahl der möglichen Rekursionen begrenzt. Halten Sie Ihre iterables klein und / oder faul, und Sie sollten keine größeren Probleme haben. Zumindest die meisten Probleme, auf die Sie stoßen, erfordern nicht die sofortige Rückgabe von einer Million Ergebnissen. :)
Beachten Sie auch, dass Sie nicht alles unveränderlich machen können, da das Programm sichtbare Änderungen vornehmen muss, damit es sich lohnt, ausgeführt zu werden . Sie können jedoch den Großteil Ihrer eigenen Daten unveränderlich halten, indem Sie nur an bestimmten wichtigen Stellen, an denen die Alternativen zu lästig wären, eine winzige Teilmenge der wesentlichen veränderlichen Daten (z. B. Streams) verwenden.
Lange Version:
Einfach ausgedrückt, ein Java-Programm kann Variablen nicht vollständig vermeiden, wenn es etwas tun möchte, das es wert ist, getan zu werden. Sie können enthalten und somit die Veränderlichkeit in hohem Maße einschränken, aber das eigentliche Design der Sprache und der API sowie die Notwendigkeit, das zugrunde liegende System zu ändern, machen eine vollständige Unveränderlichkeit unmöglich.
Java wurde von Anfang an als imperative , objektorientierte Sprache konzipiert.
- Imperative Sprachen hängen fast immer von veränderlichen Variablen ab. Sie bevorzugen zum Beispiel die Iteration gegenüber der Rekursion und fast alle iterativen Konstrukte - sogar
while (true)
und for (;;)
! - sind absolut abhängig von einer Variablen, die sich von Iteration zu Iteration ändert.
- Objektorientierte Sprachen stellen sich fast jedes Programm als ein Diagramm von Objekten vor, die Nachrichten aneinander senden und in fast allen Fällen auf diese Nachrichten reagieren, indem sie etwas mutieren.
Das Endergebnis dieser Entwurfsentscheidungen ist, dass Java ohne veränderbare Variablen keine Möglichkeit hat, den Status von irgendetwas zu ändern - selbst von etwas so Einfachem wie dem Drucken von "Hallo Welt!" zum Bildschirm gehört ein Ausgabestream, bei dem Bytes in eine veränderbare Datei eingefügt werden Puffer .
Aus praktischen Gründen beschränken wir uns darauf, die Variablen aus unserem eigenen Code zu verbannen . OK, das können wir irgendwie machen. Fast. Grundsätzlich müssten wir fast alle Iterationen durch Rekursionen und alle Mutationen durch rekursive Aufrufe ersetzen, die den geänderten Wert zurückgeben. wie so ...
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()));
}
Grundsätzlich erstellen wir eine verknüpfte Liste, wobei jeder Knoten eine Liste für sich ist. Jede Liste hat einen "Kopf" (den aktuellen Wert) und einen "Schwanz" (die verbleibende Unterliste). Die meisten funktionalen Sprachen tun etwas Ähnliches, weil sie einer effizienten Unveränderlichkeit sehr zugänglich sind. Eine "nächste" Operation gibt nur das Ende zurück, das normalerweise in einem Stapel rekursiver Aufrufe an die nächste Ebene übergeben wird.
Dies ist eine extrem vereinfachte Version dieses Materials. Aber es ist gut genug, um ein ernstes Problem mit diesem Ansatz in Java zu demonstrieren. Betrachten Sie diesen Code:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Obwohl wir nur 25 Zoll für das Ergebnis benötigen, squares_of
weiß das nicht. Es wird das Quadrat jeder Zahl in zurückgeben integers
. Eine Rekursion mit einer Tiefe von 20 Millionen Ebenen verursacht in Java ziemlich große Probleme.
Sehen Sie, die funktionalen Sprachen, in denen Sie normalerweise so verrückt sind, haben eine Funktion namens "Tail Call Elimination". Dies bedeutet, dass der Compiler, wenn er sieht, dass der Code zuletzt sich selbst aufruft (und das Ergebnis zurückgibt, wenn die Funktion nicht ungültig ist), den Stapelrahmen des aktuellen Aufrufs verwendet, anstatt einen neuen zu erstellen, und stattdessen einen "Sprung" ausführt eines "Aufrufs" (so bleibt der verwendete Stapelspeicherplatz konstant). Kurz gesagt geht es um 90% des Weges, um die Schwanzrekursion in Iteration umzuwandeln. Es könnte mit diesen Milliarden-Ints umgehen, ohne den Stapel zu überschwemmen. (Es würde schließlich immer noch nicht genügend Arbeitsspeicher zur Verfügung stehen, aber das Zusammenstellen einer Liste mit einer Milliarde Ints wird Sie auf einem 32-Bit-System sowieso speicherintensiv machen.)
Java macht das in den meisten Fällen nicht. (Es hängt vom Compiler und der Laufzeit ab, aber die Oracle-Implementierung tut dies nicht.) Jeder Aufruf einer rekursiven Funktion beansprucht den Speicher eines Stack-Frames. Wenn Sie zu viel verbrauchen, kommt es zu einem Stapelüberlauf. Ein Überlaufen des Stapels garantiert jedoch den Tod des Programms. Also müssen wir sicherstellen, dass wir das nicht tun.
Eine Problemumgehung ... faule Auswertung. Wir haben immer noch die Stack-Beschränkungen, aber sie können mit Faktoren zusammenhängen, über die wir mehr Kontrolle haben. Wir müssen nicht eine Million Ints kalkulieren, um 25 zurückzugeben. :)
Bauen wir also eine Lazy-Evaluation-Infrastruktur auf. (Dieser Code wurde vor einiger Zeit getestet, aber ich habe ihn seitdem ziemlich verändert. Lies die Idee, nicht die Syntaxfehler. :))
// 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()); }
};
}
(Denken Sie daran, dass, wenn dies in Java tatsächlich möglich wäre, Code, der zumindest in etwa der oben genannten Form entspricht, bereits Teil der API ist.)
Wenn eine Infrastruktur vorhanden ist, ist das Schreiben von Code, der keine veränderlichen Variablen benötigt und zumindest für kleinere Eingabemengen stabil ist, eher trivial.
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);
}
Dies funktioniert meistens, aber es ist immer noch etwas anfällig für Stapelüberläufe. Versuchen take
Sie es mit 2 Milliarden Ints und machen Sie etwas dagegen. : P Irgendwann wird eine Ausnahme ausgelöst, mindestens bis 64 GB RAM zum Standard werden. Das Problem ist, dass der für den Stack reservierte Speicher eines Programms nicht so groß ist. Es liegt normalerweise zwischen 1 und 8 MiB. (Sie können für größere fragen, aber es spielt keine Rolle , dass alle viel , wie viel Sie verlangen - Sie rufen take(1000000000, someInfiniteSequence)
Sie wird . Eine Ausnahme erhalten) Zum Glück, mit lazy evaluation, die Schwachstelle liegt in einem Gebiet können wir besser kontrollieren . Wir müssen nur vorsichtig sein, wie viel wir take()
.
Es wird immer noch viele Probleme beim Skalieren geben, da unsere Stapelverwendung linear zunimmt. Jeder Aufruf behandelt ein Element und leitet den Rest an einen anderen Aufruf weiter. Nun, da ich darüber nachdenke, gibt es einen Trick, den wir anwenden können, der uns möglicherweise mehr Spielraum verschafft: Die Anrufkette in einen Baum von Anrufen verwandeln . Betrachten Sie etwas Ähnliches:
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
teilt die Arbeit im Grunde genommen in zwei Hälften auf und ordnet jede Hälfte einem anderen Aufruf zu. Da jeder Aufruf die Größe der Arbeitsliste eher um die Hälfte als um eins verringert, sollte dies logarithmisch und nicht linear skaliert werden.
Das Problem ist, dass diese Funktion eine Eingabe benötigt - und bei einer verknüpften Liste muss die gesamte Liste durchlaufen werden, um die Länge zu ermitteln. Das ist jedoch leicht zu lösen. Es ist einfach egal, wie viele Einträge es gibt. :) Der obige Code würde mit so etwas wie Integer.MAX_VALUE
der Zählung funktionieren , da eine Null die Verarbeitung sowieso anhält. Die Zählung ist größtenteils vorhanden, sodass wir einen soliden Basisfall haben. Wenn Sie damit rechnen, mehr als Integer.MAX_VALUE
Einträge in einer Liste zu haben, können Sie workWith
den Rückgabewert überprüfen - er sollte am Ende null sein. Ansonsten rekursiv.
Denken Sie daran, dies berührt so viele Elemente, wie Sie es wünschen. Es ist nicht faul; es macht seine Sache sofort. Sie möchten dies nur für Aktionen ausführen, d. H. Für Objekte, deren einziger Zweck darin besteht, sich auf jedes Element in einer Liste anzuwenden. Wenn ich es mir gerade überlege, scheint es mir, dass Sequenzen viel weniger kompliziert wären, wenn sie linear gehalten würden. sollte kein Problem sein, da Sequenzen sich sowieso nicht selbst aufrufen - sie erstellen einfach Objekte, die sie erneut aufrufen.