"JVM không hỗ trợ tối ưu hóa cuộc gọi đuôi, vì vậy tôi dự đoán rất nhiều ngăn xếp phát nổ"
Bất cứ ai nói điều này (1) đều không hiểu tối ưu hóa cuộc gọi đuôi hoặc (2) không hiểu JVM hoặc (3) cả hai.
Tôi sẽ bắt đầu với định nghĩa các cuộc gọi đuôi từ Wikipedia (nếu bạn không thích Wikipedia, đây là một cách thay thế ):
Trong khoa học máy tính, một cuộc gọi đuôi là một cuộc gọi chương trình con xảy ra bên trong một thủ tục khác như là hành động cuối cùng của nó; nó có thể tạo ra một giá trị trả về và sau đó được trả về ngay lập tức bởi thủ tục gọi
Trong mã dưới đây, cuộc gọi đến bar()
là cuộc gọi đuôi của foo()
:
private void foo() {
// do something
bar()
}
Tối ưu hóa cuộc gọi đuôi xảy ra khi thực hiện ngôn ngữ, nhìn thấy một cuộc gọi đuôi, không sử dụng lời gọi phương thức thông thường (tạo khung ngăn xếp), mà thay vào đó tạo ra một nhánh. Đây là một tối ưu hóa vì khung ngăn xếp yêu cầu bộ nhớ và nó yêu cầu các chu kỳ CPU đẩy thông tin (như địa chỉ trả về) lên khung và vì cặp gọi / trả lại được cho là yêu cầu nhiều chu kỳ CPU hơn là một bước nhảy vô điều kiện.
TCO thường được áp dụng cho đệ quy, nhưng đó không phải là cách sử dụng duy nhất. Nó cũng không áp dụng cho tất cả các cuộc thu hồi. Ví dụ, mã đệ quy đơn giản để tính toán một giai thừa, không thể được tối ưu hóa cuộc gọi đuôi, bởi vì điều cuối cùng xảy ra trong hàm là một phép toán nhân.
public static int fact(int n) {
if (n <= 1) return 1;
else return n * fact(n - 1);
}
Để thực hiện tối ưu hóa cuộc gọi đuôi, bạn cần hai điều:
- Một nền tảng hỗ trợ phân nhánh ngoài các cuộc gọi subtroutine.
- Một bộ phân tích tĩnh có thể xác định xem có thể tối ưu hóa cuộc gọi đuôi hay không.
Đó là nó. Như tôi đã lưu ý ở nơi khác, JVM (giống như bất kỳ kiến trúc hoàn chỉnh Turing nào khác) có một goto. Nó xảy ra để có một goto vô điều kiện , nhưng chức năng có thể dễ dàng được thực hiện bằng cách sử dụng một nhánh có điều kiện.
Các phân tích tĩnh là những gì khó khăn. Trong một chức năng duy nhất, nó không có vấn đề gì. Ví dụ: đây là hàm Scala đệ quy đuôi để tính tổng các giá trị trong List
:
def sum(acc:Int, list:List[Int]) : Int = {
if (list.isEmpty) acc
else sum(acc + list.head, list.tail)
}
Hàm này biến thành mã byte sau:
public int sum(int, scala.collection.immutable.List);
Code:
0: aload_2
1: invokevirtual #63; //Method scala/collection/immutable/List.isEmpty:()Z
4: ifeq 9
7: iload_1
8: ireturn
9: iload_1
10: aload_2
11: invokevirtual #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
14: invokestatic #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
17: iadd
18: aload_2
19: invokevirtual #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
22: checkcast #59; //class scala/collection/immutable/List
25: astore_2
26: istore_1
27: goto 0
Lưu ý goto 0
ở cuối. Khi so sánh, một hàm Java tương đương (phải sử dụng một Iterator
để bắt chước hành vi phá vỡ danh sách Scala thành đầu và đuôi) biến thành mã byte sau. Lưu ý rằng hai thao tác cuối cùng bây giờ là một lệnh gọi , theo sau là trả về rõ ràng của giá trị được tạo bởi lệnh gọi đệ quy đó.
public static int sum(int, java.util.Iterator);
Code:
0: aload_1
1: invokeinterface #64, 1; //InterfaceMethod java/util/Iterator.hasNext:()Z
6: ifne 11
9: iload_0
10: ireturn
11: iload_0
12: aload_1
13: invokeinterface #70, 1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
18: checkcast #25; //class java/lang/Integer
21: invokevirtual #74; //Method java/lang/Integer.intValue:()I
24: iadd
25: aload_1
26: invokestatic #43; //Method sum:(ILjava/util/Iterator;)I
29: ireturn
Tối ưu hóa cuộc gọi đuôi của một hàm duy nhất là tầm thường: trình biên dịch có thể thấy rằng không có mã nào sử dụng kết quả của cuộc gọi, vì vậy nó có thể thay thế lệnh gọi bằng a goto
.
Cuộc sống trở nên khó khăn là gì nếu bạn có nhiều phương pháp. Các hướng dẫn phân nhánh của JVM, không giống như các hướng dẫn của bộ xử lý đa năng như 80x86, bị giới hạn trong một phương thức. Nó vẫn tương đối đơn giản nếu bạn có các phương thức riêng tư: trình biên dịch có thể tự do nội tuyến các phương thức đó cho phù hợp, do đó có thể tối ưu hóa các cuộc gọi đuôi (nếu bạn tự hỏi làm thế nào điều này có thể hoạt động, hãy xem xét một phương pháp phổ biến sử dụng switch
để kiểm soát hành vi). Bạn thậm chí có thể mở rộng kỹ thuật này cho nhiều phương thức công khai trong cùng một lớp: trình biên dịch nội tuyến các thân phương thức, cung cấp các phương thức cầu nối công khai và các cuộc gọi nội bộ biến thành các bước nhảy.
Nhưng, mô hình này bị hỏng khi bạn xem xét các phương thức công khai trong các lớp khác nhau, đặc biệt là về ánh sáng của giao diện và trình nạp lớp. Trình biên dịch cấp nguồn đơn giản là không có đủ kiến thức để thực hiện tối ưu hóa cuộc gọi đuôi. Tuy nhiên, không giống như các triển khai "kim loại trần", * JVM (có thông tin để thực hiện việc này, dưới dạng trình biên dịch Hotspot (ít nhất là trình biên dịch ex-Sun). Tôi không biết liệu nó có thực sự thực hiện không tối ưu hóa cuộc gọi đuôi, và nghi ngờ không, nhưng nó có thể .
Điều này đưa tôi đến phần thứ hai của câu hỏi của bạn, mà tôi sẽ viết lại là "chúng ta có nên quan tâm không?"
Rõ ràng, nếu ngôn ngữ của bạn sử dụng đệ quy như là nguyên thủy duy nhất của nó để lặp lại, bạn quan tâm. Nhưng, các ngôn ngữ cần tính năng này có thể thực hiện nó; vấn đề duy nhất là liệu trình biên dịch cho ngôn ngữ nói có thể tạo ra một lớp có thể gọi và được gọi bởi một lớp Java tùy ý hay không.
Ngoài trường hợp đó, tôi sẽ mời downvote bằng cách nói rằng nó không liên quan. Hầu hết các mã đệ quy mà tôi đã thấy (và tôi đã làm việc với rất nhiều dự án đồ thị) không thể tối ưu hóa cuộc gọi . Giống như giai thừa đơn giản, nó sử dụng đệ quy để xây dựng trạng thái và thao tác đuôi là kết hợp.
Đối với mã có thể tối ưu hóa cuộc gọi đuôi, việc dịch mã đó thành dạng lặp có thể đơn giản. Ví dụ, sum()
chức năng mà tôi đã trình bày trước đó có thể được khái quát như foldLeft()
. Nếu bạn nhìn vào nguồn , bạn sẽ thấy rằng nó thực sự được triển khai như một thao tác lặp. Jörg W Mittag đã có một ví dụ về một máy trạng thái được thực hiện thông qua các lệnh gọi hàm; có rất nhiều triển khai máy trạng thái hiệu quả (và có thể bảo trì) không dựa vào các lệnh gọi hàm được dịch thành các bước nhảy.
Tôi sẽ kết thúc với một cái gì đó hoàn toàn khác. Nếu bạn Google theo cách của bạn từ chú thích trong SICP, bạn có thể sẽ ở đây . Cá nhân tôi thấy rằng một nơi thú vị hơn nhiều vì phải biên dịch của tôi thay thế JSR
bằng JUMP
.