ถ้าจะเขียน Rust ใหม่อีกครั้ง
(josephg.com)- Rust มีจุดเด่นทั้ง ความปลอดภัยของหน่วยความจำ ประสิทธิภาพ algebraic types และข้อดีของ Cargo แต่ผู้เขียนเริ่มจากความรู้สึกว่าช่องทาง stable พัฒนาภาษาได้ช้าลง จนให้ความรู้สึกเหมือน “ผลิตภัณฑ์รุ่นแรกที่ยังไม่เสร็จสมบูรณ์”
- หลังชุมชนขยายใหญ่ขึ้น การตัดสินใจบนฐานฉันทามติ กลายเป็นคอขวด และฟีเจอร์อย่าง coroutine แม้จะถูก implement แล้ว ก็ยังสะสมเป็นฟีเจอร์ที่ใช้งานใน stable Rust ไม่ได้อยู่นาน
- “seph edition” ที่สมมติขึ้นเป็นแนวคิดที่จะคงระบบนิเวศ Rust และ Cargo เดิมไว้ แต่ยอมรับ breaking change เพื่อทดลองระบบ effect, capability ตอนคอมไพล์, การ borrow ฟิลด์ของ struct,
comptimeเป็นต้น - หากต้องการลดความเสี่ยงด้านซัพพลายเชน ก็ควรผูก capability กับความสามารถอ่อนไหวอย่างการเขียนไฟล์ เครือข่าย FFI และ
unsafeและให้ผู้เรียกอนุญาตอย่างชัดเจน - ข้อเสนอส่วนใหญ่เข้ากันไม่ได้กับ Rust เดิม และแม้แต่การเพิ่ม capability เพียงอย่างเดียวก็ทำให้เกิดเงื่อนไขความล้มเหลวแบบใหม่ที่ทำลายความเข้ากันได้ของ semver จึงต้องมี edition ใหม่หรือแยกคอมไพเลอร์ออกมา
ทำไม Rust ถึงให้ความรู้สึกเหมือน “ผลิตภัณฑ์รุ่นแรก”
- ตอนแรก Rust ดูน่าดึงดูดมากจาก algebraic types, ความปลอดภัยของหน่วยความจำโดยไม่เสียประสิทธิภาพ และตัวจัดการแพ็กเกจสมัยใหม่
- แต่หลังจากใช้งานมาราว 4 ปี ก็เริ่มรู้สึกว่าภาษา “ขาดอะไรไปนิดหน่อยอยู่เสมอ”
- แก่นของความไม่พอใจคือ ฟีเจอร์ใหม่เข้าสู่ stable Rust น้อยลง และความเร็วในการพัฒนาภาษาก็ช้าลงมาก
- Rust unstable book มี unstable feature ราว 700 รายการ โดยจำนวนมากเกี่ยวข้องกับการเปลี่ยนแปลงใน standard library
- Coroutines เป็นฟีเจอร์ที่มี RFC มาแล้ว 7 ปี และมี implementation อยู่ในคอมไพเลอร์แล้ว แต่ผู้ใช้ stable Rust ก็ยังใช้ไม่ได้
- กระบวนการ Rust RFC ดูเหมือนเป็นที่ที่ไอเดียดี ๆ เข้าไปค้างอยู่นาน
- มีการยกตัวอย่างการถกเถียงเรื่องการปรับปรุง
Mutexที่มีผู้ร่วมแสดงความเห็น 25 คน ตลอด 2 ปี รวมกว่า 200 คอมเมนต์ แต่ก็ยังไม่ได้ข้อสรุปชัดเจน
- มีการยกตัวอย่างการถกเถียงเรื่องการปรับปรุง
แนวคิด “seph edition” ที่สมมติขึ้น
- ผู้เขียนจินตนาการถึงการ fork คอมไพเลอร์ แต่ปล่อยโค้ด Rust เดิมไว้เหมือนเดิม แล้วเพิ่ม Rust edition แยกต่างหากชื่อ “seph”
- ใน edition นี้จะยอมรับ breaking change แต่ถ้าคอมไพเลอร์ยังคอมไพล์ mainline Rust ได้ ก็ยังใช้ crate เดิมของ Cargo ต่อได้
- แกนหลักที่อยากเปลี่ยนมี 5 เรื่องใหญ่
- function trait และระบบ effect
- capability ตอนคอมไพล์
- ออกแบบ
Pin, move และ struct borrow ใหม่ comptimeแบบ Zig- การปรับ syntax และ standard library เล็ก ๆ น้อย ๆ
Function trait และระบบ effect
- Rust มี trait สำหรับ struct อยู่แล้ว แต่ข้อเสนอคือควรใส่ trait/effect ที่หลากหลายขึ้นให้กับฟังก์ชันได้ด้วย
- คุณสมบัติที่ฟังก์ชันอาจมี ได้แก่
- panic ได้หรือไม่
- ใช้ stack ขนาดคงที่หรือไม่
- รันจนจบหรือมี
yieldหรือawait - ถ้าเป็น coroutine continuation จะมี type อะไร
- เป็น pure function หรือไม่
- มีการรัน
unsafecode ทางอ้อมหรือไม่ - รับประกันการสิ้นสุดการทำงานหรือไม่
- หากเปิดเผยพารามิเตอร์และชนิดคืนค่าของฟังก์ชันเหมือนเป็น associated type ของฟังก์ชัน ก็จะทำให้สามารถอ้างอิง type แบบที่ stable Rust ปัจจุบันทำไม่ได้
- ตัวอย่างคือโค้ดที่ตั้งชื่อชนิดคืนค่าของฟังก์ชันโดยตรงแบบ
some_iter::Outputแล้วนำไปใช้ในฟิลด์ของ struct - ในกรณีอย่าง Linux kernel ที่ต้องการรับประกันว่าโค้ดบางบล็อกจะไม่มีวัน panic ก็อาจตรวจความเป็นไปได้ของ panic รวมถึงการเรียกซ้ำได้ผ่านการระบุอย่าง
#[disallow(Panic)] - คอมไพเลอร์จัดการ trait ที่เกี่ยวกับฟังก์ชันอย่าง
Fn,FnOnce,FnMutอยู่แล้ว แต่ผู้เขียนมองว่ารูปแบบปัจจุบันยังเรียบง่ายเกินไป - มีลิงก์อ้างอิงเพิ่มเติมไปยัง บทความและงานนำเสนอเรื่อง effect system ของ Yoshua Wuyts
ลดความเสี่ยงซัพพลายเชนด้วย capability ตอนคอมไพล์
- โปรเจ็กต์ Rust ส่วนใหญ่นำเข้า crate จากภายนอกจำนวนมาก และแม้แต่ utility crate เล็ก ๆ ก็สร้าง ความเสี่ยงด้านซัพพลายเชน ได้ผ่านการอัปเดตที่เป็นอันตราย
- เช่นเดียวกับที่
unsafeในเรื่องความปลอดภัยของหน่วยความจำเป็นการ opt-in แบบชัดเจน ความสามารถอ่อนไหวอย่างระบบไฟล์ เครือข่าย FFI และ raw pointer ก็ควรถูกอนุญาตอย่างชัดเจนเช่นกัน - ข้อเสนอคือให้ใส่ marker tag กับฟังก์ชันที่อ่อนไหวด้านความปลอดภัยใน standard library
- ตัวอย่าง:
std::fs::write(path, contents)สามารถเขียนไฟล์ไปยัง path ใดก็ได้ จึงอาจมีแท็กอย่าง#[cap(fs_write)] - คอมไพเลอร์จะทำ taint ให้ทั้ง call tree ที่เรียกฟังก์ชันนี้โดยอัตโนมัติ
- ตัวอย่าง:
- หาก crate ภายนอกต้องการ capability
fs_writeผู้เรียกต้องอนุญาตอย่างชัดเจนผ่านCargo.tomlหรือ annotation ที่จุดเรียก - หากไม่อนุญาต คอมไพเลอร์อาจแจ้งว่า
foo::do_stuff()มีการเขียนลง local file system แต่ cratefooไม่ได้รับความเชื่อถือสำหรับ capability นั้น - utility crate หลายตัวอย่าง
human-sizeหรือserdeไม่จำเป็นต้องใช้ capability พิเศษ ดังนั้นแม้ผู้เขียนจะใส่โค้ดอันตรายเพิ่มเข้ามา ก็ยังถูกขัดขวางไม่ให้เขียนไฟล์หรือใช้งานเครือข่ายได้ตั้งแต่ขั้นคอมไพล์ - อีกทางเลือกหนึ่งคือเปลี่ยน API ที่อ่อนไหวให้รับพารามิเตอร์
Capabilityแยกต่างหาก- เช่น
std::fs::writeอาจต้องรับค่าFsWriteCapability - และจำกัดให้สร้างอ็อบเจ็กต์
Capabilityได้เฉพาะใน root crate
- เช่น
- วิธีนี้เพิ่ม boilerplate แต่ยืดหยุ่นกว่า และจำเป็นต้องใช้แนวทางคล้ายกันกับสคริปต์
build.rsและบล็อกunsafeด้วย - แม้ในสถานการณ์ที่ crates.io ถูกเจาะและมีการใส่โค้ด cryptolocker ลงใน
serdeแนวทาง capability ก็จะทำให้เกิดข้อผิดพลาดตอนคอมไพล์ก่อนที่โค้ดนั้นจะรันบนเครื่องนักพัฒนาหลายเครื่องหรือถูกรวมเข้าไปในไบนารี
ออกแบบ Pin, move และ struct borrow ใหม่
- ผู้เขียนมองว่า
Pinเป็นแฮ็กที่ซับซ้อนเพื่ออ้อมช่องโหว่ของ borrow checker และเป็นเพียง band-aid ที่เกิดจากการพยายามรักษาความเข้ากันได้ย้อนหลัง - สิ่งที่ต้องการจริง ๆ น่าจะใกล้เคียงกับ marker trait
Moveสำหรับระบุ type ที่เคลื่อนย้ายได้ - ปัจจุบัน Rust ไม่มี trait
Pinแต่มีUnpinและ!Unpinซึ่งรูปแบบปฏิเสธซ้อนนี้ทำให้แนวคิดเข้าใจยากขึ้น - มีคำวิจารณ์ว่า
Pinใช้ได้เฉพาะกับ reference type จึงทำให้เกิดโค้ดที่ต้องห่อด้วยBoxอยู่หลายแห่ง- มีการยกตัวอย่างลิงก์ไปยัง Tokio stream wrapper,
ouroboros,async-trait,self_cellและ helper library อื่น ๆ
- มีการยกตัวอย่างลิงก์ไปยัง Tokio stream wrapper,
- ฟังก์ชันที่รับค่าซึ่งถูก pin ไว้ เช่น
Future::poll(self: Pin<&mut Self>, ..)ก็ซับซ้อนขึ้น และยังต้องพึ่ง crate แยกสำหรับจัดการ projection - ภายในฟังก์ชัน borrow checker จัดการตัวแปรด้วยสถานะอย่าง “owned”, “borrowed”, “mutably borrowed” แต่สถานะเหล่านี้ไม่ได้แสดงให้โปรแกรมเมอร์เห็นโดยตรง
async fnทำงานโดยให้คอมไพเลอร์สร้าง hidden struct เพื่อเก็บสถานะระหว่างการหยุดรอ และในกระบวนการนี้อาจเกิดรูปแบบที่ฟิลด์หนึ่ง borrow อีกฟิลด์หนึ่ง- Rust ไม่มี syntax สำหรับแสดงว่าฟิลด์ของ struct อยู่ในสถานะ borrowed และไม่สามารถระบุ lifetime ระหว่างฟิลด์ได้โดยตรง
- ทิศทางที่เสนอคือขยาย borrow checker เพื่อให้เขียน struct field borrow ได้โดยตรง
- เช่น syntax แบบ “local borrow” อย่าง
y: &'Self::x Vec<usize> - คอมไพเลอร์จะรู้ว่า
xถูก borrow อยู่ และใช้ข้อจำกัดเดียวกับตัวแปร borrowed ภายในฟังก์ชัน
- เช่น syntax แบบ “local borrow” อย่าง
- syntax แบบนี้ยังใช้กับ self-referential struct หรือกรณีอย่าง AST ที่มี
source: Stringและast_nodes: Vec<&'Self::source str>ได้ด้วย - struct ที่มี borrowed field จะไม่สามารถถูกย้ายได้ จึงไม่ implement
Moveและยังมีการพิจารณา traitMoverสำหรับการย้ายตัวเองอย่างปลอดภัยด้วย
comptime แบบ Zig และปัญหา macro ของ Rust
- ผู้เขียนมองว่าคอมไพเลอร์ Rust ในทางปฏิบัติต้องจัดการหลายภาษาไปพร้อมกัน ทั้ง Rust, ภาษา macro ของ Rust และ proc macro
- ประเด็นสำคัญคือ Rust เองดีอยู่แล้ว แต่ ภาษา macro นั้นไม่ดี
- comptime ของ Zig คือแนวทางที่คอมไพเลอร์มีอินเทอร์พรีเตอร์ขนาดเล็กสำหรับรันบางส่วนของโค้ดในช่วงคอมไพล์
- สามารถระบุให้ฟังก์ชัน พารามิเตอร์
ifและ loop เป็นโค้ดช่วงคอมไพล์ได้ และโค้ดที่ไม่ใช่ comptime จะถูก emit เป็นโปรแกรมจริง - กรณีศึกษา Zig
std printแสดงตัวอย่างการรับ format string เป็นพารามิเตอร์ comptime แล้ว parse ด้วยcomptimeloop เพื่อสร้างโค้ดสำหรับพิมพ์ผลลัพธ์ - การทำงานของ
println!()ใน Rust อาศัยการเรียกฟังก์ชันภายในอย่างformat_args_nlซึ่งผู้เขียนคาดว่าน่าจะถูก hardcode ไว้ในคอมไพเลอร์ - จึงตามมาด้วยคำวิจารณ์ว่าแม้แต่ผู้เขียน Rust compiler เองก็ดูเหมือนไม่อยากใช้ภาษา macro ของ Rust
ข้อเสนอปรับเล็ก ๆ ใน syntax และ standard library
- มีข้อเสนอว่าอยากแก้ปัญหาที่
Range<T>implementCopyเมื่อT: Copy - ปัญหา derive ที่มี associated type ก็ถูกมองว่าเป็นอีกจุดที่ควรแก้
- ประเด็นที่เกี่ยวข้อง: derive with associated types
- ตัวอย่าง: Playground
- มีข้อเสนอว่า expression
if letควรรองรับ logical AND- รูปแบบที่ต้องการ:
if let Some(x) = some_var && some_expr { } - วิธีเลี่ยงในปัจจุบันคือ match tuple แบบ
(some_var, check_foo())แต่check_foo()จะยังทำงานแม้some_varเป็นNoneซึ่งต่างจากพฤติกรรม short-circuit ของifปกติ - ตัวอย่าง: Playground
- รูปแบบที่ต้องการ:
- เรื่อง ergonomics ของ raw pointer ก็ถูกยกเป็นเป้าหมายการปรับปรุง
- กับ reference เราเขียน
myref.xได้ แต่กับ pointer ต้องเขียน(*myptr).xหรือ(*(*myptr).p).y - ผู้เขียนเห็นว่า
unsafecode ควรอ่านและเขียนได้ง่ายที่สุดเท่าที่จะทำได้
- กับ reference เราเขียน
- ยังมีข้อเสนอว่า built-in collection type ควรรับ
Allocatorอย่างชัดเจนใน constructor แทนการพึ่ง global allocator - ส่วน
asyncก็ยังต้องปรับอีกมาก แต่ผู้เขียนบอกว่าเรื่องนี้ใหญ่พอจะต้องแยกไปเขียนอีกบทความหนึ่ง
ความเข้ากันได้และความเป็นไปได้ในการทำจริง
- ข้อเสนอส่วนใหญ่เข้ากันไม่ได้กับ Rust เดิม
- แม้แต่การเพิ่ม security capability ก็ทำให้ crate เกิดเงื่อนไขความล้มเหลวรูปแบบใหม่ที่ทำลายความเข้ากันได้ของ semver จึงต้องใช้ Rust edition ใหม่
- ผู้เขียนบอกว่าถ้าเป็นเมื่อหลายปีก่อนอาจจะเขียน RFC ไปแล้ว แต่ตอนนี้ไม่อยากผ่านกระบวนการคอมเมนต์ RFC บน GitHub อันยาวนาน เพียงเพื่อให้มันกลายเป็นอีกหนึ่งไอเดียที่ไม่ถูกทำจริง
- สุดท้ายจึงย้อนกลับมาที่จินตนาการเรื่องการ fork คอมไพเลอร์เอง แต่ก็ยังติดข้อจำกัดตามความเป็นจริงว่ามีโปรเจ็กต์อื่นที่ต้องทำมากเกินไป
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ตรงข้ามกับคำกล่าวที่ว่ากระบวนการ RFC ของ Rust เป็นสุสานของไอเดียดี ๆ ผมมองว่าการที่ทีมหลักของ Rust ทำให้การเพิ่ม ฟีเจอร์ใหม่ เข้าไปในภาษาโปรแกรมเป็นเรื่องยากนั้นเป็นทิศทางที่ถูกต้องแล้ว
ต้องป้องกันไม่ให้พื้นผิวของภาษาบวมเทอะทะ ขาดความสอดคล้อง และคาดเดาไม่ได้ ตอนแรกผมก็ชอบ Swift แต่พอมีสิ่งต่าง ๆ เพิ่มขึ้นเรื่อย ๆ เช่น ชื่อฟังก์ชันที่ซ้ำซ้อนอย่าง
isMultiple(of:), กฎการ parse วงเล็บปีกกาสำหรับ SwiftUI, กฎเรื่องชนิดแบบอ้างอิง/ค่าและ mutability, การเขียนย่ออาร์กิวเมนต์ของ closure สุดท้ายก็ยอมแพ้ไป ไอเดียดี ๆ มีอยู่ทั่วไป เลยอยากให้ Rust คงความ เพรียวบาง ไว้ให้มากที่สุดตัวอย่างเช่น ฟังก์ชันที่คืนค่า
impl Traitใช้ได้ และ struct ก็มีฟิลด์ใด ๆ ได้ แต่เอาค่าที่คืนมาด้วยimpl Traitไปใส่ในฟิลด์ของ struct ไม่ได้ เพราะตั้งชื่อ type ให้มันไม่ได้if a && bทำได้ และif let Some(x) = xก็ทำได้ แต่if let Some(x) = x && bทำไม่ได้ เรื่องแบบนี้อยากให้แก้ ถ้าวัดตามจำนวนบรรทัดโค้ดของคอมไพเลอร์ Rust คงใหญ่ขึ้น แต่ถ้าวัดตามความซับซ้อนในการเรียนรู้และใช้งาน ช่องโหว่ของฟีเจอร์แบบนี้กลับทำให้ภาษาซับซ้อนยิ่งกว่าเดิมPinไม่ใช่งานใหญ่ในการ implement ไว้ใน standard library แต่ก็ไม่ใช่ฟีเจอร์ที่เพรียวบางเลย และเพิ่มภาระทางความเข้าใจมหาศาล ผมคิดว่าการมี borrow checker ที่ซับซ้อนแต่ได้โค้ด Rust ที่อ่านง่าย ดีกว่าคอมไพเลอร์ที่เรียบง่ายแต่ภาษาใช้งานยากต่อให้ไอเดียที่รออยู่ถูกยอมรับ 100% และสุดท้ายถูกนำไปทำจริง มันก็ยังดูเป็นแบบนั้นเพราะความต่างระหว่างอัตราที่ไอเดียเข้ามากับอัตราที่งานออกไป ถ้าอยากไปให้ไกลกว่าคนเขียน RFC ก็ควรไม่ใช่แค่เขียน RFC แต่ทำ implementation ที่พร้อมใช้ใน production พร้อมเอกสารและเทสต์ ให้สามารถ merge เข้าต้นไม้โค้ดได้อย่างสะอาด
ใช้เป็น const generic ของ type อื่นก็ไม่ได้ ใช้เป็นความยาว array ก็ไม่ได้ ถ้าแค่ต้องการค่า ก็แค่ define ฟังก์ชันให้คืนค่ามาก็พอ ดังนั้นตอนนี้แทบไม่มีประโยชน์ ถ้ารองรับได้ถูกต้อง ก็จะกำจัด crate เสริมอย่าง
generic_array,typenumในไลบรารีเข้ารหัสได้ ถึงอย่างนั้นก็ยังเห็นด้วยว่าทีม Rust ควรระมัดระวังในการเพิ่มฟีเจอร์สถานการณ์เรื่อง dependency ค่อนข้างรุนแรง แต่ดูเหมือนไม่ค่อยมีใครอยากยอมรับ ตัวอย่างที่เพิ่งเห็นคือ crate cargo-watch
แก่นของมันคือแอปง่ายๆ ที่คอยเฝ้าดูการเปลี่ยนแปลงของไฟล์แล้วรันคอมไพเลอร์ใหม่ และตัว implementation ก็ไม่ถึง 1,000 บรรทัด แต่พอ vendor dependency เข้ามา โค้ด Rust กลับพองไปเกือบ 4 ล้านบรรทัด กระจายอยู่ในไฟล์กว่า 8,000 ไฟล์ สำหรับตัวเฝ้าดูไฟล์ธรรมดาๆ แล้วถือว่าเกินไป
https://crates.io/crates/cargo-watch
C/C++ แทบเป็นกรณีเดียวในบรรดาภาษาที่ใช้กันกว้างขวางที่ package manager แบบ npm ยังไม่เป็นกระแสหลัก ดังนั้นไลบรารีส่วนใหญ่จึงมัก self-contained หรือมี dependency น้อยและเป็นทางเลือก
efswคือ file system watcher ภาษา C++ ขนาด 7,000 บรรทัดที่ไม่มี dependency ไลบรารีแบบ single-header ในสาย game programming อย่างstb_*,cgltfและ Dear ImGui เป็นประสบการณ์ที่สบายที่สุดเท่าที่เคยใช้มา ผมคิดว่า package manager รุ่นใหม่อาจได้ประโยชน์โดยรวมจากการ ห้าม transitive dependency ส่วน dependency ที่เป็นไลบรารีขนาดใหญ่ก็ให้ผู้ใช้ติดตั้งเองได้ หรือจัดให้ผ่าน callback หรือทำให้เป็นฟีเจอร์มาตรฐานhttps://github.com/SpartanJ/efsw
https://github.com/nothings/stb
https://github.com/jkuhlmann/cgltf
https://github.com/ocornut/imgui
ผมมองว่า file watcher โดยเฉพาะเมื่อรองรับหลายแพลตฟอร์ม ไม่ได้เรียบง่ายเลย
ส่วนหนึ่งของ
cargo watchดึงไลบรารี wrapper ของ Win32 API เข้ามา ซึ่งเป็น binding ที่สร้างอัตโนมัติสำหรับการเรียก Win32 จึงหลีกเลี่ยงไม่ได้ที่จะมีขนาดใหญ่ standard library ของภาษาส่วนใหญ่ก็มีเป็นล้านบรรทัด และแอปก็ใช้แค่บางส่วนเท่านั้น Boost ของ C++ ก็ใหญ่โตเหมือนสัตว์ประหลาด แต่ developer ก็ดึงมาใช้แค่ extension บางส่วน ที่จริง dependency ขนาดใหญ่จำนวนน้อยกว่าซึ่งมีหลายคนดูแลและพึ่งพา ยังดีกว่านรกแบบ npm ที่แพ็กเกจอย่างisOddหรือis evenเพียงตัวเดียวอาจทำให้ทั้ง ecosystem พังได้ และโดยรวม Rust ค่อนข้างอยู่ฝั่งนั้นมากกว่าdependency ส่วนใหญ่ของ
cargo-watchน่าจะมาจาก requirement โดยตรง 3 ตัวคือclap,cargo_metadata,watchexecclapดึงองค์ประกอบ CLI เฉพาะแพลตฟอร์มเข้ามาเยอะ และcargo_metadataก็เลี่ยงไม่ได้ที่จะดึงตระกูลserdeเข้ามามากwatchexecยังมีช่องให้ปรับปรุง เพราะมันพึ่งพาcommand-groupที่องค์กรเดียวกันดูแล และตัวนี้บังคับต้องใช้ Tokio ปัญหาที่ใหญ่กว่าคือเราแก้ dependency ที่มากเกินไป ของ crate อื่นได้ไม่ง่าย ถ้ามี crate ที่ผูกอยู่กับwatchexecรุ่นเก่า บางครั้งอาจปรับCargo.lockให้เข้ากันได้ แต่โดยทั่วไปทำยากและต้องอ้อมด้วย[patch]คงดีถ้ามีวิธีกำหนด “stand-in” เพื่อแทนที่ crate เวอร์ชันใดเวอร์ชันหนึ่งได้ง่ายๆ แต่สำหรับ package manager ที่มีอยู่แล้ว น่าจะเป็นโจทย์วิจัยใหญ่Rust ไม่ใช่ภาษาหน้าใหม่ที่น่าตื่นเต้นอีกต่อไปแล้ว แต่กำลังเข้าสู่ช่วงมุ่งสู่ การนำไปใช้ในวงกว้าง การที่การพัฒนาฟีเจอร์ช้าลงเป็นเรื่องธรรมชาติและดีต่อสุขภาพของภาษา
ตอนนี้ความผิดพลาดด้านการออกแบบเป็นอันตรายมากกว่าความเร็วที่ต่ำมาก เหตุผลที่ Rust น่าสนใจไม่ใช่เพราะฟีเจอร์เท่ๆ แต่เพราะมันเป็นภาษากลุ่มใหม่ที่ memory-safe, ไม่มี garbage collection และพร้อมใช้ในโปรดักชัน การถูกนำไปใช้จริงในจุดสำคัญน่าสนใจกว่าการทำให้ภาษาดีขึ้นอีก และการที่ผู้คนเชื่อว่ามันถูกบริหารอย่างระมัดระวังก็ทำให้เรื่องนั้นง่ายขึ้น
spread operator, generator function,
async, arrow function,leftpad,Dateใหม่ และรายการฟีเจอร์สำคัญๆ ยังไม่จบแค่นั้น JS เก่ากว่า Rust มากและถูกใช้กว้างขวางกว่ามาก อีกทั้งมี implementation สำหรับโปรดักชันหลายตัว จึงต้องทำให้ฟีเจอร์ใหม่ทั้งหมดสอดคล้องกัน เคยมีช่วงชะงักแถวๆ ES5 แต่ความต่างดูเหมือนอยู่ที่คณะกรรมการมาตรฐาน ECMAScript ตั้งสติได้แล้วRustacean หมกมุ่นกับการเขียนทุกอย่างใหม่ด้วย Rust มาก จนผมนึกจริงๆ ว่าถ้าเป็นบทความเขียน Rust ใหม่ ก็น่าจะเป็น มุกเสียดสีเชิงเมตา
ค่อนข้างแปลกที่บ่นเรื่องการตัดสินใจที่เชื่องช้าก่อน แล้วจึงยกฟีเจอร์หลายอย่างที่เหตุผลว่ายังไม่เสถียรแทบไม่เกี่ยวกับการตัดสินใจเลย
ตัวอย่างเช่น coroutine ติดอยู่เพราะมี edge case ที่แก้ให้ถูกต้องได้ยาก ไม่ใช่ว่ามี implementation ที่เสร็จสมบูรณ์อยู่ในคอมไพเลอร์แล้ว “แค่เปิดใช้ก็พอ” แต่เป็น implementation ที่ยังไม่สมบูรณ์ ซึ่งทำงานได้ในหลายกรณีแต่เปิดในเวอร์ชัน stable ไม่ได้ ส่วน function trait ก็มีการตัดสินใจไว้อย่างชัดเจนแล้วว่าจะไม่ทำให้เสถียรในรูปแบบปัจจุบัน เพราะเหตุผลทางเทคนิคหลายอย่างและปฏิสัมพันธ์กับฟีเจอร์ในอนาคต ถ้าย้อนเวลากลับไปได้ก็คงมีบางส่วนที่ออกแบบต่างไป แต่ส่วนใหญ่เกี่ยวข้องกับการตัดสินใจในยุคแรก ๆ ของ Rust ตอนที่ทีมและทรัพยากรยังเล็กกว่านี้มาก อาจมีแนวคิดว่าเพราะตอนนี้เลือกได้ดีกว่า จึงควรสร้างการแตกหักแบบ Rust 2.0 แต่เพราะหายนะจากการย้าย Python 2 ไป 3 ใหญ่มาก หลายคนจึงเห็นว่าทนกับมุมขรุขระไปดีกว่า ถ้าดูจดหมายข่าวรายสัปดาห์ของ Rust จะเห็นว่าการอนุมัติ RFC และการตัดสินใจทำให้เสถียรถูกจัดการทุกสัปดาห์ มีบางกรณีที่ใช้เวลานานเกินไปจริง ๆ แต่ปัญหาเรื่องคน การประสานงาน และเวลาที่ไม่พอมักแก้ยากกว่าปัญหาทางเทคนิค
บทความนี้ควรอ่านคำตอบใน Reddit ของ Josh Triplett ควบคู่กันด้วย หนึ่งในตัวอย่างหลักของบทความอย่าง Mutex นั้นผิดไปเลย
https://old.reddit.com/r/rust/comments/1fpomvp/rewriting_rus...
แก้ไข: คอมเมนต์เดียวกันอยู่ที่นี่ด้วย
https://news.ycombinator.com/item?id=41655268
สิ่งแรกที่รู้สึกตอนเรียน Rust คือดูเหมือนว่าฟีเจอร์แทบทุกอย่างที่จินตนาการได้มีอยู่ในนั้นแล้ว
ไม่ได้หมายความว่าทีม Rust ไม่เคยปฏิเสธอะไรเลย แต่ถึงอย่างนั้นผู้คนก็ยังต้องการฟีเจอร์มากขึ้น บางอย่างก็มีเหตุผล แต่บางอย่างดูเหมือนเป็นฟีเจอร์ที่นักพัฒนาแค่ 2% จะใช้ และต้องใส่เข้าไปในภาษาที่มีนักพัฒนาแค่ 1% เท่านั้นที่เข้าใจ ภาษาที่ซับซ้อนอยู่แล้วไม่จำเป็นต้องซับซ้อนขึ้นอีก Zig เรียบง่ายกว่า และน่าจะเร็วกว่า อีกทั้งมีดราม่าในชุมชนน้อยกว่ามาก อยากให้มีเงินทุนไหลไปที่ Zig มากกว่านี้
Rust ไม่ได้พยายามใส่ฟีเจอร์ทุกอย่างที่เป็นไปได้ เพียงแต่ว่าถ้าไม่รู้ชัดเจนว่ามันแก้ปัญหาอะไร เช่น GAT หรือ TAIT ก็อาจรู้สึกแบบนั้นได้ Zig อาจเป็นภาษาสมัยใหม่ที่ดี แต่ถ้าเป้าหมายคือ ความปลอดภัยของหน่วยความจำ มันก็ไม่ใช่ตัวเลือก
ตัวอย่างเช่นปฏิสัมพันธ์ระหว่าง
Pinกับ future เพิ่มความซับซ้อนให้ภาษาอย่างไม่สมเหตุสมผล และผมคิดว่าบางส่วนในนั้นไม่จำเป็น อยากให้มีภาษาคล้าย Rust ที่ไม่มีPinเลย ตัว borrow checker เองก็น่าจะมีวิธีทำให้เรียบง่ายขึ้นได้ทั้งในด้านไวยากรณ์และ implementation แต่ยังไม่ได้คิดเป็นรูปธรรม ตอนนี้คงเปลี่ยนได้ยากเว้นแต่จะ fork ภาษา แต่ภาษาที่ใช้ borrow checker จะไม่หยุดอยู่ที่ Rust เป็นภาษาสุดท้าย ผมคาดหวังว่ารุ่นถัดไปยังมีพื้นที่ให้ปรับปรุงได้มาก โดยไม่ต้องสร้างภาษาที่ใหญ่ขึ้นกว่าเดิมผมเห็นดราม่าที่เกี่ยวข้องกับ Rust อยู่บ้าง แต่ส่วนใหญ่มักเกิดจากฝ่ายที่ต่อต้านการใช้หรือการนำ Rust ไปใช้ เช่นความวุ่นวายเรื่อง Rust for Linux เมื่อไม่นานนี้ ดูไม่เหมือนว่าเป็นเรื่องปกติภายในชุมชน แต่อาจมีอะไรที่ผมพลาดไป Zig ยอดเยี่ยม แต่ยังไม่ถึงขั้น พร้อมใช้งานจริงในโปรดักชัน
ผู้เขียนคอมเมนต์ที่ลิงก์ไว้ได้วิเคราะห์ primitive สำหรับการซิงโครไนซ์ ของหลายภาษาอย่างกว้างขวาง แล้วเขียน primitive สำหรับการซิงโครไนซ์ อย่าง
MutexและRwLockของ Rust ขึ้นใหม่ให้ใช้ฟังก์ชันดิบของ OS ระดับล่างโดยตรงในแต่ละระบบปฏิบัติการเขาใช้สิ่งอย่าง
futexของ Linux เพื่อทำให้เร็วขึ้น เล็กลง และโดยรวมดีขึ้น และในกระบวนการนั้นก็แทบจะเขียนหนังสือเรื่องการเขียนโปรแกรมขนานด้วย Rust ไปด้วย หนังสือนี้มีประโยชน์แม้กับการเขียนโปรแกรมขนานที่ไม่ใช่ Rusthttps://www.oreilly.com/library/view/rust-atomics-and/978109...
ตลอด 7 ปีที่ผ่านมาไม่ได้เอาแต่เล่นอยู่กับฝั่ง coroutine เท่านั้น มีการเพิ่มฟีเจอร์หลายอย่างที่จำเป็นต่อการทำให้ฟังก์ชัน async, trait ที่สามารถมีฟังก์ชัน async ได้, และ
AsyncWriteกับAsyncReadเป็นมาตรฐาน และใน nightly ก็มีการ implement generator แล้วด้วย ขณะนี้ยังถกกันอยู่ว่าจะรับความซับซ้อนของ coroutine แบบทั่วไปเต็มรูปแบบหรือจะหยุดแค่ generator ฟีเจอร์บางอย่างอย่างAsyncIteratorเดินช้า แต่ก็มีงานจำนวนมากที่กำลังดำเนินอย่างคึกคัก น่าสนใจเสมอที่ฝั่งหนึ่งบอกว่าภาษาช้าเกินไป ส่วนอีกฝั่งบ่นว่าเร็วเกินไปช่วงหลังมีการสำรวจการออกแบบครั้งใหญ่ในเรื่อง function trait และ effect system ด้วย และอยากหาแนวทางที่ไม่ต้องเขียนฟังก์ชันซ้ำหลายครั้งสำหรับแต่ละชุดผสมของ
async,try,constการ sandbox crate ที่เป็นอันตรายไม่ใช่ปัญหาระดับภาษา แต่ต้องอาศัยการผสมกันระหว่าง verifier กับ runtime sandbox และ WebAssembly component ดูมีความเป็นไปได้มากกว่า อย่างไรก็ตาม มีความสนใจอย่างมากใน capability ตอนคอมไพล์ เช่น allocator หรือการเลือกรันไทม์ async และสมมติฐานว่าเป็นแพลตฟอร์ม 64 บิต ส่วน proc macro sandboxing ต้องการเพื่อการแคชที่ถูกต้อง ไม่ใช่เพื่อป้องกันเจตนาร้ายstruct แบบอ้างอิงตัวเองไม่ใช่ปัญหาไวยากรณ์ แต่เป็นปัญหาที่ยากมากสำหรับ borrow checker จะจัดการ partial borrow นั้นรองรับอยู่แล้วในการ capture ของ closure แต่ประเด็นสำคัญคือจะรักษา semantic versioning ที่เสถียรอย่างไรเมื่อเปิดเผยผ่าน API สาธารณะ แนวทางอย่าง “borrow group” ที่มีชื่อดูมีโอกาสสูง ฝั่ง
comptimeก็มีงานหลายทิศทาง และ RFC สำหรับเสริมความสามารถของmacro_rulesก็เพิ่งเขียนเมื่อไม่นานมานี้implของRangeก็กำลังเดินหน้าอยู่แล้ว โดยผูกกับการเปลี่ยนแปลงแบบไม่เข้ากันผ่าน edition การรวมif letกับ logical AND มีเป็นฟีเจอร์ unstable อยู่และใกล้จะ stabilize แล้ว ข้อเสนอปรับปรุงไวยากรณ์การเข้าถึง field ของ pointer ก็มีหลายข้อ แต่ยังเป็นคำถามเปิดอยู่ว่าการเพิ่มพื้นผิวของภาษาอีกจะได้มากกว่าเสียหรือไม่ ด้วย Rust edition ถ้ามีการออกแบบที่น่าเชื่อถือเพียงพอ ก็ยังมีหลายอย่างที่เปลี่ยนได้ ฟีเจอร์ unstable 700 รายการเป็นปัญหาจริง จึงต้องมีงานใหญ่ในการจัดระเบียบสิ่งที่มีโอกาสต่ำจะ stabilizemacro ที่เป็น declarative มากขึ้น, การมอบ logic ของ crate
-sysให้ shared library,cfg(version)/cfg(accessible)จะช่วยลดความจำเป็นที่ผู้ใช้ต้อง implement เองได้มาก แต่ runtime ก็ยังคงเหลืออยู่ ยิ่งคิดก็ยิ่งรู้สึกว่า ACL ของ cackle ดูเป็นแนวทางที่ขยายได้ดี สำหรับติดตามงานทั้งใน proc macro, build script และโค้ด runtime รวมถึง audit การใช้งาน dependency tree ได้ยินมาว่าcargo-redpenก็กำลังพัฒนาเป็นเครื่องมือ audit การเรียกใช้งานเช่นกัน แต่กำลังจินตนาการถึงระดับที่สูงกว่านั้นแบบ cacklehttps://github.com/cackle-rs/cackle
ผมเข้าใจที่ผู้คนบ่นว่า Rust เป็นภาษาขนาดใหญ่และไม่อยากให้มันใหญ่ขึ้นอีก แต่การคง async implementation ที่สุก ๆ ดิบ ๆ ในปัจจุบันไว้ไม่ได้ทำให้ภาษามีขนาดเล็กลงหรือเรียบง่ายขึ้น มันแค่ทำให้ภาษาแย่ลงเท่านั้น partial borrow ถ้าไม่ได้ทำงานใน API สาธารณะ อย่างน้อยทำงานได้ภายใน crate เดียวกันก็ยังดี นี่เป็นปัญหาที่เจอตลอดในการเขียนโปรแกรมจริง และผมก็สงสัยว่าทำไมการ sandbox crate ที่เป็นอันตรายถึงเป็นไปไม่ได้ในระดับภาษา ถ้าฟังก์ชันหนึ่งไม่มีโค้ด third-party แบบ
unsafeอยู่ใน call tree และไม่ทำ system call มันก็น่าจะจัดการได้เฉพาะอาร์กิวเมนต์ที่ส่งเข้ามา ตัวแปร local และ global ในสโคปที่มีอยู่แล้วเท่านั้น ถ้าสามารถเสริมกำแพงนี้ให้ใช้เป็นขอบเขตความปลอดภัยได้ ก็ดูเหมือนจะลดความเสี่ยงของ supply chain ฝั่ง dependency ได้อย่างมากภารกิจของ Rust ตั้งแต่แรกคือเรื่องยากในการผสมผสาน performance, safety และ expressiveness เข้าด้วยกัน และเมื่อ Mozilla ถอนตัว Rust ก็สูญเสีย founder mode ไป อีกทั้งทีมแกนหลักดั้งเดิมส่วนใหญ่ก็ออกไปแล้ว จึงไม่น่าแปลกใจที่ความคืบหน้าช้าลง
โดยส่วนตัวผมมองว่ายังดีกว่าเดินผิดทาง
ถ้าเขียน Rust ใหม่ ผมคิดว่าน่าจะไปทางลดฟีเจอร์มากกว่าเพิ่มฟีเจอร์
คล้ายกับที่ QBE ทำกับ LLVM คือให้ Rust 70% ด้วยโค้ด 10% ถ้าตัด macro กับฟีเจอร์บางอย่างที่ไม่ค่อยใช้ อาจเป็นไปได้ก็ได้
https://c9x.me/compile/
ไม่ว่าจะเป็น Rust หรืออะไรอื่น ก่อนจะลองทำจริงก็ยากจะรู้ว่าเป็นไปได้แค่ไหน