ในทางปฏิบัติฉันเข้าใจว่าการเรียกซ้ำใด ๆ สามารถเขียนเป็นลูป (และในทางกลับกัน (?)) และหากเราวัดด้วยคอมพิวเตอร์จริงเราพบว่าลูปนั้นเร็วกว่าการเรียกซ้ำสำหรับปัญหาเดียวกัน แต่มีทฤษฎีอะไรที่ทำให้เกิดความแตกต่างนี้
ในทางปฏิบัติฉันเข้าใจว่าการเรียกซ้ำใด ๆ สามารถเขียนเป็นลูป (และในทางกลับกัน (?)) และหากเราวัดด้วยคอมพิวเตอร์จริงเราพบว่าลูปนั้นเร็วกว่าการเรียกซ้ำสำหรับปัญหาเดียวกัน แต่มีทฤษฎีอะไรที่ทำให้เกิดความแตกต่างนี้
คำตอบ:
เหตุผลที่ลูปเร็วกว่าการเรียกซ้ำเป็นเรื่องง่าย
การวนซ้ำมีลักษณะเช่นนี้ในชุดประกอบ
mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork
การกระโดดตามเงื่อนไขเดียวและการบันทึกบัญชีสำหรับตัวนับลูป
การเรียกซ้ำ (เมื่อไม่ได้หรือไม่สามารถปรับให้เหมาะสมโดยคอมไพเลอร์) มีลักษณะดังนี้:
start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret
มันมีความซับซ้อนมากขึ้นและคุณจะได้รับการกระโดดอย่างน้อย 3 ครั้ง (1 การทดสอบเพื่อดูว่าทำเสร็จแล้วโทรหนึ่งครั้งและส่งคืนหนึ่งครั้ง)
นอกจากนี้ในการเรียกซ้ำพารามิเตอร์จะต้องมีการตั้งค่าและดึงข้อมูล
ไม่มีสิ่งใดที่ต้องการในการวนซ้ำเนื่องจากพารามิเตอร์ทั้งหมดได้รับการตั้งค่าแล้ว
ในทางทฤษฎีพารามิเตอร์สามารถอยู่ในตำแหน่งที่มีการเรียกซ้ำด้วยเช่นกัน แต่ไม่มีคอมไพเลอร์ที่ฉันรู้ว่าจริง ๆ แล้วมันไปไกลในการเพิ่มประสิทธิภาพของพวกเขา
ความแตกต่างระหว่างการโทรและ jmp
คู่การโทรกลับไม่ได้แพงกว่า jmp มากนัก ทั้งคู่ใช้เวลา 2 รอบและ jmp ใช้เวลา 1 สังเกตเห็นได้ยาก
ในการเรียกร้องการประชุมว่าการสนับสนุนค่าใช้จ่ายในการลงทะเบียนพารามิเตอร์สำหรับพารามิเตอร์ในน้อยที่สุด แต่แม้พารามิเตอร์สแต็คที่มีราคาถูกตราบเท่าที่บัฟเฟอร์ของ CPU ไม่ล้น
เป็นค่าใช้จ่ายในการตั้งค่าการโทรที่กำหนดโดยการเรียกการประชุมและการจัดการพารามิเตอร์ที่ใช้งานซึ่งจะทำให้การเรียกซ้ำช้าลง
นี่ขึ้นอยู่กับการใช้งานเป็นอย่างมาก
ตัวอย่างของการจัดการการเรียกซ้ำที่ไม่ดี
ตัวอย่างเช่นหากพารามิเตอร์ถูกส่งผ่านที่นับการอ้างอิง (เช่นพารามิเตอร์ประเภทที่ไม่ได้จัดการ const) มันจะเพิ่ม 100 รอบที่ทำการปรับการล็อกการนับการอ้างอิงทั้งหมดฆ่าประสิทธิภาพเทียบกับลูป
ในภาษาที่ได้รับการปรับเพื่อเรียกซ้ำพฤติกรรมที่ไม่ดีนี้จะไม่เกิดขึ้น
การปรับให้เหมาะสมของ CPU
อีกเหตุผลที่การเรียกซ้ำช้ากว่านั้นคือทำงานได้กับกลไกการปรับให้เหมาะสมใน CPU
สามารถทำนายผลตอบแทนได้อย่างถูกต้องหากมีจำนวนไม่มากในแถว CPU มีบัฟเฟอร์ส่งคืนสแต็กที่มีรายการไม่กี่รายการ เมื่อสิ่งที่หมดไปทุกครั้งที่ส่งคืนจะถูกเข้าใจผิดทำให้เกิดความล่าช้ามาก
บน CPU ใด ๆ ที่ใช้การเรียกซ้ำบัฟเฟอร์การเรียกคืนตามสแต็กที่เกินขนาดบัฟเฟอร์จะหลีกเลี่ยงได้ดีที่สุด
เกี่ยวกับตัวอย่างโค้ดเล็กน้อยโดยใช้การเรียกซ้ำ
หากคุณใช้ตัวอย่างการเรียกซ้ำอย่างการสร้างหมายเลขฟีโบนักชีผลกระทบเหล่านี้จะไม่เกิดขึ้นเพราะคอมไพเลอร์ใด ๆ ที่ 'รู้' เกี่ยวกับการเรียกซ้ำจะเปลี่ยนเป็นลูปเหมือนโปรแกรมเมอร์ใด ๆ หากว่า
หากคุณเรียกใช้ตัวอย่างเล็ก ๆ น้อย ๆ เหล่านี้ในสภาพแวดล้อมที่ไม่ได้รับการปรับให้เหมาะสมอย่างเหมาะสมเกินกว่าที่ call stack จะ (โดยไม่จำเป็น) จะเติบโตเกินขอบเขต
เกี่ยวกับการเรียกซ้ำแบบหาง
โปรดทราบว่าบางครั้งคอมไพเลอร์จะปรับการเรียกซ้ำแบบหางให้เหมาะสมโดยเปลี่ยนเป็นลูป เป็นการดีที่สุดที่จะพึ่งพาพฤติกรรมนี้ในภาษาที่มีประวัติที่เป็นที่รู้จักในเรื่องนี้
มีหลายภาษาที่แทรกโค้ดการล้างข้อมูลที่ซ่อนอยู่ก่อนที่จะส่งคืนสุดท้ายเพื่อป้องกันการปรับหางซ้ำ
ความสับสนระหว่างการเรียกซ้ำแบบจริงและแบบหลอก
หากสภาพแวดล้อมการเขียนโปรแกรมของคุณเปลี่ยนซอร์สโค้ดแบบวนซ้ำของคุณให้เป็นการวนซ้ำ
การเรียกซ้ำที่แท้จริงจำเป็นต้องมีการจัดเก็บ breadcrumbs เพื่อให้รูทีนการเรียกซ้ำสามารถติดตามขั้นตอนหลังจากที่มันออก
มันคือการจัดการเส้นทางนี้ที่ทำให้การเรียกซ้ำช้ากว่าการใช้ลูป เอฟเฟกต์นี้ขยายโดยการใช้งาน CPU ในปัจจุบันตามที่อธิบายไว้ข้างต้น
ผลกระทบของสภาพแวดล้อมในการเขียนโปรแกรม
หากภาษาของคุณปรับไปสู่การปรับให้เหมาะสมที่สุดในการเรียกซ้ำแล้วโดยทั้งหมดไปข้างหน้าและใช้การเรียกซ้ำในทุกโอกาส ในกรณีส่วนใหญ่ภาษาจะเปลี่ยนการสอบถามซ้ำเป็นวงวน
ในกรณีที่ไม่สามารถทำได้โปรแกรมเมอร์จะถูกกดอย่างหนักเช่นกัน หากภาษาการเขียนโปรแกรมของคุณไม่ได้รับการปรับไปสู่การเรียกซ้ำคุณควรหลีกเลี่ยงการยกเว้นกรณีที่โดเมนนั้นเหมาะสำหรับการเรียกซ้ำ
น่าเสียดายที่หลายภาษาไม่จัดการการเรียกซ้ำได้เป็นอย่างดี
การใช้การเรียกซ้ำในทางที่ผิด
ไม่จำเป็นต้องคำนวณลำดับฟีโบนักชีโดยใช้การเรียกซ้ำในความเป็นจริงมันเป็นตัวอย่างทางพยาธิวิทยา
การเรียกซ้ำใช้ดีที่สุดในภาษาที่สนับสนุนอย่างชัดเจนหรือในโดเมนที่การเรียกซ้ำเกิดขึ้นเช่นการจัดการข้อมูลที่จัดเก็บในทรี
ฉันเข้าใจว่าการเรียกซ้ำใด ๆ สามารถเขียนเป็นลูปได้
ใช่ถ้าคุณยินดีที่จะนำรถเข็นมาก่อนม้า
อินสแตนซ์ทั้งหมดของการเรียกซ้ำสามารถเขียนเป็นวนรอบบางอินสแตนซ์เหล่านั้นต้องการให้คุณใช้กองซ้อนอย่างชัดเจนเช่นที่เก็บข้อมูล
หากคุณต้องการม้วนสแต็กของคุณเองเพียงเพื่อเปลี่ยนรหัสเวียนเป็นวนซ้ำคุณอาจใช้การเรียกซ้ำแบบธรรมดา
ยกเว้นกรณีที่คุณมีความต้องการพิเศษเช่นการใช้ตัวแจงนับในโครงสร้างแบบต้นไม้และคุณไม่มีการสนับสนุนด้านภาษาที่เหมาะสม
คำตอบอื่น ๆ เหล่านี้ค่อนข้างทำให้เข้าใจผิด ฉันยอมรับว่าพวกเขาระบุรายละเอียดการปฏิบัติที่สามารถอธิบายความแตกต่างนี้ แต่พวกเขาคุยโวเรื่องนี้มากเกินไป เป็นข้อเสนอแนะอย่างถูกต้องโดย jmite พวกเขามีการดำเนินงานที่มุ่งเน้นไปยังเสียการใช้งานฟังก์ชั่นของสาย / recursion หลายภาษาใช้ลูปผ่านการเรียกซ้ำดังนั้นลูปจะไม่ชัดเจนในภาษาเหล่านั้น การเรียกซ้ำไม่มีประสิทธิภาพน้อยกว่าการวนซ้ำ (เมื่อใช้ทั้งคู่) ในทางทฤษฎี ให้ฉันพูดบทคัดย่อเพื่อกระดาษ 1977 Guy Steele Debunking "การเรียกขั้นตอนแพง" ตำนานหรือการดำเนินการตามขั้นตอนถือว่าเป็นอันตรายหรือแลมบ์ดา: GOTO ที่สุด
ชาวบ้านกล่าวว่างบ GOTO เป็น "ถูก" ในขณะที่การเรียกขั้นตอนเป็น "แพง" ตำนานนี้ส่วนใหญ่เป็นผลมาจากการใช้งานภาษาที่ออกแบบมาไม่ดี การเติบโตทางประวัติศาสตร์ของตำนานนี้ได้รับการพิจารณา ทั้งแนวคิดทางทฤษฎีและการนำไปปฏิบัติที่มีอยู่ถูกกล่าวถึงซึ่งทำให้ debunk ตำนานนี้ แสดงให้เห็นว่าการใช้การเรียกใช้โปรซีเจอร์แบบไม่ จำกัด ช่วยให้มีอิสระในการออกแบบโวหาร โดยเฉพาะอย่างยิ่งผังงานใด ๆ ที่สามารถเขียนเป็นโปรแกรม "โครงสร้าง" โดยไม่ต้องแนะนำตัวแปรเพิ่มเติม ความยากลำบากในการใช้คำสั่ง GOTO และการเรียกโพรซีเดอร์นั้นเป็นลักษณะที่ขัดแย้งกันระหว่างแนวคิดการเขียนโปรแกรมเชิงนามธรรมและการสร้างภาษาที่เป็นรูปธรรม
"ความขัดแย้งระหว่างแนวคิดการเขียนโปรแกรมนามธรรมและภาษาโครงสร้างคอนกรีต" จะเห็นได้จากความจริงที่ว่าแบบจำลองทางทฤษฎีส่วนใหญ่เช่นที่ untyped แคลคูลัสแลมบ์ดา , ไม่ได้มีสแต็ค แน่นอนความขัดแย้งนี้ไม่จำเป็นตามที่อธิบายไว้ข้างต้นและยังแสดงให้เห็นโดยภาษาที่ไม่มีกลไกการทำซ้ำนอกเหนือจากการเรียกซ้ำเช่น Haskell
ตอนนี้เป็นตัวอย่าง กำหนดfact
เป็น
fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1
นี่คือการประเมินผลของfact 3
ที่ไหนแน่นฉันจะใช้g
เป็นคำพ้องสำหรับเช่นfix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1))
fact = g 1
สิ่งนี้ไม่มีผลต่อการโต้แย้งของฉัน
fact 3
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6
คุณสามารถดูได้จากรูปร่างโดยไม่ต้องดูรายละเอียดว่าไม่มีการเติบโตและการทำซ้ำแต่ละครั้งต้องใช้พื้นที่จำนวนเท่ากัน (ในทางเทคนิคแล้วผลลัพธ์ที่เป็นตัวเลขจะเพิ่มขึ้นซึ่งไม่สามารถหลีกเลี่ยงได้และเป็นจริงสำหรับwhile
ลูป) ฉันขอท้าให้คุณชี้ให้เห็น "สแต็ค" ที่เติบโตอย่างไม่มีขอบเขต
ดูเหมือนว่าซีแมนทิกส์ตามแบบฉบับของแลมบ์ดาแคลคูลัสได้ทำสิ่งที่เรียกกันทั่วไปว่า แน่นอนไม่มี "การเพิ่มประสิทธิภาพ" เกิดขึ้นที่นี่ ไม่มีกฎพิเศษสำหรับการโทรแบบ "tail" ซึ่งตรงข้ามกับการโทรแบบ "ปกติ" ด้วยเหตุนี้จึงเป็นเรื่องยากที่จะให้คุณลักษณะ "นามธรรม" ของการเพิ่มประสิทธิภาพหางเรียกว่า "การเพิ่มประสิทธิภาพ" ทำเช่นเดียวกับในลักษณะนามธรรมจำนวนมากของอรรถศาสตร์การเรียกใช้ฟังก์ชันไม่มีอะไรสำหรับการเพิ่มประสิทธิภาพหางเรียกว่า "การเพิ่มประสิทธิภาพ"
คำจำกัดความที่คล้ายคลึงกันfact
ในหลายภาษา "overflows สแต็ค" เป็นความล้มเหลวโดยภาษาเหล่านั้นในการใช้ความหมายการเรียกใช้ฟังก์ชันอย่างถูกต้อง (บางภาษามีข้อแก้ตัว) สถานการณ์นั้นคล้ายคลึงกับการใช้ภาษาที่ใช้อาร์เรย์กับรายการที่เชื่อมโยง การทำดัชนีลงใน "อาร์เรย์" นั้นจะเป็นการดำเนินการ O (n) ซึ่งไม่เป็นไปตามความคาดหวังของอาร์เรย์ ถ้าฉันใช้ภาษาที่แยกต่างหากซึ่งใช้อาร์เรย์จริงแทนที่จะเป็นรายการที่เชื่อมโยงคุณจะไม่พูดว่าฉันได้ใช้ "การเพิ่มประสิทธิภาพการเข้าถึงอาร์เรย์" คุณจะบอกว่าฉันได้แก้ไขการใช้งานอาร์เรย์ที่ไม่สมบูรณ์
ดังนั้นการตอบสนองต่อคำตอบของ Veedrac สแต็คเป็นไม่ได้ "พื้นฐาน" เพื่อเรียกซ้ำ เท่าที่พฤติกรรม "เหมือนกอง" เกิดขึ้นในระหว่างการประเมินผลสิ่งนี้สามารถเกิดขึ้นได้ในกรณีที่ลูป (ไม่มีโครงสร้างข้อมูลเสริม) จะไม่สามารถใช้ได้ตั้งแต่แรก! เพื่อให้เป็นอีกวิธีหนึ่งฉันสามารถใช้ลูปกับการเรียกซ้ำด้วยลักษณะการทำงานที่เหมือนกันทุกประการ แน่นอนทั้ง Scheme และ SML ประกอบด้วยโครงสร้างวนลูป แต่ทั้งคู่กำหนดนิยามในรูปแบบของการเรียกซ้ำ (และอย่างน้อยใน Scheme do
มักจะถูกนำมาใช้เป็นมาโครที่ขยายเป็นการเรียกซ้ำ) ในทำนองเดียวกันสำหรับคำตอบของ Johan คอมไพเลอร์ต้องปล่อยแอสเซมบลีโจฮานที่อธิบายไว้สำหรับการเรียกซ้ำ อันที่จริงชุดประกอบเดียวกันทุกประการไม่ว่าคุณจะใช้ลูปหรือเรียกซ้ำ มีเพียงครั้งเดียวที่คอมไพเลอร์จะ ( ข้อผูกมัดปล่อยออกมา) เพื่อปล่อยแอสเซมบลีอย่างที่ Johan อธิบายคือเมื่อคุณกำลังทำบางสิ่งที่ไม่สามารถแสดงได้โดยลูป ตามที่ระบุไว้ในกระดาษของสตีลและแสดงให้เห็นโดยการปฏิบัติจริงของภาษาเช่น Haskell, โครงการ SML และก็ไม่ได้เป็น "หายาก" ว่าสายหางสามารถ "ที่ดีที่สุด" ที่พวกเขาสามารถเสมอเป็น "ปรับ" การเรียกใช้การเรียกซ้ำโดยเฉพาะจะทำงานในพื้นที่คงที่หรือไม่นั้นขึ้นอยู่กับวิธีการเขียน แต่ข้อ จำกัด ที่คุณต้องใช้เพื่อให้เป็นไปได้นั้นเป็นข้อ จำกัด ที่คุณต้องการเพื่อให้พอดีกับปัญหาของคุณในรูปแบบของลูป (ที่จริงพวกเขามีความเข้มงวดน้อย. มีปัญหาเช่นการเข้ารหัสเครื่องของรัฐที่มีมากขึ้นอย่างเรียบร้อยและมีประสิทธิภาพการจัดการผ่านทางหางสายเมื่อเทียบกับลูปซึ่งจะต้องมีตัวแปรเสริมมี.) อีกครั้งเรียกซ้ำครั้งเดียวที่ต้องทำมากขึ้นการทำงาน เมื่อรหัสของคุณไม่วนซ้ำอยู่ดี
ฉันเดาว่า Johan หมายถึงคอมไพเลอร์ C ที่มีข้อ จำกัด โดยพลการเมื่อมันจะทำการ tail tail "optimization" Johan ยังกล่าวถึงภาษาเช่น C ++ และ Rust เมื่อเขาพูดถึง "ภาษาที่มีประเภทที่มีการจัดการ" RAIIสำนวนจาก C ++ และปัจจุบันใน Rust เช่นกันทำให้สิ่งที่มีลักษณะเช่นเผินโทรหางไม่สายหาง (เพราะ "destructors" ยังคงต้องเรียกว่า) มีข้อเสนอที่จะใช้ไวยากรณ์ที่แตกต่างเพื่อเลือกในความหมายที่แตกต่างกันเล็กน้อยที่จะอนุญาตให้เรียกซ้ำหาง (เช่น destructors การโทรก่อนการเรียกหางสุดท้ายและไม่อนุญาตให้เข้าถึงวัตถุ "ถูกทำลาย" อย่างชัดเจน (การรวบรวมขยะไม่มีปัญหาดังกล่าวและ Haskell, SML และ Scheme ทั้งหมดเป็นภาษาที่รวบรวมขยะ) ในหลอดเลือดดำที่แตกต่างกันมากบางภาษาเช่น Smalltalk เปิดเผย "สแต็ก" เป็นวัตถุชั้นหนึ่งในสิ่งเหล่านี้ ในกรณีที่ "สแต็ค" นั้นไม่ได้มีรายละเอียดการใช้งานอีกต่อไปแม้ว่าจะไม่ได้แยกการเรียกสายที่มีความหมายต่างกัน (Java กล่าวว่าไม่สามารถทำได้เนื่องจากวิธีการจัดการด้านความปลอดภัย แต่นี่เป็นเรื่องจริงเท็จ )
ในทางปฏิบัติความชุกของการใช้งานที่ไม่สมบูรณ์ของการเรียกใช้ฟังก์ชันมาจากปัจจัยหลักสามประการ ขั้นแรกให้หลายภาษาสืบทอดการใช้งานที่เสียหายจากภาษาการใช้งาน (โดยปกติคือ C) ประการที่สองการจัดการทรัพยากรที่กำหนดขึ้นเป็นสิ่งที่ดีและทำให้ปัญหาซับซ้อนขึ้นแม้ว่าจะมีเพียงไม่กี่ภาษาเท่านั้นที่เสนอสิ่งนี้ ประการที่สามและจากประสบการณ์ของฉันเหตุผลที่คนส่วนใหญ่สนใจคือพวกเขาต้องการร่องรอยสแต็กเมื่อเกิดข้อผิดพลาดสำหรับการแก้ไขจุดบกพร่อง มีเพียงเหตุผลที่สองเท่านั้นที่สามารถจูงใจในทางทฤษฎี
ความแตกต่างที่สำคัญคือการเรียกซ้ำรวมถึงสแต็กโครงสร้างข้อมูลเสริมที่คุณอาจไม่ต้องการในขณะที่ลูปไม่ทำเช่นนั้นโดยอัตโนมัติ เฉพาะในบางกรณีเท่านั้นที่คอมไพเลอร์ทั่วไปสามารถสรุปได้ว่าคุณไม่ต้องการสแต็คจริง ๆ
หากคุณเปรียบเทียบการวนซ้ำแทนการทำงานด้วยตนเองบนสแต็กที่จัดสรร (เช่นผ่านตัวชี้ไปยังหน่วยความจำฮีป) โดยปกติคุณจะพบว่าไม่เร็วกว่าหรือช้ากว่าการใช้สแต็กของฮาร์ดแวร์