Versão curta:
Para fazer com que o estilo de atribuição única funcione de maneira confiável em Java, você precisa (1) de algum tipo de infraestrutura amigável para imutáveis e (2) suporte no nível do compilador ou do tempo de execução para eliminação da chamada de cauda.
Podemos escrever grande parte da infraestrutura e organizar coisas para evitar o preenchimento da pilha. Mas, desde que cada chamada ocupe um quadro de pilha, haverá um limite para quanta recursão você pode fazer. Mantenha seus iterables pequenos e / ou preguiçosos, e você não deve ter grandes problemas. Pelo menos a maioria dos problemas com que você se depara não exige o retorno de um milhão de resultados de uma só vez. :)
Observe também que, como o programa precisa efetivamente fazer alterações visíveis para valer a pena ser executado, você não pode tornar tudo imutável. Você pode, no entanto, manter imutável a grande maioria de suas próprias coisas, usando um pequeno subconjunto de mutáveis essenciais (fluxos, por exemplo) apenas em determinados pontos-chave em que as alternativas seriam muito onerosas.
Versão longa:
Simplificando, um programa Java não pode evitar totalmente variáveis se quiser fazer algo que valha a pena fazer. Você pode contê- los e, assim, restringir a mutabilidade em um grande grau, mas o próprio design da linguagem e da API - junto com a necessidade de eventualmente alterar o sistema subjacente - inviabiliza a total imutabilidade.
Java foi projetado desde o início como um imperativo , orientado a objeto linguagem.
- Linguagens imperativas quase sempre dependem de variáveis mutáveis de algum tipo. Eles tendem a favorecer a iteração sobre a recursão, por exemplo, e quase todas as construções iterativas - even
while (true)
e for (;;)
! - são totalmente dependentes de uma variável em algum lugar que muda de iteração para iteração.
- As linguagens orientadas a objetos encaram praticamente todos os programas como um gráfico de objetos enviando mensagens uns aos outros e, em quase todos os casos, respondendo a essas mensagens mudando alguma coisa.
O resultado final dessas decisões de design é que, sem variáveis mutáveis, o Java não tem como alterar o estado de qualquer coisa - mesmo algo tão simples quanto imprimir "Olá, mundo!" para a tela envolve um fluxo de saída, que envolve a aderência de bytes em um buffer mutável .
Portanto, para todos os fins práticos, estamos limitados a banir as variáveis do nosso próprio código. OK, podemos meio que fazer isso. Quase. Basicamente, o que precisamos é substituir quase toda iteração por recursão, e todas as mutações por chamadas recursivas retornando o valor alterado. igual a...
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()));
}
Basicamente, criamos uma lista vinculada, onde cada nó é uma lista em si. Cada lista possui uma "cabeça" (o valor atual) e uma "cauda" (a sub-lista restante). A maioria das linguagens funcionais faz algo semelhante a isso, porque é muito passível de imutabilidade eficiente. Uma operação "next" apenas retorna a cauda, que normalmente é passada para o próximo nível em uma pilha de chamadas recursivas.
Agora, esta é uma versão extremamente simplificada desse material. Mas é bom o suficiente para demonstrar um problema sério com essa abordagem em Java. Considere este código:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Embora precisemos apenas de 25 polegadas para o resultado, squares_of
não sabemos disso. Ele retornará o quadrado de cada número em integers
. A recursão de 20 milhões de níveis de profundidade causa grandes problemas em Java.
Veja, os idiomas funcionais em que você costuma fazer maluquices como essa têm um recurso chamado "eliminação de chamada de cauda". O que isso significa é que, quando o compilador vê o último ato do código sendo chamado (e retorna o resultado se a função não for nula), ele usa o quadro de pilha da chamada atual em vez de configurar um novo e faz um "salto" de uma "chamada" (para que o espaço de pilha usado permaneça constante). Em resumo, ele percorre cerca de 90% do caminho para transformar a recursão da cauda em iteração. Ele poderia lidar com esses bilhões de ints sem sobrecarregar a pilha. (Ele ainda acabou ficando sem memória, mas montar uma lista de bilhões de ints vai atrapalhar sua memória de qualquer maneira em um sistema de 32 bits.)
Java não faz isso, na maioria dos casos. (Depende do compilador e do tempo de execução, mas a implementação da Oracle não faz isso.) Cada chamada para uma função recursiva consome a memória de um quadro de pilha. Use muito e você terá um estouro de pilha. Transbordar a pilha quase garante a morte do programa. Portanto, temos que ter certeza de não fazer isso.
Uma semi-solução alternativa ... avaliação preguiçosa. Ainda temos as limitações da pilha, mas elas podem estar vinculadas a fatores sobre os quais temos mais controle. Não precisamos calcular um milhão de ints apenas para retornar 25. :)
Então, vamos construir uma infraestrutura de avaliação lenta. (Este código foi testado há algum tempo, mas eu o modifiquei bastante desde então; leia a ideia, não os erros de sintaxe. :))
// 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()); }
};
}
(Lembre-se de que, se isso fosse realmente viável em Java, o código pelo menos um pouco como o acima já faria parte da API.)
Agora, com uma infraestrutura instalada, é bastante trivial escrever código que não precise de variáveis mutáveis e seja pelo menos estável para quantidades menores de entrada.
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);
}
Isso funciona principalmente, mas ainda é um pouco propenso a estouros de pilha. Tentando take
2 bilhões de polegadas e realizando alguma ação neles. : P Eventualmente, lançará uma exceção, pelo menos até 64 GB de RAM se tornar padrão. O problema é que a quantidade de memória de um programa reservada para sua pilha não é tão grande. É tipicamente entre 1 e 8 MiB. (Você pode pedir por mais, mas não importa o quanto pedir - você telefona take(1000000000, someInfiniteSequence)
, você terá uma exceção.) Felizmente, com uma avaliação preguiçosa, o ponto fraco está em uma área que podemos controlar melhor . Nós apenas temos que ter cuidado com o quanto nós take()
.
Ainda haverá muitos problemas de expansão, porque o uso de nossa pilha aumenta linearmente. Cada chamada lida com um elemento e passa o restante para outra chamada. Agora que penso nisso, porém, há um truque que podemos adotar que pode ganhar um pouco mais de espaço: transformar a cadeia de chamadas em uma árvore de chamadas. Considere algo mais parecido com isto:
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
basicamente divide o trabalho em duas partes e atribui cada metade a outra chamada para si mesma. Como cada chamada reduz o tamanho da lista de trabalho pela metade e não por uma, isso deve ser escalado logaritmicamente e não linearmente.
O problema é que essa função deseja uma entrada - e com uma lista vinculada, obter o comprimento requer percorrer a lista inteira. Isso é facilmente resolvido; simplesmente não se importa com quantas entradas existem. :) O código acima funcionaria com algo como Integer.MAX_VALUE
a contagem, pois um nulo interrompe o processamento de qualquer maneira. Como a contagem é maior, temos um caso básico sólido. Se você antecipar ter mais do que Integer.MAX_VALUE
entradas em uma lista, poderá verificar workWith
o valor de retorno - ele deve ser nulo no final. Caso contrário, recorra.
Lembre-se de que isso toca tantos elementos quanto você deseja. Não é preguiçoso; faz sua coisa imediatamente. Você deseja fazer isso apenas para ações - isto é, coisas cujo único objetivo é aplicar-se a todos os elementos de uma lista. Como estou pensando agora, parece-me que as seqüências seriam muito menos complicadas se mantidas lineares; não deve ser um problema, pois as seqüências não se chamam de qualquer maneira - elas apenas criam objetos que as chamam novamente.