Tối ưu hóa cuộc gọi đuôi là gì?


818

Rất đơn giản, tối ưu hóa cuộc gọi đuôi là gì?

Cụ thể hơn, một số đoạn mã nhỏ có thể được áp dụng ở đâu và ở đâu không, với lời giải thích tại sao?


10
TCO biến một cuộc gọi chức năng ở vị trí đuôi thành một goto, một bước nhảy.
Will Ness

8
Câu hỏi này đã được hỏi đầy đủ 8 năm trước đó;)
majelbstoat

Câu trả lời:


755

Tối ưu hóa cuộc gọi đuôi là nơi bạn có thể tránh phân bổ khung ngăn xếp mới cho một hàm vì hàm gọi sẽ đơn giản trả về giá trị mà nó nhận được từ hàm được gọi. Việc sử dụng phổ biến nhất là đệ quy đuôi, trong đó một hàm đệ quy được viết để tận dụng tối ưu hóa cuộc gọi đuôi có thể sử dụng không gian ngăn xếp không đổi.

Lược đồ là một trong số ít các ngôn ngữ lập trình đảm bảo trong thông số kỹ thuật rằng bất kỳ triển khai nào cũng phải cung cấp tối ưu hóa này (JavaScript cũng vậy, bắt đầu với ES6) , vì vậy đây là hai ví dụ về chức năng giai thừa trong Lược đồ :

(define (fact x)
  (if (= x 0) 1
      (* x (fact (- x 1)))))

(define (fact x)
  (define (fact-tail x accum)
    (if (= x 0) accum
        (fact-tail (- x 1) (* x accum))))
  (fact-tail x 1))

Hàm đầu tiên không phải là đệ quy đuôi vì khi thực hiện cuộc gọi đệ quy, hàm cần theo dõi phép nhân mà nó cần thực hiện với kết quả sau khi cuộc gọi trở lại. Như vậy, ngăn xếp trông như sau:

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

Ngược lại, dấu vết ngăn xếp cho giai thừa đệ quy đuôi trông như sau:

(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6

Như bạn có thể thấy, chúng tôi chỉ cần theo dõi cùng một lượng dữ liệu cho mỗi cuộc gọi đến đuôi thực tế bởi vì chúng tôi chỉ đơn giản là trả lại giá trị mà chúng tôi nhận được ngay từ đầu. Điều này có nghĩa là ngay cả khi tôi gọi (thực tế 1000000), tôi chỉ cần cùng một dung lượng như (thực tế 3). Đây không phải là trường hợp với thực tế không đệ quy và vì các giá trị lớn như vậy có thể gây ra tràn ngăn xếp.


99
Nếu bạn muốn tìm hiểu thêm về điều này, tôi khuyên bạn nên đọc chương đầu tiên về Cấu trúc và Giải thích các Chương trình Máy tính.
Kyle Cronin

3
Câu trả lời tuyệt vời, giải thích hoàn hảo.
Giô-na

15
Nói một cách chính xác, tối ưu hóa cuộc gọi đuôi không nhất thiết thay thế khung ngăn xếp của người gọi bằng các calle, nhưng, đảm bảo rằng số lượng cuộc gọi không giới hạn ở vị trí đuôi chỉ cần một lượng không gian giới hạn. Xem bài viết của Will Clinger " Đệ
Jon Harrop

3
Đây có phải chỉ là một cách để viết các hàm đệ quy theo cách không gian không đổi? Bởi vì bạn không thể đạt được kết quả tương tự bằng cách sử dụng phương pháp lặp?
dclowd9901

5
@ dclowd9901, TCO cho phép bạn thích một kiểu chức năng hơn là một vòng lặp. Bạn có thể thích phong cách bắt buộc. Nhiều ngôn ngữ (Java, Python) không cung cấp TCO, sau đó bạn phải biết rằng một cuộc gọi chức năng sẽ tốn bộ nhớ ... và kiểu bắt buộc được ưa thích hơn.
mcoolive

552

Chúng ta hãy đi qua một ví dụ đơn giản: hàm giai thừa được triển khai trong C.

Chúng tôi bắt đầu với định nghĩa đệ quy rõ ràng

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    return n * fac(n - 1);
}

Một hàm kết thúc bằng một cuộc gọi đuôi nếu thao tác cuối cùng trước khi hàm trả về là một lệnh gọi hàm khác. Nếu cuộc gọi này gọi cùng chức năng, thì đó là đệ quy đuôi.

Mặc dù fac()thoạt nhìn có vẻ đệ quy, nó không giống như những gì thực sự xảy ra

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    unsigned acc = fac(n - 1);
    return n * acc;
}

tức là thao tác cuối cùng là phép nhân và không phải là hàm gọi.

Tuy nhiên, có thể viết lại fac()thành đệ quy đuôi bằng cách chuyển giá trị tích lũy xuống chuỗi cuộc gọi dưới dạng đối số bổ sung và chỉ chuyển kết quả cuối cùng lên một lần nữa dưới dạng giá trị trả về:

unsigned fac(unsigned n)
{
    return fac_tailrec(1, n);
}

unsigned fac_tailrec(unsigned acc, unsigned n)
{
    if (n < 2) return acc;
    return fac_tailrec(n * acc, n - 1);
}

Bây giờ, tại sao điều này hữu ích? Vì chúng tôi ngay lập tức quay lại sau lệnh gọi đuôi, chúng tôi có thể loại bỏ stackframe trước đó trước khi gọi hàm ở vị trí đuôi hoặc trong trường hợp các hàm đệ quy, sử dụng lại stackframe như hiện tại.

Tối ưu hóa cuộc gọi đuôi biến đổi mã đệ quy của chúng tôi thành

unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

Điều này có thể được đưa vào fac()và chúng tôi đến

unsigned fac(unsigned n)
{
    unsigned acc = 1;

TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

tương đương với

unsigned fac(unsigned n)
{
    unsigned acc = 1;

    for (; n > 1; --n)
        acc *= n;

    return acc;
}

Như chúng ta có thể thấy ở đây, một trình tối ưu hóa đủ tiên tiến có thể thay thế đệ quy đuôi bằng phép lặp, hiệu quả hơn nhiều khi bạn tránh được chi phí gọi hàm và chỉ sử dụng một lượng không gian ngăn xếp không đổi.


bạn có thể giải thích chính xác một stackframe có nghĩa là gì không? Có sự khác biệt giữa ngăn xếp cuộc gọi và stackframe không?
Shasak

10
@Kasahs: khung ngăn xếp là một phần của ngăn xếp cuộc gọi 'thuộc về' một chức năng (hoạt động) nhất định; cf en.wikipedia.org/wiki/Call_stack#Str struct
Christoph

1
Tôi vừa có một sự hiển hiện khá mãnh liệt sau khi đọc bài đăng này sau khi đọc 2ality.com/2015/06/tail-call-optimization.html
agm1984

198

TCO (Tối ưu hóa cuộc gọi đuôi) là quá trình mà trình biên dịch thông minh có thể thực hiện cuộc gọi đến một chức năng và không mất thêm không gian ngăn xếp. Các tình huống duy nhất mà điều này xảy ra là nếu các hướng dẫn mới nhất thực hiện trong một hàm f là một cuộc gọi đến một hàm g (Lưu ý: g có thể f ). Chìa khóa ở đây là f không còn cần không gian ngăn xếp - nó chỉ đơn giản gọi g và sau đó trả về bất cứ thứ gì g sẽ trả về. Trong trường hợp này, việc tối ưu hóa có thể được thực hiện khi g chỉ chạy và trả về bất kỳ giá trị nào nó sẽ có cho thứ gọi là f.

Tối ưu hóa này có thể làm cho các cuộc gọi đệ quy mất không gian ngăn xếp liên tục, thay vì phát nổ.

Ví dụ: hàm giai thừa này không phải là TCOptimizable:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

Hàm này thực hiện mọi việc ngoài việc gọi hàm khác trong câu lệnh return.

Chức năng dưới đây là TCOptimizable:

def fact_h(n, acc):
    if n == 0:
        return acc
    return fact_h(n-1, acc*n)

def fact(n):
    return fact_h(n, 1)

Điều này là do điều cuối cùng xảy ra trong bất kỳ chức năng nào trong số này là gọi một chức năng khác.


3
Toàn bộ 'hàm g có thể là điều f' hơi khó hiểu, nhưng tôi hiểu ý của bạn, và các ví dụ thực sự làm rõ mọi thứ. Cảm ơn rất nhiều!
majelbstoat

10
Ví dụ tuyệt vời minh họa khái niệm. Chỉ cần tính đến việc ngôn ngữ bạn chọn phải thực hiện loại bỏ cuộc gọi đuôi hoặc tối ưu hóa cuộc gọi đuôi. Trong ví dụ, được viết bằng Python, nếu bạn nhập giá trị 1000, bạn sẽ nhận được "RuntimeError: vượt quá độ sâu đệ quy tối đa" vì việc triển khai Python mặc định không hỗ trợ Loại bỏ đệ quy đuôi. Xem một bài đăng từ chính Guido giải thích lý do tại sao: neopythonic.blogspot.pt/2009/04/tail-recursion-006ination.html .
rmcc

" Tình huống duy nhất " là một chút quá tuyệt đối; cũng có TRMC , ít nhất là trên lý thuyết, sẽ tối ưu hóa (cons a (foo b))hoặc (+ c (bar d))ở vị trí đuôi theo cùng một cách.
Will Ness

Tôi thích cách tiếp cận f và g của bạn tốt hơn câu trả lời được chấp nhận, có thể vì tôi là người giỏi toán.
Nithin

Tôi nghĩ bạn có nghĩa là TCOptimized. Nói rằng nó không phải là TCOptimizable mà nó không bao giờ có thể được tối ưu hóa (khi thực tế nó có thể)
Jacques Mathieu

65

Có lẽ mô tả cấp cao tốt nhất mà tôi đã tìm thấy cho các cuộc gọi đuôi, cuộc gọi đuôi đệ quy và tối ưu hóa cuộc gọi đuôi là bài đăng trên blog

"Cái quái gì thế này: Một cuộc gọi đuôi"

bởi Dan Sugalski. Về tối ưu hóa cuộc gọi, ông viết:

Hãy xem xét, trong một khoảnh khắc, chức năng đơn giản này:

sub foo (int a) {
  a += 15;
  return bar(a);
}

Vì vậy, những gì bạn có thể, hoặc đúng hơn là trình biên dịch ngôn ngữ của bạn, làm gì? Chà, những gì nó có thể làm là biến mã của biểu mẫu return somefunc();thành chuỗi cấp thấp pop stack frame; goto somefunc();. Trong ví dụ của chúng tôi, điều đó có nghĩa là trước khi chúng tôi gọi bar, footự dọn dẹp và sau đó, thay vì gọi barnhư một chương trình con, chúng tôi thực hiện một gotothao tác cấp thấp để bắt đầu bar. FooNó đã tự dọn sạch khỏi ngăn xếp, vì vậy khi barbắt đầu, có vẻ như bất kỳ ai được gọi foođã thực sự được gọi barvà khi bartrả về giá trị của nó, nó sẽ trả lại trực tiếp cho bất kỳ ai được gọi foo, thay vì footrả lại cho người gọi.

Và trên đệ quy đuôi:

Đệ quy đuôi xảy ra nếu một chức năng, như hoạt động cuối cùng của nó, trả về kết quả của việc gọi chính nó . Đệ quy đuôi dễ đối phó hơn vì thay vì phải nhảy vào đầu một số chức năng ngẫu nhiên ở đâu đó, bạn chỉ cần thực hiện một goto trở lại từ đầu của chính mình, đó là một việc đơn giản dễ làm.

Vì vậy, điều này:

sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

lặng lẽ biến thành:

sub foo (int a, int b) {
  label:
    if (b == 1) {
      return a;
    } else {
      a = a*a + a;
      b = b - 1;
      goto label;
   }

Điều tôi thích về mô tả này là cách cô đọng và dễ nắm bắt đối với những người đến từ nền tảng ngôn ngữ bắt buộc (C, C ++, Java)


4
Lỗi 404. Tuy nhiên, nó vẫn có sẵn trên archive.org: web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/ Kẻ
Tommy

Tôi đã không nhận được nó, không phải là foocuộc gọi đuôi chức năng ban đầu được tối ưu hóa? Nó chỉ gọi một chức năng là bước cuối cùng của nó, và nó chỉ đơn giản là trả về giá trị đó, phải không?
SexyBeast

1
@TryinHard có thể không phải là những gì bạn đã nghĩ, nhưng tôi đã cập nhật nó để đưa ra ý chính về những gì nó nói về. Xin lỗi, sẽ không lặp lại toàn bộ bài viết!
btiernay

2
Cảm ơn bạn, điều này đơn giản và dễ hiểu hơn so với ví dụ về chương trình được bình chọn nhiều nhất (chưa kể, Scheme không phải là ngôn ngữ phổ biến mà hầu hết các nhà phát triển đều hiểu)
Sevin7

2
Là một người hiếm khi đi sâu vào các ngôn ngữ chức năng, thật hài lòng khi thấy một lời giải thích trong "phương ngữ của tôi". Có một xu hướng (có thể hiểu được) cho các lập trình viên chức năng truyền giáo bằng ngôn ngữ họ chọn, nhưng đến từ thế giới mệnh lệnh, tôi thấy việc quấn đầu mình xung quanh một câu trả lời như thế này dễ dàng hơn nhiều.
James Beninger

15

Lưu ý trước hết rằng không phải tất cả các ngôn ngữ đều hỗ trợ nó.

TCO áp dụng cho một trường hợp đệ quy đặc biệt. Ý chính của nó là, nếu điều cuối cùng bạn làm trong một hàm là tự gọi (ví dụ: nó tự gọi từ vị trí "đuôi"), thì trình biên dịch có thể được tối ưu hóa để hoạt động như phép lặp thay vì đệ quy chuẩn.

Bạn thấy, thông thường trong quá trình đệ quy, bộ thực thi cần theo dõi tất cả các cuộc gọi đệ quy, để khi một cuộc gọi trở lại, nó có thể tiếp tục ở cuộc gọi trước, v.v. (Thử viết thủ công kết quả của một cuộc gọi đệ quy để có ý tưởng trực quan về cách thức hoạt động của nó.) Theo dõi tất cả các cuộc gọi chiếm không gian, điều này trở nên quan trọng khi hàm tự gọi rất nhiều. Nhưng với TCO, nó chỉ có thể nói "quay lại từ đầu, chỉ lần này thay đổi các giá trị tham số thành những giá trị mới này". Nó có thể làm điều đó bởi vì không có gì sau cuộc gọi đệ quy đề cập đến các giá trị đó.


3
Các cuộc gọi đuôi có thể áp dụng cho các chức năng không đệ quy là tốt. Bất kỳ chức năng nào có tính toán cuối cùng trước khi trả về là một cuộc gọi đến một chức năng khác đều có thể sử dụng một cuộc gọi đuôi.
Brian

Không nhất thiết phải đúng trên ngôn ngữ theo cơ sở ngôn ngữ - trình biên dịch C # 64 bit có thể chèn mã op đuôi trong khi phiên bản 32 bit thì không; và bản phát hành F # sẽ, nhưng mặc định F # sẽ không được gỡ lỗi.
Steve Gilham

3
"TCO áp dụng cho trường hợp đệ quy đặc biệt". Tôi sợ điều đó là hoàn toàn sai. Cuộc gọi đuôi áp dụng cho bất kỳ cuộc gọi ở vị trí đuôi. Thường được thảo luận trong bối cảnh đệ quy nhưng thực tế không có gì đặc biệt để làm với đệ quy.
Jon Harrop

@Brian, hãy xem liên kết @btiernay được cung cấp ở trên. Không phải là foocuộc gọi đuôi phương thức ban đầu được tối ưu hóa?
SexyBeast

13

Ví dụ có thể chạy tối thiểu GCC với phân tích tháo gỡ x86

Chúng ta hãy xem GCC có thể tự động thực hiện tối ưu hóa cuộc gọi đuôi cho chúng ta bằng cách nhìn vào tổ hợp được tạo.

Điều này sẽ phục vụ như một ví dụ cực kỳ cụ thể về những gì đã được đề cập trong các câu trả lời khác, chẳng hạn như https://stackoverflow.com/a/9814654/895245 rằng việc tối ưu hóa có thể chuyển đổi các lệnh gọi hàm đệ quy thành một vòng lặp.

Điều này lần lượt tiết kiệm bộ nhớ và cải thiện hiệu suất, vì truy cập bộ nhớ thường là điều chính làm cho các chương trình chậm hiện nay .

Là một đầu vào, chúng tôi cung cấp cho GCC một giai thừa dựa trên ngăn xếp ngây thơ không được tối ưu hóa:

tail_call.c

#include <stdio.h>
#include <stdlib.h>

unsigned factorial(unsigned n) {
    if (n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(int argc, char **argv) {
    int input;
    if (argc > 1) {
        input = strtoul(argv[1], NULL, 0);
    } else {
        input = 5;
    }
    printf("%u\n", factorial(input));
    return EXIT_SUCCESS;
}

GitHub ngược dòng .

Biên dịch và tháo rời:

gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
  -o tail_call.out tail_call.c
objdump -d tail_call.out

nơi -foptimize-sibling-callslà tên của tổng quát của cuộc gọi đuôi theo man gcc:

   -foptimize-sibling-calls
       Optimize sibling and tail recursive calls.

       Enabled at levels -O2, -O3, -Os.

như đã đề cập tại: Làm cách nào để kiểm tra xem gcc có thực hiện tối ưu hóa đệ quy đuôi không?

Tôi chọn -O1vì:

  • việc tối ưu hóa không được thực hiện với -O0. Tôi nghi ngờ rằng điều này là do thiếu các biến đổi trung gian cần thiết.
  • -O3 tạo ra mã hiệu quả vô duyên sẽ không mang tính giáo dục cao, mặc dù nó cũng được gọi là đuôi được tối ưu hóa.

Tháo gỡ với -fno-optimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       89 f8                   mov    %edi,%eax
    1147:       83 ff 01                cmp    $0x1,%edi
    114a:       74 10                   je     115c <factorial+0x17>
    114c:       53                      push   %rbx
    114d:       89 fb                   mov    %edi,%ebx
    114f:       8d 7f ff                lea    -0x1(%rdi),%edi
    1152:       e8 ee ff ff ff          callq  1145 <factorial>
    1157:       0f af c3                imul   %ebx,%eax
    115a:       5b                      pop    %rbx
    115b:       c3                      retq
    115c:       c3                      retq

Với -foptimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       b8 01 00 00 00          mov    $0x1,%eax
    114a:       83 ff 01                cmp    $0x1,%edi
    114d:       74 0e                   je     115d <factorial+0x18>
    114f:       8d 57 ff                lea    -0x1(%rdi),%edx
    1152:       0f af c7                imul   %edi,%eax
    1155:       89 d7                   mov    %edx,%edi
    1157:       83 fa 01                cmp    $0x1,%edx
    115a:       75 f3                   jne    114f <factorial+0xa>
    115c:       c3                      retq
    115d:       89 f8                   mov    %edi,%eax
    115f:       c3                      retq

Sự khác biệt chính giữa hai là:

  • việc -fno-optimize-sibling-callssử dụng callq, đó là cách gọi hàm không được tối ưu hóa điển hình.

    Hướng dẫn này đẩy địa chỉ trả về ngăn xếp, do đó tăng nó.

    Hơn nữa, phiên bản này cũng làm push %rbx, mà đẩy %rbxđến ngăn xếp .

    GCC thực hiện điều này bởi vì nó lưu trữ edi, là đối số hàm đầu tiên ( n) vào ebx, sau đó gọi factorial.

    GCC cần phải làm điều này bởi vì nó đang chuẩn bị cho một cuộc gọi khác factorial, sẽ sử dụng cuộc gọi mới edi == n-1.

    Nó chọn ebxvì thanh ghi này được lưu callee: Những thanh ghi nào được lưu giữ thông qua lệnh gọi hàm linux x86-64 để subcall factorialkhông thay đổi và mất n.

  • những -foptimize-sibling-callskhông sử dụng bất kỳ hướng dẫn mà đẩy vào stack: nó chỉ thực hiện gotonhảy trong factorialcác hướng dẫn jejne.

    Do đó, phiên bản này tương đương với một vòng lặp while, không có bất kỳ lệnh gọi chức năng nào. Sử dụng ngăn xếp là không đổi.

Đã thử nghiệm trong Ubuntu 18.10, GCC 8.2.


6

Nhìn đây:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

Như bạn có thể biết, các lệnh gọi hàm đệ quy có thể tàn phá một ngăn xếp; thật dễ dàng để nhanh chóng hết không gian ngăn xếp. Tối ưu hóa cuộc gọi đuôi là cách mà bạn có thể tạo một thuật toán kiểu đệ quy sử dụng không gian ngăn xếp không đổi, do đó nó không tăng trưởng và phát triển và bạn gặp lỗi ngăn xếp.


3
  1. Chúng ta nên đảm bảo rằng không có câu lệnh goto nào trong hàm .. được chăm sóc bằng cách gọi hàm là điều cuối cùng trong hàm callee.

  2. Thu hồi quy mô lớn có thể sử dụng điều này để tối ưu hóa, nhưng ở quy mô nhỏ, chi phí hướng dẫn để thực hiện chức năng gọi một cuộc gọi đuôi làm giảm mục đích thực tế.

  3. TCO có thể gây ra chức năng chạy mãi mãi:

    void eternity()
    {
        eternity();
    }
    

3 chưa được tối ưu hóa. Đó là biểu diễn không được tối ưu hóa mà trình biên dịch chuyển thành mã lặp sử dụng không gian ngăn xếp không đổi thay vì mã đệ quy. TCO không phải là nguyên nhân của việc sử dụng sơ đồ đệ quy sai cho cấu trúc dữ liệu.
Tên của

"TCO không phải là nguyên nhân của việc sử dụng sơ đồ đệ quy sai cho cấu trúc dữ liệu" Vui lòng giải thích cách thức này có liên quan đến trường hợp cụ thể. Ví dụ trên chỉ nêu một ví dụ về các khung được phân bổ trên ngăn xếp cuộc gọi có và không có TCO.
nướngSandwich

Bạn đã chọn sử dụng đệ quy vô căn cứ để duyệt (). Điều đó không liên quan gì đến TCO. vĩnh cửu xảy ra là vị trí gọi đuôi, nhưng vị trí gọi đuôi là không cần thiết: void vĩnh cửu () {vĩnh cửu (); lối ra(); }
Tên của

Trong khi chúng ta đang ở đó, "đệ quy quy mô lớn" là gì? Tại sao chúng ta nên tránh goto trong chức năng? Điều này là không cần thiết cũng không đủ để cho phép TCO. Và hướng dẫn trên không? Điểm chung của TCO là trình biên dịch thay thế lời gọi hàm ở vị trí đuôi bằng một goto.
Tên của

TCO là về tối ưu hóa không gian được sử dụng trên ngăn xếp cuộc gọi. Bằng cách đệ quy quy mô lớn, tôi đang đề cập đến kích thước của khung. Mỗi lần xảy ra đệ quy, nếu tôi cần phân bổ một khung lớn trên ngăn xếp cuộc gọi phía trên hàm callee, TCO sẽ hữu ích hơn và cho phép tôi có nhiều mức đệ quy hơn. Nhưng trong trường hợp kích thước khung hình của tôi ít hơn, tôi có thể làm mà không cần TCO và vẫn chạy tốt chương trình của mình (tôi không nói về đệ quy vô hạn ở đây). Nếu bạn còn lại với goto trong chức năng, cuộc gọi "đuôi" không thực sự là cuộc gọi đuôi và TCO không được áp dụng.
nướngSandwich

3

Cách tiếp cận hàm đệ quy có một vấn đề. Nó xây dựng một ngăn xếp cuộc gọi có kích thước O (n), làm cho tổng bộ nhớ của chúng ta có chi phí O (n). Điều này làm cho nó dễ bị lỗi tràn ngăn xếp, trong đó ngăn xếp cuộc gọi quá lớn và hết dung lượng.

Chương trình tối ưu hóa cuộc gọi đuôi (TCO). Nơi nó có thể tối ưu hóa các chức năng đệ quy để tránh xây dựng ngăn xếp cuộc gọi cao và do đó tiết kiệm chi phí bộ nhớ.

Có nhiều ngôn ngữ đang làm TCO như (JavaScript, Ruby và vài C) trong khi Python và Java không làm TCO.

Ngôn ngữ JavaScript đã được xác nhận bằng cách sử dụng :) http://2ality.com/2015/06/tail-call-optimization.html


0

Trong một ngôn ngữ chức năng, tối ưu hóa cuộc gọi đuôi như thể một lệnh gọi hàm có thể trả về một biểu thức được đánh giá một phần làm kết quả, sau đó sẽ được người gọi đánh giá.

f x = g x

f 6 giảm xuống g 6. Vì vậy, nếu việc thực hiện có thể trả về g 6 như kết quả, và sau đó gọi biểu thức đó, nó sẽ lưu một khung stack.

Cũng thế

f x = if c x then g x else h x.

Giảm xuống f 6 xuống g 6 hoặc h 6. Vì vậy, nếu việc triển khai đánh giá c 6 và thấy nó đúng thì nó có thể giảm,

if true then g x else h x ---> g x

f x ---> h x

Một trình thông dịch tối ưu hóa cuộc gọi không đuôi đơn giản có thể trông như thế này,

class simple_expresion
{
    ...
public:
    virtual ximple_value *DoEvaluate() const = 0;
};

class simple_value
{
    ...
};

class simple_function : public simple_expresion
{
    ...
private:
    simple_expresion *m_Function;
    simple_expresion *m_Parameter;

public:
    virtual simple_value *DoEvaluate() const
    {
        vector<simple_expresion *> parameterList;
        parameterList->push_back(m_Parameter);
        return m_Function->Call(parameterList);
    }
};

class simple_if : public simple_function
{
private:
    simple_expresion *m_Condition;
    simple_expresion *m_Positive;
    simple_expresion *m_Negative;

public:
    simple_value *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive.DoEvaluate();
        }
        else
        {
            return m_Negative.DoEvaluate();
        }
    }
}

Một trình thông dịch tối ưu hóa cuộc gọi đuôi có thể trông như thế này,

class tco_expresion
{
    ...
public:
    virtual tco_expresion *DoEvaluate() const = 0;
    virtual bool IsValue()
    {
        return false;
    }
};

class tco_value
{
    ...
public:
    virtual bool IsValue()
    {
        return true;
    }
};

class tco_function : public tco_expresion
{
    ...
private:
    tco_expresion *m_Function;
    tco_expresion *m_Parameter;

public:
    virtual tco_expression *DoEvaluate() const
    {
        vector< tco_expression *> parameterList;
        tco_expression *function = const_cast<SNI_Function *>(this);
        while (!function->IsValue())
        {
            function = function->DoCall(parameterList);
        }
        return function;
    }

    tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
    {
        p_ParameterList.push_back(m_Parameter);
        return m_Function;
    }
};

class tco_if : public tco_function
{
private:
    tco_expresion *m_Condition;
    tco_expresion *m_Positive;
    tco_expresion *m_Negative;

    tco_expresion *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive;
        }
        else
        {
            return m_Negative;
        }
    }
}
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.