Version courte:
Pour que le style à affectation unique fonctionne de manière fiable en Java, vous aurez besoin (1) d'une sorte d'infrastructure conviviale et (2) d'une prise en charge au niveau du compilateur ou de l'exécution pour l'élimination des appels de queue.
Nous pouvons écrire une grande partie de l'infrastructure et nous pouvons arranger les choses pour éviter de remplir la pile. Mais tant que chaque appel prend une trame de pile, il y aura une limite sur la quantité de récursivité que vous pouvez faire. Gardez vos itérables petits et / ou paresseux, et vous ne devriez pas avoir de problèmes majeurs. Au moins la plupart des problèmes que vous rencontrerez ne nécessitent pas de renvoyer un million de résultats à la fois. :)
Notez également que, comme le programme doit réellement effectuer des modifications visibles pour être exécuté, vous ne pouvez pas tout rendre immuable. Vous pouvez, cependant, garder la grande majorité de vos propres trucs immuables, en utilisant un minuscule sous-ensemble de mutables essentiels (flux, par exemple) uniquement à certains points clés où les alternatives seraient trop onéreuses.
Version longue:
Autrement dit, un programme Java ne peut pas totalement éviter les variables s'il veut faire quelque chose qui en vaille la peine. Vous pouvez les contenir et limiter ainsi la mutabilité dans une large mesure, mais la conception même du langage et de l'API - ainsi que la nécessité de changer éventuellement le système sous-jacent - rendent l'immuabilité totale irréalisable.
Java a été conçu dès le départ comme un impératif , orienté objet langage.
- Les langages impératifs dépendent presque toujours de variables mutables d'une certaine sorte. Ils ont tendance à privilégier l'itération à la récursivité, par exemple, et presque toutes les constructions itératives - même
while (true)
et for (;;)
! - dépendent totalement d'une variable qui change quelque part d'itération en itération.
- Les langages orientés objet envisagent à peu près chaque programme comme un graphique d'objets qui s'envoient des messages et, dans presque tous les cas, répondent à ces messages en mutant quelque chose.
Le résultat final de ces décisions de conception est que sans variables mutables, Java n'a aucun moyen de changer l'état de quoi que ce soit - même quelque chose d'aussi simple que d'imprimer "Hello world!" à l'écran implique un flux de sortie, ce qui implique de coller des octets dans un tampon mutable .
Donc, à toutes fins pratiques, nous sommes limités à bannir les variables de notre propre code. OK, on peut faire ça. Presque. Fondamentalement, nous aurions besoin de remplacer presque toutes les itérations par la récursivité et toutes les mutations par des appels récursifs renvoyant la valeur modifiée. ainsi...
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()));
}
Fondamentalement, nous construisons une liste liée, où chaque nœud est une liste en soi. Chaque liste a une "tête" (la valeur actuelle) et une "queue" (la sous-liste restante). La plupart des langages fonctionnels font quelque chose de similaire, car ils se prêtent très bien à une immuabilité efficace. Une opération "suivante" renvoie simplement la queue, qui est généralement passée au niveau suivant dans une pile d'appels récursifs.
Maintenant, c'est une version extrêmement simpliste de ce genre de choses. Mais c'est assez bon pour démontrer un problème sérieux avec cette approche en Java. Considérez ce code:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Bien que nous n'ayons besoin que de 25 pouces pour le résultat, squares_of
je ne le sais pas. Il va retourner le carré de chaque nombre integers
. La récursivité de 20 millions de niveaux de profondeur cause de gros problèmes en Java.
Vous voyez, les langages fonctionnels dans lesquels vous feriez généralement de la folie comme ça, ont une fonctionnalité appelée "élimination des appels de queue". Cela signifie que lorsque le compilateur voit que le dernier acte du code consiste à s'appeler lui-même (et à renvoyer le résultat si la fonction n'est pas nulle), il utilise le cadre de pile de l'appel en cours au lieu d'en configurer un nouveau et effectue un "saut" à la place. d'un "appel" (donc l'espace de pile utilisé reste constant). En bref, cela fait environ 90% du chemin vers la transformation de la récursivité de queue en itération. Il pourrait gérer ces milliards d'ints sans déborder la pile. (Il finirait toujours par manquer de mémoire, mais assembler une liste d'un milliard d'ints va de toute façon vous gâcher en termes de mémoire sur un système 32 bits.)
Java ne fait pas cela, dans la plupart des cas. (Cela dépend du compilateur et de l'exécution, mais l'implémentation d'Oracle ne le fait pas.) Chaque appel à une fonction récursive consomme la mémoire d'une trame de pile. Utilisez trop et vous obtenez un débordement de pile. Débordement de la pile, mais garantit la mort du programme. Nous devons donc nous assurer de ne pas le faire.
Un semi-contournement ... évaluation paresseuse. Nous avons toujours les limites de la pile, mais elles peuvent être liées à des facteurs sur lesquels nous avons plus de contrôle. Nous n'avons pas à calculer un million d'ints juste pour en retourner 25. :)
Construisons-nous donc une infrastructure d'évaluation paresseuse. (Ce code a été testé il y a quelque temps, mais je l'ai beaucoup modifié depuis; lisez l'idée, pas les erreurs de syntaxe. :))
// 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()); }
};
}
(Gardez à l'esprit que si cela était réellement viable en Java, du code au moins un peu comme celui-ci ferait déjà partie de l'API.)
Maintenant, avec une infrastructure en place, il est plutôt trivial d'écrire du code qui n'a pas besoin de variables mutables et qui est au moins stable pour de plus petites quantités d'entrée.
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);
}
Cela fonctionne principalement, mais il est toujours quelque peu sujet à des débordements de pile. Essayer take
2 milliards d'ints et agir sur eux. : P Il lèvera éventuellement une exception, au moins jusqu'à ce que 64+ Go de RAM deviennent standard. Le problème est que la quantité de mémoire d'un programme réservée à sa pile n'est pas si grande. Il se situe généralement entre 1 et 8 Mio. (Vous pouvez demander plus, mais il n'a pas d' importance tant que ça combien vous demandez - vous appelez take(1000000000, someInfiniteSequence)
, vous allez . Obtenir une exception) Heureusement, avec évaluation paresseuse, le point faible est dans une zone que nous pouvons mieux contrôler . Nous devons juste faire attention à combien nous take()
.
Il y aura toujours beaucoup de problèmes à l'échelle, car notre utilisation de la pile augmente de manière linéaire. Chaque appel gère un élément et transmet le reste à un autre appel. Maintenant que j'y pense, cependant, il y a une astuce que nous pouvons tirer qui pourrait nous gagner beaucoup plus de marge: transformer la chaîne d'appels en un arbre d'appels. Considérez quelque chose de plus comme ceci:
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
divise essentiellement le travail en deux moitiés et attribue chaque moitié à un autre appel à lui-même. Étant donné que chaque appel réduit la taille de la liste de travail de moitié plutôt que d'un, cela doit être mis à l'échelle logarithmiquement plutôt que linéairement.
Le problème est que cette fonction veut une entrée - et avec une liste chaînée, obtenir la longueur nécessite de parcourir toute la liste. C'est facile à résoudre, cependant; ne vous souciez simplement pas du nombre d'entrées. :) Le code ci-dessus fonctionnerait avec quelque chose comme Integer.MAX_VALUE
le nombre, car une valeur nulle arrête le traitement de toute façon. Le décompte est principalement là, nous avons donc un cas de base solide. Si vous prévoyez d'avoir plus de Integer.MAX_VALUE
entrées dans une liste, vous pouvez vérifier workWith
la valeur de retour de - elle doit être nulle à la fin. Sinon, récusez.
Gardez à l'esprit que cela touche autant d'éléments que vous le dites. Ce n'est pas paresseux; il fait son truc immédiatement. Vous ne voulez le faire que pour des actions - c'est-à-dire des trucs dont le seul but est de s'appliquer à chaque élément d'une liste. Comme j'y réfléchis en ce moment, il me semble que les séquences seraient beaucoup moins compliquées si elles étaient linéaires; ne devrait pas être un problème, car les séquences ne s'appellent pas de toute façon - elles créent simplement des objets qui les appellent à nouveau.