ฟังก์ชันรีเคอร์ชันพื้นฐานสำหรับโปรแกรมเมอร์สายปฏิบัติ
(matklad.github.io)- การถกเถียงในทำนองว่า “ถ้าไม่เป็น Turing-complete ก็ปลอดภัย” นั้นคลาดเคลื่อนจากความหมายทางคณิตศาสตร์ และ ความไม่เป็น Turing-complete ก็แทบไม่เกี่ยวกับคุณสมบัติที่ใช้งานจริงอย่างการจบการทำงาน ความกำหนดแน่นอน หรือ sandboxing
- การคำนวณของ Turing Machine ที่เวลารันถูกจำกัดด้วย ฟังก์ชันรีเคอร์ชันพื้นฐาน ของอินพุต ก็สามารถเขียนใหม่ด้วยฟังก์ชันรีเคอร์ชันพื้นฐานได้เช่นกัน
- ฟังก์ชันรีเคอร์ชันพื้นฐานจบการทำงานเสมอ แต่ก็สามารถสร้างฟังก์ชันที่โตเร็วมากอย่าง
2^(2^N)ได้ ดังนั้น การรับประกันว่าจะจบ ไม่ได้หมายความว่าจะใช้เวลารันในทางปฏิบัติได้เสมอ - ในงานจริง โปรแกรมที่ไม่หยุดกับโปรแกรมที่กว่าจะจบต้องรออีกหลายพันล้านปีสร้างปัญหาแทบไม่ต่างกัน และภาษาที่เป็น Turing-complete ก็สามารถบังคับหยุดได้ด้วย ตัวนับสเต็ป
- คุณภาพของภาษา config ขึ้นอยู่กับ ความกำหนดแน่นอน, ความหมายเชิงนิยามที่ชัดเจน, ความบริสุทธิ์, ความปลอดภัยและ sandboxing, การควบคุมเวลารัน, และความเรียบง่าย มากกว่าจะขึ้นกับการเป็น Turing-complete หรือไม่
ความเข้าใจผิดหลักในการถกเถียงเรื่อง Turing-completeness
- โปรแกรมเมอร์บนอินเทอร์เน็ตมักพูดถึงการ “ไม่เป็น Turing-complete” ว่าเป็นข้อดีหรือเป็นข้อกำหนดในบางโดเมน
- แต่ Turing completeness เป็นคำเฉพาะจากคณิตศาสตร์ ดังนั้นถ้านำมาใช้แทนคุณสมบัติหลายอย่างที่ผู้ปฏิบัติงานต้องการ ความหมายก็จะพร่าเลือน
- คุณสมบัติที่ต้องการจริง ๆ คือการรับประกันว่าจะจบ การรันที่เร็ว พฤติกรรมแบบกำหนดแน่นอน sandboxing หรือภาษา config ที่เรียบง่าย ซึ่งส่วนใหญ่แล้วเป็นคนละแกนกับ Turing completeness
- เพื่อเข้าใจความต่างนี้ ต้องอาศัยผลทางทฤษฎีแบบง่าย ๆ ของ ฟังก์ชันรีเคอร์ชันพื้นฐาน (Primitive Recursive Functions, PRF)
Turing Machine ที่เร็วพอสามารถแปลงเป็น PRF ได้
- แม้โปรแกรมจะเขียนด้วยภาษาที่เป็น Turing-complete ถ้ารู้ว่าเวลารันเร็วกว่า
O(2^(2^N))ก็สามารถนำอัลกอริทึมเดียวกันไปทำใน ภาษาที่ไม่เป็น Turing-complete ได้ - ปัญหาเชิงปฏิบัติส่วนใหญ่อยู่ในขอบเขตที่จบเร็วกว่านั้น
- ดังนั้นภาษาที่ไม่เป็น Turing-complete จึงไม่ได้จำกัดความสามารถในการคำนวณสำหรับงานจริงอย่างมีนัยสำคัญ และก็ไม่ได้มอบพลังพิเศษในการควบคุมการคำนวณให้อัตโนมัติ
- ในมุมมองงานจริง โปรแกรมสองแบบต่อไปนี้แทบสร้างปัญหาเหมือนกัน
- โปรแกรมที่ไม่จบการทำงาน
- โปรแกรมที่กว่าจะจบต้องรอหลังผ่านไป
10^9คูณ10^9สเต็ป
- แม้แต่ภาษาที่เป็น Turing-complete ก็สามารถ นับจำนวนสเต็ป ในระดับ implementation แล้วหยุดด้วย error เมื่อเกินเพดานที่กำหนด เพื่อกันปัญหาไม่จบการทำงานได้ตรง ๆ
FSM: จบเสมอ แต่พลังการแสดงออกจำกัด
- Finite State Machine (FSM) เป็น recognizer ที่รับสตริงเป็นอินพุตแล้วคืนค่า “yes” หรือ “no”
- FSM ประกอบด้วยเซตสถานะจำกัด สถานะเริ่มต้น เซตของสถานะ yes และฟังก์ชันการเปลี่ยนสถานะ
- หลังจากใช้ฟังก์ชันการเปลี่ยนสถานะซ้ำกับแต่ละสัญลักษณ์ของอินพุตแล้ว ผลลัพธ์จะถูกกำหนดจากว่าสถานะสุดท้ายอยู่ในเซต yes หรือไม่
- พลังการแสดงออกของ FSM เทียบเท่ากับ regular expression
- FSM ทำงานเป็นเชิงเส้นตามความยาวอินพุตและจบเสมอ แต่ไม่สามารถรู้จำเซตของสตริงได้ทุกแบบ
- ตัวอย่างเช่น เซตของสตริงอย่าง
1,010,00100,0001000ที่มี0อยู่สองข้างของ1เท่ากันนั้น FSM ไม่สามารถรู้จำได้ - เมื่ออินพุตยาวพอ สถานะจะเริ่มวนซ้ำเป็นวัฏจักร และถ้าคัดลอกช่วงวัฏจักรนั้น FSM ก็ยังไปถึงสถานะ yes ได้ แต่เงื่อนไขของสตริงจะพัง
- ตัวอย่างเช่น เซตของสตริงอย่าง
Turing Machine: โมเดลที่เพิ่มเทปแบบเปลี่ยนค่าได้ให้ FSM
- Turing Machine (TM) มีสถานะและฟังก์ชันการเปลี่ยนสถานะเหมือน FSM แต่ทำงานบนเทปที่แก้ไขได้แทนอินพุตคงที่
- ในแต่ละสเต็ป TM จะอ่านสัญลักษณ์ปัจจุบันบนเทปแล้วทำงานดังนี้
- แทนที่สัญลักษณ์ปัจจุบันด้วยสัญลักษณ์ใหม่
- เปลี่ยนสถานะภายใน
- เลื่อนหัวอ่านไปทางซ้ายหรือขวาหนึ่งช่อง
- เมื่อ TM ไปถึงสถานะ halt ก็จะหยุด และเนื้อหาบนเทป ณ ตอนนั้นคือผลลัพธ์
- FSM เป็นตัวรู้จำแบบไบนารี ขณะที่ TM เป็นอุปกรณ์สำหรับคำนวณฟังก์ชัน
- TM ไม่จำเป็นต้องหยุดเสมอ และอาจเลื่อนไปมาบนเทปพร้อมเปลี่ยนสถานะโดยไม่เคยไปถึงสถานะสุดท้ายเลย
Universal Turing Machine และพลังการคำนวณ
- โปรแกรมของ TM ไม่ได้มาในรูปโค้ดที่ผู้ใช้ป้อนเข้าไป แต่ถูก hardcode อยู่ใน ฟังก์ชันการเปลี่ยนสถานะ เอง
- อย่างไรก็ตาม เราสามารถเข้ารหัส TM ใด ๆ และอินพุตของมันเป็นไฟล์ข้อความ แล้วสร้าง TM แบบ “interpreter” ที่แปลความมันได้
- TM แบบนี้เรียกว่า Universal Turing Machine และมันจะจำลอง TM ตัวอื่นที่ได้รับมาเป็นอินพุต
- เราสามารถสร้าง TM interpreter ด้วย Python ได้ และในทางกลับกันก็สร้าง Python interpreter ด้วย TM ได้ ดังนั้นทั้งสองจึงถือว่าเทียบเท่ากันในแง่พลังการคำนวณ
- FSM อ่อนกว่า TM
- TM สามารถจำลอง FSM ได้
- TM สามารถตัดสินสตริงที่มี
0จำนวนเท่ากันสองข้างและมี1อยู่ตรงกลางได้ด้วยการจัดการเทป - FSM แก้ปัญหาเดียวกันนี้ไม่ได้
มองเทปเป็นสแตกสองกองได้
- เทปของ TM เป็น abstraction ที่ไม่สะดวกจะ implement โดยตรงในภาษาทั่วไป
- เทปและตำแหน่งของหัวอ่านสามารถแทนด้วย สแตกสองกอง
- เนื้อหาทางซ้ายของหัวอ่านคือสแตกซ้าย
- เนื้อหาทางขวาของหัวอ่านคือสแตกขวาโดยกลับลำดับไว้
- การเลื่อนหัวอ่านไปซ้ายหรือขวาจึงกลายเป็นการ pop จากสแตกหนึ่งแล้ว push ไปอีกสแตกหนึ่ง
- ดังนั้น TM จึงมีพลังการคำนวณเทียบเท่ากับ “FSM ที่มีสแตกสองกอง”
- ถ้าสัญลักษณ์ในสแตกมีแค่
0และ1ก็สามารถแทนสแตกทั้งกองด้วยจำนวนธรรมชาติหนึ่งจำนวนได้- ตรวจ top:
stack % 2 - pop:
stack / 2 - push
x:stack * 2 + x
- ตรวจ top:
ข้อจำกัดของ Turing Machine: Halting Problem และผลลัพธ์ตระกูล Rice
- TM ทุกตัวสามารถเข้ารหัสเป็นข้อความได้ จึงสามารถลิสต์ TM ที่เป็นไปได้ทั้งหมดเป็นรายการอนันต์ได้
- ด้วยวิธีพิสูจน์แบบ diagonalization เราสามารถแสดงได้ว่ามีฟังก์ชันที่ TM คำนวณไม่ได้อยู่จริง
- ตัวอย่างที่เฉพาะเจาะจงกว่าคือ Halting Problem
- เมื่อให้ซอร์สโค้ดของ TM และอินพุตมาแล้ว ต้องตัดสินว่า TM นั้นจะหยุดในสักวันหรือไม่
- ถ้าสมมติว่า
halts(program, input)จบเสมอและถูกต้องเสมอ จะเกิดความขัดแย้งกับโปรแกรมweirdที่รับซอร์สโค้ดของตัวเองเป็นอินพุต- ถ้าตัดสินว่าจะหยุด มันจะเข้าลูปอนันต์และไม่หยุด
- ถ้าตัดสินว่าจะไม่หยุด มันจะจบทันที
- ดังนั้น
haltsต้องมีบางกรณีที่ตอบผิด หรือบางกรณีที่ไม่จบการทำงาน - โดยทั่วไปกว่านั้น เราไม่สามารถตัดสินเชิงอัลกอริทึมเกี่ยวกับคุณสมบัติที่ไม่เป็น trivial และคงพฤติกรรมเดิมของ TM ใด ๆ ได้
- คุณสมบัติทางไวยากรณ์ตรวจได้ แต่คุณสมบัติด้านพฤติกรรมที่ยังคงอยู่หลัง refactor โดยทั่วไปตัดสินไม่ได้
ฟังก์ชันรีเคอร์ชันพื้นฐาน: อุปกรณ์คำนวณที่จบเสมอ
- ฟังก์ชันรีเคอร์ชันพื้นฐาน (PRF) นิยามเป็นฟังก์ชันที่รับทูเพิลของจำนวนธรรมชาติและคืนค่าจำนวนธรรมชาติหนึ่งจำนวน
- ฟังก์ชันพื้นฐานคือ
zeroและsucczero = 0succ(x) = x + 1
- เราสามารถสร้างค่าคงที่ผ่านการประกอบฟังก์ชันได้
succ(zero) = 1succ(succ(zero)) = 2
- ไม่อนุญาตให้ใช้ recursion ทั่วไป แต่อนุญาตลูปแบบจำกัด
LOOP(init, f, n)ที่จำนวนรอบถูกกำหนดล่วงหน้าLOOP(init, f, 0) = initLOOP(init, f, 1) = f(init)LOOP(init, f, 2) = f(f(init))
- ข้อจำกัดสำคัญคือจำนวนรอบ
nต้องถูกตรึงไว้ก่อนเริ่มลูป และตัวนับลูปเปลี่ยนจากใน body ของลูปไม่ได้
สร้างองค์ประกอบการเขียนโปรแกรมพื้นฐานด้วย PRF
- การบวกนิยามได้เป็น
add(x, y) = LOOP(x, succ, y) - การคูณนิยามได้เป็น
mul(x, y) = LOOP(0, add x, y) - การยกกำลังนิยามได้เป็น
pow(x, y) = LOOP(1, mul x, y) - จากนั้นก็สร้างฟังก์ชันที่โตเร็วได้
pow_2(n) = pow(2, n)pow_2_2(n) = pow_2(pow_2(n))
- ถ้าเพิ่ม
predเข้าไปในฟังก์ชันพื้นฐาน ก็จะสร้างการลบแบบอิ่มตัวและ boolean operation ได้sub(x, y) = LOOP(x, pred, y)and(x, y) = mul(x, y)not(x) = sub(1, x)if(cond, a, b)ก็เขียนเป็นนิพจน์เชิงคณิตศาสตร์ได้
- การเปรียบเทียบ ค่าเศษ และการหาร ก็ implement ได้ด้วยลูปที่มีเพดานและนิพจน์เงื่อนไข
โครงสร้างข้อมูลของ PRF และการจำลอง TM
- PRF รับอาร์กิวเมนต์ได้หลายตัว แต่ผลลัพธ์มีได้เพียงจำนวนธรรมชาติเดียว ดังนั้นต้องเข้ารหัสโครงสร้างข้อมูลเป็นจำนวนธรรมชาติ
- คู่
(a, b)สามารถแทนด้วย2^a * 3^b - หากต้องการดึงองค์ประกอบออกมา ก็หาเลขชี้กำลังสูงสุดของจำนวนเฉพาะที่หารลงตัว
fst(p)คือเลขชี้กำลังสูงสุดของ2ที่หารpลงตัวsnd(p)คือเลขชี้กำลังสูงสุดของ3ที่หารpลงตัว
- แบบเดียวกันนี้ ค่าสามตัว
(S, stack1, stack2)ก็แพ็กเป็นจำนวนธรรมชาติเดียวได้ - สถานะการตั้งค่าของ TM แสดงได้ด้วยองค์ประกอบสามตัวดังนี้
- สถานะปัจจุบัน
S - สแตกด้านซ้ายของเทป
- สแตกด้านขวาของเทป
- สถานะปัจจุบัน
- การจัดการสแตก implement ได้ด้วย modulo การคูณ และการหาร ดังนั้นจึงเข้ารหัส หนึ่งสเต็ป ของ TM เป็น PRF ได้
- เมื่อใช้
LOOP(initial_config, single_step, n)ก็สามารถจำลอง TM ได้ตรงตามจริงเป็นเวลาnสเต็ป - ปัญหาคือเราไม่รู้
nที่มากพอ แต่ถ้ารู้ว่าเวลารันถูกจำกัดด้วย PRF ตัวใด ก็แค่ให้ลูปซ้ำตามนั้น - สรุปคือ การคำนวณของ TM ที่เวลารันถูกจำกัดด้วยฟังก์ชันรีเคอร์ชันพื้นฐาน สามารถแทนด้วย PRF ได้
ข้อจำกัดของ PRF: จบเสมอแต่ไม่ได้ทรงพลังเท่า TM
- PRF จบเสมอ แต่ไม่ได้แสดงฟังก์ชันที่จบการทำงานทั้งหมดได้
- มีฟังก์ชันที่ TM คำนวณได้แต่ PRF คำนวณไม่ได้
- วิธีแสดงคือกำหนดเพดานอัตราการเติบโตโดยอิงจาก ความลึก ของ syntax tree ของ PRF
- PRF ที่มีความลึกไม่เกิน
dจะถูกครอบบนไว้ไม่ให้โตเร็วเกินฟังก์ชันเอกภาคบางตัวA_d - นิยามของ
A(d, x)เป็นดังนี้A(1, x) = x + 1A(d + 1, 0) = A(d, A(d, 0))A(d + 1, x) = A(d, A(d + 1, x - 1))
- นิยามนี้เมื่อคำนวณบน TM จะจบ เพราะทุกครั้งที่เรียก recursion ค่า
(d, x)จะเล็กลงตามลำดับพจนานุกรม - ฟังก์ชัน
a(x) = A(x, x)โตเร็วกว่าทุก PRF และแม้ TM จะคำนวณได้ แต่ PRF คำนวณไม่ได้
กลับมาที่งานจริง: แค่ไม่เป็น Turing-complete ยังไม่พอ
- Turing Machine อาจไม่หยุดได้
- อุปกรณ์ที่จบเสมออย่าง FSM และ PRF ก็ไม่ได้รับประกันว่าจะจบเร็ว
- PRF คำนวณฟังก์ชันใหญ่มากอย่าง
2^(2^N)ได้ ดังนั้นการรับประกันว่าจะจบเพียงอย่างเดียวจึงไม่พอจะรับประกันเวลารันที่ใช้งานได้จริง - อัลกอริทึมจริงจำนวนมากมีเวลารันที่ถูกจำกัดด้วย PRF จึงสามารถแสดงได้แม้ด้วยอุปกรณ์ที่ไม่เป็น Turing-complete
- วิธีทั่วไปในการทำให้การคำนวณแบบ Turing-complete มีพฤติกรรมคล้าย PRF คือเพิ่มตัวนับรอบ แล้วบังคับหยุดเมื่อค่าตัวนับใหญ่เกินไป
คุณสมบัติที่ภาษา config ต้องการจริง ๆ
- หลายครั้งภาษา config ตั้งเป้าออกแบบว่า “ไม่เป็น Turing-complete” แต่ของที่ต้องการจริง ๆ คือคุณสมบัติที่แรงกว่านั้นหลายอย่าง
-
ความกำหนดแน่นอน
- ภาษา config ควรเป็นแบบ deterministic
- พฤติกรรมอย่าง
id([])ของ Python ที่ให้ค่าต่างกันในแต่ละครั้ง อาจยอมรับได้ในงานเขียนโปรแกรมทั่วไป แต่ไม่เหมาะกับ config - config มักถูกใช้เป็นคีย์ของระบบ incremental build หรือ caching ดังนั้นถ้ามีความไม่กำหนดแน่นอนเข้ามา พฤติกรรมของ cache จะสั่นคลอน
-
ความหมายเชิงนิยามที่ชัดเจน
- พฤติกรรมของภาษาควรถูกตรึงไว้อย่างชัดเจนด้วยเกณฑ์อ้างอิงที่ตรวจสอบได้
- แม้จะปิด ASLR และใช้ allocator เฉพาะเพื่อทำให้
id([])ของ Python เป็น deterministic ได้ แต่ก็ไม่ได้แปลว่าผลนั้นจะคาดเดาได้หรือเหมือนกันทุก implementation - ถ้าต้องการรับประกันพฤติกรรมเดียวกันข้าม implementation หรือข้ามเวอร์ชันของ Python ก็ต้องมี semantics ที่ชัดเจน
-
ความบริสุทธิ์
- ถ้า config อ่านตัวแปรสภาพแวดล้อมหรือไฟล์บนดิสก์ได้ ความหมายของ config ก็จะขึ้นกับสภาพแวดล้อมที่ใช้ประเมิน
- ถ้าต้องการให้ caching ทำงานได้ถูกต้อง ภาษา config ต้องมี ความบริสุทธิ์
-
ความปลอดภัยและ sandboxing
- ทั้งความบริสุทธิ์และความปลอดภัยทำได้โดยไม่เปิดเผย IO ทั่วไป
- แต่จุดประสงค์ของสองคุณสมบัตินี้ต่างกัน
- ความบริสุทธิ์มีไว้เพื่อกันไม่ให้ผลลัพธ์กลายเป็นแบบไม่กำหนดแน่นอน
- ความปลอดภัยมีไว้เพื่อไม่ให้ทรัพยากรอย่าง access token รั่วไปถึงผู้โจมตี
-
การควบคุมเวลารัน
- ต่อให้ควบคุม IO ได้ ก็ยังโจมตีแบบ ปฏิเสธการให้บริการ ด้วยการเผา CPU ผ่าน config อันตรายได้
- หากต้องการรับประกันเวลารัน สามารถใช้ได้สองแนวทาง
- จำกัดให้การประมวลผลมีโครงสร้างเชิงเส้นที่ชัดเจน ซึ่งแปรผันตรงกับขนาดอินพุต
- ใช้การรันแบบมีมิเตอร์ (metered execution) โดยลดค่าตัวนับทุก atomic step และหยุดเมื่อค่าตัวนับเป็น 0
-
ความเรียบง่าย
- ภาษา config ควรชี้นำให้ผู้ใช้เขียนโปรแกรมที่เรียบง่าย
- การห้าม recursion และลูปอนันต์อาจเป็น speed bump ที่ช่วยชี้นำไปสู่ความเรียบง่าย
- แต่ดังตัวอย่าง PRF การห้ามเช่นนี้ไม่ได้กันการเขียนโปรแกรมแบบ recursive โดยอ้อมอย่างสมบูรณ์ เพียงแค่ทำให้ต้องเขียนโค้ดอ้อมมากขึ้น
- ดูตัวอย่างที่เกี่ยวข้องได้ที่ some roundabout code
สรุปท้ายบท
- อัลกอริทึมของ Turing Machine ที่เวลารันบนอินพุตถูกจำกัดด้วยฟังก์ชันรีเคอร์ชันพื้นฐานบางตัว สามารถ implement ได้ด้วยฟังก์ชันรีเคอร์ชันพื้นฐานเช่นกัน
- ความไม่เป็น Turing-complete อาจมอบคุณสมบัติเดียวคือการรับประกันว่าจะจบ แต่ไม่ได้รับประกันเพดานเวลารันหรือคุณภาพของภาษา config ที่งานจริงต้องการโดยอัตโนมัติ
- ในการออกแบบภาษา config ประเด็นสำคัญกว่าการเป็น Turing complete คือความกำหนดแน่นอน ความหมายเชิงนิยามที่ชัดเจน ความบริสุทธิ์ sandboxing การวัดควบคุมเวลารัน และความเรียบง่าย
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
โปรโมตตัวเอง: https://www.nayuki.io/page/primitive-recursive-functions
ในส่วนสรุปของบทความมีเกณฑ์ที่ค่อนข้างดีเกี่ยวกับ ภาษา configuration เลยสงสัยว่ามีภาษาในปัจจุบันที่ตรงตามเกณฑ์ทั้งหมดหรือส่วนใหญ่หรือไม่
Dhall เป็นภาษาฟังก์ชันแบบ total โดยเจตนา จึงไปในทาง “ไม่ให้เป็น Turing-complete” ส่วน Cue ไม่มีฟังก์ชัน จึงไม่มีอะไรให้ recurse
ผมคิดว่า RCL [3] ตรงตามเกณฑ์ เป็นแบบ deterministic และ pure มีการรันแบบมีการวัดปริมาณ และ sandbox การเข้าถึงไฟล์ซิสเต็ม ถ้า policy ของ sandbox อนุญาต ก็อ่านไฟล์ได้ แต่ไฟล์แบบนั้นถือเป็นส่วนหนึ่งของซอร์สโค้ดและทำงานเหมือน import
ใน RCL เราไม่อยากไปทาง “ไม่ให้เป็น Turing-complete” ด้วยเหตุผลที่ผู้เขียนพูดถึง การที่โปรแกรมจะจบลงในท้ายที่สุดไม่ใช่คุณสมบัติที่มีประโยชน์นักในงานจริง และในทางกลับกัน แม้แต่ภาษา total function อย่าง Agda ก็ยังเขียนโปรแกรมที่ซับซ้อนมากได้ ดังนั้นการไม่เป็น Turing-complete จึงไม่ได้รับประกันโปรแกรม/การตั้งค่าที่เรียบง่าย
loop ทุกตัวใน RCL มีขอบเขต แต่มีฟังก์ชันจึง recurse ได้ด้วย ไม่มี tail call ดังนั้นตอนแรกจึงใส่ข้อจำกัดความลึกของ recursion เพื่อกัน native stack overflow แต่ fuzzer ไปเจอฟังก์ชันที่หยุดได้ทั้งที่รันด้วยพื้นที่ stack คงที่ และจนถึงตอนนี้ก็ยังไม่เข้าใจทั้งหมด:
let f = g => g(g(h => k => g(g(h)))); f(f)สุดท้ายแล้ว การที่สามารถแสดงฟังก์ชันพยาธิแบบนี้ได้ไม่ใช่ปัญหาในงานจริง แค่มีข้อจำกัดจำนวนขั้นตอนการรัน หรือ “gas limit” หรือที่ผู้เขียนเรียกว่า “การรันแบบมีการวัดปริมาณ” ก็พอ การที่โครงสร้าง loop ในตัวมีขอบเขตและ recursion ไม่สะดวกเป็นตัวชักนำที่ดีให้รักษาโค้ดให้เรียบง่าย แต่สุดท้ายเครื่องมือที่มีค่าที่สุดคือ code review และวิจารณญาณที่ดี
[1]: https://dhall-lang.org/
[2]: https://cuelang.org/
[3]: https://rcl-lang.org/
งานวิจัยของ Dennis Ritchie ที่ MIT โฟกัสหัวข้อที่เขาเรียกว่า loop programming
The complexity of loop programs - ALBERT R. MEYER and DENNIS M. RITCHIE
https://people.csail.mit.edu/meyer/meyer-ritchie.pdf
Structured programming ที่โปรแกรมเมอร์สมัยใหม่แทบทุกคนยึดเป็นพื้นฐาน โดยแท้จริงแล้วเป็นพาราไดม์ที่ผลักไปทางฟังก์ชัน primitive recursive เนื่องจาก structured programming ได้รับการยอมรับแทบทั่วไปเมื่อเทียบกับอีกสองแนวทางอย่าง pop และ functional จึงดูเหมือนผู้คนจะสับสนกับบทความ “goto ถือว่าเป็นอันตราย” ของ Dijkstra
ฟังก์ชัน primitive recursive ไม่ได้ครอบคลุมฟังก์ชันที่คำนวณได้ทั้งหมด แต่ครอบคลุมฟังก์ชันเชิงสัญชาตญาณที่รับประกันว่าจะจบแทบทั้งหมด แน่นอนว่ามีบางกรณีที่จำเป็นต้องใช้ loop ซึ่งไม่รู้จำนวนรอบตอนเริ่มเข้า loop จริง ๆ แต่ถ้าใช้เฉพาะตอนจำเป็นจริง ๆ ก็เป็นปืนที่ยิงเท้าตัวเองซึ่งหลีกเลี่ยงได้
แม้แต่ COBOL ก็ถูกทำให้ทันสมัยขึ้นโดยย้าย goto แบบไม่จำกัดไปไว้ในคำสั่ง ALTER นึกไม่ออกว่ามีภาษาสมัยใหม่ที่มีประโยชน์ภาษาไหนที่ไม่อนุญาตฟังก์ชัน PR
ใน C เอง หากเขียนโดยหลีกเลี่ยง
whileและหลีกเลี่ยง fall through อย่างชัดเจน ก็แทบจะสร้างโค้ด total function ที่ต้องจบเสมอได้เกือบตลอดก็มีกรณีพยาธิอย่าง type inference ของ ML อยู่บ้าง ต้นทุนจริงถูกกว่ามากเมื่อเทียบกับ complexity class ดังนั้นแม้ภาษาจะจำกัดการใช้งานแบบนี้ได้ยากหากไม่ทำให้เป็น total function ก็ยังคุ้มที่จะยอมรับ
ในเชิงปฏิบัติ ภาษาทุกภาษามักให้ค่าเริ่มต้นที่รองรับเกณฑ์เหล่านี้ส่วนใหญ่ แต่ข้อจำกัดที่บังคับใช้สิ่งนี้จะจำกัดประโยชน์ของภาษาอย่างรุนแรง ผมมองว่าแม้แต่ SOLID กับ Clean framework ที่มักถูกด่าก็ยังผลักไปทางโมเดลนี้
Structured programming กลายเป็นเรื่องแพร่หลายมากจนลืมประเด็นนี้ได้ง่าย และถึงขั้นสอนกันไม่เป็นด้วยซ้ำ จากมุมของคนแก่มีเครา ผมจำได้ว่าเคยเรียนรู้ถึงอันตรายของ
WHILEและอื่น ๆคำกล่าวที่ว่ามันรันเร็วกว่า
O(2^(2^N))คงเป็นคำพูดที่พยายามทำให้เรียบง่าย แต่ส่วนที่ว่า “จำนวนที่ใหญ่มาก” ทำให้ความน่าเชื่อลดลงเล็กน้อยพูดให้ถูกน่าจะเป็น “ฟังก์ชันที่เติบโตเร็วมาก” หรือไม่ก็เหมือนจะหมายความว่าโปรแกรมจบในจำนวนน้อยกว่า
O(2^(2^N))ขั้นตอนถ้าดูเฉพาะส่วนแรกที่บอกว่าภาษาที่ถูกจำกัดเหมาะกับบางแอปพลิเคชันมากกว่า ผมคิดว่าข้อดีคือสามารถ คำนวณขอบเขตบน ของจำนวนขั้นตอนที่ต้องใช้แบบสแตติกได้หรือเปล่า
ถ้าอย่างนั้น การคำนวณที่ละเมิดข้อจำกัดสำหรับอินพุตใด ๆ ก็จะถูกปฏิเสธและส่งคืนข้อผิดพลาดที่มีความหมายได้
ในทางกลับกัน วิธีที่กำหนดข้อจำกัดตอนรันไทม์ให้กับภาษาที่ Turing-complete อาจตั้งข้อจำกัดต่ำเกินไปสำหรับอินพุตบางตัวที่เราสนใจได้ จะไม่รู้จนกว่าจะรันแล้วชนข้อจำกัด บางครั้งเห็นเรื่องแบบนี้ใน recursion ของ template ใน C++
ผมอาจจะเข้าใจผิดไปหมดก็ได้ ถ้ามีคนที่รู้ดีกว่าช่วยอธิบายจะดีมาก
แม้ workflow แย่ ๆ จะใช้เวลานานขึ้นอีกหลายเท่ากว่าจะล้มเหลว ก็ยังอยู่ในระดับมิลลิวินาที จึงไม่น่าก่อความเสียหายมาก
โดยทั่วไปจะเจอปัญหานี้เมื่อมีคนจัดการโดเมนปัญหาเป็นกราฟมีทิศทางแบบไม่มีวงจร แต่บังคับความ “ไม่มีวงจร” ไม่ได้ การโมเดลปัญหาเป็นกราฟมีทิศทางแบบไม่มีวงจรเหมือน Dark Galadriel ที่กำลังลังเลว่าจะรับแหวนจาก Frodo หรือไม่ “ทุกคนจะรักข้าและสิ้นหวัง” คนที่สร้างของแบบนี้มักภูมิใจกับมันมากกว่าที่ควรจะภูมิใจเสมอ
สุดท้ายลูกค้าที่ถูกดึงเข้ามาด้วยวิธีแก้ปัญหาที่แพงและซับซ้อนก็เงินหมด และปัญหาของตัวเองก็เริ่มดูเล็กลงมาก แล้วในความหมายตามตัวอักษร ก็จะเหลือเพียงแอปที่ไม่สามารถลดต้นทุนต่อ task ให้ต่ำพอจะรักษาธุรกิจของลูกค้าไว้ได้
ผมสนใจมากกว่ากับการใช้มันเป็นค้อนอีกอันในกล่องเครื่องมือ เพื่อจับบั๊กที่หลุดผ่านค้อนก่อนหน้าอย่างการตรวจสอบชนิดแบบสแตติก, ไม่มี null, ไม่มีการเปลี่ยนแปลงค่า
การที่รู้ว่าจะจบภายในเวลาจำกัดไม่ได้พิสูจน์ความถูกต้องสอดคล้อง แต่ถ้าผมประกาศว่าโค้ดจะจบภายในเวลาจำกัด แล้วคอมไพเลอร์ไม่เห็นด้วย ผมก็จะเชื่อว่าโค้ดนั้นผิด
ถ้าการจบก่อนกำหนดจะมีผลต่อเวลารัน ก็ต้องสมมติว่ามี lazy evaluation หรือเงื่อนไขแบบ short-circuit แต่ภาษาเชิงปฏิบัติโดยมากก็มักมีสิ่งเหล่านั้น
n log nแต่มี ขอบเขตบนกำลังสอง ตามขนาดอินพุตถ้าอย่างนั้นจะปฏิเสธการคำนวณ quicksort ไหม? ยิ่งสุดโต่งกว่านั้น อัลกอริทึม Hindley–Milner มีขอบเขตบนแบบเอกซ์โพเนนเชียล แต่ในทางปฏิบัติมักรันเป็นเวลาเชิงเส้น
แต่ก็คิดไม่ออกเลยว่ามีสถานการณ์ไหนที่นี่เป็นข้อกำหนดที่แข็งมากจริง ๆ มีระบบสักกี่ระบบที่ไม่สามารถส่งข้อผิดพลาดว่า “คิวรีใช้เวลานานเกินไป” ได้?
ผมเคยอยากลองใช้ภาษาโปรแกรมมิงเว็บฝั่งแบ็กเอนด์ที่ตั้งอยู่บนหลักการ ไม่จบ/มีขอบเขต
แนวคิดคือคอมไพเลอร์พิสูจน์ได้ว่าทุกฟังก์ชันมีการรันอยู่ภายในขอบเขตสำหรับสถานะและอาร์กิวเมนต์ที่ให้มา และแสดงได้ว่า X เป็นขอบเขตล่าง ส่วน Y เป็นขอบเขตบน ข้อมูลนี้จะถูกส่งต่อไปถึง entry point
ผมเห็นด้วยกับผู้เขียนที่บอกว่าต้องการ semantics ที่แข็งแรงกว่า และนั่นคือเหตุผลที่คิดถึงภาษานี้ขึ้นมา หลายครั้งเราต้องการการรับประกันเกี่ยวกับเวลารันของโปรแกรม
แก่นหลักน่าจะอิงกับฟังก์ชัน primitive recursive แต่ในทางปฏิบัติอาจเป็น Turing-complete ได้ เหมือนที่ Rust ปฏิเสธ borrow ที่ผิด แต่ก็มี
unsafeสำหรับ raw pointer ภาษานี้ก็จะคำนวณขอบเขตจาก primitive แบบวนซ้ำง่าย ๆ หรือให้ใช้ตัวดำเนินการunsafeแล้วระบุสูตรทดแทนสำหรับขอบเขตได้ผมไม่ค่อยเข้าใจส่วนบ่น/แรงจูงใจสั้น ๆ ของบทความ
คือส่วนที่ว่า “โดยทั่วไปในบางโดเมน การไม่เป็น Turing-complete มักถูกยกย่องว่าเป็นข้อดีหรือข้อกำหนด ผมคิดว่าการถกเถียงส่วนใหญ่ในเรื่องนี้เข้าใจผิด — การบอกว่าไม่เป็น Turing-complete ไม่ได้หมายความอย่างที่ผู้คนคาดหวัง”
ทำไมการถกเถียงพวกนั้นถึงผิด? เครื่องมือวิเคราะห์เชิงรูปแบบส่วนใหญ่ เช่น Coq, Isabelle, Agda มักกำหนดให้ต้องมีหลักฐานว่าฟังก์ชันจบการทำงาน นี่เท่ากับการพิสูจน์ว่าฟังก์ชันนั้นเป็นฟังก์ชันทั้งหมด และดังนั้นก็หมายความว่าเป็น primitive recursive ไม่ใช่หรือ?
ประเด็นนี้ก็เพิ่งถูกพูดถึงในการอภิปรายเกี่ยวกับ CEL เมื่อไม่นานนี้:
https://news.ycombinator.com/item?id=40954652
ถ้าจำไม่ผิด สิ่งนี้เทียบเท่ากับ NP ที่มี oracle แบบ co-NP หรือระดับที่สองของ polynomial hierarchy แม้จะทำได้ในปัญหาเล็ก ๆ แต่ก็แพง
เครื่องมือเหล่านี้ทำงานได้ดีที่สุดเมื่อจัดโครงสร้างโปรแกรมให้เป็นฟังก์ชันทั้งหมด ในบรรดาวิธีเหล่านั้น วิธีที่พบบ่อยที่สุดคือใช้เฉพาะ
FORใน structured programming หรือใช้WHILE/recursion ที่มีจำนวนรอบจำกัดแม้จะเกี่ยวข้องเฉพาะกับ SAT แต่รูปแบบที่จัดการได้ในทฤษฎีบท dichotomy ของ Schaefer เป็นเลนส์ที่เข้าถึงง่ายที่สุดเท่าที่ผมนึกออก
Agda และอาจรวมถึงเครื่องมืออื่น ๆ สามารถพิสูจน์การจบการทำงานของฟังก์ชัน recursive ที่ไม่ใช่ primitive recursive บางส่วนได้ แน่นอนว่าเป็นไปไม่ได้ที่จะทำได้ทั้งหมด
ความเข้าใจผิดที่บทความบ่นถึงน่าจะประมาณนี้: “Turing-completeness หมายถึงคำนวณได้ ส่วน non-Turing-completeness หมายถึงคำนวณไม่ได้และมีคุณสมบัติที่ดีสำหรับภาษา configuration”
ใจความของบทความคือ แม้ไม่เป็น Turing-complete ก็ยังทำงานที่มีต้นทุนการคำนวณสูงหรือยุ่งยากได้มากมาย และถ้าเป็นภาษา configuration ก็ต้องมีข้อจำกัดที่เข้มงวดกว่าการแค่ไม่เป็น Turing-complete มาก
ผมเป็นนักพัฒนา CUE CUE เป็นแบบ primitive recursive และยังเป็นไปตามเกณฑ์ที่คาดหวังจากภาษา configuration ที่ “ดี” ด้วย
[0] https://en.wikipedia.org/wiki/Rule_110