แก่นสำคัญของ Rust
(jyn.dev)- Rust เป็นภาษาแบบ แนวคิดหลากหลายส่วนถักทอกันอย่างแน่นแฟ้น ทำให้แม้แต่การเข้าใจโปรแกรมพื้นฐานก็ต้องเรียนรู้หลายองค์ประกอบไปพร้อมกัน
- ฟังก์ชัน, เจเนอริก, enum, pattern matching, trait, reference, ownership,
Send/Sync,Iteratorฯลฯ ล้วนเป็น องค์ประกอบแกนหลักที่ถูกออกแบบให้ทำงานร่วมกัน - เมื่อเทียบกับ JavaScript แล้ว JS สามารถเขียนโค้ดได้แม้รู้เพียงบางแนวคิด แต่ Rust นั้น ต้องเข้าใจบริบทของภาษาทั้งระบบก่อน จึงจะเขียนโค้ดที่มีความหมายได้
- ความซับซ้อนลักษณะนี้ของ Rust แม้จะเพิ่มกำแพงในการเรียนรู้ แต่ก็ให้ทั้ง ความปลอดภัยและความสม่ำเสมอ และส่งผลอย่างมากต่อวิธีออกแบบโค้ด
- โครงสร้างทางภาษาที่ร้อยเรียงกันเช่นนี้ทำให้ Rust มีความพิเศษ และวิสัยทัศน์ของ “Rust ที่เล็กลง” ก็ชวนให้กลับมาทบทวน ปรัชญาภาษาที่เชื่อมประสานกันอย่างประณีต
ความยากในการเรียนรู้ Rust
- แม้ Rust จะมีอุปสรรคในการเริ่มต้นสูง แต่ก็มีผู้คนจำนวนมากร่วมกันพัฒนา เอกสาร, API และการวินิจฉัยข้อผิดพลาด มาโดยตลอด
- แนวคิดพื้นฐานได้แก่ ฟังก์ชันแบบ first-class, enum, pattern matching, เจเนอริก, trait, reference, borrow checker, ความปลอดภัยด้านการทำงานพร้อมกัน, iterator เป็นต้น
- แนวคิดเหล่านี้ พึ่งพาและพันเกี่ยวกัน จนยากจะเรียนรู้แยกเป็นส่วน ๆ และไลบรารีมาตรฐานเองก็ใช้ความสามารถเหล่านี้เกือบทั้งหมด
- ต่อให้เป็นโค้ด Rust ยาวเพียงราว 20 บรรทัด ก็ยังต้องเข้าใจหลายองค์ประกอบพร้อมกัน เช่นกระบวนทัศน์เชิงฟังก์ชัน,
Resultและการจัดการข้อผิดพลาด, ชนิดข้อมูลแบบเจเนอริก, enum, iterator เป็นต้น
เปรียบเทียบ Rust กับ JavaScript
- เมื่อเขียนโปรแกรมตรวจจับการเปลี่ยนแปลงไฟล์แบบเดียวกันด้วย Rust และ JS จะเห็นว่า Rust มีแนวคิดทางภาษาจำนวนมากที่เชื่อมโยงกันอยู่
- โดยพื้นฐานแล้ว JS สามารถเขียนโค้ดที่ทำงานได้ หากเข้าใจเพียง ฟังก์ชันและการจัดการ null
- นี่ไม่ได้หมายความว่า Rust แค่ยากกว่าเท่านั้น แต่แสดงให้เห็นว่า Rust เป็น ภาษาที่ออกแบบมาให้ต้องอาศัยความเข้าใจเชิงโครงสร้างของทั้งระบบภาษา
การออกแบบแบบเชื่อมโยงถึงกันของ Rust
- แก่นของ Rust คือ การผสานกันของฟีเจอร์ที่ถูกออกแบบอย่างเป็นองค์รวม
- enum จะใช้งานได้ไม่สะดวกหากไม่มี pattern matching และ pattern matching เองก็มีข้อจำกัดหากไม่มี enum
ResultและIteratorจะไม่สามารถถูกทำให้เกิดขึ้นได้เลยหากไม่มีเจเนอริก- แนวคิด
Send/Syncและข้อจำกัดของprintlnจะถูกแสดงอย่างปลอดภัยไม่ได้หากไม่มี trait - borrow checker รับประกันความปลอดภัยของ
Send/Syncผ่านการวิเคราะห์การ capture ของ closure
- การเชื่อมโยงถึงกันแบบนี้ทำให้ Rust ไม่ได้เป็นเพียงชุดของฟีเจอร์ แต่เป็นระบบภาษาที่บูรณาการกัน
วิสัยทัศน์ของ Rust ที่เล็กลง
- ในปี 2019 without.boats ได้กล่าวถึง “Smaller Rust” พร้อมอภิปรายถึงความเป็นไปได้ของ Rust ที่เล็กและผ่านการขัดเกลา
- ปัจจุบัน Rust เติบโตขึ้นมากแล้ว แต่แนวคิดของ Rust ที่เล็กลงยังคงช่วย เตือนให้เห็นแก่นแท้ของการออกแบบภาษาที่ประกบกันอย่างประณีต
- เสน่ห์ของ Rust อยู่ที่องค์ประกอบทางภาษาซึ่งเป็นอิสระต่อกัน แต่เมื่อรวมเข้าด้วยกันแล้วกลับมอบทั้ง พลังในการแสดงออกและความปลอดภัย
บทสรุป
- Rust เรียนรู้ได้ยาก แต่ ความสม่ำเสมอและความเป็นเนื้อเดียวกันของแนวคิดที่ถักทอกันอยู่ คือจุดแข็งสำคัญ
- ด้วยโครงสร้างนี้ Rust จึงทำให้นักพัฒนาไม่ได้เพียงแค่เขียนโค้ด แต่ยังมี วิธีคิดที่คำนึงถึงทั้งความปลอดภัยและประสิทธิภาพไปพร้อมกัน
- แก่นแท้ของ Rust อยู่ที่ “ภาษาหลักขนาดเล็กที่ประณีต” และนี่ก็ยังเป็นปรัชญาสำคัญแม้ใน Rust ที่ขยายตัวขึ้นในปัจจุบัน
1 ความคิดเห็น
ความเห็นจาก Hacker News
fs.watchระบุชัดว่าต้องตรวจสอบเสมอว่าfilenameใน callback อาจเป็นnullได้ ถ้าเป็น Rust ข้อเท็จจริงนี้จะถูกสะท้อนอยู่ใน type system และบังคับให้จัดการ แต่ใน JS มันเขียนโค้ดแบบหยาบๆ ได้ง่าย เอกสารที่เกี่ยวข้องnullด้วย เลยคิดว่านี่เป็นตัวอย่างที่ดีว่า TS เป็นขั้นกลางที่ภาระไม่มากนัก แต่ขยับเข้าใกล้ความถูกต้องแบบฝั่ง Rust มากขึ้นในโลก JSfor path in pathsต้องเป็นfor (const path of paths)JS จะโยน error ทันทีถ้าไม่มีวงเล็บก็จริง แต่ความต่างระหว่างinกับofคือinจะวนผ่าน index ของ iterable ไม่ใช่ค่าจริง ทำให้ index ถูกแปลงเป็น string แล้วหลุดเข้าไปเป็นอาร์กิวเมนต์ตัวแรกของfs.watchได้เลย และแม้แต่ TypeScript ก็อาจจับความผิดพลาดนี้ไม่ได้kindมาจากไหน ในconsole.log("${kind} ${filename}")มันควรเป็นeventType(string) มากกว่าkindprintlnของ Rust พิมพ์ได้เฉพาะ type ที่ implement traitDisplayหรือDebugเท่านั้น เพราะงั้นPathจึงพิมพ์ตรงๆ ไม่ได้ ไม่ใช่ว่าทุก OS จะเก็บ path เป็น UTF-8 และ string type ของ Rust ก็เป็น UTF-8 ทั้งหมด นั่นแปลว่าการพิมพ์Pathอาจทำให้ข้อมูลสูญหายได้Pathจึงมีเมธอดdisplayที่คืน type ซึ่ง implementDisplayRust ฝังเรื่องนี้ไว้ใน type system แต่ใน JS/TS มันยากที่จะระบุให้ชัดว่า string ภายในเป็น UTF-16 และ path ที่ไม่ใช่ Unicode ต้องใช้TextEncoder/TextDecoderเองถึงจะจัดการได้ถูกต้อง จากประสบการณ์เก่าๆ ถ้าเซิร์ฟเวอร์ส่งข้อความมาเป็น Shift_JIS แล้วไปอ่านด้วยresponse.text()ใน runtime จะได้แค่ string ว่างๆ ถ้าไม่คุ้นกับปัญหา encoding ก็อาจเสียเวลา debug ไปหลายวันได้ และตัวอย่าง JS ก็มีทั้งบั๊กและ syntax error ที่ไม่มีในโค้ด Rust (ในลูปต้องใช้for-ofแทนfor-in) จะบอกว่าตัวอย่างนี้ใช้แค่ "ฟังก์ชันชั้นหนึ่ง" อย่างเดียวก็คงไม่ใช่ เพราะยังต้องเข้าใจ iterator แบบ Rust ด้วย และยังใช้ CommonJS อีก นอกจากนี้ยังต้องเรียนรู้async/await, Promises และ top-level await ด้วย โดย top-level await เพิ่งมีในบาง runtime รวมถึง node ไม่นานมานี้เอง และยังไม่มีใน JS engine บางตัว (เช่น Hermes ของ React Native)นี่แหละเหตุผลที่ผมยังใช้ Rust ต่อไป ตัวอย่างนี้เป็นแค่กรณีหนึ่ง แต่ปัญหาเล็กๆ และกับดักแบบนี้มีอยู่เต็มไปหมดในภาษาอื่นๆ แต่ละอย่างอาจไม่เกิดขึ้นเสมอไป ทว่าพอรวมกันตลอดอายุของโปรแกรม ก็จะมีบั๊กประหลาดโผล่มาเรื่อยๆ จากที่ไหนไม่รู้และต้องคอยตามหาอยู่ตลอด ใน Rust เรื่องแบบนี้ไม่เกิดขึ้น type system ช่วยกันกรณีไร้สาระได้ล่วงหน้าเป็นจำนวนมหาศาล จริงๆ แล้วพอปล่อยซอฟต์แวร์ที่เขียนฟีเจอร์ครบด้วย Rust ออกไป ผมก็มีแค่เพิ่มฟีเจอร์เป็นครั้งคราว และแทบไม่ต้องเหนื่อยกับการไล่บั๊กทั่วไปอีกเลย แน่นอนว่าบั๊กเชิงตรรกะเกิดได้ทุกที่ แต่ปัญหาจากความไม่ตรงกันของ type/โครงสร้างแบบโง่ๆ ที่เจอบ่อยในภาษาอื่นถูกตัดทิ้งไปตั้งแต่ต้น ทำให้ productivity และการดูแลรักษาเป็นคนละประสบการณ์เลย
ส่วนตัวรู้สึกว่าใน JS/TS มีนักพัฒนาไม่มากที่เข้าใจ thenable/Promise กับ async-await แบบลึกจริงๆ เคยเห็นอะไรแบบนี้ด้วย:
คือเอา wrapper แบบ callback ไปห่อเป็น Promise แล้วก็เอากลับมาใช้ใน async function อีกที เห็นแบบนี้ทีไรปวดใจมาก และเจอโค้ดลักษณะนี้ตามที่ต่างๆ จริงๆ ยิ่งถ้าคิดถึงการ import โมดูล,
async import(), transpile, code splitting ฯลฯ ก็ยิ่งซับซ้อนจริงๆ-Zscriptแล้วไปไล่อ่านต่อ มันทำมาตั้งแต่ปี 2023 และมี open issue ที่ดูใกล้เสร็จมากแล้วด้วย ผมยังเห็นใน repo ของ ZomboDB ว่าใช้ rust จัดการ build pipeline เช่นกัน แต่ยังไม่ได้เข้าใจบริบททั้งหมด อยากบอกด้วยว่า cargo frontmatter มีประโยชน์มากในแง่ portability ของสคริปต์ แค่แชร์ไฟล์เดียวก็พอ และสามารถดึง dependency มาใช้ได้ทันทีโดยไม่ต้องติดตั้ง/initialization เพิ่มแบบ Python หรือ Node.js#!/some/pathเชลล์ก็แค่ส่งทั้งไฟล์เข้าstdinของคำสั่งที่ระบุแล้วรันasyncกับconstถ้างั้นก็บอกตรงๆ ไปเลยว่า "Rust ก่อนมี async กับ const เล็กและสะอาดกว่า" จะชัดกว่า แต่ในบทความไม่ได้อธิบายตรงขนาดนั้น ซึ่งน่าเสียดายCopytrait, reborrowing, deref coercion, การinto_iterอัตโนมัติในลูป, การเรียกdropอัตโนมัติเมื่อออกจาก scope (จะให้เรียกเองหรือให้คอมไพเลอร์ error แทนก็ได้), ค่าเริ่มต้น:Sizedใน trait bound, lifetime elision, match ergonomic และตัวช่วยอัตโนมัติ/ความสะดวกอื่นๆ ออกไป ก็จะได้ Rust ที่ง่ายแบบเชิงกลไกจริงๆ แต่ภาษานั้นจะใช้งานในชีวิตประจำวันได้อึดอัดมาก และก็น่าขำที่สิ่งเหล่านี้จริงๆ ออกแบบมาเพื่อช่วยมือใหม่asyncและconstนั้นเล็กและสะอาดกว่า เหตุผลที่ไม่พูดตรงๆ เพราะมีเพื่อนหลายคนที่ทำงานพัฒนาฟีเจอร์เหล่านั้น Matklad อธิบายไว้ดีมากใน lobste.rs ว่า Rust ปี 2015 มีความสำเร็จรูปและความสอดคล้องกันมากกว่า แต่ vision ของ Rust ไม่ใช่ความสอดคล้องสมบูรณ์แบบ (coherence) หากเป็นการเป็นภาษาที่ใช้งานได้จริงในภาคอุตสาหกรรม.into()กับ traitFromก็ทำให้การแปลง type เกิดขึ้นแบบลับๆ เกินไป มาตรฐานไลบรารีเองก็มีฟังก์ชัน "อำนวยความสะดวก" แบบนี้เยอะ สุดท้าย type ของอ็อบเจ็กต์จึงคลุมเครือ และเชื่อมโยงการเรียกฟังก์ชันกับ implementation ได้ยากขึ้น (แน่นอนว่า IDE ช่วยได้บ้าง)constอาจช่วยลดภาระในการต้องมาแก้นิสัยที่เรียนผิดมาจากภาษาเดิมในภายหลังด้วยmemดังนั้นถ้าอยากเข้าใจโครงสร้างของ interface ให้ชัด แนะนำให้เริ่มจาก std::mem