ภาษาที่ใช้งานได้ดีกว่าเมื่อเรียกซ้ำหรือไม่


41

TL; DR: ภาษาที่ใช้งานได้จัดการการเรียกซ้ำได้ดีกว่าภาษาที่ไม่ทำงานหรือไม่

ขณะนี้ฉันกำลังอ่าน Code Complete 2 ในบางช่วงของหนังสือผู้แต่งเตือนเราเกี่ยวกับการเรียกซ้ำ เขาบอกว่าควรหลีกเลี่ยงเมื่อเป็นไปได้และฟังก์ชั่นที่ใช้การเรียกซ้ำโดยทั่วไปมีประสิทธิภาพน้อยกว่าโซลูชันที่ใช้ลูป ตัวอย่างผู้เขียนเขียนฟังก์ชัน Java โดยใช้การเรียกซ้ำเพื่อคำนวณแฟกทอเรียลของตัวเลขเช่นนั้น (อาจไม่เหมือนกันทั้งหมดเนื่องจากฉันไม่มีหนังสือกับฉันในขณะนี้):

public int factorial(int x) {
    if (x <= 0)
        return 1;
    else
        return x * factorial(x - 1);
}

นี่เป็นวิธีแก้ปัญหาที่ไม่ดี อย่างไรก็ตามในภาษาที่ใช้งานได้การใช้การเรียกซ้ำเป็นวิธีที่นิยมใช้ในการทำสิ่งต่างๆ ตัวอย่างเช่นนี่คือฟังก์ชันแฟกทอเรียลใน Haskell โดยใช้การเรียกซ้ำ:

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

และได้รับการยอมรับอย่างกว้างขวางว่าเป็นทางออกที่ดี อย่างที่ฉันได้เห็น Haskell ใช้การเรียกซ้ำบ่อยมากและฉันไม่เห็นว่ามันขมวดคิ้ว

ดังนั้นคำถามของฉันโดยทั่วไปคือ:

  • ภาษาที่ใช้งานได้จัดการการเรียกซ้ำได้ดีกว่าภาษาที่ไม่ใช้การได้หรือไม่?

แก้ไข: ฉันรู้ว่าตัวอย่างที่ฉันใช้นั้นไม่ได้ดีที่สุดในการอธิบายคำถามของฉัน ฉันแค่ต้องการชี้ให้เห็นว่า Haskell (และภาษาที่ใช้งานได้ทั่วไป) ใช้การเรียกซ้ำบ่อยกว่าภาษาที่ไม่สามารถใช้งานได้


10
ตรงประเด็น: ภาษาที่ใช้งานได้หลายอย่างใช้ประโยชน์จากการเพิ่มประสิทธิภาพการโทรหางอย่างหนักในขณะที่ภาษาเชิงปฏิบัติน้อยมากที่ทำเช่นนั้น ซึ่งหมายความว่าการเรียกซ้ำหมายเลขโทรหางนั้นมีราคาถูกกว่ามากในภาษาที่ใช้งานได้เหล่านั้น
Joachim Sauer

7
อันที่จริงคำจำกัดความของ Haskell ที่คุณให้นั้นแย่มาก factorial n = product [1..n]รวบรัดมากขึ้นมีประสิทธิภาพมากขึ้นและไม่ล้นสแต็กสำหรับขนาดใหญ่n(และถ้าคุณต้องการบันทึกช่วยจำต้องใช้ตัวเลือกที่แตกต่างกันโดยสิ้นเชิง) productถูกกำหนดในแง่ของบางอย่างfoldซึ่งถูกกำหนดแบบเรียกซ้ำ แต่ด้วยความระมัดระวัง การเรียกซ้ำเป็นวิธีแก้ปัญหาที่ยอมรับได้ส่วนใหญ่ แต่ก็ยังง่ายที่จะทำผิด / ไม่เหมาะสม

1
@JoachimSauer - ด้วยการจัดแต่งเล็กน้อยความคิดเห็นของคุณจะให้คำตอบที่คุ้มค่า
Mark Booth

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

@delnan: ขอบคุณสำหรับการชี้แจง! ฉันจะแก้ไขการแก้ไขของฉัน;)
marco-fiset

คำตอบ:


36

ใช่พวกเขาทำ แต่ไม่ได้เพียงเพราะพวกเขาสามารถแต่เพราะพวกเขาต้อง

แนวคิดหลักที่นี่คือความบริสุทธิ์ : ฟังก์ชั่นที่บริสุทธิ์คือฟังก์ชั่นที่ไม่มีผลข้างเคียงและไม่มีรัฐ ภาษาโปรแกรมที่ใช้งานได้นั้นมีความบริสุทธิ์ด้วยเหตุผลหลายประการเช่นการให้เหตุผลเกี่ยวกับโค้ดและการหลีกเลี่ยงการพึ่งพาที่ไม่ชัดเจน บางภาษาที่สะดุดตาที่สุด Haskell แม้กระทั่งเพื่อให้ได้รหัสบริสุทธิ์เท่านั้น ผลข้างเคียงใด ๆ ที่โปรแกรมอาจมี (เช่นการแสดง I / O) ถูกย้ายไปยังรันไทม์ที่ไม่ใช้ความบริสุทธิ์ทำให้ภาษานั้นบริสุทธิ์

การไม่มีผลข้างเคียงหมายความว่าคุณไม่สามารถมีตัวนับลูป (เนื่องจากตัวนับลูปจะประกอบด้วยสถานะที่ไม่แน่นอนและการแก้ไขสถานะดังกล่าวจะเป็นผลข้างเคียง) ดังนั้นการทำซ้ำภาษาที่ใช้งานได้จริงที่สุดคือการย้ำผ่านรายการ ( โดยทั่วไปการดำเนินการนี้เรียกว่าforeachหรือmap) อย่างไรก็ตามการเรียกซ้ำคือการจับคู่ตามธรรมชาติกับการเขียนโปรแกรมการทำงานอย่างแท้จริง - ไม่จำเป็นต้องมีการเรียกคืนสถานะยกเว้นการขัดแย้งกับฟังก์ชั่น (อ่านอย่างเดียว) และค่าส่งคืน (เขียนอย่างเดียว)

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


2
สำหรับบันทึกการกำจัดหางแบบโทรไม่ได้ขึ้นอยู่กับความบริสุทธิ์
scarfridge

2
@ scarfridge: แน่นอนมันไม่ได้ อย่างไรก็ตามเมื่อมีการกำหนดความบริสุทธิ์คอมไพเลอร์จะเรียงลำดับรหัสของคุณใหม่เพื่อให้สามารถเรียก tail ได้ง่ายขึ้น
tdammers

GCC ทำงานของ TCO ได้ดีกว่า GHC เนื่องจากคุณไม่สามารถทำ TCO ข้ามการสร้าง thunk ได้
dan_waterworth

18

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

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

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


ฉันรู้แล้วว่าการจับคู่รูปแบบเกี่ยวข้องกับอะไรและฉันคิดว่ามันเป็นคุณสมบัติที่ยอดเยี่ยมใน Haskell ที่ฉันพลาดในภาษาที่ฉันใช้!
marco-fiset

@marcof จุดของฉันคือการพูดคุยทั้งหมดเกี่ยวกับการเรียกซ้ำและการวนซ้ำไม่ได้พูดถึงความเรียบง่ายของตัวอย่างโค้ดของคุณ มันเกี่ยวกับการจับคู่รูปแบบกับเงื่อนไข บางทีฉันควรตอบคำถามนี้ให้ดี
chrisaycock

ใช่ฉันเข้าใจเช่นกัน: P
marco-fiset

@ chrisaycock: เป็นไปได้ไหมที่จะเห็นการวนซ้ำเป็นหางเรียกซ้ำซึ่งตัวแปรทั้งหมดที่ใช้ในลูปบอดี้เป็นทั้งอาร์กิวเมนต์และค่าส่งคืนของการเรียกซ้ำ
จอร์โจ

@Giorgio: ใช่ทำให้ฟังก์ชั่นของคุณใช้และส่งกลับ tuple ประเภทเดียวกัน
Ericson2314

5

ไม่มีทางเทคนิค แต่ใช่จริง

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

  1. การเพิ่มประสิทธิภาพการโทรหาง ตามที่ผู้โพสต์คนอื่นชี้ให้เห็นภาษาการใช้งานมักจะต้องใช้ TCO

  2. การประเมินผลขี้เกียจ Haskell (และภาษาอื่น ๆ สองสามภาษา) ได้รับการประเมินอย่างขี้เกียจ สิ่งนี้จะชะลอการ 'ทำงาน' ของวิธีการจริงจนกว่าจะมีความจำเป็น สิ่งนี้มีแนวโน้มที่จะนำไปสู่โครงสร้างข้อมูลแบบเรียกซ้ำและโดยการขยายวิธีแบบเรียกซ้ำเพื่อทำงานกับมัน

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

ไม่มีสิ่งเหล่านี้มีความเฉพาะเจาะจงกับภาษาที่ใช้งานกับคนอื่น ๆ ดังนั้นพวกเขาจึงไม่ได้ดีขึ้นเพราะพวกเขาทำงาน แต่เนื่องจากมันใช้งานได้การตัดสินใจในการออกแบบจึงมีแนวโน้มที่จะนำไปใช้กับคุณสมบัติเหล่านี้เพราะมันมีประโยชน์มากกว่า


1
Re: 1. ผลตอบแทนก่อนไม่มีอะไรเกี่ยวข้องกับการเรียกหาง คุณสามารถกลับมาก่อนได้ด้วยการโทรแบบหางและให้ผลตอบแทนแบบ "ล่าช้า" ยังมีการโทรแบบหางและคุณสามารถมีการแสดงออกอย่างง่าย ๆ เพียงครั้งเดียวด้วยการโทรแบบเรียกซ้ำไม่ได้อยู่ในตำแหน่งท้าย

@delnan: ขอบคุณ; มันเร็วและมันก็ค่อนข้างนานแล้วตั้งแต่ฉันได้ศึกษาเรื่องนี้
Telastyn

1

Haskell และภาษาที่ใช้งานอื่น ๆ มักใช้การประเมินผลแบบขี้เกียจ คุณสมบัตินี้ช่วยให้คุณสามารถเขียนฟังก์ชันเรียกซ้ำแบบไม่สิ้นสุด

หากคุณเขียนฟังก์ชันแบบเรียกซ้ำโดยไม่ต้องกำหนดตัวพิมพ์ใหญ่ที่การเรียกซ้ำสิ้นสุดลงคุณจะต้องจบลงด้วยการเรียกใช้ฟังก์ชันนั้นและสแต็คโอเวอร์โฟลว์

Haskell ยังรองรับการปรับการเรียกฟังก์ชันแบบเรียกซ้ำ ใน Java การเรียกใช้ฟังก์ชันแต่ละครั้งจะซ้อนกันและทำให้โอเวอร์เฮด

ดังนั้นใช่ภาษาที่ใช้งานได้จะจัดการการเรียกซ้ำได้ดีกว่าภาษาอื่น


5
Haskell เป็นหนึ่งในภาษาที่ไม่เคร่งครัดน้อยมาก - ตระกูล ML ทั้งหมด (นอกเหนือจากงานวิจัยที่เพิ่มความขี้เกียจ), Lisps ยอดนิยม, Erlang และอื่น ๆ ล้วนเข้มงวด นอกจากนี้ยังย่อหน้าที่สองดูเหมือนปิด - เป็นคุณได้อย่างถูกต้องระบุในวรรคแรก, ความเกียจคร้านไม่ช่วยให้การเรียกซ้ำอนันต์ (โหมโรง Haskell มีประโยชน์อย่างมากforever a = a >> forever aตัวอย่างเช่น)

@deinan: เท่าที่ฉันรู้ SML / NJ ยังมีการประเมินผลที่ขี้เกียจ แต่มันก็เป็นส่วนเสริมของ SML ฉันต้องการตั้งชื่อภาษาที่ขี้เกียจสองสามภาษาด้วยกัน: Miranda และ Clean
จอร์โจ

1

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

โปรดทราบว่าการเพิ่มประสิทธิภาพนี้ไม่สามารถใช้งานได้กับการโทรแบบเรียกซ้ำใด ๆ มีเพียงวิธีการเรียกซ้ำแบบเรียกซ้ำ (เช่นวิธีการที่ไม่รักษาสถานะไว้ในขณะที่มีการโทรซ้ำ)


1
(1) การปรับให้เหมาะสมดังกล่าวใช้เฉพาะในกรณีที่เฉพาะเจาะจงมาก - ตัวอย่างของ OP ไม่ใช่ของพวกเขาและฟังก์ชั่นอื่น ๆ ที่ตรงไปตรงมาจำเป็นต้องได้รับการดูแลเป็นพิเศษเพื่อให้เกิดการเรียกซ้ำ (2) การเพิ่มประสิทธิภาพการโทรแบบเรียลไทม์ไม่เพียงเพิ่มประสิทธิภาพฟังก์ชั่นแบบเรียกซ้ำเท่านั้น แต่จะลบค่าใช้จ่ายในพื้นที่ออกจากการโทรใด ๆที่ตามด้วยการส่งคืนทันที

@delnan: (1) ใช่จริงมาก ใน 'ฉบับร่างดั้งเดิม' ของคำตอบนี้ฉันได้กล่าวถึงว่า :( (2) ใช่ แต่ภายในบริบทของคำถามฉันคิดว่าจะไม่เกี่ยวข้องกับการพูดถึง
Steven Evers

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

1

คุณจะต้องดูว่าGarbage Collection นั้นเร็ว แต่ Stack ก็เร็วกว่ากระดาษเกี่ยวกับการใช้สิ่งที่โปรแกรมเมอร์ C คิดว่าเป็น "heap" สำหรับ stack frames ใน compiled C. ฉันเชื่อว่าผู้เขียน tinkered กับ Gcc ทำเช่นนั้น . ไม่ใช่คำตอบที่ชัดเจน แต่อาจช่วยให้คุณเข้าใจปัญหาบางอย่างกับการเรียกซ้ำ

การเขียนโปรแกรมภาษา Alefซึ่งเคยที่จะมาพร้อมกับแผน 9 จากเบลล์แล็บมี "กลายเป็น" คำสั่ง (ดูหัวข้อ 6.6.4 ของเอกสารอ้างอิงนี้ ) เป็นประเภทของการเพิ่มประสิทธิภาพการเรียกซ้ำแบบ tail-call อย่างชัดเจน "แต่มันใช้สายการโทรขึ้น!" การโต้แย้งการเรียกซ้ำอาจเกิดขึ้นได้ด้วย


0

TL: DR: ใช่พวกเขา
เรียกใช้ซ้ำเป็นเครื่องมือสำคัญในการเขียนโปรแกรมการทำงานและดังนั้นจึงมีงานจำนวนมากในการเพิ่มประสิทธิภาพการโทรเหล่านี้ ตัวอย่างเช่น R5RS ต้องการ (ในข้อมูลจำเพาะ!) ว่าการใช้งานทั้งหมดจัดการการเรียกซ้ำแบบหางที่ไม่ถูกผูกไว้โดยที่โปรแกรมเมอร์ไม่ต้องกังวลเกี่ยวกับการล้นสแต็ก สำหรับการเปรียบเทียบโดยค่าเริ่มต้นคอมไพเลอร์ C จะไม่ทำแม้กระทั่งการเพิ่มประสิทธิภาพ tail-call ชัดเจน (ลองย้อนกลับแบบเรียกซ้ำของรายการที่เชื่อมโยง) และหลังจากการโทรบางครั้งโปรแกรมจะยุติลง (คอมไพเลอร์จะปรับให้เหมาะสม O2)

แน่นอนว่าในโปรแกรมที่เขียนขึ้นอย่างน่ากลัวเช่นfibตัวอย่างที่โด่งดังซึ่งเป็นเลขชี้กำลังผู้แปลมีตัวเลือกเล็กน้อยที่ไม่มีตัวเลือกในการทำ 'วิเศษ' ของมัน ดังนั้นจึงควรระมัดระวังไม่ให้ขัดขวางความพยายามรวบรวมในการเพิ่มประสิทธิภาพ

แก้ไข: จากตัวอย่างปดฉันหมายถึงต่อไปนี้:

(define (fib n)
 (if (< n 3) 1 
  (+ (fib (- n 1)) (fib (- n 2)))
 )
)

0

ภาษาที่ใช้งานได้ดีกว่าในการเรียกซ้ำสองชนิดที่เจาะจงมาก: การเรียกซ้ำแบบหางและการเรียกซ้ำแบบไม่สิ้นสุด พวกเขาไม่ดีเท่ากับภาษาอื่น ๆ ในการเรียกซ้ำแบบอื่นเช่นfactorialตัวอย่าง ของคุณ

ไม่ได้หมายความว่าไม่มีอัลกอริทึมที่ทำงานได้ดีกับการเรียกซ้ำแบบปกติในกระบวนทัศน์ทั้งสอง ตัวอย่างเช่นสิ่งใดก็ตามที่ต้องการโครงสร้างข้อมูลแบบกองซ้อนเช่นการค้นหาต้นไม้เชิงลึกเป็นวิธีที่ง่ายที่สุดในการนำไปใช้กับการเรียกซ้ำ

การเรียกซ้ำเกิดขึ้นบ่อยกว่าด้วยฟังก์ชันการเขียนโปรแกรม แต่มันก็เป็นวิธีที่ใช้มากเกินไปโดยเฉพาะอย่างยิ่งโดยผู้เริ่มต้นหรือแบบฝึกหัดสำหรับผู้เริ่มต้นบางทีอาจเป็นเพราะผู้เริ่มต้นส่วนใหญ่ถึงการเขียนโปรแกรมเชิงปฏิบัติการได้ใช้การเรียกซ้ำก่อนหน้านี้ มีการเขียนโปรแกรมการทำงานอื่น ๆ เช่น list comprehensions, ฟังก์ชันลำดับสูงและการดำเนินการอื่น ๆ บนคอลเลกชันซึ่งโดยปกติจะเหมาะสมกว่า conceptually สำหรับสไตล์สำหรับความรัดกุมประสิทธิภาพและความสามารถในการเพิ่มประสิทธิภาพ

ตัวอย่างเช่นคำแนะนำของ delnan factorial n = product [1..n]ไม่เพียง แต่กระชับและง่ายต่อการอ่านเท่านั้น เช่นเดียวกับการใช้foldหรือreduceถ้าภาษาของคุณไม่ได้มีproductอยู่แล้วภายในการเรียกซ้ำเป็นวิธีการแก้ปัญหาสุดท้ายของปัญหานี้ เหตุผลหลักที่คุณเห็นว่ามันแก้ไขซ้ำแล้วซ้ำอีกในแบบฝึกหัดคือเป็นจุดกระโดดก่อนที่จะได้รับการแก้ปัญหาที่ดีกว่าไม่ใช่ตัวอย่างของการปฏิบัติที่ดีที่สุด

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.