Укороченная версия:
Для того чтобы стиль единого назначения работал надежно в Java, вам понадобится (1) какая-то неизменяемая инфраструктура и (2) поддержка на уровне компилятора или среды выполнения для устранения хвостовых вызовов.
Мы можем написать большую часть инфраструктуры, и мы можем организовать вещи, чтобы избежать заполнения стека. Но до тех пор, пока каждый вызов занимает кадр стека, будет ограничено количество рекурсии, которое вы можете выполнить. Держите ваши итераторы маленькими и / или ленивыми, и у вас не должно быть серьезных проблем. По крайней мере, большинство проблем, с которыми вы столкнетесь, не требуют одновременного возврата миллиона результатов. :)
Также обратите внимание: поскольку для того, чтобы быть достойным запуска, программа должна фактически вносить видимые изменения, вы не можете сделать все неизменным. Однако вы можете оставить неизменным подавляющее большинство ваших собственных вещей, используя небольшое подмножество необходимых изменяемых файлов (например, потоков) только в определенных ключевых точках, где альтернативы будут слишком обременительными.
Длинная версия:
Проще говоря, Java-программа не может полностью избежать переменных, если она хочет сделать что-то стоящее. Вы можете содержать их и, таким образом, в значительной степени ограничивать изменчивость, но сам дизайн языка и API - наряду с необходимостью в конечном итоге изменять базовую систему - делают невозможным полную неизменность.
Java был разработан с самого начала как императивный , объектно-ориентированный язык.
- Императивные языки почти всегда зависят от изменяемых переменных. Например, они предпочитают итерацию, а не рекурсию, и почти всем итерационным конструкциям - даже
while (true)
и for (;;)
! - полностью зависят от переменной, где-то переходящей от итерации к итерации.
- Объектно-ориентированные языки в значительной степени представляют каждую программу в виде графа объектов, отправляющих сообщения друг другу, и почти во всех случаях, отвечающих на эти сообщения путем мутации чего-либо.
Конечный результат этих дизайнерских решений заключается в том, что без изменяемых переменных у Java нет возможности изменить состояние чего-либо - даже такого простого, как печать «Hello world!» к экрану относится выходной поток, который включает в себя вставку байтов в изменяемый буфер.
Таким образом, для практических целей мы ограничены исключением переменных из нашего собственного кода. ОК, мы можем сделать это. Почти. По сути, нам нужно заменить почти всю итерацию рекурсией, а все мутации - рекурсивными вызовами, возвращающими измененное значение. вот так...
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()));
}
По сути, мы строим связанный список, где каждый узел сам по себе является списком. Каждый список имеет «голову» (текущее значение) и «хвост» (оставшийся подсписок). Большинство функциональных языков делают что-то похожее на это, потому что это очень поддается эффективной неизменности. «Следующая» операция просто возвращает хвост, который обычно передается на следующий уровень в стеке рекурсивных вызовов.
Теперь это чрезвычайно упрощенная версия этого материала. Но этого достаточно, чтобы продемонстрировать серьезную проблему с этим подходом в Java. Рассмотрим этот код:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Хотя нам нужно только 25 дюймов для результата, squares_of
не знает этого. Это собирается вернуть квадрат каждого числа в integers
. Рекурсия глубиной в 20 миллионов вызывает большие проблемы в Java.
Видите ли, функциональные языки, в которых вы обычно совершаете дурацкие поступки, имеют функцию, называемую «устранение хвостовых вызовов». Это означает, что когда компилятор видит, что последним действием кода является сам вызов (и возвращает результат, если функция не является пустым), он использует кадр стека текущего вызова вместо установки нового и вместо этого выполняет «переход» "вызова" (поэтому используемое пространство стека остается постоянным). Короче говоря, до 90% пути превращения хвостовой рекурсии в итерацию. Он может справиться с этими миллиардами целых без переполнения стека. (В конце концов, все равно не хватило бы памяти, но при составлении списка из миллиарда целых в любом случае это может испортить вам память в 32-битной системе.)
В большинстве случаев Java этого не делает. (Это зависит от компилятора и времени выполнения, но реализация Oracle этого не делает.) Каждый вызов рекурсивной функции пожирает объем памяти стека. Используйте слишком много, и вы получите переполнение стека. Переполнение стека почти гарантирует смерть программы. Поэтому мы должны быть уверены, что не будем этого делать.
Один полуобход ... ленивая оценка. У нас все еще есть ограничения стека, но они могут быть связаны с факторами, которые мы можем контролировать больше. Нам не нужно рассчитывать миллион целых, чтобы вернуть 25. :)
Итак, давайте создадим нам инфраструктуру для ленивых вычислений. (Этот код был протестирован некоторое время назад, но с тех пор я немного его изменил; прочитайте идею, а не синтаксические ошибки. :))
// 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()); }
};
}
(Имейте в виду, что если бы это было действительно жизнеспособно в Java, код, по крайней мере, в некоторой степени похожий на приведенный выше, уже был бы частью API.)
Теперь, имея инфраструктуру, довольно просто писать код, который не требует изменяемых переменных и, по крайней мере, стабилен при меньших объемах ввода.
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);
}
Это в основном работает, но все еще несколько подвержено переполнению стека. Попробуйте take
2 миллиарда целых и сделайте с ними какие-то действия. : P Это в конечном итоге вызовет исключение, по крайней мере, до тех пор, пока 64+ ГБ ОЗУ не станут стандартными. Проблема в том, что объем памяти программы, который зарезервирован для ее стека, не так велик. Обычно это от 1 до 8 МиБ. (Вы можете попросить больше, но это не имеет значения , все , что много , сколько вы просите - вы звоните take(1000000000, someInfiniteSequence)
, вы будете получать исключение.) Контроля К счастью, с ленивыми вычислениями, слабое место в области , мы можем лучше , Мы просто должны быть осторожны с тем, сколько мы take()
.
У него все еще будет много проблем с масштабированием, потому что использование нашего стека увеличивается линейно. Каждый вызов обрабатывает один элемент, а остальные передает другому вызову. Теперь, когда я думаю об этом, есть один трюк, который мы можем применить, который может получить нам немного больше запаса: превратить цепочку вызовов в дерево вызовов. Рассмотрим что-то более похожее на это:
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
в основном разбивает работу на две половины и назначает каждую половину другому вызову для себя. Поскольку каждый вызов уменьшает размер рабочего списка вдвое, а не на единицу, он должен масштабироваться логарифмически, а не линейно.
Проблема в том, что эта функция хочет ввода - и со связанным списком, получение длины требует обхода всего списка. Это легко решается, хотя; просто не важно сколько там записей. :) Приведенный выше код будет работать с чем-то вроде Integer.MAX_VALUE
счетчика, так как null в любом случае останавливает обработку. Количество в основном там, поэтому у нас есть солидный базовый случай. Если вы ожидаете, что Integer.MAX_VALUE
в списке будет больше записей, вы можете проверить workWith
возвращаемое значение - в конце оно должно быть нулевым. В противном случае, рекурсировать.
Имейте в виду, это затрагивает столько элементов, сколько вы говорите. Это не лень; это делает свое дело немедленно. Вы хотите делать это только для действий - то есть для вещей, единственная цель которых - применить себя к каждому элементу в списке. Поскольку я обдумываю это прямо сейчас, мне кажется, что последовательности были бы намного менее сложными, если бы они были линейными; не должно быть проблемой, так как последовательности в любом случае не вызывают себя - они просто создают объекты, которые вызывают их снова.