Versione breve:
Per far sì che lo stile a singola assegnazione funzioni in modo affidabile in Java, occorrerebbe (1) una sorta di infrastruttura intuitiva e (2) supporto a livello di compilatore o runtime per l'eliminazione delle chiamate in coda.
Possiamo scrivere gran parte dell'infrastruttura e possiamo organizzare le cose per cercare di evitare di riempire lo stack. Ma finché ogni chiamata prende uno stack frame, ci sarà un limite su quanta ricorsione si può fare. Mantieni i tuoi iterabili piccoli e / o pigri e non dovresti avere grossi problemi. Almeno la maggior parte dei problemi che incontrerai non richiede la restituzione di un milione di risultati contemporaneamente. :)
Inoltre, poiché il programma deve effettivamente apportare modifiche visibili per poter essere eseguito, non è possibile rendere tutto immutabile. Tuttavia, puoi mantenere immutabile la stragrande maggioranza delle tue cose, usando un piccolo sottoinsieme di mutabili essenziali (flussi, ad esempio) solo in determinati punti chiave in cui le alternative sarebbero troppo onerose.
Versione lunga:
In poche parole, un programma Java non può evitare totalmente le variabili se vuole fare qualcosa che valga la pena fare. Puoi contenerli , e quindi limitare enormemente la mutabilità, ma il design stesso della lingua e dell'API - insieme alla necessità di cambiare il sistema sottostante - rendono l'immutabilità totale impossibile.
Java è stato progettato fin dall'inizio come un imperativo , orientato agli oggetti linguaggio.
- Le lingue imperative dipendono quasi sempre da variabili mutabili di qualche tipo. Tendono a favorire l'iterazione rispetto alla ricorsione, per esempio, e quasi tutti i costrutti iterativi - anche
while (true)
e for (;;)
! - dipendono totalmente da una variabile che cambia da iterazione a iterazione.
- I linguaggi orientati agli oggetti praticamente immaginano ogni programma come un grafico di oggetti che inviano messaggi l'un l'altro e, in quasi tutti i casi, rispondono a quei messaggi mutando qualcosa.
Il risultato finale di queste decisioni di progettazione è che senza variabili mutabili, Java non ha modo di cambiare lo stato di nulla, nemmeno qualcosa di semplice come stampare "Hello world!" allo schermo comporta un flusso di output, che comporta il blocco dei byte in un buffer mutabile .
Quindi, per tutti gli scopi pratici, siamo limitati a bandire le variabili dal nostro codice. OK, possiamo farlo. Quasi. Fondamentalmente ciò di cui avremmo bisogno è sostituire quasi tutte le iterazioni con la ricorsione e tutte le mutazioni con chiamate ricorsive che restituiscono il valore modificato. così...
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()));
}
Fondamentalmente, costruiamo un elenco collegato, in cui ogni nodo è un elenco in sé. Ogni lista ha una "testa" (il valore corrente) e una "coda" (la restante lista secondaria). La maggior parte dei linguaggi funzionali fa qualcosa di simile a questo, perché è molto suscettibile di efficienza immutabilità. Un'operazione "successiva" restituisce semplicemente la coda, che in genere viene passata al livello successivo in una pila di chiamate ricorsive.
Ora, questa è una versione estremamente semplificata di questa roba. Ma è abbastanza buono per dimostrare un grave problema con questo approccio in Java. Considera questo codice:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Sebbene per il risultato siano necessari solo 25 pollici, squares_of
non lo sappiamo. Restituirà il quadrato di ogni numero in integers
. La ricorsione a 20 milioni di livelli di profondità causa grossi problemi a Java.
Vedi, i linguaggi funzionali in cui in genere faresti la stranezza in questo modo, hanno una funzione chiamata "eliminazione della coda". Ciò significa che, quando il compilatore vede l'ultimo atto del codice da chiamare se stesso (e restituisce il risultato se la funzione non è nulla), utilizza il frame di stack della chiamata corrente invece di impostarne uno nuovo e fa invece un "salto" di una "chiamata" (quindi lo spazio di stack utilizzato rimane costante). In breve, si tratta di circa il 90% della strada per trasformare la ricorsione della coda in iterazione. Potrebbe gestire quei miliardi di ints senza traboccare lo stack. (Alla fine sarebbe ancora a corto di memoria, ma assemblare un elenco di un miliardo di ints ti rovinerà comunque in termini di memoria su un sistema a 32 bit.)
Java non lo fa, nella maggior parte dei casi. (Dipende dal compilatore e dal runtime, ma l'implementazione di Oracle non lo fa.) Ogni chiamata a una funzione ricorsiva consuma la memoria di un frame dello stack. Usalo troppo e otterrai uno stack overflow. Traboccando lo stack tutto ma garantisce la morte del programma. Quindi dobbiamo assicurarci di non farlo.
Una soluzione semi-alternativa ... valutazione pigra. Abbiamo ancora le limitazioni dello stack, ma possono essere legate a fattori su cui abbiamo più controllo. Non dobbiamo calcolare un milione di ints solo per restituire 25. :)
Quindi costruiamoci un'infrastruttura di valutazione pigra. (Questo codice è stato testato qualche tempo fa, ma da allora l'ho modificato un po '; leggi l'idea, non gli errori di sintassi. :))
// 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()); }
};
}
(Tieni presente che se questo fosse effettivamente praticabile in Java, il codice almeno in qualche modo come sopra sarebbe già parte dell'API.)
Ora, con un'infrastruttura in atto, è piuttosto banale scrivere codice che non ha bisogno di variabili mutabili ed è almeno stabile per piccole quantità di input.
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);
}
Funziona principalmente, ma è ancora in qualche modo incline agli overflow dello stack. Prova a take
ingaggiare 2 miliardi di ints e ad agire di conseguenza. : P Alla fine genererà un'eccezione, almeno fino a quando 64+ GB di RAM diventeranno standard. Il problema è che la quantità di memoria di un programma riservata per il suo stack non è così grande. In genere è compreso tra 1 e 8 MiB. (Si può chiedere per i più grandi, ma non importa più di tanto di quanto si chiede - si chiama take(1000000000, someInfiniteSequence)
, si avrà . Ottenere un'eccezione) Fortunatamente, con valutazione pigra, il punto debole è in una zona che possiamo migliorare il controllo . Dobbiamo solo stare attenti a quanto take()
.
Avrà ancora molti problemi a scalare, perché il nostro utilizzo dello stack aumenta in modo lineare. Ogni chiamata gestisce un elemento e passa il resto a un'altra chiamata. Ora che ci penso, però, c'è un trucco che possiamo ottenere che potrebbe farci guadagnare un po 'più di margine: trasformare la catena di chiamate in un albero di chiamate. Considera qualcosa di più simile a questo:
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
fondamentalmente suddivide il lavoro in due metà e assegna ciascuna metà a un'altra chiamata a se stessa. Poiché ogni chiamata riduce la dimensione della lista di lavoro della metà anziché di una, ciò dovrebbe ridimensionare logaritmicamente anziché linearmente.
Il problema è che questa funzione richiede un input e, con un elenco collegato, per ottenere la lunghezza è necessario attraversare l'intero elenco. Questo è facilmente risolto, però; semplicemente non importa quante voci ci sono. :) Il codice sopra funzionerebbe con qualcosa come Integer.MAX_VALUE
il conteggio, poiché un null interrompe comunque l'elaborazione. Il conteggio è per lo più lì, quindi abbiamo un solido caso di base. Se prevedi di avere più di Integer.MAX_VALUE
voci in un elenco, puoi controllare workWith
il valore di ritorno - alla fine dovrebbe essere nullo. Altrimenti, recluta.
Ricorda, questo tocca tutti gli elementi che dici. Non è pigro; fa immediatamente la sua cosa. Vuoi farlo solo per azioni , cioè cose il cui unico scopo è applicarsi a ogni elemento in un elenco. Mentre ci sto pensando proprio ora, mi sembra che le sequenze sarebbero molto meno complicate se mantenute lineari; non dovrebbe essere un problema, dal momento che le sequenze non si chiamano comunque: creano semplicemente oggetti che le richiamano di nuovo.