短縮版:
Javaで単一割り当てスタイルを確実に機能させるには、(1)何らかの不変にフレンドリーなインフラストラクチャ、および(2)テールコールの除去に対するコンパイラレベルまたはランタイムレベルのサポートが必要です。
インフラストラクチャの多くを記述でき、スタックがいっぱいにならないように調整できます。ただし、各呼び出しがスタックフレームを使用する限り、実行できる再帰の量には制限があります。イテラブルを小さくしたり、怠laにしたりすると、大きな問題は発生しません。少なくとも、発生する問題のほとんどは、一度に100万件の結果を返す必要はありません。:)
また、プログラムは実行する価値があるために実際に目に見える変化をもたらさなければならないので、すべてを不変にすることはできません。ただし、必要な変更可能要素(ストリームなど)の小さなサブセットを使用して、代替物が面倒になる特定のキーポイントでのみ、自分のものの大部分を不変に保つことができます。
ロングバージョン:
簡単に言えば、Javaプログラムは、やりがいのあることをしたい場合、変数を完全に回避することはできません。それら を含めることができるため、可変性を大幅に制限できますが、言語とAPIの設計自体が、最終的には基礎となるシステムを変更する必要があるため、完全な不変性を実現不可能にします。
Javaのは、次のように最初から設計されましたが不可欠、オブジェクト指向言語。
- 命令型言語は、ほぼ常に何らかの可変変数に依存しています。たとえば、再帰よりも反復を優先する傾向があり、ほぼすべての反復構成要素が偶数
while (true)
とfor (;;)
!-反復ごとにどこかで変化する変数に完全に依存しています。
- オブジェクト指向言語は、すべてのプログラムを相互にメッセージを送信するオブジェクトのグラフとして、そしてほとんどすべての場合、何かを変化させることによってそれらのメッセージに応答することを想定しています。
これらの設計上の決定の最終結果は、可変変数なしでは、Javaが何かの状態を変更する方法を持たないということです。画面への出力には、可変バッファへのバイトの固定を伴う出力ストリームが含まれます。
したがって、すべての実用的な目的のために、独自のコードから変数を削除することに制限されています。OK、私たちはちょっとそれをすることができます。ほとんど。基本的に必要なのは、ほとんどすべての反復を再帰で置き換え、すべての突然変異を変更された値を返す再帰呼び出しで置き換えることです。そのようです...
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()));
}
基本的に、各ノードがそれ自体のリストであるリンクリストを作成します。各リストには、「head」(現在の値)と「tail」(残りのサブリスト)があります。ほとんどの関数型言語はこれに似た何かをします。なぜなら、それは効率的な不変性に非常に敏感だからです。「次の」操作はテールを返すだけで、通常は再帰呼び出しのスタックの次のレベルに渡されます。
さて、これはこのものの非常に単純化されたバージョンです。しかし、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で非常に大きな問題を引き起こします。
参照してください、あなたが通常このような奇抜を行う関数型言語には、「テールコールの除去」と呼ばれる機能があります。つまり、コンパイラは、コードの最後の動作がそれ自体を呼び出す(および関数の非voidの場合は結果を返す)ことを確認すると、新しいものを設定する代わりに現在の呼び出しのスタックフレームを使用し、代わりに「ジャンプ」を実行します「呼び出し」の(したがって、使用されるスタック領域は一定のままです)。つまり、末尾再帰を反復に変換する方法の約90%になります。スタックをオーバーフローさせることなく、これらの10億の整数を処理できます。(最終的にはメモリ不足になりますが、32ビットシステムでは10億のintのリストを作成すると、メモリがめちゃくちゃになります。)
ほとんどの場合、Javaはそれを行いません。(コンパイラとランタイムに依存しますが、Oracleの実装はそれを行いません。)再帰関数の各呼び出しは、スタックフレームのメモリを消費します。使いすぎると、スタックオーバーフローが発生します。スタックのオーバーフローは、プログラムの死を保証します。ですから、そうしないように気をつけなければなりません。
1つの半回避策...遅延評価。スタックの制限はまだありますが、それらは、より詳細に制御できる要因に結び付けることができます。25を返すためだけに100万intを計算する必要はありません。
それでは、遅延評価インフラストラクチャを構築しましょう。(このコードはしばらく前にテストされましたが、それからかなり変更しました;構文エラーではなくアイデアを読んでください:))
// 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億のintを試し、それらに対して何らかのアクションを実行します。:P少なくとも64 GB以上のRAMが標準になるまで、最終的に例外をスローします。問題は、スタック用に予約されているプログラムのメモリの量がそれほど大きくないことです。通常は1〜8 MiBです。(あなたは大きなを求めることができますが、それはすべての非常にどのくらいあなたが求めることは重要ではありません-あなたが呼び出すtake(1000000000, someInfiniteSequence)
、あなたがします。例外を取得)の領域に私たちはより良いことができます幸い、遅延評価で、弱いスポットがあるコントロールを。私たちはどれだけ気をつけなければなりませんtake()
。
スタックの使用量は直線的に増加するため、スケールアップにはまだ多くの問題があります。各呼び出しは1つの要素を処理し、残りを別の呼び出しに渡します。しかし、今考えてみると、プルすることができる1つのトリックがあります。これにより、ヘッドルームをかなり増やすことができます:呼び出しのチェーンを呼び出しのツリーに変える。次のようなものを検討してください。
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
基本的に作業を2つに分割し、それぞれの半分を別の呼び出しに割り当てます。呼び出しごとに作業リストのサイズが1つではなく半分に縮小されるため、これは線形ではなく対数的にスケーリングする必要があります。
問題は、この関数が入力を必要としていることです。リンクされたリストでは、長さを取得するにはリスト全体を走査する必要があります。ただし、これは簡単に解決できます。単に気にしないがいくつあるかのエントリ。:)上記のコードはInteger.MAX_VALUE
、nullがとにかく処理を停止するため、カウントのようなもので動作します。カウントはほとんどそこにあるので、堅実なベースケースがあります。Integer.MAX_VALUE
リストに複数のエントリがあると予想される場合は、workWith
の戻り値を確認できます。最後はnullである必要があります。それ以外の場合、再帰。
覚えておいて、これはあなたがそれを伝える限り多くの要素に触れます。怠け者ではありません。それはすぐにそのことを行います。それはアクション、つまり、リスト内のすべての要素に自分自身を適用することを唯一の目的とするものに対してのみ行います。私は今考え直しているように、シーケンスが線形に保たれている場合、シーケンスはそれほど複雑ではないように思えます。シーケンスはとにかく自分自身を呼び出さないので、問題になるべきではありません-シーケンスを再度呼び出すオブジェクトを作成するだけです。