เมื่อไม่มี TCO เมื่อใดต้องกังวลเกี่ยวกับการสแต็คกอง?


14

ทุกครั้งที่มีการอภิปรายเกี่ยวกับภาษาการเขียนโปรแกรมใหม่ที่กำหนดเป้าหมาย JVM ย่อมมีคนพูดถึงสิ่งต่าง ๆ เช่น:

"JVM ไม่รองรับการปรับให้เหมาะสมแบบหางเรียกดังนั้นฉันทำนายสแต็กการระเบิดจำนวนมาก"

ชุดรูปแบบนั้นมีการเปลี่ยนแปลงหลายพันรายการ

ตอนนี้ฉันรู้ว่าบางภาษาเช่น Clojure มีโครงสร้างการเกิดขึ้นเป็นพิเศษที่คุณสามารถใช้

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

แหล่งที่มาหลักของความสับสนอาจมาจากความจริงที่ว่า Java เป็นหนึ่งในภาษาที่ประสบความสำเร็จมากที่สุดและภาษา JVM บางส่วนดูเหมือนจะทำได้ค่อนข้างดี วิธีการที่เป็นไปได้ถ้าขาดการ TCO เป็นจริงของใด ๆกังวล?


4
หากคุณมีการเรียกซ้ำลึกพอที่จะระเบิดสแต็กโดยไม่ต้อง TCO แล้วคุณจะมีปัญหาแม้กับ TCO
ratchet freak

18
@ratchet_freak นั่นไร้สาระ โครงการไม่ได้มีการวนซ้ำ แต่เนื่องจากข้อมูลจำเพาะได้รับการสนับสนุน TCO การวนซ้ำแบบซ้ำในชุดจำนวนมากจะไม่แพงกว่าลูปที่จำเป็น (ด้วยโบนัสที่ Scheme สร้างส่งกลับค่า)
itsbruce

6
@ratchetfreak TCO เป็นกลไกสำหรับการทำฟังก์ชั่นวนซ้ำที่เขียนด้วยวิธีการบางอย่าง (เช่นหางแบบวนซ้ำ) ไม่สามารถพัดสแต็กได้อย่างสมบูรณ์แม้ว่าพวกเขาต้องการ คำสั่งของคุณเหมาะสมสำหรับการเรียกซ้ำที่ไม่ได้เขียนแบบวนซ้ำซึ่งในกรณีนี้คุณถูกต้องและ TCO จะไม่ช่วยคุณ
Evicatos

2
ล่าสุดฉันดู 80x86 ไม่ได้ทำการเพิ่มประสิทธิภาพการโทรหาง (ดั้งเดิม) แต่นั่นไม่ได้หยุดนักพัฒนาภาษาจากการย้ายภาษาที่ใช้ คอมไพเลอร์ระบุว่าเมื่อใดที่สามารถใช้การข้ามกับ jsr และทุกคนมีความสุข คุณสามารถทำสิ่งเดียวกันกับ JVM ได้
kdgregory

3
@kdgregory: แต่ x86 มีGOTOJVM ไม่ และ x86 ไม่ได้ถูกใช้เป็นแพลตฟอร์ม interop JVM ไม่มีGOTOและหนึ่งในเหตุผลหลักในการเลือกแพลตฟอร์ม Java คือการทำงานร่วมกัน ถ้าคุณต้องการนำ TCO ไปใช้กับ JVM คุณต้องทำอะไรบางอย่างกับสแต็ก จัดการมันด้วยตัวคุณเอง (เช่นไม่ใช้ JVM call stack เลย) ใช้ trampolines ใช้ข้อยกเว้นเป็นGOTOอะไรแบบนั้น ในทุกกรณีคุณจะไม่สามารถใช้ JVM call stack ได้ เป็นไปไม่ได้ที่จะใช้งานร่วมกับ Java, TCO และประสิทธิภาพสูง คุณต้องเสียสละหนึ่งในสามอย่างนี้
Jörg W Mittag

คำตอบ:


16

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

int factorial(int i){ return factorial(i, 1);}
int factorial(int i, int accum){
  if(i == 0) return accum;
  return factorial(i-1, accum * i);
}

ตอนนี้เรารู้สึกฉลาดดีเราสามารถเขียนแฟคทอเรียลของเราได้แม้ไม่มีลูป! แต่เมื่อเราทดสอบเราสังเกตเห็นว่าด้วยจำนวนที่มีขนาดพอสมควรเราจะได้รับข้อผิดพลาดของ stackoverflow เนื่องจากไม่มี TCO

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

คลาสของภาษาที่ใช้งานได้ซึ่งกำหนดเป้ หากแปลเป็น Scheme แล้วแฟคทอเรียลด้านบนจะเป็นแฟกทอเรียลที่ดี มันจะไม่สะดวกอย่างมากหากการวนซ้ำ 5,000 ครั้งทำให้โปรแกรมของคุณพัง สิ่งนี้สามารถแก้ไขได้ด้วยrecurรูปแบบพิเศษคำอธิบายประกอบที่ทำให้การโทรด้วยตนเองดีที่สุด แต่พวกเขาทั้งหมดบังคับประสิทธิภาพการทำงานฮิตหรืองานที่ไม่จำเป็นในโปรแกรมเมอร์

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

โดยสรุปแล้วนี่ไม่ใช่เรื่องใหญ่สำหรับหลาย ๆ กรณี หางส่วนใหญ่เรียกว่าดำเนินการต่อหนึ่งสแต็กเฟรมลึกด้วยสิ่งต่าง ๆ เช่น

return foo(bar, baz); // foo is just a simple method

หรือเรียกซ้ำ อย่างไรก็ตามสำหรับชั้นเรียนของ TC ที่ไม่สอดคล้องกับเรื่องนี้ภาษา JVM ทุกคนรู้สึกเจ็บปวด

อย่างไรก็ตามมีเหตุผลที่ดีว่าทำไมเรายังไม่มี TCO JVM ให้เราติดตามสแต็ก ด้วย TCO เราจะกำจัดสแต็คเฟรมที่เรารู้ว่าเป็น "วาระ" อย่างเป็นระบบ แต่ JVM อาจต้องการเหล่านี้ในภายหลังสำหรับสแต็กสแต็ก! สมมติว่าเราใช้ FSM แบบนี้โดยที่แต่ละรัฐเรียกสิ่งต่อไปนี้ เราจะลบระเบียนทั้งหมดของรัฐก่อนหน้าดังนั้นการย้อนกลับจะแสดงให้เราเห็นว่ารัฐอะไร แต่ไม่เกี่ยวกับวิธีการที่เราไปถึงที่นั่น

ยิ่งไปกว่านั้นการตรวจสอบความถูกต้องของรหัสส่วนใหญ่นั้นเป็นแบบกองซ้อนการกำจัดสิ่งที่ช่วยให้เราสามารถตรวจสอบความถูกต้องของการเข้ารหัสโดยไม่จำเป็น ระหว่างนี้และข้อเท็จจริงที่ว่า Java มีลูป TCO ดูเหมือนปัญหามากกว่าเล็กน้อยกว่ามันคุ้มค่ากับวิศวกร JVM


2
ปัญหาที่ใหญ่ที่สุดคือตัวตรวจสอบรหัสไบต์ซึ่งขึ้นอยู่กับการตรวจสอบสแต็กอย่างสมบูรณ์ นั่นเป็นข้อบกพร่องที่สำคัญในข้อกำหนด JVM 25 ปีที่แล้วเมื่อ JVM ได้รับการออกแบบผู้คนกล่าวแล้วว่าจะเป็นการดีกว่าที่จะใช้ภาษารหัส JVM แบบไบต์เพื่อความปลอดภัยตั้งแต่แรกแทนที่จะเป็นภาษาที่ไม่ปลอดภัย อย่างไรก็ตาม Matthias Felleisen (หนึ่งในบุคคลสำคัญในชุมชน Scheme) เขียนบทความที่แสดงให้เห็นว่าสามารถเพิ่มการโทรหางลงใน JVM ได้อย่างไรในขณะที่ยังคงรักษารหัสตรวจสอบไบต์ไว้
Jörg W Mittag

2
ที่น่าสนใจ J9 JVM โดย IBM ไม่ดำเนินการ TCO
Jörg W Mittag

1
@jozefg ที่น่าสนใจไม่มีใครสนใจเกี่ยวกับ stacktrace รายการสำหรับลูปดังนั้นอาร์กิวเมนต์ stacktrace ไม่ถือน้ำอย่างน้อยฟังก์ชั่นวนซ้ำหาง
Ingo

2
@MasonWheeler นั่นคือจุดของฉัน: stacktrace ไม่ได้บอกคุณว่ามันเกิดขึ้นซ้ำ คุณสามารถเห็นสิ่งนี้ได้ทางอ้อมโดยการตรวจสอบตัวแปรลูป ฯลฯ ดังนั้นทำไมคุณถึงต้องการให้ฮันเดอร์ทสแต็กติดตามรายการของฟังก์ชันเวียนเกิดซ้ำหลายครั้ง เฉพาะอันสุดท้ายเท่านั้นที่น่าสนใจ! และเช่นเดียวกับลูปคุณสามารถกำหนดว่าการเรียกซ้ำตัวเองโดยการตรวจสอบ varaibles ท้องถิ่นค่าอาร์กิวเมนต์ ฯลฯ
Ingo

3
@Ingo: หากฟังก์ชั่นเกิดขึ้นซ้ำกับตัวเองการติดตามสแต็กอาจไม่แสดงมากนัก อย่างไรก็ตามหากกลุ่มของฟังก์ชั่นนั้นเกิดซ้ำซึ่งกันและกันการติดตามสแต็กอาจแสดงเป็นจำนวนมาก
supercat

7

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

มีวิธีแก้ไขข้อ จำกัด ของ JVM:

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

นี่อาจเป็นตัวอย่างที่ใหญ่กว่า พิจารณาภาษาที่มีการปิด (เช่น JavaScript หรือคล้ายกัน) เราสามารถเขียนแฟกทอเรียลเป็น

def fac(n, acc = 1) = if (n <= 1) acc else n * fac(n-1, acc*n)

print fac(x)

ตอนนี้เราสามารถคืนค่าโทรกลับแทน:

def fac(n, acc = 1) =
  if (n <= 1) acc
  else        (() => fac(n-1, acc*n))  // this isn't full CPS, but you get the idea…

var continuation = (() => fac(x))
while (continuation instanceof function) {
  continuation = continuation()
}
var result = continuation
print result

ตอนนี้ทำงานในพื้นที่สแต็คคงที่ซึ่งเป็นประเภทโง่เพราะหางซ้ำแล้วซ้ำอีก อย่างไรก็ตามเทคนิคนี้สามารถแผ่หางทั้งหมดให้เป็นพื้นที่สแต็คคงที่ได้ และถ้าโปรแกรมอยู่ใน CPS นี่หมายความว่า callstack นั้นมีค่าคงที่โดยรวม (ใน CPS ทุกการโทรคือการเรียกแบบหาง)

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

ด้วยเหตุผลเหล่านี้จึงเป็นการดีที่จะให้ VM ใช้งาน tail call op - languages ​​เช่น Java ที่มีเหตุผลที่ดีที่จะไม่สนับสนุน tail call นั้นไม่จำเป็นต้องใช้


1
"เมื่อ TCO นำส่วนหนึ่งของสแต็คมาใช้ใหม่การติดตามสแต็กจากข้อยกเว้นจะไม่สมบูรณ์" - ใช่ แต่จากนั้นสแต็คสแต็กจากภายในลูปจะไม่สมบูรณ์ - ไม่บันทึกความถี่ที่ลูปถูกเรียกใช้ - อนิจจาแม้ว่า JVM จะรองรับการโทรหางที่เหมาะสม แต่ก็ยังสามารถยกเลิกในระหว่างการดีบักได้ จากนั้นสำหรับการผลิตให้เปิดใช้งาน TCO เพื่อให้แน่ใจว่ารหัสนั้นทำงานด้วยการเรียกแบบหาง 100,000 หรือ 100,000,000
Ingo

1
@Ingo No. (1) เมื่อลูปไม่ถูกใช้เป็นการเรียกซ้ำไม่มีเหตุผลที่พวกเขาจะแสดงบนสแต็ก (การเรียกแบบหาง≠การกระโดด≠โทร) (2) TCO นั้นกว้างกว่าการปรับหางแบบเรียกซ้ำ คำตอบของฉันใช้การเรียกซ้ำเป็นตัวอย่าง (3) หากคุณกำลังเขียนโปรแกรมในสไตล์ที่ต้องพึ่งพา TCO การปิดการเพิ่มประสิทธิภาพนี้ไม่ใช่ตัวเลือก - TCO แบบเต็มหรือการติดตามสแต็กเต็มรูปแบบเป็นคุณสมบัติภาษาหรือไม่ เช่นโครงการจัดการเพื่อความสมดุลข้อเสีย TCO กับระบบข้อยกเว้นขั้นสูงมากขึ้น
อมร

1
(1) เห็นด้วยอย่างเต็มที่ แต่ด้วยเหตุผลเดียวกันไม่มีเหตุผลที่จะเก็บรายการการติดตามสแต็คนับร้อยนับพันที่ชี้ไปreturn foo(....);ในวิธีfoo(2) เห็นด้วยอย่างแน่นอน ถึงกระนั้นเราก็ยอมรับการติดตามที่ไม่สมบูรณ์จากลูป, การมอบหมาย (!), ลำดับของคำสั่ง ตัวอย่างเช่นหากคุณพบค่าที่ไม่คาดคิดในตัวแปรคุณก็อยากรู้ว่ามันไปถึงที่นั่นได้อย่างไร แต่คุณไม่บ่นเกี่ยวกับร่องรอยที่หายไปในกรณีนั้น เพราะมันถูกจารึกไว้ในสมองของเราอย่างใดอย่างหนึ่ง) มันเกิดขึ้นเฉพาะในการโทรข) มันเกิดขึ้นในทุกสาย ทั้งสองไม่มีเหตุผล IMHO
Ingo

(3) ไม่เห็นด้วย ฉันไม่เห็นเหตุผลว่าทำไมมันเป็นไปไม่ได้เลยที่จะแก้จุดบกพร่องรหัสของฉันด้วยปัญหาขนาด N สำหรับบาง N ขนาดเล็กพอที่จะไปกับสแต็กปกติ จากนั้นเพื่อเปิดสวิตช์และเปิดใช้งาน TCO - ลดข้อ จำกัด ของขนาดโพรบได้อย่างมีประสิทธิภาพ
Ingo

@Ingo“ ไม่เห็นด้วย ฉันไม่เห็นเหตุผลว่าทำไมมันเป็นไปไม่ได้เลยที่จะแก้จุดบกพร่องรหัสของฉันด้วยปัญหาขนาด N สำหรับบางคน N ตัวเล็กพอที่จะใช้สแต็กปกติได้” หาก TCO / TCE สำหรับการแปลง CPS การปิดเครื่องจะล้นสแต็กและทำให้โปรแกรมขัดข้องดังนั้นจึงไม่สามารถทำการดีบักได้ Google ปฏิเสธที่จะดำเนินการใน TCO V8 JS เพราะปัญหาที่เกิดขึ้นนี้โดยบังเอิญ พวกเขาต้องการไวยากรณ์พิเศษเพื่อให้โปรแกรมเมอร์สามารถประกาศว่าเขาต้องการ TCO และการสูญเสียการติดตามสแต็ก ไม่มีใครทราบว่าข้อยกเว้นจะถูกสกรูโดย TCO หรือไม่
Shelby Moore III

6

ส่วนสำคัญของการโทรในโปรแกรมคือการโทรแบบหาง ทุกรูทีนย่อยมีการโทรครั้งสุดท้ายดังนั้นทุกรูทีนย่อยมีการเรียกหางอย่างน้อยหนึ่งครั้ง การเรียกแบบ Tail มีคุณสมบัติด้านประสิทธิภาพGOTOแต่ความปลอดภัยของการเรียกรูทีนย่อย

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

หากไม่มี PTC คุณต้องใช้GOTOหรือ Trampolines หรือข้อยกเว้นเป็นโฟลว์การควบคุมหรืออะไรทำนองนั้น มันน่าเกลียดมากและไม่ค่อยเป็นตัวแทนของเครื่องรัฐโดยตรง 1: 1

(โปรดสังเกตว่าฉันหลีกเลี่ยงการใช้ตัวอย่าง "loop" ที่น่าเบื่อได้อย่างชาญฉลาดนี่เป็นตัวอย่างที่ PTCs มีประโยชน์แม้ในภาษาที่มีลูป)

ฉันจงใจใช้คำว่า "Proper Tail Calls" ที่นี่แทนที่จะเป็น TCO TCO เป็นการเพิ่มประสิทธิภาพของคอมไพเลอร์ PTC เป็นคุณสมบัติภาษาที่ต้องการคอมไพเลอร์ทุกตัวในการดำเนินการ TCO


The vast majority of calls in a program are tail calls. ไม่ใช่ถ้า "ส่วนใหญ่" ของวิธีการที่เรียกว่าดำเนินการมากกว่าหนึ่งสายของตัวเอง Every subroutine has a last call, so every subroutine has at least one tail call. นี่คือแสดงให้เห็นนิด ๆ return a + bเป็นเท็จ (เว้นแต่คุณจะอยู่ในภาษาที่บ้าบางอย่างซึ่งการดำเนินการทางคณิตศาสตร์ขั้นพื้นฐานถูกกำหนดเป็นการเรียกใช้ฟังก์ชัน)
Mason Wheeler

1
"การเพิ่มตัวเลขสองตัวคือการเพิ่มตัวเลขสองตัว" ยกเว้นภาษาที่ไม่มี แล้วการดำเนินการ + ใน Lisp / Scheme ที่ตัวดำเนินการทางคณิตศาสตร์เดี่ยวสามารถรับอาร์กิวเมนต์จำนวนเท่าใดก็ได้ (+ 1 2 3) วิธีเดียวที่มีสติในการใช้งานนั้นเป็นฟังก์ชั่น
Evicatos

1
@Mason Wheeler: คุณหมายถึงอะไรโดย abstraction inversion?
Giorgio

1
@MasonWheeler นั่นคือโดยไม่ต้องสงสัยรายการ Wikipedia ที่เป็นคลื่นมือที่สุดในเรื่องทางเทคนิคที่ฉันเคยเห็น ฉันเคยเห็นรายการที่น่าสงสัย แต่ก็แค่ ... ว้าว
Evicatos

1
@MasonWheeler: คุณกำลังพูดถึงฟังก์ชั่นความยาวของรายการในหน้า 22 และ 23 ของ On Lisp หรือไม่? รุ่นหางเรียกนั้นมีความซับซ้อนประมาณ 1.2 เท่าไม่มีที่ไหนใกล้กับ 3x ฉันยังไม่ชัดเจนในสิ่งที่คุณหมายถึงโดยสิ่งที่ตรงกันข้ามสิ่งที่เป็นนามธรรม
Michael Shaw

4

"JVM ไม่รองรับการปรับให้เหมาะสมแบบหางเรียกดังนั้นฉันทำนายสแต็กการระเบิดจำนวนมาก"

ใครก็ตามที่บอกว่าสิ่งนี้ (1) ไม่เข้าใจการเพิ่มประสิทธิภาพการโทรแบบหางหรือ (2) ไม่เข้าใจ JVM หรือ (3) ทั้งคู่

ฉันจะเริ่มด้วยคำนิยามของ tail-call จากWikipedia (ถ้าคุณไม่ชอบ Wikipedia นี่เป็นทางเลือก ):

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

ในรหัสด้านล่างการเรียกเพื่อbar()เป็นการเรียกหางของfoo():

private void foo() {
    // do something
    bar()
}

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

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

public static int fact(int n) {
    if (n <= 1) return 1;
    else return n * fact(n - 1);
}

ในการใช้การเพิ่มประสิทธิภาพการโทรหางคุณต้องมีสองสิ่ง:

  • แพลตฟอร์มที่รองรับการแยกย่อยนอกเหนือจากการเรียกรูทีนย่อย
  • ตัววิเคราะห์แบบสแตติกที่สามารถพิจารณาได้ว่าการเพิ่มประสิทธิภาพการโทรหางเป็นไปได้หรือไม่

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

ชิ้นส่วนการวิเคราะห์แบบคงที่คือสิ่งที่ยุ่งยาก ภายในฟังก์ชั่นเดียวก็ไม่มีปัญหา ตัวอย่างเช่นต่อไปนี้เป็นฟังก์ชัน Scala แบบเรียกซ้ำเพื่อรวมค่าในList:

def sum(acc:Int, list:List[Int]) : Int = {
  if (list.isEmpty) acc
  else sum(acc + list.head, list.tail)
}

ฟังก์ชั่นนี้จะกลายเป็น bytecode ต่อไปนี้:

public int sum(int, scala.collection.immutable.List);
  Code:
   0:   aload_2
   1:   invokevirtual   #63; //Method scala/collection/immutable/List.isEmpty:()Z
   4:   ifeq    9
   7:   iload_1
   8:   ireturn
   9:   iload_1
   10:  aload_2
   11:  invokevirtual   #67; //Method scala/collection/immutable/List.head:()Ljava/lang/Object;
   14:  invokestatic    #73; //Method scala/runtime/BoxesRunTime.unboxToInt:(Ljava/lang/Object;)I
   17:  iadd
   18:  aload_2
   19:  invokevirtual   #76; //Method scala/collection/immutable/List.tail:()Ljava/lang/Object;
   22:  checkcast   #59; //class scala/collection/immutable/List
   25:  astore_2
   26:  istore_1
   27:  goto    0

สังเกตgoto 0ที่ท้าย โดยการเปรียบเทียบฟังก์ชั่น Java เทียบเท่า (ซึ่งจะต้องใช้Iteratorเพื่อเลียนแบบพฤติกรรมของการแบ่งรายการ Scala เป็นหัวและหาง) กลายเป็น bytecode ต่อไปนี้ หมายเหตุที่สองการดำเนินงานที่ผ่านมาในขณะนี้เป็นวิงวอนขอตามมาด้วยผลตอบแทนที่ชัดเจนของค่าที่ผลิตโดยที่การภาวนา recursive

public static int sum(int, java.util.Iterator);
  Code:
   0:   aload_1
   1:   invokeinterface #64,  1; //InterfaceMethod java/util/Iterator.hasNext:()Z
   6:   ifne    11
   9:   iload_0
   10:  ireturn
   11:  iload_0
   12:  aload_1
   13:  invokeinterface #70,  1; //InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
   18:  checkcast   #25; //class java/lang/Integer
   21:  invokevirtual   #74; //Method java/lang/Integer.intValue:()I
   24:  iadd
   25:  aload_1
   26:  invokestatic    #43; //Method sum:(ILjava/util/Iterator;)I
   29:  ireturn

หางเพิ่มประสิทธิภาพการเรียกร้องของฟังก์ชั่นเดียวจิ๊บจ๊อย: คอมไพเลอร์จะเห็นว่ามีเป็นรหัสที่ใช้ผลของการโทรไม่ดังนั้นมันสามารถแทนที่วิงวอนgotoด้วย

ชีวิตจะยุ่งยากหากคุณมีหลายวิธี คำแนะนำการแยกสาขาของ JVM ซึ่งแตกต่างจากตัวประมวลผลที่ใช้งานทั่วไปเช่น 80x86 นั้น จำกัด อยู่ที่วิธีการเดียว มันยังค่อนข้างตรงไปตรงมาถ้าคุณมีวิธีการส่วนตัว: คอมไพเลอร์มีอิสระในการแทรกวิธีการเหล่านั้นตามความเหมาะสมดังนั้นสามารถเพิ่มประสิทธิภาพการโทรหาง (หากคุณสงสัยว่ามันทำงานอย่างไรให้พิจารณาวิธีการทั่วไปที่ใช้การswitchควบคุมพฤติกรรม) คุณสามารถขยายเทคนิคนี้ไปยังวิธีสาธารณะหลายวิธีในคลาสเดียวกัน: คอมไพเลอร์อินไลน์เนื้อความวิธีการให้วิธีการสะพานสาธารณะและการโทรภายในกลายเป็นกระโดด

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

ข้อใดนำฉันมาที่ส่วนที่สองของคำถามของคุณซึ่งฉันจะใช้ถ้อยคำใหม่ว่า

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

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

สำหรับรหัสที่สามารถปรับได้ตามความเหมาะสม tail-มักจะตรงไปตรงมาเพื่อแปลรหัสนั้นในรูปแบบ iterable ตัวอย่างเช่นsum()ฟังก์ชั่นที่ฉันแสดงให้เห็นก่อนหน้านี้สามารถเป็นแบบทั่วไปfoldLeft()ได้ หากคุณดูที่แหล่งข้อมูลคุณจะเห็นว่ามีการนำไปใช้จริงเป็นการดำเนินการซ้ำ ๆ Jörg W Mittagมีตัวอย่างของเครื่องสถานะที่ใช้ผ่านการเรียกใช้ฟังก์ชัน มีการใช้งานเครื่องรัฐอย่างมีประสิทธิภาพ (และบำรุงรักษาได้) ที่ไม่พึ่งพาการเรียกใช้ฟังก์ชันที่ถูกแปลเป็นการข้าม

ฉันจะจบสิ่งที่แตกต่างอย่างสิ้นเชิง หากคุณ Google วิธีการของคุณจากเชิงอรรถใน SICP คุณอาจจะจบลงที่นี่ ผมเองพบว่าเป็นสถานที่ที่น่าสนใจมากขึ้นกว่าที่มีคอมไพเลอร์ของฉันแทนที่โดยJSRJUMP


หาก opcode แบบหางเรียกมีอยู่ทำไมการเพิ่มประสิทธิภาพแบบ tail-call ต้องการสิ่งอื่นนอกเหนือจากการสังเกตที่แต่ละไซต์การโทรว่าวิธีการโทรจะต้องใช้รหัสใด ๆ หลังจากนั้นหรือไม่ มันอาจเป็นไปได้ว่าในบางกรณีคำแถลงอย่างเดียวreturn foo(123);อาจทำได้ดีกว่าการfooสร้างโค้ดเพื่อจัดการสแต็กและกระโดด แต่ฉันไม่เห็นว่าทำไมการโทรหางจะแตกต่างจากการโทรธรรมดาใน เรื่องนั้น
supercat

@supercat - ฉันไม่แน่ใจว่าคำถามของคุณคืออะไร จุดแรกของโพสต์นี้คือคอมไพเลอร์ไม่สามารถรู้ได้ว่าสแต็กเฟรมของ Callees ที่มีศักยภาพทั้งหมดอาจมีลักษณะอย่างไร (โปรดจำไว้ว่าสแต็กเฟรมเก็บไม่เพียง แต่อาร์กิวเมนต์ของฟังก์ชัน แต่ยังรวมถึงตัวแปรท้องถิ่น) ฉันคิดว่าคุณสามารถเพิ่ม opcode ที่ทำการตรวจสอบรันไทม์สำหรับเฟรมที่ใช้งานร่วมกันได้ แต่นั่นทำให้ฉันได้รับส่วนที่สองของโพสต์: มูลค่าที่แท้จริงคืออะไร
kdgregory
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.