Коротка версія:
Для того, щоб стиль Java-надії надійно працював на 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 ints для результату, squares_of
це не знає. Він поверне квадрат кожного числа в integers
. Рекурсія на глибину 20 мільйонів рівнів викликає досить великі проблеми на Яві.
Дивіться, функціональні мови, якими ви зазвичай займаєтеся неслухняністю, мають цю функцію, яка називається "усунення хвостових викликів". Що це означає, що коли компілятор бачить, що останнім актом коду є виклик себе (та повернення результату, якщо функція недійсна), він використовує поточний кадр стека поточного виклику замість того, щоб встановити новий, а замість цього зробить "стрибок" "виклику" (тому простір стека залишається незмінним). Коротше кажучи, це проходить приблизно 90% шляху до перетворення хвостової рекурсії в ітерацію. Він міг би мати справу з тими мільярдами точок, не переповнюючи стек. (Зрештою, пам’яті все-таки закінчиться, але складання списку в мільярд ints все одно зіпсує вам пам'ять у 32-бітній системі.)
Java в більшості випадків цього не робить. (Це залежить від компілятора і часу виконання, але реалізація Oracle цього не робить.) Кожен виклик рекурсивної функції з’їдає об'єм пам'яті, що стоїть на кадрі. Використовуйте занадто багато, і ви отримуєте переповнення стека. Переповнення стека все, але гарантує загибель програми. Тому ми повинні переконатися, що цього не робити.
Один напівпроблемний ... лінива оцінка. У нас все ще є обмеження на стек, але вони можуть бути прив'язані до факторів, над якими ми маємо більший контроль. Нам не доведеться обчислювати мільйон ints, щоб повернутись 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 мільярди int і зробіть деякі дії над ними. : 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
підрахунку, оскільки нуль зупиняє обробку в будь-якому випадку. Кількість в основному є, тому у нас є міцний базовий випадок. Якщо ви очікуєте, що Integer.MAX_VALUE
в списку буде більше записів, то ви можете перевірити workWith
повернене значення - воно повинно бути нульовим в кінці. В іншому випадку повторіть.
Майте на увазі, це стосується стільки елементів, скільки ви їм розповідаєте. Це не лінь; вона робить свою справу негайно. Ви хочете робити це лише для дій - тобто речей, єдиною метою яких є застосування себе до кожного елемента в списку. Оскільки я зараз це замислююся, мені здається, що послідовності були б набагато менш складними, якби вони зберігалися лінійними; це не повинно бути проблемою, оскільки послідовності ніяк не називають себе - вони просто створюють об'єкти, які викликають їх знову.