Version corta:
Para que el estilo de asignación única funcione de manera confiable en Java, necesitaría (1) algún tipo de infraestructura amigable inmutable, y (2) soporte a nivel de tiempo de ejecución o compilador para la eliminación de llamadas de cola.
Podemos escribir gran parte de la infraestructura y podemos organizar cosas para tratar de evitar llenar la pila. Pero siempre que cada llamada tome un marco de pila, habrá un límite en la cantidad de recursión que puede hacer. Mantenga sus iterables pequeños y / o flojos, y no debería tener problemas importantes. Al menos la mayoría de los problemas con los que se encontrará no requieren devolver un millón de resultados a la vez. :)
También tenga en cuenta que, dado que el programa debe efectuar cambios visibles para que valga la pena ejecutarlo, no puede hacer que todo sea inmutable. Sin embargo, puede mantener la gran mayoría de sus propias cosas inmutables, utilizando un pequeño subconjunto de mutables esenciales (secuencias, por ejemplo) solo en ciertos puntos clave donde las alternativas serían demasiado onerosas.
Versión larga:
En pocas palabras, un programa Java no puede evitar totalmente las variables si quiere hacer algo que valga la pena. Puede contenerlos y, por lo tanto, restringir la mutabilidad en gran medida, pero el diseño mismo del lenguaje y la API, junto con la necesidad de cambiar eventualmente el sistema subyacente, hace que la inmutabilidad total sea inviable.
Java fue diseñado desde el principio como un imperativo , orientado a objetos lenguaje.
- Los lenguajes imperativos casi siempre dependen de variables mutables de algún tipo. Tienden a favorecer la iteración sobre la recursividad, por ejemplo, y casi todas las construcciones iterativas, incluso
while (true)y for (;;)! - dependen por completo de una variable en algún lugar que cambia de iteración a iteración.
- Los lenguajes orientados a objetos visualizan prácticamente cada programa como un gráfico de objetos que se envían mensajes entre sí y, en casi todos los casos, responden a esos mensajes mutando algo.
El resultado final de esas decisiones de diseño es que sin variables mutables, Java no tiene forma de cambiar el estado de nada, incluso algo tan simple como imprimir "¡Hola, mundo!" a la pantalla implica una secuencia de salida, que implica pegar bytes en un búfer mutable .
Entonces, a todos los efectos prácticos, estamos limitados a desterrar las variables de nuestro propio código. OK, podemos hacer eso. Casi. Básicamente, lo que necesitaríamos es reemplazar casi todas las iteraciones por recursividad, y todas las mutaciones con llamadas recursivas que devuelven el valor cambiado. al igual que...
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()));
}
Básicamente, construimos una lista vinculada, donde cada nodo es una lista en sí misma. Cada lista tiene una "cabeza" (el valor actual) y una "cola" (la sublista restante). La mayoría de los lenguajes funcionales hacen algo similar a esto, porque es muy susceptible a la inmutabilidad eficiente. Una operación "siguiente" simplemente devuelve la cola, que generalmente se pasa al siguiente nivel en una pila de llamadas recursivas.
Ahora, esta es una versión extremadamente simplificada de estas cosas. Pero es lo suficientemente bueno como para demostrar un problema grave con este enfoque en Java. Considera este código:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Aunque solo necesitamos 25 pulgadas para el resultado, squares_ofno lo sabemos. Va a devolver el cuadrado de cada número integers. La recursión de 20 millones de niveles de profundidad causa problemas bastante grandes en Java.
Mire, los lenguajes funcionales en los que normalmente se comportaría de esta manera tienen una función llamada "eliminación de llamadas de cola". Lo que eso significa es que, cuando el compilador ve que el último acto del código es llamarse a sí mismo (y devolver el resultado si la función no es nula), usa el marco de la pila de la llamada actual en lugar de configurar uno nuevo y en su lugar hace un "salto". de una "llamada" (por lo que el espacio de pila utilizado permanece constante). En resumen, representa aproximadamente el 90% del camino para convertir la recursión de cola en iteración. Podría lidiar con esos mil millones de pulgadas sin desbordar la pila. (Con el tiempo, se quedaría sin memoria, pero reunir una lista de mil millones de ints te va a estropear la memoria de todos modos en un sistema de 32 bits).
Java no hace eso, en la mayoría de los casos. (Depende del compilador y del tiempo de ejecución, pero la implementación de Oracle no lo hace). Cada llamada a una función recursiva consume la memoria de un marco de pila. Usa demasiado y obtienes un desbordamiento de pila. Desborda la pila, pero garantiza la muerte del programa. Así que tenemos que asegurarnos de no hacer eso.
Una semi-solución ... evaluación perezosa. Todavía tenemos las limitaciones de la pila, pero pueden estar vinculadas a factores sobre los que tenemos más control. No tenemos que calcular un millón de pulgadas solo para devolver 25. :)
Así que construyamos una infraestructura de evaluación perezosa. (Este código se probó hace un tiempo, pero lo he modificado bastante desde entonces; lea la idea, no los errores de sintaxis. :))
// 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()); }
};
}
(Tenga en cuenta que si esto fuera realmente viable en Java, un código al menos similar al anterior ya sería parte de la API).
Ahora, con una infraestructura en su lugar, es bastante trivial escribir código que no necesita variables mutables y que al menos sea estable para cantidades de entrada más pequeñas.
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);
}
Esto funciona principalmente, pero todavía es algo propenso a desbordamientos de pila. Trate takeing 2 mil millones de enteros y haciendo algunas medidas al respecto. : P Eventualmente arrojará una excepción, al menos hasta que más de 64 GB de RAM se conviertan en estándar. El problema es que la cantidad de memoria de un programa que está reservada para su pila no es tan grande. Es típicamente entre 1 y 8 MiB. (Puede solicitar más grande, pero eso no importa tanto la cantidad que pide - se llama take(1000000000, someInfiniteSequence), que va . Obtener una excepción) Afortunadamente, la evaluación perezosa, el punto débil se encuentra en una zona podemos controlar mejor . Solo tenemos que tener cuidado con la cantidad que tenemos take().
Todavía tendrá muchos problemas para ampliar, porque nuestro uso de pila aumenta linealmente. Cada llamada maneja un elemento y pasa el resto a otra llamada. Ahora que lo pienso, sin embargo, hay un truco que podemos hacer que podría ganarnos un poco más de margen: convertir la cadena de llamadas en un árbol de llamadas. Considere algo más como esto:
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);
}
workWithbásicamente divide el trabajo en dos mitades y asigna cada mitad a otra llamada a sí mismo. Como cada llamada reduce el tamaño de la lista de trabajo a la mitad en lugar de uno, esto debería escalar logarítmicamente en lugar de linealmente.
El problema es que esta función quiere una entrada, y con una lista vinculada, obtener la longitud requiere recorrer toda la lista. Sin embargo, eso se resuelve fácilmente; simplemente no me importa cuántas entradas hay. :) El código anterior funcionaría con algo Integer.MAX_VALUEcomo el recuento, ya que un valor nulo detiene el procesamiento de todos modos. El conteo está principalmente allí, así que tenemos un caso base sólido. Si prevé tener más de Integer.MAX_VALUEentradas en una lista, puede verificar workWithel valor de retorno, que debe ser nulo al final. De lo contrario, recurse.
Tenga en cuenta que esto toca tantos elementos como usted le indique. No es vago; hace lo suyo de inmediato. Solo desea hacerlo para acciones , es decir, cosas cuyo único propósito es aplicarse a cada elemento de una lista. Como lo estoy pensando ahora, me parece que las secuencias serían mucho menos complicadas si se mantuvieran lineales; no debería ser un problema, ya que las secuencias no se llaman a sí mismas de todos modos, solo crean objetos que las llaman de nuevo.