Python ปรับหางแบบวนซ้ำให้เหมาะสมหรือไม่?


206

ฉันมีรหัสต่อไปนี้ซึ่งล้มเหลวด้วยข้อผิดพลาดต่อไปนี้:

RuntimeError: เกินความลึกการเรียกซ้ำสูงสุด

ฉันพยายามเขียนสิ่งนี้เพื่ออนุญาตการปรับให้เหมาะสมแบบเรียกซ้ำหาง (TCO) ฉันเชื่อว่ารหัสนี้ควรจะประสบความสำเร็จหาก TCO เกิดขึ้น

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

print(trisum(1000, 0))

ฉันควรสรุปได้หรือไม่ว่า Python ไม่ได้ทำ TCO ประเภทใดหรือฉันต้องกำหนดมันให้แตกต่างกันหรือไม่?


11
@Wessie TCO นั้นคำนึงถึงความเรียบง่ายของภาษา ยกตัวอย่างเช่น Lua ก็ทำเช่นกัน คุณเพียงจำสายหาง (ค่อนข้างง่ายทั้งที่ระดับ AST และระดับ bytecode) จากนั้นใช้เฟรมสแต็กปัจจุบันแทนการสร้างใหม่ (เช่นง่ายเรียบง่ายจริง ๆ ง่ายกว่าล่ามกว่าในรหัสเนทีฟ) .

11
โอ้หนึ่ง nitpick: คุณพูดคุยเฉพาะเกี่ยวกับการเรียกซ้ำหาง แต่ใช้คำย่อ "TCO" ซึ่งหมายถึงการเพิ่มประสิทธิภาพการโทรหางและนำไปใช้กับตัวอย่างใด ๆของreturn func(...)(ชัดเจนหรือโดยปริยาย) ไม่ว่าจะซ้ำหรือไม่ TCO เป็น superset ที่เหมาะสมของ TRE และมีประโยชน์มากกว่า (เช่นทำให้การส่งต่อความเป็นไปได้ในรูปแบบเป็นไปได้ซึ่ง TRE ไม่สามารถทำได้) และไม่ยากที่จะนำไปใช้

1
นี่คือวิธีแฮ็กที่จะใช้มัน - มัณฑนากรที่ใช้การยกข้อยกเว้นเพื่อโยนเฟรมการดำเนินการออกไป: metapython.blogspot.com.br/2010/11//
jsbueno

2
หากคุณ จำกัด การเรียกซ้ำตัวเองฉันไม่คิดว่าการย้อนกลับที่เหมาะสมนั้นมีประโยชน์มาก คุณมีสายเรียกเข้าfooจากภายในโทรfooจากภายในสู่ภายในfooจากภายในโทรไปที่foo... ฉันไม่คิดว่าข้อมูลที่เป็นประโยชน์จะหายไปจากการสูญเสียสิ่งนี้
เควิน

1
ฉันเพิ่งเรียนรู้เกี่ยวกับมะพร้าวแต่ยังไม่ได้ลองเลย มันดูน่าลองดู มันอ้างว่ามีการเพิ่มประสิทธิภาพการเรียกซ้ำหาง
Alexey

คำตอบ:


215

ไม่และมันจะไม่เกิดขึ้นเพราะGuido van Rossumชอบที่จะมีร่องรอยที่เหมาะสม:

การกำจัดหางแบบวนซ้ำ (2009-04-22)

คำสุดท้ายในการโทรหาง (2009-04-27)

คุณสามารถกำจัดการเรียกซ้ำด้วยตนเองด้วยการแปลงดังนี้:

>>> 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
หรือถ้าคุณกำลังจะเปลี่ยนมันเหมือนว่า - เพียงแค่: from operator import add; reduce(add, xrange(n + 1), csum)?
Jon Clements

38
@JonClements ที่ทำงานในตัวอย่างนี้โดยเฉพาะ การแปลงเป็น a while loop ใช้สำหรับการเรียกซ้ำแบบหางในกรณีทั่วไป
John La Rooy

25
+1 สำหรับการเป็นคำตอบที่ถูกต้อง แต่ดูเหมือนว่าการตัดสินใจออกแบบหัวกระดูกอย่างไม่น่าเชื่อ เหตุผลให้ดูเหมือนจะต้มลงไป "มันยากที่จะไม่กำหนดวิธีการหลามถูกตีความและผมไม่ชอบมันอยู่แล้วจึงมี!"
พื้นฐาน

12
@jwg งั้น ... อะไรนะ? คุณต้องเขียนภาษาก่อนที่คุณจะสามารถแสดงความคิดเห็นในการตัดสินใจออกแบบที่ไม่ดี? ดูเหมือนว่ามีเหตุผลหรือในทางปฏิบัติ ฉันสันนิษฐานจากความคิดเห็นของคุณว่าคุณไม่ได้มีความคิดเห็นเกี่ยวกับคุณสมบัติใด ๆ (หรือขาดมัน) ในภาษาใด ๆ ที่เคยเขียน?
พื้นฐาน

2
@ ขั้นพื้นฐานไม่ได้ แต่คุณต้องอ่านบทความที่คุณแสดงความคิดเห็น ดูเหมือนว่าคุณจะไม่ได้อ่านมันอย่างจริงจังจริง ๆ พิจารณาว่ามัน "เดือด" กับคุณอย่างไร (จริง ๆ แล้วคุณอาจจำเป็นต้องอ่านบทความที่เชื่อมโยงทั้งคู่โชคไม่ดีเนื่องจากข้อโต้แย้งบางส่วนแพร่กระจายไปทั่วทั้งสองบทความ) แทบไม่มีอะไรเกี่ยวข้องกับการใช้ภาษา แต่ทุกอย่างเกี่ยวกับความหมายที่ตั้งใจไว้
Veky

178

ฉันเผยแพร่โมดูลที่ใช้การเพิ่มประสิทธิภาพการโทรหาง (จัดการทั้งการเรียกซ้ำแบบหางและการส่งต่อแบบต่อเนื่อง): https://github.com/baruchel/tco

ปรับหางคำสั่งซ้ำให้เหมาะสมใน Python

มันมักถูกอ้างว่า tail-recursion ไม่เหมาะกับวิธีการเข้ารหัสแบบ Pythonic และคนเราไม่ควรสนใจว่าจะฝังมันไว้ในวงวนอย่างไร ฉันไม่ต้องการโต้เถียงกับมุมมองนี้ บางครั้งฉันชอบลองหรือนำความคิดใหม่มาใช้เป็นฟังก์ชั่นแบบเรียกซ้ำแทนที่จะวนซ้ำด้วยเหตุผลหลาย ๆ อย่าง (เน้นที่ความคิดมากกว่าในกระบวนการโดยมีฟังก์ชั่นสั้น ๆ ยี่สิบหน้าจอในเวลาเดียวกันมากกว่าสาม "Pythonic" ฟังก์ชั่นที่ทำงานในเซสชันแบบโต้ตอบแทนการแก้ไขรหัส ฯลฯ )

การเพิ่มประสิทธิภาพการเรียกซ้ำแบบหางใน Python นั้นง่ายมาก ในขณะที่มีการกล่าวถึงว่าเป็นไปไม่ได้หรือยุ่งยากมากฉันคิดว่ามันสามารถทำได้ด้วยวิธีแก้ปัญหาที่หรูหราสั้นและทั่วไป ฉันคิดว่าโซลูชันเหล่านี้ส่วนใหญ่ไม่ใช้คุณสมบัติ Python อย่างอื่นที่ควรทำ การแสดงออกแลมบ์ดาที่สะอาดทำงานพร้อมกับลูปมาตรฐานมากนำไปสู่เครื่องมือที่รวดเร็วมีประสิทธิภาพและใช้งานได้อย่างเต็มที่สำหรับการปรับใช้การปรับหางแบบเรียกซ้ำ

เพื่อความสะดวกส่วนตัวฉันเขียนโมดูลขนาดเล็กที่ใช้การเพิ่มประสิทธิภาพดังกล่าวด้วยสองวิธีที่แตกต่างกัน ฉันอยากจะพูดคุยเกี่ยวกับหน้าที่หลักของฉันที่นี่

วิธีที่สะอาด: แก้ไข Y combinator

Y Combinatorเป็นที่รู้จักกันดี; อนุญาตให้ใช้ฟังก์ชัน lambda ในลักษณะวนซ้ำ แต่ไม่อนุญาตให้ฝังการเรียกซ้ำแบบวนซ้ำ แลมบ์ดาแคลคูลัสเพียงอย่างเดียวไม่สามารถทำสิ่งนี้ได้ การเปลี่ยนแปลงเล็กน้อยใน Y combinator แต่สามารถป้องกันการโทรซ้ำเพื่อรับการประเมินจริง การประเมินผลอาจล่าช้า

นี่คือการแสดงออกที่มีชื่อเสียงสำหรับ Y combinator:

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

ด้วยการเปลี่ยนแปลงเล็กน้อยมากฉันจะได้รับ:

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

แทนที่จะเรียกตัวเองตอนนี้ฟังก์ชั่น f จะคืนค่าฟังก์ชั่นที่ใช้การโทรเดียวกัน แต่เนื่องจากส่งคืนการประเมินจึงสามารถทำได้ในภายหลังจากภายนอก

รหัสของฉันคือ:

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

ฟังก์ชั่นสามารถใช้งานได้ด้วยวิธีดังต่อไปนี้ ต่อไปนี้เป็นตัวอย่างสองตัวอย่างที่มีแฟกทอเรียลและ 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

ความชัดลึกของการเรียกซ้ำไม่ได้เป็นปัญหาอีกต่อไป:

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

นี่เป็นจุดประสงค์ที่แท้จริงของฟังก์ชัน

มีเพียงสิ่งเดียวเท่านั้นที่ไม่สามารถทำได้ด้วยการปรับให้เหมาะสมนี้: ไม่สามารถใช้กับฟังก์ชันแบบเรียกซ้ำ (recursive function) ที่ประเมินเป็นฟังก์ชั่นอื่นได้ (นี่มาจากข้อเท็จจริงที่ว่าวัตถุที่ส่งคืนได้ เนื่องจากปกติฉันไม่ต้องการฟีเจอร์ดังกล่าวฉันจึงมีความสุขกับโค้ดด้านบน อย่างไรก็ตามเพื่อให้เป็นโมดูลทั่วไปฉันคิดอีกเล็กน้อยเพื่อหาวิธีแก้ปัญหาสำหรับปัญหานี้ (ดูหัวข้อถัดไป)

เกี่ยวกับความเร็วของกระบวนการนี้ (ซึ่งไม่ใช่เรื่องจริงอย่างไรก็ตาม) มันค่อนข้างดี ฟังก์ชั่น tail-recursive นั้นประเมินได้เร็วกว่ามากด้วยโค้ดต่อไปนี้โดยใช้นิพจน์ที่ง่ายขึ้น:

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

ฉันคิดว่าการประเมินหนึ่งนิพจน์แม้จะซับซ้อนนั้นเร็วกว่าการประเมินนิพจน์ง่าย ๆ หลายอย่างซึ่งเป็นกรณีในรุ่นที่สองนี้ ฉันไม่ได้รักษาฟังก์ชั่นใหม่นี้ไว้ในโมดูลของฉันและฉันไม่เห็นสถานการณ์ใดที่จะสามารถใช้งานได้มากกว่าที่จะเป็น "ทางการ"

สไตล์การส่งต่อที่มีข้อยกเว้น

นี่คือฟังก์ชั่นทั่วไปมากขึ้น มันสามารถจัดการฟังก์ชั่นหางแบบวนซ้ำทั้งหมดรวมถึงฟังก์ชันที่ส่งคืนฟังก์ชันอื่น การเรียกซ้ำจะรับรู้จากค่าตอบแทนอื่น ๆ โดยใช้ข้อยกเว้น โซลูชันนี้ช้ากว่าโซลูชันก่อนหน้า รหัสที่รวดเร็วกว่าอาจจะเขียนได้โดยใช้ค่าพิเศษบางอย่างเมื่อตรวจพบ "ค่าสถานะ" ในลูปหลัก แต่ฉันไม่ชอบแนวคิดของการใช้ค่าพิเศษหรือคำหลักภายใน มีการตีความที่ตลกเกี่ยวกับการใช้ข้อยกเว้น: ถ้า Python ไม่ชอบการโทรซ้ำแบบเรียกซ้ำควรมีการยกข้อยกเว้นเมื่อมีการโทรแบบเรียกซ้ำแบบหางเกิดขึ้นและวิธี Pythonic จะจับข้อยกเว้นเพื่อค้นหาความสะอาด ทางออกซึ่งเป็นสิ่งที่เกิดขึ้นจริงที่นี่ ...

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

ตอนนี้ทุกฟังก์ชั่นสามารถใช้งานได้ ในตัวอย่างต่อไปนี้f(n)ถูกประเมินเป็นฟังก์ชันเอกลักษณ์สำหรับค่าบวกใด ๆ ของ n:

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

แน่นอนมันอาจเป็นที่ถกเถียงกันอยู่ว่าข้อยกเว้นนั้นไม่ได้มีวัตถุประสงค์เพื่อใช้ในการเปลี่ยนเส้นทางล่ามโดยเจตนา (เป็นgotoข้อความหรืออาจเป็นรูปแบบการส่งต่อที่ต่อเนื่อง) ซึ่งฉันต้องยอมรับ แต่อีกครั้งฉันพบความคิดตลกของการใช้tryกับบรรทัดเดียวเป็นreturnคำสั่ง: เราพยายามที่จะส่งคืนบางสิ่ง (พฤติกรรมปกติ) แต่เราไม่สามารถทำได้เพราะมีการโทรซ้ำเกิดขึ้น (ข้อยกเว้น)

คำตอบเริ่มต้น (2013-08-29)

ฉันเขียนปลั๊กอินขนาดเล็กมากสำหรับจัดการการเรียกซ้ำหาง คุณอาจพบว่ามีคำอธิบายของฉันที่นั่น: https://groups.google.com/forum/?hl=fr#!topic/comp.lang.python/dIsnJ2BoBKs

มันสามารถฝังฟังก์ชั่นแลมบ์ดาที่เขียนด้วยรูปแบบการเรียกซ้ำหางในฟังก์ชันอื่นซึ่งจะประเมินว่าเป็นลูป

คุณสมบัติที่น่าสนใจที่สุดในฟังก์ชั่นเล็ก ๆ นี้ในความเห็นที่ต่ำต้อยของฉันคือฟังก์ชั่นนี้ไม่ได้อาศัยแฮ็คโปรแกรมที่สกปรก แต่อยู่ในแคลคูลัสแลมบ์ดาเพียง: พฤติกรรมของฟังก์ชั่นนั้นเปลี่ยนไปเป็นอีกฟังก์ชันหนึ่ง ดูเหมือน combinator Y มาก


คุณช่วยกรุณายกตัวอย่างของการกำหนดฟังก์ชั่น (โดยเฉพาะอย่างยิ่งในลักษณะที่คล้ายกับคำนิยามปกติ) ซึ่งหางเรียกหนึ่งในหลายฟังก์ชั่นอื่น ๆ ตามเงื่อนไขบางอย่างโดยใช้วิธีการของคุณ? ฟังก์ชั่นการห่อของคุณbet0สามารถใช้เป็นมัณฑนากรสำหรับวิธีการเรียนได้หรือไม่?
Alexey

@Alexey ฉันไม่แน่ใจว่าฉันสามารถเขียนรหัสในรูปแบบบล็อกภายในความคิดเห็น แต่แน่นอนคุณสามารถใช้defไวยากรณ์สำหรับฟังก์ชั่นของคุณและจริง ๆ แล้วตัวอย่างสุดท้ายข้างต้นขึ้นอยู่กับเงื่อนไข ในโพสต์ของฉันbaruchel.github.io/python/2015/11/07/ …คุณสามารถเห็นย่อหน้าที่ขึ้นต้นด้วย "แน่นอนคุณสามารถคัดค้านว่าไม่มีใครจะเขียนโค้ดดังกล่าว" ที่ฉันให้ตัวอย่างด้วยไวยากรณ์คำจำกัดความตามปกติ สำหรับคำถามที่สองของคุณฉันต้องคิดอีกเล็กน้อยเพราะฉันไม่ได้ใช้เวลาสักพัก ความนับถือ.
โทมัสบารูเชล

คุณควรสนใจว่าการเรียกซ้ำเกิดขึ้นในฟังก์ชันของคุณแม้ว่าคุณจะใช้การใช้ภาษาที่ไม่ใช่ TCO นี่เป็นเพราะส่วนของฟังก์ชั่นที่เกิดขึ้นหลังจากการเรียกซ้ำเป็นส่วนที่ต้องเก็บไว้ในกองซ้อน ดังนั้นการทำให้ฟังก์ชั่น tail-recursive ของคุณลดจำนวนข้อมูลที่คุณต้องจัดเก็บต่อการโทรซ้ำซึ่งช่วยให้คุณมีพื้นที่มากขึ้นในการโทรซ้ำแบบเรียกซ้ำหากคุณต้องการ
josiah

21

คำพูดของ Guido อยู่ที่http://neopythonic.blogspot.co.uk/2009/04/tail-recursion-elimination.html

ฉันเพิ่งโพสต์ข้อความในบล็อกประวัติความเป็นมาของ Python เกี่ยวกับคุณสมบัติการทำงานของ Python ข้อสังเกตด้านข้างเกี่ยวกับการไม่สนับสนุนการกำจัดหางแบบเรียกซ้ำ (TRE) จุดประกายความคิดเห็นหลายประการเกี่ยวกับสิ่งที่น่าเสียดายก็คือ Python ไม่ได้ทำสิ่งนี้รวมถึงลิงก์ไปยังรายการบล็อกล่าสุดโดยผู้อื่นที่พยายาม "พิสูจน์" ว่า TRE อย่างง่ายดาย ดังนั้นให้ฉันปกป้องตำแหน่งของฉัน (ซึ่งฉันไม่ต้องการ TRE ในภาษา) หากคุณต้องการคำตอบสั้น ๆ มันเป็นเพียงเสียงที่ไพเราะ นี่คือคำตอบที่ยาว:


12
และในที่นี้คือปัญหาที่เรียกว่า BDsFL
Adam Donahue

6
@ AdamDonahue คุณพอใจกับการตัดสินใจทุกครั้งที่มาจากคณะกรรมการหรือไม่? อย่างน้อยคุณก็ได้คำอธิบายที่สมเหตุสมผลและเชื่อถือได้จาก BDFL
Mark Ransom

2
ไม่ไม่แน่นอน แต่พวกเขาก็ตีฉันเหมือนกัน สิ่งนี้มาจาก prescriptivist ไม่ใช่ descriptivist การประชด
Adam Donahue

6

CPythonไม่ได้และอาจจะไม่สนับสนุนการเพิ่มประสิทธิภาพการโทรหางโดยอิงจากGuido van Rossumคำสั่งในหัวข้อนี้

ฉันได้ยินข้อโต้แย้งว่ามันทำให้การดีบักยากขึ้นเนื่องจากวิธีการแก้ไขการติดตามสแต็ก


18
@mux CPython เป็นการใช้งานอ้างอิงของภาษาโปรแกรม Python มีการใช้งานอื่น ๆ (เช่น PyPy, IronPython และ Jython) ซึ่งใช้ภาษาเดียวกัน แต่แตกต่างกันในรายละเอียดการใช้งาน ความแตกต่างมีประโยชน์ที่นี่เพราะ (ในทางทฤษฎี) เป็นไปได้ที่จะสร้างการใช้งาน Python ทางเลือกที่ทำด้วย TCO ฉันไม่ได้ตระหนักถึงใครเลยแม้แต่คิดเกี่ยวกับมันและประโยชน์จะถูก จำกัด เป็นรหัสที่อาศัยมันจะทำลายการใช้งานหลามอื่น ๆ ทั้งหมด


2

นอกจากการปรับการวนรอบหางให้เหมาะสมแล้วคุณสามารถตั้งค่าความลึกการวนซ้ำด้วยตนเองได้โดย:

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

5
ทำไมคุณไม่ใช้ jQuery?
Jeremy Hert

5
เพราะมันยังไม่ได้มีการ TCO? :-D stackoverflow.com/questions/3660577/…
Veky
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.