Python có tối ưu hóa đệ quy đuôi không?


206

Tôi có đoạn mã sau bị lỗi với lỗi sau:

RuntimeError: vượt quá độ sâu đệ quy tối đa

Tôi đã cố gắng viết lại điều này để cho phép tối ưu hóa đệ quy đuôi (TCO). Tôi tin rằng mã này đã thành công nếu một TCO đã diễn ra.

def trisum(n, csum):
    if n == 0:
        return csum
    else:
        return trisum(n - 1, csum + n)

print(trisum(1000, 0))

Tôi có nên kết luận rằng Python không thực hiện bất kỳ loại TCO nào không, hay tôi chỉ cần định nghĩa nó theo cách khác?


11
@Wessie TCO đơn giản là về ngôn ngữ động hay tĩnh như thế nào. Lua, ví dụ, cũng làm điều đó. Bạn chỉ cần nhận ra các cuộc gọi đuôi (khá đơn giản, cả ở cấp AST và cấp mã byte), sau đó sử dụng lại khung ngăn xếp hiện tại thay vì tạo một cuộc gọi mới (cũng đơn giản, thậm chí đơn giản hơn trong trình thông dịch so với mã gốc) .

11
Ồ, một nitpick: Bạn nói riêng về đệ quy đuôi, nhưng sử dụng từ viết tắt "TCO", có nghĩa là tối ưu hóa cuộc gọi đuôi và áp dụng cho bất kỳ trường hợp nàoreturn func(...) (rõ ràng hoặc ngầm), cho dù đó là đệ quy hay không. TCO là một superset thích hợp của TRE, và hữu ích hơn (ví dụ: nó làm cho việc tiếp tục vượt qua phong cách trở nên khả thi, mà TRE không thể), và không khó thực hiện hơn nhiều.

1
Dưới đây là một cách hackish để thực hiện nó - một trang trí sử dụng nâng cao ngoại lệ để ném khung thực hiện đi: metapython.blogspot.com.br/2010/11/...
jsbueno

2
Nếu bạn hạn chế theo dõi đệ quy, tôi không nghĩ rằng một truy nguyên thích hợp là siêu hữu ích. Bạn có một cuộc gọi footừ bên trong một cuộc gọi đến footừ bên trong đến footừ bên trong một cuộc gọi đến foo... Tôi không nghĩ bất kỳ thông tin hữu ích nào sẽ bị mất do mất tin nhắn này.
Kevin

1
Gần đây tôi đã biết về Dừa nhưng chưa thử. Có vẻ đáng để xem xét. Nó được tuyên bố là có tối ưu hóa đệ quy đuôi.
Alexey

Câu trả lời:


216

Không, và nó sẽ không bao giờ kể từ khi Guido van Rossum thích có thể có các tracebacks thích hợp:

Loại bỏ đệ quy đuôi (2009-04-22)

Lời cuối cùng trong các cuộc gọi đuôi (2009-04-27)

Bạn có thể loại bỏ thủ công đệ quy bằng cách chuyển đổi như sau:

>>> def trisum(n, csum):
...     while True:                     # Change recursion to a while loop
...         if n == 0:
...             return csum
...         n, csum = n - 1, csum + n   # Update parameters instead of tail recursion

>>> trisum(1000,0)
500500

12
Hoặc nếu bạn định biến đổi nó như thế - chỉ : from operator import add; reduce(add, xrange(n + 1), csum)?
Jon Clements

38
@JonClements, hoạt động trong ví dụ cụ thể này. Việc chuyển đổi thành một vòng lặp while hoạt động cho đệ quy đuôi trong các trường hợp chung.
John La Rooy

25
+1 Vì là câu trả lời đúng nhưng đây có vẻ là một quyết định thiết kế cực kỳ xương. Những lý do được đưa ra dường như sôi sục lên "thật khó để đưa ra cách giải thích trăn và dù sao tôi cũng không thích nó!"
Cơ bản

12
@jwg Vậy ... Cái gì? Bạn phải viết một ngôn ngữ trước khi bạn có thể nhận xét về các quyết định thiết kế kém? Hầu như không hợp lý hoặc thực tế. Tôi giả sử từ nhận xét của bạn rằng bạn không có ý kiến ​​về bất kỳ tính năng (hoặc thiếu) trong bất kỳ ngôn ngữ nào từng được viết?
Cơ bản

2
@Basic Không, nhưng bạn phải đọc bài viết bạn đang bình luận. Có vẻ như rất mạnh mẽ rằng bạn đã không thực sự đọc nó, xem xét làm thế nào nó "sôi sục" với bạn. .
Veky

179

Tôi đã xuất bản một mô-đun thực hiện tối ưu hóa cuộc gọi đuôi (xử lý cả kiểu đệ quy đuôi và kiểu chuyển tiếp liên tục): https://github.com/baruchel/tco

Tối ưu hóa đệ quy đuôi trong Python

Người ta thường tuyên bố rằng đệ quy đuôi không phù hợp với cách mã hóa của Pythonic và người ta không nên quan tâm đến cách nhúng nó vào một vòng lặp. Tôi không muốn tranh luận với quan điểm này; tuy nhiên đôi khi tôi thích thử hoặc thực hiện các ý tưởng mới dưới dạng các hàm đệ quy hơn là các vòng lặp vì nhiều lý do (tập trung vào ý tưởng hơn là vào quá trình, có hai mươi chức năng ngắn trên màn hình của tôi cùng một lúc thay vì chỉ có ba "Pythonic" các chức năng, làm việc trong một phiên tương tác thay vì chỉnh sửa mã của tôi, v.v.).

Tối ưu hóa đệ quy đuôi trong Python trên thực tế khá dễ dàng. Trong khi nó được cho là không thể hoặc rất khó, tôi nghĩ nó có thể đạt được bằng các giải pháp thanh lịch, ngắn gọn và chung chung; Tôi thậm chí nghĩ rằng hầu hết các giải pháp này không sử dụng các tính năng của Python nếu không. Các biểu thức lambda sạch làm việc cùng với các vòng lặp rất chuẩn dẫn đến các công cụ nhanh chóng, hiệu quả và hoàn toàn có thể sử dụng để thực hiện tối ưu hóa đệ quy đuôi.

Để thuận tiện cho cá nhân, tôi đã viết một mô-đun nhỏ thực hiện tối ưu hóa như vậy bằng hai cách khác nhau. Tôi muốn thảo luận ở đây về hai chức năng chính của tôi.

Cách sạch sẽ: sửa đổi tổ hợp Y

Tổ hợp Y được biết đến nhiều; nó cho phép sử dụng các hàm lambda theo cách đệ quy, nhưng bản thân nó không cho phép nhúng các cuộc gọi đệ quy trong một vòng lặp. Lambda tính toán một mình không thể làm một điều như vậy. Tuy nhiên, một thay đổi nhỏ trong bộ kết hợp Y có thể bảo vệ cuộc gọi đệ quy được đánh giá thực sự. Đánh giá do đó có thể bị trì hoãn.

Đây là biểu thức nổi tiếng cho tổ hợp Y:

lambda f: (lambda x: x(x))(lambda y: f(lambda *args: y(y)(*args)))

Với một thay đổi rất nhỏ, tôi có thể nhận được:

lambda f: (lambda x: x(x))(lambda y: f(lambda *args: lambda: y(y)(*args)))

Thay vì tự gọi, hàm f bây giờ trả về một hàm thực hiện cùng một cuộc gọi, nhưng vì nó trả về nó, nên việc đánh giá có thể được thực hiện sau đó từ bên ngoài.

Mã của tôi là:

def bet(func):
    b = (lambda f: (lambda x: x(x))(lambda y:
          f(lambda *args: lambda: y(y)(*args))))(func)
    def wrapper(*args):
        out = b(*args)
        while callable(out):
            out = out()
        return out
    return wrapper

Các chức năng có thể được sử dụng theo cách sau; Dưới đây là hai ví dụ với các phiên bản đệ quy đuôi của giai thừa và Fibonacci:

>>> from recursion import *
>>> fac = bet( lambda f: lambda n, a: a if not n else f(n-1,a*n) )
>>> fac(5,1)
120
>>> fibo = bet( lambda f: lambda n,p,q: p if not n else f(n-1,q,p+q) )
>>> fibo(10,0,1)
55

Rõ ràng độ sâu đệ quy không còn là vấn đề nữa:

>>> bet( lambda f: lambda n: 42 if not n else f(n-1) )(50000)
42

Tất nhiên đây là mục đích thực sự duy nhất của chức năng.

Chỉ có một điều không thể thực hiện được với tối ưu hóa này: nó không thể được sử dụng với hàm đệ quy đuôi đánh giá chức năng khác (điều này xuất phát từ thực tế là các đối tượng trả về có thể gọi được đều được xử lý như các cuộc gọi đệ quy tiếp theo không có sự phân biệt). Vì tôi thường không cần một tính năng như vậy, tôi rất hài lòng với mã ở trên. Tuy nhiên, để cung cấp một mô-đun tổng quát hơn, tôi đã suy nghĩ thêm một chút để tìm ra cách giải quyết cho vấn đề này (xem phần tiếp theo).

Liên quan đến tốc độ của quá trình này (tuy nhiên không phải là vấn đề thực sự), nó xảy ra khá tốt; Các hàm đệ quy đuôi thậm chí được đánh giá nhanh hơn nhiều so với mã sau sử dụng các biểu thức đơn giản hơn:

def bet1(func):
    def wrapper(*args):
        out = func(lambda *x: lambda: x)(*args)
        while callable(out):
            out = func(lambda *x: lambda: x)(*out())
        return out
    return wrapper

Tôi nghĩ rằng việc đánh giá một biểu thức, thậm chí phức tạp, nhanh hơn nhiều so với việc đánh giá một số biểu thức đơn giản, đó là trường hợp trong phiên bản thứ hai này. Tôi đã không giữ chức năng mới này trong mô-đun của mình và tôi thấy không có trường hợp nào nó có thể được sử dụng thay vì "chính thức".

Phong cách tiếp tục vượt qua với ngoại lệ

Đây là một chức năng tổng quát hơn; nó có thể xử lý tất cả các hàm đệ quy đuôi, bao gồm cả các hàm trả về các hàm khác. Các cuộc gọi đệ quy được nhận ra từ các giá trị trả về khác bằng cách sử dụng các ngoại lệ. Giải pháp này chậm hơn giải pháp trước; một mã nhanh hơn có thể được viết bằng cách sử dụng một số giá trị đặc biệt là "cờ" được phát hiện trong vòng lặp chính, nhưng tôi không thích ý tưởng sử dụng các giá trị đặc biệt hoặc từ khóa nội bộ. Có một số cách giải thích hài hước về việc sử dụng các ngoại lệ: nếu Python không thích các cuộc gọi đệ quy đuôi, một ngoại lệ sẽ được đưa ra khi một cuộc gọi đệ quy đuôi xảy ra và cách Pythonic sẽ bắt ngoại lệ để tìm ra một số ngoại lệ giải pháp, đó thực sự là những gì xảy ra ở đây ...

class _RecursiveCall(Exception):
  def __init__(self, *args):
    self.args = args
def _recursiveCallback(*args):
  raise _RecursiveCall(*args)
def bet0(func):
    def wrapper(*args):
        while True:
          try:
            return func(_recursiveCallback)(*args)
          except _RecursiveCall as e:
            args = e.args
    return wrapper

Bây giờ tất cả các chức năng có thể được sử dụng. Trong ví dụ sau, f(n)được ước tính cho hàm nhận dạng cho bất kỳ giá trị dương nào của n:

>>> f = bet0( lambda f: lambda n: (lambda x: x) if not n else f(n-1) )
>>> f(5)(42)
42

Tất nhiên, có thể lập luận rằng các trường hợp ngoại lệ không nhằm mục đích sử dụng cho việc cố ý chuyển hướng trình thông dịch (như một loại gototuyên bố hoặc có thể là một kiểu chuyển tiếp tiếp tục), mà tôi phải thừa nhận. Nhưng, một lần nữa, tôi thấy buồn cười ý tưởng sử dụng tryvới một dòng duy nhất là mộtreturn tuyên bố: chúng tôi cố gắng trả lại một cái gì đó (hành vi bình thường) nhưng chúng tôi không thể thực hiện được vì một cuộc gọi đệ quy xảy ra (ngoại lệ).

Câu trả lời ban đầu (2013-08-29).

Tôi đã viết một plugin rất nhỏ để xử lý đệ quy đuôi. Bạn có thể tìm thấy nó với lời giải thích của tôi ở đó: https://groups.google.com/forum/?hl=fr#!topic/comp.lang.python/dIsnJ2BoBKs

Nó có thể nhúng một hàm lambda được viết với kiểu đệ quy đuôi trong một hàm khác sẽ đánh giá nó như một vòng lặp.

Theo tôi, tính năng thú vị nhất trong hàm nhỏ này là hàm không dựa vào một số hack lập trình bẩn mà chỉ dựa trên phép tính lambda: hành vi của hàm được thay đổi thành hàm khác khi được chèn vào hàm lambda khác. trông rất giống tổ hợp Y.


Bạn có thể, xin vui lòng, cung cấp một ví dụ về việc xác định hàm (tốt nhất là theo cách tương tự như định nghĩa thông thường) gọi đuôi một trong một số hàm khác dựa trên một số điều kiện, sử dụng phương thức của bạn? Ngoài ra, chức năng gói của bạn có thể bet0được sử dụng như một trang trí cho các phương thức lớp không?
Alexey

@Alexey Tôi không chắc mình có thể viết mã theo kiểu khối bên trong một nhận xét, nhưng tất nhiên bạn có thể sử dụng defcú pháp cho các hàm của mình và thực sự là ví dụ cuối cùng ở trên dựa trên một điều kiện. Trong bài đăng của tôi baruchel.github.io/python/2015/11/07/ Bạn có thể thấy một đoạn văn bắt đầu bằng "Tất nhiên bạn có thể phản đối rằng không ai sẽ viết một mã như vậy" trong đó tôi đưa ra một ví dụ với cú pháp định nghĩa thông thường. Đối với phần thứ hai của câu hỏi của bạn, tôi phải suy nghĩ về nó nhiều hơn một chút vì tôi đã không dành thời gian trong đó một thời gian. Trân trọng.
Thomas Baruchel

Bạn nên quan tâm nơi cuộc gọi đệ quy xảy ra trong chức năng của mình ngay cả khi bạn đang sử dụng triển khai ngôn ngữ không phải TCO. Điều này là do phần của hàm xảy ra sau lệnh gọi đệ quy là phần cần được lưu trữ trên ngăn xếp. Do đó, làm cho chức năng đệ quy đuôi của bạn giảm thiểu lượng thông tin bạn phải lưu trữ cho mỗi cuộc gọi đệ quy, giúp bạn có nhiều chỗ hơn để có ngăn xếp cuộc gọi đệ quy lớn nếu bạn cần chúng.
josiah

21

Từ của Guido có tại http://neopythonic.blogspot.co.uk/2009/04/tail-recursion- Friination.html

Gần đây tôi đã đăng một mục trong blog Lịch sử Python của tôi về nguồn gốc của các tính năng chức năng của Python. Một nhận xét bên lề về việc không hỗ trợ loại bỏ đệ quy đuôi (TRE) ngay lập tức đã gây ra một số ý kiến ​​về việc thật đáng tiếc khi Python không làm điều này, bao gồm các liên kết đến các mục blog gần đây của những người khác đang cố gắng "chứng minh" rằng TRE có thể được thêm vào Python dễ dàng Vì vậy, hãy để tôi bảo vệ vị trí của mình (đó là tôi không muốn TRE bằng ngôn ngữ). Nếu bạn muốn có một câu trả lời ngắn, nó chỉ đơn giản là unpythonic. Đây là câu trả lời dài:


12
Và đây là vấn đề với cái gọi là BDsFL.
Adam Donahue

6
@AdamDonahue bạn đã hoàn toàn hài lòng với mọi quyết định đến từ một ủy ban chưa? Ít nhất bạn có được một lời giải thích hợp lý và có thẩm quyền từ BDFL.
Đánh dấu tiền chuộc

2
Không, tất nhiên là không, nhưng họ tấn công tôi bằng tay hơn. Điều này từ một người kê đơn, không phải là người mô tả. Sự trớ trêu.
Adam Donahue

6

CPython không và có lẽ sẽ không bao giờ hỗ trợ tối ưu hóa cuộc gọi đuôi dựa trên Guido van Rossum tuyên bố về chủ đề này.

Tôi đã nghe các lập luận rằng nó làm cho việc gỡ lỗi trở nên khó khăn hơn do cách nó sửa đổi dấu vết ngăn xếp.


18
@mux CPython là triển khai tham chiếu của ngôn ngữ lập trình Python. Có các triển khai khác (như PyPy, IronPython và Jython), thực hiện cùng ngôn ngữ nhưng khác nhau về chi tiết triển khai. Sự khác biệt rất hữu ích ở đây vì (về lý thuyết) có thể tạo ra một triển khai Python thay thế có TCO. Tôi không biết bất cứ ai thậm chí nghĩ về nó, và tính hữu dụng sẽ bị hạn chế vì mã dựa vào nó sẽ phá vỡ tất cả các triển khai Python khác.


2

Bên cạnh việc tối ưu hóa đệ quy đuôi, bạn có thể đặt độ sâu đệ quy theo cách thủ công bằng cách:

import sys
sys.setrecursionlimit(5500000)
print("recursion limit:%d " % (sys.getrecursionlimit()))

5
Tại sao bạn không sử dụng jQuery?
Jeremy Hert

5
Bởi vì nó cũng không cung cấp TCO? :-D stackoverflow.com/questions/3660577/ từ
Veky
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.