Khi không có TCO, khi nào lo lắng về việc thổi chồng?


14

Mỗi lần có một cuộc thảo luận về một ngôn ngữ lập trình mới nhắm mục tiêu vào JVM, chắc chắn sẽ có những người nói những điều như:

"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ổ"

Có hàng ngàn biến thể về chủ đề đó.

Bây giờ tôi biết rằng một số ngôn ngữ, như Clojure chẳng hạn, có cấu trúc tái phát đặc biệt mà bạn có thể sử dụng.

Điều tôi không hiểu là: việc thiếu tối ưu hóa cuộc gọi đuôi nghiêm trọng đến mức nào? Khi nào tôi nên lo lắng về nó?

Nguồn gây nhầm lẫn chính của tôi có lẽ đến từ thực tế rằng Java là một trong những ngôn ngữ thành công nhất từ ​​trước đến nay và khá nhiều ngôn ngữ JVM dường như đang hoạt động khá tốt. Làm thế nào là tốt nếu thiếu TCO thực sự là của bất kỳ mối quan tâm?


4
nếu bạn có đệ quy đủ sâu để thổi stack mà không có TCO thì bạn sẽ gặp vấn đề ngay cả với TCO
ratchet freak

18
@ratchet_freak Điều đó thật vô nghĩa. Lược đồ thậm chí không có vòng lặp, nhưng vì thông số kỹ thuật bắt buộc hỗ trợ TCO, nên phép lặp đệ quy trên một tập hợp lớn các nút không đắt hơn một vòng lặp bắt buộc (với phần thưởng mà cấu trúc Đề án trả về giá trị).
itbruce

6
@ratchetfreak TCO là một cơ chế để tạo các hàm đệ quy được viết theo một cách nhất định (nghĩa là đệ quy đuôi) hoàn toàn không thể thổi bay ngăn xếp ngay cả khi chúng muốn. Tuyên bố của bạn chỉ có ý nghĩa đối với đệ quy không được viết theo cách đệ quy, trong trường hợp đó bạn đúng và TCO sẽ không giúp bạn.
Evicatos

2
Lần cuối cùng tôi nhìn, 80x86 cũng không thực hiện tối ưu hóa cuộc gọi đuôi (bản địa). Nhưng điều đó đã không ngăn các nhà phát triển ngôn ngữ chuyển các ngôn ngữ sử dụng nó. Trình biên dịch xác định khi nào nó có thể sử dụng bước nhảy so với jsr và mọi người đều vui vẻ. Bạn có thể làm điều tương tự trên JVM.
kdgregory

3
@kdgregory: Nhưng x86 có GOTO, JVM thì không. Và x86 không được sử dụng làm nền tảng interop. JVM không có GOTOvà một trong những lý do chính để chọn Nền tảng Java là sự can thiệp. Nếu bạn muốn triển khai TCO trên JVM, bạn phải làm gì đó với ngăn xếp. Tự mình quản lý nó (tức là không sử dụng ngăn xếp cuộc gọi JVM), sử dụng trampolines, sử dụng các ngoại lệ như GOTO, một cái gì đó tương tự. Trong tất cả các trường hợp đó, bạn trở nên không tương thích với ngăn xếp cuộc gọi JVM. Không thể tương thích với ngăn xếp với Java, có TCO và hiệu năng cao. Bạn phải hy sinh một trong ba.
Jörg W Mittag

Câu trả lời:


16

Hãy xem xét điều này, giả sử chúng ta đã loại bỏ tất cả các vòng lặp trong Java (các trình soạn thảo trình biên dịch đang đình công hoặc một cái gì đó). Bây giờ chúng tôi muốn viết giai thừa, vì vậy chúng tôi có thể đúng một cái gì đó như thế này

int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
  if(i == 0) return accum;
  return factorial(i-1, accum * i);
}

Bây giờ chúng tôi cảm thấy khá thông minh, chúng tôi đã quản lý để viết giai thừa của chúng tôi ngay cả khi không có vòng lặp! Nhưng khi chúng tôi kiểm tra, chúng tôi nhận thấy rằng với bất kỳ số nào có kích thước hợp lý, chúng tôi sẽ gặp lỗi stackoverflow vì không có TCO.

Trong Java thực, đây không phải là một vấn đề. Nếu chúng ta có một thuật toán đệ quy đuôi, chúng ta có thể chuyển đổi nó thành một vòng lặp và sẽ ổn. Tuy nhiên, những gì về ngôn ngữ không có vòng lặp? Sau đó, bạn chỉ hos. Đó là lý do tại sao clojure có recurdạng này , nếu không có nó, nó thậm chí còn không hoàn thành (Không có cách nào để thực hiện các vòng lặp vô hạn).

Lớp các ngôn ngữ chức năng nhắm vào JVM, Frege, Kawa (Scheme), Clojure luôn cố gắng xử lý việc thiếu các cuộc gọi đuôi, bởi vì trong các ngôn ngữ này, TC là cách thực hiện các vòng lặp! Nếu được dịch sang Đề án, giai thừa đó ở trên sẽ là một giai thừa tốt. Sẽ rất bất tiện nếu việc lặp 5000 lần khiến chương trình của bạn bị sập. Điều này có thể được giải quyết xung quanh, với recurcác hình thức đặc biệt, chú thích gợi ý tối ưu hóa các cuộc gọi tự, trampolining, bất cứ điều gì. Nhưng tất cả đều buộc các lượt truy cập hiệu năng hoặc công việc không cần thiết lên lập trình viên.

Bây giờ Java cũng không được miễn phí, vì có nhiều hơn cho TCO sau đó chỉ là đệ quy, còn các hàm đệ quy lẫn nhau thì sao? Chúng không thể được dịch trực tiếp thành các vòng lặp, nhưng vẫn không được JVM tối ưu hóa. Điều này gây khó chịu một cách ngoạn mục khi cố gắng viết các thuật toán bằng cách sử dụng đệ quy lẫn nhau bằng Java vì nếu bạn muốn hiệu suất / phạm vi tốt, bạn phải thực hiện phép thuật đen tối để làm cho nó phù hợp với các vòng lặp.

Vì vậy, tóm lại, đây không phải là một vấn đề lớn đối với nhiều trường hợp. Hầu hết các cuộc gọi đuôi chỉ tiến hành một stackframe sâu, với những thứ như

return foo(bar, baz); // foo is just a simple method

hoặc là đệ quy. Tuy nhiên, đối với lớp TC không phù hợp với điều này, mọi ngôn ngữ JVM đều cảm thấy đau.

Tuy nhiên, có một lý do chính đáng tại sao chúng ta chưa có TCO. JVM cung cấp cho chúng ta dấu vết ngăn xếp. Với TCO, chúng tôi loại bỏ một cách có hệ thống các stackframes mà chúng tôi biết là "cam chịu", nhưng JVM thực sự có thể muốn những thứ này sau này cho một stacktrace! Giả sử chúng tôi triển khai một FSM như thế này, trong đó mỗi tiểu bang gọi đuôi tiếp theo. Chúng tôi sẽ xóa tất cả hồ sơ về các trạng thái trước đó để một lần truy tìm sẽ cho chúng tôi biết trạng thái nào, nhưng không phải bất cứ điều gì về cách chúng tôi đến đó.

Ngoài ra, và cấp bách hơn, phần lớn xác minh mã byte dựa trên ngăn xếp, loại bỏ điều cho phép chúng tôi xác minh mã byte không phải là triển vọng dễ chịu. Giữa điều này và thực tế là Java có các vòng lặp, TCO có vẻ rắc rối hơn một chút so với giá trị của các kỹ sư JVM.


2
Vấn đề lớn nhất là trình xác minh mã byte, hoàn toàn dựa trên kiểm tra ngăn xếp. Đó là một lỗi lớn trong đặc tả JVM. 25 năm trước, khi JVM được thiết kế, mọi người đã nói rằng sẽ tốt hơn nếu ngôn ngữ mã byte JVM được an toàn ngay từ đầu thay vì ngôn ngữ đó không an toàn và sau đó dựa vào xác minh mã byte sau khi thực tế. Tuy nhiên, Matthias Felleisen (một trong những nhân vật chính trong cộng đồng Scheme) đã viết một bài báo chứng minh cách các cuộc gọi đuôi có thể được thêm vào JVM trong khi bảo tồn trình xác minh mã byte.
Jörg W Mittag

2
Điều thú vị là J9 JVM bởi IBM không thực hiện TCO.
Jörg W Mittag

1
@jozefg Thật thú vị, không ai quan tâm đến các mục stacktrace cho các vòng lặp, do đó, đối số stacktrace không giữ nước, ít nhất là cho các hàm đệ quy đuôi.
Ingo

2
@MasonWheeler Đó chính xác là quan điểm của tôi: stacktrace không cho bạn biết điều đó đã xảy ra. Bạn chỉ có thể thấy điều này một cách gián tiếp, bằng cách kiểm tra các biến vòng lặp, v.v ... Vậy tại sao bạn lại muốn một vài mục theo dõi ngăn xếp hundert của hàm đệ quy đuôi? Chỉ có điều cuối cùng là thú vị! Và, giống như với các vòng lặp, bạn có thể xác định đệ quy nào bằng cách kiểm tra các biến số cục bộ, giá trị đối số, v.v.
Ingo

3
@Ingo: Nếu một hàm chỉ đệ quy với chính nó, dấu vết ngăn xếp có thể không hiển thị nhiều. Tuy nhiên, nếu một nhóm các hàm được đệ quy lẫn nhau, thì dấu vết ngăn xếp đôi khi có thể hiển thị rất nhiều.
supercat

7

Tối ưu hóa cuộc gọi đuôi là chủ yếu quan trọng vì đệ quy đuôi. Tuy nhiên, có một lập luận tại sao thực sự tốt khi JVM không tối ưu hóa các cuộc gọi đuôi: Vì TCO sử dụng lại một phần của ngăn xếp, nên một dấu vết ngăn xếp từ một ngoại lệ sẽ không đầy đủ, do đó làm cho việc gỡ lỗi khó hơn một chút.

Có nhiều cách để khắc phục các hạn chế của JVM:

  1. Trình đệ quy đuôi đơn giản có thể được tối ưu hóa thành một vòng lặp bởi trình biên dịch.
  2. Nếu chương trình theo kiểu truyền tiếp, thì việc sử dụng trampolining trực tiếp là không quan trọng. Ở đây, một hàm không trả về kết quả cuối cùng, nhưng tiếp tục được thực hiện ở bên ngoài. Kỹ thuật này cho phép người viết trình biên dịch mô hình hóa luồng điều khiển phức tạp tùy ý.

Điều này có thể cần một ví dụ lớn hơn. Xem xét một ngôn ngữ có bao đóng (ví dụ JavaScript hoặc tương tự). Chúng ta có thể viết giai thừa như

def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)

print fac(x)

Bây giờ chúng ta có thể có nó trả lại một cuộc gọi lại thay thế:

def fac(n, acc = 1) =
  if (n <= 1) acc
  else        (() => fac(n-1, acc*n))  // this isn't full CPS, but you get the idea…

var continuation = (() => fac(x))
while (continuation instanceof function) {
  continuation = continuation()
}
var result = continuation
print result

Điều này bây giờ hoạt động trong không gian ngăn xếp liên tục, đó là loại ngớ ngẩn vì dù sao nó cũng là đệ quy đuôi. Tuy nhiên, kỹ thuật này có thể làm phẳng tất cả các cuộc gọi đuôi vào không gian ngăn xếp không đổi. Và nếu chương trình nằm trong CPS, thì điều này có nghĩa là tổng số cuộc gọi là tổng thể không đổi (trong CPS, mỗi cuộc gọi là một cuộc gọi đuôi).

Một nhược điểm lớn của kỹ thuật này là khó gỡ lỗi hơn, khó thực hiện hơn một chút và ít hiệu suất hơn - xem tất cả các cách đóng và gián tiếp mà tôi đang sử dụng.

Vì những lý do này, VM sẽ thực hiện một lệnh gọi op - các ngôn ngữ như Java có lý do chính đáng để không hỗ trợ các cuộc gọi đuôi sẽ không phải sử dụng nó.


1
"Khi TCO sử dụng lại một phần của ngăn xếp, dấu vết ngăn xếp từ một ngoại lệ sẽ không đầy đủ" - vâng, nhưng sau đó, một ngăn xếp từ bên trong một vòng lặp cũng không đầy đủ - nó không ghi lại tần suất thực hiện của vòng lặp. - Than ôi, ngay cả khi JVM sẽ hỗ trợ các cuộc gọi đuôi thích hợp, người ta vẫn có thể từ chối, trong khi gỡ lỗi, nói. Và sau đó, để sản xuất, cho phép TCO đảm bảo rằng mã chạy với các cuộc gọi đuôi 100.000 hoặc 100.000.000.
Ingo

1
@Ingo số (1) Khi các vòng lặp không được triển khai dưới dạng đệ quy, không có lý do nào để chúng hiển thị trên ngăn xếp (gọi đuôi nhảy ≠ gọi). (2) TCO tổng quát hơn tối ưu hóa đệ quy đuôi. Câu trả lời của tôi sử dụng đệ quy làm ví dụ . (3) Nếu bạn đang lập trình theo kiểu dựa trên TCO, tắt tối ưu hóa này không phải là một tùy chọn - TCO đầy đủ hoặc dấu vết ngăn xếp đầy đủ là một tính năng ngôn ngữ, hoặc chúng không phải là. Ví dụ: Scheme quản lý để cân bằng các nhược điểm của TCO với một hệ thống ngoại lệ tiên tiến hơn.
amon

1
(1) hoàn toàn đồng ý. Nhưng theo cùng một lý do, dĩ nhiên , không có lý do nào để giữ hàng trăm và hàng ngàn mục theo dõi ngăn xếp mà tất cả chỉ ra return foo(....);trong phương thức foo(2) hoàn toàn đồng ý, tất nhiên. Tuy nhiên, chúng tôi chấp nhận theo dõi không đầy đủ từ các vòng lặp, bài tập (!), Trình tự câu lệnh. Ví dụ: nếu bạn tìm thấy một giá trị bất ngờ trong một biến, bạn chắc chắn muốn biết làm thế nào nó đạt được điều đó. Nhưng bạn không phàn nàn về dấu vết bị thiếu trong trường hợp đó. Bởi vì bằng cách nào đó, nó được khắc sâu trong bộ não của chúng ta rằng a) nó chỉ xảy ra trên các cuộc gọi b) nó xảy ra trên tất cả các cuộc gọi. Cả hai đều vô nghĩa, IMHO.
Ingo

(3) Không đồng ý. Tôi không thể thấy lý do tại sao không thể gỡ lỗi mã của mình với một vấn đề về kích thước N, đối với một số N đủ nhỏ để thoát khỏi ngăn xếp thông thường. Và sau đó, để bật công tắc và bật TCO - giảm hiệu quả ràng buộc về kích thước của con mồi.
Ingo

@Ingo không đồng ý. Tôi không thể thấy lý do tại sao không thể gỡ lỗi mã của mình với một vấn đề về kích thước N, đối với một số N đủ nhỏ để thoát khỏi ngăn xếp thông thường. Nếu một TCO / TCE chuyển đổi CPS, thì hãy chuyển nó tắt sẽ tràn ngăn xếp và làm hỏng chương trình, do đó không thể gỡ lỗi. Google đã từ chối triển khai TCO trong V8 JS, vì sự cố này xảy ra tình cờ . Họ sẽ muốn một số cú pháp đặc biệt để lập trình viên có thể tuyên bố anh ta thực sự muốn TCO và mất dấu vết ngăn xếp. Có ai biết nếu ngoại lệ cũng bị TCO làm hỏng?
Shelby Moore III

6

Một phần đáng kể của các cuộc gọi trong một chương trình là các cuộc gọi đuôi. Mỗi chương trình con có một cuộc gọi cuối cùng, vì vậy mỗi chương trình con có ít nhất một cuộc gọi đuôi. Các cuộc gọi đuôi có các đặc tính hiệu suất GOTOnhưng sự an toàn của một cuộc gọi chương trình con.

Có các cuộc gọi Đuôi phù hợp cho phép bạn viết các chương trình mà bạn không thể viết. Lấy ví dụ, một máy trạng thái. Một máy trạng thái có thể được thực hiện rất trực tiếp bằng cách mỗi trạng thái là một chương trình con và mỗi chuyển đổi trạng thái là một lệnh gọi chương trình con. Trong trường hợp đó, bạn chuyển từ trạng thái này sang trạng thái khác, bằng cách thực hiện cuộc gọi sau cuộc gọi sau cuộc gọi và bạn thực sự không bao giờ quay lại! Nếu không có các cuộc gọi đuôi thích hợp, bạn sẽ ngay lập tức thổi bay ngăn xếp.

Không có PTC, bạn phải sử dụng GOTOhoặc Trampolines hoặc ngoại lệ làm luồng điều khiển hoặc đại loại như thế. Nó xấu hơn nhiều, và không phải là đại diện trực tiếp 1: 1 của máy trạng thái.

(Lưu ý cách tôi khéo léo tránh sử dụng ví dụ "vòng lặp" nhàm chán. Đây là một ví dụ trong đó PTC hữu ích ngay cả trong một ngôn ngữ các vòng lặp.)

Tôi đã cố tình sử dụng thuật ngữ "Cuộc gọi Đuôi phù hợp" ở đây thay vì TCO. TCO là một tối ưu hóa trình biên dịch. PTC là một tính năng ngôn ngữ yêu cầu mọi trình biên dịch thực hiện TCO.


The vast majority of calls in a program are tail calls. Không phải nếu "đại đa số" các phương thức được gọi thực hiện nhiều hơn một cuộc gọi của riêng họ. Every subroutine has a last call, so every subroutine has at least one tail call. Đây là sai lầm tầm thường là sai : return a + b. (Tất nhiên trừ khi bạn sử dụng một số ngôn ngữ điên rồ, nơi các hoạt động số học cơ bản được xác định là các lệnh gọi hàm, tất nhiên.)
Mason Wheeler

1
"Thêm hai số là thêm hai số." Ngoại trừ những ngôn ngữ không có. Điều gì về phép toán + trong Lisp / Scheme trong đó một toán tử số học duy nhất có thể nhận một số lượng đối số tùy ý? (+ 1 2 3) Cách duy nhất lành mạnh để thực hiện đó là dưới dạng hàm.
Evicatos

1
@Mason Wheeler: Ý nghĩa của việc đảo ngược trừu tượng là gì?
Giorgio

1
@MasonWheeler Đó là, không nghi ngờ gì, mục Wikipedia gợn sóng nhất về một chủ đề kỹ thuật mà tôi từng thấy. Tôi đã thấy một số mục đáng ngờ nhưng đó chỉ là ... wow.
Evicatos

1
@MasonWheeler: Bạn đang nói về các chức năng độ dài danh sách trên trang 22 và 23 của On Lisp? Phiên bản gọi đuôi là khoảng 1,2 lần phức tạp, không nơi nào gần 3x. Tôi cũng không rõ ý của bạn là gì khi đảo ngược trừu tượng.
Michael Shaw

4

"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ế JSRbằng JUMP.


Nếu tồn tại một opcode cuộc gọi đuôi, tại sao tối ưu hóa cuộc gọi đuôi lại yêu cầu bất cứ điều gì ngoài việc quan sát tại mỗi trang web cuộc gọi cho dù phương thức thực hiện cuộc gọi có cần thực hiện bất kỳ mã nào sau đó không? Có thể trong một số trường hợp, một câu lệnh như return foo(123);có thể được thực thi tốt hơn bằng cách xếp hàng foohơn là tạo mã để thao tác ngăn xếp và thực hiện bước nhảy, nhưng tôi không hiểu tại sao cuộc gọi đuôi lại khác với cuộc gọi thông thường trong liên quan
supercat

@supercat - Tôi không chắc câu hỏi của bạn là gì. Điểm đầu tiên của bài đăng này là trình biên dịch không thể biết khung stack của tất cả các calle tiềm năng trông như thế nào (hãy nhớ rằng khung stack không chỉ giữ các đối số hàm mà còn cả các biến cục bộ của nó). Tôi cho rằng bạn có thể thêm một opcode kiểm tra thời gian chạy cho các khung tương thích, nhưng điều đó đưa tôi đến phần thứ hai của bài đăng: giá trị thực là gì?
kdgregory
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.