Phiên bản ngắn:
Để làm cho kiểu chuyển nhượng đơn hoạt động đáng tin cậy trong Java, bạn cần (1) một số loại cơ sở hạ tầng thân thiện không thay đổi và (2) trình biên dịch hoặc hỗ trợ mức thời gian chạy để loại bỏ cuộc gọi đuôi.
Chúng tôi có thể viết nhiều cơ sở hạ tầng, và chúng tôi có thể sắp xếp mọi thứ để cố gắng tránh lấp đầy ngăn xếp. Nhưng miễn là mỗi cuộc gọi mất một khung stack, sẽ có giới hạn về mức độ đệ quy bạn có thể làm. Giữ iterables của bạn nhỏ và / hoặc lười biếng, và bạn không nên có vấn đề lớn. Ít nhất là hầu hết các vấn đề bạn sẽ gặp phải không yêu cầu trả lại một triệu kết quả cùng một lúc. :)
Cũng lưu ý, vì chương trình phải thực sự ảnh hưởng đến những thay đổi có thể nhìn thấy để có giá trị chạy, bạn không thể biến mọi thứ thành bất biến. Tuy nhiên, bạn có thể giữ cho phần lớn các công cụ của riêng bạn không thay đổi, bằng cách sử dụng một tập hợp nhỏ các biến đổi thiết yếu (ví dụ như luồng) chỉ tại một số điểm chính mà các lựa chọn thay thế sẽ quá khó chịu.
Phiên bản dài:
Nói một cách đơn giản, một chương trình Java hoàn toàn không thể tránh các biến nếu nó muốn làm bất cứ điều gì đáng làm. Bạn có thể chứa chúng, và do đó hạn chế khả năng biến đổi ở một mức độ lớn, nhưng chính thiết kế ngôn ngữ và API - cùng với nhu cầu thay đổi hệ thống cơ bản - khiến cho tính bất biến hoàn toàn không thể thực hiện được.
Java được thiết kế từ đầu như một ngôn ngữ bắt buộc , hướng đối tượng .
- Các ngôn ngữ bắt buộc hầu như luôn phụ thuộc vào các biến có thể thay đổi của một số loại. Họ có xu hướng thích lặp đi lặp lại hơn đệ quy, ví dụ, và gần như tất cả các cấu trúc lặp - thậm chí
while (true)
và for (;;)
! - hoàn toàn phụ thuộc vào một biến ở đâu đó thay đổi từ lần lặp sang lần lặp.
- Các ngôn ngữ hướng đối tượng hình dung khá nhiều chương trình như một biểu đồ của các đối tượng gửi tin nhắn cho nhau và trong hầu hết các trường hợp, trả lời các tin nhắn đó bằng cách thay đổi một cái gì đó.
Kết quả cuối cùng của những quyết định thiết kế đó là không có các biến có thể thay đổi, Java không có cách nào để thay đổi trạng thái của bất cứ thứ gì - thậm chí là một thứ đơn giản như in "Hello world!" đến màn hình liên quan đến một luồng đầu ra, bao gồm việc gắn các byte vào một bộ đệm có thể thay đổi .
Vì vậy, đối với tất cả các mục đích thực tế, chúng tôi giới hạn trong việc trục xuất các biến khỏi mã của chính chúng tôi . OK, chúng ta có thể làm điều đó. Hầu hết. Về cơ bản những gì chúng ta cần là thay thế gần như tất cả các lần lặp bằng đệ quy và tất cả các đột biến bằng các cuộc gọi đệ quy trả về giá trị đã thay đổi. như vậy ...
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()));
}
Về cơ bản, chúng tôi xây dựng một danh sách được liên kết, trong đó mỗi nút là một danh sách. Mỗi danh sách có một "đầu" (giá trị hiện tại) và "đuôi" (danh sách con còn lại). Hầu hết các ngôn ngữ chức năng làm một cái gì đó giống như điều này, bởi vì nó rất phù hợp với tính bất biến hiệu quả. Một hoạt động "tiếp theo" chỉ trả về đuôi, thường được chuyển sang cấp độ tiếp theo trong một chồng các cuộc gọi đệ quy.
Bây giờ, đây là một phiên bản cực kỳ đơn giản của công cụ này. Nhưng nó đủ tốt để chứng minh một vấn đề nghiêm trọng với cách tiếp cận này trong Java. Xem xét mã này:
public function doStuff() {
final Ints integers = ...somehow assemble list of 20 million ints...;
final Ints result = take(25, squares_of(integers));
...
}
Mặc dù chúng tôi chỉ cần 25 ints cho kết quả, squares_of
nhưng không biết điều đó. Nó sẽ trả về bình phương của mỗi số trong integers
. Đệ quy sâu 20 triệu cấp gây ra các vấn đề khá lớn trong Java.
Hãy xem, các ngôn ngữ chức năng mà bạn thường làm như thế này, có một tính năng gọi là "loại bỏ cuộc gọi đuôi". Điều đó có nghĩa là, khi trình biên dịch thấy hành động cuối cùng của mã là tự gọi (và trả về kết quả nếu hàm không trống), nó sử dụng khung ngăn xếp của cuộc gọi hiện tại thay vì thiết lập một cái mới và thay vào đó là "nhảy" của một "cuộc gọi" (vì vậy không gian ngăn xếp được sử dụng không đổi). Nói tóm lại, nó đi khoảng 90% theo hướng chuyển đổi đệ quy đuôi thành phép lặp. Nó có thể đối phó với hàng tỷ int mà không tràn ra ngăn xếp. (Cuối cùng nó vẫn hết bộ nhớ, nhưng việc tập hợp danh sách một tỷ int sẽ khiến bạn rối tung lên theo trí nhớ trên hệ thống 32 bit.)
Java không làm điều đó, trong hầu hết các trường hợp. (Nó phụ thuộc vào trình biên dịch và thời gian chạy, nhưng việc triển khai của Oracle không thực hiện được.) Mỗi lệnh gọi đến một hàm đệ quy sẽ ngốn hết bộ nhớ của khung stack. Sử dụng quá nhiều, và bạn nhận được một ngăn xếp tràn. Tràn đầy tất cả nhưng đảm bảo cái chết của chương trình. Vì vậy, chúng tôi phải đảm bảo không làm điều đó.
Một cách giải quyết ... lười đánh giá. Chúng tôi vẫn có các giới hạn ngăn xếp, nhưng chúng có thể được gắn với các yếu tố chúng tôi có quyền kiểm soát nhiều hơn. Chúng ta không phải tính một triệu int chỉ để trả về 25. :)
Vì vậy, hãy xây dựng cho chúng tôi một số cơ sở hạ tầng đánh giá lười biếng. (Mã này đã được kiểm tra một thời gian trước, nhưng tôi đã sửa đổi nó khá nhiều kể từ đó; đọc ý tưởng, không phải lỗi cú pháp. :))
// 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()); }
};
}
(Hãy nhớ rằng nếu điều này thực sự khả thi trong Java, mã ít nhất giống như ở trên sẽ là một phần của API.)
Bây giờ, với một cơ sở hạ tầng sẵn có, việc viết mã không cần các biến có thể thay đổi và ít nhất là ổn định cho số lượng đầu vào nhỏ hơn.
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);
}
Điều này chủ yếu hoạt động, nhưng nó vẫn có phần dễ bị chồng chất. Hãy thử take
ing 2 tỷ ints và thực hiện một số hành động trên chúng. : P Cuối cùng sẽ đưa ra một ngoại lệ, ít nhất là cho đến khi hơn 64 GB RAM trở thành tiêu chuẩn. Vấn đề là, dung lượng bộ nhớ của chương trình dành riêng cho ngăn xếp của nó không lớn. Nó thường nằm trong khoảng từ 1 đến 8 MiB. (Bạn có thể yêu cầu lớn hơn, nhưng không quan trọng bạn yêu cầu bao nhiêu - bạn gọi take(1000000000, someInfiniteSequence)
, bạn sẽ có một ngoại lệ.) May mắn thay, với sự đánh giá lười biếng, điểm yếu nằm ở khu vực chúng ta có thể kiểm soát tốt hơn . Chúng ta chỉ cần cẩn thận về số lượng chúng ta take()
.
Nó vẫn sẽ có nhiều vấn đề mở rộng, bởi vì việc sử dụng ngăn xếp của chúng tôi tăng tuyến tính. Mỗi cuộc gọi xử lý một yếu tố và chuyển phần còn lại sang cuộc gọi khác. Tuy nhiên, bây giờ tôi nghĩ về nó, có một mẹo chúng ta có thể rút ra có thể giúp chúng ta có thêm một chút khoảng trống: biến chuỗi cuộc gọi thành một cây các cuộc gọi. Hãy xem xét một cái gì đó như thế này:
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
về cơ bản chia công việc thành hai nửa và gán mỗi nửa cho một cuộc gọi khác cho chính nó. Vì mỗi cuộc gọi làm giảm kích thước của danh sách làm việc xuống một nửa thay vì một, nên điều này sẽ mở rộng quy mô logarit thay vì tuyến tính.
Vấn đề là, chức năng này muốn có một đầu vào - và với một danh sách được liên kết, việc lấy chiều dài đòi hỏi phải đi qua toàn bộ danh sách. Điều đó dễ dàng giải quyết, mặc dù; chỉ đơn giản là không quan tâm có bao nhiêu mục. :) Đoạn mã trên sẽ hoạt động với thứ gì đó giống Integer.MAX_VALUE
như số đếm, vì null sẽ dừng quá trình xử lý. Số lượng chủ yếu là ở đó vì vậy chúng tôi có một trường hợp cơ sở vững chắc. Nếu bạn dự đoán có nhiều hơn Integer.MAX_VALUE
các mục trong danh sách, thì bạn có thể kiểm tra workWith
giá trị trả về - cuối cùng nó sẽ không có giá trị. Nếu không, tái diễn.
Hãy ghi nhớ, điều này chạm đến nhiều yếu tố như bạn nói với nó. Nó không lười biếng; nó làm việc của nó ngay lập tức Bạn chỉ muốn làm điều đó cho các hành động - nghĩa là, điều mà mục đích duy nhất của nó là áp dụng chính nó cho mọi yếu tố trong danh sách. Như tôi đang nghĩ về nó ngay bây giờ, đối với tôi, dường như các chuỗi sẽ ít phức tạp hơn nếu được giữ tuyến tính; không nên là một vấn đề, vì các chuỗi không tự gọi mình bằng mọi cách - họ chỉ tạo các đối tượng gọi lại chúng.