简洁版本:
为了使单分配样式在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
。递归深度达2000万个级别会在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
输入20亿个整数并对其进行一些操作。:P最终将引发异常,至少直到64 GB以上的RAM成为标准。问题是,为程序堆栈保留的程序内存量不是很大。通常在1到8 MiB之间。(您可以要求更大,但是无论您要求多少,都没关系-您打电话给您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
count之类的东西一起工作,因为无论如何null都会停止处理。计数大部分在这里,所以我们有一个坚实的基础案例。如果您预计Integer.MAX_VALUE
列表中会有更多条目,则可以检查workWith
的返回值-结尾应为null。否则,请递归。
请记住,这涉及到您告诉它的尽可能多的元素。这不是偷懒;它立即执行其操作。您只想执行操作 -也就是说,仅用于将自身应用于列表中的每个元素的事。正如我现在正在考虑的那样,在我看来,如果保持线性,序列将不那么复杂;应该不会有什么问题,因为序列无论如何都不会调用它们自己-它们只是创建再次调用它们的对象。