Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Rust Programming Language

โดย Steve Klabnik, Carol Nichols, และ Chris Krycho พร้อมด้วยผลงานจากชุมชน Rust

หนังสือฉบับนี้สมมติว่าคุณกำลังใช้ Rust 1.96.0 (เปิดตัวเมื่อ 2026-05-28) หรือใหม่กว่า ร่วมกับ edition = "2024" ในไฟล์ Cargo.toml ของโปรเจกต์ทั้งหมดเพื่อกำหนดค่าให้ใช้สำนวนของ Rust 2024 Edition ดูหัวข้อ “การติดตั้ง” ในบทที่ 1 สำหรับคำแนะนำในการติดตั้งหรืออัปเดต Rust และดู ภาคผนวก E สำหรับข้อมูลเกี่ยวกับรุ่น (editions)

รูปแบบ HTML สามารถใช้งานทางออนไลน์ได้ที่ https://doc.rust-lang.org/stable/book/ และใช้งานแบบออฟไลน์ผ่านการติดตั้ง Rust ด้วย rustup; รัน rustup doc --book เพื่อเปิดใช้งาน

นอกจากนี้ยังมีหนังสือฉบับ แปลโดยชุมชน ในภาษาอื่นๆ อีกด้วย

ฉบับนี้มีจำหน่ายใน รูปแบบหนังสือเล่มปกอ่อนและอีบุ๊กจาก No Starch Press

🚨 ต้องการประสบการณ์การเรียนรู้แบบมีปฏิสัมพันธ์ (interactive) มากขึ้นหรือไม่? ลองใช้หนังสือ Rust Book เวอร์ชันอื่นที่มี: แบบทดสอบ, การเน้นข้อความ, ภาพจำลองภาพประกอบ (visualizations), และอื่นๆ: https://rust-book.cs.brown.edu

คำนิยม

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

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

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

หนังสือ The Rust Programming Language ฉบับนี้เป็นการอัปเดตฉบับสมบูรณ์ ซึ่งสะท้อนถึงวิวัฒนาการของภาษาตลอดหลายปีที่ผ่านมา และมอบข้อมูลใหม่ๆ ที่มีคุณค่า แต่มันไม่ได้เป็นเพียงคู่มือแนะนำไวยากรณ์และไลบรารีเท่านั้น—แต่มันคือคำเชิญชวนให้เข้าร่วมชุมชนที่ให้คุณค่าแก่คุณภาพ ประสิทธิภาพ และการออกแบบอย่างพิถีพิถัน ไม่ว่าคุณจะเป็นนักพัฒนามากประสบการณ์ที่ต้องการสำรวจ Rust เป็นครั้งแรก หรือเป็น Rustacean ผู้มีประสบการณ์ที่ต้องการขัดเกลาทักษะของคุณ หนังสือฉบับนี้มีบางสิ่งบางอย่างสำหรับทุกคน

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

ยินดีต้อนรับสู่ชุมชน Rust!

  • Bec Rumbul, ผู้อำนวยการบริหาร Rust Foundation

บทนำ

หมายเหตุ: หนังสือฉบับแปลนี้อิงตามเนื้อหาเดียวกันกับหนังสือ The Rust Programming Language ซึ่งจัดพิมพ์ในรูปแบบสื่อสิ่งพิมพ์และหนังสืออิเล็กทรอนิกส์ (ebook) โดยสำนักพิมพ์ No Starch Press

ยินดีต้อนรับสู่หนังสือ The Rust Programming Language ซึ่งเป็นหนังสือแนะนำเบื้องต้นเกี่ยวกับภาษา Rust ภาษาโปรแกรม Rust จะช่วยให้คุณเขียนซอฟต์แวร์ได้รวดเร็วและมีความน่าเชื่อถือยิ่งขึ้น การออกแบบภาษาโปรแกรมมักมีความขัดแย้งกันระหว่างความสะดวกสบายในการใช้งานระดับสูง (high-level ergonomics) และความสามารถในการควบคุมระดับต่ำ (low-level control) แต่ภาษา Rust ได้ท้าทายข้อจำกัดความขัดแย้งนั้น ด้วยการสร้างความสมดุลระหว่างความสามารถทางเทคนิคที่ทรงพลังและประสบการณ์การพัฒนาที่ยอดเยี่ยม Rust จึงมอบทางเลือกให้คุณควบคุมรายละเอียดในระดับต่ำ (เช่น การใช้งานหน่วยความจำ) ได้โดยไม่สร้างความยุ่งยากเหมือนการควบคุมในรูปแบบเดิมๆ

ภาษา Rust เหมาะสำหรับใคร

ภาษา Rust เหมาะสมสำหรับผู้คนจำนวนมากด้วยเหตุผลที่หลากหลาย เรามาลองดูกลุ่มบุคคลที่สำคัญที่สุดบางส่วนกัน

ทีมงานนักพัฒนาโปรแกรม

ภาษา Rust กำลังพิสูจน์ให้เห็นว่าเป็นเครื่องมือที่มีประสิทธิภาพสำหรับการทำงานร่วมกันในทีมนักพัฒนาขนาดใหญ่ซึ่งมีระดับความรู้ด้านการเขียนโปรแกรมระบบ (systems programming) ที่แตกต่างกัน โค้ดในระดับต่ำมีแนวโน้มที่จะเกิดข้อผิดพลาดที่ซับซ้อนและตรวจหาได้ยาก ซึ่งในภาษาอื่นๆ ส่วนใหญ่มักจะตรวจพบได้ก็ต่อเมื่อผ่านการทดสอบอย่างละเอียดถี่ถ้วนและการตรวจสอบโค้ดอย่างระมัดระวังโดยนักพัฒนาที่มีประสบการณ์สูงเท่านั้น แต่สำหรับใน Rust คอมไพเลอร์จะทำหน้าที่เสมือนเป็นผู้เฝ้าประตูโดยปฏิเสธการคอมไพล์โค้ดที่มีข้อผิดพลาดแฝงเหล่านั้น ซึ่งรวมถึงข้อผิดพลาดในการทำงานพร้อมกัน (concurrency bugs) ด้วยการทำงานประสานร่วมกับคอมไพเลอร์ ทีมงานจึงสามารถใช้เวลาไปกับการมุ่งเน้นที่ตรรกะการทำงานของโปรแกรมแทนการคอยไล่ตามแก้จุดบกพร่อง

นอกจากนี้ Rust ยังได้นำเครื่องมือพัฒนาร่วมสมัยมาสู่โลกของการเขียนโปรแกรมระบบอีกด้วย:

  • Cargo ซึ่งเป็นเครื่องมือจัดการไลบรารีที่ต้องใช้ (dependency manager) และเครื่องมือสร้างระบบ (build tool) ที่รวมมาให้ ช่วยให้การเพิ่ม การคอมไพล์ และการจัดการส่วนที่ต้องพึ่งพาทำได้อย่างราบรื่นและสอดคล้องกันทั่วทั้งระบบนิเวศของ Rust
  • เครื่องมือจัดรูปแบบโค้ด rustfmt ช่วยรับประกันว่ารูปแบบการเขียนโค้ดของนักพัฒนาทุกคนจะเป็นไปในแนวทางเดียวกัน
  • Rust Language Server ช่วยรองรับการเชื่อมต่อกับสภาพแวดล้อมการพัฒนาแบบรวม (IDE) เพื่อช่วยเติมโค้ดอัตโนมัติและการแสดงข้อความแจ้งข้อผิดพลาดในบรรทัดพิมพ์โค้ด

ด้วยการใช้เครื่องมือเหล่านี้และเครื่องมืออื่นๆ ในระบบนิเวศของ Rust นักพัฒนาจึงสามารถทำงานได้อย่างมีประสิทธิภาพในขณะที่เขียนโค้ดในระดับระบบ (systems-level code)

นักเรียนและนักศึกษา

ภาษา Rust เหมาะสำหรับนักเรียน นักศึกษา และผู้ที่สนใจเรียนรู้เกี่ยวกับแนวคิดการเขียนโปรแกรมระบบ ผู้คนจำนวนมากได้เรียนรู้เกี่ยวกับหัวข้อต่างๆ เช่น การพัฒนาโครงสร้างระบบปฏิบัติการผ่านการใช้ Rust ชุมชนผู้ใช้ของเรามีความเป็นกันเองและยินดีตอบคำถามของผู้เรียนเสมอ ด้วยความมุ่งมั่นผ่านหนังสือเล่มนี้ ทีมงาน Rust ต้องการช่วยให้แนวคิดการเขียนโปรแกรมระบบเป็นเรื่องที่เข้าใจได้ง่ายและเข้าถึงได้สำหรับผู้คนจำนวนมากขึ้น โดยเฉพาะผู้ที่เป็นมือใหม่ในการเขียนโปรแกรม

บริษัทและองค์กรธุรกิจ

บริษัทหลายร้อยแห่งทั้งขนาดใหญ่และขนาดเล็กเลือกใช้ Rust ในระบบงานจริงสำหรับงานประเภทต่างๆ รวมถึงเครื่องมือบรรทัดคำสั่ง (command line tools), บริการเว็บ (web services), เครื่องมือ DevOps, อุปกรณ์ฝังตัว (embedded devices), การวิเคราะห์และแปลงรหัสไฟล์เสียงและวิดีโอ (audio and video analysis and transcoding), คริปโทเคอร์เรนซี (cryptocurrencies), ชีวสารสนเทศศาสตร์ (bioinformatics), โปรแกรมค้นหา (search engines), แอปพลิเคชันอินเทอร์เน็ตของสรรพสิ่ง (Internet of Things applications), การเรียนรู้ของเครื่อง (machine learning) และแม้กระทั่งส่วนประกอบหลักของเว็บเบราว์เซอร์ Firefox

นักพัฒนาโอเพนซอร์ส

ภาษา Rust เหมาะสำหรับผู้ที่ต้องการร่วมสร้างภาษาโปรแกรม Rust, ชุมชน, เครื่องมือสำหรับนักพัฒนา และไลบรารีต่างๆ พวกเรายินดีต้อนรับการเข้ามามีส่วนร่วมช่วยเหลือปรับปรุงพัฒนาภาษา Rust จากคุณเสมอ

ผู้ที่ให้ความสำคัญกับความเร็วและเสถียรภาพ

ภาษา Rust เหมาะสำหรับผู้ที่ต้องการความเร็วและเสถียรภาพจากภาษาโปรแกรม ความเร็วในที่นี้หมายถึงทั้งความรวดเร็วในการประมวลผลทำงานของโค้ด Rust และความเร็วที่ Rust ช่วยให้คุณสามารถเขียนโปรแกรมขึ้นมาได้ การตรวจสอบของคอมไพเลอร์ Rust ช่วยรับประกันเสถียรภาพในขณะที่มีการเพิ่มคุณลักษณะใหม่และการปรับปรุงโครงสร้างโค้ด (refactoring) ซึ่งตรงกันข้ามกับปัญหาโค้ดมรดกตกทอดที่เปราะบางในภาษาที่ไม่มีการตรวจสอบเหล่านี้ ซึ่งนักพัฒนามักจะไม่กล้าเข้าไปแตะต้องแก้ไข ด้วยความมุ่งมั่นสร้างนามธรรมที่ไม่มีค่าใช้จ่ายเพิ่ม (zero-cost abstractions) ซึ่งเป็นการทำงานระดับสูงที่สามารถคอมไพล์ลงไปเป็นโค้ดระดับต่ำที่มีความเร็วเทียบเท่ากับโค้ดที่เขียนขึ้นด้วยมือ Rust จึงพยายามทำให้โค้ดที่ปลอดภัยกลายเป็นโค้ดที่ทำงานได้รวดเร็วเช่นเดียวกัน

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

หนังสือเล่มนี้เหมาะสำหรับใคร

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

วิธีการใช้งานหนังสือเล่มนี้

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

คุณจะพบบทเรียนสองประเภทในหนังสือเล่มนี้ ได้แก่ บทเชิงแนวคิด (concept chapters) และบทเชิงโครงการ (project chapters) ในบทเชิงแนวคิด คุณจะได้เรียนรู้เกี่ยวกับแง่มุมต่างๆ ของ Rust ส่วนในบทเชิงโครงการ เราจะลงมือสร้างโปรแกรมขนาดเล็กไปด้วยกันเพื่อประยุกต์ใช้สิ่งที่คุณได้เรียนรู้มาทั้งหมด โดยบทที่ 2, บทที่ 12 และบทที่ 21 จะเป็นบทเรียนเชิงโครงการ ส่วนบทที่เหลือจะเป็นบทเรียนเชิงแนวคิด

บทที่ 1 อธิบายวิธีการติดตั้ง Rust, วิธีการเขียนโปรแกรมแรกอย่าง “Hello, world!” และวิธีการใช้งาน Cargo ซึ่งเป็นระบบจัดการแพ็กเกจและเครื่องมือสร้างระบบของ Rust บทที่ 2 เป็นการแนะนำภาคปฏิบัติในการเริ่มเขียนโปรแกรมในภาษา Rust โดยคุณจะได้สร้างเกมทายตัวเลข ในบทนี้เราจะอธิบายแนวคิดในภาพรวมระดับสูงก่อน แล้วบทต่อๆ ไปจะลงลึกรายละเอียดเพิ่มเติม หากคุณต้องการทดลองลงมือเขียนโค้ดในทันที บทที่ 2 คือจุดเริ่มต้นที่เหมาะสมสำหรับคุณ แต่หากคุณเป็นผู้เรียนที่ใส่ใจในรายละเอียดเป็นพิเศษและต้องการเรียนรู้รายละเอียดทั้งหมดก่อนที่จะก้าวไปสู่ขั้นถัดไป คุณอาจข้ามบทที่ 2 และตรงไปยัง บทที่ 3 ซึ่งครอบคลุมคุณลักษณะต่างๆ ของ Rust ที่มีความคล้ายคลึงกับภาษาโปรแกรมอื่นๆ จากนั้นคุณค่อยย้อนกลับมาทำบทที่ 2 เมื่อต้องการลงมือทำโครงการเพื่อประยุกต์ใช้รายละเอียดต่างๆ ที่ได้เรียนรู้มา

ใน บทที่ 4 คุณจะได้เรียนรู้เกี่ยวกับระบบการเป็นเจ้าของข้อมูล (ownership system) ของ Rust บทที่ 5 อธิบายเกี่ยวกับโครงสร้างข้อมูล (structs) และเมธอด (methods) บทที่ 6 ครอบคลุมเรื่องเอ็นัม (enums), นิพจน์ match และโครงสร้างควบคุมทิศทาง if let และ let...else คุณจะได้ใช้โครงสร้างข้อมูลและเอ็นัมเพื่อสร้างชนิดข้อมูลกำหนดเอง (custom types) ของคุณ

ใน บทที่ 7 คุณจะได้เรียนรู้เกี่ยวกับระบบโมดูลของ Rust และกฎความเป็นส่วนตัว (privacy rules) สำหรับจัดระเบียบโค้ดของคุณ รวมถึงอินเตอร์เฟซการเขียนโปรแกรมประยุกต์ (API) สาธารณะของโค้ดนั้น บทที่ 8 อธิบายโครงสร้างข้อมูลคอลเลกชันทั่วไปบางส่วนที่ไลบรารีมาตรฐานเตรียมไว้ให้ เช่น เวกเตอร์ (vectors), ข้อความ (strings) และแฮชแมป (hash maps) บทที่ 9 จะเจาะลึกแนวคิดและเทคนิคการจัดการข้อผิดพลาด (error-handling) ของ Rust

บทที่ 10 เจาะลึกเรื่องเจเนอริก (generics), เทรต (traits) และอายุขัย (lifetimes) ซึ่งช่วยให้คุณสามารถนิยามโค้ดที่สามารถนำไปใช้กับชนิดข้อมูลหลายประเภทได้ บทที่ 11 เป็นเรื่องเกี่ยวกับการทดสอบโปรแกรม (testing) ทั้งหมด ซึ่งแม้ว่า Rust จะมีการรับประกันความปลอดภัยของระบบแล้วก็ตาม แต่การทดสอบก็ยังคงจำเป็นเพื่อให้มั่นใจว่าตรรกะของโปรแกรมคุณถูกต้อง ใน บทที่ 12 เราจะสร้างโปรแกรมจำลองฟังก์ชันการทำงานบางส่วนของเครื่องมือบรรทัดคำสั่ง grep เพื่อค้นหาข้อความภายในไฟล์ด้วยตนเอง สำหรับโครงการนี้ เราจะใช้แนวคิดจำนวนมากที่ได้อธิบายไปในบทก่อนหน้านี้

บทที่ 13 สำรวจเกี่ยวกับโคลเชอร์ (closures) และตัววนซ้ำ (iterators) ซึ่งเป็นคุณลักษณะของ Rust ที่หยิบยกมาจากภาษาโปรแกรมเชิงฟังก์ชัน (functional programming languages) ใน บทที่ 14 เราจะเจาะลึกการใช้ Cargo เพิ่มเติมและพูดคุยเกี่ยวกับวิธีปฏิบัติที่ดีที่สุดในการแบ่งปันไลบรารีของคุณให้กับผู้อื่น บทที่ 15 อธิบายเกี่ยวกับสมาร์ตพอยน์เตอร์ (smart pointers) ที่ไลบรารีมาตรฐานจัดเตรียมไว้ให้ รวมถึงเทรตที่ช่วยเปิดใช้งานความสามารถของสมาร์ตพอยน์เตอร์เหล่านั้น

ใน บทที่ 16 เราจะแนะนำโมเดลรูปแบบต่างๆ ของการเขียนโปรแกรมประมวลผลพร้อมกัน (concurrent programming) และพูดคุยถึงวิธีที่ Rust ช่วยให้คุณเขียนโปรแกรมแบบแยกการทำงานหลายเธรด (multiple threads) ได้อย่างปราศจากความกังวล ใน บทที่ 17 เราจะต่อยอดเรื่องนี้โดยสำรวจไวยากรณ์ async และ await ของ Rust ร่วมกับงาน (tasks), ฟิวเจอร์ส (futures), สตรีมส์ (streams) และรูปแบบการประมวลผลพร้อมกันแบบน้ำหนักเบา (lightweight concurrency model) ที่ทำงานด้วยกลไกเหล่านี้

บทที่ 18 สำรวจวิธีการเปรียบเทียบสำนวนภาษาของ Rust กับหลักการเขียนโปรแกรมเชิงวัตถุ (object-oriented programming) ที่คุณอาจคุ้นเคยอยู่แล้ว บทที่ 19 เป็นคู่มืออ้างอิงเกี่ยวกับรูปแบบและการจับคู่รูปแบบ (patterns and pattern matching) ซึ่งเป็นวิธีที่มีประสิทธิภาพในการอธิบายเป้าหมายการทำงานภายในโปรแกรม Rust บทที่ 20 รวบรวมหัวข้อขั้นสูงต่างๆ ที่น่าสนใจไว้มากมาย รวมถึงการใช้งาน Unsafe Rust, มาโคร (macros) และรายละเอียดเพิ่มเติมเกี่ยวกับอายุขัย, เทรต, ชนิดข้อมูล, ฟังก์ชัน และโคลเชอร์

ใน บทที่ 21 เราจะมาทำโครงการสุดท้ายร่วมกันด้วยการสร้างเว็บเซิร์ฟเวอร์แบบมัลติเธรด (multithreaded web server) ในระดับต่ำ!

สุดท้าย ภาคผนวกต่างๆ จะมีข้อมูลที่เป็นประโยชน์เกี่ยวกับภาษาในรูปแบบคู่มืออ้างอิง ภาคผนวก A ครอบคลุมเรื่องคำสงวนของ Rust, ภาคผนวก B ครอบคลุมตัวดำเนินการและสัญลักษณ์ต่างๆ ของ Rust, ภาคผนวก C ครอบคลุมเทรตที่สามารถจัดทำอนุพันธ์ได้ที่ไลบรารีมาตรฐานเตรียมไว้ให้, ภาคผนวก D ครอบคลุมเครื่องมือพัฒนาที่เป็นประโยชน์ และ ภาคผนวก E อธิบายเกี่ยวกับรุ่น (editions) ต่างๆ ของ Rust ใน ภาคผนวก F คุณสามารถดูข้อมูลโครงการแปลหนังสือเล่มนี้ในภาษาต่างๆ ได้ และใน ภาคผนวก G เราจะพูดถึงวิธีการพัฒนาภาษา Rust และข้อมูลว่า nightly Rust คืออะไร

ไม่มีวิธีการอ่านหนังสือเล่มนี้ที่ผิด: หากคุณต้องการอ่านข้ามไปศึกษาข้างหน้าก่อน ก็สามารถทำได้เลย! คุณอาจเพียงต้องย้อนกลับมาอ่านบทแรกๆ หากพบข้อสงสัยหรือความสับสนในบางจุด แต่คุณสามารถเลือกวิธีใดก็ได้ที่เหมาะสมกับตัวคุณเองมากที่สุด

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

FerrisMeaning
Ferris with a question markโค้ดนี้ไม่สามารถคอมไพล์ได้!
Ferris throwing up their handsโค้ดนี้จะเกิดการตื่นตระหนก (panics)!
Ferris with one claw up, shruggingโค้ดนี้ไม่มีพฤติกรรมตามที่คาดหวังไว้

ในกรณีส่วนใหญ่ เราจะนำเสนอโค้ดเวอร์ชันที่ถูกต้องของโค้ดต่างๆ ที่คอมไพล์ไม่ผ่านเหล่านั้นให้แก่คุณ

ซอร์สโค้ด (Source Code)

ไฟล์ซอร์สโค้ดที่ใช้สำหรับสร้างหนังสือเล่มนี้สามารถดาวน์โหลดศึกษาเพิ่มเติมได้ที่ GitHub

เริ่มต้นใช้งาน

มาเริ่มต้นการเดินทางร่วมกับภาษา Rust กันเลย! มีสิ่งต่างๆ ให้เรียนรู้อีกมากมาย แต่ทุกการเดินทางย่อมมีจุดเริ่มต้นเสมอ ในบทนี้เราจะพูดคุยเกี่ยวกับ:

  • การติดตั้ง Rust บนระบบปฏิบัติการ Linux, macOS และ Windows
  • การเขียนโปรแกรมที่แสดงข้อความ Hello, world!
  • การใช้ cargo ซึ่งเป็นเครื่องมือจัดการแพ็กเกจและระบบสร้างของ Rust

Installation

การติดตั้ง

ขั้นตอนแรกคือการติดตั้ง Rust เราจะดาวน์โหลด Rust ผ่าน rustup ซึ่งเป็นเครื่องมือบรรทัดคำสั่ง (command line tool) สำหรับจัดการเวอร์ชันของ Rust และเครื่องมือที่เกี่ยวข้อง คุณจำเป็นต้องเชื่อมต่ออินเทอร์เน็ตในการดาวน์โหลด

หมายเหตุ: หากคุณมีเหตุผลที่ไม่ต้องการใช้งาน rustup สามารถศึกษารายละเอียดทางเลือกอื่นๆ เพิ่มเติมได้ที่ หน้าวิธีการติดตั้ง Rust อื่นๆ

ขั้นตอนต่อไปนี้จะทำการติดตั้งคอมไพเลอร์ Rust เวอร์ชันเสถียร (stable) ล่าสุด การรับประกันความเสถียรของ Rust ช่วยให้มั่นใจได้ว่าโค้ดตัวอย่างทั้งหมดในหนังสือเล่มนี้ที่สามารถคอมไพล์ได้ จะยังคงสามารถคอมไพล์ได้ด้วย Rust เวอร์ชันที่ใหม่กว่า ผลลัพธ์จากการรันคำสั่ง (output) อาจแตกต่างกันไปเล็กน้อยในแต่ละเวอร์ชัน เนื่องจาก Rust มักมีการพัฒนาข้อความแสดงข้อผิดพลาดและคำเตือนให้ดีขึ้น กล่าวอีกนัยหนึ่งคือ Rust เวอร์ชันเสถียรเวอร์ชันใหม่ใดๆ ที่คุณติดตั้งผ่านขั้นตอนเหล่านี้จะทำงานได้อย่างถูกต้องกับเนื้อหาในหนังสือเล่มนี้อย่างแน่นอน

เครื่องหมายที่ใช้ในบรรทัดคำสั่ง

ในบทนี้และบทต่อๆ ไปในหนังสือเล่มนี้ เราจะแสดงคำสั่งบางส่วนที่ใช้ในเทอร์มินัล (terminal) บรรทัดคำสั่งที่คุณต้องป้อนลงในเทอร์มินัลจะเริ่มต้นด้วยเครื่องหมาย $ เสมอ โดยคุณไม่จำเป็นต้องพิมพ์อักขระ $ ดังกล่าวลงไป เนื่องจากมันเป็นเพียงสัญลักษณ์บรรทัดคำสั่ง (command line prompt) ที่ระบุจุดเริ่มต้นของแต่ละคำสั่ง ส่วนบรรทัดที่ไม่ได้ขึ้นต้นด้วย $ มักแสดงผลลัพธ์จากการประมวลผลคำสั่งก่อนหน้านั้น นอกจากนี้ ตัวอย่างการป้อนคำสั่งเฉพาะของ PowerShell จะใช้เครื่องหมาย > แทนเครื่องหมาย $

การติดตั้ง rustup บน Linux หรือ macOS

หากคุณใช้งาน Linux หรือ macOS ให้เปิดเทอร์มินัลแล้วป้อนคำสั่งต่อไปนี้:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

คำสั่งนี้จะดาวน์โหลดสคริปต์การติดตั้งและเริ่มทำงานโปรแกรมติดตั้ง rustup ซึ่งจะทำการติดตั้งคอมไพเลอร์ Rust เวอร์ชันเสถียรล่าสุด คุณอาจต้องระบุรหัสผ่านเข้าเครื่องคอมพิวเตอร์ของคุณ หากการติดตั้งสำเร็จ บรรทัดข้อความต่อไปนี้จะปรากฏขึ้น:

Rust is installed now. Great!

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

บนระบบ macOS คุณสามารถติดตั้งคอมไพเลอร์ภาษา C ได้ด้วยการรันคำสั่ง:

$ xcode-select --install

สำหรับผู้ใช้ Linux โดยทั่วไปควรติดตั้ง GCC หรือ Clang ตามแต่เอกสารคู่มือของชุดแจกจ่าย (distribution) นั้นๆ แนะนำ ตัวอย่างเช่น หากคุณใช้ Ubuntu คุณสามารถติดตั้งแพ็กเกจ build-essential ได้

การติดตั้ง rustup บน Windows

สำหรับระบบ Windows ให้เปิดไปที่ https://www.rust-lang.org/tools/install และทำตามขั้นตอนแนะนำสำหรับการติดตั้ง Rust ในระหว่างขั้นตอนติดตั้ง คุณอาจได้รับคำแนะนำให้ติดตั้ง Visual Studio ซึ่งจะช่วยจัดเตรียมลิงก์เกอร์และไลบรารีดั้งเดิมของระบบ (native libraries) ที่จำเป็นสำหรับการคอมไพล์โปรแกรม หากต้องการศึกษาความช่วยเหลือเพิ่มเติมในขั้นตอนนี้ สามารถดูได้ที่ https://rust-lang.github.io/rustup/installation/windows-msvc.html

เนื้อหาที่เหลือของหนังสือเล่มนี้จะใช้คำสั่งที่สามารถใช้ได้ทั้งใน cmd.exe และ PowerShell หากมีจุดใดที่แตกต่างกันโดยเฉพาะ เราจะระบุคำแนะนำอธิบายวิธีการใช้งานไว้ให้

การแก้ไขปัญหาเบื้องต้น (Troubleshooting)

ในการตรวจสอบว่าคุณได้ติดตั้ง Rust เรียบร้อยถูกต้องหรือไม่ ให้เปิดเชลล์ (shell) ขึ้นมาแล้วป้อนคำสั่งนี้:

$ rustc --version

คุณควรจะพบกับหมายเลขเวอร์ชัน, รหัสคอมมิต (commit hash) และวันที่คอมมิตของเวอร์ชันเสถียรล่าสุดที่มีการเผยแพร่ออกมา ในรูปแบบดังต่อไปนี้:

rustc x.y.z (abcabcabc yyyy-mm-dd)

หากคุณเห็นข้อมูลเหล่านี้ แสดงว่าคุณได้ทำการติดตั้ง Rust สำเร็จเรียบร้อยแล้ว! แต่หากไม่พบข้อมูลนี้ ให้ตรวจสอบว่า Rust ได้ถูกบันทึกลงในตัวแปรระบบ %PATH% ของคุณแล้วหรือยัง ตามขั้นตอนดังต่อไปนี้

ใน Windows CMD ให้ใช้คำสั่ง:

> echo %PATH%

ใน PowerShell ให้ใช้คำสั่ง:

> echo $env:Path

ใน Linux และ macOS ให้ใช้คำสั่ง:

$ echo $PATH

หากข้อมูลตัวแปรระบบถูกต้องแล้วแต่ Rust ยังไม่สามารถทำงานได้ คุณสามารถค้นหาความช่วยเหลือได้จากหลากหลายช่องทาง ลองดูวิธีการติดต่อกับเหล่า Rustaceans (ฉายาตลกๆ ที่พวกเราใช้เรียกกันเองในกลุ่มผู้ใช้ Rust) ได้ที่ หน้าชุมชนของเรา

การอัปเดตและการถอนการติดตั้ง

เมื่อคุณติดตั้ง Rust ผ่าน rustup แล้ว การอัปเกรดปรับปรุงเวอร์ชันใหม่ก็สามารถทำได้ง่ายๆ จากภายในเชลล์ของคุณ ให้รันสคริปต์อัปเดตดังต่อไปนี้:

$ rustup update

หากต้องการถอนการติดตั้ง Rust และ rustup ออกจากระบบ ให้รันสคริปต์ถอนการติดตั้งดังต่อไปนี้จากเชลล์ของคุณ:

$ rustup self uninstall

การอ่านเอกสารคู่มือในเครื่องของคุณ (Local Documentation)

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

เมื่อใดก็ตามที่มีชนิดข้อมูลหรือฟังก์ชันที่จัดเตรียมโดยไลบรารีมาตรฐานแล้วคุณไม่มั่นใจว่ามันทำอะไรหรือมีวิธีการใช้อย่างไร คุณสามารถเปิดดูข้อมูลเพิ่มเติมได้ในเอกสารอธิบายการเขียนโปรแกรมประยุกต์ (API documentation) นี้!

การเลือกใช้เครื่องมือแก้ไขข้อความและ IDE

หนังสือเล่มนี้ไม่ได้กำหนดเครื่องมือที่คุณต้องใช้เขียนโค้ด Rust คุณสามารถเลือกใช้เครื่องมือแก้ไขข้อความ (text editor) ใดๆ ก็ตามในการเขียนโปรแกรมได้อย่างไม่มีปัญหา! อย่างไรก็ดี เครื่องมือแก้ไขข้อความและสภาพแวดล้อมการพัฒนาแบบรวม (IDE) จำนวนมากมีฟีเจอร์ช่วยเหลือสนับสนุนภาษา Rust อยู่ภายในตัว คุณสามารถดูรายการข้อมูลอัปเดตของโปรแกรมแก้ไขและ IDE ต่างๆ ได้ที่ หน้าเครื่องมือบนเว็บไซต์ Rust

การทำงานออฟไลน์ร่วมกับหนังสือเล่มนี้

ในตัวอย่างเนื้อหาหลายส่วน เราจะมีการเรียกใช้แพ็กเกจภายนอกของ Rust ที่อยู่นอกเหนือจากไลบรารีมาตรฐาน ในการปฏิบัติตามตัวอย่างเหล่านั้น คุณจำเป็นต้องมีการเชื่อมต่ออินเทอร์เน็ต หรือได้ดาวน์โหลดส่วนพึ่งพาเหล่านั้นเตรียมไว้ล่วงหน้าแล้ว ในการดาวน์โหลดแพ็กเกจเหล่านั้นมาไว้ในเครื่องก่อน คุณสามารถรันคำสั่งต่อไปนี้ (เราจะมาอธิบายรายละเอียดเพิ่มเติมในภายหลังว่า cargo คืออะไรและคำสั่งเหล่านี้ทำหน้าที่อะไรบ้าง)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.10.1 trpl@0.2.0

คำสั่งนี้จะทำการบันทึกแคช (cache) ของแพ็กเกจเหล่านี้ไว้ในเครื่องของคุณ ทำให้คุณไม่จำเป็นต้องดาวน์โหลดเพิ่มอีกในภายหลัง และเมื่อคุณรันคำสั่งเหล่านี้สำเร็จแล้ว คุณไม่จำเป็นต้องเก็บโฟลเดอร์ get-dependencies นี้ไว้อีกต่อไป และเมื่อคุณทำตามนี้แล้ว คุณจะสามารถใส่แฟล็ก --offline ร่วมกับทุกคำสั่งของ cargo ในเนื้อหาส่วนที่เหลือของหนังสือเล่มนี้ เพื่อใช้งานเวอร์ชันที่ถูกแคชไว้แทนการพยายามเชื่อมต่อผ่านเครือข่ายอินเทอร์เน็ต

Hello, World!

Hello, World!

เมื่อคุณทำการติดตั้ง Rust เรียบร้อยแล้ว ก็ถึงเวลาที่จะเขียนโปรแกรม Rust แรกของคุณ ตามธรรมเนียมปฏิบัติของการเรียนรู้ภาษาใหม่ มักจะเริ่มต้นด้วยการเขียนโปรแกรมขนาดเล็กเพื่อพิมพ์ข้อความ Hello, world! แสดงผลบนหน้าจอ ดังนั้นเรามาทำแบบเดียวกันในที่นี้กันเลย!

หมายเหตุ: หนังสือเล่มนี้ตั้งสมมติฐานว่าคุณมีความคุ้นเคยพื้นฐานเกี่ยวกับบรรทัดคำสั่ง (command line) อยู่บ้าง ภาษา Rust ไม่ได้มีข้อกำหนดเป็นพิเศษเกี่ยวกับเครื่องมือแก้ไขโค้ด เครื่องมือพัฒนา หรือตำแหน่งที่เก็บไฟล์โค้ดของคุณ ดังนั้น หากคุณต้องการเขียนโค้ดด้วยโปรแกรมพัฒนาแบบรวม (IDE) แทนการใช้บรรทัดคำสั่ง ก็สามารถเลือกใช้ IDE ที่คุณชื่นชอบได้ตามต้องการ ปัจจุบัน IDE หลายตัวรองรับการทำงานกับภาษา Rust ในระดับที่น่าพอใจ โดยสามารถตรวจสอบรายละเอียดได้จากเอกสารคู่มือของ IDE นั้นๆ ทั้งนี้ทีมพัฒนา Rust ได้มุ่งเน้นการพัฒนาการทำงานร่วมกับ IDE อย่างดีเยี่ยมผ่าน rust-analyzer สามารถดูรายละเอียดเพิ่มเติมได้ที่ ภาคผนวก D

การตั้งค่าโฟลเดอร์โครงการ (Project Directory Setup)

คุณจะเริ่มต้นด้วยการสร้างโฟลเดอร์ (directory) สำหรับใช้จัดเก็บไฟล์โค้ด Rust สำหรับตัวภาษา Rust เองไม่มีเงื่อนไขว่าโค้ดของคุณจะต้องจัดเก็บไว้ที่ใด แต่เพื่อความสะดวกในการฝึกทำแบบฝึกหัดและโครงการต่างๆ ในหนังสือเล่มนี้ เราแนะนำให้สร้างโฟลเดอร์ชื่อ projects ไว้ในโฟลเดอร์หลักของผู้ใช้ (home directory) และจัดเก็บทุกโครงการของคุณไว้ที่นั่น

ให้เปิดเทอร์มินัลแล้วพิมพ์ป้อนคำสั่งดังต่อไปนี้ เพื่อสร้างโฟลเดอร์ชื่อ projects และสร้างโฟลเดอร์โครงการ “Hello, world!” ย่อยอยู่ภายในโฟลเดอร์ projects นั้น

สำหรับ Linux, macOS และ PowerShell บน Windows ให้ป้อนคำสั่งดังนี้:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

สำหรับ Windows CMD ให้ป้อนคำสั่งดังนี้:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

พื้นฐานของโปรแกรมภาษา Rust

ถัดไป ให้สร้างไฟล์ซอร์สโค้ดใหม่และตั้งชื่อว่า main.rs ไฟล์ของภาษา Rust จะต้องลงท้ายด้วยนามสกุล .rs เสมอ หากคุณต้องการใช้คำหลายคำในชื่อไฟล์ ตามข้อตกลงสากลแนะนำให้ใช้เครื่องหมายขีดล่าง (underscore) ในการแยกคำเหล่านั้น ตัวอย่างเช่น เลือกใช้ชื่อ hello_world.rs แทนที่จะเขียนติดกันเป็น helloworld.rs

จากนั้นให้เปิดไฟล์ main.rs ที่คุณเพิ่งสร้างขึ้น และพิมพ์ป้อนโค้ดตามตัวอย่างใน Listing 1-1

fn main() {
    println!("Hello, world!");
}

บันทึกไฟล์และย้อนกลับไปที่หน้าต่างเทอร์มินัลของคุณในโฟลเดอร์ ~/projects/hello_world สำหรับ Linux หรือ macOS ให้ป้อนคำสั่งต่อไปนี้เพื่อคอมไพล์และสั่งรันไฟล์งาน:

$ rustc main.rs
$ ./main
Hello, world!

สำหรับ Windows ให้ป้อนคำสั่ง .\main แทนที่จะพิมพ์ ./main:

> rustc main.rs
> .\main
Hello, world!

ไม่ว่าคุณจะใช้งานระบบปฏิบัติการใดก็ตาม ข้อความ Hello, world! ควรจะถูกพิมพ์แสดงผลออกมาบนหน้าต่างเทอร์มินัล หากคุณไม่พบผลลัพธ์ดังกล่าวนี้ ให้ย้อนกลับไปดูวิธีแก้ไขปัญหาในหัวข้อ “การแก้ไขปัญหาเบื้องต้น” ในส่วนของการติดตั้งเพื่อศึกษาช่องทางการรับความช่วยเหลือ

หากข้อความ Hello, world! พิมพ์แสดงผลขึ้นมาได้สำเร็จ ขอแสดงความยินดีด้วย! คุณได้เขียนโปรแกรม Rust แรกขึ้นมาอย่างเป็นทางการแล้ว และนั่นส่งผลให้คุณกลายเป็นนักเขียนโปรแกรม Rust อย่างเต็มตัว—ยินดีต้อนรับ!

โครงสร้างส่วนประกอบของโปรแกรม Rust (The Anatomy of a Rust Program)

เรามาลองทบทวนรายละเอียดของโปรแกรม “Hello, world!” นี้กันอย่างละเอียด นี่คือชิ้นส่วนแรกของปริศนา:

fn main() {

}

บรรทัดเหล่านี้ทำการนิยามฟังก์ชันชื่อว่า main ฟังก์ชัน main มีความสำคัญเป็นพิเศษ: มันเป็นโค้ดส่วนแรกที่จะถูกสั่งให้รันเสมอในโปรแกรม Rust ทุกตัวที่รันได้ (executable) ในจุดนี้ บรรทัดแรกทำการประกาศฟังก์ชันชื่อ main ที่ไม่มีพารามิเตอร์ส่งเข้ามาและไม่มีการส่งค่าใดๆ กลับ หากฟังก์ชันมีพารามิเตอร์ พารามิเตอร์เหล่านั้นจะถูกระบุไว้ภายในวงเล็บ (())

ตัวฟังก์ชัน (function body) จะถูกครอบไว้ด้วยวงเล็บปีกกา {} ภาษา Rust กำหนดให้คุณต้องมีวงเล็บปีกกาครอบส่วนเนื้อหาฟังก์ชันทั้งหมด รูปแบบการเขียนโค้ดที่ดีคือกำหนดให้เครื่องหมายเปิดวงเล็บปีกกาจัดวางอยู่ในบรรทัดเดียวกันกับการประกาศฟังก์ชัน โดยเว้นระยะห่างหนึ่งช่องว่างระหว่างนั้น

หมายเหตุ: หากคุณต้องการให้สไตล์การเขียนโค้ดของคุณเป็นไปตามมาตรฐานเดียวกันในทุกโครงการของ Rust คุณสามารถเรียกใช้เครื่องมือจัดรูปแบบอัตโนมัติชื่อ rustfmt เพื่อช่วยจัดสไตล์โค้ดของคุณในรูปแบบที่ต้องการได้ (ศึกษาข้อมูล rustfmt เพิ่มเติมได้ใน ภาคผนวก D) ซึ่งทีมงาน Rust ได้รวมเครื่องมือตัวนี้มาให้พร้อมกับชุดติดตั้งมาตรฐานของ Rust อยู่แล้ว เช่นเดียวกับคอมไพเลอร์ rustc ดังนั้นมันจึงควรพร้อมใช้งานอยู่ในคอมพิวเตอร์ของคุณแล้ว!

ส่วนของเนื้อหาภายในฟังก์ชัน main จะเก็บโค้ดดังต่อไปนี้:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

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

ประการแรก คำสั่ง println! เป็นการเรียกใช้มาโคร (macro) ของ Rust หากคำสั่งนี้เป็นการเรียกฟังก์ชันปกติทั่วไป มันจะถูกเขียนในรูปแบบ println (โดยไม่มีเครื่องหมาย !) มาโครของ Rust คือวิถีทางเขียนโค้ดที่ใช้สำหรับสร้างโค้ดอื่นขึ้นมาเพื่อช่วยขยายขอบเขตความสามารถของไวยากรณ์ Rust ซึ่งเราจะพูดคุยในรายละเอียดเชิงลึกเกี่ยวกับเรื่องนี้กันใน บทที่ 20 ในปัจจุบันนี้ คุณเพียงจำเป็นต้องทราบว่าการมีอักขระ ! อยู่ด้วย หมายถึงคุณกำลังเรียกใช้งานมาโครแทนที่จะเป็นฟังก์ชันธรรมดา และมาโครไม่ได้ดำเนินตามกฎเกณฑ์เดียวกันกับฟังก์ชันทั่วไปเสมอไป

ประการที่สอง คุณจะเห็นข้อมูลข้อความ "Hello, world!" เราส่งข้อความนี้ผ่านเข้าไปเป็นอาร์กิวเมนต์ให้กับ println! และข้อความดังกล่าวจะถูกพิมพ์แสดงผลออกมาบนหน้าจอ

ประการที่สาม เราสิ้นสุดบรรทัดคำสั่งด้วยเครื่องหมายอัฒภาคหรือเซมิโคลอน (;) ซึ่งระบุว่านิพจน์ (expression) นี้เสร็จสิ้นลงแล้ว และพร้อมสำหรับการเริ่มต้นในนิพจน์ถัดไป โค้ดภาษา Rust ส่วนใหญ่จะจบบรรทัดด้วยเครื่องหมายเซมิโคลอน

การคอมไพล์และการประมวลผลคำสั่ง (Compilation and Execution)

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

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

$ rustc main.rs

หากคุณเคยมีประสบการณ์เขียนภาษา C หรือ C++ มาก่อน คุณจะสังเกตได้ว่าคำสั่งนี้คล้ายคลึงกับการเรียกใช้งาน gcc หรือ clang หลังจากทำการคอมไพล์สำเร็จแล้ว Rust จะส่งไฟล์ไบนารีที่เปิดรันทำงานได้ออกมา (binary executable)

สำหรับ Linux, macOS และ PowerShell บน Windows คุณสามารถตรวจสอบไฟล์ที่รันระบบดังกล่าวได้โดยการป้อนคำสั่ง ls ในเชลล์ของคุณ:

$ ls
main  main.rs

สำหรับ Linux และ macOS คุณจะเห็นไฟล์ปรากฏอยู่ 2 ไฟล์ด้วยกัน ส่วนใน PowerShell บน Windows คุณจะพบไฟล์ 3 ไฟล์ ซึ่งเหมือนกับสิ่งที่คุณพบเมื่อเรียกใช้โปรแกรม CMD โดยใน CMD ของ Windows ให้คุณป้อนคำสั่งดังต่อไปนี้:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

การทำงานนี้จะแสดงไฟล์ซอร์สโค้ดที่ลงท้ายด้วยนามสกุล .rs, ไฟล์ที่รันระบบได้ (main.exe สำหรับ Windows แต่จะชื่อว่า main ในแพลตฟอร์มอื่นๆ) และเมื่อคุณใช้งาน Windows จะมีไฟล์สำหรับเก็บข้อมูลการดีบักลงท้ายด้วยนามสกุล .pdb แสดงเพิ่มขึ้นมาด้วย และจากจุดนี้ คุณสามารถสั่งรันไฟล์ main หรือ main.exe ได้ดังนี้:

$ ./main # หรือ .\main สำหรับ Windows

หากไฟล์ main.rs ของคุณเก็บโค้ดของโปรแกรม “Hello, world!” อยู่ คำสั่งบรรทัดนี้จะพิมพ์แสดงผลข้อความ Hello, world! ออกมาทางเทอร์มินัลของคุณ

หากคุณมีความคุ้นเคยกับภาษาที่เป็นแบบไดนามิกมากกว่า เช่น Ruby, Python หรือ JavaScript คุณอาจจะไม่คุ้นเคยกับขั้นตอนการคอมไพล์และการรันโปรแกรมที่เป็นขั้นตอนที่แยกออกจากกัน ภาษา Rust เป็นภาษาประเภท คอมไพล์ล่วงหน้า (ahead-of-time compiled) ซึ่งหมายความว่าคุณสามารถคอมไพล์โปรแกรมแล้วส่งมอบเฉพาะไฟล์ที่รันผลลัพธ์สำเร็จรูปนี้ให้แก่ผู้อื่นนำไปเปิดรันทำงานได้โดยตรงโดยที่เครื่องปลายทางเหล่านั้นไม่จำเป็นต้องติดตั้ง Rust เลยก็ได้ แต่หากคุณส่งมอบไฟล์ .rb, .py หรือ .js ให้คนอื่น เครื่องของพวกเขาจำเป็นต้องมีการติดตั้งโปรแกรมสำหรับแปรภาษาและรันภาษา Ruby, Python หรือ JavaScript (ตามลำดับ) เตรียมไว้ด้วย แต่ทว่าข้อดีในกลุ่มภาษานั้นคือคุณใช้เพียงคำสั่งเดียวในการสั่งให้ระบบคอมไพล์และทำงาน ทุกสิ่งล้วนเป็นเรื่องของการแลกเปลี่ยนในการออกแบบโครงสร้างภาษาโปรแกรม

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

Hello, Cargo!

สวัสดี, Cargo!

Cargo คือระบบการคอมไพล์ (build system) และตัวจัดการแพ็กเกจ (package manager) ของภาษา Rust ชาว Rustacean ส่วนใหญ่ใช้เครื่องมือนี้ในการจัดการโปรเจกต์ Rust ของตนเอง เนื่องจาก Cargo ช่วยจัดการงานต่างๆ ให้คุณเป็นจำนวนมาก เช่น การคอมไพล์โค้ดของคุณ, การดาวน์โหลดไลบรารีที่โค้ดของคุณจำเป็นต้องใช้งาน และการคอมไพล์ไลบรารีเหล่านั้น (เราเรียกไลบรารีที่โค้ดของคุณจำเป็นต้องใช้งานว่า ไลบรารีที่ต้องใช้พึ่งพา (dependencies))

โปรแกรม Rust ที่ง่ายที่สุดอย่างโปรแกรมที่เราเพิ่งเขียนไปนั้นไม่มีไลบรารีที่ต้องใช้พึ่งพาใดๆ เลย หากเราสร้างโปรเจกต์ “Hello, world!” ด้วย Cargo มันจะใช้เพียงแค่ส่วนของ Cargo ที่มีหน้าที่จัดการการคอมไพล์โค้ดของคุณเท่านั้น เมื่อคุณเขียนโปรแกรม Rust ที่มีความซับซ้อนมากขึ้น คุณจะได้เพิ่มไลบรารีที่ต้องใช้พึ่งพาเข้าไป และถ้าหากคุณเริ่มโปรเจกต์โดยใช้ Cargo การเพิ่มไลบรารีที่ต้องใช้พึ่งพาก็จะทำได้ง่ายขึ้นมาก

เนื่องจากโปรเจกต์ Rust ส่วนใหญ่ใช้ Cargo เนื้อหาในส่วนที่เหลือของหนังสือเล่มนี้จึงถือว่าคุณใช้งาน Cargo เช่นกัน Cargo จะถูกติดตั้งมาพร้อมกับ Rust หากคุณใช้ตัวติดตั้งอย่างเป็นทางการตามที่อธิบายไว้ในหัวข้อ “การติดตั้ง” แต่ถ้าคุณติดตั้ง Rust ด้วยวิธีอื่น ให้ตรวจสอบว่า Cargo ถูกติดตั้งไว้หรือไม่โดยป้อนคำสั่งต่อไปนี้ในเทอร์มินัลของคุณ:

$ cargo --version

หากคุณเห็นหมายเลขเวอร์ชัน แสดงว่าคุณมีมันแล้ว! หากคุณเห็นข้อผิดพลาด เช่น command not found ให้ดูเอกสารประกอบสำหรับวิธีการติดตั้งของคุณเพื่อตรวจสอบวิธีการติดตั้ง Cargo แยกต่างหาก

การสร้างโปรเจกต์ด้วย Cargo

มาสร้างโปรเจกต์ใหม่โดยใช้ Cargo และดูว่ามันแตกต่างจากโปรเจกต์ “Hello, world!” เดิมของเราอย่างไร ย้อนกลับไปยังไดเรกทอรี projects ของคุณ (หรือที่ใดก็ตามที่คุณเลือกเก็บโค้ดไว้) จากนั้น ไม่ว่าจะบนระบบปฏิบัติการใด ให้รันคำสั่งต่อไปนี้:

$ cargo new hello_cargo
$ cd hello_cargo

คำสั่งแรกจะสร้างไดเรกทอรีและโปรเจกต์ใหม่ชื่อว่า hello_cargo เราตั้งชื่อโปรเจกต์ของเราว่า hello_cargo และ Cargo จะสร้างไฟล์ต่างๆ ไว้ในไดเรกทอรีที่มีชื่อเดียวกัน

เข้าไปในไดเรกทอรี hello_cargo แล้วแสดงรายการไฟล์ คุณจะเห็นว่า Cargo ได้สร้างสองไฟล์และหนึ่งไดเรกทอรีให้กับเรา นั่นคือไฟล์ Cargo.toml และไดเรกทอรี src ที่มีไฟล์ main.rs อยู่ข้างใน

นอกจากนี้ยังได้ทำการสร้าง Git repository ใหม่ขึ้นมาพร้อมกับไฟล์ .gitignore ด้วย ทั้งนี้ไฟล์ Git จะไม่ถูกสร้างขึ้นหากคุณรันคำสั่ง cargo new ภายใต้ Git repository ที่มีอยู่แล้ว แต่คุณสามารถข้ามข้อกำหนดนี้ได้โดยการใช้คำสั่ง cargo new --vcs=git

หมายเหตุ: Git คือระบบควบคุมเวอร์ชัน (version control system) ที่นิยมใช้กันทั่วไป คุณสามารถเปลี่ยนให้ cargo new ใช้ระบบควบคุมเวอร์ชันอื่น หรือไม่ใช้ระบบควบคุมเวอร์ชันเลยก็ได้ โดยใช้แฟล็ก --vcs รันคำสั่ง cargo new --help เพื่อดูตัวเลือกที่มีทั้งหมด

เปิดไฟล์ Cargo.toml ด้วยโปรแกรมแก้ไขข้อความที่คุณต้องการ มันควรจะดูคล้ายกับโค้ดในรายการที่ 1-2

[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]

ไฟล์นี้อยู่ในรูปแบบ TOML (Tom’s Obvious, Minimal Language) ซึ่งเป็นรูปแบบไฟล์กำหนดค่า (configuration format) ของ Cargo

บรรทัดแรก [package] คือหัวข้อส่วน (section heading) ซึ่งระบุว่าข้อความหลังจากนี้เป็นการกำหนดค่าของแพ็กเกจ เมื่อเราเพิ่มข้อมูลเข้าไปในไฟล์นี้มากขึ้น เราจะได้เพิ่มส่วน (section) อื่นๆ เข้าไปด้วย

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

บรรทัดสุดท้าย [dependencies] คือจุดเริ่มต้นของส่วนระบุรายการไลบรารีที่ต้องใช้พึ่งพาของโปรเจกต์ ในภาษา Rust แพ็กเกจของโค้ดจะถูกเรียกว่า เครต (crates) เรายังไม่จำเป็นต้องใช้เครตอื่นๆ ในโปรเจกต์นี้ แต่เราจะได้ใช้ในโปรเจกต์แรกของบทที่ 2 ดังนั้นเราจะมาใช้ส่วนของ dependencies นี้ในตอนนั้น

ตอนนี้ให้เปิดไฟล์ src/main.rs แล้วลองมาดูกัน:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo ได้สร้างโปรแกรม “Hello, world!” ให้กับคุณเรียบร้อยแล้ว เช่นเดียวกับโปรแกรมที่เราเขียนไว้ในรายการที่ 1-1! จนถึงตอนนี้ สิ่งที่แตกต่างระหว่างโปรเจกต์ของเรากับโปรเจกต์ที่ Cargo สร้างขึ้นก็คือ Cargo นำโค้ดไปใส่ไว้ในไดเรกทอรี src และเรามีไฟล์กำหนดค่า Cargo.toml อยู่ในไดเรกทอรีระดับบนสุด

Cargo คาดหวังว่าไฟล์ซอร์สโค้ดของคุณจะอยู่ภายในไดเรกทอรี src ส่วนไดเรกทอรีโปรเจกต์ระดับบนสุดนั้นมีไว้สำหรับไฟล์ README, ข้อมูลใบอนุญาต (license), ไฟล์กำหนดค่า และสิ่งอื่นๆ ที่ไม่เกี่ยวข้องกับโค้ดของคุณ การใช้ Cargo จะช่วยจัดระเบียบโปรเจกต์ของคุณ ทุกๆ สิ่งจะมีที่ทางของตัวเอง และทุกๆ สิ่งจะอยู่ในที่ที่ควรอยู่

หากคุณเริ่มโปรเจกต์ที่ไม่ได้ใช้ Cargo เหมือนอย่างโปรเจกต์ “Hello, world!” ก่อนหน้านี้ คุณสามารถแปลงให้เป็นโปรเจกต์ที่ใช้ Cargo ได้ โดยย้ายโค้ดโปรเจกต์เข้าไปในไดเรกทอรี src แล้วสร้างไฟล์ Cargo.toml ที่เหมาะสม วิธีง่ายๆ ในการสร้างไฟล์ Cargo.toml ดังกล่าวคือการรันคำสั่ง cargo init ซึ่งจะสร้างมันขึ้นมาให้คุณโดยอัตโนมัติ

การคอมไพล์และการรันโปรเจกต์ Cargo

ทีนี้มาดูกันว่ามีอะไรแตกต่างไปบ้างเมื่อเราคอมไพล์และรันโปรแกรม “Hello, world!” ด้วย Cargo! จากไดเรกทอรี hello_cargo ของคุณ ให้คอมไพล์โปรเจกต์ด้วยการพิมพ์คำสั่งดังต่อไปนี้:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

คำสั่งนี้จะสร้างไฟล์โปรแกรมที่รันได้ (executable file) ขึ้นมาใน target/debug/hello_cargo (หรือ target\debug\hello_cargo.exe บน Windows) แทนที่จะอยู่ในไดเรกทอรีปัจจุบันของคุณ เนื่องจากค่าเริ่มต้นจะเป็นการคอมไพล์แบบดีบั๊ก (debug build) Cargo จึงเก็บไฟล์ไบนารีไว้ในไดเรกทอรีชื่อว่า debug คุณสามารถรันโปรแกรมดังกล่าวได้ด้วยคำสั่งนี้:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

หากทุกอย่างเป็นไปด้วยดี คำว่า Hello, world! ควรจะแสดงขึ้นมาบนเทอร์มินัล การรัน cargo build เป็นครั้งแรกจะทำให้ Cargo สร้างไฟล์ใหม่ขึ้นมาในไดเรกทอรีระดับบนสุดด้วย นั่นคือไฟล์ Cargo.lock ไฟล์นี้ใช้เพื่อติดตามเวอร์ชันที่แน่นอนของไลบรารีที่ต้องใช้พึ่งพาในโปรเจกต์ของคุณ เนื่องจากโปรเจกต์นี้ยังไม่มีไลบรารีที่ต้องใช้พึ่งพา ไฟล์นี้จึงค่อนข้างว่างเปล่า คุณไม่จำเป็นต้องแก้ไขไฟล์นี้ด้วยตนเองแต่อย่างใด เนื่องจาก Cargo จะคอยจัดการเนื้อหาภายในให้คุณเอง

เราเพิ่งจะคอมไพล์โปรเจกต์ด้วยคำสั่ง cargo build และรันด้วย ./target/debug/hello_cargo แต่เราก็ยังสามารถใช้คำสั่ง cargo run เพื่อคอมไพล์โค้ดและรันโปรแกรมที่ได้ทั้งหมดในคำสั่งเดียวได้เช่นกัน:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

การใช้งาน cargo run นั้นสะดวกกว่าการที่ต้องคอยจำว่าต้องรัน cargo build แล้วค่อยรันผ่านพาธของไฟล์ไบนารีทั้งหมด ดังนั้นนักพัฒนาส่วนใหญ่จึงนิยมใช้งาน cargo run

โปรดสังเกตว่าในครั้งนี้เราจะไม่เห็นผลลัพธ์ที่แสดงว่า Cargo กำลังคอมไพล์ hello_cargo เนื่องจาก Cargo ทราบว่าไม่มีไฟล์ใดเปลี่ยนแปลง จึงไม่ได้คอมไพล์ใหม่และเพียงแค่รันไฟล์ไบนารีเท่านั้น หากคุณมีการแก้ไขซอร์สโค้ด Cargo จะคอมไพล์โปรเจกต์ใหม่ก่อนที่จะรัน และคุณจะเห็นผลลัพธ์ลักษณะนี้:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

นอกจากนี้ Cargo ยังมีคำสั่งชื่อ cargo check ด้วย คำสั่งนี้จะตรวจสอบโค้ดของคุณอย่างรวดเร็วเพื่อให้แน่ใจว่ามันสามารถคอมไพล์ได้สำเร็จ แต่จะไม่สร้างไฟล์ที่รันได้ออกมา:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

ทำไมคุณถึงไม่ต้องการไฟล์ที่รันได้ล่ะ? บ่อยครั้งที่คำสั่ง cargo check จะทำงานเร็วกว่า cargo build มาก เนื่องจากมันข้ามขั้นตอนการสร้างไฟล์โปรแกรมที่รันได้ไป หากคุณกำลังตรวจสอบการทำงานของคุณไปเรื่อยๆ ในขณะที่เขียนโค้ด การใช้ cargo check จะช่วยเร่งกระบวนการทำให้คุณทราบว่าโปรเจกต์ของคุณยังคงคอมไพล์ผ่านอยู่หรือไม่! ด้วยเหตุนี้ ชาว Rustacean หลายคนจึงรันคำสั่ง cargo check เป็นระยะๆ ระหว่างเขียนโปรแกรมเพื่อความแน่ใจว่าโปรแกรมคอมไพล์ผ่าน จากนั้นจึงรัน cargo build เมื่อพร้อมที่จะใช้งานไฟล์โปรแกรมที่รันได้

มาสรุปสิ่งที่เราได้เรียนรู้เกี่ยวกับ Cargo กันอีกครั้ง:

  • เราสามารถสร้างโปรเจกต์ได้โดยใช้คำสั่ง cargo new
  • เราสามารถคอมไพล์โปรเจกต์ได้โดยใช้คำสั่ง cargo build
  • เราสามารถคอมไพล์และรันโปรเจกต์ได้ในขั้นตอนเดียวโดยใช้คำสั่ง cargo run
  • เราสามารถตรวจสอบหาข้อผิดพลาดในโปรเจกต์โดยไม่ต้องสร้างไฟล์ไบนารีได้โดยใช้คำสั่ง cargo check
  • แทนที่จะบันทึกผลลัพธ์การคอมไพล์ไว้ในไดเรกทอรีเดียวกับโค้ดของเรา Cargo จะเก็บมันไว้ในไดเรกทอรี target/debug แทน

ข้อดีเพิ่มเติมของการใช้ Cargo ก็คือ คำสั่งต่างๆ จะเหมือนกันทั้งหมดไม่ว่าคุณจะทำงานอยู่บนระบบปฏิบัติการใด ดังนั้นนับจากจุดนี้เป็นต้นไป เราจะไม่ลงรายละเอียดแยกตามระบบปฏิบัติการระหว่าง Linux/macOS กับ Windows อีก

การคอมไพล์เพื่อใช้งานจริง (Release)

เมื่อโปรเจกต์ของคุณพร้อมสำหรับการนำไปใช้งานจริงแล้ว คุณสามารถใช้คำสั่ง cargo build --release เพื่อคอมไพล์โปรเจกต์พร้อมการปรับประสิทธิภาพให้เหมาะสมที่สุด (optimizations) คำสั่งนี้จะสร้างไฟล์ที่รันได้ใน target/release แทนที่จะเป็น target/debug การเปิดใช้ตัวเลือกปรับประสิทธิภาพจะทำให้โค้ด Rust ของคุณทำงานได้เร็วขึ้น แต่จะส่งผลให้โปรแกรมใช้เวลาคอมไพล์นานขึ้นด้วย นี่จึงเป็นเหตุผลที่มีการแบ่งโปรไฟล์การทำงานเป็นสองแบบ: แบบแรกสำหรับขั้นตอนการพัฒนา เมื่อคุณต้องการคอมไพล์ใหม่ซ้ำๆ ได้อย่างรวดเร็ว และอีกแบบสำหรับตอนสร้างโปรแกรมสุดท้ายที่จะส่งมอบให้แก่ผู้ใช้งาน ซึ่งจะไม่ต้องถูกคอมไพล์ซ้ำอีกแล้วและต้องการให้รันได้เร็วที่สุด หากคุณกำลังวัดประสิทธิภาพเวลาในการทำงานของโค้ด (benchmarking) อย่าลืมรันคำสั่ง cargo build --release และทดสอบผ่านไฟล์ที่รันได้ในโฟลเดอร์ target/release

การใช้ประโยชน์จากข้อกำหนดตกลงร่วมกันของ Cargo

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

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

$ git clone example.org/someproject
$ cd someproject
$ cargo build

หากต้องการข้อมูลเพิ่มเติมเกี่ยวกับ Cargo สามารถเข้าไปดูได้ที่ เอกสารประกอบการใช้งานของ Cargo

สรุป

คุณได้เริ่มต้นเดินทางสู่การเรียนรู้ภาษา Rust ได้อย่างยอดเยี่ยมแล้ว! ในบทนี้ คุณได้เรียนรู้วิธีการ:

  • ติดตั้ง Rust เวอร์ชันเสถียร (stable) ล่าสุด โดยใช้ rustup
  • อัปเดต Rust ให้เป็นเวอร์ชันใหม่กว่า
  • เปิดเอกสารประกอบคู่มือที่ติดตั้งอยู่บนเครื่องของคุณ
  • เขียนและรันโปรแกรม “Hello, world!” โดยใช้ตัวคอมไพล์ rustc โดยตรง
  • สร้างและรันโปรเจกต์ใหม่ตามข้อกำหนดตกลงร่วมกันของ Cargo

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

การเขียนโปรแกรมเกมทายตัวเลข

มาเริ่มก้าวเข้าสู่ภาษา Rust ด้วยการลงมือทำโปรเจกต์จริงไปด้วยกัน! บทนี้จะแนะนำคุณให้รู้จักกับแนวคิดพื้นฐานทั่วไปของ Rust ผ่านการเขียนโปรแกรมใช้งานจริง คุณจะได้เรียนรู้เกี่ยวกับ let, match, เมธอด (methods), ฟังก์ชันที่เกี่ยวข้อง (associated functions), เครตภายนอก (external crates) และอีกมากมาย! ในบทต่อๆ ไป เราจะได้สำรวจแนวคิดเหล่านั้นอย่างละเอียด สำหรับบทนี้ คุณจะได้ฝึกฝนเฉพาะส่วนที่เป็นพื้นฐานที่จำเป็นเท่านั้น

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

การตั้งค่าโปรเจกต์ใหม่

สำหรับการตั้งค่าโปรเจกต์ใหม่ ให้เข้าไปยังไดเรกทอรี projects ที่คุณสร้างขึ้นในบทที่ 1 แล้วสร้างโปรเจกต์ใหม่โดยใช้ Cargo ดังนี้:

$ cargo new guessing_game
$ cd guessing_game

คำสั่งแรก cargo new จะใช้ชื่อโปรเจกต์ (guessing_game) เป็นอาร์กิวเมนต์แรก คำสั่งที่สองจะสลับไปยังไดเรกทอรีของโปรเจกต์ใหม่

ลองมาดูไฟล์ Cargo.toml ที่ถูกสร้างขึ้นกัน:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

ตามที่คุณได้เห็นในบทที่ 1 คำสั่ง cargo new จะสร้างโปรแกรม “Hello, world!” ให้คุณโดยอัตโนมัติ ลองมาตรวจสอบไฟล์ src/main.rs กัน:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

คราวนี้มาลองคอมไพล์โปรแกรม “Hello, world!” นี้และรันไปในขั้นตอนเดียวกันด้วยคำสั่ง cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

คำสั่ง run จะมีประโยชน์อย่างยิ่งเมื่อคุณต้องการปรับแก้และพัฒนาโปรเจกต์อย่างรวดเร็ว (rapid iterate) ดังที่เราจะทำกันในเกมนี้ เพื่อตรวจสอบผลในแต่ละรอบการแก้ไขอย่างรวดเร็วก่อนที่จะดำเนินงานในขั้นตอนถัดไป

เปิดไฟล์ src/main.rs ขึ้นมาอีกครั้ง คุณจะเขียนโค้ดทั้งหมดลงในไฟล์นี้

การประมวลผลค่าที่ทาย

ส่วนแรกของโปรแกรมเกมทายตัวเลขคือการร้องขออินพุตจากผู้ใช้งาน ประมวลผลอินพุตดังกล่าว และตรวจสอบว่าอินพุตอยู่ในรูปแบบที่คาดหวัง ในตอนเริ่มต้น เราจะปล่อยให้ผู้เล่นป้อนตัวเลขที่ต้องการทาย ให้เขียนโค้ดในรายการที่ 2-1 ลงในไฟล์ src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

โค้ดนี้มีข้อมูลต่างๆ อยู่มากมาย มาลองทำความเข้าใจกันไปทีละบรรทัด เพื่อที่จะรับค่าอินพุตจากผู้ใช้และแสดงผลลัพธ์ที่ได้ออกมาทางเอาต์พุต เราจำเป็นต้องนำเข้าไลบรารีสำหรับนำข้อมูลเข้า/ออก (input/output) ชื่อว่า io เข้ามาในขอบเขต (scope) ไลบรารี io นี้มาจากไลบรารีมาตรฐานที่ชื่อว่า std:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

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

หากประเภทข้อมูล (type) ที่คุณต้องการใช้งานไม่ได้อยู่ใน prelude คุณต้องนำเข้าขอบเขตนั้นอย่างเจาะจงผ่านคำสั่ง use การใช้งานไลบรารี std::io จะช่วยอำนวยความสะดวกในการใช้งานฟีเจอร์ที่มีประโยชน์หลายตัว รวมถึงการรับข้อมูลอินพุตจากผู้ใช้ด้วย

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

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

ไวยากรณ์ fn ใช้เพื่อประกาศฟังก์ชันใหม่ วงเล็บ () ระบุว่าฟังก์ชันนี้ไม่มีพารามิเตอร์ และปีกกา { จะเริ่มต้นเนื้อหาของฟังก์ชัน

ตามที่คุณได้เรียนรู้ในบทที่ 1 มาโคร println! จะมีหน้าที่พิมพ์ข้อความลงบนหน้าจอ:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

โค้ดส่วนนี้จะพิมพ์คำแนะนำเพื่อบอกว่านี่คือเกมอะไรและขอรับข้อมูลจากผู้ใช้

การเก็บค่าด้วยตัวแปร

ถัดไป เราจะสร้าง ตัวแปร (variable) เพื่อเก็บข้อมูลอินพุตของผู้ใช้ ดังนี้:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

ตอนนี้โปรแกรมเริ่มน่าสนใจขึ้นแล้ว! มีการทำงานเกิดขึ้นหลายอย่างในบรรทัดเล็กๆ นี้ เราใช้งานคำสั่ง let เพื่อสร้างตัวแปรขึ้นมา ตัวอย่างเช่น:

let apples = 5;

บรรทัดนี้จะสร้างตัวแปรใหม่ชื่อ apples และผูก (bind) ค่าเข้ากับเลข 5 ในภาษา Rust ตัวแปรจะไม่สามารถเปลี่ยนแปลงค่าได้เป็นค่าเริ่มต้น (immutable by default) ซึ่งหมายความว่าเมื่อเราให้ค่าแก่ตัวแปรนั้นแล้ว ค่านั้นจะไม่สามารถเปลี่ยนแปลงได้อีก เราจะหารือเกี่ยวกับแนวคิดนี้โดยละเอียดในหัวข้อ “ตัวแปรและการแก้ไขค่าได้” ในบทที่ 3 หากต้องการทำให้ตัวแปรสามารถแก้ไขค่าได้ เราจะต้องเติม mut หน้าชื่อตัวแปร:

let apples = 5; // immutable
let mut bananas = 5; // mutable

หมายเหตุ: รูปแบบ // เป็นการเริ่มต้นการเขียนคำอธิบายโค้ด (comments) ซึ่งจะมีผลไปจนถึงสิ้นสุดบรรทัด Rust จะเพิกเฉยต่อข้อมูลทุกอย่างที่อยู่ในคำอธิบายโค้ด เราจะพูดคุยเกี่ยวกับคำอธิบายโค้ดเพิ่มเติมใน บทที่ 3

ย้อนกลับมาที่โปรแกรมเกมทายตัวเลขของเรา ตอนนี้คุณทราบแล้วว่า let mut guess จะเป็นการสร้างตัวแปรที่แก้ไขค่าได้ชื่อว่า guess เครื่องหมายเท่ากับ (=) จะคอยบอก Rust ว่าเราต้องการนำบางสิ่งมาผูกเข้ากับตัวแปรในตอนนี้ ส่วนทางขวาของเครื่องหมายเท่ากับคือค่าที่ตัวแปร guess จะถูกผูกด้วย ซึ่งได้จากผลการเรียกฟังก์ชัน String::new ซึ่งเป็นฟังก์ชันที่สร้างออบเจกต์อินสแตนซ์ใหม่ของประเภทข้อมูล String โดยที่ String เป็นประเภทของข้อความ (string type) ที่มีให้ในไลบรารีมาตรฐาน ซึ่งมีขนาดที่ขยายเพิ่มได้ และเข้ารหัสข้อความด้วย UTF-8

เครื่องหมาย :: ในบรรทัด ::new แสดงให้เห็นว่า new คือฟังก์ชันที่เกี่ยวข้อง (associated function) ของประเภทข้อมูล String โดย ฟังก์ชันที่เกี่ยวข้อง คือฟังก์ชันที่เขียนไว้บนตัวประเภทข้อมูล (ในกรณีนี้คือประเภทข้อมูล String) ฟังก์ชัน new นี้จะสร้างข้อความใหม่ที่ว่างเปล่าขึ้นมา คุณจะพบฟังก์ชัน new ในประเภทข้อมูลหลายตัว เนื่องจากมันเป็นชื่อนิยมทั่วไปสำหรับฟังก์ชันที่มีไว้สร้างค่าใหม่ของประเภทข้อมูลนั้นๆ

สรุปโดยรวมแล้ว บรรทัด let mut guess = String::new(); จะสร้างตัวแปรที่สามารถแก้ไขค่าได้ซึ่งในปัจจุบันถูกผูกไว้กับอินสแตนซ์ใหม่ที่ว่างเปล่าของประเภทข้อมูล String นั่นเอง 휴!

การรับข้อมูลอินพุตจากผู้ใช้

จำได้ไหมว่าเราได้นำเอาฟังก์ชันการนำเข้าและแสดงผล (input/output) จากไลบรารีมาตรฐานเข้ามาด้วยคำสั่ง use std::io; ในบรรทัดแรกของโปรแกรม คราวนี้เราจะเรียกใช้ฟังก์ชัน stdin จากโมดูล io ซึ่งจะช่วยให้เราสามารถจัดการกับอินพุตของผู้ใช้งานได้:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

หากเราไม่ได้นำเข้าโมดูล io ด้วยคำสั่ง use std::io; ตั้งแต่แรกเริ่มของโปรแกรม เราก็ยังสามารถเรียกใช้งานฟังก์ชันนี้ได้อยู่ดีโดยเขียนว่า std::io::stdin โดยฟังก์ชัน stdin จะส่งกลับอินสแตนซ์ของประเภทข้อมูล std::io::Stdin ซึ่งทำหน้าที่เป็นตัวจัดการ (handle) สำหรับการรับข้อมูลเข้ามาตรฐาน (standard input) ของเทอร์มินัลของคุณ

บรรทัดถัดมาคือ .read_line(&mut guess) จะเรียกใช้งานเมธอด read_line บนตัวจัดการการรับข้อมูลมาตรฐานเพื่อรับข้อมูลจากผู้ใช้งาน เรายังต้องส่งอาร์กิวเมนต์เป็น &mut guess ให้กับ read_line เพื่อบอกว่าจะให้บันทึกผลอินพุตของผู้ใช้ลงในข้อความใด หน้าที่ทั้งหมดของ read_line คือนำสิ่งใดก็ตามที่ผู้ใช้งานพิมพ์เข้ามาในข้อมูลเข้ามาตรฐานมาต่อท้าย (append) ลงไปในข้อความที่เรากำหนด (โดยไม่ลบเนื้อหาเก่าทิ้ง) ดังนั้นเราจึงส่งผ่านข้อความนั้นเป็นอาร์กิวเมนต์ และข้อความดังกล่าวจะต้องสามารถแก้ไขค่าได้ (mutable) เมธอดนี้จึงจะสามารถปรับเปลี่ยนเนื้อหาภายในข้อความได้

เครื่องหมาย & บ่งบอกว่าอาร์กิวเมนต์ตัวนี้คือ การอ้างอิง (reference) ซึ่งช่วยให้ส่วนต่างๆ ของโค้ดสามารถเข้าถึงข้อมูลชิ้นเดียวกันได้โดยไม่จำเป็นต้องคัดลอกข้อมูลนั้นลงบนหน่วยความจำซ้ำๆ หลายรอบ การอ้างอิง (references) ถือเป็นคุณลักษณะที่มีความซับซ้อน และหนึ่งในข้อดีหลักของ Rust ก็คือความปลอดภัยและความสะดวกในการใช้งานเรื่องดังกล่าว คุณไม่จำเป็นต้องเรียนรู้รายละเอียดทั้งหมดนี้เพื่อที่จะเขียนโปรแกรมนี้ให้เสร็จสมบูรณ์ สำหรับตอนนี้ สิ่งที่คุณต้องรู้คือ การอ้างอิงก็เหมือนกับตัวแปรทั่วไป คือจะไม่สามารถแก้ไขค่าได้เป็นค่าเริ่มต้น (immutable by default) ดังนั้นคุณจึงจำเป็นต้องเขียน &mut guess แทนที่จะเป็น &guess เพื่อทำให้แก้ไขค่าได้ (บทที่ 4 จะอธิบายเรื่องการอ้างอิงอย่างละเอียดอีกครั้ง)

การจัดการกับข้อผิดพลาดที่อาจเกิดขึ้นด้วย Result

เรายังคงทำความเข้าใจบรรทัดนี้อยู่ ตอนนี้เรากำลังพูดถึงข้อความส่วนที่สาม แต่โปรดทราบว่ามันยังคงเป็นส่วนหนึ่งของบรรทัดโค้ดเดียวกันในเชิงการทำงาน ส่วนถัดไปคือเมธอดนี้:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

เราสามารถเขียนโค้ดนี้ย่อลงเป็นบรรทัดเดียวได้ดังนี้:

io::stdin().read_line(&mut guess).expect("Failed to read line");

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

ดังที่ได้กล่าวไว้ก่อนหน้านี้ว่า read_line จะนำข้อความที่ผู้ใช้พิมพ์มาเก็บไว้ในอาร์กิวเมนต์ที่เราส่งผ่านไปให้ แต่ก็ยังส่งกลับค่าประเภท Result มาด้วย ซึ่ง Result คือ ประเภทข้อมูลแจกแจงค่า (enumeration) หรือที่เรียกสั้นๆ ว่า enum ซึ่งก็คือประเภทข้อมูลที่สามารถมีสถานะที่เป็นไปได้หลายแบบ เราเรียกแต่ละสถานะที่เป็นไปได้นั้นว่า ตัวแปรรูปแบบ (variant)

ใน บทที่ 6 จะอธิบายเรื่อง enums อย่างละเอียดอีกครั้ง วัตถุประสงค์ของประเภทข้อมูล Result นี้ก็คือการเข้ารหัสข้อมูลสำหรับใช้ในการจัดการกับข้อผิดพลาด (error-handling)

ตัวแปรรูปแบบ (variants) ของ Result คือ Ok และ Err โดยรูปแบบ Ok จะเป็นตัวบ่งบอกว่าการทำงานดังกล่าวประสบความสำเร็จและมีค่าผลลัพธ์ที่สร้างได้สำเร็จบรรจุอยู่ภายใน ส่วนรูปแบบ Err จะหมายถึงการทำงานเกิดข้อผิดพลาดและจะมีข้อมูลที่บอกว่าความผิดพลาดนั้นเกิดขึ้นได้อย่างไรหรือเพราะเหตุใดบรรจุอยู่

ค่าของข้อมูลประเภท Result ก็เหมือนกับประเภทอื่นๆ คือมีเมธอดต่างๆ ที่ถูกประกาศไว้บนตัวมัน อินสแตนซ์ของ Result จะมีเมธอด expect ที่สามารถเรียกใช้งานได้ หากผลลัพธ์ของ Result นั้นมีค่าเป็น Err การเรียก expect จะทำให้โปรแกรมหยุดทำงาน (crash) และจะแสดงข้อความที่คุณส่งไปเป็นอาร์กิวเมนต์ของ expect บนหน้าจอ หากเมธอด read_line คืนค่ากลับมาเป็น Err มักเกิดจากข้อผิดพลาดที่ส่งมาจากฝั่งระบบปฏิบัติการพื้นฐาน และถ้าหากผลลัพธ์ของ Result เป็นค่า Ok ตัวเมธอด expect จะดึงเอาผลลัพธ์ค่าจริงที่อยู่ในรูปแบบ Ok นั้นออกมาและส่งคืนให้กับคุณเพื่อนำไปใช้งานต่อได้ทันที ซึ่งในกรณีนี้ ค่าดังกล่าวก็คือจำนวนขนาดไบต์ของอินพุตที่ผู้ใช้พิมพ์เข้ามา

หากคุณไม่ยอมเรียกใช้งาน expect โปรแกรมจะยังสามารถคอมไพล์ผ่านอยู่ได้ แต่คุณจะได้รับข้อความแจ้งเตือนความปลอดภัย (warning):

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust แจ้งเตือนว่าคุณไม่ได้ใช้ค่า Result ที่คืนกลับมาจากเมธอด read_line ซึ่งหมายความว่าตัวโปรแกรมยังไม่ได้เตรียมการรับมือกับข้อผิดพลาดที่อาจจะเกิดขึ้น

วิธีการแก้ไขคำเตือนนี้ให้ถูกต้องจริงๆ คือการเขียนโค้ดจัดการกับข้อผิดพลาด (error-handling code) แต่สำหรับกรณีของเรานี้ เราแค่ต้องการให้โปรแกรมหยุดทำงานทันทีเมื่อเกิดข้อผิดพลาดขึ้น ดังนั้นเราจึงสามารถเลือกใช้เมธอด expect ได้ คุณจะได้เรียนรู้เกี่ยวกับการกู้คืนระบบจากข้อผิดพลาดต่างๆ ใน บทที่ 9

การแสดงผลค่าตัวแปรด้วยตัวสำรองที่นั่ง (Placeholders) ของ println!

นอกจากปีกกาปิดแล้ว ก็มีเพียงแค่อีกหนึ่งบรรทัดเท่านั้นที่เราจะมาอธิบายกันจากโค้ดที่เขียนไว้ก่อนหน้านี้:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

บรรทัดนี้จะพิมพ์ข้อความซึ่งตอนนี้นั้นเก็บค่าอินพุตของผู้ใช้งานเอาไว้ เครื่องหมายปีกกา {} ทำหน้าที่เป็นตัวแทนช่องว่างสำหรับเติมคำ (placeholder): ให้นึกถึง {} เหมือนกับก้ามปูตัวเล็กๆ ที่คอยหนีบค่าเอาไว้ไม่ให้หายไป เมื่อต้องการแสดงผลลัพธ์ของตัวแปร เราสามารถระบุชื่อของตัวแปรลงไปในเครื่องหมายปีกกานั้นได้ และเมื่อต้องการพิมพ์ค่าที่เกิดจากการประมวลผลนิพจน์ (expression) ให้ใส่เครื่องหมายปีกกาเปล่าในข้อความหลัก แล้วตามด้วยรายการนิพจน์ที่คั่นด้วยเครื่องหมายจุลภาค (comma) เพื่อพิมพ์ลงในช่องว่างนั้นตามลำดับ การพิมพ์ค่าของตัวแปรและผลลัพธ์ของนิพจน์ด้วยคำสั่ง println! ในครั้งเดียวจะมีรูปแบบดังนี้:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

โค้ดนี้จะแสดงผลออกมาเป็น x = {x} and y + 2 = {}, y + 2 ในกรณีทั่วไป แต่สำหรับโค้ดนี้จะพิมพ์ x = 5 and y + 2 = 12

การทดสอบการทำงานส่วนแรก

มาทดสอบการทำงานส่วนแรกของเกมทายตัวเลขกัน โดยให้ทดลองรันโดยการใช้คำสั่ง cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

มาถึงจุดนี้ การทำงานในส่วนแรกของเกมก็สำเร็จลุล่วงแล้ว: เราสามารถรับข้อมูลนำเข้าจากคีย์บอร์ดแล้วจึงแสดงข้อความนั้นกลับออกมาได้

การสร้างตัวเลขปริศนา

ขั้นตอนต่อไปคือเราต้องการสร้างตัวเลขปริศนาขึ้นมาเพื่อให้ผู้ใช้นำมาใช้ในการทาย ซึ่งตัวเลขปริศนานี้ควรจะเปลี่ยนไปในทุกๆ รอบเพื่อให้เกมน่าเล่นมากกว่าหนึ่งครั้ง เราจะสุ่มตัวเลขระหว่าง 1 ถึง 100 เพื่อให้เกมน่าเล่นและไม่ยากเกินไป ภาษา Rust ยังไม่ได้เพิ่มฟังก์ชันสำหรับการสุ่มตัวเลขเข้ามาในไลบรารีมาตรฐานของตนเอง อย่างไรก็ตาม ทีมงาน Rust ก็ได้สร้างเครื่องมือตัวช่วยเป็น rand crate ที่มาพร้อมความสามารถในการสุ่มตัวเลขนี้ไว้ให้ใช้งาน

การเพิ่มฟังก์ชันการทำงานด้วยเครต (Crate)

จำได้ไหมว่าเครต (crate) คือชุดไฟล์ซอร์สโค้ดของภาษา Rust โปรเจกต์ที่เรากำลังสร้างขึ้นนี้ถือเป็นเครตไบนารี (binary crate) ซึ่งก็คือไฟล์ที่รันได้โดยตรง ส่วนเครต rand จะเป็นเครตไลบรารี (library crate) ซึ่งเป็นโค้ดที่มีเจตนาสำหรับนำไปให้โปรแกรมอื่นๆ เรียกใช้งานร่วมกันและตัวมันเองไม่สามารถรันแยกต่างหากแบบอิสระได้

การจัดการเครตภายนอกต่างๆ ถือเป็นจุดเด่นหลักของ Cargo ก่อนที่เราจะสามารถเริ่มเขียนโค้ดเพื่อเรียกใช้งาน rand ได้ เราจำเป็นต้องทำการแก้ไขไฟล์ Cargo.toml เพื่อเพิ่มเครต rand เข้าไปในรายการไลบรารีที่ต้องใช้พึ่งพาก่อน ให้ลองเปิดไฟล์นั้นขึ้นมาตอนนี้ แล้วเพิ่มบรรทัดดังต่อไปนี้ลงไปที่ด้านล่างสุดของส่วนหัวข้อ [dependencies] ที่ Cargo สร้างไว้ให้ โปรดเขียนระบุ rand และหมายเลขเวอร์ชันให้ตรงตามตัวอย่างนี้ทุกประการ ไม่เช่นนั้นตัวอย่างในบทเรียนนี้อาจรันไม่ผ่านได้:

Filename: Cargo.toml

[dependencies]
rand = "0.10.1"

ในไฟล์ Cargo.toml ข้อมูลทั้งหมดที่อยู่ภายใต้หัวข้อส่วนหนึ่งๆ จะถือเป็นส่วนเดียวกันไปเรื่อยๆ จนกว่าจะมีส่วนหัวข้อใหม่เกิดขึ้น ภายใต้หัวข้อ [dependencies] คุณจะได้ระบุข้อมูลให้ Cargo รับรู้ว่าโปรเจกต์ของคุณจำเป็นต้องพึ่งพาเครตภายนอกตัวใดบ้างและต้องการเวอร์ชันใดบ้าง ในที่นี้เราได้กำหนดให้ใช้เครต rand ด้วยการประกาศหมายเลขเวอร์ชัน 0.10.1 ทาง Cargo จะอ่านค่านี้ตามรูปแบบ การระบุเวอร์ชันเชิงความหมาย (Semantic Versioning) (หรือเรียกย่อๆ ว่า SemVer) ซึ่งเป็นมาตรฐานสากลที่นิยมใช้ในการเขียนระบุรุ่นของซอฟต์แวร์ การระบุว่า 0.10.1 จะหมายถึงค่าตัวย่อจากรูปเต็ม ^0.10.1 ซึ่งหมายความว่าจะอนุญาตให้ใช้งานเวอร์ชันใดก็ได้ที่มีค่าตั้งแต่ 0.10.1 ขึ้นไป แต่ต้องต่ำกว่ารุ่น 0.11.0

Cargo พิจารณาว่าเวอร์ชันในย่านดังกล่าวจะมีโครงสร้าง API สาธารณะที่ใช้งานเข้ากันได้ดีกับเวอร์ชัน 0.10.1 และการกำหนดกฎแบบนี้จะช่วยให้คุณได้รับรุ่นปรับปรุงล่าสุด (patch release) ที่ยังคอมไพล์เข้ากับโค้ดที่เราเขียนร่วมกันในบทนี้ได้ ส่วนเวอร์ชันรุ่น 0.11.0 ขึ้นไป จะไม่การันตีว่าจะยังมีรูปแบบ API ที่สอดคล้องกับตัวอย่างโค้ดที่จะใช้ถัดจากนี้ไป

คราวนี้โดยที่ยังไม่ต้องแก้ไขตัวโค้ดใดๆ เลย ให้ลองทำการคอมไพล์โปรเจกต์ด้วย Cargo ดังที่แสดงในรายการที่ 2-2:

$ cargo build
    Updating crates.io index
     Locking 8 packages to latest Rust 1.96.0 compatible versions
  Downloaded rand_core v0.10.1
  Downloaded chacha20 v0.10.1
  Downloaded rand v0.10.1
  Downloaded 3 crates (162.9KiB) in 0.59s
   Compiling libc v0.2.186
   Compiling rand_core v0.10.1
   Compiling getrandom v0.4.3
   Compiling cfg-if v1.0.4
   Compiling chacha20 v0.10.1
   Compiling rand v0.10.1
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.03s

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

เมื่อเรานำเข้าไลบรารีภายนอกเข้ามาใช้งาน ทาง Cargo จะตรวจสอบและดึงเอาเวอร์ชันล่าสุดของเครตอื่นๆ ทั้งหมดที่เครตนี้จำเป็นต้องใช้เพิ่มเติมจากระบบฐานข้อมูลทะเบียนกลาง (registry) ซึ่งดึงข้อมูลจำลองมาจากเว็บไซต์ Crates.io ซึ่งเป็นพื้นที่แบ่งปันผลงานที่สมาชิกนักเขียนโค้ดในชุมชน Rust มารวมตัวกันแชร์โปรเจกต์โอเพนซอร์สของตนเพื่อให้คนอื่นๆ นำไปประยุกต์ใช้งาน

ภายหลังอัปเดตทะเบียนกลางแล้ว Cargo จะดำเนินการตรวจสอบในส่วนของ [dependencies] แล้วดาวน์โหลดเครตที่ระบุไว้หากเครื่องของคุณยังไม่ได้ทำการโหลดมันมาเก็บไว้ ในโปรเจกต์นี้แม้เราจะสั่งระบุ dependency ไว้เพียงตัวเดียวคือ rand แต่ในกระบวนการทำงาน Cargo ก็ได้ดึงข้อมูลเครตอื่นๆ อีกมากมายที่ rand ต้องพึ่งพามาให้โดยอัตโนมัติ หลังจากขั้นตอนการโหลดตัวเครตเสร็จแล้ว Rust จะดำเนินการคอมไพล์เครตพวกนี้เสียก่อน แล้วจึงคอมไพล์ตัวโปรเจกต์หลักของเราเข้ากับตัวไลบรารีดังกล่าว

หากคุณรีบรันคำสั่ง cargo build อีกครั้งทันทีโดยไม่มีการปรับแก้โค้ดเลย คุณจะไม่พบผลลัพธ์การทำงานใดๆ เลยนอกจากบรรทัดรายงาน Finished เนื่องจาก Cargo ทราบดีว่าไลบรารีที่ต้องใช้พึ่งพานั้นถูกดาวน์โหลดและประกอบไว้เรียบร้อยแล้ว และคุณก็ไม่ได้แก้ไข้อะไรเพิ่มเติมในไฟล์ Cargo.toml ทั้งหมดนี้รวมถึง Cargo ทราบว่าตัวโค้ดของคุณก็ยังไม่มีการเปลี่ยนแปลงใดๆ จึงไม่ต้องทำการคอมไพล์โค้ดใหม่ เมื่อไม่มีสิ่งใดค้างคาให้ทำระบบจึงเลือกจบการทำงานและปิดไป

หากคุณสลับมาเปิดไฟล์ src/main.rs แล้วทดลองแก้เนื้อหาเล็กๆ น้อยๆ แล้วบันทึกและสั่งสร้างใหม่อีกครั้ง คุณจะได้รับผลลัพธ์รายงานออกมาเพียงสองแถวเท่านั้น:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

ผลลัพธ์ดังกล่าวชี้ให้เห็นว่า Cargo เลือกที่จะอัปเดตการประกอบโปรแกรมเฉพาะจุดตรงที่คุณได้เขียนแก้ไขไว้ในไฟล์ src/main.rs เท่านั้น เนื่องจากความสัมพันธ์กับเครตภายนอกอื่นๆ ยังคงเหมือนเดิม Cargo จึงตัดสินใจดึงโครงสร้างเดิมที่เคยดาวน์โหลดและคอมไพล์เสร็จสิ้นแล้วกลับมาใช้ซ้ำได้ทันที

การรับประกันการคอมไพล์ที่ให้ผลลัพธ์เหมือนเดิมทุกครั้ง (Reproducible Builds)

Cargo มีกลไกที่ช่วยรับประกันว่าตัวชิ้นงานผลลัพธ์ (artifact) ที่คอมไพล์ได้จากตัวโค้ดของคุณจะคงที่และเหมือนเดิมอยู่เสมอทุกๆ ครั้ง ไม่ว่าคุณหรือใครก็ตามจะเป็นผู้นำโค้ดนี้ไปสั่งสร้างใหม่ นั่นคือ Cargo จะบังคับล็อกการใช้งานรุ่นเวอร์ชันของไลบรารีต่างๆ ให้อยู่ตรงตามที่คุณเจาะจงไว้เดิมไปตลอดจนกว่าคุณจะสั่งการให้เปลี่ยนแปลง ยกตัวอย่างเช่น ในสัปดาห์หน้าเครต rand มีการออกรุ่นใหม่เวอร์ชัน 0.10.2 ซึ่งแม้จะช่วยแก้ไขข้อผิดพลาดที่สำคัญได้ แต่อาจเกิดปัญหาจุดอื่นที่อาจจะทำให้โปรแกรมของคุณทำงานล้มเหลว เพื่อหลีกเลี่ยงข้อกังวลดังกล่าว Rust จึงได้ทำการสร้างไฟล์ Cargo.lock ขึ้นมาในการรันคำสั่ง cargo build รอบแรกสุด ซึ่งคุณจะเห็นไฟล์ตัวนี้อยู่ในไดเรกทอรี guessing_game ของคุณเรียบร้อยแล้ว

ในตอนที่คุณเริ่มคอมไพล์สร้างโปรเจกต์เป็นครั้งแรก Cargo จะทำการประเมินค้นหารุ่นเวอร์ชันของไลบรารีทั้งหมดที่เข้าเกณฑ์และเขียนรายงานพวกนี้เก็บเข้าไว้ในไฟล์ Cargo.lock เมื่อใดก็ตามที่คุณสั่งการคอมไพล์โปรเจกต์นี้ในภายหลัง Cargo จะตรวจสอบว่าไฟล์ Cargo.lock นี้มีอยู่หรือไม่ และจะตรงเข้าไปหยิบค่าของรุ่นที่กำหนดในนั้นมาใช้งานทันทีโดยไม่ต้องเริ่มต้นประเมินคำนวณเวอร์ชันที่คู่ควรใหม่อีกรอบ สิ่งนี้ช่วยให้คุณมีการสร้างซอฟต์แวร์ที่ให้ผลลัพธ์คงที่อยู่เสมอตลอดไปโดยอัตโนมัติ กล่าวคือ โปรเจกต์ของคุณจะถูกล็อกไว้กับรุ่น 0.10.1 เสมอจนกว่าคุณจะสั่งอัปเกรดอย่างเป็นทางการ ด้วยประโยชน์จากไฟล์ Cargo.lock นี้ และเนื่องจากไฟล์นี้มีความสำคัญต่อการรักษารูปแบบดั้งเดิมของโปรเจกต์ มันจึงมักถูกนำเข้าสู่ระบบจัดการซอร์สโค้ด (source control) ร่วมกับโค้ดส่วนที่เหลือในโปรเจกต์ด้วย

การอัปเดตเครตเพื่อใช้เวอร์ชันใหม่กว่า

เมื่อคุณ มีความต้องการ จะสั่งอัปเกรดเครตจริง ๆ ทาง Cargo ก็มีเครื่องมือเตรียมไว้ให้ผ่านการใช้คำสั่ง update ซึ่งจะช่วยข้ามขั้นตอนการอ่านค่าล็อกเดิมในไฟล์ Cargo.lock และคำนวณหาเวอร์ชันรุ่นล่าสุดในปัจจุบันที่ยังเข้าข่ายเงื่อนไขที่คุณกำหนดไว้ในไฟล์ Cargo.toml จากนั้น Cargo จะนำรุ่นเวอร์ชันใหม่เหล่านั้นกลับไปแก้ไขบันทึกแทนที่ในไฟล์ Cargo.lock โดยตามขั้นตอนปกติทั่วไปแล้ว Cargo จะค้นหาช่วงรุ่นเวอร์ชันที่มากกว่า 0.10.1 แต่ต้องน้อยกว่า 0.11.0 เท่านั้น หากเครต rand มีการปล่อยอัปเดตเวอร์ชันออกมา 2 ตัว ได้แก่ 0.10.2 และ 0.999.0 แล้วคุณได้สั่งรัน cargo update คุณจะพบผลลัพธ์ปรากฏบนหน้าจอทำนองนี้:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.96.0 compatible version
    Updating rand v0.10.1 -> v0.10.2 (available: v0.999.0)

Cargo เลือกที่จะเพิกเฉยต่อการออกรุ่น 0.999.0 และเมื่อตรวจดูในตอนหลัง คุณจะพบรายงานในไฟล์ Cargo.lock ปรับเปลี่ยนข้อมูลไปบันทึกว่าเวอร์ชันของเครต rand ที่เลือกใช้งานปัจจุบันนั้นเปลี่ยนมาเป็นรุ่น 0.10.2 หากคุณมีความประสงค์เจาะจงจะเรียกใช้เวอร์ชัน 0.999.0 หรือกลุ่มรุ่นตระกูล 0.999.x คุณจำเป็นต้องไปปรับแก้เนื้อหาในไฟล์ Cargo.toml ให้แสดงผลดังด้านล่างนี้แทน (แต่อย่าเพิ่งแก้ไขโปรเจกต์ของคุณจริงๆ ตอนนี้นะครับ เนื่องจากตัวอย่างอื่นๆ ด้านล่างจะอิงการทำงานกับเครต rand รุ่น 0.10 เป็นหลัก):

[dependencies]
rand = "0.999.0"

และในรอบถัดไปที่คุณรันคำสั่ง cargo build ระบบของ Cargo จะอัปเดตทะเบียนรายชื่อเครตและประเมินเงื่อนไขการนำเข้าของ rand ใหม่ทั้งหมดตามค่าเวอร์ชันที่คุณกำหนดไว้ล่าสุด

ยังมีข้อมูลและรายละเอียดที่น่าสนใจอีกมากมายเกี่ยวกับ Cargo และ ระบบนิเวศ (ecosystem) ของมัน ซึ่งเราจะกลับมาร่วมพูดคุยอย่างละเอียดในบทที่ 14 แต่สำหรับจุดนี้ข้อมูลเท่านี้ถือว่าพร้อมใช้แล้ว Cargo ช่วยให้นักพัฒนาสามารถเรียกนำโค้ดในรูปแบบไลบรารีกลับมาใช้ซ้ำได้อย่างไร้กังวล ส่งผลให้ชาว Rustacean สามารถเขียนโค้ดโครงการย่อย ๆ ที่เชื่อมประกอบเข้าหากันจากหลากหลายแพ็กเกจมารวมกันได้อย่างสะดวกโยธิน

การสร้างตัวเลขแบบสุ่ม

มาเริ่มทดลองดึงใช้เครต rand มาสร้างตัวเลขปริศนาให้เราทายกันเลย ขั้นตอนถัดมาคือให้เราเข้าไปปรับโครงสร้างของไฟล์ src/main.rs ดังที่แสดงในรายการที่ 2-3

use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

อันดับแรก เราจะเติมคำสั่ง use rand::prelude::*; เข้าไปที่หัวไฟล์ โดยที่โมดูล prelude นี้จะมีกลุ่มฟังก์ชันความสามารถส่วนที่ใช้สอยบ่อยๆ ของเครต rand บรรจุอยู่ และการใช้ use จะทำให้กลุ่มความสามารถดังกล่าวสามารถหยิบมาใช้ได้ทันทีภายใต้ขอบเขตการทำงานของโปรแกรมเรา

ถัดไปเราจะเพิ่มคำสั่งสองบรรทัดไว้ที่ส่วนตรงกลาง ในบรรทัดแรกจะเป็นการเรียกใช้ฟังก์ชัน rand::rng เพื่อส่งกลับตัวสุ่มตัวเลขที่เราต้องการใช้งาน นั่นคือตัวสุ่มที่จะแยกกันทำงานในแต่ละเธรด (local thread) และสร้างค่าตั้งต้นสำหรับการสุ่ม (seeded) โดยอิงความปลอดภัยจากระบบปฏิบัติการ หลังจากนั้นเราจึงเรียกใช้เมธอด random_range บนตัวสุ่มดังกล่าว เมธอดนี้ถูกนิยามผ่านความสามารถของ RngExt เทรต (trait) ซึ่งเป็นส่วนหนึ่งในโมดูล rand::prelude ที่เรานำเข้ามาในขอบเขตการทำงานของไฟล์ตั้งแต่หัวโปรแกรมตัวช่วย use rand::prelude::*; โดยที่เมธอด random_range จะรับนิพจน์อธิบายขอบเขตช่วงตัวเลข (range expression) เป็นอาร์กิวเมนต์เพื่อกำหนดว่าต้องการคำนวณตัวเลขสุ่มในย่านใด ซึ่งรูปแบบของนิพจน์กำหนดช่วงที่เราเลือกใช้งานนี้จะเขียนระบุในรูป ค่าเริ่มต้น..=ค่าสิ้นสุด ซึ่งมีผลครอบคลุมและนับรวมเอาทั้งค่าขอบเขตล่างและขอบเขตบนไปด้วย ดังนั้นเราจึงต้องสั่งให้ค้นหาค่าตัวเลขระหว่างช่วง 1..=100 เพื่อให้ได้ตัวสุ่มระหว่าง 1 ถึง 100

หมายเหตุ: คุณคงจะไม่มีทางล่วงรู้ได้โดยธรรมชาติว่าจำเป็นต้องนำเข้าสิ่งใดจากขอบเขตบ้าง หรือจะต้องเรียกใช้ฟังก์ชันและเมธอดตัวไหนจากเครตบ้าง ดังนั้นเครตแต่ละตัวจึงมาพร้อมคู่มืออธิบายการนำไปใช้งาน และอีกหนึ่งคุณสมบัติสุดเท่ของ Cargo ก็คือการที่คุณสามารถรันคำสั่ง cargo doc --open ซึ่งจะประกอบช่วยดึงคู่มือของ dependencies ทั้งหมดที่คุณดาวน์โหลดไว้มาสร้างเว็บเอกสารบนเครื่องและเปิดเปิดมันขึ้นมาแสดงบนเว็บเบราว์เซอร์ให้คุณทันที หากคุณสนใจและต้องการรู้เรื่องขีดความสามารถอื่น ๆ ของเครต rand ตัวอย่างเช่น ให้ทดลองสั่งรันคำสั่ง cargo doc --open แล้วเลือกคลิกที่คำว่า rand ในแถบด้านซ้ายมือได้เลย

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

ลองสลับมารันโปรแกรมดูสักสองสามครั้ง:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

คุณควรจะได้รับการสุ่มที่แสดงผลเลขหลังจากรันโปรแกรมในแต่ละรอบที่แตกต่างกัน และตัวเลขทั้งหมดนั้นควรอยู่ในช่วงตั้งแต่ 1 ถึง 100 หากคุณพบคำแจ้งเตือนความปลอดภัย (warnings) คุณสามารถเพิกเฉยได้ทันที และถ้าพบข้อผิดพลาดประการใด ให้ตรวจทานความถูกต้องว่าคุณใส่ rand = "0.10.1" ไว้ในไฟล์ Cargo.toml หรือไม่ เนื่องจากเครตเวอร์ชันสูงถัดไปอาจปรับเปลี่ยนโครงสร้าง API ไปจากนี้บ้าง แต่หากอยู่ในช่วงตระกูลรุ่น 0.10 ทั้งหมดจะสามารถใช้งานกับตัวอย่างโค้ดในบทเรียนนี้ได้เป็นอย่างดี

การเปรียบเทียบค่าที่ทายกับตัวเลขปริศนา

เมื่อเรามีทั้งอินพุตจากทางผู้เล่นและตัวเลขสุ่มปริศนาแล้ว ถัดมาคือขั้นตอนในการนำค่าทั้งสองมาชั่งเปรียบเทียบกัน โดยขั้นตอนนี้แสดงตัวอย่างในรายการที่ 2-4 แต่พึงสังเกตว่าในตอนแรกนี้โค้ดยังไม่สามารถคอมไพล์ได้ผ่านฉลุย ซึ่งเราจะกลับมารายงานอธิบายเหตุผลให้ทราบกัน

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

อันดับแรก เราจะเพิ่มส่วนนำเข้าคำสั่ง use เข้าไปเพื่อเรียกใช้โครงสร้างประเภทข้อมูล std::cmp::Ordering จากในตัวคลังไลบรารีมาตรฐาน โดยข้อมูลประเภท Ordering นี้ก็ถือเป็นรูปแบบข้อมูล enum ประเภทหนึ่งที่มีตัวแปรรูปแบบ (variants) ทั้งหมดสามรุ่น ได้แก่ Less (น้อยกว่า), Greater (มากกว่า) และ Equal (เท่ากับ) ซึ่งนี่คือผลลัพธ์ทั้งสามแบบที่เป็นไปได้ในเชิงการตรวจสอบการเปรียบเทียบค่าสองค่า

หลังจากนั้นเราจึงเสริมโค้ดเพิ่มไปอีกห้าแถวด้านล่างสุดเพื่อประยุกต์ใช้งานประเภทข้อมูล Ordering โดยที่เมธอด cmp จะช่วยทำหน้าที่คำนวณเปรียบเทียบความสัมพันธ์ของค่า 2 ค่า ซึ่งเราจะสามารถเรียกใช้งานเมธอดนี้ได้บนตัวแปรใดๆ ก็ตามที่สามารถถูกประเมินชั่งเปรียบเทียบได้ โดยตัวมันจะรับค่าอ้างอิงของตัวเลขที่เราต้องการนำมาร่วมประเมิน ในที่นี้คือจะนำ guess ไปชั่งน้ำหนักกับ secret_number แล้วจึงคืนผลตอบรับกลับมาเป็นหนึ่งในรูปแบบ variants ของ enum Ordering ที่เราเรียกใช้ผ่าน use เมื่อครู่ จากนั้นเราจะใช้นิพจน์ควบคุม match ในการตัดสินใจทิศทางการดำเนินงานถัดไปด้วยการตรวจสอบรูปแบบของ Ordering ที่ได้มาจากการเปรียบเทียบ

นิพจน์ match จะถูกประกอบขึ้นมาจากตัวเลือกต่างๆ ที่เรียกว่า กิ่งการทำงาน (arms) โดยในแต่ละกิ่งจะระบุแนวทาง รูปแบบตัวอย่าง (pattern) เพื่อนำมาจับคู่วัดผล รวมถึงจะผูกโค้ดที่จะถูกทำงานเมื่อประเมินว่าค่าที่ส่งให้ match นั้นมีหน้าตาตรงกับเงื่อนไขรูปแบบของกิ่งดังกล่าว ซึ่ง Rust จะหยิบค่าต้นขั้วที่ส่งให้แก่ match มาเปรียบเทียบไล่ตรวจสอบทีละกิ่งตามลำดับ โครงสร้างเงื่อนไข Pattern และชุดคำสั่ง match จัดเป็นเครื่องมือที่มีประสิทธิภาพสูงของภาษา Rust ที่ช่วยให้นักพัฒนาอธิบายสภาวะการณ์ที่อาจเกิดขึ้นได้หลากหลายรูปแบบ และยังรับประกันว่าโค้ดจะดักสกัดครอบคลุมทุกเส้นทางที่อาจเกิดขึ้นได้ ซึ่งคุณลักษณะนี้จะได้รับคำอธิบายอย่างรอบด้านในบทที่ 6 และบทที่ 19 ตามลำดับ

ลองมาไล่สเต็ปทำความเข้าใจการทำงานนิพจน์ match ในกรณีตัวอย่างนี้ด้วยกัน สมมติว่าผู้ใช้งานกรอกคำตอบตัวเลขที่ทายเป็น 50 และระบบได้สุ่มค่าปริศนาสำหรับการทดสอบครั้งนี้ได้เลข 38

เมื่อโค้ดเริ่มเปรียบเทียบเลข 50 เข้ากับ 38 เมธอด cmp จะคืนค่ากลับมารายงานว่า Ordering::Greater เนื่องจาก 50 มีค่ามากกว่า 38 ทางนิพจน์ match จะนำเอาค่า Ordering::Greater ที่ได้รับนี้ไปตรวจเปรียบเทียบรูปแบบกับกิ่งตัวเลือกต่างๆ โดยเริ่มตรวจอันแรกคือ Ordering::Less ซึ่งพบว่าไม่สอดคล้องกันจึงเพิกเฉยไม่ประมวลผลโค้ดของกิ่งนั้นแล้วสลับไปทดสอบกิ่งต่อมา กิ่งถัดไปมีเงื่อนไขรูปแบบตรงกับ Ordering::Greater ซึ่งส่งผลให้เงื่อนไขตรงกันพอดิบพอดี! ดังนั้นระบบจึงรันโค้ดและพิมพ์คำว่า Too big! (ใหญ่เกินไป!) แสดงออกมาทางหน้าจอ นิพจน์ของ match จะยุติขบวนการทันทีหลังจากการหาคู่เปรียบเทียบแรกประสบความสำเร็จ ดังนั้นจึงไม่มีความจำเป็นต้องไล่ดูไปจนถึงกิ่งสุดท้ายในกรณีตัวอย่างรอบนี้

อย่างไรก็ตาม โค้ดในรายการที่ 2-4 นั้นยังไม่สามารถผ่านขั้นตอนคอมไพล์ได้ในตอนนี้ มาลองทดสอบกันเลย:

$ cargo build
   Compiling libc v0.2.186
   Compiling rand_core v0.10.1
   Compiling cfg-if v1.0.0
   Compiling getrandom v0.4.3
   Compiling chacha20 v0.10.1
   Compiling rand v0.10.1
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/cmp.rs:999:7

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

หัวใจหลักของข้อความข้อผิดพลาดชี้ว่าเกิดปัญหารูปแบบข้อมูลไม่สอดคล้องกัน (mismatched types) ภาษา Rust ใช้ระบบตรวจสอบชนิดข้อมูลที่เข้มงวดและคงที่ (strong, static type system) แต่ขณะเดียวกันก็มีความสามารถในการคาดเดาชนิดข้อมูลได้เอง (type inference) ตอนที่เราเขียนโค้ดระบุ let mut guess = String::new() ภาษา Rust สามารถคาดคะเนได้ว่าตัวแปร guess ควรเป็นข้อมูลประเภท String โดยไม่ต้องสั่งให้นักเขียนโค้ดระบุตัวแปรลงไปชัดๆ ในทางกลับกัน ตัวแปร secret_number ถูกสร้างมาจากระบบสุ่มซึ่งคืนค่าเป็นตัวเลข ซึ่งชนิดข้อมูลตัวเลขของ Rust ที่จะรองรับค่าระหว่าง 1 ถึง 100 นั้นมีหลากหลายรูปแบบ เช่น i32 (เลขจำนวนเต็มมีเครื่องหมายขนาด 32 บิต), u32 (เลขจำนวนเต็มไม่มีเครื่องหมายขนาด 32 บิต), i64 (ตัวเลขขนาด 64 บิต) และอื่นๆ ซึ่งหากไม่ได้สั่งการใดๆ เพิ่มเติม Rust จะตั้งค่าเริ่มต้นให้ประเภทตัวเลขสุ่มดังกล่าวเป็นชนิดข้อมูล i32 เสมอ เว้นแต่เราจะไปช่วยป้อนข้อมูลชนิดที่จำเป็นในส่วนอื่นเพื่อช่วยให้ Rust เดาไปเป็นชนิดตัวเลขอื่นได้ และข้อผิดพลาดเกิดขึ้นก็เนื่องจาก Rust ปฏิเสธการชั่งน้ำหนักเปรียบเทียบข้อมูลต่างประเภทกัน นั่นคือระหว่างประเภทข้อความ (String) กับประเภทตัวเลข นั่นเอง

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

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

ซึ่งประโยคคำสั่งดังกล่าวคือ:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

เราสร้างตัวแปรขึ้นมาตัวหนึ่งชื่อว่า guess แต่ช้าก่อน โปรแกรมของเราไม่ใช่ว่ามีตัวแปรชื่อ guess อยู่แล้วอย่างนั้นหรือ? ใช่ครับ แต่ระบบของภาษา Rust ยอมให้ผู้ใช้งานสร้างตัวแปรทับซ้อนชื่อเดิมที่ประกาศไปก่อนหน้านี้ได้ ซึ่งเรียกว่า การบังเงา (Shadowing) คุณลักษณะนี้ช่วยให้นักพัฒนาสามารถนำชื่อตัวแปรเก่ากลับมาใช้ซ้ำได้ แทนที่จะต้องมาตั้งชื่อตัวแปรแปลกแยกใหม่ขึ้นมา เช่น guess_str และ guess เป็นต้น เราจะไปดูรายละเอียดส่วนนี้กันใน บทที่ 3 สำหรับในตอนนี้ ให้จำเพียงว่าฟังก์ชันนี้มักถูกนำมาประยุกต์ใช้งานอย่างกว้างขวางเมื่อเราต้องการแปลงข้อมูลประเภทหนึ่งไปเป็นชนิดข้อมูลประเภทอื่น

เราผูกค่าตัวแปรตัวใหม่นี้เข้ากับนิพจน์ guess.trim().parse() โดยที่คำว่า guess ภายในนิพจน์จะไปดึงอ้างอิงค่ามาจากตัวแปร guess แรกเริ่มสุดที่ใช้เก็บค่าข้อความอินพุต เมธอด trim บนอินสแตนซ์ของ String จะจัดการล้างเครื่องหมายเว้นวรรคและเครื่องหมายขึ้นบรรทัดใหม่ทั้งหมดที่อยู่ขอบหน้าและขอบหลังข้อความ ซึ่งเป็นขั้นตอนที่สำคัญมากก่อนจะป้อนแปลงให้ข้อความดังกล่าวไปเป็นประเภทข้อมูล u32 (ซึ่งเก็บได้เฉพาะข้อมูลตัวเลขเพียวๆ เท่านั้น) เนื่องจากเวลาที่ผู้ใช้พิมพ์คำตอบแล้วกดปุ่ม enter เพื่อส่งผลลัพธ์ให้แก่ระบบของเมธอด read_line ตัวระบบจะบันทึกปุ่มอินพุตดังกล่าวแนบพ่วงท้ายข้อความมาด้วย ตัวอย่างเช่น หากผู้ใช้พิมพ์ตัวเลข 5 แล้วเคาะ enter ค่าตัวแปร guess จะหน้าตาเป็น 5\n โดยที่ \n คือเครื่องหมายขึ้นบรรทัดใหม่ (บนฝั่งระบบ Windows การกดแป้นพิมพ์ enter จะได้รับสัญลักษณ์พ่วงท้ายเป็น \r\n) เมธอด trim จะลบพวกสัญลักษณ์ \n หรือ \r\n เหล่านี้ทิ้งไปจนเหลือเพียงตัวเลขโดด 5

ส่วนเมธอด parse บนข้อความ จะมีหน้าที่เปลี่ยนประเภทข้อมูลข้อความไปเป็นข้อมูลรูปแบบอื่น ในที่นี้คือต้องการแปลงตัวอักษรให้กลายเป็นจำนวนเลข เรามีหน้าที่ชี้แนะให้ Rust ทราบความต้องการชนิดตัวเลขอย่างจำเพาะด้วยการพิมพ์ตัวบ่งชี้กำกับว่า let guess: u32 เครื่องหมายทวิภาค (:) หลังตัวแปร guess จะทำหน้าที่บอกกล่าวว่าจะทำการระบุชนิดข้อมูลให้ตัวแปรนี้ Rust เตรียมชนิดข้อมูลตัวเลขพื้นฐานไว้ให้เลือกใช้งานจำนวนหนึ่ง ตัวอย่างเช่น u32 ตรงจุดนี้คือจำนวนเต็มบวกไม่มีเครื่องหมายลบขนาด 32 บิต ซึ่งเหมาะสมมากสำหรับการใช้เป็นค่าตัวเลขบวกขนาดย่อม คุณจะได้ศึกษาข้อมูลประเภทตัวเลขอื่นใน บทที่ 3

และด้วยการใส่ป้ายกำกับชนิดข้อมูลชนิด u32 บนตัวแปรที่นำมาประเมินเปรียบเทียบกับ secret_number นี้เอง ทาง Rust ก็จะแอบอนุมานวิเคราะห์ความสัมพันธ์และแปลงค่าตัวแปร secret_number ให้รับรู้ว่าเป็นชนิดข้อมูล u32 ตามไปด้วยทันที ส่งผลให้ขั้นตอนชั่งตวงการเปรียบเทียบถัดไปนี้เป็นการคำนวณระหว่างตัวแปรชนิดประเภทตัวเลขขนาดยอดเดียวกันในที่สุด!

เมธอด parse จะยอมประมวลผลคำนวณสำเร็จได้เฉพาะกับตัวอักษรใด ๆ ที่สามารถนำมาประเมินค่าเป็นตัวเลขตามตรรกะได้เท่านั้น ด้วยเหตุนี้มันจึงสามารถเกิดข้อผิดพลาดขึ้นได้โดยง่าย ตัวอย่างเช่น หากข้อความอินพุตนำเข้ามาระบุเป็นสัญลักษณ์หน้าตาประหลาด เช่น A👍% ระบบจะไม่มีทางแปลงคำตอบนั้นมาเป็นตัวเลขจำนวนได้เลย และเนื่องจากกระบวนการนี้มีความเป็นไปได้ที่จะล้มเหลว เมธอด parse จึงส่งคืนผลลัพธ์เป็นข้อมูลประเภท Result สอดคล้องกับพฤติกรรมเดียวกับที่เมธอด read_line ทำงาน (ตามที่เราพูดคุยกันไว้ในหัวข้อ “การจัดการกับข้อผิดพลาดที่อาจเกิดขึ้นด้วย Result) เราจะจัดการกับค่าผลลัพธ์ Result ตัวนี้ในรูปแบบเดียวกันโดยเลือกพ่วงการเรียกใช้งานเมธอด expect อีกครั้ง หาก parse คืนผลกลับมาเป็น variant Err ของ Result เนื่องจากความล้มเหลวในการจัดกลุ่มแปลงข้อความ การสั่งรันคำสั่ง expect จะส่งผลให้โปรแกรมยุติทำงาน (crash) และรายงานข้อความที่เราส่งเข้าไป และในทางตรงกันข้ามหากเมธอด parse เปลี่ยนข้อความออกมาเป็นตัวเลขได้สมบูรณ์ มันจะรายงานค่าเป็น variant Ok ของข้อมูล Result และเมธอด expect จะแกะเอาตัวเลขจำนวนที่อยู่ภายในกล่อง Ok คืนกลับมาให้เรานำไปใช้งานได้ทันที

มาลองรันโปรแกรมดูตอนนี้กันเลย:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

ยอดเยี่ยม! แม้ว่าจะมีการเคาะเว้นวรรคเพิ่มเติมไปก่อนหน้าตัวเลขคำตอบ ตัวโปรแกรมก็ยังสามารถประเมินได้ถูกต้องว่าผู้ใช้งานป้อนค่าเลข 76 เข้ามา ให้ลองสลับรันโปรแกรมดูอีกสักสองสามรอบเพื่อประเมินความสอดคล้องของโปรแกรมตามสถานการณ์ต่างๆ เช่น ทายตัวเลขได้ตรงเฉลยพอดี, ทายตัวเลขที่สูงเกินไป และทายตัวเลขที่ต่ำเกินไป

เรามีส่วนการทำงานหลักของเกมทำงานได้เป็นส่วนใหญ่แล้ว แต่ผู้เล่นยังสามารถทายได้เพียงแค่รอบเดียวเท่านั้น เราลองมาใส่ลูปเพิ่มขีดความสามารถการทายหลายรอบกันดีกว่า!

การอนุญาตให้ทายได้หลายรอบด้วยโครงสร้างการวนซ้ำ

คำสั่งคำสำคัญ loop จะช่วยสร้างส่วนวนซ้ำไม่สิ้นสุด (infinite loop) ขึ้นมาใช้งาน เราจะเพิ่มมันเข้าไปในโปรแกรมเพื่อให้ผู้ใช้สามารถมีโอกาสแก้ตัวทายตัวเลขปริศนาได้เรื่อย ๆ:

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

จากตัวอย่างโค้ดด้านบน จะสังเกตเห็นว่าเราได้โยกย้ายขั้นตอนการป้อนอินพุตทายตัวเลขเป็นต้นไปทั้งหมดเข้าไปใส่ไว้ภายใต้การครอบทำงานของ loop อย่าลืมจัดย่อหน้าเว้นวรรค (indent) สำหรับบรรทัดคำสั่งต่างๆ ที่อยู่ด้านใน loop เพิ่มเข้าไปอีกฝั่งละ 4 สเปซด้วย และสั่งรันตัวโปรแกรมใหม่อีกครั้ง คราวนี้โปรแกรมจะร้องขอตัวเลขทายไปเรื่อย ๆ ตลอดกาล ซึ่งจริงๆ แล้วพฤติกรรมนี้จะสร้างปัญหาปวดหัวชุดใหม่ให้เราตามมา เนื่องจากดูเหมือนว่าผู้ใช้จะไม่มีวิธีสั่งปิดการทำงานตัวเกมได้เลย!

โดยธรรมชาติแล้ว ผู้ใช้งานสามารถสั่งหยุดตัวโปรแกรมลงกะทันหันได้เสมอโดยการกดแป้นพิมพ์คีย์ลัด ctrl ร่วมกับ C แต่นอกเหนือจากนั้นก็ยังมีกลเม็ดวิธีเอาตัวรอดออกมาจากลูปนรกนี้ได้อยู่ ดังเช่นข้อมูลที่เราคุยกันในส่วนของเมธอด parse ในหัวข้อ [“การเปรียบเทียบค่าที่ทายกับตัวเลขปริศนา”](#comparing-the-guess to-the-secret-number) นั่นคือหากผู้ใช้อินพุตพิมพ์ข้อมูลแปลกปลอมที่ไม่ใช่ตัวเลขเข้ามา ตัวโปรแกรมจะทำการแครชปิดตัวลงไปเองโดยทันที ซึ่งเราสามารถนำเงื่อนไขลักษณะพิเศษจุดนี้มาใช้ประโยชน์เป็นคำสั่งช่วยทางเลือกออกจากการทำงานได้ ดังตัวอย่างนี้:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' (6694925) panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

การพิมพ์คำว่า quit จะสามารถสั่งปิดตัวเกมได้สำเร็จ แต่ตามที่คุณคงสังเกตเห็นได้ว่าความจริงแล้วการป้อนคำปริศนาแปลกปลอมใด ๆ ที่ไม่ใช่ตัวเลขก็นำไปสู่การปิดเกมแบบเดียวกันทั้งหมด ซึ่งเป็นพฤติกรรมที่ไม่สมบูรณ์แบบเอาเสียเลย และนอกจากนี้เรายังต้องการให้ระบบเกมยุติขบวนการทำงานทันทีที่มีการทายตัวเลขได้ตรงเฉลยสำเร็จอีกด้วย

การหยุดการทำงานทันทีหลังจากทายถูก

เรามาสั่งการให้ตัวโปรแกรมหยุดรันตัวเกมเมื่อทายตัวเลขได้สำเร็จด้วยการเสริมชุดคำสั่ง break เข้าไปดังนี้:

Filename: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

การเพิ่มคำสั่ง break ต่อท้ายคำแสดงความยินดี You win! จะส่งผลให้โปรแกรมกระโดดหลุดออกมาจากขอบเขตการวนลูปในทันทีเมื่อทายเลขตรง และการหลุดจากขอบเขตวนลูปดังกล่าวจะทำให้โปรแกรมทำงานจบกระบวนการและปิดลงด้วย เนื่องจากตัวลูปนั้นถูกระบุไว้เป็นส่วนประกอบสุดท้ายของฟังก์ชัน main

การรับมือกับค่าข้อมูลป้อนเข้าไม่ถูกต้อง

เพื่อปรับพฤติกรรมของเกมให้มีความลื่นไหลเป็นมืออาชีพยิ่งขึ้น แทนที่เราจะปล่อยให้ตัวโปรแกรมปิดหน้าต่างหรือแครชตัวเองทิ้งเวลาที่ผู้เล่นพิมพ์ข้อมูลผิดหลงเข้ามา เราจะแก้ไขให้ระบบเพิกเฉยสัญลักษณ์เหล่านั้นและอนุญาตให้ผู้เล่นพิมพ์ทดสอบคำตอบใหม่ได้ทันทีเพื่อดำเนินการทายตัวเลขถัดไป เราสามารถจัดแจงแก้ไขแถวประโยคแปลตัวแปร guess จาก String ไปเป็น u32 ได้ดังที่แสดงตัวอย่างในรายการที่ 2-5

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

เราสลับปรับแก้จากการใช้คำสั่ง expect เดิม มาปรับเรียกใช้คำสั่งผ่านประโยคนิพจน์ควบคุม match เพื่อหลีกเลี่ยงกระบวนการปิดโปรแกรมเนื่องจากเกิดข้อผิดพลาด และผันตัวมารับมือจัดแจงปัญหาด้วยระบบจัดการความปลอดภัย จำไว้ว่าเมธอด parse จะส่งผลคืนข้อมูลเป็นชนิด Result ซึ่งเป็น enum ที่ประกอบสร้าง variant สำคัญ 2 รุ่นคือ Ok และ Err เราใช้คำสั่งตรวจจับ match ในจุดนี้เช่นเดียวกับที่ประยุกต์ใช้งานไปแล้วกับผลลัพธ์ข้อมูล Ordering ของเมธอด cmp ก่อนหน้านี้

หากเมธอด parse ทำงานแปลงตัวอักษรเป็นค่าจำนวนเต็มบวกเสร็จสิ้นสมบูรณ์ มันจะคืนค่าส่งกลับมาในห่อ variant ชื่อ Ok ซึ่งบรรจุเลขผลลัพธ์ที่แปลงได้อยู่ข้างใน และห่อ Ok ดังกล่าวจะมีรูปแบบเงื่อนไขพฤติกรรมไปตรงตามเงื่อนไขของกิ่งแรก Ok(num) ส่งผลให้นิพจน์ match แกะเอามูลค่าแท้จริงของ num ที่ parse คำนวณได้ส่งต่อออกมา และค่าตัวเลขจำนวนดังกล่าวจะถูกนำไปบันทึกระบุตรงตัวแปร guess ใหม่ที่เราเตรียมประกาศค่าไว้นั่นเอง

และในทำนองกลับกัน หากเมธอด parse ประสบปัญหาไม่สามารถปรับเปลี่ยนข้อความให้ไปเป็นข้อมูลตัวเลขจำนวนได้ มันจะคืนค่ารายงานมาเป็นตัวแปรรูปแบบ Err ซึ่งบรรจุประวัติข้อมูลข้อผิดพลาดพ่วงมาด้วย ซึ่งค่า Err ดังกล่าวจะประเมินแล้วไม่ตรงกับเงื่อนไขในกิ่ง Ok(num) กิ่งแรก แต่จะจับคู่ลงล็อกพอดิบพอดีกับกิ่งเงื่อนไขตัวเลือกที่สองคือ Err(_) โดยเครื่องหมายขีดล่าง _ ทำหน้าที่เป็นตัวแปรแบบดักจับทั้งหมด (catch-all value) ซึ่งในกรณีตัวอย่างนี้เราประกาศเพื่อสื่อว่าต้องการดักจับเงื่อนไขข้อมูลผิดพลาด Err ทุกรูปแบบไม่ว่าประวัติความล้มเหลวข้างในจะระบุเป็นข้อมูลประการใดก็ตาม ซึ่งตัวโปรแกรมจะดำเนินการตามโค้ดของกิ่งที่สองนั่นคือการเรียกใช้ฟังก์ชัน continue ซึ่งสั่งให้โปรแกรมข้ามขบวนการที่เหลือลงไปและย้อนเวลากลับกระโดดขึ้นไปเริ่มรันวนรอบใหม่ของโครงสร้าง loop ทันทีและร้องขออินพุตตัวเลขทายต่อไป ส่งผลลัพธ์ในทางปฏิบัติจริงให้ตัวโปรแกรมเพิกเฉยข้ามข้อผิดพลาดทั้งหมดที่เครื่องมือ parse มีโอกาสพบนั่นเอง!

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

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

สุดยอดมาก! ด้วยการปรับแต่งความเรียบร้อยจุดเล็ก ๆ จุดสุดท้าย เราก็จะสามารถปิดโปรเจกต์เกมทายตัวเลขนี้ได้แล้ว จำได้ใช่ไหมว่าโปรแกรมของเรายังมีการแสดงข้อความเฉลยตัวเลขปริศนาทิ้งไว้อยู่ ซึ่งแม้จะมีประโยชน์ต่อขั้นตอนพัฒนาระบบตัวช่วย แต่มันทำให้เกมขาดความตื่นเต้นท้าทาย เราจะมาจัดการลบแถวประโยคคำสั่ง println! ตัวที่เฉลยเลขสุ่มนั้นทิ้งไป โค้ดที่สมบูรณ์แบบพร้อมใช้งานจริงแสดงให้ดูในรายการที่ 2-6

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

และตอนนี้ คุณก็ได้สร้างโปรแกรมเกมทายตัวเลขด้วยตนเองได้สำเร็จเรียบร้อยแล้ว ขอแสดงความยินดีด้วยครับ!

สรุป

โปรเจกต์นี้ถือเป็นบทเรียนในเชิงลงมือปฏิบัติที่ช่วยแนะนำตัวอย่างโครงสร้างและแนวคิดสำคัญของภาษา Rust ไว้มากมาย ไม่ว่าจะเป็น let, match, ฟังก์ชัน, วิธีการเรียกใช้งานเครตภายนอก และรายละเอียดอื่นๆ ซึ่งในบทเรียนถัดๆ ไปข้างหน้า คุณจะได้มีโอกาสเจาะลึกศึกษาข้อมูลเหล่านี้อย่างกระจ่างและเป็นระบบยิ่งขึ้น โดยที่บทที่ 3 จะครอบคลุมแนวคิดพื้นฐานเรื่องตัวแปร, ชนิดประเภทข้อมูล และฟังก์ชันที่ภาษาส่วนใหญ่มีร่วมกัน รวมถึงวิธีใช้ใน Rust บทที่ 4 จะเริ่มพาไปรู้จักกับคุณสมบัติความเป็นเจ้าของสิทธิ์การครอบครองหน่วยความจำ (ownership) ซึ่งเป็นฟีเจอร์เด่นอันเป็นเอกลักษณ์ที่ทำให้ภาษา Rust แตกต่างจากภาษาอื่นๆ บทที่ 5 จะแนะนำเรื่องโครงสร้างอ็อบเจกต์ (structs) และรูปแบบเมธอด และบทที่ 6 จะอธิบายถึงการทำงานของ enums

แนวคิดการเขียนโปรแกรมทั่วไป

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

โดยเฉพาะอย่างยิ่ง คุณจะได้เรียนรู้เกี่ยวกับตัวแปร, ชนิดข้อมูลพื้นฐาน, ฟังก์ชัน, คำอธิบายโค้ด (comments) และโครงสร้างควบคุมการทำงาน (control flow) ซึ่งรากฐานเหล่านี้จะปรากฏอยู่ในทุกๆ โปรแกรมของภาษา Rust การเรียนรู้สิ่งเหล่านี้ตั้งแต่แรกเริ่มจะช่วยสร้างพื้นฐานที่มั่นคงให้แก่คุณในการก้าวต่อไป

คำสำคัญ (Keywords)

ภาษา Rust มีกลุ่มของ คำสำคัญ (keywords) ที่ถูกสงวนไว้สำหรับการใช้งานเฉพาะของตัวภาษาเท่านั้น เช่นเดียวกับภาษาโปรแกรมอื่นๆ โปรดระลึกไว้เสมอว่าคุณจะไม่สามารถนำคำเหล่านี้ไปใช้เป็นชื่อของตัวแปรหรือฟังก์ชันได้ คำสำคัญส่วนใหญ่จะมีความหมายและหน้าที่การทำงานพิเศษเฉพาะตัว ซึ่งคุณจะได้ใช้งานพวกมันในการจัดการงานต่างๆ ในโปรแกรม Rust ของคุณ ส่วนคำสำคัญบางคำจะยังไม่มีผลลัพธ์การทำงานใดๆ ผูกติดไว้ในปัจจุบัน แต่ถูกจองสงวนสิทธิ์เอาไว้สำหรับฟังก์ชันความสามารถที่อาจจะถูกเพิ่มเข้ามาในภาษา Rust ในอนาคต คุณสามารถดูรายชื่อคำสำคัญทั้งหมดได้ใน ภาคผนวก A

Variables and Mutability

ตัวแปรและการแก้ไขค่าได้

ดังที่ได้กล่าวถึงในหัวข้อ “การเก็บค่าด้วยตัวแปร” โดยค่าเริ่มต้นแล้ว ตัวแปรจะไม่สามารถแก้ไขค่าได้ (immutable) นี่เป็นหนึ่งในหลายๆ แนวทางที่ภาษา Rust ผลักดันให้คุณเขียนโค้ดเพื่อใช้ประโยชน์จากความปลอดภัยและความสะดวกในเรื่องการทำงานแบบขนาน (concurrency) ที่ Rust นำเสนอ อย่างไรก็ตาม คุณก็ยังคงมีทางเลือกที่จะทำตัวแปรของคุณให้แก้ไขค่าได้ (mutable) อยู่ดี มาลองสำรวจดูว่ามีวิธีอย่างไร และเพราะเหตุใด Rust จึงผลักดันให้คุณนิยมใช้ความไม่สามารถแก้ไขค่าได้ และทำไมในบางครั้งคุณถึงอยากจะข้ามตัวเลือกนี้ไป

เมื่อตัวแปรไม่สามารถแก้ไขค่าได้ เมื่อมีการผูก (bind) ค่าเข้ากับชื่อหนึ่งๆ ไปแล้ว คุณจะไม่สามารถเปลี่ยนค่านั้นได้อีก เพื่อสาธิตให้เห็นภาพนี้ ให้ลองสร้างโปรเจกต์ใหม่ชื่อว่า variables ในไดเรกทอรี projects ของคุณ โดยใช้คำสั่ง cargo new variables

จากนั้น ในไดเรกทอรี variables ใหม่ของคุณ ให้เปิดไฟล์ src/main.rs และแทนที่โค้ดในนั้นด้วยโค้ดดังต่อไปนี้ ซึ่งจะยังไม่สามารถคอมไพล์ผ่านได้ในตอนนี้:

Filename: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

บันทึกและรันโปรแกรมโดยใช้คำสั่ง cargo run คุณควรจะได้รับข้อความแสดงข้อผิดพลาดเกี่ยวกับการแก้ไขค่าไม่ได้ ดังแสดงในผลลัพธ์ต่อไปนี้:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` (bin "variables") due to 1 previous error

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

คุณได้รับข้อความแสดงข้อผิดพลาด cannot assign twice to immutable variable `x` (ไม่สามารถกำหนดค่าสองครั้งให้กับตัวแปร x ที่แก้ไขค่าไม่ได้) เนื่องจากคุณพยายามกำหนดค่าเป็นครั้งที่สองให้กับตัวแปร x ซึ่งไม่สามารถแก้ไขค่าได้

การที่เราได้รับข้อผิดพลาดในขั้นตอนการคอมไพล์ (compile-time errors) เมื่อพยายามเปลี่ยนค่าที่ถูกระบุว่าแก้ไขค่าไม่ได้นั้นถือเป็นสิ่งสำคัญ เพราะเหตุการณ์ในลักษณะนี้สามารถนำไปสู่บั๊กได้ หากส่วนหนึ่งของโค้ดทำงานภายใต้สมมติฐานว่าค่านั้นจะไม่มีวันเปลี่ยนแปลง แต่อีกส่วนหนึ่งของโค้ดกลับไปเปลี่ยนแปลงค่านั้น มันก็เป็นไปได้ว่าโค้ดส่วนแรกจะไม่ทำงานตามที่มันถูกออกแบบมา สาเหตุของบั๊กประเภทนี้อาจเป็นเรื่องยากที่จะสืบหาในภายหลัง โดยเฉพาะอย่างยิ่งเมื่อโค้ดส่วนที่สองนั้นเปลี่ยนแปลงค่าเป็นครั้งคราวเท่านั้น ตัวคอมไพเลอร์ของ Rust รับประกันว่าเมื่อคุณระบุว่าค่านั้นจะไม่มีการเปลี่ยนแปลง มันก็จะไม่มีการเปลี่ยนแปลงจริงๆ ดังนั้นคุณจึงไม่จำเป็นต้องคอยตรวจสอบสิ่งนี้ด้วยตัวเอง ส่งผลให้โค้ดของคุณเข้าใจได้ง่ายขึ้น

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

ตัวอย่างเช่น ให้เปลี่ยนโค้ดในไฟล์ src/main.rs เป็นดังนี้:

Filename: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

เมื่อรันโปรแกรมในตอนนี้ เราจะได้รับผลลัพธ์ดังนี้:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

เราจะได้รับอนุญาตให้เปลี่ยนค่าที่ผูกกับ x จาก 5 เป็น 6 ได้เมื่อใช้ mut ท้ายที่สุดแล้ว การตัดสินใจว่าจะใช้ความสามารถในการแก้ไขค่าได้หรือไม่นั้นขึ้นอยู่กับคุณ และขึ้นอยู่กับว่าคุณคิดว่าแบบใดจะชัดเจนที่สุดในสถานการณ์นั้นๆ

การประกาศค่าคงที่ (Constants)

เช่นเดียวกับตัวแปรที่แก้ไขค่าไม่ได้ ค่าคงที่ (constants) คือค่าที่ผูกกับชื่อหนึ่งๆ และไม่ได้รับอนุญาตให้เปลี่ยนแปลง แต่มีข้อแตกต่างบางประการระหว่างค่าคงที่และตัวแปร

ข้อแรก คุณไม่ได้รับอนุญาตให้ใช้ mut กับค่าคงที่ ค่าคงที่ไม่ใช่แค่ไม่สามารถแก้ไขค่าได้เป็นค่าเริ่มต้น แต่พวกมันไม่สามารถแก้ไขค่าได้ตลอดเวลา คุณประกาศค่าคงที่โดยใช้คำสำคัญ const แทนคำสำคัญ let และชนิดข้อมูล (type) ของค่าคงที่จะต้องระบุกำกับไว้เสมอ เราจะครอบคลุมเรื่องชนิดข้อมูลและการระบุชนิดข้อมูลในหัวข้อถัดไป “ชนิดข้อมูล” ดังนั้นจึงไม่ต้องกังวลกับรายละเอียดในตอนนี้ เพียงแค่รู้ว่าคุณต้องระบุชนิดข้อมูลเสมอ

ค่าคงที่สามารถถูกประกาศในขอบเขต (scope) ใดก็ได้ รวมถึงขอบเขตระดับโกลบอล (global scope) ซึ่งทำให้พวกมันมีประโยชน์สำหรับค่าที่โค้ดหลายๆ ส่วนจำเป็นต้องใช้งาน

ข้อแตกต่างประการสุดท้ายคือ ค่าคงที่อาจถูกตั้งค่าได้เฉพาะด้วยนิพจน์ค่าคงที่ (constant expression) เท่านั้น ไม่สามารถตั้งค่าด้วยผลลัพธ์ที่สามารถคำนวณได้เฉพาะในตอนที่โปรแกรมรัน (runtime) เท่านั้น

นี่คือตัวอย่างการประกาศค่าคงที่:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

ชื่อของค่าคงที่คือ THREE_HOURS_IN_SECONDS และค่าของมันถูกตั้งเป็นผลลัพธ์ของการคูณ 60 (จำนวนวินาทีในหนึ่งนาที) ด้วย 60 (จำนวนนาทีในหนึ่งชั่วโมง) ด้วย 3 (จำนวนชั่วโมงที่เราต้องการนับในโปรแกรมนี้) รูปแบบการตั้งชื่อตามหลักปฏิบัติของภาษา Rust สำหรับค่าคงที่คือการใช้ตัวพิมพ์ใหญ่ทั้งหมดและใช้เครื่องหมายขีดล่างคั่นระหว่างคำ ตัวคอมไพเลอร์สามารถประเมินผลกลุ่มการทำงานที่มีจำกัดได้ตั้งแต่ขั้นตอนคอมไพล์ ทำให้เราเลือกที่จะเขียนค่านี้ออกมาในแบบที่เข้าใจและตรวจสอบได้ง่ายขึ้น แทนที่จะต้องตั้งค่าคงที่นี้ให้เป็นเลข 10,800 ตรงๆ สามารถดูข้อมูลเพิ่มเติมเกี่ยวกับการดำเนินการที่สามารถนำมาใช้ในการประกาศค่าคงที่ได้ใน หัวข้อเรื่องการประเมินค่าคงที่ของเอกสารอ้างอิง Rust

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

การตั้งชื่อให้กับค่าที่ฝังตัวเลขลงไปในโปรแกรม (hardcoded values) ที่ใช้งานกระจัดกระจายอยู่ในโปรแกรมด้วยการประกาศเป็นค่าคงที่จะช่วยสื่อสารความหมายของค่านั้นๆ ให้แก่ผู้ที่จะมาคอยดูแลโค้ดนี้ในอนาคตได้อย่างดี นอกจากนี้ยังช่วยให้มีเพียงจุดเดียวในโค้ดที่คุณต้องเข้ามาแก้ไขหากจำเป็นต้องปรับปรุงค่าคงที่นี้ในอนาคต

การบังเงา (Shadowing)

ดังที่คุณได้เห็นในบทเรียนการสร้างเกมทายตัวเลขใน บทที่ 2 คุณสามารถประกาศตัวแปรใหม่ที่มีชื่อเดียวกันกับตัวแปรที่มีอยู่ก่อนหน้านี้ได้ ชาว Rustacean เรียกสิ่งนี้ว่า ตัวแปรแรกถูก บังเงา (shadowed) โดยตัวแปรตัวที่สอง ซึ่งหมายความว่าตัวแปรตัวที่สองคือสิ่งที่จะปรากฏให้คอมไพเลอร์มองเห็นเมื่อคุณเรียกใช้ชื่อตัวแปรนั้น ในทางปฏิบัติ ตัวแปรตัวที่สองจะมาบดบังตัวแรก โดยสิทธิ์การใช้ชื่อตัวแปรทั้งหมดจะตกเป็นของตัวมันเองจนกว่าตัวมันจะโดนบังเงาซ้ำอีกรอบหรือสิ้นสุดขอบเขต (scope) ลง เราสามารถทำบังเงาตัวแปรได้โดยใช้ชื่อตัวแปรเดิมและประกาศใช้งานคำสำคัญ let ซ้ำอีกครั้ง ดังตัวอย่างต่อไปนี้:

Filename: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

โปรแกรมนี้จะผูกตัวแปร x เข้ากับค่า 5 เป็นอันดับแรก จากนั้นมันจะสร้างตัวแปร x ตัวใหม่ด้วยการประกาศคำสำคัญซ้ำว่า let x = โดยดึงค่าเริ่มต้นเดิมมาบวกเพิ่ม 1 ส่งผลให้มูลค่าของ x กลายเป็น 6 หลังจากนั้นภายใต้ขอบเขตการทำงานด้านในที่สร้างด้วยปีกกา คำสั่ง let แถวที่สามก็จะทำบังเงาตัวแปร x และสร้างตัวแปรใหม่ขึ้นมา โดยจะนำค่าก่อนหน้านี้มาคูณกับ 2 ส่งผลให้ x มีค่ากลายเป็น 12 และเมื่อหมดรอบขอบเขตการทำงานชั้นใน การบังเงาด้านในจะยุติลง และ x จะกลับมามีค่าเป็น 6 ตามเดิม เมื่อเราสั่งรันโปรแกรมนี้ มันจะแสดงผลลัพธ์ดังนี้:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

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

ความแตกต่างอีกประการหนึ่งระหว่างการใช้งาน mut และการบังเงาคือ เนื่องจากในกระบวนการบังเงาเรากำลังทำสร้างตัวแปรตัวใหม่ขึ้นมาจริงๆ เมื่อเราเรียกใช้งานคำสำคัญ let อีกครั้ง เราจึงสามารถเปลี่ยนแปลงประเภทชนิดข้อมูลของตัวแปรไปพร้อมกับนำชื่อเดิมกลับมาใช้ใหม่ได้ ตัวอย่างเช่น สมมติว่าโปรแกรมของเราต้องการขอข้อมูลจากผู้ใช้ว่าต้องการเว้นวรรคระหว่างข้อความมากน้อยเพียงใดโดยการป้อนตัวอักษรเว้นวรรค (spaces) แล้วเราต้องการนำข้อมูลที่พิมพ์เข้ามานั้นมาจัดเก็บเป็นข้อมูลตัวเลขจำนวนแทน:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

ตัวแปร spaces แรกสุดจะเป็นข้อมูลชนิดข้อความ (string type) และตัวแปร spaces แถวที่สองจะเป็นข้อมูลชนิดตัวเลขจำนวน (number type) การบังเงาจึงช่วยป้องกันไม่ให้เราต้องมาคอยคิดหาชื่อตัวแปรที่แตกต่างกัน เช่น spaces_str และ spaces_num ทำให้เราสามารถนำชื่อที่สั้นกระชับอย่าง spaces กลับมาใช้ใหม่ได้ อย่างไรก็ตาม หากเราเลือกประยุกต์ใช้งาน mut จัดการเรื่องนี้แทนดังที่แสดงตัวอย่างด้านล่างนี้ เราจะได้รับข้อผิดพลาดในขั้นตอนคอมไพล์ทันที:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

ข้อความแจ้งเตือนข้อผิดพลาดระบุว่าเราไม่สามารถปรับเปลี่ยนชนิดข้อมูลประเภทตัวแปรได้:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` (bin "variables") due to 1 previous error

เมื่อเราได้เรียนรู้วิธีการทำงานของตัวแปรแล้ว ถัดไปมาลองทำความเข้าใจกับชนิดข้อมูลต่างๆ ที่ตัวแปรสามารถเก็บได้กัน

Data Types

ชนิดข้อมูล

ทุกๆ ค่าในภาษา Rust จะต้องมี ชนิดข้อมูล (data type) ที่แน่นอน ซึ่งช่วยระบุให้ Rust รับทราบว่าเป็นข้อมูลประเภทใด เพื่อที่จะสามารถจัดการและทำงานกับข้อมูลนั้นๆ ได้อย่างถูกต้อง ในที่นี้เราจะมาดูชนิดข้อมูลย่อย 2 กลุ่มหลัก ได้แก่: ชนิดข้อมูลสเกลาร์ (scalar) และชนิดข้อมูลเชิงประกอบ (compound)

โปรดจำไว้ว่า Rust เป็นภาษาโปรแกรมที่ใช้ระบบตรวจชนิดข้อมูลแบบคงที่ (statically typed) ซึ่งหมายความว่ามันจำเป็นต้องทราบชนิดข้อมูลของตัวแปรทั้งหมดตั้งแต่ขั้นตอนคอมไพล์ โดยทั่วไปแล้วคอมไพเลอร์จะสามารถคาดเดาชนิดข้อมูลที่เราต้องการเลือกใช้งานได้โดยอิงตามค่าข้อมูลและรูปแบบการเรียกใช้งานของเรา แต่ในกรณีที่มีชนิดข้อมูลที่เป็นไปได้อยู่หลากหลายแบบ เช่น ตอนที่เราแปลงค่า String ไปเป็นประเภทตัวเลขผ่านคำสั่ง parse ในหัวข้อ “การเปรียบเทียบค่าที่ทายกับตัวเลขปริศนา” ในบทที่ 2 เราจำเป็นต้องระบุป้ายระบุชนิดข้อมูลกำกับ (type annotation) เพิ่มเติม ดังตัวอย่างนี้:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

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

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

คุณจะได้เห็นรูปแบบการระบุชนิดข้อมูลที่แตกต่างกันออกไปสำหรับข้อมูลชนิดอื่นๆ

ชนิดข้อมูลสเกลาร์ (Scalar Types)

ชนิดข้อมูลสเกลาร์ (scalar type) เป็นตัวแทนของมูลค่าข้อมูลชิ้นเดี่ยวๆ ภาษา Rust มีชนิดข้อมูลสเกลาร์หลักอยู่ 4 ประเภท ได้แก่: เลขจำนวนเต็ม (integers), เลขทศนิยม (floating-point numbers), ค่าความจริงทางตรรกศาสตร์ (Booleans) และตัวอักษร (characters) คุณอาจจะคุ้นเคยกับคำศัพท์เหล่านี้จากภาษาโปรแกรมอื่นๆ มาลองดูกันเลยว่าพวกมันทำงานอย่างไรใน Rust

ชนิดข้อมูลเลขจำนวนเต็ม (Integer Types)

เลขจำนวนเต็ม (integer) คือตัวเลขที่ไม่มีส่วนประกอบของเศษส่วนหรือจุดทศนิยม เราเคยเรียกใช้งานข้อมูลประเภทจำนวนเต็มชนิดหนึ่งไปแล้วในบทที่ 2 นั่นคือประเภทข้อมูล u32 การประกาศประเภทชนิดแบบนี้บ่งชี้ว่าค่าที่ผูกอยู่นั้นคือจำนวนเต็มแบบไม่มีเครื่องหมายลบ (unsigned integer) โดยที่ชนิดจำนวนเต็มแบบมีเครื่องหมาย (signed integer) จะขึ้นต้นด้วยสัญลักษณ์อักษร i แทนอักษร u และข้อมูลดังกล่าวมีขนาดพื้นที่หน่วยความจำ 32 บิต ตารางที่ 3-1 แสดงรายชื่อประเภทข้อมูลเลขจำนวนเต็มพื้นฐานภายในตัวของภาษา Rust เราสามารถเลือกใช้ประเภทใดในกลุ่มเหล่านี้ไปประกาศชนิดตัวแปรตัวเลขจำนวนเต็มได้ตามต้องการ

ตารางที่ 3-1: ชนิดข้อมูลเลขจำนวนเต็มใน Rust

ขนาดมีเครื่องหมายไม่มีเครื่องหมาย
8 บิตi8u8
16 บิตi16u16
32 บิตi32u32
64 บิตi64u64
128 บิตi128u128
ขึ้นกับสถาปัตยกรรมisizeusize

จำนวนเต็มในแต่ละประเภทย่อยนั้นสามารถเป็นแบบมีเครื่องหมายหรือไม่มีเครื่องหมายได้ และขนาดจะได้รับการระบุชัดเจน โดยคำว่า มีเครื่องหมาย (signed) และ ไม่มีเครื่องหมาย (unsigned) จะใช้เรียกอ้างอิงถึงความเป็นไปได้ที่จำนวนนั้นจะติดลบได้หรือไม่ กล่าวคือ ตัวเลขนั้นจำเป็นต้องมีเครื่องหมายประกอบ (+/-) หรือไม่ (มีเครื่องหมาย) หรือว่าค่าของมันจะเป็นบวกเสมอไปตลอดทำให้ไม่จำเป็นต้องระบุสัญลักษณ์เครื่องหมายไว้เคียงข้าง (ไม่มีเครื่องหมาย) มันคล้ายกับเวลาที่เราเขียนตัวเลขลงบนกระดาษ เมื่อสัญลักษณ์มีความสำคัญ ตัวเลขจะถูกเขียนโดยมีเครื่องหมายบวกรวมถึงลบกำกับอยู่ข้างหน้า แต่เมื่อเราทราบแน่ชัดว่าจำนวนดังกล่าวเป็นบวกแน่นอนอยู่แล้ว เราจะเขียนเฉพาะตัวเลขเปล่าๆ ปราศจากเครื่องหมาย ชนิดข้อมูลตัวเลขแบบมีเครื่องหมายจะถูกจัดเก็บโดยใช้รูปแบบการแทนค่าด้วย ส่วนเติมเต็มสอง (two’s complement)

ชนิดข้อมูลแบบมีเครื่องหมาย (signed) ในแต่ละขนาดย่อยจะเก็บค่าตัวเลขตั้งแต่มูลค่า −(2n − 1) ไปจนถึง 2n − 1 − 1 (นับรวมขอบเขตด้วย) โดยที่ค่า n คือขนาดของจำนวนบิตข้อมูลที่ตัวเลือกชนิดย่อยนั้นๆ เรียกใช้งาน ดังนั้นตัวแปรชนิด i8 จะเก็บตัวเลขได้จากย่าน −(27) ถึง 27 − 1 ซึ่งมีค่าเทียบเท่ากับช่วง −128 ถึง 127 ส่วนประเภทตัวแปรไม่มีเครื่องหมาย (unsigned) จะเก็บมูลค่าตัวเลขสเกลาร์ได้จาก 0 ถึง 2n − 1 ดังนั้นชนิดข้อมูล u8 จึงเก็บข้อมูลได้ในช่วงตั้งแต่ 0 ถึง 28 − 1 ซึ่งเทียบเท่ากับช่วง 0 ถึง 255

นอกจากนี้ ประเภทข้อมูลชนิด isize และ usize จะเปลี่ยนไปตามประเภทสถาปัตยกรรมชิปเซ็ตประมวลผล (architecture) ของเครื่องคอมพิวเตอร์ที่ตัวโปรแกรมของคุณกำลังทำงานอยู่ โดยจะมีขนาด 64 บิตหากรันอยู่บนระบบประมวลผลสถาปัตยกรรมแบบ 64 บิต และมีขนาด 32 บิตหากรันบนระบบ 32 บิต

คุณสามารถเขียนค่าคงที่ตัวเลขจำนวนเต็ม (integer literals) ได้หลากหลายรูปแบบตามตัวอย่างที่ระบุในตารางที่ 3-2 พึงสังเกตว่าเลขจำนวนคงที่ที่มีโอกาสตีความได้เป็นข้อมูลหลายประเภทนั้นอนุญาตให้ผู้เขียนเสริมสัญลักษณ์ต่อท้ายชื่อชนิด (type suffix) เข้าไปได้ เช่น 57u8 เพื่อระบุบ่งบอกชนิดตัวแปรทันที ยิ่งไปกว่านั้น ค่าตัวเลขยังอนุญาตให้เติมสัญลักษณ์ _ คั่นระหว่างตัวเลขตามความเหมาะสมเพื่อช่วยให้อ่านข้อมูลจำแนกตัวเลขได้ง่ายขึ้น เช่น 1_000 ซึ่งจะได้รับการประมวลผลเป็นตัวเลขเดียวกับค่า 1000

ตารางที่ 3-2: รูปแบบการเขียนค่าคงที่ตัวเลขจำนวนเต็มใน Rust

รูปแบบการเขียนตัวเลขตัวอย่าง
Decimal (ฐานสิบ)98_222
Hex (ฐานสิบหก)0xff
Octal (ฐานแปด)0o77
Binary (ฐานสอง)0b1111_0000
Byte (u8 เท่านั้น)b'A'

แล้วคุณจะทราบได้อย่างไรว่าควรเลือกใช้งานตัวเลขจำนวนเต็มประเภทใดดี? หากคุณยังไม่มีข้อสรุปในใจ ค่าเริ่มต้นพื้นฐานของ Rust มักเป็นตัวเลือกที่ดีในการเริ่มต้นทำโปรเจกต์ ซึ่งชนิดข้อมูลจำนวนเต็มของ Rust จะมีค่าเริ่มต้นเป็นประเภท i32 เสมอ ส่วนสถานการณ์หลักๆ ที่คุณจำเป็นต้องนำชนิดข้อมูล isize หรือ usize มาประยุกต์ใช้งาน คือช่วงที่คุณต้องระบุตำแหน่งดัชนี (index) ของกลุ่มข้อมูลประเภทโครงสร้างคอลเลกชัน (collection)

ปัญหาตัวเลขเกินขอบเขตข้อมูล (Integer Overflow)

สมมติว่าคุณมีตัวแปรประเภทข้อมูลชนิด u8 ซึ่งสามารถเก็บมูลค่าขอบเขตตัวเลขอยู่ระหว่างช่วง 0 ถึง 255 หากคุณพยายามป้อนสั่งแก้ไขตัวแปรดังกล่าวให้เป็นค่านอกย่าน เช่น 256 จะเกิดปรากฏการณ์ ตัวเลขเกินขอบเขตข้อมูล (integer overflow) ขึ้น ซึ่งสามารถนำไปสู่ผลลัพธ์พฤติกรรมการทำงานได้ 2 แบบ หากคุณสั่งประกอบโปรแกรม (compiling) ในโหมดดีบั๊ก (debug mode) Rust จะเพิ่มระบบตรวจสอบความปลอดภัยเพื่อป้องกันปัญหาตัวเลขเกินขอบเขตนี้ ซึ่งจะมีผลให้ตัวโปรแกรมยุติทำงานกะทันหันหรือหยุดชะงัก (panic) ในขณะที่โปรแกรมรัน (runtime) หากพบลักษณะพฤติกรรมนี้ ภาษา Rust จะเรียกใช้คำว่า panicking หรือการเกิดตื่นตระหนกเพื่ออธิบายสถานะที่ตัวโปรแกรมปิดการทำงานเนื่องจากเกิดข้อผิดพลาดรุนแรง เราจะได้พูดคุยรายละเอียดเรื่อง panics นี้ลึกขึ้นในหัวข้อ “ข้อผิดพลาดที่ไม่สามารถกู้คืนได้ด้วย panic! ในบทที่ 9

เมื่อคุณดำเนินการคอมไพล์ประกอบโปรแกรมในโหมดใช้งานจริง (release mode) ด้วยการป้อนพ่วงแฟล็ก --release Rust จะ ไม่ได้ เพิ่มกลไกตรวจสอบระบบป้องกันตัวเลขเกินขอบเขตเพื่อสั่งหยุดโปรแกรม (panic) มาให้ ในทางกลับกันหากเกิดปัญหาตัวเลขเกินขอบเขตขึ้น Rust จะทำการคำนวณสลับค่าวนทับสอดคล้องกับหลักการ two’s complement wrapping สรุปอย่างย่นย่อคือ ค่าที่มากกว่าช่วงสูงสุดที่ชนิดข้อมูลจะยอมรับจะถูกนำมาเปรียบเทียบคำนวณวนลูปกลับมาเริ่มต้นจากค่าต่ำสุดที่ชนิดนั้นจะเก็บได้ สำหรับตัวแปรชนิด u8 ค่าตัวเลข 256 จะผันค่าสลับมาเป็น 0 และค่าตัวเลข 257 จะผันค่ามาเป็น 1 ไปเรื่อยๆ เป็นลำดับ แม้ว่าตัวโปรแกรมจะไม่ได้สั่งปิดการทำงานกะทันหัน (panic) แต่ค่าตัวแปรจะเปลี่ยนสภาพไปแสดงข้อมูลประหลาดที่คุณคงไม่ได้คาดหมายไว้ตั้งแต่ตอนเขียนโปรแกรม การปล่อยให้โปรแกรมพึ่งพาพฤติกรรมจำลองคำนวณวนทับลักษณะนี้จะถือเป็นข้อผิดพลาดในการเขียนโปรแกรม

หากต้องการรับมือกับปัญหาความเป็นไปได้ที่จะเกิดสภาวะตัวเลขล้นขอบเขตนี้อย่างเป็นทางการ คุณสามารถเลือกเรียกใช้งานกลุ่มเมธอดเสริมที่เตรียมไว้ให้ในไลบรารีมาตรฐานสำหรับตัวเลขพื้นฐานประเภทย่อยต่าง ๆ ดังนี้:

  • ปรับแก้ในเชิงวนค่าทับกลับมาในทุกๆ โหมดคอมไพล์ด้วยกลุ่มเมธอด wrapping_* เช่น wrapping_add
  • สั่งให้คืนค่าผลลัพธ์เป็น None หากเกิดปัญหาล้นขอบเขตขึ้นด้วยกลุ่มเมธอด checked_*
  • สั่งคืนผลการคำนวณพร้อมค่าความจริง Boolean เพื่อรายงานว่ามีค่าล้นขอบเขตหรือไม่ด้วยกลุ่มเมธอด overflowing_*
  • ล็อกขอบเขตการคำนวณไว้ที่ขอบสูงสุดหรือขอบต่ำสุดเพื่อจำกัดค่าด้วยกลุ่มเมธอด saturating_*

ชนิดข้อมูลเลขทศนิยม (Floating-Point Types)

นอกจากนี้ Rust ยังได้เตรียมชนิดข้อมูลพื้นฐาน 2 ชนิดสำหรับจัดเก็บข้อมูล เลขทศนิยม (floating-point numbers) ซึ่งก็คือตัวเลขที่มีจุดทศนิยมประกบอยู่ ข้อมูลประเภททศนิยมของ Rust คือชนิด f32 และ f64 ซึ่งมีขนาดขอบเขตหน่วยความจำ 32 บิตและ 64 บิตตามลำดับ โดยที่ชนิดเริ่มต้นระบบของ Rust สำหรับเลขทศนิยมคือประเภท f64 เนื่องจากในหน่วยประมวลผล (CPUs) สมัยใหม่ การคำนวณด้วยชนิด f64 จะมีระดับความเร็วใกล้เคียงเทียบเท่ากับการทำงานของชนิด f32 แต่ความแม่นยำของผลลัพธ์ทศนิยมจะดีกว่ามาก และข้อมูลประเภททศนิยมทุกชนิดจะมีเครื่องหมายกำกับ (+/-) เสมอ

ตัวอย่างต่อไปนี้จะแสดงวิธีการประยุกต์ใช้งานเลขทศนิยมในการทำงานจริง:

Filename: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

ข้อมูลประเภทเลขทศนิยมได้รับการจัดรูปแบบการแสดงผลตามข้อตกลงมาตรฐานอ้างอิงสากล IEEE-754

การคำนวณทางคณิตศาสตร์ (Numeric Operations)

Rust รองรับระบบคำนวณพื้นฐานทางคณิตศาสตร์อย่างครบถ้วนตามความต้องการของชนิดตัวเลข ได้แก่: การบวก, การลบ, การคูณ, การหาร และการหาเศษ (modulo) พึงระลึกว่าขั้นตอนการหารตัวเลขจำนวนเต็ม (integer division) จะทำการตัดทิ้งเศษทศนิยมทั้งหมดและปัดเศษเข้าหาเลขศูนย์เสมอ โค้ดต่อไปนี้แสดงภาพตัวอย่างวิธีกำหนดการคำนวณวิชาคณิตศาสตร์เบื้องต้นในแต่ละแบบด้วยคำสั่ง let:

Filename: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

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

ชนิดข้อมูลค่าความจริง (Boolean Type)

เช่นเดียวกับแนวทางของภาษาโปรแกรมส่วนใหญ่ ชนิดข้อมูลค่าความจริง (Boolean type) ของ Rust จะประเมินค่าแสดงผลได้เป็น 2 สถานะ ได้แก่: true (จริง) และ false (เท็จ) โดยที่ข้อมูลประเภทนี้มีขนาดพื้นที่จัดเก็บ 1 ไบต์ ชนิดข้อมูลประเภทนี้จะเขียนประกาศด้วยคำสำคัญว่า bool ตัวอย่างเช่น:

Filename: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

พฤติกรรมหลักในการประยุกต์นำค่าความจริง Boolean ไปเขียนโค้ดคือส่วนเงื่อนไขการทำงาน เช่น นิพจน์เงื่อนไขตัวกรองอย่าง if เราจะร่วมสำรวจขั้นตอนทำงานนิพจน์ if อย่างเป็นระบบในหัวข้อ “โครงสร้างควบคุมการทำงาน” ถัดไป

ชนิดข้อมูลตัวอักษร (Character Type)

ชนิดข้อมูล char ของ Rust เป็นรูปแบบชนิดข้อมูลอักขระขั้นพื้นฐานที่สุดในตัวภาษา ตัวอย่างการประกาศค่าตัวอักษร char มีดังนี้:

Filename: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

พึงสังเกตว่าการเขียนค่าคงที่ตัวอักษร char (char literals) จะต้องสั่งครอบเขียนด้วยเครื่องหมายอัญประกาศเดี่ยว (single quotation marks) เสมอ ตรงกันข้ามกับการเขียนข้อมูลข้อความ (strings) ที่จะครอบเขียนด้วยเครื่องหมายอัญประกาศคู่ (double quotation marks) โดยที่ข้อมูลประเภทอักขระ char ของ Rust จะมีขนาดหน่วยความจำ 4 ไบต์ และเป็นตัวแทนของรหัส Unicode Scalar Value ซึ่งหมายความว่าจะสามารถรองรับและพิมพ์ตัวอักษรได้กว้างขวางครอบคลุมมากกว่าระบบ ASCII มาตรฐานดั้งเดิม เช่น ตัวอักษรที่มีเครื่องหมายกำกับการออกเสียง (accented letters), ตัวอักษรภาษาจีน ภาษาญี่ปุ่น และภาษาเกาหลี, สัญลักษณ์อิโมจิ ตลอดจนช่องว่างไร้ความกว้าง (zero-width spaces) ก็ล้วนถือเป็นค่าข้อมูล char ที่ถูกต้องของ Rust โดยขอบเขตของ Unicode Scalar Value จะอยู่ในช่วงครอบคลุมตั้งแต่รหัสอ้างอิง U+0000 ถึง U+D7FF และรหัส U+E000 ถึง U+10FFFF (นับรวมขอบเขตด้วย) อย่างไรก็ตามคำว่า “ตัวอักษร” หรือ “อักขระ” ในความรับรู้โดยธรรมชาติของมนุษย์อาจจะไม่ตรงกันพอดิบพอดีกับข้อมูลสเปก char ของ Rust ในเชิงเทคนิคคอมพิวเตอร์ เราจะหยิบยกประเด็นนี้นำมาขยายความลึกซึ้งในบทที่ 8 หัวข้อ “การจัดเก็บข้อความเข้ารหัส UTF-8 ด้วย Strings”

ชนิดข้อมูลเชิงประกอบ (Compound Types)

ชนิดข้อมูลเชิงประกอบ (compound types) ช่วยให้เราสามารถรวบรวมกลุ่มข้อมูลหลากหลายชิ้นเอาไว้รวมกันภายใต้ชนิดข้อมูลหลักชนิดเดียวได้ ภาษา Rust มีชนิดข้อมูลเชิงประกอบพื้นฐานอยู่ 2 รูปแบบ ได้แก่: ทูเพิล (tuples) และอาร์เรย์ (arrays)

ชนิดข้อมูลทูเพิล (Tuple Type)

ทูเพิล (tuple) คือวิธีการมาตรฐานทั่วไปในการรวบรวมกลุ่มข้อมูลสารสนเทศต่างๆ ที่อาจจะมีประเภทชนิดข้อมูลที่แตกต่างกัน ให้เข้ามารวมกลุ่มอยู่ในชุดโครงสร้างข้อมูลเชิงประกอบชิ้นเดียว โดยที่ทูเพิลจะมีขนาดความยาวคงที่ (fixed length) เมื่อได้ประกาศสร้างขึ้นมาแล้ว ขนาดของมันจะไม่สามารถขยายใหญ่ขึ้นหรือหดสั้นลงได้อีก

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

Filename: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

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

Filename: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

โปรแกรมตัวอย่างนี้จะสร้างทูเพิลแล้วสั่งผูกข้อมูลไว้กับตัวแปร tup จากนั้นจึงใช้นิพจน์ควบคุมคำสั่ง let แตกย่อยโครงสร้างตัวแปรของ tup กระจายออกไปจัดเก็บเป็นตัวแปรใหม่จำนวน 3 ตัว ได้แก่ x, y และ z ซึ่งกระบวนการนี้เรียกว่า การกระจายโครงสร้าง (destructuring) เนื่องจากเป็นการแยกชิ้นส่วนของทูเพิลตัวเดี่ยวออกเป็น 3 ส่วนย่อย และขั้นตอนสุดท้ายตัวโปรแกรมจะแสดงผลค่าของ y ซึ่งมีค่าเป็น 6.4

เราสามารถก็ตรงเข้าไปเรียกข้อมูลภายในทูเพิลแต่ละตำแหน่งได้โดยตรงผ่านการเขียนสัญลักษณ์จุดทศนิยม (.) แล้วพ่วงต่อท้ายด้วยหมายเลขดัชนี (index) ของตำแหน่งข้อมูลที่เราต้องการเรียกใช้ ตัวอย่างเช่น:

Filename: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

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

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

ชนิดข้อมูลอาร์เรย์ (Array Type)

อาร์เรย์ (array) เป็นอีกหนึ่งทางเลือกในการจัดกลุ่มเก็บรายการข้อมูลหลากหลายชิ้น แต่จะมีความต่างไปจากทูเพิลคือ สมาชิกทุกตำแหน่งข้อมูลภายในอาร์เรย์จะต้องมีประเภทชนิดข้อมูลที่สอดคล้องตรงกันทุกประการ และสอดคล้องกับพฤติกรรมของทูเพิลคือ โครงสร้างอาร์เรย์ของภาษา Rust จะมีขนาดความยาวคงที่ (fixed length)

เราเขียนสร้างค่าข้อมูลภายในอาร์เรย์ด้วยการป้อนรายการข้อมูลคั่นด้วยสัญลักษณ์จุลภาคไว้ภายในวงเล็บเหลี่ยม (square brackets):

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

อาร์เรย์มีประโยชน์มากเมื่อคุณต้องการเก็บจองข้อมูลตัวแปรไว้ในส่วนหน่วยความจำแบบสแตก (stack) สอดคล้องกับชนิดข้อมูลพื้นฐานอื่นๆ ทั้งหมดที่เราเห็นไปก่อนหน้านี้ แทนที่จะนำไปจัดเก็บในหน่วยความจำส่วนฮีป (heap) (เราจะได้ร่วมพูดคุยถึงความแตกต่างระหว่าง stack และ heap ละเอียดใน บทที่ 4) หรือเมื่อคุณต้องการรับประกันอย่างเข้มงวดว่าโปรแกรมจะคงขนาดจำนวนสมาชิกที่แน่นอนไปตลอดการทำงาน แต่อย่างไรก็ดี อาร์เรย์จะไม่มีความยืดหยุ่นในการปรับแต่งขนาดได้เหมือนกับโครงสร้างประเภทเวกเตอร์ (vector) โดยที่เวกเตอร์จัดเป็นกลุ่มประเภทข้อมูลจัดเก็บรายการรูปแบบคล้ายคลึงกันที่ถูกบรรจุไว้ในคลังไลบรารีมาตรฐาน ซึ่ง สามารถ ปรับขยายขนาดเพิ่มหรือหดเล็กลงได้ตามความจำเป็นเนื่องจากโครงสร้างข้อมูลย่อยภายในตัวจะถูกจัดเก็บจองไว้บนพื้นที่ของ heap หากคุณยังไม่มีความแน่ใจว่าจะเลือกใช้โครงสร้างข้อมูลระหว่างอาร์เรย์หรือเวกเตอร์ดีกว่ากัน โดยทั่วไปแล้วแนะนำให้เลือกเรียกใช้เวกเตอร์เป็นหลัก สามารถอ่านข้อมูลเพิ่มเติมของเวกเตอร์ได้ใน บทที่ 8

อย่างไรก็ตาม อาร์เรย์จะมีประโยชน์และเหมาะสมอย่างยิ่งเมื่อคุณทราบตัวเลขจำนวนสมาชิกอย่างชัดเจนแน่นอนอยู่แล้วว่าจะไม่มีการปรับเปลี่ยนปริมาณอีก เช่น หากคุณต้องการประกาศข้อมูลเก็บรายชื่อเดือนทั้ง 12 เดือนในตัวโปรแกรม คุณควรเลือกใช้งานอาร์เรย์แทนการสร้างเวกเตอร์ เนื่องจากเราทราบดีว่าข้อมูลเดือนจะต้องมีจำนวนเป็น 12 เสมอไปไม่เปลี่ยนแปลง:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

คุณระบุเขียนประโยคอธิบายชนิดข้อมูลของอาร์เรย์ได้ด้วยการเขียนขีดคร่อมด้วยวงเล็บเหลี่ยม โดยใส่ประเภทชนิดของสมาชิกก่อน คั่นด้วยเครื่องหมายอัฒภาค (semicolon) แล้วปิดท้ายด้วยระบุตัวเลขจำนวนสมาชิกที่อยู่ในอาร์เรย์ ดังนี้:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

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

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

#![allow(unused)]
fn main() {
let a = [3; 5];
}

อาร์เรย์ชื่อ a นี้จะถูกประกอบด้วยสมาชิกจำนวน 5 ตัว โดยสมาชิกทุกตัวจะถูกตั้งค่าคงที่เริ่มแรกให้มีมูลค่าเป็นตัวเลข 3 การทำแบบนี้จะให้ผลลัพธ์เดียวกับการพิมพ์ว่า let a = [3, 3, 3, 3, 3]; แต่เขียนได้สั้นกระชับและสะอาดตากว่ามาก

การเข้าถึงสมาชิกภายในอาร์เรย์

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

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

ในตัวอย่างนี้ ตัวแปรชื่อ first จะได้รับจัดเก็บมูลค่าเป็น 1 เนื่องจากเป็นข้อมูลที่ถูกจองตำแหน่งไว้ที่ตำแหน่งแรกคือดัชนีชี้ [0] ในอาร์เรย์ และตัวแปรชื่อ second จะได้รับจัดเก็บมูลค่าเป็น 2 จากดัชนีชี้ตำแหน่ง [1]

การเข้าถึงข้อมูลสมาชิกอาร์เรย์ออกนอกขอบเขตที่ไม่ถูกต้อง

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

Filename: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

โค้ดส่วนนี้จะสามารถผ่านขั้นตอนคอมไพล์ได้ผ่านฉลุย หากคุณสั่งรันโปรแกรมนี้ผ่านคำสั่ง cargo run แล้วกรอกเลขนำเข้าเป็น 0, 1, 2, 3 หรือ 4 ตัวโปรแกรมจะทำงานและแสดงค่าของข้อมูลตำแหน่งนั้นตามปกติ แต่ถ้าคุณลองเปลี่ยนมาป้อนตัวเลขขอบเขตที่เลยออกไปนอกรายการของขนาดอาร์เรย์ เช่น เลข 10 คุณจะได้รับผลลัพธ์ปรากฏดังนี้:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

โปรแกรมได้เกิดข้อผิดพลาดขึ้นในขณะรันโปรแกรม (runtime error) ตรงจุดที่ผู้ใช้สั่งดึงดูข้อมูลผ่านเลขดัชนีชี้ตำแหน่งที่ไม่ถูกต้อง ตัวโปรแกรมเลือกที่จะปิดตัวเองและรายงานข้อความความล้มเหลวออกมาทันทีโดยข้ามการทำงานในแถวสุดท้ายที่มีฟังก์ชัน println! ไปเลย เมื่อใดก็ตามที่คุณพยายามดึงข้อมูลสมาชิกผ่านดัชนีชี้ Rust จะสั่งประมวลผลด่านตรวจเพื่อเทียบว่าค่าดัชนีที่คุณป้อนนั้นมีขนาดน้อยกว่าความยาวของอาร์เรย์จริงหรือไม่ และถ้าดัชนีดังกล่าวมีค่ามากกว่าหรือเท่ากับขนาดความยาว Rust จะสั่ง panic หยุดโปรแกรมทันที ซึ่งขั้นตอนการกรองตรวจสอบความปลอดภัยลักษณะนี้จำเป็นต้องดำเนินการตรวจสอบในช่วงโปรแกรมรัน (runtime) โดยเฉพาะอย่างยิ่งกรณีเช่นนี้ เนื่องจากตัวคอมไพเลอร์ไม่มีทางล่วงรู้ได้เลยว่าผู้ใช้งานจะกรอกป้อนตัวเลขใดเข้ามาให้โปรแกรมในตอนประมวลผล

นี่เป็นตัวอย่างเชิงรูปธรรมที่แสดงให้เห็นถึงการรักษาความปลอดภัยของหน่วยความจำ (memory safety) ของภาษา Rust ในภาษาโปรแกรมระดับต่ำ (low-level languages) อื่น ๆ หลายภาษามักจะไม่มีด่านช่วยสกัดกรองตรวจสอบแบบนี้ ซึ่งเมื่อผู้เขียนสั่งดึงดัชนีผิดตำแหน่ง ระบบของภาษาเหล่านั้นอาจจะแอบเข้าไปดึงอ่านข้อมูลที่อยู่ในหน่วยความจำจุดอื่นๆ ที่ไม่พึงประสงค์ได้โดยตรง แต่ Rust ได้จัดเตรียมระบบปกป้องคุณจากสภาวะเสี่ยงภัยลักษณะนี้ด้วยการปิดกระบวนการและปิดตัวลงทันทีแทนการปล่อยผ่านให้โปรแกรมเข้าถึงหน่วยความจำตำแหน่งแปลกปลอมแล้วประมวลผลงานต่อไป ในบทที่ 9 จะอธิบายถึงการจัดการความผิดพลาดของ Rust เพิ่มเติม รวมถึงวิธีการเขียนโค้ดที่ทั้งอ่านง่าย ปลอดภัย ไม่แครช (panic) และป้องกันสิทธิ์เข้าถึงพฤติกรรมพื้นที่หน่วยความจำต้องห้ามอย่างเหมาะสม

Functions

ฟังก์ชัน

ฟังก์ชันมีอยู่ทั่วไปในโค้ดของภาษา Rust คุณได้เห็นหนึ่งในฟังก์ชันที่สำคัญที่สุดในตัวภาษาไปเรียบร้อยแล้ว นั่นคือ ฟังก์ชัน main ซึ่งทำหน้าที่เป็นจุดเริ่มต้นการทำงาน (entry point) ของโปรแกรมส่วนใหญ่ และคุณยังได้เห็นคำสำคัญ fn ซึ่งใช้เพื่อประกาศสร้างฟังก์ชันใหม่ด้วยเช่นกัน

โค้ดภาษา Rust จะมีข้อตกลงและหลักปฏิบัติทั่วไปในการเขียนชื่อฟังก์ชันรวมถึงชื่อตัวแปรด้วยรูปแบบ snake case ซึ่งตัวอักษรทั้งหมดจะเป็นตัวพิมพ์เล็กและใช้เครื่องหมายขีดล่าง (underscore) คั่นระหว่างแต่ละคำ ต่อไปนี้เป็นตัวอย่างการประกาศนิยามฟังก์ชันในโปรแกรม:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

เรานิยามฟังก์ชันใน Rust ได้โดยเขียนคำสำคัญ fn ตามด้วยชื่อฟังก์ชันและเครื่องหมายวงเล็บ ปีกกาจะทำหน้าที่คอยบอกคอมไพเลอร์ว่าเนื้อหาภายในฟังก์ชัน (function body) เริ่มต้นตรงจุดใดและไปสิ้นสุดลงตรงจุดใด

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

มาลองเริ่มสร้างโปรเจกต์ไบนารีใหม่ชื่อว่า functions เพื่อร่วมกันศึกษาเรียนรู้ฟังก์ชันเพิ่มเติมกัน ให้ลองเขียนตัวอย่างฟังก์ชัน another_function ข้างต้นลงในไฟล์ src/main.rs แล้วรันโปรแกรมดู คุณควรจะพบผลลัพธ์แสดงดังต่อไปนี้:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

บรรทัดคำสั่งต่างๆ จะได้รับการประมวลผลตามลำดับก่อนหลังที่ระบุไว้ในฟังก์ชัน main โดยอันดับแรก ข้อความ “Hello, world!” จะแสดงขึ้นมาก่อน หลังจากนั้นฟังก์ชัน another_function จะถูกเรียกทำงานและแสดงผลข้อความย่อยภายในของมันออกมา

พารามิเตอร์ (Parameters)

เราสามารถประกาศออกแบบฟังก์ชันให้มี พารามิเตอร์ (parameters) ได้ ซึ่งพารามิเตอร์คือตัวแปรชนิดพิเศษที่เป็นองค์ประกอบส่วนหนึ่งในลายเซ็นของฟังก์ชัน (function’s signature) เมื่อฟังก์ชันมีพารามิเตอร์ คุณจะสามารถส่งมูลค่าที่ชัดเจนของข้อมูลไปผูกกับพารามิเตอร์เหล่านั้นได้ ซึ่งในเชิงวิชาการแล้ว ค่าข้อมูลจริงที่ส่งไปนั้นจะเรียกว่า อาร์กิวเมนต์ (arguments) แต่ในระดับบทสนทนาทั่วไป ผู้คนมักจะพูดคุยโดยหยิบใช้คำว่า parameter และ argument สลับแทนที่กันในความหมายเดียวกันได้ ไม่ว่าจะหมายถึงตัวแปรในนิยามต้นแบบของฟังก์ชัน หรือหมายถึงค่าข้อมูลเดี่ยวที่ส่งเข้าไปเมื่อตอนเรียกใช้ฟังก์ชัน

ในโครงสร้างเวอร์ชันนี้ของ another_function เราได้เติมพารามิเตอร์เพิ่มเข้าไปดังนี้:

Filename: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

ลองสั่งรันตัวโปรแกรมนี้ดู คุณควรจะได้รับรายงานผลลัพธ์ปรากฏดังนี้:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

การประกาศของ another_function จะมีพารามิเตอร์ตัวแปรอยู่ 1 ตัวชื่อว่า x โดยที่ชนิดข้อมูลของ x จะถูกกำหนดจำเพาะเป็นประเภท i32 และเมื่อเราสั่งป้อนค่า 5 เข้าไปในการเรียกใช้งาน another_function ทางมาโคร println! จะดำเนินการนำเอาเลข 5 เข้าไปแทนที่ตำแหน่งเครื่องหมายปีกกาคู่ที่มีตัวแปร x บรรจุอยู่ภายในข้อความควบคุม

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

เมื่อต้องการกำหนดออกแบบพารามิเตอร์หลายๆ ตัว ให้ใช้เครื่องหมายจุลภาคคั่นระหว่างการประกาศพารามิเตอร์แต่ละตัว ดังนี้:

Filename: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

ในกรณีตัวอย่างนี้จะเป็นการสร้างฟังก์ชันชื่อว่า print_labeled_measurement โดยประกอบด้วยพารามิเตอร์ 2 ตัว พารามิเตอร์แรกชื่อว่า value ซึ่งเป็นข้อมูลชนิด i32 ส่วนตัวที่สองชื่อว่า unit_label เป็นข้อมูลชนิด char จากนั้นภายในฟังก์ชันจะสั่งพิมพ์ข้อความรายงานข้อมูลซึ่งรวมมูลค่าของทั้ง value และ unit_label ออกมา

ลองมาทดสอบรันโค้ดส่วนนี้ดู ให้ลองนำตัวอย่างด้านบนนี้ไปเขียนแทนที่โค้ดเดิมในไฟล์ src/main.rs ของโปรเจกต์ functions ของคุณ แล้วรันผ่านคำสั่ง cargo run:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

และเนื่องจากเราดำเนินการเรียกใช้ฟังก์ชันโดยส่งผ่านอาร์กิวเมนต์ตัวเลข 5 เป็นมูลค่าของ value และส่งตัวอักษร 'h' เป็นมูลค่าของ unit_label ผลลัพธ์การรายงานของโปรแกรมจึงแสดงค่าตัวแปรทั้งคู่ปรากฏให้เราเห็น

คำสั่งและนิพจน์ (Statements and Expressions)

เนื้อหาในขอบเขตการทำงานของฟังก์ชันจะประกอบขึ้นมาจากกลุ่มของประโยคคำสั่ง (statements) และอาจมีหรือไม่มีจุดลงท้ายด้วยนิพจน์ (expression) ก็ได้ จนถึงบรรทัดนี้ ฟังก์ชันต่างๆ ที่เราเขียนร่วมกันมายังไม่ได้ใส่ส่วนขอบเขตนิพจน์ปิดท้ายการทำงานเลย แต่คุณอาจจะพบเห็นนิพจน์แทรกตัวทำงานร่วมเป็นส่วนหนึ่งของประโยคคำสั่งแล้ว เนื่องจากภาษา Rust ถูกจัดเป็นภาษาที่มีรากฐานแนวคิดบนรูปแบบนิพจน์ (expression-based language) ประเด็นข้อแตกต่างนี้จึงมีความสำคัญอย่างมากที่ผู้เรียนจำเป็นต้องทำความเข้าใจ ในภาษาโปรแกรมอื่นอาจจะไม่มีการแบ่งรายละเอียดจำแนกเรื่องพวกนี้ชัดเจนนัก มาลองดูกันเลยว่าคำสั่งและนิพจน์คืออะไร และความแตกต่างของพวกมันส่งผลอย่างไรต่อโครงสร้างเนื้อหาของฟังก์ชัน

  • ประโยคคำสั่ง (Statements) คือคำสั่งที่มีหน้าที่ระบุให้โปรแกรมดำเนินการทางพฤติกรรมบางอย่าง และไม่ส่งคืนผลลัพธ์มูลค่าข้อมูลใดๆ กลับออกมา
  • นิพจน์ (Expressions) คือโครงสร้างส่วนที่คำนวณและประเมินผลลัพธ์ออกมาเป็นมูลค่าค่าหนึ่ง

มาลองพิจารณาตัวอย่างพฤติกรรมร่วมกัน

จริงๆ แล้วเราเคยได้หยิบเอาประโยคคำสั่งและนิพจน์มาเขียนโปรแกรมใช้งานอยู่บ่อยครั้งแล้ว ขั้นตอนจัดตั้งตัวแปรและส่งมอบข้อมูลเข้าไปจัดเก็บด้วยคำสำคัญ let จะนับเป็นประโยคคำสั่ง ในรายการที่ 3-1 ข้อความคำสั่ง let y = 6; คือตัวแทนของประโยคคำสั่ง

fn main() {
    let y = 6;
}

ขั้นตอนการประกาศนิยามจัดทำฟังก์ชัน (function definitions) ก็นับเป็นประโยคคำสั่งเช่นเดียวกัน ตัวอย่างซอร์สโค้ดด้านบนทั้งหมดข้างต้นจึงจัดเป็นรูปประโยคคำสั่งในตัวมันเองด้วย (แต่ทว่าขั้นตอนสั่งการเรียกใช้ฟังก์ชันจะไม่ได้นับเป็นประโยคคำสั่ง ดังที่เราจะสังเกตเห็นได้ในอีกไม่ช้า)

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

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

เมื่อคุณสั่งรันตัวโปรแกรมนี้ ข้อผิดพลาดที่รายงานขึ้นบนหน้าจอจะมีลักษณะดังนี้:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` (part of `#[warn(unused)]`) on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6 ;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

ประโยคคำสั่ง let y = 6 จะไม่ส่งคืนผลลัพธ์ข้อมูลใด ๆ ออกมาเลย ส่งผลให้โปรแกรมไม่มีมูลค่าชิ้นใดหลงเหลืออยู่นำไปส่งผูกต่อให้แก่ตัวแปร x ได้เลย พฤติกรรมนี้จะต่างไปจากผลลัพธ์ของภาษาโปรแกรมอื่นอย่างเช่นภาษา C หรือ Ruby ที่ขั้นตอนกำหนดเก็บค่าตัวแปรจะดำเนินการส่งคืนมูลค่าข้อมูลที่จัดเก็บนั้นกลับออกมาด้วย ซึ่งในระบบของภาษาเหล่านั้นคุณจะสามารถเขียนคำสั่งผูกต่อเนื่องย่อเป็น x = y = 6 เพื่อส่งผลลัพธ์ให้ทั้ง x และ y ร่วมถือครองเลข 6 ไปพร้อมๆ กันได้ แต่เงื่อนไขนี้ไม่สามารถนำมาใช้กับภาษา Rust ได้

นิพจน์จะคำนวณและคืนผลลัพธ์ออกเป็นมูลค่าข้อมูล ซึ่งเนื้อหาโครงสร้างส่วนใหญ่ที่เหลือของโค้ดที่คุณเขียนในภาษา Rust จะสร้างขึ้นด้วยรูปแบบของนิพจน์ ลองมาดูตัวอย่างการคำนวณเลขอย่าง 5 + 6 ซึ่งนับเป็นนิพจน์ที่ประเมินผลคืนค่าออกมาเป็นมูลค่าเลข 11 นิพจน์นั้นสามารถฝังรวมอยู่เป็นส่วนหนึ่งของประโยคคำสั่งได้ เช่นในตารางที่ 3-1 ตัวเลข 6 ในคำสั่ง let y = 6; ก็นับเป็นนิพจน์ที่ประเมินตัวมันเองออกมามีค่าเป็น 6 การเรียกฟังก์ชันก็นับเป็นนิพจน์ การรันชุดคำสั่งมาโครก็นับเป็นนิพจน์ รวมถึงการประกาศบล็อกจัดพื้นที่ขอบเขตใหม่ด้วยการปีกกาคร่อมก็ถือเป็นรูปแบบนิพจน์ด้วยเช่นกัน ตัวอย่างเช่น:

Filename: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

ตัวนิพจน์ด้านล่างนี้:

{
    let x = 3;
    x + 1
}

คือโครงสร้างบล็อกคำสั่งซึ่งในกรณีนี้ประเมินมูลค่าออกมารายงานได้ค่าเป็น 4 ซึ่งมูลค่าดังกล่าวจะถูกนำไปผูกมัดเก็บไว้กับตัวแปร y ภายใต้ขั้นตอนการรันประโยคคำสั่ง let พึงระลึกและสังเกตว่าบรรทัด x + 1 จะเขียนระบุไว้ปราศจากเครื่องหมายอัฒภาค (semicolon) ลงท้ายบรรทัด ซึ่งจะต่างไปจากโครงสร้างส่วนใหญ่ที่คุณเคยเห็นผ่านหูผ่านตามาก่อนหน้านี้ เนื่องจากนิพจน์จะไม่อนุญาตให้เขียนปิดท้ายบรรทัดด้วยเครื่องหมายอัฒภาคเป็นอันขาด หากคุณเผลอใส่สัญลักษณ์อัฒภาคกำกับปิดท้ายนิพจน์ รูปประโยคส่วนนั้นจะถูกสลับแปรสภาพไปนับเป็นโครงสร้างประโยคคำสั่ง (statement) ทันที ซึ่งจะไม่ส่งมูลค่าคืนผลลัพธ์ข้อมูลใด ๆ ออกมาอีกเลย โปรดจดจำเงื่อนไขข้อนี้ให้ขึ้นใจเพื่อนำไปประยุกต์ใช้งานกับการศึกษาเรื่องการส่งคืนค่าฟังก์ชันในสเต็ปถัดไป

ฟังก์ชันที่มีการส่งคืนค่าผลลัพธ์ (Functions with Return Values)

ฟังก์ชันสามารถส่งคืนผลลัพธ์ข้อมูลกลับไปยังจุดโค้ดที่รันสั่งเรียกใช้มันได้ตามสมควร เราไม่จำเป็นต้องตั้งชื่อเฉพาะให้กับข้อมูลขากลับนี้ แต่เรามีหน้าที่ต้องเขียนระบุชนิดประเภทของข้อมูลขากลับนั้นตามหลังป้ายสัญลักษณ์ลูกศร (->) เสมอ ในภาษา Rust ค่าข้อมูลคืนกลับของฟังก์ชันจะทำหน้าที่เปรียบเสมือนค่าข้อมูลตัวเดียวกับนิพจน์สุดท้าย (final expression) ที่ปรากฏอยู่ด้านในบล็อกเนื้อความฟังก์ชัน คุณสามารถระบุสั่งขอหยุดการทำงานฟังก์ชันและคืนผลลัพธ์ล่วงหน้าทันทีได้ผ่านการป้อนคำสำคัญ return ร่วมกับการระบุค่าข้อมูลที่ประสงค์จะคืนกลับ แต่ปรกติโดยทั่วไปแล้ว ฟังก์ชันใน Rust ส่วนใหญ่จะส่งคืนค่านิพจน์สุดท้ายโดยอัตโนมัติ (implicitly) ต่อไปนี้เป็นตัวอย่างฟังก์ชันที่มีการส่งคืนค่าขากลับ:

Filename: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

ไม่พบกระบวนการเรียกใช้งานฟังก์ชันอื่น ไม่พบคู่มือมาโคร และไม่มีแม้แต่ประโยคคำสั่ง let อยู่ภายในขอบเขตฟังก์ชัน five เลย มีเพียงมูลค่าตัวเลข 5 โดดๆ วางอยู่ตัวเดียวเท่านั้น แต่นี่คือโครงสร้างฟังก์ชันที่ถูกต้องและสมบูรณ์ทุกประการของภาษา Rust พึงระลึกว่าชนิดขากลับของฟังก์ชันได้รับการระบุกำกับไว้เช่นกันเป็นลูกศร -> i32 ลองมาทดสอบรันโค้ดส่วนนี้ดู ผลลัพธ์ควรจะรายงานผลปรากฏดังนี้:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

เลข 5 ด้านในฟังก์ชัน five ทำหน้าที่เป็นมูลค่าข้อมูลขากลับของฟังก์ชัน จึงเป็นเหตุผลว่าเหตุใดถึงเขียนระบุชนิดขากลับกำกับเป็น i32 มาลองดูพฤติกรรมนี้ให้ละเอียดขึ้นอีกสักนิด โดยมีประเด็นสำคัญอยู่ 2 จุดด้วยกัน: ประการแรกคือ แถวโค้ด let x = five(); ชี้ให้เห็นว่าเราสามารถนำเอาค่าผลลัพธ์ขากลับจากการทำงานของฟังก์ชันมาใช้ประกาศตั้งต้นมูลค่าตัวแปรได้ เนื่องจากฟังก์ชัน five ส่งคืนมูลค่าข้อมูลเป็น 5 บรรทัดดังกล่าวจึงมีผลเทียบเท่ากับการพิมพ์คำสั่งตรงๆ ว่า:

#![allow(unused)]
fn main() {
let x = 5;
}

และประการที่สองคือ ฟังก์ชัน five ไม่มีพารามิเตอร์ใด ๆ พ่วงมาด้วยและมีการระบุข้อมูลชนิดขากลับไว้ครบถ้วน ทว่าเนื้อหาภายในตัวของฟังก์ชันมีเพียงข้อมูลสเกลาร์เลข 5 ตั้งอยู่เดี่ยวๆ ปราศจากเครื่องหมายอัฒภาคลงท้ายเนื่องจากมันคือนิพจน์ที่เราต้องการส่งค่าคืนกลับไปนั่นเอง

มาลองพิจารณาตัวอย่างถัดไปเพิ่มเติม:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

ขั้นตอนการสั่งรันโค้ดส่วนนี้จะแสดงผลออกมาเป็น The value of x is: 6 แต่จะเกิดอะไรขึ้นหากเรานำสัญลักษณ์อัฒภาค (semicolon) ไปใส่ลงท้ายบรรทัดโค้ดส่วนนิพจน์ x + 1 เพื่อเปลี่ยนแปลงระบบคำนวณจากรูปนิพจน์ไปเป็นรูปแบบประโยคคำสั่งแทน?

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

ขั้นตอนการประกอบคอมไพล์โค้ดชุดดังกล่าวจะทำให้เกิดข้อผิดพลาดขึ้น ดังรายงานผลลัพธ์นี้:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error

ใจความสำคัญของข้อผิดพลาดระบุปัญหาชนิดข้อมูลไม่เข้ากัน (mismatched types) ซึ่งชี้ต้นเหตุของข้อผิดพลาดในโค้ดตัวอย่างนี้ โดยโครงสร้างนิยามของฟังก์ชัน plus_one ประกาศกฎเกณฑ์ไว้ชัดเจนว่าจะต้องทำการคืนค่าขากลับเป็นข้อมูลชนิด i32 แต่ประโยคคำสั่ง (statements) จะคำนวณและรายงานมูลค่าตัวแปรออกเป็นค่าว่างเปล่า ซึ่งแทนสภาพด้วยยูนิต () (unit type) ส่งผลให้ฟังก์ชันไม่มีผลลัพธ์มูลค่าข้อมูลขากลับส่งต่อไปให้ผู้รับ ซึ่งมีเงื่อนไขขัดแย้งกับลายเซ็นฟังก์ชันที่ตกลงกันไว้และเกิดปัญหาขึ้นตามมา ในหน้าต่างรายงานผลลัพธ์การแครชนี้ Rust ได้ระบุคำชี้แนะถึงหนทางช่วยแก้ปัญหาที่เหมาะสมไว้ด้วย นั่นคือเสนอแนะให้ลบเครื่องหมายอัฒภาค (semicolon) ท้ายบรรทัดออกไป ซึ่งจะช่วยแก้ไขปัญหาของโปรแกรมนี้ให้กลับมาคอมไพล์ได้สำเร็จ

Comments

คำอธิบายโค้ด (Comments)

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

นี่คือตัวอย่างคำอธิบายโค้ดแบบง่ายๆ:

#![allow(unused)]
fn main() {
// hello, world
}

ในภาษา Rust รูปแบบการเขียนอธิบายโค้ดตามหลักปฏิบัติจะเริ่มต้นด้วยเครื่องหมายทับสองตัว (double slashes) และคำอธิบายจะดำเนินต่อไปจนกระทั่งสิ้นสุดบรรทัดนั้น สำหรับคำอธิบายที่มีความยาวมากกว่าหนึ่งบรรทัด คุณจำเป็นต้องใส่เครื่องหมาย // ไว้ที่หน้าบรรทัดทุกๆ แถว ดังนี้:

#![allow(unused)]
fn main() {
// So we're doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what's going on.
}

คำอธิบายโค้ดสามารถเขียนระบุไว้ที่ด้านท้ายบรรทัดของแถวโค้ดได้เช่นกัน:

Filename: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

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

Filename: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

นอกจากนี้ Rust ยังมีคำอธิบายโค้ดอีกประเภทหนึ่งคือ คำอธิบายสำหรับทำคู่มือเอกสาร (documentation comments) ซึ่งเราจะได้ร่วมพูดคุยกันในบทที่ 14 หัวข้อ “การเผยแพร่เครตไปยัง Crates.io”

Control Flow

โครงสร้างควบคุมการทำงาน

ความสามารถในการรันโค้ดบางส่วนโดยขึ้นอยู่กับว่าเงื่อนไขเป็นจริง (true) หรือไม่ และความสามารถในการรันโค้ดบางส่วนซ้ำๆ ในขณะที่เงื่อนไขเป็นจริงอยู่ ล้วนเป็นโครงสร้างพื้นฐานทั่วไปในภาษาโปรแกรมส่วนใหญ่ โดยโครงสร้างที่นิยมใช้งานกันมากที่สุดในการใช้ควบคุมทิศทางและลำดับการทำงาน (control flow) ของโค้ด Rust ได้แก่ นิพจน์ if และโครงสร้างการวนซ้ำ (loops)

นิพจน์ if

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

ให้ทดลองสร้างโปรเจกต์ใหม่ชื่อว่า branches ในไดเรกทอรี projects ของคุณ เพื่อร่วมสำรวจตรวจสอบนิพจน์ if โดยในไฟล์ src/main.rs ให้ป้อนโค้ดต่อไปนี้ลงไป:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

นิพจน์ if ทั้งหมดจะเริ่มต้นด้วยคำสำคัญ if ตามด้วยตัวเงื่อนไข ซึ่งในกรณีตัวอย่างนี้ เงื่อนไขคือการตรวจสอบว่าตัวแปร number มีค่าน้อยกว่า 5 หรือไม่ เราจะนำบล็อกโค้ดที่ต้องการประมวลผลเมื่อเงื่อนไขได้รับการประเมินเป็นจริง (true) มาวางไว้ต่อท้ายข้อความเงื่อนไขทันทีโดยครอบภายในวงเล็บปีกกา บล็อกโค้ดที่เชื่อมโยงอยู่กับเงื่อนไขในนิพจน์ if บางครั้งจะเรียกว่า กิ่งการทำงาน (arms) เช่นเดียวกับกิ่งตัวเลือกในนิพจน์ match ที่เราคุยกันไว้ในหัวข้อ “การเปรียบเทียบค่าที่ทายกับตัวเลขปริศนา” ในบทที่ 2

นอกจากนี้เรายังสามารถเขียนเพิ่มนิพจน์ else เข้ามาร่วมด้วยได้ตามความเหมาะสม เช่นที่เราเลือกทำในตัวอย่างนี้ เพื่อกำหนดบล็อกโค้ดทางเลือกให้โปรแกรมรันทำงานในกรณีที่เงื่อนไขถูกประเมินค่าออกมาเป็นเท็จ (false) หากคุณไม่ได้ใส่นิพจน์ else ประกอบไว้ และเงื่อนไขประเมินได้เป็นเท็จ ตัวโปรแกรมจะทำการข้ามบล็อกการทำงานของ if นั้นไปเลยและเปลี่ยนไปรันโค้ดส่วนถัดไปแทน

ลองสั่งรันโค้ดส่วนนี้ดู คุณควรจะพบรายงานผลลัพธ์ปรากฏดังนี้:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

ลองมาสลับเปลี่ยนค่าของตัวแปร number ให้แสดงเงื่อนไขประเมินค่าเป็นเท็จ (false) เพื่อตรวจสอบพฤติกรรมดู:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

ให้สั่งรันโปรแกรมใหม่อีกครั้ง แล้วมาตรวจผลลัพธ์กัน:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

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

Filename: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

เงื่อนไขของ if ในรอบนี้ประเมินมูลค่าออกมาได้เป็นตัวเลข 3 ซึ่งทำให้ Rust ส่งข้อความแจ้งเตือนความล้มเหลวขึ้นมาทันที:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

ข้อผิดพลาดบ่งชี้ว่า Rust คาดหวังจะได้รับข้อมูลชนิด bool แต่กลับได้รับค่าส่งมาเป็นเลขจำนวนเต็ม ซึ่งจุดนี้จะต่างไปจากภาษาโปรแกรมอื่นๆ อย่างเช่น Ruby หรือ JavaScript ที่ระบบเบื้องหลังจะช่วยอำนวยความสะดวกแปลงข้อมูลชนิดที่ไม่ใช่ Boolean ให้แปรสภาพเป็นค่าความจริงโดยอัตโนมัติ ใน Rust คุณจำเป็นต้องเขียนชี้แนะชัดเจนตรงไปตรงมาและป้อนเงื่อนไขของ if เป็นค่าความจริง Boolean เสมอ ตัวอย่างเช่น หากเราต้องการให้บล็อกคำสั่งของ if ทำงานเฉพาะเมื่อจำนวนตัวเลขมีมูลค่าไม่เท่ากับ 0 เท่านั้น เราสามารถเปลี่ยนรูปนิพจน์ if ให้เป็นรูปแบบดังนี้:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

ขั้นตอนการรันโค้ดส่วนนี้จะแสดงผลรายงานออกมาเป็น number was something other than zero

การรับมือกับเงื่อนไขที่หลากหลายด้วย else if

คุณสามารถประกาศระบุเงื่อนไขสำหรับดักตรวจจับเหตุการณ์ได้มากกว่าหนึ่งทางแยกโดยการผสานกำลังคำสั่งของ if และ else เข้ามาร่วมทำงานในรูปนิพจน์ else if ตัวอย่างเช่น:

Filename: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

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

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

ในระหว่างโปรแกรมประมวลผลคำสั่ง มันจะไล่ตรวจทานนิพจน์เงื่อนไขของแต่ละกิ่งไปทีละรายการตามลำดับก่อนหลัง และจะตกลงยอมประมวลผลรันเฉพาะบล็อกเนื้อความแรกสุดที่ระบบประเมินค่าเงื่อนไขออกมาได้ผลเป็นจริง (true) เท่านั้น พึงสังเกตว่าแม้เลข 6 จะสามารถนำมาหารด้วย 2 ลงตัวได้เช่นกัน แต่เราจะไม่พบเห็นข้อความแสดงผล number is divisible by 2 ปรากฏขึ้นมาบนหน้าจอเลย และก็ไม่เห็นข้อความจากกิ่ง else เช่นกัน เหตุผลก็เนื่องมาจาก Rust จะเลือกทำบล็อกการทำงานแรกสุดที่สอดคล้องกับเงื่อนไขที่เป็นจริงเท่านั้น และเมื่อตรวจพบเป้าหมายสำเร็จแล้ว ตัวคอมไพเลอร์จะไม่ลงไปไล่ตรวจสอบเงื่อนไขที่เหลืออยู่ด้านล่างอีกเลย

การเขียนเรียกใช้งานนิพจน์ else if ปริมาณที่หนาแน่นเกินไปมักส่งผลให้โครงสร้างโค้ดของคุณดูรกรุงรังและจัดการแก้ไขปรับปรุงยาก ดังนั้นหากคุณเริ่มมีกิ่งเงื่อนไขจำนวนมาก แนะนำให้มองหาแนวทางปรับปรุงจัดระเบียบโครงสร้างโค้ด (refactoring) ในบทที่ 6 จะอธิบายถึงเครื่องมือควบคุมเส้นทางแยกที่มีความยืดหยุ่นและเปี่ยมประสิทธิภาพของ Rust ที่เรียกว่า match สำหรับใช้จัดการกับเงื่อนไขในกรณีเหล่านี้

การใช้งาน if ภายในประโยคคำสั่ง let

เนื่องจากโครงสร้าง if ถูกวิเคราะห์นับเป็นประโยคนิพจน์ชนิดหนึ่ง เราจึงสามารถเขียนระบุประยุกต์ใช้งานมันไว้ที่ส่วนทางขวาของประโยคคำสั่ง let เพื่อนำเอาผลสรุปขากลับจากการคำนวณมาใช้กำหนดมูลค่าเก็บไว้ในตัวแปรได้ ดังแสดงในรายการที่ 3-2

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}

ตัวแปร number จะได้รับการผูกมูลค่าข้อมูลเข้ากับผลลัพธ์ที่คำนวณสรุปได้จากประโยคนิพจน์ if ให้ลองรันโค้ดส่วนนี้เพื่อตรวจทานผลการประมวลผลดู:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

จำได้ไหมว่าบล็อกคำสั่งจะประเมินค่าออกมาได้เท่ากับมูลค่านิพจน์ขอบเขตแถวสุดท้ายภายในบล็อกนั้นๆ และมูลค่าสเกลาร์ตัวเลขเดี่ยวก็จัดเป็นรูปนิพจน์ด้วยเช่นกัน ดังนั้นในกรณีนี้ ข้อมูลขากลับของนิพจน์ if ทั้งหมดจะผันเปลี่ยนไปตามชนิดการประมวลผลของบล็อกโค้ดกิ่งที่ได้ทำงาน ซึ่งมีผลให้ค่าข้อมูลที่เป็นไปได้ทั้งหมดจากการคำนวณของแต่ละกิ่งในโครงสร้าง if จำเป็นต้อง มีประเภทชนิดข้อมูลขากลับที่ตรงกันอย่างสมบูรณ์ โดยในรายการที่ 3-2 ผลลัพธ์ขากลับจากทั้งกิ่ง if และกิ่ง else ต่างคืนค่ามาเป็นตัวเลขจำนวนเต็มชนิด i32 ทั้งคู่ หากชนิดข้อมูลขากลับไม่ตรงกันดังเช่นที่แสดงในตัวอย่างถัดไปด้านล่างนี้ ระบบจะเกิดข้อผิดพลาดขึ้นกะทันหัน:

Filename: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

เมื่อเราพยายามประมวลผลเพื่อคอมไพล์โค้ดชุดดังกล่าว ระบบจะแจ้งเตือนข้อผิดพลาดทันที โดยกิ่ง if และกิ่ง else จะคืนค่าชนิดข้อมูลที่ขัดแย้งใช้งานร่วมกันไม่ได้ และ Rust จะพิมพ์ชี้เป้ารายงานตำแหน่งพิกัดจุดเกิดเหตุในโปรแกรมออกมาอย่างแม่นยำ:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

นิพจน์ภายในบล็อกงานของ if ประเมินผลได้เป็นตัวเลขจำนวนเต็ม แต่นิพจน์ในกิ่ง else กลับประเมินผลได้เป็นข้อมูลชนิดข้อความ (string) พฤติกรรมนี้จะไม่ได้รับอนุญาตเนื่องจากตัวแปรใน Rust จะต้องมีชนิดข้อมูลประเภทเดียวที่แน่นอน และ Rust จำเป็นต้องรับทราบอย่างแน่ชัดตั้งแต่ขั้นตอนคอมไพล์ว่าตัวแปร number มีประเภทข้อมูลเป็นอะไร การที่คอมไพเลอร์ทราบชนิดข้อมูลที่ตายตัวของ number จะเปิดโอกาสให้ระบบสแกนคอยตรวจสอบประเมินได้ครอบคลุมในทุกจุดที่ตัวแปร number นี้ถูกเรียกใช้งาน Rust จะสูญเสียความปลอดภัยนี้ไปทันทีหากยอมปล่อยให้ตัวแปร number เปลี่ยนชนิดข้อมูลไปตามช่วงโปรแกรมทำงาน (runtime) ยิ่งไปกว่านั้นตัวโครงสร้างคอมไพเลอร์จะต้องเขียนขึ้นมาอย่างซับซ้อนมาก และมีผลให้การันตีการคุ้มกันความปลอดภัยในส่วนของตัวแปรมีประสิทธิภาพลดลงหากระบบต้องมาคอยไล่ตามสืบประวัติชนิดข้อมูลสมมติที่อาจเปลี่ยนโฉมไปมา

การทำงานวนซ้ำด้วยลูป (Repetition with Loops)

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

ภาษา Rust มีกลุ่มลูปการทำงานหลักอยู่ 3 ประเภท ได้แก่: loop, while และ for มาลองศึกษาวิธีใช้งานแต่ละแบบไปด้วยกัน

การวนซ้ำโค้ดไปเรื่อยๆ ด้วย loop

คำสำคัญ loop จะเป็นตัวบอก Rust ให้ทำการประมวลผลรันบล็อกคำสั่งวนไปวนมาเรื่อยๆ ตลอดกาลจนกว่าจะสั่งการอย่างเป็นทางการจากโค้ดภายในให้สั่งยุติการทำงาน

มาลองดูตัวอย่างกัน ให้เขียนปรับโครงสร้างโค้ดในไฟล์ src/main.rs ภายในไดเรกทอรี loops ของคุณให้แสดงพฤติกรรมดังต่อไปนี้:

Filename: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

เมื่อสั่งรันประมวลผลโปรแกรมนี้ เราจะพบคำว่า again! แสดงผลทับซ้ำๆ วนเวียนอยู่บนหน้าจอไปเรื่อยๆ ไม่มีวันสิ้นสุดจนกว่าเราจะกดสั่งหยุดโปรแกรมด้วยตนเอง โดยส่วนใหญ่แล้วบนหน้าต่างเทอร์มินัลเกือบทุกระบบจะรองรับการกดแป้นพิมพ์คีย์ลัด ctrl ร่วมกับ C เพื่อเข้าไปขัดขวางหยุดการทำงานโปรแกรมที่ติดขัดวนลูปไม่รู้จบอยู่ ให้ลองกดทดสอบรันดู:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

สัญลักษณ์ ^C บ่งชี้บริเวณตึกพิกัดที่คุณได้ทำการกดปุ่มคีย์ลัด ctrl-C

คุณอาจจะพบเห็นคำว่า again! ถูกพิมพ์ปรากฏเพิ่มขึ้นมาถัดจากเครื่องหมาย ^C หรือไม่อยู่บ้างเล็กน้อย ซึ่งพฤติกรรมดังกล่าวจะแปรผันตามตำแหน่งประมวลผลโค้ดในลูปตรงช่วงเวลาที่สัญญาณขัดจังหวะโปรแกรม (interrupt signal) ถูกส่งเข้ามาประชิดตัวพอดี

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

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

การส่งคืนมูลค่าข้อมูลออกจากลูป

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

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

ก่อนเริ่มสั่งงานลูป เราได้ทำสร้างตัวแปรตัวหนึ่งชื่อว่า counter ตั้งต้นเก็บค่าเป็น 0 จากนั้นเราประกาศจัดตั้งตัวแปรชื่อว่า result คอยเก็บข้อมูลส่งกลับขากลับจากลูป ภายในลูปในแต่ละรอบการทำงาน ตัวโปรแกรมจะประมวลผลเพิ่มค่าตัวเลขสะสม 1 ให้แก่ counter แล้วตามด้วยตรวจสอบว่าค่า counter มีขนาดเท่ากับ 10 พอดีหรือยัง และเมื่อสะสมไปถึงพิกัดสำเร็จ เราสั่งเรียกคำสั่ง break แนบพ่วงค่าที่ต้องการส่งกลับคำนวณย่อยเป็น counter * 2 ออกมาภายนอก ภายหลังเสร็จสิ้นกระบวนการของลูป เราใส่เครื่องหมายอัฒภาคลงท้ายเพื่อจบร่างประโยคคำสั่งที่กำหนดค่ามอบให้แก่ result และบรรทัดท้ายสุดเราแสดงผลค่าของ result ซึ่งประมวลผลได้ค่าเป็น 20 พอดี

คุณยังสามารถสั่งเรียก return จากด้านในลูปได้เช่นกัน พึงระลึกไว้ว่าในขณะที่คำสั่ง break จะมีผลเฉพาะเจาะจงให้ยุติทำงานข้ามออกมานอกลูปปัจจุบันเท่านั้น แต่คำสั่ง return จะมีผลสูงสุดคือสั่งการหยุดรันข้ามออกไปนอกฟังก์ชันปัจจุบันทันที

การจำแนกความสัมพันธ์ด้วยป้ายกำกับลูป (Loop Labels)

หากคุณมีลูปซ้อนลูป (nested loops) คำสั่ง break และ continue จะมีผลกับลูปในสุด ณ จุดนั้นเท่านั้น คุณสามารถระบุ ป้ายกำกับลูป (loop label) บนลูปได้ จากนั้นคุณสามารถนำป้ายกำกับไปใช้ร่วมกับ break หรือ continue เพื่อระบุให้คำสำคัญเหล่านั้นทำงานกับลูปที่มีป้ายกำกับแทนที่จะเป็นลูปในสุด ป้ายกำกับลูปจำเป็นต้องขึ้นต้นด้วยเครื่องหมายอัญประกาศเดี่ยว (single quote) เสมอ ต่อไปนี้เป็นตัวอย่างที่มีลูปซ้อนกันสองชั้น:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

ลูปชั้นนอกมีป้ายกำกับว่า 'counting_up และจะนับเลขขึ้นจาก 0 ถึง 2 ลูปชั้นในที่ไม่มีป้ายกำกับจะนับถอยหลังลงจาก 10 ถึง 9 คำสั่ง break บรรทัดแรกที่ไม่ได้ระบุป้ายกำกับจะยุติการทำงานเฉพาะลูปชั้นในเท่านั้น ส่วนประโยคคำสั่ง break 'counting_up; จะมีผลให้ยุติการทำงานและออกจากลูปชั้นนอก โค้ดนี้จะแสดงผลลัพธ์ดังนี้:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

การเขียนวนซ้ำแบบมีเงื่อนไขด้วย while

บ่อยครั้งที่การวนซ้ำในโปรแกรมจำเป็นต้องประเมินเงื่อนไขประกอบไปด้วย ในระหว่างที่เงื่อนไขเป็นจริง (true) ลูปจะทำงานต่อไป และเมื่อใดที่เงื่อนไขเปลี่ยนเป็นเท็จ โปรแกรมจะเรียกใช้ break เพื่อหยุดลูป เราสามารถสร้างพฤติกรรมนี้ได้โดยใช้ loop, if, else และ break ร่วมกัน แต่เนื่องจากรูปแบบนี้พบบ่อยมาก Rust จึงมีโครงสร้างเฉพาะเตรียมไว้ให้ในตัวภาษา นั่นคือลูปแบบ while ในรายการที่ 3-3 เราจะใช้ while ในการรันโค้ดซ้ำ 3 ครั้ง โดยนับถอยหลังในแต่ละรอบ และแสดงข้อความจบลูปก่อนจะสิ้นสุดการทำงาน

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

โครงสร้างนี้ช่วยลดการเขียนโค้ดซ้อนกันหลายชั้น (nesting) ที่อาจเกิดขึ้นหากเลือกใช้ loop, if, else และ break ส่งผลให้โค้ดมีความชัดเจนและอ่านง่ายขึ้น ในขณะที่เงื่อนไขยังคงเป็นจริง โค้ดภายในบล็อกจะถูกรันซ้ำ หากเงื่อนไขเป็นเท็จ โปรแกรมจะออกจากลูปทันที

การวนซ้ำเพื่ออ่านข้อมูลในคอลเลกชันด้วย for

คุณสามารถเลือกใช้โครงสร้าง while เพื่อวนลูปอ่านข้อมูลของสมาชิกแต่ละชิ้นในคอลเลกชัน เช่น อาร์เรย์ ได้ ตัวอย่างเช่น ลูปในรายการที่ 3-4 จะพิมพ์ค่าสมาชิกแต่ละตัวในอาร์เรย์ a ออกมา

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

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

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

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

อย่างไรก็ตาม วิธีการนี้ค่อนข้างมีความเสี่ยงที่จะเกิดข้อผิดพลาดได้ง่าย (error-prone) เนื่องจากเราอาจทำให้โปรแกรมเกิดการแครชหยุดชะงัก (panic) ได้หากการตั้งค่าดัชนีหรือตัวดักกรองตรวจเงื่อนไขเกิดผิดพลาด ตัวอย่างเช่น หากคุณแก้ไขขนาดโครงสร้างอาร์เรย์ a ให้มีสมาชิกเหลือเพียง 4 ตัว แต่ดันลืมเข้าไปแก้ไขตัวตรวจเงื่อนไขลูปเป็น while index < 4 ตัวโปรแกรมจะแครชในที่สุด ยิ่งไปกว่านั้น วิธีการนี้ทำงานค่อนข้างช้าเนื่องจากคอมไพเลอร์จำเป็นต้องเพิ่มชุดคำสั่งเบื้องหลังเข้ามาคอยสแกนตรวจสอบความถูกต้องว่าดัชนีที่ป้อนนั้นอยู่ในขอบเขตขนาดของอาร์เรย์จริงหรือไม่ในทุกๆ รอบที่มีการรันทำงานวนลูป

เพื่อแนวทางแก้ไขที่สั้นกระชับและปลอดภัยยิ่งขึ้น คุณสามารถเลือกเรียกใช้งานลูปแบบ for เพื่อสั่งการรันประมวลผลโค้ดสำหรับสมาชิกแต่ละชิ้นในคอลเลกชันได้โดยตรง โครงสร้างลูป for แสดงตัวอย่างดังในรายการที่ 3-5

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

เมื่อสั่งรันรหัสโค้ดชุดนี้ เราจะได้ผลรายงานผลลัพธ์เดียวกับข้อมูลที่ได้รับไปในรายการที่ 3-4 แต่ประเด็นที่สำคัญยิ่งกว่าคือ ตอนนี้เราได้ยกระดับความคุ้มกันความปลอดภัยให้แก่โค้ดและป้องกันสภาวะการเกิดบั๊กที่อาจจะมาจากการเข้าถึงดัชนีชี้ตำแหน่งเลยความจุของอาร์เรย์ หรืออ่านข้อมูลไม่ครบทุกตำแหน่งได้อย่างถาวร ยิ่งไปกว่านั้น โครงสร้างโค้ดเครื่องประมวลผล (machine code) ที่สร้างขึ้นมาจากการคอมไพเลอร์ลูป for จะมีระดับการประมวลผลที่มีประสิทธิภาพสูงกว่าด้วย เนื่องจากระบบไม่จำเป็นต้องมาคอยชั่งวัดค่าตัวแปรดัชนีเปรียบเทียบกับขนาดความยาวอาร์เรย์ซ้ำๆ ในทุกรอบวนลูป

ด้วยประโยชน์จากการประยุกต์ลูป for นี้ คุณไม่จำเป็นต้องกังวลที่จะต้องคอยสลับกลับมาคอยอัปเดตเปลี่ยนค่าตัวเลขตำแหน่งเงื่อนไขดักตรวจจับตัวเลขใดๆ เลยหากในภายหลังคุณมีการแก้ไขปริมาณความจุรายการสมาชิกในอาร์เรย์ ซึ่งจะแตกต่างไปจากวิธีดั้งเดิมที่ใช้ในตารางที่ 3-4

ข้อเด่นในเรื่องความปลอดภัยและความสั้นกระชับของการใช้งานลูป for ส่งผลให้มันกลายเป็นลูปควบคุมการวนซ้ำที่ถูกเลือกใช้งานอย่างแพร่หลายที่สุดใน Rust แม้แต่สภาวะที่คุณมีความประสงค์ต้องการสั่งการให้โปรแกรมทำงานวนซ้ำๆ ตามจำนวนรอบที่ระบุกำหนดชัดเจน เช่น การนับถอยหลังด้วยลูป while ในรายการที่ 3-3 สมาชิกส่วนใหญ่ชาว Rustacean ก็นิยมเลือกประยุกต์ใช้งานลูป for แทนที่อยู่ดี วิธีการประยุกต์งานลักษณะนี้จะใช้อุปกรณ์ช่วงขอบเขต Range ที่จัดหาให้ในระบบไลบรารีมาตรฐาน ซึ่งจะช่วยสร้างกลุ่มตัวเลขเรียงตามลำดับสะสมจากค่าตัวแรกไปจนจบพิกัดถัดก่อนหน้าเลขปลายทาง

ตัวอย่างภาพการนับถอยหลังด้วยลูป for และการเรียกใช้งานร่วมกับอีกเครื่องมือที่เรายังไม่มีโอกาสพูดถึงอย่าง rev สำหรับสลับกลับด้านช่วงตัวเลขขอบเขต มีดังนี้:

Filename: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

โค้ดส่วนนี้ดูสวยงามและสะอาดตากว่าเดิมมากเลยใช่ไหมครับ?

สรุป

คุณทำสำเร็จแล้ว! บทนี้นับเป็นบทเรียนที่มีขนาดข้อมูลและเนื้อความที่แน่นและจุใจมาก: คุณได้เรียนรู้เกี่ยวกับตัวแปร, ชนิดข้อมูลประเภทสเกลาร์และเชิงประกอบ, ฟังก์ชัน, คำอธิบายโค้ด, นิพจน์เงื่อนไข if ตลอดจนโครงสร้างการวนลูป! เพื่อทบทวนทักษะฝีมือความรู้เกี่ยวกับแนวคิดต่างๆ ที่เราคุยกันในบทนี้ ให้ทดลองสร้างโปรแกรมจัดการแก้ไขโจทย์เหล่านี้เล่นกันดูครับ:

  • ตัวแปลงค่าหน่วยอุณหภูมิสลับไปมาระหว่าง Fahrenheit และ Celsius
  • การคำนวณสุ่มหาลำดับเลขฟีโบนัชชี (Fibonacci number) ตำแหน่งที่ n
  • การสั่งพิมพ์เนื้อเพลงคริสต์มาสคลาสสิก “The Twelve Days of Christmas” โดยประยุกต์ใช้ประโยชน์จากการรันลูปพิมพ์ทวนทับซ้ำๆ ของเพลง

และเมื่อคุณมีความพร้อมที่จะก้าวเดินหน้าต่อ หัวข้อถัดไปเราจะมาร่วมศึกษากับแนวคิดหลักของ Rust ซึ่งเป็นคุณลักษณะเด่นเฉพาะตัวที่ไม่ค่อยมีให้เห็นในภาษาโปรแกรมอื่น นั่นคือระบบสิทธิ์การครอบครองหน่วยความจำ (ownership) ครับ

ความเข้าใจเรื่องสิทธิ์การครอบครองหน่วยความจำ (Ownership)

ระบบสิทธิ์การครอบครองหน่วยความจำ (Ownership) ถือเป็นคุณลักษณะเฉพาะตัวที่เป็นเอกลักษณ์ที่สุดของภาษา Rust และส่งผลกระทบอย่างลึกซึ้งต่อส่วนอื่นๆ ทั้งหมดของตัวภาษา มันช่วยให้ Rust สามารถรับประกันความปลอดภัยของหน่วยความจำ (memory safety) ได้โดยไม่จำเป็นต้องใช้ระบบเก็บขยะ (garbage collector) ดังนั้นการทำความเข้าใจวิธีการทำงานของระบบนี้จึงมีความสำคัญอย่างยิ่ง ในบทนี้ เราจะได้มาพูดคุยถึงระบบสิทธิ์การครอบครองหน่วยความจำ ตลอดจนคุณลักษณะเด่นอื่น ๆ ที่เกี่ยวข้อง ได้แก่: การยืมใช้ข้อมูล (borrowing), ส่วนแบ่งข้อมูล (slices) และวิธีการที่ภาษา Rust จัดวางข้อมูลต่าง ๆ ลงบนหน่วยความจำ

What is Ownership?

สิทธิ์การครอบครองหน่วยความจำคืออะไร? (What Is Ownership?)

สิทธิ์การครอบครองหน่วยความจำ (Ownership) คือกฎเกณฑ์ชุดหนึ่งที่ควบคุมวิธีการจัดหาและควบคุมดูแลหน่วยความจำของโปรแกรมในภาษา Rust โดยโปรแกรมคอมพิวเตอร์ทั้งหมดจำเป็นต้องคอยบริหารจัดการวิธีการหยิบใช้หน่วยความจำของตัวเครื่องในระหว่างประมวลผลการทำงาน ในภาษาโปรแกรมบางภาษาจะเลือกใช้ระบบเก็บขยะ (garbage collection) คอยค้นหาหน่วยความจำที่ไม่มีความจำเป็นต้องเรียกใช้งานอีกต่อไปอยู่เป็นระยะๆ ระหว่างโปรแกรมทำงาน ส่วนในภาษาอื่น โปรแกรมเมอร์มีหน้าที่ต้องระบุสั่งการจองหน่วยความจำและสั่งคืนพื้นที่ด้วยตนเองอย่างเป็นทางการ แต่ Rust เลือกเดินเส้นทางสายที่สาม: โดยจะสกัดจัดตั้งระบบสิทธิ์การครอบครองหน่วยความจำ (ownership) ซึ่งประกอบด้วยกฎเกณฑ์ที่เข้มงวดให้ตัวคอมไพเลอร์ทำหน้าที่ตรวจสอบสแกนให้ หากพบว่ามีกฎเกณฑ์ข้อใดละเมิด โปรแกรมจะไม่ยอมผ่านขั้นตอนคอมไพล์เด็ดขาด และคุณสมบัติความปลอดภัยของระบบสิทธิ์การครอบครองนี้จะไม่มีส่วนทำให้โปรแกรมของคุณทำงานช้าลงเลยขณะที่โปรแกรมทำงาน

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

เมื่อคุณมีความเข้าใจเรื่องสิทธิ์การครอบครองหน่วยความจำอย่างกระจ่างแจ้งแล้ว คุณจะมีพื้นฐานที่มั่นคงสำหรับนำไปศึกษาทำความเข้าใจความสามารถเฉพาะตัวจุดอื่นๆ ที่ทำให้ภาษา Rust โดดเด่นไม่ซ้ำใคร ในบทนี้ คุณจะเรียนรู้กลไกสิทธิ์การครอบครองจากการทดลองเขียนตัวอย่างโดยมุ่งเน้นไปที่โครงสร้างข้อมูลพื้นฐานที่ใช้กันอย่างแพร่หลาย นั่นคือ ข้อมูลข้อความ (strings)

ข้อมูลประเภทสแตกและฮีป (The Stack and the Heap)

ภาษาโปรแกรมส่วนใหญ่มักจะไม่ร้องขอให้คุณมาคอยกังวลคิดถึงโครงสร้างหน่วยความจำประเภทสแตก (stack) และฮีป (heap) บ่อยครั้งนัก แต่สำหรับภาษาประเภทสำหรับระบบ (systems programming language) อย่างเช่น Rust การที่มูลค่าตัวแปรจะถูกจัดเก็บไว้บน stack หรือ heap จะส่งผลให้ระบบการทำงานภาษาตอบสนองแตกต่างกันและเป็นเหตุผลสำคัญว่าทำไมคุณต้องตัดสินใจเขียนโค้ดรูปประโยคแบบเฉพาะเจาะจง รายละเอียดบางส่วนของระบบสิทธิ์ครอบครองสิทธิ์จะได้รับการอ้างอิงและบรรยายเชื่อมโยงกับ stack และ heap ในบทนี้ ดังนั้นต่อไปนี้เป็นคู่มือคำอธิบายย่อยเพื่อเตรียมความพร้อมเบื้องต้น

ทั้งสแตก (stack) และฮีป (heap) ต่างเป็นพื้นที่ส่วนหน่วยความจำที่โค้ดของคุณสามารถดึงมาจัดสรรเก็บข้อมูลได้ในขณะรันโปรแกรม (runtime) แต่จะมีวิธีการจัดวางโครงสร้างพื้นที่ที่แตกต่างกันอย่างสิ้นเชิง โดยสแตกจะทำหน้าที่เก็บมูลค่าข้อมูลตามลำดับเวลาที่ได้รับส่งเข้ามา และจะถอดถอนล้างข้อมูลย้อนกลับทิศทางตรงกันข้าม ซึ่งพฤติกรรมนี้จะเรียกว่า เข้าทีหลัง ออกก่อน (last in, first out - LIFO) ให้นึกภาพซ้อนทับเสมือนตั้งจานอาหาร: เมื่อต้องการวางจานเพิ่ม คุณจะวางซ้อนไว้ที่ด้านบนสุดของกองจาน และเมื่อต้องการหยิบจานไปใช้งาน คุณจะดึงเอาจานใบด้านบนสุดออกไปก่อน การพยายามยัดจานหรือดึงจานออกจากส่วนกลางหรือส่วนล่างของตั้งจานจะไม่สะดวกแน่นอน! ขั้นตอนป้อนจัดเก็บข้อมูลลงสแตกจะเรียกว่า การพุชเข้าสแตก (pushing onto the stack) และขั้นตอนดึงข้อมูลออกจะเรียกว่า การป็อปออกจากสแตก (popping off the stack) และที่สำคัญ ข้อมูลชิ้นใดๆ ที่ต้องการจะนำมาจัดเก็บจองไว้บน stack จำเป็นต้อง มีขนาดความจุข้อมูลที่ระบบรับทราบตายตัวแน่นอนตั้งแต่แรกเริ่ม (known, fixed size) ส่วนชิ้นข้อมูลใดๆ ที่ยังประเมินขนาดความกว้างไม่ได้ในช่วงเวลาคอมไพล์ หรือขนาดอาจขยายตัวแปรผันได้ระหว่างรันโปรแกรม จะต้องนำไปบันทึกเก็บไว้ในส่วน heap แทน

ส่วนพื้นที่ฮีป (heap) จะมีความไร้ระเบียบมากกว่า: เมื่อคุณต้องการบันทึกข้อมูลลง heap คุณต้องสั่งส่งคำร้องขอจองขนาดพื้นที่หน่วยความจำที่ต้องการไปให้ระบบช่วยหาตำแหน่งว่างให้ ระบบจัดสรรหน่วยความจำ (memory allocator) จะสแกนค้นหาช่องว่างที่กว้างใหญ่พอแก่ความต้องการใน heap จากนั้นจะทำเครื่องหมายกากบาทว่าตำแหน่งนี้ถูกใช้งานแล้ว และส่งคืนค่าสัญลักณ์ชี้พิกัดตำแหน่ง (pointer) ซึ่งทำหน้าที่เป็นข้อมูลระบุที่อยู่ของตำแหน่งนั้นๆ กลับคืนมาให้ กระบวนการจองหน่วยความจำแบบนี้เรียกว่า การจัดสรรจองหน่วยความจำบนฮีป (allocating on the heap) หรือเรียกสั้นๆ ย่อยว่า การจัดสรรจอง (allocating) (การพุชค่าข้อมูลลง stack จะไม่ถูกนับเป็นการจัดสรรจองลักษณะนี้) และเนื่องจากมูลค่าตัวชี้ pointer ไปยังตำแหน่ง heap จะมีขนาดความกว้างที่ตายตัวแน่นอนคงที่ คุณจึงสามารถสั่งบันทึกตัวชี้ pointer นี้ลงใน stack ได้ปกติ แต่เมื่อใดที่คุณต้องการอ่านเนื้อข้อมูลภายในชิ้นงานจริงๆ คุณต้องใช้วิธีสะกดรอยตามตัวชี้ pointer นั้นไปอ่านที่ heap อีกที ให้นึกภาพเสมือนพฤติกรรมการบริการในร้านอาหาร: เมื่อคุณเดินเข้าร้านอาหาร คุณแจ้งจำนวนสมาชิกในกลุ่ม พนักงานต้อนรับจะโต๊ะว่างที่รองรับจำนวนคนได้เหมาะสมแล้วนำทางคุณไปยังโต๊ะอาหารตัวนั้น และหากเพื่อนของคุณตามมาทีหลัง พวกเขาสามารถสอบถามพนักงานว่าโต๊ะที่คุณนั่งอยู่ตั้งอยู่โซนใดเพื่อเดินตามมาหาคุณได้ถูกต้อง

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

การเข้าถึงข้อมูลที่เก็บไว้ในฮีปตามปกติแล้วจะทำงานได้ช้ากว่าการเข้าถึงข้อมูลในสแตกค่อนข้างเห็นได้ชัด เนื่องจากคุณต้องเสียเวลาเดินตามเบาะแสข้อมูลตัวชี้ pointer เพื่อเข้าถึงพื้นที่ข้อมูล โดยตัวประมวลผลชิปเซ็ต (processors) ในปัจจุบันจะประมวลผลได้คล่องตัวและทำรอบความเร็วได้ดีกว่าหากพวกมันไม่จำเป็นต้องทำการกระโดดสลับตำแหน่งไปมาในหน่วยความจำบ่อยครั้ง ให้นึกภาพย้อนเทียบกับพนักงานเสิร์ฟอาหารที่ต้องบริการจดรับออร์เดอร์จากโต๊ะลูกค้าหลากหลายโต๊ะ การทำงานที่มีประสิทธิภาพสูงสุดคือจดออร์เดอร์สมาชิกทุกคนในโต๊ะเดียวกันให้ครบถ้วนก่อนจะสลับเดินไปรับออร์เดอร์ของโต๊ะถัดไป หากพนักงานสลับบริการด้วยการเดินไปจดโต๊ะ A หนึ่งคน แล้วสลับไปจดโต๊ะ B หนึ่งคน แล้ววนกลับมาโต๊ะ A อีกรอบ จะส่งผลให้การเสิร์ฟช้าลงมาก และทำนองเดียวกัน ตัวประมวลผลของเครื่องจะประมวลผลการทำงานได้สมบูรณ์และลื่นไหลที่สุดหากข้อมูลชิ้นต่างๆ ที่ต้องใช้ประมวลผลนั้นได้รับการจัดวางไว้ใกล้เคียงเคียงข้างกัน (เหมือนที่ตั้งซ้อนทับอยู่บน stack) มากกว่าการจัดเก็บแยกย้ายกันไปกระจัดกระจายต่างตำแหน่ง (เหมือนที่กระจายตัวอยู่ใน heap)

เมื่อโค้ดของคุณทำการสั่งเรียกใช้งานฟังก์ชัน ค่าข้อมูลต่างๆ ที่ส่งผ่านเข้าไปในฟังก์ชัน (รวมถึงมูลค่าตัวชี้ pointer ที่อาจจะโยงระบุตัวแปรบน heap) และตัวแปรเฉพาะถิ่น (local variables) ของฟังก์ชัน จะถูกจัดการพุชพ่วงเข้าไปจัดเก็บอยู่ใน stack และเมื่อฟังก์ชันทำงานสิ้นสุดเสร็จสิ้นกระบวนการ ค่าข้อมูลชิ้นเหล่านั้นจะถูกสั่งป็อปปลดปล่อยล้างออกจาก stack คืนหน่วยความจำทันที

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

กฎของสิทธิ์การครอบครองหน่วยความจำ (Ownership Rules)

อันดับแรก ลองมาพิจารณากฎเกณฑ์พื้นฐานของระบบสิทธิ์การครอบครองหน่วยความจำด้วยกัน โปรดจดจำกฎเกณฑ์เหล่านี้ให้ขึ้นใจระหว่างที่เราไล่ศึกษาภาพตัวอย่างต่าง ๆ ด้านล่างนี้ประกอบ:

  • ข้อมูลทุกๆ ค่าในภาษา Rust จะต้องมี ผู้ครอบครองสิทธิ์ (owner) เสมอ
  • ในเวลาขณะใดขณะหนึ่ง ค่าข้อมูลชิ้นนั้นจะสามารถมีผู้ครอบครองสิทธิ์ได้เพียงแค่รายเดียวเท่านั้น
  • เมื่อใดก็ตามที่ตัวแปรผู้ครอบครองสิทธิ์หลุดออกไปนอกขอบเขตการทำงาน (out of scope) ค่าข้อมูลชิ้นนั้นจะถูกทำลายทิ้งทันที (dropped)

ขอบเขตการทำงานของตัวแปร (Variable Scope)

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

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

#![allow(unused)]
fn main() {
let s = "hello";
}

ตัวแปร s โยงเก็บข้อมูลค่าคงที่ข้อความ (string literal) โดยที่มูลค่าข้อความดังกล่าวจะถูกฝังค่าลงไปในซอร์สโค้ดของโปรแกรมโดยตรง ตัวแปรนี้จะสามารถเริ่มเรียกใช้งานได้ตั้งแต่ขั้นตอนบรรทัดที่มันถูกประกาศสร้างไปเรื่อยๆ จนถึงพิกัดสิ้นสุดขอบเขตงานปัจจุบัน รายการที่ 4-1 แสดงตัวอย่างโปรแกรมและคำอธิบายโค้ดระบุพื้นที่ตำแหน่งที่ตัวแปร s จะมีผลทำงาน:

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

กล่าวอีกนัยหนึ่ง มีจุดเวลาที่สำคัญ 2 จุดดังนี้:

  • เมื่อ s เข้ามาอยู่ภายในขอบเขตการทำงาน มันจะมีผลใช้งานได้ทันที
  • มันจะยังคงใช้งานได้ไปเรื่อยๆ จนกระทั่งหลุดออกนอกขอบเขตการทำงานไป

มาถึงตรงนี้ ความสัมพันธ์ระหว่างขอบเขตการทำงานและความถูกต้องของตัวแปรจะมีพฤติกรรมสอดคล้องคล้ายคลึงกับภาษาโปรแกรมทั่วไปอื่น ๆ ในตอนนี้เราจะสร้างความเข้าใจเพิ่มขึ้นอีกระดับด้วยการแนะนำประเภทข้อมูลชนิด String

ชนิดข้อมูลข้อความประเภท String

เพื่อทำความเข้าใจกฎของสิทธิ์การครอบครองหน่วยความจำ เราจำเป็นต้องศึกษาด้วยประเภทข้อมูลที่มีความซับซ้อนมากกว่าชนิดข้อมูลทั่วไปที่เราพูดคุยกันไว้ในหัวข้อ “ชนิดข้อมูล” ในบทที่ 3 โดยกลุ่มชนิดข้อมูลที่ศึกษาไปก่อนหน้านี้ล้วนมีขนาดความจุที่ระบบรับทราบแน่นอนตั้งแต่ตอนคอมไพล์ จึงสามารถจัดเก็บไว้บน stack และป็อปออกจากหน่วยความจำได้สะดวกเมื่อจบรอบขอบเขต รวมถึงสามารถคัดลอกส่งมอบเป็นมูลค่าอิสระแยกตัวออกไปใช้งานได้อย่างรวดเร็วในกรณีที่โค้ดส่วนอื่นต้องการเรียกใช้ข้อมูลชิ้นเดียวกัน แต่ในหัวข้อนี้เราต้องการจะสำรวจสิทธิ์ของตัวแปรข้อมูลที่ต้องจองเก็บไว้ใน heap และทำความเข้าใจว่า Rust รู้ได้อย่างไรว่าจะต้องเข้าไปเก็บกวาดข้อมูลเหล่านั้นทิ้งเวลาใด ซึ่งข้อมูลประเภทชนิด String จัดเป็นกรณีศึกษาตัวอย่างที่ยอดเยี่ยมมาก

เราจะเพ่งความสนใจไปที่องค์ประกอบการทำงานต่าง ๆ ของ String ส่วนที่เชื่อมโยงกับระบบสิทธิ์การครอบครองหน่วยความจำ ซึ่งแนวคิดพฤติกรรมพวกนี้จะนำไปประยุกต์ร่วมกับโครงสร้างข้อมูลเชิงซ้อนประเภทอื่นๆ ทั้งหมดเช่นกัน ไม่ว่าจะจัดหาให้โดยมาตรฐานหรือสร้างขึ้นด้วยตัวคุณเอง เราจะกลับมาพูดคุยรายละเอียดเชิงลึกเรื่อง String ส่วนอื่นๆ ที่ไม่ใช่เงื่อนไขครอบครองสิทธิ์ใน บทที่ 8

เราเคยพบเห็นค่าคงที่ข้อความ (string literals) ซึ่งเป็นข้อมูลที่ฝังค่าไว้ลงบนตัวโปรแกรมของเรา ค่าคงที่ข้อความเขียนใช้สะดวกมากแต่ไม่ได้เหมาะสมสำหรับนำมาประยุกต์ใช้งานในทุกๆ สถานการณ์ของการจัดการอักขระและข้อความ เหตุผลแรกคือพวกมันไม่สามารถปรับเปลี่ยนข้อมูลได้ (immutable) และอีกประการคือข้อมูลข้อความบางรายการเราไม่มีทางล่วงรู้ขนาดหรือเนื้อหาข้อมูลล่วงหน้าได้เลยในขณะกำลังเขียนโค้ด เช่น หากเราประสงค์จะขอรับข้อมูลอินพุตจากแป้นพิมพ์ผู้เล่นแล้วนำมาบันทึกเก็บไว้ เป็นต้น เพื่อรับมือกับสถานการณ์ทางเลือกเหล่านี้ Rust จึงเตรียมประเภทข้อมูลชนิด String มาให้ ซึ่งข้อมูลประเภทนี้จะบริหารจัดการข้อมูลด้วยการจองพื้นที่บน heap ส่งผลให้มีความยืดหยุ่นในการจัดเก็บความจุเนื้อหาข้อความปริมาณใดๆ ก็ได้ตามที่ผู้ใช้กรอกเข้ามาโดยไม่ต้องกังวลเรื่องขนาดในช่วงคอมไพล์ คุณสามารถเขียนสร้างออบเจกต์ชนิด String ขึ้นมาจากตัวคงที่ข้อความผ่านการเรียกฟังก์ชัน from ได้ดังนี้:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

สัญลักษณ์เครื่องหมายโคลอนคู่ :: ทำหน้าที่ช่วยระบุจัดกลุ่มขอบเขตพื้นที่ชื่อ (namespace) ของฟังก์ชัน from นี้ให้อยู่ภายใต้สังกัดการทำงานของชนิดข้อมูล String แทนการตั้งชื่อเดี่ยวลอยๆ เช่น string_from เราจะได้มาเรียนรู้หลักไวยากรณ์ข้อกำหนดเหล่านี้ลึกขึ้นในบทที่ 5 หัวข้อ “เมธอด” และขยายความประเด็นโมดูลบริหารจัดการในบทที่ 7 หัวข้อ “พาธสำหรับอ้างอิงไอเทมในแผนผังโมดูล”

ข้อความอักขระประเภทนี้ สามารถ ปรับปรุงแก้ไขเนื้อความภายในได้ดังนี้:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

แล้วจุดแตกต่างแท้จริงของทั้งคู่คืออะไร? เพราะเหตุใดข้อมูลชนิด String ถึงปรับเปลี่ยนข้อมูลได้ แต่อักขระแบบ literals กลับปฏิเสธการแก้ไขข้อมูล? ความแตกต่างที่ว่านั้นเกิดมาจากความแตกต่างด้านการบริหารจัดการขอบเขตบนหน่วยความจำของข้อมูลทั้ง 2 ประเภท

หน่วยความจำและการจัดสรรจองพื้นที่ (Memory and Allocation)

ในกรณีของค่าคงที่ข้อความ (string literal) เราทราบเนื้อหาของอักขระแน่นอนอยู่แล้วตั้งแต่ขั้นตอนคอมไพล์ ส่งผลให้ข้อความนั้นถูกจัดแปลฝังลงไปในไบนารีไฟล์รันของโปรแกรมโดยตรง นี่เป็นเหตุผลสำคัญที่อธิบายว่าทำไมอักขระชนิด literals จึงรันทำงานได้รวดเร็วมากและไม่กินพลังงานประมวลผลเครื่อง แต่ข้อดีคุณลักษณะเด่นลักษณะนี้ได้มาจากการบังคับกฎห้ามปรับปรุงแก้ไขข้อมูลของ literals เท่านั้น น่าเสียดายที่เราไม่สามารถนำเอาตัวแปรข้อความต่างๆ ที่มีขนาดไม่ชัดเจนคงที่หรือความยาวข้อมูลสามารถเปลี่ยนแปลงได้ตลอดในตอนที่โปรแกรมกำลังรัน ไปเขียนจองฝังลงในไฟล์ไบนารีระบบลักษณะนั้นได้

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

  • จะต้องส่งคำร้องขอพื้นที่หน่วยความจำไปยัง memory allocator ในช่วงที่โปรแกรมรัน (runtime)
  • เราจำเป็นต้องมีกระบวนการคืนพื้นที่หน่วยความจำนี้กลับคืนไปให้ตัวระบบบริหารจัดการภายหลังจากสิ้นสุดการใช้งาน String ชิ้นนั้นแล้ว

เงื่อนไขข้อแรกนั้นเป็นขอบเขตงานฝั่งเรา: เมื่อเราเริ่มทำการเรียกใช้งานคำสั่ง String::from โครงสร้างการทำงานภายในของมันจะส่งคำร้องขอหน่วยความจำขนาดที่จำเป็นต้องใช้ให้แก่เราโดยอัตโนมัติ ซึ่งนี่เป็นเงื่อนไขพฤติกรรมระดับมาตรฐานสากลทั่วไปในภาษาเขียนโปรแกรมคอมพิวเตอร์เกือบทุกๆ ภาษา

แต่ทว่าในส่วนของข้อตกลงเรื่องเงื่อนไขที่สองจะมีกระบวนการทำงานที่แตกต่างออกไป ในระบบภาษาเขียนโปรแกรมที่มีระบบเก็บขยะอัตโนมัติ (garbage collector - GC) ตัวประมวลผล GC จะคอยดูแลเฝ้าติดตามคัดแยกและล้างหน่วยความจำหมดอายุให้แก่ผู้ใช้งานเองโดยเราไม่ต้องมาคอยคิดถึงเรื่องดังกล่าว แต่ในภาษาอื่นส่วนใหญ่ที่ไม่มีระบบ GC คอยดูแล เป็นความรับผิดชอบอย่างเป็นทางการของนักเขียนโค้ดที่จะต้องจำแนกจุดทำงานให้ได้ว่าหน่วยความจำบริเวณใดสิ้นสุดการใช้งานแล้ว และต้องเขียนระบุคำสั่งคืนพื้นที่หน่วยความจำ (free) คลายออกไปด้วยตนเองสอดคล้องกับพฤติกรรมตอนที่สั่งจอง ซึ่งการจัดการเรื่องนี้ให้สมบูรณ์ปลอดภัยถือเป็นปัญหาโลกแตกที่ทำให้นักพัฒนาโปรแกรมปวดหัวมาอย่างยาวนานในอดีต หากเราลืมใส่คำสั่งส่งคืนพื้นที่ จะเกิดภาวะหน่วยความจำรั่วไหล (memory leak) สิ้นเปลืองพื้นที่หน่วยความจำ หากเราสั่งคืนพื้นที่ไวเกินไป ตัวแปรจะเสียหายใช้งานต่อไม่ได้ และหากเราเผลอสั่งคืนพื้นที่ตำแหน่งเดิมซ้ำสองรอบ ก็จะทำให้โปรแกรมทำงานแครชเกิดบั๊กขึ้นทันที โดยนักเขียนโค้ดจำเป็นต้องประสานคู่การจอง (allocate) และคำสั่งคืนพื้นที่ (free) ให้ตรงคู่สอดรับกันอย่างพอดี 1 ต่อ 1 เสมอ

แต่ภาษา Rust เลือกเดินบนหนทางใหม่ที่แตกต่างออกไป: นั่นคือพื้นที่หน่วยความจำจองจะถูกจัดส่งคืนส่งคืนพิกัดกลับสู่ส่วนกลางโดยอัตโนมัติทันทีที่ตัวแปรผู้ครอบครองสิทธิ์หลุดออกนอกขอบเขตการทำงาน (out of scope) ต่อไปนี้เป็นตัวอย่างการดัดแปลงโปรแกรมขอบเขตจากตารางที่ 4-1 โดยหันมาใช้ชนิด String แทนอักขระสัญลักษณ์คงที่ literals:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

มีจุดเวลาที่เป็นธรรมชาติอยู่จุดหนึ่งที่ระบบสมควรทำการส่งคืนพื้นที่หน่วยความจำของ String ให้แก่ระบบตัวจัดสรร: นั่นคือตำแหน่งจังหวะที่ตัวแปร s เริ่มก้าวเดินพ้นหลุดนอกขอบเขตการทำงาน เมื่อใดก็ตามที่ตัวแปรเริ่มหลุดขอบเขต Rust จะเรียกใช้งานฟังก์ชันชนิดพิเศษตัวหนึ่งขึ้นมาทำงานแทนเราโดยอัตโนมัติ ฟังก์ชันพิเศษนี้มีชื่อเรียกว่า drop ซึ่งเป็นจุดที่คุณสมบัติเด่นของ String จะระบุขั้นตอนการทำงานสำหรับคืนหน่วยความจำเครื่องไว้ และ Rust จะทำการรัน drop นี้ให้อัตโนมัติทันทีตรงจุดสัญลักษณ์ปีกกาปิดขอบเขต

หมายเหตุ: ในภาษา C++ รูปแบบพฤติกรรมการปล่อยคืนพื้นที่ทรัพยากรระบบทันทีที่ตัวแปรสิ้นอายุขัยนี้ มักเรียกขานในกลุ่มวิชาการว่า Resource Acquisition Is Initialization (RAII) ฟังก์ชัน drop ของ Rust จะทำงานสอดรับและมีทิศทางทำนองเดียวกันกับหลักการรูปแบบ RAII นี้หากคุณเคยมีประสบการณ์ใช้งานมาก่อน

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

ปฏิสัมพันธ์ระหว่างตัวแปรกับชิ้นข้อมูลในรูปแบบการย้ายสิทธิ์ (Move)

ภายใต้ระบบภาษา Rust ตัวแปรหลายตัวสามารถระบุเชื่อมโยงเข้าหาชุดข้อมูลชิ้นเดียวกันได้หลากหลายแนวทาง รายการที่ 4-2 แสดงตัวอย่างพฤติกรรมนี้โดยประยุกต์ร่วมกับตัวแปรชนิดเลขจำนวนเต็ม

fn main() {
    let x = 5;
    let y = x;
}

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

คราวนี้สลับลองมาดูในเวอร์ชันที่เปลี่ยนมาใช้งานชนิด String แทนกันบ้าง:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

ลองมาตรวจสอบพิจารณาจากภาพแผนภาพที่ 4-1 เพื่อศึกษาทิศทางการเคลื่อนไหวของ String ภายใต้ระบบอย่างละเอียด ข้อมูลชนิด String จะถูกประกอบสร้างขึ้นจากชิ้นส่วนข้อมูล 3 องค์ประกอบหลัก (แสดงในตารางฝั่งซ้ายมือ) ได้แก่: ข้อมูลตัวชี้พิกัดหน่วยความจำ (pointer) ไปยังตำแหน่งเก็บเนื้อข้อความจริง, ขนาดความยาวข้อมูลข้อความปัจจุบัน (length) และความสามารถจัดเก็บของหน่วยความจำสูงสุด (capacity) โดยที่กลุ่มข้อมูลสารสนเทศแถบซ้ายนี้ทั้งหมดจะได้รับการบันทึกจัดเก็บจองพื้นที่ไว้บน stack และพื้นที่ฝั่งขวามือคือบริเวณของเนื้อที่บน heap ที่ใช้เก็บข้อมูลตัวอักษรเนื้อความข้อความจริง

Two tables: the first table contains the representation of s1 on the stack, consisting of its length (5), capacity (5), and a pointer to the first value in the second table. The second table contains the representation of the string data on the heap, byte by byte.

รูปภาพที่ 4-1: การแทนค่าในหน่วยความจำของ String ที่เก็บค่า "hello" ซึ่งผูกไว้กับ s1

ขนาดความยาว (length) บ่งบอกข้อมูลปริมาณการใช้สอยพื้นที่หน่วยความจำจริงคิดหน่วยวัดเป็นไบต์ของเนื้อความข้อความปัจจุบันที่ String หยิบใช้ ส่วนปริมาณจัดเก็บสูงสุด (capacity) บ่งชี้จำนวนพื้นที่หน่วยความจำรวมทั้งหมดเป็นไบต์ที่ตัว String ได้รับสิทธิ์ลงทะเบียนจองจาก allocator ข้อมูลความต่างระหว่าง length และ capacity จะมีความสำคัญในบางพฤติกรรมการรันโค้ด แต่มือสมัครเล่นในบทเรียนนี้ยังไม่ต้องกังวล สามารถเพิกเฉยข้ามไปก่อนได้

เมื่อเราเขียนป้อนประโยคสั่งผูกค่า s1 ไปให้แก่ s2 ข้อมูลตัวแปรหลักของ String จะถูกคัดลอกส่งผ่านไป ซึ่งหมายความว่าระบบจะคัดลอกเฉพาะข้อมูล pointer, ค่าความยาว length และค่าสูงสุด capacity ที่วางตัวจัดเก็บอยู่บน stack เท่านั้น ระบบจะ ไม่ได้ คัดลอกชิ้นงานข้อมูลอักขระข้อความจริงที่เก็บอยู่บน heap ไปด้วย กล่าวคือแผนผังข้อมูลบนหน่วยความจำจริงจะมีลักษณะตามรูปภาพที่ 4-2:

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap.

รูปภาพที่ 4-2: การแทนค่าในหน่วยความจำของตัวแปร s2 ที่ได้คัดลอก pointer, ความยาว และขนาดความจุสูงสุดของ s1 มาใช้งาน

รูปแบบแผนผังการแทนค่าหน่วยความจำนี้จะ ไม่ได้ ออกมาในรูปแบบที่เห็นในรูปภาพที่ 4-3 ซึ่งโครงสร้างรูปภาพนั้นจะเกิดขึ้นในกรณีที่ Rust เลือกที่จะโคลนคัดลอกชิ้นงานข้อมูลอักษรบน heap ซ้ำไปจัดสรรพื้นที่เพิ่มขึ้นมาอีกชุดหนึ่งด้วย หาก Rust ดำเนินการขั้นตอนคัดลอกลักษณะนั้น การสั่งคีย์ประโยค s2 = s1 จะก่อให้เกิดการหน่วงประสิทธิภาพทำงานรุนแรงมากในช่วงรันโปรแกรม (runtime) หากชิ้นงานข้อมูลข้อความบน heap มีขนาดความยาวที่ใหญ่มาก

Four tables: two tables representing the stack data for s1 and s2, and each points to its own copy of string data on the heap.

รูปภาพที่ 4-3: ความเป็นไปได้อีกประการหนึ่งของคำสั่ง s2 = s1 หากภาษา Rust เลือกที่จะสั่งคัดลอกข้อมูลอักขระใน heap ซ้ำขึ้นมาด้วย

ก่อนหน้านี้เราได้อธิบายไปแล้วว่าเมื่อตัวแปรหลุดขอบเขตงาน Rust จะเข้ามาจัดการเรียกใช้งานฟังก์ชัน drop คืนพื้นที่หน่วยความจำ heap ให้โปรแกรมเองโดยอัตโนมัติ แต่จากรูปภาพที่ 4-2 แสดงพิกัดให้เห็นว่าตัวชี้ pointer ของข้อมูลตัวแปรทั้งคู่กำลังระบุชี้ไปที่พิกัดช่องหน่วยความจำฮีปเดียวกันพอดิบพอดี ซึ่งพฤติกรรมนี้จะก่อเกิดปัญหาใหญ่ขึ้น: นั่นคือเมื่อทั้ง s2 และ s1 หลุดออกขอบเขตงานไปตามลำดับ ตัวแปรทั้งสองจะพยายามสั่งปลดปล่อยล้างคืนหน่วยความจำที่ช่องเดิมซ้ำซ้อนกัน ข้อผิดพลาดนี้เรียกว่า ข้อผิดพลาดการล้างหน่วยความจำซ้ำซ้อน (double free error) ซึ่งเป็นหนึ่งในช่องโหว่ความปลอดภัยของหน่วยความจำ (memory safety) ร้ายแรงที่เราพูดถึงก่อนหน้านี้ การคืนพิกัดหน่วยความจำเดิมซ้ำสองรอบอาจส่งผลให้โปรแกรมเสียหายรุนแรง (memory corruption) และอาจเปิดทางสู่ภัยคุกคามสิทธิ์เข้าถึงระบบความปลอดภัยของระบบโปรแกรม

เพื่อปิดความเสี่ยงภัยประเด็นหน่วยความจำและรับประกันความปลอดภัยอย่างสูงสุด ภายหลังจากบรรทัดคำสั่ง let s2 = s1; ประมวลผลเสร็จ Rust จะทำประกาศประวัติรายงานทันทีว่าตัวแปร s1 มีสถานะ ถูกยกเลิกการใช้งานและใช้งานไม่ได้อีกต่อไป (no longer valid) ส่งผลให้คอมไพเลอร์ Rust ไม่มีความจำเป็นที่จะต้องสั่งล้างหน่วยความจำใดๆ อีกเลยเมื่อถึงรอบขอบเขตปิดตัวแปร s1 ลองมาสังเกตดูพฤติกรรมว่าจะเกิดอะไรขึ้นหากคุณยังคงฝืนฝอยเขียนสั่งใช้งานตัวแปร s1 ภายหลังจากที่ตั้งตัวแปร s2 ไปแล้ว ซึ่งจะพบว่าคอมไพเลอร์ปฏิเสธรันงานคอมไพล์ผ่าน:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

คุณจะได้รับข้อผิดพลาดแจ้งเตือนลักษณะนี้รายงานขึ้นมา เนื่องจาก Rust จะคอยสกัดดักจับไม่ให้คุณหยิบใช้ตัวแปรอ้างอิงที่ถูกยกเลิกการใช้งานไปแล้ว:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

หากคุณเคยได้ยินคำศัพท์คำว่า การคัดลอกแบบตื้น (shallow copy) และ การคัดลอกแบบลึก (deep copy) จากการเรียนรู้ภาษาเขียนโปรแกรมอื่นมาก่อน พฤติกรรมการคัดลอกสติกข้อมูล pointer, length และ capacity จากสแตกโดยหลีกเลี่ยงการก๊อปปี้เนื้อความใน heap อาจจะฟังดูคล้ายคลึงกับการทำ shallow copy ทั่วไปมาก แต่เนื่องจาก Rust เลือกที่จะจัดการยับยั้งสิทธิ์และยกเลิกสถานะของตัวแปรต้นขั้วแรกทิ้งไปด้วยเพื่อความปลอดภัย พฤติกรรมการทำงานลักษณะนี้ในภาษา Rust จึงไม่เรียกว่า shallow copy แต่จะนิยามว่าเป็นการกระทำแบบ การย้ายสิทธิ์ (move) ในกรณีตัวอย่างนี้เราจะกล่าวว่า s1 ได้ถูก ย้ายสิทธิ์ (moved) ไปบรรจุอยู่ในตัวแปร s2 เรียบร้อยแล้ว ซึ่งทิศทางการทำงานแผนผังข้อมูลบนหน่วยความจำจริงแสดงให้ดูในรูปภาพที่ 4-4:

Three tables: tables s1 and s2 representing those strings on the stack, respectively, and both pointing to the same string data on the heap. Table s1 is grayed out because s1 is no longer valid; only s2 can be used to access the heap data.

รูปภาพที่ 4-4: การแทนค่าในหน่วยความจำภายหลังตัวแปร s1 ถูกยกเลิกความสมบูรณ์สิทธิ์การใช้งาน

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

ยิ่งไปกว่านั้น การตัดสินใจสเปกเงื่อนไขการทำงานในจุดนี้ได้ซ่อนนัยยะของการออกแบบภาษา Rust ไว้ประการหนึ่งคือ: Rust จะไม่มีวันสั่งสร้าง “การคัดลอกแบบลึก (deep copy)” ให้แก่ข้อมูลของคุณโดยอัตโนมัติอย่างเด็ดขาด ส่งผลให้ขั้นตอนกระบวนการคัดลอกข้อมูลโดยอัตโนมัติ (automatic copying) ใดๆ ในระบบของ Rust จะทำงานได้ว่องไวและประหยัดกำลังเครื่องเป็นที่มั่นใจได้เสมอ

ขอบเขตการทำงานและการมอบค่าตัวแปร

ทิศทางกลับกันของเงื่อนไขนี้จะมีผลไปถึงความสัมพันธ์ระหว่างขอบเขตงาน, สิทธิ์การครอบครอง ตลอดจนสิทธิ์การปลดปล่อยหน่วยความจำด้วยการรันฟังก์ชัน drop เช่นกัน เมื่อใดก็ตามที่คุณเขียนโค้ดสั่งกำหนดค่าข้อมูลชุดใหม่ถอดด้ามเข้าไปบันทึกทับในตัวแปรที่มีการตั้งค่าสิทธิ์ครอบครองจองพื้นที่ไว้ก่อนหน้า ระบบ Rust จะดำเนินการเรียกใช้ฟังก์ชัน drop ทันทีเพื่อเก็บกวาดล้างพื้นที่หน่วยความจำของมูลค่าชิ้นเดิมบน heap ออกไปทันที ลองพิจารณาตัวอย่างโค้ดนี้ประกอบกัน:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

เริ่มต้นโปรแกรมเราประกาศตั้งตัวแปร s พร้อมผูกข้อมูลเข้ากับชนิด String มูลค่า "hello" หลังจากนั้นเราป้อนรหัสสร้างข้อความชุดใหม่มูลค่า "ahoy" มาบันทึกกำหนดทับลงตัวแปร s ทันที ณ จุดนี้ส่งผลให้ข้อมูลชุดดั้งเดิม "hello" บน heap ตกอยู่ในสภาวะไม่มีตัวแปร pointer ชิ้นใดระบุชี้ไปหาอีกต่อไป แผนผังการจัดสรรสิทธิ์แสดงได้ตามรูปภาพที่ 4-5:

One table representing the string value on the stack, pointing to the second piece of string data (ahoy) on the heap, with the original string data (hello) grayed out because it cannot be accessed anymore.

รูปภาพที่ 4-5: การแทนค่าในหน่วยความจำภายหลังค่าเริ่มต้นชุดแรกโดนระบุป้อนทับเขียนแทนที่แบบสมบูรณ์

อักขระตัวข้อความเริ่มแรกจึงนับเป็นหลุดขอบเขตการทำงานโดยปริยาย Rust จะดำเนินการเรียกใช้คำสั่งทำงานของฟังก์ชัน drop บนพื้นที่ช่องหน่วยความจำดั้งเดิมนั้นเพื่อเคลียร์พื้นที่คืนทันที และในบรรทัดสุดท้ายของการแสดงข้อความ ผลลัพธ์ข้อมูลที่พิมพ์ปรากฏคือคำว่า "ahoy, world!"

ปฏิสัมพันธ์ระหว่างตัวแปรกับชิ้นข้อมูลในรูปแบบการโคลนคัดลอก (Clone)

หากเรา มีความประสงค์ ต้องการคัดลอกลอกโครงสร้างข้อมูลอักขระที่อยู่บน heap ทั้งย่านแบบลึกซึ้ง (deep copy) โดยไม่ได้ต้องการคัดลอกแค่ข้อมูล pointer เล็กๆ บน stack คุณสามารถเลือกสั่งการผ่านเมธอดมาตรฐานทั่วไปที่ระบบเตรียมไว้ให้ชื่อว่า clone เราจะได้พูดคุยเกี่ยวกับไวยากรณ์โครงสร้างเมธอดอย่างเป็นระบบในบทที่ 5 แต่เนื่องจากเมธอดจัดเป็นโครงสร้างที่พบบ่อยทั่วไปในหลายภาษาโปรแกรม คุณน่าจะเคยเข้าใจการทำงานพฤติกรรมนี้มาก่อน

ตัวอย่างการเรียกใช้งานเมธอด clone มีพฤติกรรมดังนี้:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

โค้ดส่วนนี้จะประมวลผลงานผ่านฉลุย และแสดงพฤติกรรมการทำงานลอกเลียนตรงตามรูปแบบที่เราวาดให้เห็นในรูปภาพที่ 4-3 ทุกประการ นั่นคือข้อมูลเนื้อหาอักขระใน heap จะถูกทำจัดทำสำเนาคัดลอกจองพื้นที่ใหม่เพิ่มขึ้นมาคู่กันจริงๆ

ดังนั้นเมื่อใดก็ตามที่คุณสะดุดสายตาพบการเขียนรหัสระบุเรียกใช้เมธอด clone คุณจะรับรู้ได้โดยปริยายทันทีว่าโปรแกรมกำลังสั่งรันประมวลผลส่วนโค้ดเฉพาะตัวที่อาจจะทำให้เครื่องทำงานประมวลผลหนักขึ้นเล็กน้อย (expensive) ถือเป็นป้ายสะกิดแจ้งเตือนสายตาของนักพัฒนาให้รับรู้สถานการณ์ได้ชัดเจน

ชิ้นข้อมูลประเภทจำกัดตัวเฉพาะสแตก: การทำก๊อปปี้ (Copy)

ยังมีรายละเอียดจุดเล็กๆ อีกหนึ่งหัวข้อที่เรายังไม่หยิบยกขึ้นมาร่วมสนทนา โค้ดประกาศกำหนดตัวแปรเลขจำนวนเต็มชุดนี้ (ซึ่งส่วนหนึ่งเคยแสดงไว้ในรายการที่ 4-2) สามารถรันทำงานได้ปกติและมีรูปแบบที่ถูกต้องสมบูรณ์:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

แต่พฤติกรรมโค้ดส่วนนี้กลับดูขัดแย้งกับหลักการครอบครองสิทธิ์ที่เราเพิ่งทำความเข้าใจไปครู่ใหญ่: ในแถวคำสั่งเราไม่มีการป้อนระบุสั่งใช้เมธอด clone เลย ทว่าตัวแปร x กลับยังคงแสดงสถานะใช้งานได้อย่างถูกต้องสมบูรณ์และสิทธิ์ข้อมูลก็ไม่ได้ถูกสั่งให้ย้ายพ้นไปเก็บไว้ที่ตัวแปร y

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

ภาษา Rust มีกลุ่มความสามารถคุณสมบัติกำกับชนิดข้อมูลพิเศษเรียกว่า Copy เทรต (trait) ซึ่งเราจะสามารถใส่คุณสมบัตินี้พ่วงให้แก่ข้อมูลประเภทย่อยต่างๆ ที่มีคุณลักษณะบันทึกจัดเก็บเฉพาะบน stack เช่นกลุ่มประเภทตัวเลขจำนวนเต็ม (เราจะมาทำความเข้าใจเกี่ยวกับเรื่องเทรต trait ลึกซึ้งยิ่งขึ้นใน บทที่ 10) หากชนิดประเภทใดๆ มีการประกาศความสามารถของ Copy เทรตไว้ ตัวแปรต่างๆ ที่เรียกใช้งานข้อมูลประเภทนั้นจะไม่มีวันถูกประมวลผลแบบย้ายสิทธิ์ (move) แต่ระบบจะทำการก็อปปี้เนื้อตัวเลขไปจองพื้นที่ใหม่ทันที และยังรักษาคงสถานะให้ตัวแปรเดิมยังสมบูรณ์ใช้งานต่อได้แม้อยู่ถัดจากขั้นตอนสั่งผูกข้อมูลมอบให้ตัวแปรอื่น

ทั้งนี้คอมไพเลอร์ Rust จะไม่อนุญาตให้ประกาศแนบตราคุณสมบัติ Copy ให้แก่ชนิดประเภทใดๆ ที่มีการประกาศพฤติกรรมของ Drop เทรตไว้ข้างใน หรือมีองค์ประกอบย่อยส่วนใดส่วนหนึ่งที่เรียกใช้งานสิทธิ์คืนหน่วยความจำ Drop หากประเภทชนิดดังกล่าวมีพฤติกรรมเฉพาะถิ่นที่ต้องคืนหน่วยความจำเมื่อหลุดออกนอกขอบเขตการทำงาน แล้วเราพยายามจะไปบังคับยัดตราประทับ Copy พ่วงให้คอมไพเลอร์ Rust จะส่งคืนข้อผิดพลาดช่วงคอมไพล์ทันที สามารถศึกษาวิธีเขียนระบุตรากำกับ Copy พ่วงให้แก่ประเภทข้อมูลของคุณใน “คุณลักษณะเทรตที่อนุมานสร้างขึ้นเองได้ (Derivable Traits)” ในภาคผนวก C

แล้วมีชนิดข้อมูลประเภทใดบ้างที่ได้รับตราประทับคุณสมบัติ Copy เทรตนี้พ่วงมาด้วยตั้งแต่เริ่มแรก? คุณสามารถตรวจสอบประวัติชนิดนั้นๆ ได้ในเอกสารคู่มือของภาษาได้โดยตรงเพื่อความแม่นยำ แต่โดยเกณฑ์มาตรฐานทั่วไปแล้ว ชนิดประเภทข้อมูลเดี่ยวสเกลาร์ (scalar values) อย่างง่ายทั้งหมดจะได้รับสิทธิ์แนบคุณสมบัติ Copy และข้อมูลส่วนใดๆ ก็ตามที่จำเป็นต้องร้องขอพื้นที่จองหน่วยความจำ heap หรือทำหน้าที่เป็นทรัพยากรหลักภายนอกระบบจะปฏิเสธการสวมสิทธิ์ Copy ตัวอย่างชนิดข้อมูลทั่วไปที่เป็นกลุ่ม Copy ได้แก่:

  • ชนิดตัวเลขจำนวนเต็มทั้งหมด เช่น u32
  • ชนิดข้อมูลค่าความจริง bool ซึ่งถือครองค่า true และ false
  • ชนิดข้อมูลเลขทศนิยมทั้งหมด เช่น f64
  • ชนิดข้อมูลอักขระตัวอักษร char
  • กลุ่มทูเพิล (Tuples) หากสมาชิกทุกๆ ตัวภายในทูเพิลนั้นมีชนิดข้อมูลที่อยู่ในกลุ่ม Copy ด้วยเช่นกัน เช่น ทูเพิลสเปก (i32, i32) จะมีสถานะเป็น Copy แต่ทูเพิลขนาด (i32, String) จะไม่ได้สิทธิ์ความเป็น Copy

สิทธิ์การครอบครองหน่วยความจำและฟังก์ชัน (Ownership and Functions)

กลไกพฤติกรรมในการส่งผ่านตัวแปรข้อมูลเข้าไปในฟังก์ชันจะมีพฤติกรรมและความสอดคล้องกับพฤติกรรมตอนป้อนกำหนดระบุค่าตัวแปร การส่งมอบตัวแปรให้แก่ฟังก์ชันจะทำให้เกิดพฤติกรรมย้ายสิทธิ์ (move) หรือทำสำเนาคัดลอก (copy) ขึ้นกับตราประทับลักษณะของชนิดตัวแปรต้นทาง รายการที่ 4-3 แสดงตัวอย่างซอร์สโค้ดพร้อมคำอธิบายกำกับประวัติขอบเขตการทำงานและการหลุดขอบเขตงานของตัวแปร:

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

หากคุณพยายามที่จะเขียนดึงใช้งานตัวแปร s อีกครั้งในบรรทัดด้านล่างที่อยู่ถัดจากการเรียกฟังก์ชัน takes_ownership คอมไพเลอร์ Rust จะรายงานความล้มเหลวไม่อนุญาตให้รันโค้ดผ่านได้ทันที ด่านกรองตรวจสอบสิทธิ์ (static checks) คอยคุ้มครองคุณจากความพลั้งพลาด ให้ลองเสริมโค้ดทดสอบลงในขอบเขตฟังก์ชัน main โดยเขียนเรียกใช้งานตัวแปร s และ x เพื่อร่วมตรวจสอบศึกษาขอบเขตจุดที่คุณสามารถดึงเรียกพวกมันไปประมวลผลได้จริง และพิกัดตำแหน่งใดที่กฎสิทธิ์การครอบครองหน่วยความจำเริ่มเข้ามาสกัดกั้นไม่ให้ใช้

ค่าผลลัพธ์ขากลับและขอบเขตการทำงาน (Return Values and Scope)

ขั้นตอนการส่งคืนค่าขากลับของฟังก์ชัน (returning values) ก็มีผลต่อการส่งมอบถ่ายโอนสิทธิ์การครอบครองหน่วยความจำ (transfer ownership) เช่นกัน รายการที่ 4-4 แสดงตัวอย่างโครงสร้างฟังก์ชันขากลับ พร้อมคู่มือคำอธิบายประวัติสิทธิ์และขอบเขตคล้ายตัวอย่างก่อนหน้า:

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

ทิศทางสิทธิ์การครอบครองของตัวแปรจะดำเนินพฤติกรรมในรูปแบบที่แน่นอนเสมอในทุกๆ ครั้ง: การผูกกำหนดค่าตัวแปรเดิมให้แก่ตัวแปรอื่นจะมีพฤติกรรมเป็นการย้ายสิทธิ์การครอบครอง (move) และเมื่อตัวแปรที่มีการจองจดพื้นที่ข้อมูลบน heap หลุดออกขอบเขตงาน ข้อมูลอักขระชิ้นนั้นจะถูกเดินเก็บล้างคืนหน่วยความจำโดยคำสั่งของฟังก์ชัน drop ทันที เว้นแต่ว่าสิทธิ์ครอบครองชิ้นนั้นจะถูกสั่งโอนย้ายไปเป็นของตัวแปรใหม่สำเร็จเสียก่อน

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

ภาษา Rust อนุญาตให้เราส่งคืนผลลัพธ์ขากลับได้หลากหลายตัวพร้อมๆ กันโดยการรวมรายการจัดเก็บมาในทูเพิล ดังแสดงตัวอย่างในรายการที่ 4-5:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

แต่วิธีการนี้จะแลกมาด้วยพิธีรีตองการเขียนโค้ดที่รกรุงรังเกินความจำเป็นและเป็นภาระงานจดจำประวัติตัวแปรสำหรับแนวคิดมาตรฐานทั่วไปเช่นนี้ โชคดีมากที่ Rust ได้ตระเตรียมฟีเจอร์เด่นประเภทเฉพาะเจาะจงมาเพื่อตอบโจทย์พฤติกรรมนี้โดยตรง นั่นคือความสามารถหยิบใช้ชิ้นงานข้อมูลได้โดยปราศจากส่งโอนสิทธิ์ครอบครอง เรียกว่า: การอ้างอิงสิทธิ์ (references)

References and Borrowing

การอ้างอิงและการยืมใช้ข้อมูล (References and Borrowing)

ประเด็นปัญหาของโค้ดส่งคืนแบบทูเพิลในรายการที่ 4-5 ก็คือ เราจำเป็นต้องส่งคืนค่า String ย้อนกลับมาให้ฟังก์ชันผู้เรียกใช้ เพื่อที่จะให้ตัวแปร String นั้นยังสามารถดึงไปใช้งานต่อได้หลังจากสิ้นสุดขั้นตอนการสั่งรันฟังก์ชัน calculate_length เนื่องจากก่อนหน้านี้ตัว String ได้ถูกสั่งย้ายสิทธิ์ (moved) เข้าไปยัง calculate_length เรียบร้อยแล้ว เพื่อเป็นทางเลือกในการจัดการ เราสามารถเปลี่ยนมาส่งผ่านค่าเป็น การอ้างอิง (reference) ไปยังข้อมูล String แทนได้ โดยที่ตัวอ้างอิงจะมีลักษณะพฤติกรรมเสมือนเป็นตัวชี้พิกัด pointer กล่าวคือทำหน้าที่เป็นข้อมูลระบุที่อยู่ของหน่วยความจำที่เราสามารถสะกดรอยตามเพื่อเข้าไปอ่านข้อมูลที่บันทึกอยู่ภายในช่องพิกัดที่อยู่ดังกล่าว โดยที่เนื้อข้อมูลชิ้นนั้นจะถูกถือครองสิทธิ์โดยตัวแปรรายอื่นอยู่ และข้อเด่นประการสำคัญที่เหนือกว่าตัวชี้ pointer คือ การอ้างอิงจะได้รับความคุ้มครองและรับประกันอย่างเข้มงวดว่าจะต้องระบุชี้ไปยังชิ้นข้อมูลที่มีมูลค่าถูกต้องสมบูรณ์และตรงชนิดข้อมูลประเภทนั้นๆ เสมอตลอดอายุขัยการทำงานของการอ้างอิงตัวนั้น

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

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

ข้อสังเกตประการแรกคือ ชุดโค้ดจัดการกระจายตัวแปรทูเพิลและค่าชนิดข้อมูลขากลับทูเพิลของฟังก์ชันจะหายไปทั้งหมด และประการที่สองคือ พึงสังเกตว่าเราได้เขียนระบุสั่งส่งค่าเป็น &s1 ป้อนเข้าไปในการทำงานของฟังก์ชัน calculate_length และในส่วนของนิยามฟังก์ชันปลายทาง เราเลือกที่จะยอมรับชนิดข้อมูลแบบการอ้างอิงข้อความ &String แทนการรับข้อมูลเต็มรูป String โดยสัญลักษณ์เครื่องหมายแอมเพอร์แซนด์ (&) นี้ทำหน้าที่เป็นตัวแทนสำหรับการอ้างอิงสิทธิ์ ซึ่งจะอนุญาตให้เราดึงชิ้นข้อมูลไปเชื่อมโยงใช้งานต่อได้โดยปราศจากการถ่ายโอนสิทธิ์การครอบครองหน่วยความจำ แผนภาพที่ 4-6 แสดงให้เห็นถึงแนวคิดพฤติกรรมนี้:

Three tables: the table for s contains only a pointer to the table for s1. The table for s1 contains the stack data for s1 and points to the string data on the heap.

รูปภาพที่ 4-6: แผนภาพแสดงข้อมูลตัวแปร &String s กำลังระบุอ้างอิงชี้ไปยัง String s1

หมายเหตุ: ทิศทางกลับกันของการอ้างอิงสิทธิ์ด้วยการเรียกใช้เครื่องหมาย & คือ การถอดการอ้างอิง (dereferencing) ซึ่งจะสามารถสั่งการได้โดยเรียกใช้ตัวดำเนินการถอดรหัสอ้างอิงสัญลักษณ์เครื่องหมายดอกจัน * เราจะได้เห็นตัวอย่างการเรียกใช้งานตัวดำเนินการนี้ในบทที่ 8 และพูดคุยรายละเอียดเชิงลึกเรื่องการถอดรหัสอ้างอิงในบทที่ 15

ลองมาวิเคราะห์ขั้นตอนการสั่งเรียกใช้ฟังก์ชันในจุดนี้อย่างใกล้ชิด:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

ไวยากรณ์สัญลักษณ์ &s1 ช่วยให้เราสร้างตัวแปรอ้างอิงเพื่อ ระบุชี้ ไปยังค่าข้อมูลของตัวแปร s1 โดยไม่ได้ครอบครองสิทธิ์ความเป็นเจ้าของหน่วยความจำของข้อมูลนั้น และเนื่องจากตัวแปรอ้างอิงนี้ไม่ได้ถือครองสิทธิ์ครอบครอง ชิ้นข้อมูลปลายทางที่มันชี้ระบุไปหาจึงไม่มีวันโดนสั่งทำลายคืนหน่วยความจำทิ้ง (dropped) เมื่อสิ้นสุดอายุขัยขอบเขตการทำงานของตัวแปรอ้างอิง

ทำนองเดียวกัน ในส่วนของลายเซ็นนิยามฟังก์ชัน เราเติมเครื่องหมาย & พ่วงเข้าไปเพื่อช่วยประกาศเตือนให้ระบบรับทราบว่าพารามิเตอร์ s นี้ยอมรับชนิดข้อมูลในรูปการอ้างอิงสิทธิ์ มาลองเขียนคำอธิบายโค้ดกำกับเพิ่มเติมกัน:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

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

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

แล้วจะเกิดอะไรขึ้นหากเราพยายามที่จะเขียนโค้ดสั่งแก้ไขข้อมูลของทรัพยากรที่เรายืมมาใช้งาน? ให้ลองศึกษาโค้ดตัวอย่างในรายการที่ 4-6 และขอบอกใบ้ผลลัพธ์ล่วงหน้า: มันไม่ทำงานอย่างแน่นอน!

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

นี่คือข้อความแสดงข้อผิดพลาด:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so it cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

เฉกเช่นเดียวกับลักษณะนิสัยตัวแปรปกติทั่วไปที่จะแก้ไขค่าไม่ได้เป็นค่าเริ่มต้น (immutable by default) ระบบตัวแปรอ้างอิงก็รับข้อตกลงพฤติกรรมนี้มาใช้เช่นเดียวกัน เราจะไม่ได้รับอนุญาตให้ปรับปรุงแก้ไขมูลค่าใดๆ ของชิ้นงานผ่านตัวแปรอ้างอิงปรกติตัวนี้

การอ้างอิงแบบที่แก้ไขค่าได้ (Mutable References)

เราสามารถปรับปรุงแก้ไขข้อผิดพลาดของโค้ดในรายการที่ 4-6 เพื่ออนุญาตให้เราเขียนคำสั่งปรับแก้ค่าตัวแปรที่เรายืมใช้ข้อมูลได้สำเร็จผ่านการเปลี่ยนรูปมาใช้งาน การอ้างอิงแบบที่แก้ไขค่าได้ (mutable reference) ดังนี้:

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

อันดับแรก เราสั่งปรับแก้ตัวแปร s ให้เป็นแบบ mut เสียก่อน จากนั้นเราเขียนสร้างการอ้างอิงแบบแก้ไขค่าได้ระบุเป็น &mut s ตอนสั่งป้อนเข้าสู่ฟังก์ชัน change และปรับแก้รายละเอียดในลายเซ็นข้อกำหนดฟังก์ชันให้ยินยอมพร้อมใจที่จะรับอาร์กิวเมนต์เป็นประเภทการอ้างอิงแบบแก้ไขค่าได้อิงตามรูปแบบ some_string: &mut String ขั้นตอนเหล่านี้ช่วยบ่งบอกเจตนาให้แก่ผู้ดูแลโค้ดอย่างชัดเจนว่า ฟังก์ชัน change จะมีกระบวนการเข้าไปปรับแก้ค่าข้อมูลของตัวแปรที่ยืมมาใช้งาน

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

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{r1}, {r2}");
}

นี่คือข้อความแสดงข้อผิดพลาด:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |                -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

ข้อความแจ้งรายงานข้อผิดพลาดชี้ว่าประโยคโค้ดนี้ไม่สามารถประมวลผลได้เนื่องจากเรากระทำละเมิดกฎการยืมใช้ข้อมูล s ในรูปแบบแก้ไขค่าได้ (mutable borrow) ซ้ำซ้อนมากกว่าหนึ่งรายไปพร้อมๆ กัน โดยสิทธิ์การยืมใช้แก้ไขแบบแรกประกาศไว้ในตัวแปร r1 ซึ่งจะมีผลครอบคลุมขอบเขตงานต่อเนื่องไปจนกว่าจะสั่งรันประมวลผลใช้งานบรรทัดมาโคร println! แต่ทว่าในช่วงระยะเวลาระหว่างจัดตั้งตัวแปรอ้างอิงนั้นกับการหยิบใช้จริง เรากลับไปพยายามแทรกสร้างตัวแปรอ้างอิงแก้ไขตัวใหม่ขึ้นมาจัดเก็บไว้ใน r2 เพื่อขอยืมใช้งานเนื้อข้อมูลตัวเดียวกับของ r1

ข้อกำหนดการจำกัดการเรียกใช้งานตัวแปรอ้างอิงแบบแก้ไขค่าได้หลายรายพร้อมๆ กันนี้ แม้จะดูเป็นอุปสรรคต่อการปรับข้อมูลตามใจชอบ แต่มันเป็นการควบคุมดูแลเพื่อความเป็นระเบียบและปลอดภัยขั้นสูงสุด เป็นกฎสำคัญข้อหนึ่งที่ชาว Rustacean มือใหม่มักจะรู้สึกขัดใจในช่วงแรกเริ่มเนื่องจากในระบบภาษาเขียนโปรแกรมคอมพิวเตอร์อื่นส่วนใหญ่จะยินยอมให้คุณสลับไปอัปเดตข้อมูลได้ทุกเมื่อที่ปรารถนา ประโยชน์สำคัญของการบังคับตั้งด่านกรองข้อจำกัดนี้คือ ภาษา Rust สามารถสกัดกั้นดักจับปัญหาภาวะช่วงชิงข้อมูลอัปเดต (data races) ได้สำเร็จตั้งแต่ขั้นตอนคอมไพล์ โดยปัญหา ภาวะช่วงชิงข้อมูล (data race) จะมีทิศทางสอดคล้องทำนองเดียวกับภาวะแย่งชิงทรัพยากร (race condition) และจะเกิดขึ้นเมื่อมีลักษณะเงื่อนไขพฤติกรรม 3 ประการนี้อุบัติขึ้นพร้อมกัน:

  • มีตัวชี้พิกัด pointer ตั้งแต่ 2 ตัวขึ้นไปพยายามตรงเข้าถึงชิ้นข้อมูลตัวเดียวกันในขณะเวลาเดียวกัน
  • มีตัวชี้พิกัด pointer อย่างน้อย 1 รายกำลังดำเนินการเขียนข้อมูลทับ (write) ลงไปในชิ้นข้อมูลนั้น
  • ปราศจากการระบุใช้กลไกพิเศษเพื่อคอยเข้ามาช่วยจัดระเบียบจัดลำดับเวลาในการเข้าถึงข้อมูล (synchronization)

ภาวะช่วงชิงข้อมูลมักนำไปสู่ผลลัพธ์พฤติกรรมการทำงานโปรแกรมที่ไม่พึงประสงค์ระบุค่าแน่ชัดไม่ได้ (undefined behavior) และบ่อยครั้งเป็นเรื่องยากที่จะวิเคราะห์และหาทางแก้ไขปัญหาเมื่อต้องสืบหาเหตุการณ์ในตอนโปรแกรมรัน (runtime) Rust ปิดความเสี่ยงไฟนี้ถาวรโดยการปฏิเสธการคอมไพล์โค้ดใดๆ ก็ตามที่ตรวจพบแนวโน้มเงื่อนไขที่จะเกิด data races!

และเฉกเช่นเดิม เราสามารถเลือกใส่ปีกกาครอบย่อเพื่อสร้างขอบเขตงานย่อยใหม่ขึ้นมา ซึ่งช่วยให้เราตั้งตัวแปรอ้างอิงแบบแก้ไขค่าได้หลายตัวยืมใช้สิทธิ์ข้อมูลชิ้นเดียวกันได้ ขอเพียงแต่อย่าให้ช่วงระยะขอบเขตงานของพวกมันมีลักษณะ ทับซ้อนทับเวลาเดียวกัน (simultaneous) เท่านั้นก็เพียงพอ:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

ภาษา Rust ยังบังคับใช้กฎที่คล้ายคลึงกันสำหรับการเขียนใช้งานผสมผสานระหว่างตัวแปรอ้างอิงแบบแก้ไขค่าได้ (mutable) และแก้ไขค่าไม่ได้ (immutable) โดยโค้ดตัวอย่างด้านล่างนี้จะเกิดข้อผิดพลาดรันไม่ผ่านคอมไพล์:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{r1}, {r2}, and {r3}");
}

นี่คือข้อความแสดงข้อผิดพลาด:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

เย้! สรุปใจความคือ เรา ยัง ไม่สามารถสร้างตัวแปรอ้างอิงแบบแก้ไขค่าได้ (mutable reference) ขึ้นมาใช้งานได้เลยในช่วงเวลาที่เรายังมีตัวแปรอ้างอิงแก้ไขค่าไม่ได้ (immutable reference) โยงคาถือครองสิทธิ์ข้อมูลชิ้นเดียวกันนั้นอยู่

เนื่องจากผู้ใช้งานตัวแปรอ้างอิงแบบแก้ไขค่าไม่ได้ ย่อมคาดหวังว่าข้อมูลเป้าหมายจะต้องคงตัวที่ค่าเดิมตลอดโดยไม่มีทางผันแปรหรือถูกลักลอบปรับปรุงค่าไปกะทันหันในระหว่างประมวลผลงานของตน! แต่อย่างไรก็ตาม การสวมประกาศตัวแปรอ้างอิงแบบแก้ไขไม่ได้พร้อมกันหลายๆ ราย (multiple immutable references) จะได้รับการยินยอมให้ผ่านตามกติกา เนื่องจากทุกๆ คนทำเพียงแค่อ่านเนื้อความข้อมูลเฉยๆ (reading) จึงไม่มีผู้ใดส่งผลเสียหรือทำลายสิทธิ์เข้าถึงการอ่านข้อมูลของนักพัฒนาคนอื่น

พึงสังเกตว่าขอบเขตการทำงานของตัวแปรอ้างอิงจะเริ่มต้นนับจากจุดบรรทัดที่มันประกาศตัวขึ้น และคงประสิทธิภาพใช้งานไปเรื่อยๆ จนถึงจังหวะเวลาแถวคำสั่งสุดท้ายที่มีการหยิบใช้งานตัวแปรอ้างอิงตัวนั้นจริงๆ ยกตัวอย่างเช่น ซอร์สโค้ดนี้จะผ่านขั้นตอนคอมไพล์ได้ฉลุย เนื่องจากลำดับการเรียกใช้งานขั้นสุดท้ายของตัวแปรอ้างอิงแบบแก้ไขไม่ได้ประมวลผลจบลงแล้วที่บรรทัดคำสั่ง println! ก่อนหน้าจะเริ่มมีคำสั่งจัดสร้างประกาศตัวแปรอ้างอิงแบบแก้ไขค่าได้พ่วงเข้ามาด้านล่าง:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

ขอบเขตการทำงานของตัวแปรอ้างอิงแก้ไขไม่ได้ r1 และ r2 จะยุติอายุขัยลงหลังจากจบบรรทัดทำงาน println! ที่พวกมันถูกหยิบใช้งานเป็นรอบสุดท้าย ซึ่งถือเป็นพิกัดเวลาก่อนที่จะประมวลผลสร้างตัวแปรอ้างอิงแก้ไขค่าได้ r3 ขึ้นมา ขอบเขตงานของพวกมันจึงไม่ได้คาบเกี่ยวซ้อนทับกัน ตัวคอมไพเลอร์ Rust สามารถวิเคราะห์ได้ชัดแจ้งว่าตัวแปรอ้างอิงเหล่านั้นหมดสิ้นหน้าที่การใช้งานไปเรียบร้อยแล้วตั้งแต่ก่อนจะก้าวไปถึงขอบเขตวงเล็บปีกกาปิด

ถึงแม้ว่าสภาวะข้อผิดพลาดเกี่ยวกับการยืมใช้ข้อมูล (borrowing errors) นี้จะสร้างความขัดใจและหงุดหงิดใจให้คุณอยู่บ้างในบางครั้งคราว แต่โปรดตระหนักว่านี่คือขั้นตอนคุ้มครองความปลอดภัยที่คอมไพเลอร์ Rust ช่วยมาคอยสะกิดบอกกล่าวถึงสภาวะแนวโน้มจะเกิดบั๊กและรอยรั่วความปลอดภัยล่วงหน้าให้เราทราบทันที (ในขั้นตอนคอมไพล์ แทนการปล่อยไปเสียหายและแครชในขณะรันโปรแกรม) และยังชี้พิกัดปัญหานั้นอย่างเที่ยงตรง ช่วยลดเวลาที่คุณต้องไปเหนื่อยสืบหาเหตุการณ์ในภายหลังว่าเหตุใดข้อมูลประมวลผลถึงแสดงผลพฤติกรรมรายงานค่าผิดพลาด

ปัญหาการอ้างอิงลอยเคว้งชี้หาพื้นที่ว่างเปล่า (Dangling References)

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

ลองมาจงใจเขียนสร้างสภาวะการอ้างอิงลอยเคว้งขึ้นมาเพื่อร่วมศึกษาว่า Rust มีทิศทางดักจับปัญหานี้ด้วยข้อผิดพลาดในขั้นตอนคอมไพล์อย่างไร:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

นี่คือข้อความแสดงข้อผิดพลาด:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

ข้อความแจ้งเตือนข้อผิดพลาดนี้จะมีการเอ่ยอ้างอิงเชื่อมโยงไปถึงฟีเจอร์เด่นเรื่องหนึ่งที่เรายังไม่ได้หยิบยกมาศึกษา นั่นคือเรื่องอายุขัยช่วงเวลาครอบครอง (lifetimes) ซึ่งเราจะมาลุยศึกษากันอย่างเจาะลึกในบทที่ 10 แต่หากเราข้ามข้อมูลรายละเอียดเรื่อง lifetimes ส่วนนั้นไปก่อน ตัวใจความสำคัญของข้อผิดพลาดจะชี้บอกสาเหตุที่แท้จริงที่ทำให้โปรแกรมนี้มีปัญหาไว้ตรงประโยคว่า:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

ลองมาไล่สเต็ปวิเคราะห์เนื้อโค้ดฟังก์ชัน dangle ทีละแถวคำสั่งอย่างละเอียด:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped, so its memory goes away.
  // Danger!

เนื่องจากตัวแปร s ได้รับการประกาศจัดสร้างไว้ภายในขอบเขตงานเฉพาะถิ่นของฟังก์ชัน dangle ดังนั้นเมื่อกระบวนการทำงานของ dangle เสร็จสิ้นลง ตัวแปร s จะถูกฟังก์ชัน drop ทำลาย deallocate เคลียร์พื้นที่คืนทันที แต่ในตอนจบเรากลับพยายามจะเขียนคำสั่งโยนส่งการอ้างอิง &s ขากลับโยนออกมาภายนอก ซึ่งหมายความว่าตัวอ้างอิงขากลับนี้จะชี้เคว้งลอยระบุหาช่องพิกัดหน่วยความจำ String ที่โดนทลายล้างสลายไปแล้ว พฤติกรรมนี้ก่ออันตรายร้ายแรงแก่ระบบคอมพิวเตอร์อย่างมาก Rust จึงไม่ยอมให้ผ่านกติกาเด็ดขาด

หนทางแก้ไขปัญหาข้อจำกัดนี้ก็สามารถทำได้ง่ายๆ โดยเปลี่ยนมาส่งคืนข้อมูลอ็อบเจกต์เต็มรูปแบบชนิด String ย้อนกลับออกไปตรงๆ แทนดังนี้:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

วิธีการแก้ไขนี้จะทำงานได้อย่างปลอดภัยและคอมไพล์ผ่านฉลุย สิทธิ์การครอบครองหน่วยความจำจะได้รับการโอนย้ายย้ายสิทธิ์ออกไปภายนอกโปรแกรม และไม่มีชิ้นส่วนข้อมูลใดถูกล้างทำลายทิ้ง

กฎเหล็กอ้างอิงการใช้งานตัวแปรอ้างอิง (The Rules of References)

มาสรุปกติกาข้อตกลงเกี่ยวกับการอ้างอิงตัวแปรที่เราศึกษากันไปดังนี้:

  • ณ ช่วงเวลาขณะใดขณะหนึ่ง คุณจะสามารถเลือกจัดตั้งใช้งานได้ อย่างใดอย่างหนึ่ง ระหว่าง: การอ้างอิงแบบที่แก้ไขค่าได้ (mutable reference) เพียงรายเดียว หรือ การอ้างอิงแบบที่แก้ไขค่าไม่ได้ (immutable references) ปริมาณกี่ตัวก็ได้
  • ตัวแปรอ้างอิง (references) จะต้องชี้ระบุไปยังพิกัดข้อมูลที่ถูกต้องและมีตัวตนอยู่จริงเสมอ

และในหัวข้อต่อไป เราจะมาทำความเข้าใจเกี่ยวกับตัวแปรอ้างอิงอีกประเภทหนึ่งที่ใช้งานสะดวกและน่าสนใจมาก นั่นคือ: ส่วนแบ่งข้อมูล (slices) ครับ

The Slice Type

ชนิดข้อมูลส่วนแบ่งข้อมูล (The Slice Type)

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

ต่อไปนี้เป็นโจทย์ปัญหาการเขียนโปรแกรมขนาดเล็ก: ให้ทดลองเขียนฟังก์ชันดึงค่าคำตอบรับข้อมูลชนิดข้อความ (string) ของคำศัพท์ต่างๆ ที่แบ่งแยกกันด้วยสัญลักษณ์เว้นวรรค และส่งผลลัพธ์คืนค่าออกมาเป็นคำแรกสุดที่ระบบสแกนตรวจพบในข้อความนั้น หากภายในประโยคอักขระดังกล่าวไม่มีสัญลักษณ์เว้นวรรคปรากฏอยู่เลย แสดงว่าข้อความทั้งหมดนั้นนับเป็นคำเดียว ให้ระบบทำการส่งคืนข้อความเต็มทั้งหมดนั้นกลับออกมา

หมายเหตุ: เพื่อวัตถุประสงค์ในการแนะนำความรู้เรื่องส่วนแบ่งข้อมูล (slices) เบื้องต้น เราจะทึกทักสมมติให้ข้อความทั้งหมดรองรับเฉพาะมาตรฐานอักขระ ASCII เท่านั้นในหัวข้อนี้ คู่มือคำอธิบายการจัดการระบบคำเข้ารหัส UTF-8 อย่างลึกซึ้งจะได้รับการอธิบายในบทที่ 8 หัวข้อ “การจัดเก็บข้อความเข้ารหัส UTF-8 ด้วย Strings”

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

fn first_word(s: &String) -> ?

ฟังก์ชัน first_word จะมีพารามิเตอร์ชนิด &String ซึ่งเราไม่มีความจำเป็นต้องดึงสิทธิ์การครอบครองหน่วยความจำของข้อมูลนั้นเข้ามา จึงประกาศพารามิเตอร์รูปนี้ได้ปกติ (ตามแนวปฏิบัติทั่วไปของ Rust ฟังก์ชันต่างๆ จะหลีกเลี่ยงการยึดถือสิทธิ์ครอบครองของอาร์กิวเมนต์ที่ส่งเข้ามา ยกเว้นในกรณีที่มีความจำเป็นจริงๆ ซึ่งเหตุผลความปลอดภัยจะกระจ่างแจ้งยิ่งขึ้นเมื่อเราศึกษาต่อไป) แต่ประเด็นคือเราควรจะระบุสเปกขากลับเป็นชนิดข้อมูลใดดี? เนื่องจากเดิมเราปราศจากเครื่องมือระบุพิกัดย่าน ชิ้นส่วนย่อย (part) ของข้อความได้โดยสะดวก แต่อย่างไรก็ตามเราอาจใช้วิธีคืนค่ากลับมาเป็นตำแหน่งหมายเลขดัชนีจุดสิ้นสุดของคำคำนั้น (ระบุพิกัดโดยตรวจสอบจากเว้นวรรคช่องแรกสุดที่พบ) ลองมาประยุกต์ใช้วิธีการนี้ดูกันในตารางที่ 4-7:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

และเนื่องจากระบบจำเป็นต้องประมวลผลไล่แสกนดูชิ้นส่วนตัวแักษรภายใน String ทีละตำแหน่งเพื่อตรวจเช็คว่าจุดใดคือเว้นวรรค เราจึงสั่งแปลงร่างอ็อบเจกต์ String ให้ไปเป็นแถวอาร์เรย์ของข้อมูลชนิดไบต์ (array of bytes) ผ่านการเรียกใช้เมธอด as_bytes:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

หลังจากนั้น เราสร้างตัววนซ้ำประมวลผล (iterator) บนอาร์เรย์ของข้อมูลไบต์นั้นผ่านการเรียกใช้เมธอด iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

เราจะมาพูดคุยรายละเอียดลึกซึ้งเรื่องตัววนซ้ำประมวลผลนี้ใน บทที่ 13 สำหรับในตอนนี้ ให้จดจำเพียงว่า iter คือเมธอดที่คอยอำนวยความสะดวกส่งคืนสมาชิกแต่ละตัวของคอลเลกชันออกมาให้ และตัวเมธอด enumerate จะทำหน้าที่ครอบสรุปมูลค่าของ iter ดังกล่าวมาส่งกลับคืนมาในรูปกลุ่มข้อมูลทูเพิล (tuple) สองตำแหน่งแทน โดยสมาชิกตำแหน่งแรกสุดของทูเพิลที่ได้จาก enumerate คือหมายเลขลำดับดัชนี (index) และสมาชิกตำแหน่งที่สองคือข้อมูลตัวอ้างอิงชี้ไปยังอักขระตัวนั้น ซึ่งวิธีการนี้ช่วยเพิ่มความสะดวกแก่นักพัฒนามากกว่าการที่ต้องมาคอยเขียนสูตรคำนวณหมายเลขลำดับดัชนีขึ้นมาบวกเพิ่มเอง

เนื่องจากโครงสร้างขากลับของเมธอด enumerate รายงานผลเป็นทูเพิล เราจึงสามารถเลือกประยุกต์ใช้งานนิพจน์เงื่อนไขรูปแบบ Pattern เพื่อกระจายโครงสร้างตัวแปร (destructure) ของทูเพิลนั้นออกมาได้ทันที เราจะร่วมพูดคุยถึงหัวเรื่อง patterns เพิ่มเติมใน บทที่ 6 โดยในโครงสร้างลูป for นี้เราเขียนเงื่อนไขระบุรูปแบบจัดตั้งตัวแปร i มารับตำแหน่งดัชนี และสร้างตัวแปรอ้างอิง &item มารองรับชิ้นข้อมูลไบต์เดี่ยว และเนื่องจากผลรายงานสมาชิกจากกลไกคำสั่ง .iter().enumerate() ส่งคืนผลลัพธ์มาเป็นแบบการอ้างอิงสิทธิ์ เราจึงจำเป็นต้องเติมสัญลักษณ์เครื่องหมาย & ไว้เคียงหน้าชื่อตัวแปรในนิพจน์รูปแบบ Pattern ดักจับ

ภายในขอบเขตการรันวนลูป for เราดำเนินการสแกนค้นหาสัญลักษณ์ไบต์ที่เป็นตัวแทนของช่องว่างเว้นวรรคผ่านหลักไวยากรณ์ระบุตัวสัญลักษณ์ข้อมูลไบต์ (byte literal syntax) หากเราตรวจจับพบพิกัดเว้นวรรคดังกล่าวสำเร็จ เราจะส่งคืนตำแหน่งดัชนีชี้ตำแหน่งนั้นออกมาทันที และในทิศทางตรงกันข้ามหากประมวลผลจนจบลูปแล้วไม่พบตำแหน่งเว้นวรรคเลย เราส่งคืนความยาวเต็มข้อความออกไปด้วยประโยค s.len()

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

ณ ตอนนี้เรามีวิธีกระบวนการสืบค้นพิกัดจุดสิ้นสุดของคำศัพท์แรกในข้อความสำเร็จแล้ว แต่ทว่าระบบการจัดสรรแบบนี้จะอุบัติปัญหาสำคัญขึ้นตามมา: นั่นคือการที่เราส่งคืนเฉพาะข้อมูลตัวเลขสเกลาร์ชนิด usize ออกมาเดี่ยวๆ โดยข้อมูลตัวเลขนี้จะไม่มีนัยสำคัญที่สมบูรณ์หรือถูกผูกสิทธิ์ค้ำประกันความปลอดภัยใดๆ ร่วมกับตัวแปรหลัก &String เลย กล่าวคือเนื่องจากตัวเลขนี้ลอยตัวเป็นอิสระต่างหาก จึงไม่มีความปลอดภัยใดๆ รับรองว่าข้อมูลประวัติเนื้อความข้อความของโปรแกรมจะไม่ผันแปรไปในระหว่างทาง ลองพิจารณาตัวอย่างซอร์สโค้ดในรายการที่ 4-8 ที่หยิบเอาฟังก์ชัน first_word จากรายการที่ 4-7 มาประยุกต์ใช้งานดู:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}

โปรแกรมตัวอย่างนี้จะสามารถผ่านขั้นตอนการประกอบคอมไพล์ได้สำเร็จโดยปราศจากข้อผิดพลาดแจ้งเตือนใดๆ และจะยอมรันงานปกติแม้ว่าเราจะเขียนคำสั่งหยิบตัวแปร word ไปใช้งานต่อที่ด้านล่างหลังจากบรรทัดคำสั่งเคลียร์ข้อความ s.clear() ประมวลผลไปแล้ว เนื่องจากตัวแปรเก็บเลข word ไม่ได้เชื่อมโยงค้ำสิทธิ์ความปลอดภัยหรือรับรู้ถึงประวัติความเปลี่ยนแปลงของตัวแปรข้อความ s เลย ตัวแปร word จึงยังคงถือเก็บตัวเลข 5 ไว้ปกติ และหากเรานำเอาค่าข้อมูลเลข 5 นี้สลับกลับมาคีย์เพื่อพยายามสั่งให้ตัวแปร s แตกแยกคำข้อความแรกสุดออกมา จะเกิดความล้มเหลวข้อผิดพลาดในการประมวลผล (bug) ทันที เนื่องจากอักขระประวัติที่แท้จริงภายใน s ได้ถูกสลัดแปรสภาพไปหมดสิ้นแล้วตั้งแต่ก่อนจังหวะเวลาดึงค่าตัวเลข 5 จาก word มาใช้งาน

การที่นักพัฒนาโปรแกรมต้องคอยมานั่งพะวงระวังประเด็นตัวแปรเก็บพิกัดดัชนีอย่าง word หลุดวงรอบไม่สัมพันธ์กับค่าเนื้อความประวัติของ s ถือเป็นเรื่องที่น่าเบื่อหน่ายและเปิดช่องว่างให้เกิดบั๊กได้ง่ายมาก! และทิศทางการเขียนรหัสแบบจดหมายจำดัชนีนี้จะยิ่งเพิ่มความเปราะบางอย่างมากหากเราต้องการเขียนฟังก์ชันดึงคำที่สองเพิ่มขึ้นมาอย่างฟังก์ชัน second_word ลายเซ็นการระบุสเปกขากลับของฟังก์ชันคงต้องเขียนสเปกหน้าตาออกมาในรูปทำนองนี้:

fn second_word(s: &String) -> (usize, usize) {

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

โชคดีมากที่ Rust ได้ตระเตรียมหนทางช่วยปิดประตูข้อบกพร่องข้อกังวลนี้ไว้เรียบร้อยแล้ว: ส่วนแบ่งข้อความ (string slices)

ส่วนแบ่งข้อความ (String Slices)

ส่วนแบ่งข้อความ (string slice) คือตัวแปรอ้างอิงประเภทระบุชี้ช่วงขอบเขตข้อมูลลำดับอักขระอักษรที่จัดเก็บเรียงเป็นพืดต่อกันภายใน String โดยมีรูปแบบไวยากรณ์การเขียนดังนี้:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

แทนการอ้างอิงครอบคลุมพื้นที่ทั้งหมดของ String ตัวแปร hello จะทำหน้าที่เป็นตัวแทนเข้าอ้างอิงสิทธิ์เฉพาะบริเวณย่อยส่วนหนึ่งของ String โดยระบุขอบเขตช่วงไว้ด้วยชุดอักขระ [0..5] เพิ่มเติมที่ด้านท้ายวงเล็บเหลี่ยม เราสร้างส่วนแบ่งข้อมูลได้โดยใส่ชุดคำสั่งช่วงขอบเขต (range) ภายในเครื่องหมายวงเล็บเหลี่ยมในรูป [ดัชนีเริ่มต้น..ดัชนีสิ้นสุด] โดยที่ ดัชนีเริ่มต้น คือพิกัดตำแหน่งแรกสุดที่ต้องการหยิบใช้ภายในส่วนแบ่งข้อมูล และ ดัชนีสิ้นสุด คือพิกัดตัวเลขถัดถัดไปจากจุดปิดท้ายช่วง (บวกเพิ่มขึ้น 1 จากตำแหน่งสุดท้ายจริง) โดยโครงสร้างภายในของระบบข้อมูลส่วนแบ่งนี้จะดำเนินการจัดเก็บเฉพาะเบาะแสข้อมูลพิกัดจุดเริ่มต้นและขนาดความยาว (length) ของส่วนแบ่งข้อมูล ซึ่งมีขนาดสัมพันธ์สอดคล้องกับสมการนำเอา ดัชนีสิ้นสุด หักลบด้วย ดัชนีเริ่มต้น ดังนั้นกรณีตัวอย่างคำสั่ง let world = &s[6..11]; ตัวแปร world จะกลายโฉมเป็นข้อมูลส่วนแบ่งที่บรรจุข้อมูล pointer ชี้ไปยังตัวไบนารีไบต์ที่ดัชนี 6 ของ s และพ่วงด้วยมูลค่าความยาวเท่ากับ 5

รูปภาพที่ 4-7 แสดงทิศทางความสัมพันธ์นี้อย่างเห็นภาพชัดเจน:

Three tables: a table representing the stack data of s, which points to the byte at index 0 in a table of the string data "hello world" on the heap. The third table represents the stack data of the slice world, which has a length value of 5 and points to byte 6 of the heap data table.

รูปภาพที่ 4-7: ส่วนแบ่งข้อความ (string slice) อ้างอิงถึงบางส่วนของ String

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

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

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

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

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

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

หมายเหตุ: หมายเลขลำดับดัชนีช่วงขอบเขตของส่วนแบ่งข้อความ (String slice range indices) จะต้องได้รับการประกาศวางตำแหน่งไว้ตรงพิกัดช่วงรอยต่อรหัสตัวอักษรของ UTF-8 ที่สมบูรณ์และถูกต้อง (valid UTF-8 boundaries) เท่านั้น หากคุณพยายามจะเขียนโค้ดผ่ารอยแยกส่วนแบ่งข้อมูลครึ่งกลางตัวอักษรที่มีโครงสร้างแบบหลายไบต์ (multibyte character) ตัวโปรแกรมจะทำงานแครชและสั่งปิดการทำงานแสดงข้อผิดพลาดทันที

เมื่อเรารวบรวมข้อมูลความรู้พื้นฐานได้พร้อมสรรพแล้ว มาลองร่วมมือกันปรับแก้ปรับปรุงเนื้อโค้ดฟังก์ชัน first_word เพื่อให้ระบบดำเนินงานในรูปแบบส่งคืนผลลัพธ์กลับมาเป็นส่วนแบ่งข้อมูลอ้างอิงกันเลย โดยชนิดประเภทตัวแปรที่ใช้เป็นตัวแทนของความหมายคำว่า “ส่วนแบ่งข้อความ” จะเขียนระบุแสดงแทนด้วยชุดสัญลักษณ์ชนิดข้อมูล &str:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

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

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

กระบวนการส่งคืนผลลัพธ์กลับเป็นส่วนแบ่งข้อมูลนี้ยังสามารถนำไปเขียนประยุกต์ใช้งานออกแบบฟังก์ชัน second_word ได้อย่างมีเสถียรภาพสอดคล้องกันด้วยเช่นกัน:

fn second_word(s: &String) -> &str {

ตอนนี้นักพัฒนาจะได้มีโครงสร้างสเปกการเชื่อมต่อเรียกใช้งานโปรแกรม (API) ที่ชัดเจน สะอาดตา และยากที่จะเกิดข้อผิดพลาดในการเขียนโปรแกรมขึ้นมาอย่างมาก เนื่องจากคอมไพเลอร์ Rust จะเพิ่มมาตรการช่วยคุ้มกันความปลอดภัยคอยตรวจตราไม่ให้ตัวแปรอ้างอิงชิ้นย่อยใดๆ ใน String เกิดสภาวะลอยเคว้งสูญเสียสภาพ จำเรื่องบั๊กร้ายแรงในโปรเจกต์รายการที่ 4-8 ได้ใช่ไหมครับ ที่เราดึงเลขตำแหน่งดัชนีคำแรกออกมาจัดเก็บสำเร็จแล้วดันสั่งลบเนื้อหาข้อความต้นขั้วทิ้งจนเลขดัชนีเสียหายลอยเคว้ง? โค้ดส่วนนั้นหากพิจารณาทางตรรกศาสตร์ถือว่าบกพร่องผิดเพี้ยนเต็มๆ แต่โปรแกรมในตอนนั้นยอมปล่อยผ่านไร้การแจ้งเตือนความล้มเหลวทันที และปัญหาบั๊กประหลาดจะแอบโผล่มาสร้างผลกระทบต่อเราในภายหลังเมื่อเรานำพิกัดเลขดัชนีตกค้างนั้นไปอ้างอิงใช้งานกับข้อความว่างเปล่า การเปลี่ยนรูปแบบมาประยุกต์ใช้งานส่วนแบ่งข้อมูล (slices) จะสกัดและช่วยตัดสิทธิ์การเกิดข้อบกพร่องลักษณะดังกล่าวออกจากระบบอย่างถาวร และช่วยชี้เป้าปัญหารายงานความผิดพลาดให้เราทราบทันควันตั้งแต่ช่วงเขียนโค้ด การเรียกใช้งานฟังก์ชัน first_word เวอร์ชันส่วนแบ่งข้อมูลนี้จะส่งผลให้คอมไพเลอร์ปัดตกแสดงรายงานข้อผิดพลาดในขั้นตอนคอมไพล์ทันที:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

นี่คือข้อความแสดงข้อผิดพลาดจากคอมไพเลอร์:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                   ---- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

จำกฎเหล็กกติกายืมใช้ข้อมูลได้ไหมครับว่า หากขอบเขตงานยังมีการเรียกใช้ตัวแปรอ้างอิงแบบแก้ไขไม่ได้ (immutable reference) คาไว้อยู่ ระบบจะไม่อนุญาตให้ประกาศแทรกตั้งตัวแปรอ้างอิงแบบแก้ไขได้ (mutable reference) ขอยืมซ้อนเข้ามาในเวลาเดียวกัน และเนื่องจากการทำคำสั่ง clear มีความจำเป็นต้องเข้ามาล้างปรับหดเนื้อความของ String ให้สั้นลง มันจึงต้องส่งคำขอรับสิทธิ์เป็นตัวอ้างอิงแก้ไขแบบ mutable และบรรทัดคำสั่ง println! ที่เขียนตามหลังคำสั่ง clear มีความพยายามที่จะดึงเอาค่าตัวแปรอ้างอิงใน word มาใช้งานต่อ ส่งผลให้ตัวแปรอ้างอิงแบบแก้ไขไม่ได้ตัวนั้นยังมีผลค้ำสิทธิ์ทำงานอยูอย่างต่อเนื่องจนถึงบรรทัดดังกล่าว Rust ปฏิเสธสิทธิ์การซ้อนทับกันของสิทธิ์แก้ไข mutable ใน clear และสิทธิ์แก้ไขไม่ได้ใน word ในเสี้ยวจังหวะเดียวกัน ส่งผลให้ขั้นตอนคอมไพล์ทำงานแครชและคอมไพเลอร์ปฏิเสธรันงาน การทำเช่นนี้ Rust ไม่เพียงแต่ช่วยออกแบบโครงสร้าง API ของเราให้อ่านเข้าใจง่ายขึ้น แต่ยังช่วยกำจัดขุดรากถอนโคนประเภทกลุ่มข้อบกพร่องบั๊กคลาสสิกต่างๆ ออกไปตั้งแต่ก่อนที่โปรแกรมจะถูกรันทำงาน!

ค่าคงที่ข้อความเป็นส่วนแบ่งข้อความ (String Literals as Slices)

จำได้ไหมที่เราเคยร่วมพูดคุยถึงลักษณะพิเศษของค่าคงที่ข้อความ (string literals) ว่าข้อมูลจริงจะได้รับการเขียนบันทึกเก็บจองพื้นที่ฝังไว้ในไฟล์ไบนารีหลักของโปรแกรม เมื่อเรามีความรู้พื้นฐานเรื่องส่วนแบ่งข้อมูล slices ครบถ้วนแล้ว เราจะเข้าใจโฉมหน้าแท้จริงของค่าคงที่ข้อความได้ชัดแจ้งยิ่งขึ้นดังนี้:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

ชนิดประเภทข้อมูลที่แท้จริงของตัวแปร s ตรงจุดนี้คือ &str นั่นคือมันสวมบทบาทเป็นข้อมูลส่วนแบ่ง slice ที่ประทับตราตัวชี้ pointer โยงเจาะจงไประบุพิกัดที่อยู่ของหน่วยความจำผืนนั้นภายในไฟล์ไบนารีระบบ และนี่เป็นเหตุผลสำคัญที่อธิบายว่าทำไมอักขระประเภท literals จึงแก้ไขมูลค่าข้อมูลไม่ได้ เนื่องจากตัวแปรชนิด &str มีกติกาล็อกสถานะเป็นตัวแปรอ้างอิงแก้ไขค่าไม่ได้ (immutable reference) นั่นเอง

ส่วนแบ่งข้อความในฐานะตัวแปรพารามิเตอร์

เมื่อนักพัฒนาตระหนักดีแล้วว่าเราสามารถเขียนรหัสดึงชิ้นส่วนแบ่ง slices ออกมาจากกลุ่มค่าคงที่ข้อความ literals และอ็อบเจกต์ชนิด String ออกมาใช้งานได้อย่างอิสระ สิ่งนี้ช่วยนำทางชี้แนวทางการปรับแต่งเขียนสเปกลายเซ็นของฟังก์ชัน first_word ให้มีความยืดหยุ่นและน่าประยุกต์ใช้งานขึ้นอีกระดับ ดังนี้:

fn first_word(s: &String) -> &str {

โปรแกรมเมอร์ชาว Rustacean ที่มีความเชี่ยวชาญส่วนใหญ่จะเลือกดีไซน์ระบุลักษณะลายเซ็นของฟังก์ชันปลายทางให้มีรูปแบบดังที่แสดงในรายการที่ 4-9 แทน เนื่องจากวิถีการเขียนแบบนี้จะเปิดกว้างให้ฟังก์ชันของเรารับรองสิทธิ์ยอมรับค่าอาร์กิวเมนต์เข้ามาประมวลผลได้สารพัดประโยชน์ยิ่งขึ้น ไม่ว่าจะเป็นข้อมูลประเภท &String หรือชนิดข้อมูลกลุ่ม &str ก็รันผ่านฟังก์ชันเดียวกันได้ทั้งหมด

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

หากเรามีส่วนแบ่งข้อความอยู่แล้ว เราสามารถส่งผ่านค่าตัวแปรนั้นป้อนเข้าไปได้โดยตรง หรือถ้าหากข้อมูลต้นทางของเราจัดเก็บอยู่ในรูปแบบอ็อบเจกต์ชนิด String เราสามารถส่งผ่านตัวอ้างอิงส่วนย่อยของ String นั้นหรือเขียนระบุอ้างอิงชี้ตัวแปร String เข้าไปประมวลผลได้เช่นกัน ความคล่องตัวยืดหยุ่นในการเขียนตรงจุดนี้จะทำงานเบื้องหลังโดยดึงฟีเจอร์เด่นเรื่องระบบแปลงสิทธิ์ความสัมพันธ์อ้างอิง (deref coercions) มาประมวลผลร่วม ซึ่งเราจะได้พูดคุยอธิบายรายละเอียดฟีเจอร์นี้ในบทที่ 15 หัวข้อ “การประยุกต์ใช้ประโยชน์กลไกแปลงสิทธิ์อ้างอิง Deref Coercions ในฟังก์ชันและเมธอด”

การออกแบบฟังก์ชันให้ยินยอมรับพารามิเตอร์เป็นประเภทส่วนแบ่งข้อความ (string slice) แทนที่การระบุผูกมัดเข้มงวดไว้กับสิทธิ์ตัวแปรอ้างอิงข้อความ String จะส่งผลให้โครงสร้าง API ของซอฟต์แวร์ที่เราสร้างมีรูปแบบที่เป็นสากลและเอื้ออำนวยการหยิบไปประยุกต์ใช้ในกรณีต่าง ๆ ได้สมบูรณ์ โดยไม่สูญเสียความสามารถดั้งเดิมใดๆ เลย:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

ส่วนแบ่งข้อมูลประเภทชนิดอื่นๆ (Other Slices)

ส่วนแบ่งข้อความ (String slices) นั้น ดังที่คุณน่าจะคาดเดาได้ว่าเป็นเครื่องมือที่ออกแบบขึ้นมาเฉพาะเจาะจงสำหรับประยุกต์ใช้จัดการกับอักขระอักษรเท่านั้น แต่ความจริงแล้วภาษา Rust ได้เตรียมโครงสร้างส่วนแบ่งทั่วไปสากล (general slice type) สำหรับนำมาประยุกต์ใช้กับโครงสร้างข้อมูลชนิดอื่นๆ ไว้ให้ด้วยเช่นกัน ลองพิจารณาอาร์เรย์ตัวนี้กัน:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

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

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

ข้อมูลส่วนแบ่งชิ้นนี้จะจัดรายงานชนิดข้อมูลประเภท &[i32] ซึ่งจะมีพฤติกรรมหลักการรันคอมไพล์สอดคล้องเป็นพิมพ์เขียวเดียวกับการจัดการของส่วนแบ่งข้อความทุกประการ นั่นคือกักเก็บเบาะแสข้อมูล pointer ชี้ไปยังพิกัดเริ่มต้นย่อยของสมาชิกในอาร์เรย์พ่วงด้วยขนาดความยาวของส่วนแบ่ง โดยคุณจะได้หยิบยกเอาโครงสร้างส่วนแบ่งลักษณะอเนกประสงค์นี้ไปเขียนโค้ดจัดการกับระบบโครงสร้างกลุ่มข้อมูลสารสนเทศอื่นๆ อีกมากมาย เราจะกลับมาพูดคุยเจาะลึกเรื่องกลุ่มข้อมูลและเวกเตอร์อย่างเป็นระบบในบทที่ 8

สรุป

แนวคิดพื้นฐานเรื่องสิทธิ์การครอบครองหน่วยความจำ (ownership), การยืมใช้ข้อมูล (borrowing) ตลอดจนส่วนแบ่งข้อมูล (slices) ล้วนมีบทบาทสำคัญเป็นอย่างยิ่งคอยประสานงานปกป้องรักษาความปลอดภัยของหน่วยความจำของโปรแกรมในภาษา Rust ตั้งแต่จังหวะเวลาขั้นตอนคอมไพล์ ตัวภาษา Rust มอบสิทธิ์ในการควบคุมการจัดสรรใช้งานหน่วยความจำเครื่องให้แก่นักพัฒนาได้อย่างอิสระเทียบเคียงได้ดีเฉกเช่นวิถีของภาษาเขียนระบบอื่น ๆ แต่ด้วยการที่เรามีระบบบริหารจัดการที่ตัวแปรผู้ครอบครองสิทธิ์จะคอยทำลายกวาดล้างพื้นที่จัดเก็บข้อมูลให้อัตโนมัติเมื่อสิ้นสุดช่วงเวลาขอบเขตงาน ส่งผลให้นักพัฒนาโปรแกรมหมดภาระความกังวลที่จะต้องมาเขียนโค้ดปลดปล่อยหน่วยความจำและนั่งสืบหาไล่เช็คแก้ไขข้อผิดพลาดทางหน่วยความจำด้วยตนเอง

สิทธิ์การครอบครองหน่วยความจำส่งผลกระทบและกำหนดทิศทางวิธีการทำงานคุณสมบัติเด่นจุดอื่นๆ ของภาษา Rust แทบทุกมิติ ดังนั้นเราจะยังคงหยิบยกเอาแนวคิดพื้นฐานนี้ขึ้นมาอธิบายพ่วงขยายความต่อเนื่องกันไปตลอดซอร์สโค้ดของหนังสือเล่มนี้ มาเริ่มก้าวเดินทางต่อเข้าสู่บทที่ 5 เพื่อศึกษาแนวคิดการรวมกลุ่มกลุ่มข้อมูลเข้าด้วยกันภายในโครงสร้างวัตถุ struct กันเลยครับ

การใช้โครงสร้างข้อมูลแบบ Struct เพื่อจัดกลุ่มข้อมูลที่เกี่ยวข้องกัน

struct หรือ structure คือชนิดข้อมูลแบบกำหนดเอง (custom data type) ที่อนุญาตให้คุณสามารถนำข้อมูลหลาย ๆ ค่าที่มีความเกี่ยวข้องกันมาแพ็กเกจรวมกลุ่มเข้าไว้ด้วยกันและกำหนดชื่อเรียกได้ เพื่อให้กลายเป็นกลุ่มข้อมูลที่มีใจความสำคัญ หากคุณมีความคุ้นเคยกับภาษาเขียนโปรแกรมเชิงวัตถุ (object-oriented language) โครงสร้าง struct จะมีพฤติกรรมเปรียบเสมือนคุณสมบัติข้อมูล (data attributes) ของวัตถุ (object) ในบทนี้เราจะเปรียบเทียบและแยกความแตกต่างระหว่างทูเพิล (tuples) กับ structs เพื่อต่อยอดจากสิ่งที่คุณได้เรียนรู้มาแล้ว และแสดงให้เห็นว่าในสถานการณ์ใดที่ structs จะเป็นแนวทางที่ดีกว่าสำหรับการจัดกลุ่มข้อมูล

เราจะได้สาธิตและเรียนรู้วิธีการกำหนดนิยามและการสร้างตัวแทนสร้างออบเจกต์ (instantiate) ของ structs รวมถึงจะได้ร่วมกันพูดคุยถึงวิธีการกำหนดนิยามฟังก์ชันที่เกี่ยวข้อง (associated functions) โดยเฉพาะอย่างยิ่งฟังก์ชันที่เกี่ยวข้องประเภทที่เรียกว่า เมธอด (methods) เพื่อกำหนดพฤติกรรมการทำงานเฉพาะตัวพ่วงร่วมกับชนิดข้อมูล struct นั้น ๆ ทั้ง struct และ enum (ซึ่งจะได้อธิบายขยายความในบทที่ 6) ต่างทำหน้าที่เป็นส่วนประกอบหลักสำหรับการสร้างชนิดข้อมูลประเภทใหม่ในย่านโปรแกรมของคุณเพื่อดึงประโยชน์จากด่านตรวจสอบชนิดข้อมูลในช่วงเวลาคอมไพล์ (compile-time type checking) ของ Rust มาใช้งานได้อย่างเต็มประสิทธิภาพ

Defining and Instantiating Structs

การกำหนดนิยามและการสร้างตัวแทนโครงสร้างข้อมูล Struct (Defining and Instantiating Structs)

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

กระบวนการกำหนดนิยาม struct จะเริ่มต้นสั่งการโดยระบุคำสำคัญ struct ตามด้วยชื่อหลักประจำตัวโครงสร้าง struct นั้นๆ โดยชื่อของ struct ควรบ่งชี้ถึงใจความสำคัญของกลุ่มชุดข้อมูลสารสนเทศที่นำมารวมกัน จากนั้นภายใต้ขอบเขตวงเล็บปีกกา เราจะทำประกาศนิยามชื่อระบุตัวแปรและประเภทชนิดข้อมูลย่อยของข้อมูลแต่ละรายการ ซึ่งจะเรียกว่า ฟิลด์ (fields) ยกตัวอย่างเช่น รายการที่ 5-1 แสดงนิยาม struct สำหรับบันทึกเก็บข้อมูลทั่วไปเกี่ยวกับบัญชีผู้ใช้งาน

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}

การเรียกหยิบ struct ไปใช้งานภายหลังจากที่เราเขียนนิยามมันเสร็จเรียบร้อย จะใช้วิธีสั่งจัดสร้าง ตัวแทนอ็อบเจกต์ (instance) ของ struct นั้นๆ ผ่านการระบุป้อนค่าข้อมูลจริงเฉพาะเจาะจงให้ครบถ้วนในทุกๆ ฟิลด์ โดยเราสร้างตัวแทน instance ได้โดยระบุชื่อโครงสร้าง struct ตามด้วยวงเล็บปีกกาซึ่งบรรจุคู่ข้อมูล คีย์: ค่าข้อมูล (key: value) โดยคีย์ (keys) คือชื่อของฟิลด์ข้อมูล และค่าข้อมูล (values) คือชิ้นงานข้อมูลที่เราต้องการไปบันทึกเก็บจองไว้ในฟิลด์ตำแหน่งนั้นๆ ทั้งนี้เราไม่จำเป็นต้องเขียนระบุชื่อฟิลด์เรียงตามลำดับเดียวกับตอนประกาศนิยาม struct เสมอไป กล่าวอีกนัยหนึ่งคือ การเขียนนิยาม struct เปรียบเสมือนแบบร่างแม่พิมพ์ทั่วไป (template) ประจำชนิดข้อมูล และตัวแทน instance จะทำหน้าที่หยิบแม่พิมพ์นั้นไปเติมเต็มด้วยข้อมูลเฉพาะตัวเพื่อสร้างสรรค์มูลค่าของชนิดข้อมูลนั้นๆ ออกมาจริง ตัวอย่างเช่น เราสามารถประกาศสร้างตัวแทนข้อมูลบัญชีผู้ใช้งานคนหนึ่งได้ดังแสดงในรายการที่ 5-2:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}

หากนักพัฒนาต้องการดึงอ่านข้อมูลเฉพาะฟิลด์ออกจาก struct จะใช้วิธีระบุเครื่องหมายจุด (dot notation) ตัวอย่างเช่น การเข้าถึงข้อมูลที่อยู่อีเมลของผู้ใช้รายนี้ จะเขียนเรียกใช้ผ่านประโยคคำสั่ง user1.email และหากตัวแทน instance นั้นถูกประทับตราระบุให้แก้ไขค่าข้อมูลได้ (mutable) เราจะสามารถป้อนเขียนข้อมูลทับแทนที่ค่าข้อมูลเดิมได้ง่ายๆ โดยระบุเครื่องหมายจุดโยงหาฟิลด์เป้าหมายแล้วสั่งเขียนส่งค่าขวาเข้าไปบันทึกทับในฟิลด์นั้น รายการที่ 5-3 แสดงตัวอย่างกระบวนการเข้าแก้ไขค่าข้อมูลในฟิลด์ email ของตัวแทนข้อมูล User ประเภทแก้ไขค่าได้:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

พึงสังเกตสเปกข้อกำหนดระบุว่า การตั้งค่าความสามารถในการแก้ไขตัวแปรนั้นจำเป็นต้องประกาศครอบคลุมสิทธิ์ทั้งตัวแทน instance โดยที่ภาษา Rust จะไม่อนุญาตให้เราทำเครื่องหมายล็อกสิทธิ์ความสามารถแก้ไขเฉพาะเจาะจงรายฟิลด์ (partial mutability) และเช่นเดียวกับโครงสร้างนิพจน์คำสั่งทั่วไป เราสามารถประกอบสร้างตัวแทน instance ตัวใหม่ของ struct เป็นนิพจน์รายการสุดท้ายในเนื้อหาฟังก์ชันเพื่อทำส่งคืนค่า (return) อ็อบเจกต์ตัวแทนตัวใหม่นั้นออกไปภายนอกโดยปริยายได้

รายการที่ 5-4 แสดงตัวอย่างฟังก์ชัน build_user ซึ่งจะทำหน้าที่ส่งคืนตัวแทน User ชุดใหม่ออกมาประมวลผลปลายทางโดยบรรจุอ้างอิงอีเมลและชื่อผู้ใช้ตามอาร์กิวเมนต์ที่ส่งเข้ามา โดยระบุให้ฟิลด์ active ถือครองค่าข้อมูล true และฟิลด์ sign_in_count เริ่มต้นจัดตั้งค่าด้วยตัวเลข 1

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

การตั้งชื่อตัวแปรพารามิเตอร์ของฟังก์ชันให้มีสะกดสอดคล้องตรงตามชื่อของฟิลด์ภายใน struct ถือเป็นแนวทางปฏิบัติที่ดีและอ่านเข้าใจได้ลื่นไหล แต่อย่างไรก็ตามการที่ต้องมาคอยพิมพ์เขียนตัวแปรซ้ำสองรอบพ่วงในรูป email: email และ username: username จะแอบน่าเบื่อและเพิ่มภาระเขียนโค้ดโดยไม่จำเป็น หากในโครงสร้าง struct ของคุณมีฟิลด์สะสมปริมาณมากๆ การพิมพ์ประกบคู่ซ้ำเดิมจะยิ่งทวีความน่าปวดหัว โชคดีมากที่ Rust เตรียมไวยากรณ์ลดรูปแสนสะดวกมาช่วยตอบโจทย์นี้!

การใช้คำสั่งลดรูปประหยัดฟิลด์ (Using the Field Init Shorthand)

เนื่องจากข้อมูลชื่อพารามิเตอร์และชื่อฟิลด์เป้าหมายสะกดตรงกันพอดีในเนื้อตัวอย่างรายการที่ 5-4 เราจึงสามารถเลือกสวมหลักไวยากรณ์ ลดรูประบุสร้างฟิลด์ (field init shorthand) มาช่วยเขียนปรับแต่งนิยามโครงสร้างฟังก์ชัน build_user ใหม่ได้สำเร็จ ซึ่งจะช่วยสลัดข้อจำกัดของบรรทัดโค้ดซ้ำซากของตัวแปร username และ email ออกไปอย่างสมบูรณ์แบบโดยรักษาทิศทางการทำงานประมวลผลเบื้องหลังสอดคล้องกันตรงตามเดิมทุกประการ ดังแสดงตัวอย่างในรายการที่ 5-5:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

จากจุดตัวอย่างนี้ เรากำลังจัดสร้างตัวแทน instance ตัวใหม่ของ struct User ซึ่งมีฟิลด์ระบุชื่อว่า email โดยจุดประสงค์เราต้องการกำหนดให้ฟิลด์ email ดังกล่าวรับค่าข้อมูลตัวแปรมาจากพารามิเตอร์ email ของฟังก์ชัน build_user และเมื่อทั้งสองฝั่งใช้ชื่อเรียกที่สะกดเหมือนกันเป๊ะ ระบบจึงยินยอมให้เราเขียนระบุเพียงตัวแปร email ลอยเดี่ยวตัวเดียวได้ทันทีโดยละเครื่องหมายโคลอนพ่วงซ้ำคู่อย่าง email: email ออกไปได้เลย

การสร้างตัวแทนตัวใหม่ด้วยคำสั่งปรับเปลี่ยน Struct (Creating Instances with Struct Update Syntax)

ในหลายๆ กรณีของการออกแบบโปรเจกต์ มักจะเกิดความประสงค์ต้องการจัดสร้างตัวแทน instance ตัวใหม่ของ struct ซึ่งสเปกข้อมูลส่วนใหญ่นั้นลอกเลียนแบบยกเอามาจากข้อมูลตัวแทนตัวเก่าของชนิดประเภทเดียวกัน และต้องการปรับเปลี่ยนแก้ไขค่าข้อมูลใหม่เพิ่มเติมในบางรายการเท่านั้น คุณสามารถสั่งการลักษณะนี้ได้อย่างประหยัดโค้ดด้วยการใช้ หลักไวยากรณ์อัปเดต struct (struct update syntax)

ก่อนอื่น ในรายการที่ 5-6 เราจะแสดงทิศทางกระบวนการสร้างตัวแทนข้อมูล User ตัวใหม่เก็บไว้ในตัวแปร user2 ตามแนวทางมาตรฐานปกติทั่วไปโดยไม่ดึงสเปกอัปเดตย่อมาช่วยประมวลผล โดยเรากำหนดค่าอีเมลใหม่ให้ฟิลด์ email นอกเหนือจากนั้นจะลอกเอาประวัติข้อมูลตัวเดิมมาจากตัวแปร user1 ที่ตั้งขึ้นในรายการที่ 5-2 ทั้งหมด:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}

หากหันมาสวมหลักไวยากรณ์อัปเดต struct มาช่วยเขียน จะประหยัดความยาวบรรทัดโค้ดลงไปได้เยอะมากและให้ผลลัพธ์แบบเดียวกันดังแสดงในรายการที่ 5-7 โดยเครื่องหมายสัญลักษณ์จุดสองจุดคู่ .. จะเป็นตัวบอกเจตนาให้ระบบนำเอาเนื้อหาข้อมูลในฟิลด์ที่ไม่ได้เขียนป้อนค่าไว้ไปสแกนคัดลอกจากฟิลด์อ็อบเจกต์ตัวอ้างอิงต้นทางมาสวมประดับให้อัตโนมัติ:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

ซอร์สโค้ดในรายการที่ 5-7 จะดำเนินการสร้างอ็อบเจกต์ตัวแทนใน user2 พร้อมป้อนค่าฟิลด์อีเมลใหม่ลงใน email แต่สลัดหน้าที่ป้อนค่าฟิลด์ username, active และ sign_in_count โดยดึงประวัติสำเนาของ user1 มาปักลงให้อัตโนมัติ โดยระเบียบข้อบังคับคือไวยากรณ์คำสั่ง ..user1 จำเป็นต้องเขียนระบุไว้เป็นลำดับสุดท้ายสุดของวงเล็บปีกกาเพื่อกำกับว่าฟิลด์ส่วนที่หลงเหลือที่ไม่ได้เขียนระบุค่าจะดึงค่ามาจากอ็อบเจกต์ตัวแทน user1 และเราสามารถสลับตำแหน่งหรือกำหนดฟิลด์ใหม่เพิ่มเติมปริมาณเท่าใดก็ทำได้ตามใจชอบ ไม่ต้องกังวลว่าลำดับเขียนโค้ดจะตรงตามพิมพ์เขียวตอนนิยาม struct หรือไม่

พึงตระหนักให้ลึกซึ้งว่าการเรียกสวมหลักไวยากรณ์อัปเดต struct นั้นจะใช้กลไกเครื่องหมายเท่ากับ = เช่นเดียวกับการป้อนค่าตัวแปรทั่วไป ซึ่งพฤติกรรมนี้จะทำให้ระบบเกิดการย้ายสิทธิ์การครอบครองหน่วยความจำ (move) ของชิ้นข้อมูลเป้าหมายอิงตามประเด็นความปลอดภัยที่เราเรียนรู้กันในหัวข้อ “ปฏิสัมพันธ์ระหว่างตัวแปรกับชิ้นข้อมูลในรูปแบบการย้ายสิทธิ์ (Move)” ในตัวอย่างโค้ดนี้ส่งผลให้เราไม่สามารถเขียนเรียกใช้งานตัวแปร user1 ได้อีกต่อไปภายหลังบรรทัดจัดตั้งตัวแทน user2 ไปแล้ว เนื่องจากมูลค่าชนิดข้อความ String ในฟิลด์ username ของตัวแปร user1 ได้ถูกย้ายสิทธิ์ (moved) ย้ายขาดเข้าไปสวมสิทธิ์ใน user2 ไปแล้วเรียบร้อย แต่หากเราจงใจเขียนกำหนดชนิดข้อความ String ตัวใหม่มอบให้แก่ฟิลด์ email และ username ของ user2 ทั้งคู่ และส่งผลให้มีการดึงเฉพาะสิทธิ์ฟิลด์ active และ sign_in_count มาคัดลอกโคลนใช้งานตามลำดับ ตัวแปร user1 จะยังคงรักษาความสมบูรณ์และเปิดใช้งานต่อยอดบรรทัดล่างได้ปกติ เนื่องจากประเภทข้อมูลของทั้ง active และ sign_in_count เป็นชนิดข้อมูลประเภทรองรับ Copy เทรต ซึ่งกลไกตัวระบบจะทำคัดลอกออโต้คัดลอกค่าให้ตามทิศทางพฤติกรรมที่เราอธิบายในหัวข้อ “ชิ้นข้อมูลประเภทจำกัดตัวเฉพาะสแตก: การทำก๊อปปี้ (Copy)” และเรายังสามารถเขียนเรียกใช้งานฟิลด์เฉพาะจุดอย่าง user1.email ได้ปกติเช่นกันเนื่องจากค่าข้อมูลตรงอีเมลชุดนั้นไม่ได้ถูกย้ายสิทธิ์หลุดพ้นไปจาก user1 ในระหว่างกระบวนการ

การกำหนดตั้งตัวแปรประเภทชนิดเฉพาะผ่านทูเพิลสตรักต์ (Creating Different Types with Tuple Structs)

Rust ยังรองรับโครงสร้างสตรักต์ที่มีรูปประโยคไวยากรณ์สอดคล้องคล้ายกับโครงสร้างทูเพิล เรียกว่า ทูเพิลสตรักต์ (tuple structs) โดยทูเพิลสตรักต์จะได้รับการแนบตราคุณค่าของความหมายตามชื่อที่ระบุตั้งให้ แต่จะละเว้นการระบุชื่อเรียกฟิลด์ภายในไปเหลือเพียงแค่การเขียนประกาศชนิดประเภทของฟิลด์เรียงต่อกันไปเฉยๆ ทูเพิลสตรักต์จะมีประโยชน์อย่างมากเมื่อนักพัฒนาต้องการจัดระบบโครงสร้างข้อมูลทูเพิลแบบรวมให้ได้รับการตั้งชื่อเฉพาะตัว และเพื่อให้ทูเพิลชุดนั้นถูกคอมไพเลอร์มองเป็นชนิดข้อมูลประเภทเฉพาะ (distinct type) ที่มีเอกเทศต่างจากกลุ่มทูเพิลทั่วไป และในกรณีที่การพยายามมานั่งนิยามชื่อฟิลด์แบบสตรักต์ทั่วไปจะดูรกรุงรังเกินความจำเป็น

การสร้างคำนิยามทูเพิลสตรักต์จะป้อนระบุโดยเริ่มจากคำสำคัญ struct ตามด้วยชื่อประเภทโครงสร้าง และเปิดวงเล็บระบุชนิดข้อมูลต่างๆ เรียงต่อกันไป ยกตัวอย่างเช่นตรงจุดนี้เราเขียนนิยามและเรียกใช้งานทูเพิลสตรักต์ 2 ประเภทชื่อว่า Color และ Point:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

พึงสังเกตสเปกว่าค่าตัวแปรอ็อบเจกต์ในตัวแปร black และ origin จะนับแยกประเภทเป็นชนิดข้อมูลคนละชนิดกันอย่างถาวร (different types) เนื่องจากพวกมันเกิดมาจากการประกาศตัวแทนของทูเพิลสตรักต์คนละตัวกัน โครงสร้างสตรักต์ใดๆ ที่นักพัฒนานิยามขึ้นมาจะถูกคอมไพเลอร์มองเป็นชนิดข้อมูลเอกเทศเฉพาะตัวเสมอ แม้ว่าข้อมูลไส้ในฟิลด์ของสตรักต์เหล่านั้นจะประกาศประเภทชนิดข้อมูลตรงกันทุกสเปกก็ตาม เช่น ฟังก์ชันใดๆ ที่ออกแบบกำหนดให้รับพารามิเตอร์ชนิด Color จะปฏิเสธการสลับนำเอาตัวแปรชนิด Point ส่งผ่านเข้าไปเป็นอาร์กิวเมนต์เด็ดขาด แม้ว่าทั้งคู่จะมีองค์ประกอบย่อยเป็นชนิดตัวเลข i32 สามค่าเรียงต่อกันเหมือนกันก็ตาม ส่วนการหยิบใช้งานตัวแทนทูเพิลสตรักต์ส่วนอื่นจะมีพฤติกรรมสอดคล้องกับทูเพิลปกติทั่วไป กล่าวคือคุณสามารถเขียนกระจายโครงสร้างตัวแปร (destructure) แตกออกมาเป็นรายการเดี่ยวๆ หรือสามารถเขียนระบุเครื่องหมายจุด . ตามด้วยหมายเลขลำดับดัชนี (index) เพื่อเข้าอ่านข้อมูลรายตําแหน่งได้ แต่สิ่งที่แตกต่างจากทูเพิลคือ ทูเพิลสตรักต์ต้องการให้นักพัฒนาป้อนระบุชื่อประเภทชนิดของสตรักต์พ่วงไปด้วยยามต้องการสั่งกระจายโครงสร้างตัวแปร เช่น เราจะป้อนประโยคระบุเป็น let Point(x, y, z) = origin; เพื่อกระจายข้อมูลในพิกัดจุด origin มาเก็บแยกไว้ในตัวแปรแยกย่อยชื่อ x, y และ z ตามลำดับ

การนิยามสตรักต์แบบว่างเปล่า (Defining Unit-Like Structs)

คุณยังสามารถนิยามโครงสร้างสตรักต์ที่มีรูปแบบว่างเปล่าไม่มีฟิลด์บรรจุภายในเลยได้อีกด้วย! ซึ่งจะถูกขนานนามว่า ยูนิตสตรักต์ (unit-like structs) เนื่องจากพวกมันทำงานตอบสนองและทำตัวคล้ายกับชนิดข้อมูลยูนิต () ที่เราเอ่ยถึงในหัวข้อ “ชนิดข้อมูลแบบทูเพิล” โครงสร้างยูนิตสตรักต์จะมีประโยชน์อย่างมากเมื่อสภาวะโปรแกรมมีความจำเป็นต้องเข้าเขียนตราคุณสมบัติเทรต (trait) ครอบใส่ตัวแปรชนิดประเภทใดประเภทหนึ่ง แต่เราปราศจากความต้องการจดบันทึกเนื้อหาข้อมูลใดๆ ไว้ในตัวแปรชนิดประเภทนั้นเลย เราจะได้มาพูดคุยรายละเอียดลึกซึ้งเรื่องตราเทรตในบทที่ 10 ตัวอย่างการเขียนประโยคประกาศนิยามและสร้างตัวแทน instance ของยูนิตสตรักต์ชื่อ AlwaysEqual มีทิศทางดังนี้:

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

การสร้างรหัสนิยามของ AlwaysEqual ป้อนระบุโดยพิมพ์คำสำคัญ struct ตามด้วยชื่อที่ต้องการและตบท้ายคำสั่งด้วยเครื่องหมายอัฒภาค ; ทันที โดยปราศจากความจำเป็นต้องใส่วงเล็บปีกกาหรือวงเล็บกลมใดๆ พ่วงประกบ! จากนั้นตอนสั่งจัดสร้างตัวแทน instance มาเก็บในตัวแปร subject ก็ใช้วิธีเรียกผ่านชื่อที่เรากำหนดขึ้นเดี่ยวๆ โดยไม่ต้องเปิดประกบวงเล็บปีกกาหรือวงเล็บกลมเช่นกัน ให้นึกภาพสมมติว่าในอนาคตอันใกล้เราต้องการเขียนออกแบบพฤติกรรมเฉพาะให้แก่ชนิดประเภทนี้ เพื่อกำหนดให้ตัวแทนของ AlwaysEqual ทุกๆ ตัวมีค่าเปรียบเทียบเท่ากับอ็อบเจกต์ชนิดประเภทอื่นใดก็ได้เสมอ เช่น เพื่อประโยชน์ในการทำสถานะรายงานผลการทดสอบระบบ (testing) ซึ่งกระบวนการประกาศพฤติกรรมลักษณะนี้เราไม่จำเป็นต้องนำเนื้อหาข้อมูลฟิลด์ใดๆ มาร่วมทำงานเลย คุณจะรับทราบกระบวนการสเปกตราเทรตและขั้นตอนการนำไปประดับใส่ให้แก่ชนิดประเภทข้อมูลย่อยต่างๆ รวมถึงยูนิตสตรักต์ลักษณะนี้ได้ในบทที่ 10 ครับ

สิทธิ์การครอบครองและโครงสร้างข้อมูลภายใน Struct (Ownership of Struct Data)

ในซอร์สโค้ดคำนิยาม struct User ประจำรายการที่ 5-1 เราจงใจเลือกใช้ชนิดประเภทข้อมูลที่ถือสิทธิ์ครอบครองเนื้อความได้เองอย่างชนิด String แทนชนิดข้อมูลส่วนแบ่งข้อความประเภท &str สิ่งนี้ถือเป็นการตัดสินใจออกแบบโครงสร้างโปรเจกต์ที่สอดคล้องกับเจตจำนงของเรา เนื่องจากเราต้องการให้ตัวแทน instance แต่ละตัวของ struct มีสิทธิ์ขาดครอบครองข้อมูลภายในของตนเองอย่างสมบูรณ์ และทำให้ข้อมูลเหล่านั้นคงอยู่และถูกต้องตราบเท่าที่สตรักต์ตัวแม่ขอบเขตหลักยังมีสถานะทำงานอยู่

นอกจากนี้ โครงสร้าง structs ยังสามารถรับรองการเก็บตัวแปรอ้างอิงชี้ไปยังชิ้นข้อมูลของตัวแปรอื่นข้างนอกระบบได้เช่นกัน แต่ทว่าจำเป็นต้องนำความสามารถเรื่อง อายุขัยช่วงเวลาครอบครอง (lifetimes) ซึ่งเป็นเครื่องมือพิเศษของ Rust ที่เราจะพูดคุยในบทที่ 10 มาช่วยประคองคุมพฤติกรรม ระบบ lifetimes จะช่วยการันตีค้ำสิทธิ์ความปลอดภัยให้มั่นใจได้ว่า ข้อมูลอ้างอิงภายนอกที่ struct เรียกใช้ จะยังคงสถานะสมบูรณ์ตราบเท่าอายุขัยของสตรักต์ตัวแม่นั้นยังไม่มอดม้วย สมมติว่าหากคุณฝืนป้อนพิมพ์รหัสตัวแปรอ้างอิงใส่ในสตรักต์โดยหลีกเลี่ยงการเขียนสเปก lifetimes ดังตัวอย่างในโค้ด src/main.rs ด้านล่างนี้ คอมไพเลอร์ Rust จะไม่อนุญาตให้รันผ่าน:

struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

ตัวคอมไพเลอร์จะส่งเสียงแจ้งเตือนสกัดบ่นรายงานความต้องการกำกับตรา lifetime specifiers:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

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

An Example Program Using Structs

ตัวอย่างโปรแกรมการใช้งานโครงสร้างข้อมูล Struct (An Example Program Using Structs)

เพื่อทำความเข้าใจว่าในสถานการณ์ใดที่เรามีความเหมาะสมในการดึงเอา structs มาประยุกต์ใช้งาน ลองมาเขียนโปรแกรมคณิตศาสตร์คำนวณพื้นที่รูปสี่เหลี่ยมผืนผ้าด้วยกัน โดยเราจะเริ่มดีไซน์โปรแกรมขึ้นมาจากรูปแบบการใช้ตัวแปรเดี่ยวแยกตำแหน่งกันปกติก่อน แล้วหลังจากนั้นค่อยปรับโครงสร้างโค้ด (refactor) ขยับขยายเปลี่ยนมาประกาศใช้โครงสร้าง structs เข้ามาแทนที่ตามลำดับ

ลองมาจัดตั้งโปรเจกต์รันไบนารีตัวใหม่ด้วย Cargo ชื่อว่า rectangles ซึ่งจะคอยรับค่าสเปกความกว้างและความสูงของรูปสี่เหลี่ยมผืนผ้าคิดคำนวณมีหน่วยวัดเป็นพิกเซล (pixels) และหาคำตอบพื้นที่รูปสี่เหลี่ยมดังกล่าว รายการที่ 5-8 แสดงตัวอย่างซอร์สโค้ดฉบับย่อของวิธีเขียนโปรแกรมดำเนินการสิ่งนั้นเก็บไว้ในไฟล์ src/main.rs ของโปรเจกต์เรา:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

คราวนี้ ทดลองสั่งประมวลผลรันโปรแกรมนี้ผ่านคำสั่ง cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

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

ประเด็นปัญหาสำคัญของโค้ดชุดนี้จะปรากฏให้สังเกตเห็นได้ชัดในส่วนของสเปกลายเซ็นของฟังก์ชัน area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

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

การปรับปรุงโครงสร้างโค้ดด้วยการใช้ทูเพิล (Refactoring with Tuples)

รายการที่ 5-9 แสดงตัวอย่างโปรแกรมอีกเวอร์ชันหนึ่งที่ปรับเปลี่ยนโครงสร้างหันมาหยิบเอาทูเพิลมาเขียนประยุกต์ใช้งานแทน:

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

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

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

การปรับปรุงโครงสร้างโค้ดด้วยการใช้สตรักต์ (Refactoring with Structs)

เราเลือกประยุกต์ใช้งาน structs เพื่อป้อนเติมคุณค่าความหมายให้แก่ตัวแปรผ่านการติดป้ายชื่อระบุกำกับข้อมูล โดยเราสามารถแปลงข้อมูลทูเพิลเดิมของโปรเจกต์ให้ขยับสภาพไปเป็นสตรักต์สำเร็จรูปที่มีชื่อหลักคุมอ็อบเจกต์รวมถึงมีการตั้งชื่อระบุให้แก่ตัวแปรฟิลด์ย่อยแต่ละองค์ประกอบอย่างมีระบบระเบียบ ดังแสดงในรายการที่ 5-10:

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

จากโค้ดตรงจุดนี้ เราเขียนคำนิยามกำหนดโครงสร้าง struct ขึ้นมาโดยระบุตั้งชื่อหลักให้มันว่า Rectangle ภายในวงเล็บปีกกาเราประกาศปักป้ายฟิลด์ย่อยเป็น width และ height โดยกำหนดชนิดข้อมูลของทั้งคู่เป็นประเภทตัวเลข u32 หลังจากนั้นในส่วนของฟังก์ชัน main เราสร้างตัวแทน instance ของ Rectangle ขึ้นมาโดยป้อนกำหนดความกว้างเป็น 30 และความสูงเป็น 50

ฟังก์ชัน area ของเราในรอบนี้จะถูกกำหนดสเปกยอมรับพารามิเตอร์เพียงตัวเดียว โดยเราตั้งชื่อตัวแปรนั้นว่า rectangle สเปกชนิดตัวแปรประกาศเป็นประเภทการขอยืมอ้างอิงแบบแก้ไขไม่ได้ (immutable borrow) ชี้ไปหาตัวแทนอ็อบเจกต์สตรักต์ Rectangle ดังที่เราเรียนรู้กันไปในบทที่ 4 การเลือกใช้วิธียืมใช้ตัวแปรแทนที่การโอนย้ายสิทธิ์ครอบครองหน่วยความจำ จะช่วยเอื้ออำนวยให้ขอบเขตของฟังก์ชัน main ยังคงถือครองกรรมสิทธิ์ความปลอดภัยในตัวแปร rect1 สลักไว้ประมวลผลบรรทัดล่างต่อไปได้ และเป็นสาเหตุสำคัญที่เราจำเป็นต้องใส่เครื่องหมายแอมเพอร์แซนด์ & เคียงคู่ลายเซ็นฟังก์ชันและระบุแนบพ่วงในการสั่งส่งผ่านอาร์กิวเมนต์

ภายในฟังก์ชัน area จะเข้าอ่านข้อมูลฟิลด์ width และ height ออกมาจากสตรักต์ตัวแทน Rectangle (พึงสังเกตว่าการเขียนดึงข้อมูลเฉพาะฟิลด์ออกจากอ็อบเจกต์ตัวอ้างอิงของสตรักต์ จะไม่มีผลลัพธ์ทำให้เกิดการย้ายสิทธิ์ครอบครองหน่วยความจำของข้อมูลฟิลด์ตัวนั้นๆ แต่อย่างใด นี่จึงเป็นเหตุผลที่วิถีการเขียนขอยืมอ้างอิงสตรักต์จึงได้รับความนิยมอย่างแพร่หลาย) ลายเซ็นของฟังก์ชัน area ของเราจะบ่งชี้จุดประสงค์โปรแกรมอย่างแจ่มชัด: คือดำเนินการคำนวณพื้นที่ของ Rectangle ผ่านการเรียกหยิบข้อมูลในฟิลด์ width และ height สิ่งนี้สะท้อนความสัมพันธ์ว่าทั้งความกว้างและความสูงเป็นองค์ประกอบที่มัดรวมเกี่ยวเนื่องกัน และยังได้มีชื่อตัวแปรช่วยบอกกล่าวความหมายข้อมูล แทนที่จะเขียนแบบเดาใจดัชนีเลข 0 และ 1 ของทูเพิล ช่วยส่งเสริมความเคลียร์และอ่านง่ายของโค้ดโปรแกรมขึ้นมาอย่างชัดเจน

การเสริมสมรรถนะตัวแปรด้วยเทรตสืบทอด (Adding Functionality with Derived Traits)

ในขั้นตอนการสืบหาบั๊กและดีบั๊กโปรแกรม (debugging) นักพัฒนาย่อมมีความต้องการความสะดวกในการเขียนสั่งให้โปรแกรมคอยพิมพ์รายงานค่าข้อมูลตัวแทน instance ของ Rectangle ออกมาดูสเปกฟิลด์ทุกรายการ รายการที่ 5-11 จึงลองเขียนรหัสระบุสั่งเรียกมาโคร println! ทำนองเดียวกับที่เราเคยทำในบทก่อนๆ แต่ทว่าวิธีการนี้จะประสบความล้มเหลวไม่ผ่านคอมไพล์:

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}

เมื่อเราสั่งคอมไพล์โค้ดโปรแกรมชิ้นนี้ เราจะได้รับข้อมูลรายงานความผิดพลาดซึ่งมีใจความหลักระบุว่า:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

ตัวมาโคร println! ได้รับการตั้งสเปกให้มีความสามารถรองรับการฟอร์แมตจัดวางรูปแบบหน้าตาข้อความได้หลากหลายประการ โดยเกณฑ์มาตรฐานทั่วไปแล้ว สัญลักษณ์วงเล็บปีกกาเปล่า {} จะเป็นการส่งสัญญาณบงการบอกให้มาโครเลือกฟอร์แมตข้อมูลในรูปแบบที่เรียกว่า Display ซึ่งเป็นรูปแบบการแสดงผลแบบสะอาดสายตาที่มุ่งเน้นสำหรับการนำไปแสดงผลตรงถึงมือผู้ใช้งานทั่วไป ชนิดข้อมูลพื้นฐานทั่วไป (primitive types) ทั้งหมดที่เราเรียนรู้กันมาจะมีการแนบตราความสามารถ Display ติดตัวมาให้ตั้งแต่แรกเริ่ม เนื่องจากทิศทางการแสดงผลข้อมูลตัวเลขเช่นเลข 1 หรือข้อมูลพื้นฐานอื่นๆ จะมีทิศทางเดียวที่เดาได้ง่าย แต่พอขยับมาสเปกโครงสร้างเชิงซ้อนแบบ structs แนวทางการฟอร์แมตหน้าตาข้อความของ println! จะคลุมเครือขึ้นมากเนื่องจากมีมิติแนวทางจัดวางได้หลายแบบ: คุณต้องการให้พิมพ์คั่นด้วยเครื่องหมายจุลภาคคอมมาหรือไม่? คุณอยากให้พิมพ์วงเล็บปีกกาโชว์ขึ้นมาด้วยไหม? ควรพิมพ์แสดงเนื้อหาของฟิลด์ทุกตัวออกมาครบถ้วนหรือเปล่า? ด้วยทิศทางที่เดาเจตนายากลักษณะนี้ Rust จึงเลือกที่จะไม่พยายามสุ่มเดาใจนักพัฒนา และจัดให้โครงสร้างสตรักต์แบบแต่งเองไม่มีข้อกำหนดการทำงานของ Display ติดตัวมาให้สวมใส่กับวงเล็บปีกกา {} เปล่าใน println!

หากเราขยับสายตาลงไปอ่านข้อมูลข้อผิดพลาดถัดลงมาด้านล่างต่อ จะพบคำอธิบายช่วยแนะนำชี้ทางดังนี้:

help: the trait `std::fmt::Display` is not implemented for `Rectangle`
  --> src/main.rs:1:1

ลองทำตามคำแนะนำกันดูครับ! หน้าตาการสั่งการมาโคร println! ของเราในรอบนี้จะเปลี่ยนสเปกเป็น println!("rect1 is {rect1:?}"); การพิมพ์แทรกชุดอักษรพิเศษ :? ไว้ข้างในวงเล็บปีกกาจะเป็นการสะกิดบอกกล่าวให้ println! เลือกฟอร์แมตข้อความขากลับออกมาในรูปแบบที่เรียกว่า Debug โดยตราเทรต Debug จะคอยช่วยประมวลผลจัดการพิมพ์สตรักต์ของให้ออกมาในรูปที่เป็นมิตรและมีประโยชน์ต่อเหล่านักพัฒนา เพื่อให้เราแอบส่องดูค่าข้อมูลภายในได้สะดวกยามสืบหาจุดบกพร่องของโค้ด

สั่งคอมไพล์โค้ดอีกรอบหลังขยับรหัสเสร็จ ปัดโธ่! โปรแกรมยังคงรันไม่ผ่านและแจ้งรายงานความล้มเหลวโชว์มาอีกรอบ:

error[E0277]: `Rectangle` doesn't implement `Debug`

แต่อย่างไรก็ตาม ตัวคอมไพเลอร์ที่แสนดีก็หยิบยื่นคำแนะนำดีๆ มาประดับเพิ่มให้อีกแถว:

   |                        required by this formatting parameter
   |

Rust มี กลไกจัดทำระบบเตรียมประมวลผลข้อมูลการพิมพ์เพื่อดีบั๊ก (debugging information) ตระเตรียมไว้ให้เบื้องหลังอยู่แล้วจริง เพียงแต่สตรักต์ที่เรานิยามสร้างขึ้นมาใหม่จำเป็นต้องทำการยื่นประสงค์ขอรับรองรับสิทธิ์การใช้งานนั้นอย่างเป็นทางการก่อน วิธีสั่งการง่ายมากโดยเขียนระบุชุดตราคุณลักษณะเสริมพิเศษภายนอกในรูปคำสั่ง #[derive(Debug)] วางแปะไว้ที่บรรทัดด้านบนสุดตรงจุดเหนือนิยามความหมายของสตรักต์พอดี ดังแสดงให้เห็นในรายการที่ 5-12:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}

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

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

ยอดเยี่ยมมาก! แม้ว่าข้อมูลขากลับที่จัดหน้าพ่นออกมาจะยังไม่ได้ดูเรียบร้อยสวยงามมากนัก แต่มันแสดงสาระสเปกค่าข้อมูลในฟิลด์ทุกรายการของตัวแปรอ็อบเจกต์ตัวแทนให้ตรวจสอบดูได้ครบครัน ซึ่งช่วยเพิ่มประสิทธิภาพแก่การดีบั๊กเป็นอย่างมาก และในกรณีที่โครงสร้างสตรักต์ในระบบของคุณสะสมฟิลด์ไว้ปริมาณเยอะขึ้น การแสดงผลเป็นแถวยาวเดี่ยวอาจจะเริ่มกวาดสายตาอ่านได้ยาก คุณสามารถขยับเปลี่ยนชนิดจัดฟอร์แมตหันมาเขียนพิมพ์ในรูป {:#?} แทนสัญลักษณ์เครื่องหมาย {:?} ปกติในประโยคของ println! ได้ ซึ่งผลลัพธ์ของสไตล์ {:#?} ของโจทย์ข้อนี้จะมีพฤติกรรมแสดงรายงานข้อมูลแบบเว้นวรรคบรรทัดสวยงามดังนี้:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

นอกจากนี้ยังมีแนวทางอำนวยความสะดวกในการพิมพ์ตรวจสอบมูลค่าข้อมูลโดยสั่งฟอร์แมตในรูปแบบ Debug อีกหนึ่งเส้นทาง คือเรียกใช้งานมาโคร dbg! ซึ่งมาโครตัวนี้จะมีเงื่อนไขพฤติกรรมการประมวลผลคือจะโอนย้ายยึดครองสิทธิ์ (takes ownership) ของนิพจน์คำสั่งที่ป้อนเข้าไปประมวลผล (ต่างจากมาโคร println! ที่ใช้วิธีดึงตัวอ้างอิงขอยืมไปอ่านเฉยๆ) จากนั้นจะดำเนินการพ่นระบุแจ้งข้อมูลประวัติชื่อไฟล์ซอร์สโค้ดและเลขที่บรรทัดของจุดที่มีการเรียกใช้งานมาโคร dbg! โชว์ประกบขึ้นมาคู่ไปกับเนื้อหาผลลัพธ์ข้อมูลนิพจน์นั้นๆ และดำเนินการคืนสิทธิ์ครอบครอง (returns ownership) ขากลับไปให้ตัวแปรปลายทางหยิบไปใช้ต่อได้ตามครรลอง

หมายเหตุ: ขั้นตอนการประมวลผลสั่งรันมาโคร dbg! จะจัดส่งข้อความรายงานออกไปที่ช่องคอนโซลส่งผ่านข้อผิดพลาดมาตรฐาน (stderr) ซึ่งจะต่างจากพฤติกรรมทำงานของมาโคร println! ที่ประมวลผลส่งรายงานข้อความออกทางช่องรายงานผลลัพธ์มาตรฐานปกติ (stdout) เราจะได้มาร่วมศึกษารายละเอียดเชิงเทคนิคเรื่องช่องทางข้อมูล stderr และ stdout ได้ใน บทที่ 12 หัวข้อ “การเปลี่ยนทิศทางส่งผ่านข้อความไปยังคอนโซลข้อผิดพลาดมาตรฐาน”

ต่อไปนี้เป็นภาพสถานการณ์ตัวอย่างจริงยามเรามีความสนใจประสงค์ใคร่รู้ข้อมูลขากลับที่เกิดขึ้นตอนประมวลผลและต้องบันทึกลงไปในฟิลด์ width รวมถึงต้องการดีบั๊กดูสภาพอ็อบเจกต์รวมของ rect1 ไปพร้อมๆ กัน:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

เราสามารถเขียนขีดครอบมาโคร dbg! ใส่ในนิพจน์ส่วนการประมวลผลอย่าง 30 * scale ได้อย่างอิสระ และเนื่องจากตัวมาโคร dbg! คืนกรรมสิทธิ์ความเป็นเจ้าของของผลลัพธ์ขากลับให้ปกติ ฟิลด์ width จึงได้รับค่าคณิตศาสตร์ทำงานเหมือนเสมือนไม่มีมาโคร dbg! ไปครอบไว้ตั้งแต่แรก และเนื่องจากในบรรทัดด้านล่างสุดเราปราศจากความต้องการให้ dbg! ไปยึดสิทธิ์กรรมสิทธิ์ตัวแปรหลักของ rect1 ไปถาวร เราจึงเลือกส่งผ่านเป็นตัวอ้างอิงของ rect1 ในรูป &rect1 ในการเรียกใช้งานรอบถัดไปแทน รายละเอียดหน้าตาข้อความขากลับของโค้ดชุดนี้ปรากฏดังนี้:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

เราสามารถตรวจสอบรายงานบรรทัดแรกที่พ่นตอบขากลับมาจากจุดอ้างอิงของไฟล์ src/main.rs บรรทัดที่ 10 ซึ่งบ่งบอกพฤติกรรมวิเคราะห์สูตร 30 * scale และส่งคืนตัวเลขคำตอบคือ 60 (พฤติกรรมฟอร์แมต Debug ของเลขจำนวนเต็มปกติทั่วไปคือโชว์เฉพาะตัวเลขดิบๆ) และสำหรับตัวดีบั๊กของมาโคร dbg! บรรทัดแถวที่ 14 ของไฟล์ src/main.rs จะแสดงผลรายงานพิกัดโครงสร้างวัตถุสตรักต์ Rectangle ของข้อมูลอ้างอิง &rect1 ซึ่งการจัดหน้าข้อความขากลับนี้จะถูกสั่งรันขยับหน้าตาจัดเรียงออกมาสวยงามตามพิมพ์เขียวของ Debug ที่เราประดับใส่ไว้ให้แก่สตรักต์ Rectangle มาโคร dbg! ถือเป็นผู้ช่วยมือขวาที่แสนสะดวกยามนักพัฒนาต้องการสำรวจเส้นทางการประมวลผลแถวรหัสคำสั่งของโปรเจกต์!

นอกเหนือจากตราเทรต Debug ที่แนะนำกันไปเบื้องต้น Rust ได้จัดเตรียมตราคุณสมบัติเทรตเด่นๆ สำเร็จรูปอื่นอีกเป็นจำนวนมากให้เราดึงมาประดับสวมใส่ประยุกต์ใช้ร่วมกับคำสั่ง derive เพื่อติดป้ายมอบความสามารถเก่งๆ พ่วงเข้าใส่ชนิดข้อมูลแต่งเองของเราได้ทันที รายการเทรตพิเศษเหล่านั้นและพฤติกรรมการทำงานของพวกมันได้รับการบันทึกรวบรวมไว้ให้ศึกษาใน ภาคผนวก C เราจะมาร่วมลุยศึกษาวิธีการเขียนนิยามข้อกำหนดเทรตขึ้นมาประยุกต์ใช้งานด้วยตัวคุณเองอย่างละเอียดรวมถึงกระบวนการเขียนเทรตสร้างเองได้ในบทที่ 10 นอกจากนี้ในระบบยังมีตราสัญลักษณ์ลักษณะ (attributes) อื่นๆ อีกสารพัดประโยชน์นอกเหนือไปจาก derive สามารถศึกษาเนื้อหาเชิงลึกเพิ่มเติมต่อได้ใน หัวข้อ “คุณลักษณะประดับ (Attributes)” ของเอกสารอ้างอิงภาษา Rust (Rust Reference)

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

Methods

เมธอด (Methods)

เมธอดมีลักษณะคล้ายกับฟังก์ชัน: เราประกาศเมธอดด้วยคีย์เวิร์ด fn และตามด้วยชื่อ สามารถมีพารามิเตอร์และค่าส่งกลับได้ รวมถึงมีโค้ดบางส่วนที่จะถูกรันเมื่อเมธอดนั้นถูกเรียกใช้งานจากที่อื่น แต่สิ่งที่ต่างจากฟังก์ชันคือ เมธอดจะถูกกำหนดขึ้นภายใต้บริบทของโครงสร้างข้อมูล (struct) (หรือ enum หรือ trait object ซึ่งเราจะกล่าวถึงในบทที่ 6 และบทที่ 18 ตามลำดับ) และพารามิเตอร์แรกของเมธอดจะเป็น self เสมอ ซึ่งเป็นตัวแทนของอินสแตนซ์ของ struct ที่เมธอดนั้นกำลังถูกเรียกใช้งาน

ไวยากรณ์เมธอด

เรามาลองเปลี่ยนฟังก์ชัน area ที่รับอินสแตนซ์ของ Rectangle เป็นพารามิเตอร์ ให้กลายเป็นเมธอด area ที่ถูกกำหนดไว้บน struct Rectangle แทน ดังแสดงในรายการโค้ดที่ 5-13

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

ในการกำหนดฟังก์ชันภายใต้บริบทของ Rectangle เราจะเริ่มด้วยการสร้างบล็อก impl (implementation) สำหรับ Rectangle โดยทุกอย่างภายในบล็อก impl นี้จะเชื่อมโยงกับชนิดข้อมูล Rectangle จากนั้นเราจะย้ายฟังก์ชัน area เข้าไปไว้ในวงเล็บปีกกาของ impl และเปลี่ยนพารามิเตอร์แรก (ซึ่งในกรณีนี้คือพารามิเตอร์เดียว) ให้เป็น self ในส่วนหัวประกาศ (signature) และทุกจุดภายในตัวฟังก์ชัน (body) ในฟังก์ชัน main ที่เราเคยเรียกใช้ฟังก์ชัน area และส่ง rect1 เป็นอาร์กิวเมนต์ เราสามารถเปลี่ยนมาใช้ ไวยากรณ์เมธอด (method syntax) เพื่อเรียกเมธอด area บนอินสแตนซ์ของ Rectangle แทนได้ ไวยากรณ์เมธอดจะอยู่ต่อท้ายอินสแตนซ์ โดยเราจะใส่จุดตามด้วยชื่อเมธอด วงเล็บ และอาร์กิวเมนต์ต่าง ๆ (ถ้ามี)

ในส่วนหัวของเมธอด area เราใช้ &self แทน rectangle: &Rectangle ซึ่งอันที่จริงแล้ว &self เป็นรูปย่อของ self: &Self โดยภายในบล็อก impl ชนิดข้อมูล Self จะเป็นนามแฝง (alias) แทนชนิดข้อมูลที่บล็อก impl นั้นเขียนขึ้นเพื่อมัน เมธอดจะต้องมีพารามิเตอร์แรกชื่อ self ที่มีชนิดข้อมูลเป็น Self ดังนั้น Rust จึงอนุญาตให้คุณย่อเหลือเพียงแค่ชื่อ self ในตำแหน่งพารามิเตอร์แรกได้ ทั้งนี้ สังเกตว่าเรายังคงต้องใช้เครื่องหมาย & ไว้หน้าคำย่อ self เพื่อระบุว่าเมธอดนี้ทำการขอยืม (borrow) อินสแตนซ์ของ Self เช่นเดียวกับที่เราทำใน rectangle: &Rectangle เมธอดสามารถรับสิทธิ์ความเป็นเจ้าของ (ownership) ของ self หรือทำการขอยืม self แบบอ่านอย่างเดียว (immutably) ดังที่เราทำในตัวอย่างนี้ หรือทำการขอยืม self แบบแก้ไขได้ (mutably) ก็ได้เช่นเดียวกับพารามิเตอร์อื่น ๆ

เราเลือกใช้ &self ในที่นี้ด้วยเหตุผลเดียวกับที่เราใช้ &Rectangle ในแบบฟังก์ชัน นั่นคือเราไม่ต้องการครอบครองสิทธิ์ความเป็นเจ้าของ (ownership) และเราเพียงต้องการอ่านข้อมูลใน struct เท่านั้น ไม่ต้องการเขียนหรือแก้ไขข้อมูล หากเราต้องการแก้ไขอินสแตนซ์ที่เราเรียกใช้งานเมธอดในฐานะส่วนหนึ่งของการทำงานของเมธอด เราจะใช้ &mut self เป็นพารามิเตอร์แรก ทั้งนี้ เมธอดที่รับสิทธิ์ความเป็นเจ้าของอินสแตนซ์ด้วยการใช้ self เพียงอย่างเดียวเป็นพารามิเตอร์แรกนั้นเกิดขึ้นได้ค่อนข้างยาก โดยทั่วไปเทคนิคนี้จะใช้เมื่อเมธอดนั้นแปลง self ไปเป็นอย่างอื่น และคุณต้องการป้องกันไม่ให้ผู้เรียกใช้กลับมาใช้งานอินสแตนซ์เดิมหลังจากการแปลงดังกล่าว

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

นอกจากนี้ เรายังสามารถเลือกตั้งชื่อเมธอดให้เหมือนกับชื่อฟิลด์ใดฟิลด์หนึ่งของ struct ได้ด้วย ตัวอย่างเช่น เราสามารถกำหนดเมธอดบน Rectangle ที่มีชื่อว่า width เช่นเดียวกันได้ดังนี้:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

ในที่นี้เรากําหนดให้เมธอด width คืนค่าเป็น true หากค่าในฟิลด์ width ของอินสแตนซ์มีค่ามากกว่า 0 และคืนค่าเป็น false หากค่านั้นเป็น 0 ซึ่งเราสามารถใช้ฟิลด์ภายในเมธอดที่มีชื่อเดียวกันเพื่อวัตถุประสงค์ใด ๆ ก็ได้ ในฟังก์ชัน main เมื่อเราเรียกใช้ rect1.width และตามด้วยวงเล็บ Rust จะรู้ว่าเราหมายถึงเมธอด width แต่ถ้าเราไม่ใส่วงเล็บ Rust จะรู้ว่าเราหมายถึงฟิลด์ width แทน

บ่อยครั้ง (แต่ไม่เสมอไป) ที่การตั้งชื่อเมธอดให้เหมือนกับชื่อฟิลด์มีจุดประสงค์เพื่อให้มันคืนค่าในฟิลด์นั้น ๆ ออกมาเท่านั้นโดยไม่ต้องทำอย่างอื่น เมธอดในลักษณะนี้จะเรียกว่า เก็ตเตอร์ (getters) ซึ่งภาษา Rust ไม่ได้สร้างเก็ตเตอร์ให้อัตโนมัติสำหรับฟิลด์ของ struct เหมือนอย่างที่บางภาษาทำ เก็ตเตอร์มีประโยชน์เนื่องจากคุณสามารถกำหนดให้ฟิลด์นั้นเป็นแบบส่วนตัว (private) แต่กำหนดให้เมธอดเป็นแบบสาธารณะ (public) ได้ ซึ่งจะช่วยให้ผู้ใช้สามารถอ่านข้อมูลในฟิลด์นั้นได้อย่างเดียว (read-only) ผ่านทาง API สาธารณะของชนิดข้อมูลดังกล่าว เราจะอภิปรายเพิ่มเติมเกี่ยวกับการกำหนดสิทธิ์การเข้าถึงแบบ public และ private รวมถึงการกำหนดให้ฟิลด์หรือเมธอดเป็นแบบสาธารณะหรือส่วนตัวในบทที่ 7

ตัวดำเนินการ -> อยู่ที่ไหน?

ในภาษา C และ C++ จะใช้ตัวดำเนินการ 2 แบบที่แตกต่างกันในการเรียกใช้เมธอด ได้แก่ คุณจะใช้ . หากเรียกใช้เมธอดบนตัวอ็อบเจกต์โดยตรง และใช้ -> หากคุณเรียกใช้เมธอดบนตัวชี้ (pointer) ไปยังอ็อบเจกต์และจำเป็นต้องทำการลดระดับตัวชี้ (dereference) ก่อน พูดอีกนัยหนึ่งคือ ถ้า object เป็นตัวชี้ การเรียก object->something() จะเทียบเท่ากับ (*object).something()

ภาษา Rust ไม่มีตัวดำเนินการที่เทียบเท่ากับ -> แต่ Rust จะมีฟีเจอร์ที่เรียกว่า การอ้างอิงและลดระดับตัวชี้อัตโนมัติ (automatic referencing and dereferencing) ซึ่งการเรียกใช้งานเมธอดก็เป็นหนึ่งในไม่กี่แห่งใน Rust ที่มีพฤติกรรมในลักษณะนี้

วิธีการทำงานคือ เมื่อคุณเรียกเมธอดด้วย object.something() ทาง Rust จะใส่เครื่องหมาย &, &mut หรือ * ให้โดยอัตโนมัติ เพื่อให้ object มีรูปแบบข้อมูลตรงตามหัวประกาศ (signature) ของเมธอดนั้น ๆ พูดอีกอย่างหนึ่งก็คือ โค้ดต่อไปนี้มีค่าเท่ากัน:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

รูปแบบแรกนั้นดูสะอาดตาและอ่านง่ายกว่ามาก พฤติกรรมการสร้างการอ้างอิงอัตโนมัตินี้ทำงานได้เนื่องจากเมธอดมีผู้รับ (receiver) ที่ชัดเจน ซึ่งก็คือชนิดข้อมูลของ self เมื่อทราบข้อมูลผู้รับและชื่อของเมธอดแล้ว Rust จะสามารถระบุได้อย่างแน่ชัดว่าเมธอดดังกล่าวเป็นแบบอ่านอย่างเดียว (&self) แบบแก้ไขข้อมูล (&mut self) หรือแบบทำลายค่า (self) การที่ Rust ทำให้การขอยืม (borrowing) เป็นแบบโดยนัย (implicit) สำหรับตัวรับเมธอดนั้น ถือเป็นส่วนสำคัญที่ช่วยให้ระบบความเป็นเจ้าของ (ownership) สามารถใช้งานได้สะดวกสบายยิ่งขึ้นในทางปฏิบัติ

เมธอดที่มีพารามิเตอร์เพิ่มขึ้น

เรามาฝึกฝนการใช้เมธอดกันต่อโดยการเพิ่มเมธอดที่สองบน struct Rectangle โดยในครั้งนี้เราต้องการให้อินสแตนซ์หนึ่งของ Rectangle รับอินสแตนซ์อีกตัวของ Rectangle เข้ามา แล้วส่งกลับค่าเป็น true หากอินสแตนซ์ของ Rectangle ตัวที่สองสามารถบรรจุอยู่ภายใน self (อินสแตนซ์แรกของ Rectangle) ได้ทั้งหมดพอดี แต่ถ้าไม่สามารถบรรจุได้ ก็จะส่งกลับเป็น false ซึ่งหลังจากที่เราเขียนเมธอด can_hold นี้แล้ว เราต้องการให้สามารถเขียนโปรแกรมได้ดังที่แสดงในรายการโค้ดที่ 5-14

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

ผลลัพธ์ที่คาดหวังจะแสดงออกมาในลักษณะต่อไปนี้ เนื่องจากขนาดทั้งสองมิติของ rect2 มีขนาดเล็กกว่าขนาดของ rect1 แต่ rect3 มีความกว้างมากกว่า rect1:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

เรารู้ว่าต้องการสร้างเมธอด ดังนั้นจึงต้องเขียนไว้ภายในบล็อก impl Rectangle โดยชื่อเมธอดจะเป็น can_hold และจะรับการอ้างอิงขอยืมแบบแก้ไขไม่ได้ (immutable borrow) ของ Rectangle อีกตัวเข้ามาเป็นพารามิเตอร์ ซึ่งเราสามารถเดาชนิดข้อมูลของพารามิเตอร์นี้ได้จากการดูโค้ดตรงจุดที่เรียกใช้เมธอด ได้แก่ rect1.can_hold(&rect2) ที่ส่งค่า &rect2 เข้ามา ซึ่งหมายถึงการขอยืมแบบอ่านอย่างเดียวไปยัง rect2 ที่เป็นอินสแตนซ์ของ Rectangle เรื่องนี้สมเหตุสมผลเนื่องจากเราต้องการเพียงแค่อ่านข้อมูลจาก rect2 (ไม่ใช่ต้องการเขียนทับ ซึ่งถ้าเป็นแบบนั้นเราจะต้องใช้อ้างอิงแบบแก้ไขได้) และเราต้องการให้ main ยังคงมีความเป็นเจ้าของ rect2 อยู่ เพื่อที่เราจะนำมันไปใช้งานต่อได้หลังจากเรียกเมธอด can_hold เสร็จสิ้นแล้ว ส่วนค่าส่งกลับของ can_hold จะเป็นชนิดข้อมูลแบบบูลีน (Boolean) และส่วนการทำงานภายในจะตรวจสอบว่าทั้งความกว้างและความสูงของ self มีค่ามากกว่าความกว้างและความสูงของ Rectangle อีกตัวหนึ่งตามลำดับหรือไม่ เรามาเพิ่มเมธอดใหม่ can_hold นี้ลงในบล็อก impl จากรายการโค้ดที่ 5-13 ดังที่แสดงในรายการโค้ดที่ 5-15

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

เมื่อเรารันโค้ดชุดนี้พร้อมกับฟังก์ชัน main ในรายการโค้ดที่ 5-14 เราจะได้ผลลัพธ์ตามที่ต้องการ เมธอดต่าง ๆ สามารถรับพารามิเตอร์ได้หลายตัวโดยเพิ่มลงในหัวประกาศต่อจากพารามิเตอร์ self และพารามิเตอร์เหล่านั้นก็จะทำงานเหมือนพารามิเตอร์ในฟังก์ชันปกติทุกประการ

ฟังก์ชันที่เกี่ยวเนื่อง (Associated Functions)

ฟังก์ชันทั้งหมดที่กำหนดขึ้นภายในบล็อก impl จะเรียกว่า ฟังก์ชันที่เกี่ยวเนื่อง (associated functions) เนื่องจากฟังก์ชันเหล่านั้นมีความเชื่อมโยงกับชนิดข้อมูลที่ระบุไว้หลังคำว่า impl เราสามารถสร้างฟังก์ชันที่เกี่ยวเนื่องที่ไม่มีพารามิเตอร์แรกเป็น self ได้ (ซึ่งนั่นทำให้พวกมันไม่ใช่เมธอด) เนื่องจากพวกมันไม่ต้องการอินสแตนซ์ของชนิดข้อมูลนั้น ๆ ในการทำงาน ตัวอย่างเช่นเราเคยเรียกใช้ฟังก์ชันลักษณะนี้มาแล้ว คือฟังก์ชัน String::from ที่ถูกประกาศอยู่บนชนิดข้อมูล String

ฟังก์ชันที่เกี่ยวเนื่องที่ไม่มีสถานะเป็นเมธอด มักถูกนำมาใช้เป็นตัวสร้างอินสแตนซ์ (constructors) เพื่อคืนค่าเป็นอินสแตนซ์ใหม่ของ struct ซึ่งฟังก์ชันเหล่านี้มักตั้งชื่อว่า new แต่อย่างไรก็ตาม ชื่อ new ไม่ได้เป็นชื่อพิเศษหรือฟีเจอร์สำเร็จรูปของภาษาแต่อย่างใด ตัวอย่างเช่น เราสามารถเลือกให้บริการฟังก์ชันที่เกี่ยวเนื่องที่ชื่อว่า square ซึ่งรับพารามิเตอร์ความยาวด้านเพียงหนึ่งตัว และใช้ค่านั้นเป็นทั้งความกว้างและความสูง ซึ่งจะช่วยให้สร้างอินสแตนซ์ Rectangle ที่เป็นรูปสี่เหลี่ยมจัตุรัสได้ง่ายขึ้น โดยไม่ต้องระบุค่าเดิมซ้ำสองครั้ง:

ชื่อไฟล์: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

คีย์เวิร์ด Self ทั้งในชนิดข้อมูลที่ส่งกลับและภายในตัวฟังก์ชัน คือนามแฝงของชนิดข้อมูลที่ปรากฏหลังคีย์เวิร์ด impl ซึ่งในกรณีนี้ก็คือ Rectangle

ในการเรียกใช้งานฟังก์ชันที่เกี่ยวเนื่องนี้ เราจะใช้ไวยากรณ์ :: ร่วมกับชื่อ struct เช่น let sq = Rectangle::square(3); ฟังก์ชันนี้ถูกจัดให้อยู่ในขอบเขตชื่อ (namespace) ของ struct โดยไวยากรณ์ :: นั้นถูกใช้ทั้งในการเรียกใช้ฟังก์ชันที่เกี่ยวเนื่อง และสำหรับการเข้าถึงขอบเขตชื่อที่ถูกสร้างขึ้นโดยโมดูล (module) เราจะคุยเรื่องโมดูลอย่างละเอียดในบทที่ 7

บล็อก impl หลายบล็อก

โครงสร้างข้อมูลแต่ละตัวสามารถมีบล็อก impl ได้หลายบล็อก ตัวอย่างเช่น รายการโค้ดที่ 5-15 จะทำงานเทียบเท่ากับโค้ดที่แสดงในรายการโค้ดที่ 5-16 ซึ่งกำหนดให้แต่ละเมธอดอยู่แยกจากกันในบล็อก impl ของตัวเอง

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

ถึงแม้ว่าจะไม่มีเหตุผลจำเป็นต้องแยกเมธอดเหล่านี้ออกเป็นบล็อก impl หลายบล็อกในสถานการณ์ปัจจุบัน แต่นี่คือไวยากรณ์ที่ใช้การได้จริงและถูกต้อง เราจะได้เรียนรู้ถึงกรณีที่บล็อก impl หลายบล็อกจะมีประโยชน์จริง ๆ ในบทที่ 10 ซึ่งเราจะศึกษาเกี่ยวกับชนิดข้อมูลแบบทั่วไป (generic types) และเทรต (traits)

สรุป

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

อย่างไรก็ตาม struct ไม่ใช่วิธีเดียวที่คุณจะสร้างชนิดข้อมูลของตัวเองขึ้นมาได้ เรามาทำความรู้จักกับฟีเจอร์ enum ของภาษา Rust เพื่อเพิ่มเครื่องมืออีกชิ้นลงในกล่องเครื่องมือของคุณกันเถอะ

Enum และการจับคู่แพตเทิร์น (Pattern Matching)

ในบทนี้ เราจะมาศึกษากลุ่มข้อมูลแบบแจงนับ (enumerations) หรือที่เรียกสั้น ๆ ว่า enums (อีนัม) อีนัมช่วยให้คุณสามารถกำหนดชนิดข้อมูลได้โดยการแจงนับค่าที่เป็นไปได้ทั้งหมด (variants) ขั้นแรก เราจะกำหนดและใช้งานอีนัมเพื่อแสดงให้เห็นว่ามันสามารถสื่อความหมายควบคู่ไปกับข้อมูลได้อย่างไร ถัดไป เราจะสำรวจอีนัมที่มีประโยชน์เป็นพิเศษตัวหนึ่งที่ชื่อว่า Option ซึ่ง ใช้ระบุว่าค่าใด ๆ สามารถเป็นได้ทั้ง “มีค่าบางอย่าง” หรือ “ไม่มีค่าอะไรเลย” จากนั้นเราจะดู การจับคู่แพตเทิร์นด้วยนิพจน์ match ซึ่งช่วยให้การรันโค้ดที่แตกต่างกันสำหรับค่าต่าง ๆ ของอีนัมทำได้ง่ายขึ้น สุดท้ายนี้ เราจะครอบคลุมถึงโครงสร้างแบบ if let ซึ่ง เป็นอีกหนึ่งรูปแบบไวยากรณ์ (idiom) ที่สะดวกและกระชับในการจัดการกับอีนัมในโค้ดของคุณ

Defining an Enum

การกำหนดอีนัม (Defining an Enum)

ในขณะที่โครงสร้างข้อมูล (structs) ช่วยให้คุณจัดกลุ่มฟิลด์และข้อมูลที่เกี่ยวข้องกันเข้าไว้ด้วยกัน เช่น Rectangle ที่มี width และ height อีนัม (enums) จะเป็นวิธีที่ช่วยระบุว่าค่านั้น ๆ เป็นค่าใดค่าหนึ่งจากกลุ่มของค่าที่เป็นไปได้ทั้งหมด ตัวอย่างเช่น เราอาจต้องการบอกว่า Rectangle เป็นหนึ่งในรูปร่างที่เป็นไปได้ทั้งหมดในกลุ่มซึ่งยังรวมถึง Circle และ Triangle อีกด้วย และเพื่อทำสิ่งนี้ Rust จึงอนุญาตให้เราเข้ารหัสความน่าจะเป็นเหล่านี้เป็นอีนัม

เรามาดูสถานการณ์ที่เราอาจต้องการแสดงออกมาในรูปของโค้ด และศึกษาดูว่าเหตุใดอีนัมจึงมีประโยชน์และเหมาะสมมากกว่า struct ในกรณีนี้ สมมติว่าเราจำเป็นต้องทำงานเกี่ยวกับที่อยู่ไอพี (IP addresses) ซึ่งในปัจจุบันนี้มีมาตรฐานหลัก ๆ สองอย่างที่ใช้กันทั่วไปคือ เวอร์ชัน 4 (IPv4) และเวอร์ชัน 6 (IPv6) เนื่องจากค่าเหล่านี้เป็นเพียงสองความเป็นไปได้เดียวสำหรับที่อยู่ไอพีที่โปรแกรมของเราจะพบเจอ เราจึงสามารถทำการแจงนับ (enumerate) รูปแบบที่เป็นไปได้ทั้งหมดออกมาได้ ซึ่งนั่นก็คือที่มาของคำว่าการแจงนับ (enumeration) หรืออีนัมนั่นเอง

ที่อยู่ไอพีใด ๆ จะเป็นได้เพียงเวอร์ชัน 4 หรือไม่ก็เวอร์ชัน 6 เท่านั้น แต่จะไม่สามารถเป็นทั้งสองเวอร์ชันพร้อมกันได้ คุณสมบัติดังกล่าวของที่อยู่ไอพีทำให้อีนัมเป็นโครงสร้างข้อมูลที่เหมาะสมอย่างยิ่ง เนื่องจากค่าของอีนัมสามารถเป็นได้เพียงรูปแบบใดรูปแบบหนึ่ง (variant) ของตัวมันเองเท่านั้น ทั้งที่อยู่ไอพีเวอร์ชัน 4 และเวอร์ชัน 6 ต่างก็ยังเป็นที่อยู่ไอพีโดยพื้นฐานเหมือนกัน ดังนั้นโค้ดของเราจึงควรจัดการกับพวกมันเสมือนว่าเป็นชนิดข้อมูลเดียวกัน เมื่อจำเป็นต้องจัดการกับสถานการณ์ทั่วไปที่ใช้ได้กับที่อยู่ไอพีทุกประเภท

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

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

ตอนนี้ IpAddrKind กลายเป็นชนิดข้อมูลแบบกำหนดเองที่เราสามารถนำไปใช้งานที่อื่นในโค้ดของเราได้แล้ว

ค่าต่าง ๆ ของอีนัม (Enum Values)

เราสามารถสร้างอินสแตนซ์ของทั้งสองรูปแบบใน IpAddrKind ได้ดังนี้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

โปรดสังเกตว่าแต่ละรูปแบบ (variants) ของอีนัมจะอยู่ภายใต้ขอบเขตชื่อ (namespace) ของตัวระบุของอีนัมนั้น ๆ และเราจะใช้เครื่องหมายโคลอนคู่ (::) ในการแยกทั้งสองออกจากกัน ซึ่งสิ่งนี้มีประโยชน์มากเนื่องจากในตอนนี้ทั้งค่า IpAddrKind::V4 และ IpAddrKind::V6 ต่างก็มีชนิดข้อมูลเดียวกัน นั่นคือ IpAddrKind ดังนั้น ตัวอย่างเช่น เราจึงสามารถกำหนดฟังก์ชันที่รับพารามิเตอร์เป็นชนิดข้อมูล IpAddrKind ใด ๆ ก็ได้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

และเราสามารถเรียกใช้งานฟังก์ชันนี้ด้วยรูปแบบใดรูปแบบหนึ่งของอีนัมได้ดังนี้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

การใช้งานอีนัมยังมีข้อดีอื่น ๆ อีกมาก หากเราคิดทบทวนเพิ่มเติมเกี่ยวกับชนิดข้อมูลที่อยู่ไอพีของเรา ในขณะนี้เรายังไม่มีวิธีสำหรับจัดเก็บ ข้อมูล ที่อยู่ไอพีจริง ๆ เลย เราเพียงแค่รู้ว่ามันเป็น ประเภท ใดเท่านั้น เมื่อพิจารณาว่าคุณเพิ่งจะได้เรียนรู้เกี่ยวกับโครงสร้างข้อมูล (structs) ไปในบทที่ 5 คุณอาจอยากจะแก้ปัญหานี้ด้วยการใช้ struct ดังที่แสดงในรายการโค้ดที่ 6-1

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

ในที่นี้เรากำหนด struct ชื่อ IpAddr ที่มีสองฟิลด์ด้วยกัน ได้แก่ ฟิลด์ kind ซึ่งมีชนิดข้อมูลเป็น IpAddrKind (อีนัมที่เราเพิ่งกำหนดไปก่อนหน้านี้) และฟิลด์ address ซึ่งมีชนิดข้อมูลเป็น String จากนั้นเราได้สร้างสองอินสแตนซ์ของ struct นี้ โดยอินสแตนซ์แรกคือ home ซึ่งมีค่าใน kind เป็น IpAddrKind::V4 พร้อมข้อมูลที่อยู่ไอพีที่เชื่อมโยงกันเป็น 127.0.0.1 ส่วนอินสแตนซ์ที่สองคือ loopback ซึ่งมีค่า kind เป็นอีกหนึ่งรูปแบบของ IpAddrKind นั่นคือ V6 และมีที่อยู่ไอพีเป็น ::1 เชื่อมโยงอยู่ด้วย เราได้ใช้ struct เพื่อมัดรวมค่าประเภท (kind) และที่อยู่ (address) เข้าไว้ด้วยกัน ดังนั้นตอนนี้รูปแบบของไอพีจึงมีความสัมพันธ์เชื่อมโยงกับตัวข้อมูลแล้ว

อย่างไรก็ตาม การนำเสนอแนวคิดเดียวกันนี้โดยการใช้แค่อีนัมเพียงอย่างเดียวนั้นมีความกระชับมากกว่า กล่าวคือ แทนที่จะต้องใช้อีนัมซ้อนอยู่ภายใน struct เราสามารถนำข้อมูลไปจัดเก็บไว้ภายในแต่ละรูปแบบ (variants) ของอีนัมได้โดยตรง การกำหนดนิยามใหม่ของอีนัม IpAddr นี้ระบุว่า ทั้งรูปแบบ V4 และ V6 จะมีข้อมูลชนิด String เชื่อมโยงติดไปด้วย:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

การที่เราผูกข้อมูลเข้ากับรูปแบบแต่ละอย่างของอีนัมได้โดยตรงเช่นนี้ ทำให้ไม่มีความจำเป็นต้องใช้ struct เพิ่มเติมเข้ามาอีก และจากตรงนี้จะช่วยให้เห็นรายละเอียดที่น่าสนใจอีกอย่างของการทำงานของอีนัม นั่นคือ ชื่อของแต่ละรูปแบบของอีนัมที่เรากำหนดขึ้นนั้น จะทำหน้าที่เป็นฟังก์ชันที่สร้างอินสแตนซ์ของอีนัมด้วยเช่นกัน ซึ่งหมายความว่า IpAddr::V4() เป็นการเรียกใช้ฟังก์ชันที่รับอาร์กิวเมนต์เป็นชนิด String และคืนค่าออกมาเป็นอินสแตนซ์ชนิดข้อมูล IpAddr เราจะได้รับฟังก์ชันสร้างอินสแตนซ์ (constructor function) นี้โดยอัตโนมัติเป็นผลมาจากการกำหนดอีนัม

นอกจากนี้ยังมีข้อดีอีกประการหนึ่งในการใช้อีนัมแทน struct คือ แต่ละรูปแบบของอีนัมสามารถเก็บข้อมูลที่มีชนิดข้อมูลและจำนวนข้อมูลที่แตกต่างกันออกไปได้ ที่อยู่ไอพีเวอร์ชัน 4 มักจะประกอบด้วยส่วนประกอบตัวเลขสี่ตัวที่มีค่าอยู่ระหว่าง 0 ถึง 255 เสมอ หากเราต้องการจัดเก็บที่อยู่ไอพีแบบ V4 เป็นข้อมูลชนิด u8 จำนวนสี่ค่า แต่ยังต้องการจัดเก็บที่อยู่ไอพีแบบ V6 เป็นข้อมูลชนิด String เพียงค่าเดียว เราจะไม่สามารถทำแบบนี้ได้หากใช้งาน struct แต่อีนัมสามารถจัดการกรณีนี้ได้อย่างง่ายดาย:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

เราได้แสดงหลายวิธีในการกำหนดโครงสร้างข้อมูลเพื่อจัดเก็บที่อยู่ไอพีเวอร์ชัน 4 และเวอร์ชัน 6 ไปแล้ว อย่างไรก็ตาม ความต้องการในการจัดเก็บที่อยู่ไอพีและระบุว่ามันเป็นประเภทใดนั้น เป็นเรื่องที่พบบ่อยมากจนกระทั่งไลบรารีมาตรฐานมีคำจำกัดความให้เราสามารถนำไปใช้งานได้เลย! เรามาลองดูวิธีการที่ไลบรารีมาตรฐานกำหนด IpAddr กัน มันมีตัวอีนัมและรูปแบบต่าง ๆ ตรงกับที่เราได้กำหนดและใช้งานไปก่อนหน้านี้ทุกประการ เพียงแต่มันใช้วิธีฝังข้อมูลที่อยู่ไอพีไว้ข้างในรูปแบบต่าง ๆ ในรูปของโครงสร้างข้อมูลสองตัวที่แตกต่างกัน ซึ่งถูกกำหนดขึ้นมาเฉพาะสำหรับแต่ละรูปแบบ:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

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

โปรดทราบว่าแม้ไลบรารีมาตรฐานจะมีคำจำกัดความสำหรับ IpAddr อยู่แล้ว แต่เราก็ยังคงสามารถสร้างและใช้งานคำจำกัดความของเราเองได้โดยไม่เกิดความขัดแย้งใด ๆ เนื่องจากเราไม่ได้นำเข้า (import) นิยามของไลบรารีมาตรฐานเข้ามาในขอบเขตการทำงาน (scope) ของเรา เราจะพูดถึงรายละเอียดเกี่ยวกับการนำชนิดข้อมูลเข้ามาในขอบเขตการทำงานเพิ่มเติมในบทที่ 7

เรามาดูตัวอย่างอีนัมอีกตัวหนึ่งในรายการโค้ดที่ 6-2 ซึ่งอีนัมนี้จะมีประเภทของข้อมูลที่หลากหลายฝังอยู่ในรูปแบบต่าง ๆ ของมัน

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

อีนัมนี้มีรูปแบบที่แตกต่างกันสี่ประเภทด้วยกัน:

  • Quit: ไม่มีข้อมูลใด ๆ เชื่อมโยงติดไปด้วยเลย
  • Move: มีฟิลด์ข้อมูลที่มีชื่อเหมือนอย่างที่ struct มี
  • Write: ประกอบด้วย String เพียงค่าเดียว
  • ChangeColor: ประกอบด้วยค่า i32 สามค่า

การกำหนดอีนัมที่มีรูปแบบแตกต่างกันดังที่เห็นในรายการโค้ดที่ 6-2 นั้นมีความคล้ายคลึงกับการเขียนกำหนด struct รูปแบบต่าง ๆ แยกจากกัน เพียงแต่อีนัมจะไม่ต้องใช้คีย์เวิร์ด struct และรูปแบบทั้งหมดจะถูกจัดกลุ่มเข้าไว้ด้วยกันภายใต้ชนิดข้อมูล Message เดียวกัน ซึ่งโครงสร้างข้อมูล (structs) ต่อไปนี้สามารถจัดเก็บข้อมูลแบบเดียวกับที่รูปแบบของอีนัมข้างต้นจัดเก็บได้เช่นกัน:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

แต่ถ้าหากเราใช้ struct แยกกันในลักษณะนี้ แต่ละตัวก็จะมีชนิดข้อมูลเฉพาะเป็นของตัวเอง ซึ่งทำให้เราไม่สามารถเขียนกำหนดฟังก์ชันให้สามารถรับส่งข้อความประเภทใด ๆ เหล่านี้ได้ง่ายเหมือนกับการใช้ประโยชน์จากอีนัม Message ที่กำหนดไว้ในรายการโค้ดที่ 6-2 ซึ่งจัดเป็นชนิดข้อมูลเพียงชนิดเดียว

ความเหมือนกันอีกอย่างหนึ่งระหว่างอีนัมและ struct คือ เช่นเดียวกับที่เราสามารถกำหนดเมธอด (methods) บน struct ด้วยการใช้บล็อก impl ได้ เราก็สามารถเขียนกำหนดเมธอดบนอีนัมได้เช่นเดียวกัน ด้านล่างนี้คือตัวอย่างของเมธอดชื่อ call ที่เราสามารถสร้างขึ้นบนอีนัม Message ของเราได้:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

เนื้อความการทำงานของเมธอดจะใช้คำว่า self ในการเข้าถึงค่าที่เราใช้เรียกเมธอดนั้น ในตัวอย่างนี้ เราได้สร้างตัวแปร m ซึ่งเก็บค่าของ Message::Write(String::from("hello")) และนั่นก็คือค่าที่ self จะได้รับภายในเนื้อหาการทำงานของเมธอด call เมื่อคำสั่ง m.call() ถูกประมวลผล

เรามาลองดูตัวอย่างอีนัมอีกตัวหนึ่งในไลบรารีมาตรฐานที่มีการใช้งานบ่อยและมีประโยชน์อย่างมาก นั่นคือ Option

อีนัม Option (The Option Enum)

ส่วนนี้จะศึกษาเกี่ยวกับกรณีการใช้งานของ Option ซึ่งเป็นอีนัมอีกตัวหนึ่งที่ถูกกำหนดไว้ในไลบรารีมาตรฐาน ชนิดข้อมูล Option นี้จะช่วยในการเขียนจำลองกรณีจำลองที่พบได้บ่อยมาก นั่นคือการที่ค่าใดค่าหนึ่งอาจจะเป็น “การมีอยู่ของค่าบางอย่าง” หรือไม่ก็อาจจะเป็น “ความว่างเปล่า (ไม่มีอะไรเลย)”

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

การออกแบบภาษาโปรแกรมมักจะถูกคิดถึงในแง่ของฟีเจอร์ที่คุณใส่เพิ่มเข้ามา แต่ทว่าฟีเจอร์ที่คุณนำออกไปหรือไม่ยอมใส่เข้ามาก็มีความสำคัญไม่แพ้กันเลย ภาษา Rust ไม่มีฟีเจอร์ที่เรียกว่า ค่าว่าง (null) เหมือนอย่างที่ภาษาอื่นหลายภาษามีกัน Null เป็นค่าที่แสดงถึงการ “ไม่มีค่าอยู่ ณ ที่นั้น” ในภาษาที่มีค่า null ตัวแปรต่าง ๆ จะสามารถอยู่ในสถานะใดสถานะหนึ่งจากสองสถานะนี้ได้เสมอ คือ เป็น null หรือ ไม่เป็น null

ในการนำเสนอหัวข้อ “Null References: The Billion Dollar Mistake” ในปี ค.ศ. 2009 ทาง Tony Hoare ผู้ซึ่งเป็นผู้คิดค้นแนวคิดเรื่องค่า null ได้กล่าวไว้ว่า:

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

ปัญหาสำคัญของค่า null คือ หากคุณพยายามนำค่าที่เป็น null ไปใช้งานเสมือนว่ามันเป็นค่าที่ใช้งานได้ปกติ (not-null) คุณจะได้รับข้อผิดพลาดบางอย่างกลับมา และเนื่องจากคุณสมบัติความเป็น null หรือไม่เป็น null นี้กระจัดกระจายอยู่ทั่วไปในโปรแกรม จึงเป็นเรื่องง่ายมากที่จะเกิดข้อผิดพลาดประเภทนี้ขึ้น

อย่างไรก็ตาม แนวคิดที่ null พยายามสื่อออกมานั้นยังคงเป็นแนวคิดที่มีประโยชน์ นั่นคือ การระบุว่าค่าดังกล่าวในปัจจุบันยังใช้งานไม่ได้ หรือยังขาดหายไปเนื่องจากเหตุผลบางประการ

ปัญหาจริง ๆ จึงไม่ได้อยู่ที่ตัวแนวคิด แต่อยู่ที่การนำแนวคิดนี้ไปสร้างระบบขึ้นมาใช้ (particular implementation) ด้วยเหตุนี้ Rust จึงไม่มีค่า null แต่มีอีนัมที่ช่วยจำลองแนวคิดของการมีอยู่หรือการไม่มีอยู่ของข้อมูลทดแทน อีนัมดังกล่าวก็คือ Option<T> ซึ่งถูกกำหนดไว้ในไลบรารีมาตรฐาน ดังต่อไปนี้:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

อีนัม Option<T> มีประโยชน์มากจนถึงขั้นถูกนำมารวมไว้ใน prelude (สิ่งต่าง ๆ ที่นำเข้าให้พร้อมใช้งานโดยอัตโนมัติ) ดังนั้นคุณจึงไม่จำเป็นต้องนำมันเข้าสู่ขอบเขตการทำงาน (scope) อย่างเป็นทางการ ตัวรูปแบบที่เป็นไปได้ (variants) ของมันก็รวมอยู่ใน prelude ด้วยเช่นกัน คุณจึงสามารถเขียนใช้งาน Some และ None ได้โดยตรงโดยไม่ต้องใส่คำนำหน้า Option:: ทั้งนี้ อีนัม Option<T> ก็ยังคงเป็นเพียงอีนัมธรรมดาตัวหนึ่ง และ Some(T) กับ None ก็ยังคงเป็นรูปแบบย่อยของชนิดข้อมูล Option<T>

ไวยากรณ์ <T> เป็นคุณลักษณะเด่นของ Rust ที่เรายังไม่ได้พูดคุยกันมาก่อน มันคือพารามิเตอร์ชนิดข้อมูลทั่วไป (generic type parameter) ซึ่งเราจะศึกษาเรื่องเจเนอริกส์แบบเจาะลึกในบทที่ 10 สำหรับตอนนี้ สิ่งที่คุณจำเป็นต้องรู้มีเพียงแค่ว่า <T> หมายถึง รูปแบบ Some ของอีนัม Option จะสามารถเก็บข้อมูลหนึ่งชิ้นของชนิดข้อมูลใด ๆ ก็ได้ และชนิดข้อมูลจริงแต่ละตัวที่เข้ามาแทนที่ตำแหน่ง T จะส่งผลให้ชนิดข้อมูล Option<T> โดยรวมกลายเป็นชนิดข้อมูลที่ต่างกันออกไป ด้านล่างนี้คือตัวอย่างการใช้งานค่า Option เพื่อจัดเก็บข้อมูลประเภทตัวเลขและตัวอักษร (char):

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

ชนิดข้อมูลของ some_number จะเป็น Option<i32> ส่วนชนิดข้อมูลของ some_char จะเป็น Option<char> ซึ่งเป็นชนิดข้อมูลที่แตกต่างกันอย่างสิ้นเชิง ทาง Rust สามารถทำการอนุมานชนิดข้อมูล (infer types) เหล่านี้ได้เนื่องจากเราได้ระบุข้อมูลไว้ภายในรูปแบบ Some แล้ว แต่สำหรับตัวแปร absent_number ทาง Rust บังคับให้เราต้องเขียนระบุชนิดข้อมูลของ Option ไว้ด้วยเสมอ เนื่องจากคอมไพเลอร์ไม่สามารถเดาได้ว่ารูปแบบ Some ที่คู่กันจะเก็บข้อมูลชนิดใดจากการดูเพียงแค่ค่า None ในตัวอย่างนี้เราจึงระบุให้ Rust รู้ว่าเราตั้งใจให้ absent_number มีชนิดข้อมูลเป็น Option<i32>

เมื่อเรามีค่า Some เราจะรู้ว่ามีค่าข้อมูลอยู่ และค่านั้นจะถูกจัดเก็บอยู่ข้างใน Some แต่เมื่อเราได้ค่า None ในมุมหนึ่งจะมีความหมายเหมือนกับค่า null นั่นคือเราไม่มีค่าข้อมูลที่ใช้การได้ แล้วเหตุใดการมีอยู่ของ Option<T> จึงดีกว่าการใช้ระบบค่าว่างแบบ null?

พูดสั้น ๆ ก็คือ เนื่องจาก Option<T> และ T (โดยที่ T สามารถเป็นชนิดข้อมูลใด ๆ ก็ได้) เป็นชนิดข้อมูลที่แตกต่างกันอย่างสิ้นเชิง คอมไพเลอร์จึงไม่ยินยอมให้เรานำค่าของ Option<T> ไปใช้เสมือนว่ามันเป็นค่าที่ถูกต้องและใช้งานได้ชัวร์ ๆ ตัวอย่างเช่น โค้ดด้านล่างนี้จะคอมไพล์ไม่ผ่าน เนื่องจากพยายามนำค่าชนิด i8 ไปบวกเข้ากับค่าชนิด Option<i8>:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

หากเรารันโค้ดชุดนี้ เราจะได้รับข้อความแจ้งข้อผิดพลาดทำนองนี้:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
help: the following other types implement trait `Add<Rhs>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/arith.rs:98:8
  |
  = note: `i8` implements `Add`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/arith.rs:113:0
  |
  = note: in this macro invocation
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:22:8
  |
  = note: `&i8` implements `Add<i8>`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:33:8
  |
  = note: `i8` implements `Add<&i8>`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:44:8
  |
  = note: `&i8` implements `Add`
  = note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

เข้มงวดมาก! ผลลัพธ์ของข้อผิดพลาดนี้หมายความว่า Rust ไม่เข้าใจวิธีการนำ i8 ไปบวกกับ Option<i8> เพราะว่าพวกมันเป็นคนละชนิดข้อมูลกัน เมื่อเรามีค่าที่เป็นชนิดข้อมูลประเภทธรรมดา เช่น i8 ในภาษา Rust คอมไพเลอร์จะรับประกันให้เราเสมอว่าค่านั้นจะมีค่าที่ถูกต้องและใช้งานได้จริงแน่นอน ทำให้เราสามารถเขียนโค้ดต่อได้ด้วยความมั่นใจโดยไม่ต้องเขียนเช็ค null ก่อนนำค่านั้นไปใช้ แต่เมื่อใดที่เราต้องรับมือกับ Option<i8> (หรือชนิดข้อมูลใด ๆ ภายใต้ Option) เมื่อนั้นเราจึงต้องตระหนักถึงความเสี่ยงที่ข้อมูลอาจจะไม่มีอยู่ และคอมไพเลอร์จะบังคับให้เราจัดการกับกรณีดังกล่าวก่อนที่จะนำค่านั้นไปประมวลผล

กล่าวอีกนัยหนึ่งคือ คุณจำเป็นต้องทำการแปลงค่า Option<T> ไปเป็นค่า T เสียก่อน จึงจะสามารถทำกิจกรรมหรือคำนวณแบบเฉพาะเจาะจงของ T กับมันได้ ซึ่งโดยทั่วไปแล้วสิ่งนี้จะช่วยดักจับหนึ่งในปัญหาที่พบได้บ่อยที่สุดของระบบ null นั่นคือ การทึกทักเอาเองว่าค่านั้นไม่เป็น null ทั้งที่จริง ๆ แล้วมันกำลังเป็น null อยู่

การกำจัดความเสี่ยงจากการทึกทักเอาเองว่าข้อมูลไม่เป็น null ช่วยให้คุณมั่นใจในโค้ดของคุณมากยิ่งขึ้น ในการที่จะอนุญาตให้ค่าใด ๆ สามารถเป็นค่าว่างได้ คุณต้องแสดงความจำนงอย่างชัดเจน (explicitly opt in) ด้วยการกำหนดให้ค่าดังกล่าวเป็นชนิดข้อมูล Option<T> และหลังจากนั้นเมื่อคุณต้องการใช้งานค่านั้น คุณจะถูกบังคับให้เขียนจัดการกรณีที่ค่านั้นเป็น null อย่างชัดเจน ส่วนที่อื่น ๆ ทั้งหมดที่ตัวแปรมีชนิดข้อมูลปกติที่ไม่ได้อยู่ในรูปแบบ Option<T> คุณก็สามารถสรุปได้อย่างปลอดภัยอย่างยิ่งว่าค่านั้นไม่มีทางเป็น null ได้อย่างแน่นอน นี่คือการตัดสินใจออกแบบอย่างตั้งใจของทีมผู้สร้าง Rust เพื่อจำกัดความแพร่หลายของระบบ null และเพื่อเพิ่มระดับความปลอดภัยให้กับโค้ดของ Rust

แล้วคุณจะนำค่า T ออกมาจากรูปแบบ Some ของชนิดข้อมูล Option<T> เพื่อนำไปใช้งานได้อย่างไร? อีนัม Option<T> มีเมธอดจำนวนมากที่ออกแบบมาเพื่อช่วยอำนวยความสะดวกในสถานการณ์ต่าง ๆ ซึ่งคุณสามารถเปิดดูรายละเอียดได้ในเอกสารอ้างอิงของมัน การสร้างความคุ้นเคยกับเมธอดต่าง ๆ บน Option<T> จะเป็นประโยชน์ต่อเส้นทางการเรียนรู้ภาษา Rust ของคุณอย่างมาก

โดยทั่วไป ในการใช้งานค่าชนิด Option<T> คุณจะต้องเขียนโค้ดที่สามารถรับมือกับรูปแบบที่เป็นไปได้แต่ละแบบของมัน คุณจะต้องการให้โค้ดส่วนหนึ่งรันเฉพาะเมื่อคุณมีค่าเป็น Some(T) และโค้ดส่วนนี้จะได้รับอนุญาตให้ใช้ค่า T ที่อยู่ด้านในได้ และคุณก็ต้องการให้โค้ดอีกส่วนหนึ่งรันเฉพาะเมื่อเจอค่า None ซึ่งโค้ดส่วนนี้จะไม่สามารถเรียกใช้ค่า T ได้ นิพจน์ match คือโครงสร้างควบคุมลำดับการทำงาน (control flow construct) ที่ทำหน้าที่ในลักษณะดังกล่าวเมื่อใช้งานร่วมกับอีนัม โดยมันจะแยกการทำงานของโค้ดตามรูปแบบของอีนัมที่ตรวจพบ และโค้ดส่วนนั้นก็จะสามารถหยิบยกข้อมูลที่จัดเก็บไว้ในค่านั้นมาใช้ต่อได้

The match Control Flow Construct

โครงสร้างควบคุมลำดับการทำงานแบบ match (The match Control Flow Construct)

ภาษา Rust มีโครงสร้างควบคุมลำดับการทำงาน (control flow construct) ที่ทรงพลังอย่างยิ่งที่เรียกว่า match ซึ่งช่วยให้คุณสามารถเปรียบเทียบค่าใด ๆ กับกลุ่มของแพตเทิร์น (patterns) ต่าง ๆ และประมวลผลโค้ดตามความสอดคล้องของแพตเทิร์นที่จับคู่ได้ แพตเทิร์นสามารถสร้างขึ้นจากค่าคงที่ (literal values), ชื่อตัวแปร, สัญลักษณ์แทนค่าทั่วไป (wildcards) และอื่น ๆ อีกมากมาย โดยในบทที่ 19 จะครอบคลุมเกี่ยวกับแพตเทิร์นประเภทต่าง ๆ และลักษณะการทำงานของพวกมันทั้งหมด ความทรงพลังของ match นั้นมาจากความสามารถในการแสดงออกของแพตเทิร์น รวมถึงการที่คอมไพเลอร์ช่วยรับประกันว่ากรณีที่เป็นไปได้ทั้งหมดจะได้รับการจัดการอย่างครบถ้วน

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

เมื่อพูดถึงเหรียญแล้ว เรามาลองใช้เหรียญเป็นตัวอย่างในการเขียนอธิบาย match กัน! เราสามารถเขียนฟังก์ชันที่รับเหรียญสหรัฐฯ ที่ไม่ทราบประเภท และทำหน้าที่ระบุว่ามันคือเหรียญอะไรพร้อมคืนค่าหน่วยเป็นเซนต์ทำนองเดียวกับเครื่องนับเหรียญ ดังที่แสดงในรายการโค้ดที่ 6-3

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

เรามาวิเคราะห์โครงสร้างของ match ในฟังก์ชัน value_in_cents กัน เริ่มแรกเราจะเขียนคีย์เวิร์ด match ตามด้วยนิพจน์ ซึ่งในกรณีนี้คือค่าของตัวแปร coin สิ่งนี้ดูคล้ายกับนิพจน์เงื่อนไขที่ใช้ร่วมกับ if เป็นอย่างมาก แต่ทว่ามีความแตกต่างสำคัญประการหนึ่ง คือ สำหรับ if เงื่อนไขจำเป็นต้องได้รับการประเมินผลลัพธ์เป็นค่าบูลีน (Boolean) เสมอ แต่ในที่นี้จะเป็นชนิดข้อมูลใด ๆ ก็ได้ โดยชนิดข้อมูลของ coin ในตัวอย่างนี้ก็คืออีนัม Coin ที่เรากำหนดไว้ในบรรทัดแรก

ถัดไปคือ แขนของแมตช์ (match arms) โดยในหนึ่งแขนจะประกอบด้วยสองส่วน ได้แก่ ตัวแพตเทิร์น (pattern) และตัวโค้ด (code) แขนแรกในที่นี้จะมีแพตเทิร์นเป็นค่า Coin::Penny ตามด้วยตัวดำเนินการ => เพื่อใช้คั่นแบ่งระหว่างแพตเทิร์นและโค้ดที่จะทำงาน ซึ่งโค้ดในกรณีนี้เป็นเพียงแค่ค่าคงที่ 1 และแต่ละแขนของแมตช์จะถูกคั่นออกจากกันด้วยเครื่องหมายจุลภาค (comma)

เมื่อนิพจน์ match ทำงาน มันจะเปรียบเทียบค่าผลลัพธ์ที่ได้กับแพตเทิร์นของแต่ละแขนตามลำดับ หากแพตเทิร์นใดจับคู่ได้ตรงกับค่าดังกล่าว โค้ดที่เชื่อมโยงกับแพตเทิร์นนั้นก็จะถูกประมวลผลทันที แต่ถ้าแพตเทิร์นไม่ตรงกับค่า การทำงานจะข้ามไปยังแขนถัดไป เช่นเดียวกับหลักการของเครื่องแยกเหรียญ เราสามารถเขียนกี่แขนก็ได้ตามที่ต้องการ โดยในรายการโค้ดที่ 6-3 นิพจน์ match ของเรามีสี่แขนด้วยกัน

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

ปกติแล้วเราจะไม่นิยมใช้วงเล็บปีกกาหากโค้ดในแขนของแมตช์มีขนาดสั้นมาก ดังที่เห็นในรายการโค้ดที่ 6-3 ซึ่งแต่ละแขนมีหน้าที่เพียงแค่คืนค่าเท่านั้น แต่ถ้าหากคุณต้องการรันโค้ดหลายบรรทัดในแขนของแมตช์ คุณจะต้องใช้วงเล็บปีกกาช่วยครอบไว้ และเมื่อใช้วงเล็บปีกกาแล้ว เครื่องหมายจุลภาคที่อยู่ด้านหลังแขนนั้นจะเขียนระบุหรือไม่ก็ได้ ตัวอย่างเช่น โค้ดต่อไปนี้จะพิมพ์ข้อความ “Lucky penny!” ทุกครั้งที่เมธอดนี้ถูกเรียกใช้ด้วยค่า Coin::Penny แต่ทว่ามันยังคงคืนค่าเป็นค่าสุดท้ายของบล็อก นั่นก็คือ 1:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

แพตเทิร์นที่ผูกเข้ากับค่า (Patterns That Bind to Values)

คุณสมบัติเด่นที่มีประโยชน์อีกประการหนึ่งของแขนของแมตช์คือ พวกมันสามารถผูก (bind) เข้ากับชิ้นส่วนข้อมูลที่เก็บอยู่ภายใต้ค่าที่สอดคล้องกับแพตเทิร์นนั้น ๆ ได้ ซึ่งนี่คือวิธีที่เราจะสามารถดึงค่าข้อมูล (extract values) ออกมาจากรูปแบบย่อยของอีนัม (enum variants) ได้

ตัวอย่างเช่น เรามาปรับเปลี่ยนรูปแบบย่อยของอีนัมตัวหนึ่งของเราให้เก็บข้อมูลไว้ภายในกัน ในช่วงปี ค.ศ. 1999 ถึง 2008 สหรัฐอเมริกาได้ผลิตเหรียญควอเตอร์ (25 เซนต์) ที่มีลวดลายแตกต่างกันตามรัฐทั้ง 50 รัฐไว้ที่ด้านหนึ่งของเหรียญ ซึ่งเหรียญประเภทอื่น ๆ จะไม่มีการพิมพ์ลายรัฐเหล่านี้ ดังนั้นจึงมีเพียงเหรียญควอเตอร์เท่านั้นที่มีมูลค่าเพิ่มเติมนี้ เราสามารถนำข้อมูลนี้มาระบุในอีนัมของเราได้ โดยปรับปรุงรูปแบบ Quarter ให้จัดเก็บค่าชนิด UsState ไว้ภายใน ดังที่ทำในรายการโค้ดที่ 6-4

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

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

ในนิพจน์ match ของโค้ดชุดนี้ เราจะประกาศตัวแปรชื่อ state ไว้ในแพตเทิร์นที่ทำหน้าที่จับคู่กับรูปแบบ Coin::Quarter เมื่อมีค่าข้อมูลที่เป็น Coin::Quarter วิ่งเข้ามาจับคู่ตรงกัน ตัวแปร state จะผูกเข้ากับค่าข้อมูลรัฐของเหรียญควอเตอร์เหรียญนั้นโดยอัตโนมัติ จากนั้นเราก็จะสามารถนำตัวแปร state ไปใช้งานต่อภายในบล็อกโค้ดของแขนนั้นได้ดังนี้:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

หากเราเรียกใช้งานฟังก์ชัน value_in_cents(Coin::Quarter(UsState::Alaska)) ค่าของตัวแปร coin จะกลายเป็น Coin::Quarter(UsState::Alaska) เมื่อเรานำค่านั้นมาเปรียบเทียบกับแต่ละแขนของแมตช์ จะพบว่าไม่มีแขนใดที่จับคู่ได้เลยจนกระทั่งมาถึงแขน Coin::Quarter(state) ณ จุดนี้ ตัวแปร state จะถูกผูกกับค่า UsState::Alaska และจากนั้นเราก็จะสามารถนำข้อมูลที่ผูกไว้ไปใช้ต่อในนิพจน์ println! ได้ ซึ่งนี่ก็เป็นวิธีการดึงข้อมูลภายในของอีนัมชนิด Coin ในส่วนที่เป็นรูปแบบ Quarter ออกมาใช้งาน

แพตเทิร์น match ร่วมกับ Option<T> (The Option<T> match Pattern)

ในส่วนก่อนหน้านี้ เรามีความต้องการที่จะนำค่า T ที่อยู่ภายในรูปแบบ Some ออกมาใช้งานเมื่อเราทำงานร่วมกับชนิดข้อมูล Option<T> ซึ่งจริง ๆ แล้วเราสามารถเขียนจัดการ Option<T> ด้วยการใช้งาน match ได้เช่นกันในแบบเดียวกับที่เราทำกับอีนัม Coin! เพียงแต่เปลี่ยนจากการตรวจสอบเปรียบเทียบเหรียญมาเป็นการเปรียบเทียบรูปแบบของ Option<T> แต่ทว่ากลไกการทำงานของนิพจน์ match ก็จะยังคงเหมือนเดิมทุกประการ

สมมติว่าเราต้องการเขียนฟังก์ชันที่รับพารามิเตอร์เป็นชนิดข้อมูล Option<i32> และมีข้อกำหนดว่าหากมีค่าข้อมูลเก็บอยู่ข้างใน ให้ทำการบวกเพิ่มค่าเข้าไปอีก 1 แต่ถ้าข้างในไม่มีค่าข้อมูลใด ๆ (เป็น None) ก็ให้ฟังก์ชันคืนค่าเป็น None กลับไปโดยไม่จำเป็นต้องทำกิจกรรมหรือคำนวณใด ๆ ทั้งสิ้น

ฟังก์ชันลักษณะนี้สามารถเขียนขึ้นมาได้ง่ายดายมากด้วยการใช้คุณประโยชน์จาก match ดังแสดงในรายการโค้ดที่ 6-5

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

เรามาตรวจสอบขั้นตอนการทำงานครั้งแรกของฟังก์ชัน plus_one อย่างละเอียดกัน เมื่อเราเรียก plus_one(five) ตัวแปร x ภายในตัวฟังก์ชัน plus_one จะเก็บค่าเป็น Some(5) จากนั้นเราจะนำค่านั้นมาเปรียบเทียบกับแต่ละแขนของแมตช์ดังนี้:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

เนื่องจากค่า Some(5) ไม่ตรงกับแพตเทิร์น None การทำงานจึงข้ามไปยังแขนถัดไป:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

ค่า Some(5) ตรงตามแพตเทิร์น Some(i) หรือไม่? คำตอบคือตรง! เนื่องจากมันเป็นรูปแบบย่อยเดียวกัน และตัวแปร i จะผูกเข้ากับค่าข้อมูลที่อยู่ใน Some ส่งผลให้ i ได้รับค่าเป็น 5 จากนั้นโค้ดในแขนของแมตช์ก็จะทำงานโดยการบวกเพิ่มค่าให้กับ i ไปอีก 1 และทำการสร้างอินสแตนซ์ Some ตัวใหม่ขึ้นมาเพื่อเก็บผลลัพธ์เป็น 6 ไว้ภายใน

คราวนี้เรามาวิเคราะห์การเรียกใช้งานฟังก์ชัน plus_one ในรอบที่สองในรายการโค้ดที่ 6-5 ซึ่งตัวแปร x มีค่าข้อมูลเป็น None เมื่อเข้าสู่กระบวนการ match เราจะนำค่านั้นมาตรวจสอบกับแขนแรกก่อน:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

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

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

การตรวจสอบของ Match จะต้องครอบคลุมทุกกรณี (Matches Are Exhaustive)

ยังมีรายละเอียดการใช้งาน match อีกเรื่องหนึ่งที่เราต้องอภิปรายกัน นั่นคือ แพตเทิร์นต่าง ๆ ในแต่ละแขนของแมตช์จำเป็นต้องครอบคลุมถึงกรณีที่เป็นไปได้ทั้งหมด ลองพิจารณาฟังก์ชัน plus_one เวอร์ชันนี้ที่จงใจเขียนให้มีบั๊กและจะไม่สามารถทำการคอมไพล์ได้สำเร็จ:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

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

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/option.rs:600:0
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/option.rs:604:4
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Rust ตระหนักดีว่าเราไม่ได้ครอบคลุมเงื่อนไขที่เป็นไปได้ทั้งหมด และสามารถระบุชี้ได้ด้วยซ้ำว่าเราลืมแพตเทิร์นใดไป! การตรวจสอบด้วย match ในภาษา Rust นั้นมีลักษณะแบบ ครอบคลุมทุกกรณี (exhaustive) ซึ่งหมายความว่าเราจำเป็นต้องเขียนดักเก็บทุกรูปแบบความเป็นไปได้ให้หมดจด โค้ดจึงจะได้รับการตรวจสอบว่าถูกต้อง โดยเฉพาะกรณีของ Option<T> การที่ Rust เข้ามาช่วยขัดขวางไม่ให้เราลืมเขียนจัดการกรณีที่เป็น None จะช่วยป้องกันไม่ให้เราเผลอทึกทักเอาเองว่าเรามีค่าข้อมูลอยู่ ทั้งที่จริง ๆ ค่านั้นอาจกำลังเป็น null ซึ่งช่วยทำให้ข้อผิดพลาดมูลค่าพันล้านดอลลาร์ที่เราพูดคุยกันไปก่อนหน้านี้ไม่มีโอกาสเกิดขึ้นได้เลย

แพตเทิร์นสำหรับดักเก็บกรณีที่เหลือทั้งหมด และตัวแทนตัวขีดล่าง _ (Catch-All Patterns and the _ Placeholder)

เมื่อใช้อีนัม เราสามารถเลือกทำกิจกรรมพิเศษบางอย่างสำหรับค่าข้อมูลบางตัว และทำกิจกรรมแบบเริ่มต้น (default) สำหรับค่าข้อมูลที่เหลือทั้งหมดได้ ลองจินตนาการว่าเรากำลังเขียนเกมที่หากผู้เล่นทอยลูกเต๋าได้แต้ม 3 ตัวละครจะไม่เคลื่อนที่แต่จะได้รับหมวกใบใหม่ที่ดูสวยงามแทน หากทอยได้แต้ม 7 ตัวละครจะทำหมวกสวยงามนั้นหล่นหาย และหากทอยได้แต้มอื่น ๆ ตัวละครจะเคลื่อนที่ไปข้างหน้าตามจำนวนแต้มที่ทอยได้ ด้านล่างนี้คือนิพจน์ match ที่ทำหน้าที่เลียนแบบตรรกะดังกล่าว โดยเราจะเขียนระบุค่าผลลัพธ์แต้มลูกเต๋าแบบคงที่ไว้ก่อนแทนการสุ่มค่าจริง และตรรกะการทำงานอื่น ๆ จะจำลองด้วยฟังก์ชันที่ไม่มีเนื้อหาการทำงานภายใน เนื่องจากรายละเอียดจริงของมันอยู่นอกเหนือขอบเขตของตัวอย่างนี้:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

สำหรับสองแขนแรก แพตเทิร์นจะเป็นค่าคงที่ 3 และ 7 ส่วนแขนสุดท้ายซึ่งทำหน้าที่ดักเก็บค่าความเป็นไปได้อื่น ๆ ทั้งหมดที่เหลือ แพตเทิร์นจะเป็นชื่อตัวแปรทั่วไปที่เราตั้งชื่อมันว่า other ซึ่งโค้ดที่รันในแขน other นี้จะดึงเอาค่าจากตัวแปรนั้นส่งต่อไปให้กับฟังก์ชัน move_player อีกทีหนึ่ง

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

นอกจากนี้ภาษา Rust ยังมีแพตเทิร์นพิเศษที่เราสามารถหยิบมาใช้ได้เมื่อต้องการดักเก็บกรณีที่เหลือทั้งหมด แต่ไม่ต้องการ นำค่านั้นไปใช้ประโยชน์ ต่อ นั่นคือ สัญลักษณ์ตัวขีดล่าง _ ซึ่งเป็นแพตเทิร์นพิเศษที่จะจับคู่กับค่าใด ๆ ก็ได้โดยไม่ทำการผูก (bind) ค่านั้นเข้ากับชื่อตัวแปร วิธีนี้เป็นการบอกใบ้ให้ Rust ทราบว่าเราจะไม่หยิบจับค่านั้นไปคำนวณต่อ ซึ่งจะช่วยไม่ให้คอมไพเลอร์แสดงข้อความเตือนเกี่ยวกับตัวแปรที่ไม่ได้ใช้งาน (unused variable)

คราวนี้เราลองปรับเปลี่ยนกติกาของเกมกันใหม่ คือหากทอยแต้มได้ค่าใด ๆ ที่ไม่ใช่ 3 หรือ 7 ผู้เล่นจะต้องทำการทอยลูกเต๋าใหม่อีกครั้ง ซึ่งเราไม่จำเป็นต้องนำค่าแต้มเหล่านั้นไปทำอะไรต่อแล้ว เราจึงสามารถเปลี่ยนโค้ดของเรามาใช้เครื่องหมาย _ แทนชื่อตัวแปร other ได้ดังนี้:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

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

สุดท้ายนี้ เรามาลองเปลี่ยนกติกาของเกมอีกครั้ง คือหากคุณทอยลูกเต๋าได้ค่าอื่นใดนอกจาก 3 หรือ 7 จะไม่มีอะไรเกิดขึ้นเลยในตารอบของคุณ เราสามารถระบุเงื่อนไขนี้ได้โดยการใช้ค่าว่างแบบยูนิต (unit value ซึ่งก็คือ tuple ว่างที่เราได้ระบุไปในส่วน“ชนิดข้อมูล Tuple”) มาทำหน้าที่เป็นส่วนของโค้ดในแขน _ ดังนี้:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

ในที่นี้ เรากำลังแจ้งบอกกับ Rust อย่างเป็นลายลักษณ์อักษรว่า เราจะไม่นำค่าใด ๆ ที่ไม่ตรงกับแพตเทิร์นก่อนหน้านี้มาใช้งาน และเราก็ไม่ต้องการให้มีการประมวลผลโค้ดใด ๆ ทั้งสิ้นในกรณีนี้

ยังมีรายละเอียดการใช้งานที่น่าสนใจเกี่ยวกับแพตเทิร์นและการเปรียบเทียบจับคู่ที่เราจะเรียนรู้เพิ่มเติมในบทที่ 19 สำหรับในตอนนี้ เราจะก้าวไปศึกษาเกี่ยวกับโครงสร้างไวยากรณ์ if let ซึ่งจะมีประโยชน์มากในสถานการณ์ที่นิพจน์ match นั้นดูยาวและเยิ่นเย้อจนเกินไป

Concise Control Flow with if let and let...else

การควบคุมลำดับการทำงานอย่างกระชับด้วย if let และ let...else (Concise Control Flow with if let and let...else)

ไวยากรณ์ if let ช่วยให้คุณสามารถรวม if และ let เข้าด้วยกันเพื่อย่อวิธีการเขียนโค้ดสำหรับรับมือกับค่าที่ตรงตามแพตเทิร์นเพียงแบบเดียว ในขณะที่ละเว้นการตรวจสอบกรณีอื่น ๆ ที่เหลือ ลองพิจารณาโปรแกรมในรายการโค้ดที่ 6-6 ซึ่งทำการเปรียบเทียบค่าชนิด Option<u8> ในตัวแปร config_max แต่ทว่ามีวัตถุประสงค์เพื่อต้องการประมวลผลโค้ดก็ต่อเมื่อค่านั้นเป็นรูปแบบ Some เท่านั้น

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}

หากค่าที่ได้เป็น Some เราจะพิมพ์ค่าที่อยู่ในรูปแบบ Some นั้นออกมาโดยการผูกค่านั้นเข้ากับตัวแปร max ภายใต้แพตเทิร์น โดยที่เราไม่ประสงค์จะทำงานใด ๆ หากพบค่าที่เป็น None และเพื่อให้เป็นไปตามกฎไวยากรณ์ของนิพจน์ match เราจำเป็นต้องใส่แขน _ => () เพิ่มเติมลงไปหลังจากประมวลผลรูปแบบย่อยแรกเสร็จสิ้น ซึ่งจัดว่าเป็นโค้ดสำเร็จรูปที่ซ้ำซาก (boilerplate code) และน่ารำคาญที่ต้องเขียนเพิ่มเข้ามา

แต่ถ้าเราเปลี่ยนมาใช้วิธีที่สั้นลงโดยใช้ if let โค้ดด้านล่างนี้จะทำงานเหมือนกับนิพจน์ match ในรายการโค้ดที่ 6-6 ทุกประการ:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

ไวยากรณ์ของ if let จะรับตัวแพตเทิร์นและตัวนิพจน์แยกกันด้วยเครื่องหมายเท่ากับ (=) มันมีลักษณะการทำงานแบบเดียวกับ match ที่ส่งค่าตัวนิพจน์เข้าไป และมีแพตเทิร์นเป็นแขนแรกของแมตช์ ในตัวอย่างนี้ แพตเทิร์นคือ Some(max) และตัวแปร max จะทำหน้าที่ผูกเข้ากับค่าข้อมูลที่อยู่ภายใน Some จากนั้นเราก็จะสามารถนำตัวแปร max ไปใช้งานภายในบล็อกของ if let ได้เช่นเดียวกับที่เราใช้ max ในแขนของ match ที่คู่กัน โค้ดในบล็อก if let จะรันเฉพาะเมื่อค่าข้อมูลที่ตรวจสอบจับคู่สอดคล้องกับแพตเทิร์นเท่านั้น

การใช้งาน if let ช่วยให้พิมพ์โค้ดน้อยลง ย่นระยะการย่อหน้าลง และลดการเขียนโค้ดที่ซ้ำซากจำเจ อย่างไรก็ตาม คุณจะเสียฟังก์ชันการตรวจสอบความครอบคลุมทุกกรณี (exhaustive checking) ที่เป็นระบบบังคับของ match เพื่อรับรองว่าคุณจะไม่ได้ตกหล่นกรณีใดไป การเลือกระหว่าง match และ if let จึงขึ้นอยู่กับลักษณะงานที่คุณทำในสถานการณ์นั้น ๆ และขึ้นอยู่กับว่าการแลกมาซึ่งความกระชับเหมาะสมคุ้มค่ากับการสูญเสียการตรวจสอบอย่างครอบคลุมหรือไม่

พูดง่าย ๆ ก็คือ คุณสามารถมอง if let ว่าเป็นไวยากรณ์อำนวยความสะดวก (syntax sugar) ของโครงสร้าง match ที่รันโค้ดเมื่อค่าตรงตามแพตเทิร์นเดียวที่กำหนด และละทิ้งการตรวจสอบค่าตัวแปรอื่น ๆ ทั้งหมดที่เหลือนั่นเอง

เราสามารถใช้บล็อก else ร่วมกับ if let ได้ด้วย โดยบล็อกโค้ดที่อยู่หลังคำว่า else จะทำหน้าที่เดียวกับบล็อกโค้ดในแขน _ ของนิพจน์ match ที่เทียบเท่ากัน ย้อนกลับไปพิจารณาอีนัม Coin ในรายการโค้ดที่ 6-4 ที่รูปแบบ Quarter จัดเก็บค่าชนิด UsState ไว้ภายใน หากเราต้องการนับจำนวนเหรียญอื่น ๆ ทั้งหมดที่ไม่ใช่เหรียญควอเตอร์ไปพร้อม ๆ กับการประกาศชื่อรัฐของเหรียญควอเตอร์ เราสามารถเขียนด้วยนิพจน์ match ได้ดังนี้:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

หรือเราจะเขียนโดยการใช้โครงสร้างนิพจน์ if let และ else ได้ดังนี้:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

การคงการประมวลผลไว้บน “เส้นทางที่ราบรื่น” ด้วย let...else (Staying on the “Happy Path” with let...else)

รูปแบบที่พบได้ทั่วไปคือการคำนวณประมวลผลข้อมูลเมื่อมีค่าข้อมูลอยู่ และจะส่งค่าเริ่มต้น (default value) กลับไปในกรณีอื่น ๆ ตัวอย่างเช่น จากกรณีศึกษาเรื่องเหรียญที่มีค่า UsState หากเราต้องการจะพูดคำพูดตลก ๆ บางอย่างตามความเก่าแก่ของรัฐที่ปรากฏอยู่บนเหรียญควอเตอร์ เราอาจเพิ่มเมธอดบน UsState เพื่อให้ทำหน้าที่ตรวจสอบอายุของรัฐนั้น ๆ ดังนี้:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

จากนั้น เราอาจจะหยิบ if let มาใช้เพื่อจับคู่ประเภทของเหรียญ พร้อมทั้งประกาศตัวแปร state ขึ้นมาใช้ต่อภายในขอบเขตการทำงานของเงื่อนไข ดังในรายการโค้ดที่ 6-7

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

การเขียนแบบนี้สามารถทำให้บรรลุเป้าหมายได้ก็จริง แต่ทว่ามันทำให้ภาระงานหลัก ๆ ถูกดันเข้าไปอยู่ภายในบล็อกของเงื่อนไข if let และถ้าหากงานที่ต้องประมวลผลมีความซับซ้อนมากกว่านี้ ก็อาจจะยากที่จะเข้าใจได้ว่าโครงสร้างเงื่อนไขในระดับบนสุดเชื่อมโยงกันอย่างไร เราอาจใช้ประโยชน์จากการที่นิพจน์สามารถคืนค่าได้ เพื่อกำหนดค่าให้กับตัวแปร state จากเงื่อนไข if let หรือใช้เพื่อยุติการทำงานและส่งค่ากลับล่วงหน้า (return early) ดังแสดงในรายการโค้ดที่ 6-8 (ซึ่งคุณสามารถเขียนโครงสร้างทำนองนี้ด้วย match ได้เช่นกัน)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

อย่างไรก็ตาม การเขียนแบบนี้ก็น่าหงุดหงิดในการทำความเข้าใจในแบบของมันเอง! เนื่องจากแขนฝั่งหนึ่งของ if let มีหน้าที่คืนค่าข้อมูลออกมา ในขณะที่อีกฝั่งหนึ่งกลับเป็นการสั่งสิ้นสุดการทำงานของฟังก์ชันทันที

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

ในรายการโค้ดที่ 6-9 คุณสามารถศึกษาดูว่าเมื่อเราเปลี่ยนรายการโค้ดที่ 6-8 มาเขียนโดยการใช้งาน let...else แทน if let จะได้รูปแบบโครงสร้างอย่างไร

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

สังเกตว่าการเขียนด้วยวิธีนี้จะช่วยให้โค้ดการทำงานหลักยังคงอยู่บน “เส้นทางที่ราบรื่น (happy path)” ภายในเนื้อความหลักของฟังก์ชันได้โดยตรง โดยไม่จำเป็นต้องใช้โครงสร้างควบคุมที่ต่างกันอย่างสุดขั้วสำหรับสองเงื่อนไขเหมือนดังที่เงื่อนไข if let ทำก่อนหน้านี้

หากคุณเจอกับสถานการณ์ที่ตรรกะในโปรแกรมดูเยิ่นเย้อและเขียนด้วย match ได้ยาก โปรดระลึกไว้เสมอว่าไวยากรณ์ if let และ let...else ก็เป็นเครื่องมือที่พร้อมให้คุณเลือกใช้งานในกล่องเครื่องมือของ Rust เช่นเดียวกัน

สรุป

ในตอนนี้เราได้เรียนรู้ถึงการใช้งานอีนัม (enums) เพื่อสร้างชนิดข้อมูลขึ้นมาใช้เอง ซึ่งสามารถเป็นค่าข้อมูลใดค่าหนึ่งจากชุดของค่าที่แจงนับไว้ทั้งหมด เราได้เห็นวิธีการที่ชนิดข้อมูล Option<T> ของไลบรารีมาตรฐานช่วยให้คุณใช้งานระบบชนิดข้อมูล (type system) เพื่อปกป้องโปรแกรมจากข้อผิดพลาด และเมื่อค่าข้อมูลของอีนัมมีข้อมูลภายในบรรจุอยู่ คุณก็สามารถเลือกใช้ match หรือ if let เพื่อดึงข้อมูลเหล่านั้นออกมาใช้งานได้ตามความเหมาะสมกับปริมาณกรณีที่คุณต้องการจัดการ

ตอนนี้โปรแกรมภาษา Rust ของคุณสามารถอธิบายแนวคิดจำเพาะเจาะจงของงานคุณได้โดยการเลือกใช้ structs และ enums การสร้างชนิดข้อมูลที่กำหนดขึ้นเองเพื่อเขียนอธิบายใน API จะช่วยรับประกันความปลอดภัยของชนิดข้อมูล (type safety) ได้ กล่าวคือ คอมไพเลอร์จะคอยดูแลรับรองว่าฟังก์ชันต่าง ๆ ของคุณจะได้รับค่าข้อมูลตรงตามประเภทที่ฟังก์ชันนั้น ๆ คาดหวังไว้เท่านั้น

เพื่อที่จะนำเสนอ API ที่จัดระเบียบมาอย่างดีให้กับผู้ใช้ ซึ่งใช้งานได้ง่ายและเปิดเผยเฉพาะสิ่งที่ผู้ใช้จำเป็นต้องใช้งานจริง ๆ เท่านั้น ลำดับต่อไปเราจะหันไปเรียนรู้เกี่ยวกับระบบโมดูล (modules) ของภาษา Rust กัน

แพ็กเกจ (Packages), เครต (Crates) และโมดูล (Modules)

เมื่อคุณเริ่มเขียนโปรแกรมที่มีขนาดใหญ่ขึ้น การจัดระเบียบโค้ดของคุณจะยิ่งมีความสำคัญมากยิ่งขึ้น การจัดกลุ่มการทำงานที่เกี่ยวข้องกันและแยกโค้ดออกจากกันตามคุณลักษณะเด่น (features) ที่ชัดเจน จะช่วยให้คุณค้นพบได้ง่ายว่าโค้ดที่ใช้ขับเคลื่อนฟีเจอร์ใดฟีเจอร์หนึ่งนั้นตั้งอยู่ที่ไหน และต้องไปที่จุดใดเพื่อปรับปรุงการทำงานของฟีเจอร์นั้น

โปรแกรมที่เราเขียนกันมาจนถึงตอนนี้ล้วนเก็บอยู่ในโมดูลเดียวในไฟล์เดียว แต่ทว่าเมื่อโปรเจกต์เติบโตขึ้น คุณควรจัดระเบียบโค้ดโดยแบ่งย่อยออกเป็นหลายโมดูล และแยกเป็นหลายไฟล์ตามลำดับ แพ็กเกจ (package) หนึ่งสามารถประกอบด้วยเครตฐานสอง (binary crates) หลายเครต และมีเครตห้องสมุด (library crate) ได้หนึ่งเครตเป็นทางเลือก เมื่อแพ็กเกจขยายตัวขึ้น คุณสามารถดึงบางส่วนออกไปสร้างเป็นเครตแยกต่างหากซึ่งจะกลายเป็นทรัพยากรภายนอก (external dependencies) ที่ต้องนำเข้ามาใช้งาน ในบทนี้จะครอบคลุมถึงเทคนิคเหล่านี้ทั้งหมด สำหรับโปรเจกต์ขนาดใหญ่มากที่ประกอบด้วยกลุ่มแพ็กเกจที่มีความเกี่ยวข้องกันและพัฒนาไปด้วยกัน ทาง Cargo ก็จะมีระบบพื้นที่ทำงาน (workspaces) ให้ใช้งาน ซึ่งเราจะศึกษาเพิ่มเติมในส่วน“Cargo Workspaces” ในบทที่ 14

นอกจากนี้เรายังจะอภิปรายเกี่ยวกับการห่อหุ้มรายละเอียดการทำงานภายใน (encapsulating implementation details) ซึ่งช่วยให้คุณนำโค้ดกลับมาใช้ใหม่ได้ในระดับที่สูงขึ้น กล่าวคือ เมื่อคุณสร้างการทำงานขึ้นมาแล้ว โค้ดส่วนอื่น ๆ จะสามารถเรียกใช้งานโค้ดของคุณผ่านทางอินเตอร์เฟสสาธารณะ (public interface) ได้โดยไม่จำเป็นต้องรับรู้เลยว่ารายละเอียดภายในทำงานอย่างไร วิธีการเขียนโค้ดของคุณจะเป็นตัวกำหนดว่าส่วนใดที่เป็นสาธารณะ (public) ที่ให้โค้ดอื่นสามารถนำไปเรียกใช้ได้ และส่วนใดที่เป็นรายละเอียดภายในส่วนตัว (private implementation details) ที่คุณสงวนสิทธิ์ที่จะปรับเปลี่ยนแก้ไขได้ในอนาคต ซึ่งนี่เป็นอีกหนึ่งวิธีในการช่วยจำกัดปริมาณรายละเอียดข้อมูลที่คุณจำเป็นต้องจดจำไว้ในสมอง

แนวคิดที่มีความเกี่ยวข้องใกล้ชิดกันคือ ขอบเขตการทำงาน (scope): บริบทการเขียนโค้ดแบบซ้อนระดับชั้นจะมีชุดรายชื่อต่าง ๆ ที่ถูกประกาศว่าอยู่ในขอบเขต (“in scope”) ในขณะที่อ่าน เขียน และคอมไพล์โค้ด ทั้งโปรแกรมเมอร์และคอมไพเลอร์จำเป็นต้องรู้ว่าชื่อเฉพาะ ณ จุดหนึ่ง ๆ ในโค้ดนั้น หมายถึงตัวแปร, ฟังก์ชัน, struct, enum, โมดูล, ค่าคงที่ (constant) หรือไอเท็มอื่น ๆ และไอเท็มนั้นหมายความว่าอย่างไร คุณสามารถสร้างขอบเขตใหม่และปรับแต่งรายชื่อที่อยู่ภายในหรือภายนอกขอบเขตได้ ทั้งนี้คุณไม่สามารถมีไอเท็มสองชิ้นที่มีชื่อเดียวกันในขอบเขตการทำงานเดียวกันได้ แต่อย่างไรก็ตาม จะมีเครื่องมือต่าง ๆ พร้อมใช้งานเพื่อช่วยแก้ไขปัญหาความขัดแย้งของชื่อที่ซ้ำซ้อนกัน

Rust มีคุณลักษณะหลายประการที่ช่วยให้คุณสามารถจัดการการจัดระเบียบโค้ดของคุณ รวมถึงการกำหนดว่ารายละเอียดส่วนใดจะถูกเปิดเผย รายละเอียดส่วนใดจะเป็นแบบส่วนตัว และชื่อใดบ้างที่จะอยู่ในแต่ละขอบเขตการทำงานในโปรแกรมของคุณ คุณลักษณะเหล่านี้บางครั้งจะถูกเรียกรวมกันว่า ระบบโมดูล (module system) ซึ่งประกอบด้วย:

  • Packages (แพ็กเกจ): ฟีเจอร์ของ Cargo ที่ช่วยให้คุณสร้าง ทดสอบ และแบ่งปันเครตต่าง ๆ ได้
  • Crates (เครต): โครงสร้างแบบต้นไม้ของโมดูลต่าง ๆ ที่สร้างขึ้นมาเป็นห้องสมุด (library) หรือไฟล์โปรแกรมประมวลผล (executable)
  • Modules (โมดูล) และ use: ช่วยให้คุณควบคุมการจัดระเบียบ, ขอบเขต และความเป็นส่วนตัวของเส้นทางข้อมูล (paths)
  • Paths (เส้นทาง): วิธีในการตั้งชื่อระบุไอเท็ม เช่น struct, ฟังก์ชัน หรือโมดูล

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

Packages and Crates

แพ็กเกจ และ เครต (Packages and Crates)

ส่วนแรกสุดของระบบโมดูลที่เราจะมาทำความรู้จักกันคือ แพ็กเกจ (packages) และ เครต (crates)

เครต (crate) คือปริมาณโค้ดที่มีขนาดเล็กที่สุดที่คอมไพเลอร์ของ Rust จะพิจารณาในคราวเดียว แม้ว่าคุณจะรันคำสั่ง rustc โดยตรงไม่ใช่ cargo และส่งไฟล์ซอร์สโค้ดเพียงไฟล์เดียวเข้าไป (เหมือนที่เราทำในส่วน“พื้นฐานโปรแกรม Rust” ในบทที่ 1) คอมไพเลอร์ก็ยังจะพิจารณาว่าไฟล์ดังกล่าวเป็นเครตหนึ่ง เครตสามารถประกอบไปด้วยโมดูลต่าง ๆ และโมดูลเหล่านั้นอาจถูกกำหนดแยกไว้ในไฟล์อื่น ๆ ที่จะถูกคอมไพล์ร่วมกันกับเครตนั้นได้ ดังที่เราจะได้ศึกษาในส่วนถัด ๆ ไป

เครตสามารถแบ่งรูปแบบออกได้เป็นสองประเภทหลัก ๆ คือ เครตฐานสอง (binary crate) หรือ เครตห้องสมุด (library crate)

เครตฐานสอง (binary crates) คือโปรแกรมที่คุณสามารถคอมไพล์เป็นไฟล์ประมวลผล (executable) ที่รันใช้งานได้จริง เช่น โปรแกรมแบบบรรทัดคำสั่ง (command line) หรือระบบเซิร์ฟเวอร์ โดยแต่ละเครตจำเป็นต้องมีฟังก์ชันที่ชื่อว่า main เพื่อกำหนดสิ่งที่เกิดขึ้นเมื่อรันไฟล์ประมวลผลนั้น ซึ่งเครตทั้งหมดที่เราสร้างขึ้นมาจนถึงตอนนี้ล้วนเป็นเครตฐานสองทั้งสิ้น

เครตห้องสมุด (library crates) จะไม่มีฟังก์ชัน main และจะไม่ถูกคอมไพล์ออกมาเป็นไฟล์ประมวลผลที่รันได้โดยตรง แต่ทว่าพวกมันจะถูกกำหนดฟังก์ชันการทำงานเพื่อนำไปแบ่งปันใช้งานร่วมกับหลาย ๆ โปรเจกต์ ตัวอย่างเช่น เครต rand ที่เราเลือกใช้ในบทที่ 2 ซึ่งให้บริการความสามารถในการสุ่มตัวเลข โดยส่วนใหญ่แล้วเมื่อกลุ่มคนที่เขียนภาษา Rust (Rustaceans) พูดถึงคำว่า “เครต (crate)” มักจะสื่อความหมายถึงเครตห้องสมุด และนิยมใช้คำว่า “เครต” สลับสับเปลี่ยนไปมาแทนแนวคิดทั่วไปในการเขียนโปรแกรมอย่างคำว่า “ไลบรารี (library)”

รากเครต (crate root) คือไฟล์ซอร์สโค้ดเริ่มต้นที่คอมไพเลอร์ของ Rust จะเริ่มทำการตรวจสอบเป็นจุดแรกสุด และถือเป็นโมดูลรากของเครตของคุณ (เราจะอธิบายเกี่ยวกับโมดูลแบบเจาะลึกในส่วน“ควบคุมขอบเขตและความเป็นส่วนตัวด้วยโมดูล”)

แพ็กเกจ (package) คือกลุ่มของเครตตั้งแต่หนึ่งเครตขึ้นไปที่มารวมตัวกันเพื่อมอบคุณสมบัติการทำงานชุดหนึ่ง แพ็กเกจจะมีไฟล์ Cargo.toml บรรจุอยู่ซึ่งระบุข้อมูลวิธีการสร้างเครตเหล่านั้น อันที่จริงแล้วระบบ Cargo ก็เป็นแพ็กเกจหนึ่งที่ประกอบไปด้วยเครตฐานสองสำหรับเครื่องมือบรรทัดคำสั่งที่คุณใช้สร้างและคอมไพล์โค้ดของคุณ และแพ็กเกจ Cargo ก็ยังมีเครตห้องสมุดที่เครตฐานสองจำเป็นต้องใช้งานเชื่อมโยงอยู่ด้วย โดยโปรเจกต์อื่น ๆ ก็สามารถเลือกนำเอาเครตห้องสมุดของ Cargo นี้ไปเชื่อมโยงใช้งานตรรกะแบบเดียวกับที่เครื่องมือบรรทัดคำสั่ง Cargo ใช้งานได้เช่นกัน

แพ็กเกจสามารถจัดเก็บเครตฐานสองได้มากเท่าที่คุณต้องการ แต่ทว่าจะมีเครตห้องสมุดบรรจุอยู่ได้มากที่สุดเพียงหนึ่งเครตเท่านั้น และแพ็กเกจจำเป็นต้องมีเครตบรรจุอยู่อย่างน้อยหนึ่งเครต ไม่ว่าจะเป็นเครตห้องสมุดหรือเครตฐานสองก็ตาม

เรามาดูรายละเอียดขั้นตอนที่เกิดขึ้นเมื่อเราสั่งสร้างแพ็กเกจกัน เริ่มแรกเราจะพิมพ์คำสั่ง cargo new my-project:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

หลังจากที่เราสั่งรัน cargo new my-project แล้ว เราจะใช้คำสั่ง ls เพื่อตรวจสอบสิ่งต่าง ๆ ที่ระบบ Cargo สรรสร้างขึ้นมา โดยภายในไดเรกทอรี my-project จะมีไฟล์ Cargo.toml ปรากฏอยู่ ซึ่งแสดงความจำนงเป็นแพ็กเกจ นอกจากนี้ยังมีไดเรกทอรี src ที่เก็บไฟล์ main.rs อยู่ด้วย เมื่อลองเปิดไฟล์ Cargo.toml ในโปรแกรมแก้ไขข้อความของคุณ จะสังเกตพบว่าไม่มีการเขียนระบุชื่อของ src/main.rs อยู่ในไฟล์เลย เนื่องจากระบบ Cargo ทำงานตามข้อตกลงร่วมกัน (convention) ว่าไฟล์ src/main.rs จะทำหน้าที่เป็นรากเครต (crate root) ของเครตฐานสองที่มีชื่อเดียวกับชื่อแพ็กเกจ ในทำนองเดียวกัน Cargo ก็จะรู้ทันทีว่าหากไดเรกทอรีของแพ็กเกจมีไฟล์ src/lib.rs บรรจุอยู่ ก็หมายความว่าแพ็กเกจดังกล่าวมีเครตห้องสมุดที่ตั้งชื่อเดียวกับชื่อแพ็กเกจ และให้ไฟล์ src/lib.rs ทำหน้าที่เป็นรากเครตของมัน โดย Cargo จะส่งผ่านไฟล์รากเครตเหล่านี้ต่อไปยัง rustc เพื่อประมวลผลสร้างเป็นห้องสมุดหรือไฟล์โปรแกรมประมวลผลจริง

ในตัวอย่างนี้ เรามีแพ็กเกจที่บรรจุเพียงไฟล์ src/main.rs เท่านั้น ซึ่งหมายความว่ามันมีเพียงเครตฐานสองชื่อ my-project เพียงเครตเดียว แต่หากในแพ็กเกจมีทั้งไฟล์ src/main.rs และ src/lib.rs ก็หมายความว่ามันมีเครตสองตัวด้วยกัน คือเครตฐานสองและเครตห้องสมุด ซึ่งทั้งคู่จะใช้ชื่อเดียวกับแพ็กเกจ นอกจากนี้แพ็กเกจสามารถบรรจุเครตฐานสองได้หลายตัวโดยการนำไฟล์เหล่านั้นไปวางไว้ในไดเรกทอรี src/bin ซึ่งแต่ละไฟล์ในนั้นจะถูกประมวลผลแยกเป็นเครตฐานสองของตนเอง

Control Scope and Privacy with Modules

ควบคุมขอบเขตและความเป็นส่วนตัวด้วยโมดูล (Control Scope and Privacy with Modules)

ในส่วนนี้ เราจะพูดถึงโมดูลและส่วนอื่น ๆ ของระบบโมดูล ได้แก่ เส้นทาง (paths) ซึ่งช่วยให้คุณสามารถระบุชื่อของไอเท็มต่าง ๆ ได้, คีย์เวิร์ด use สำหรับนำเอาเส้นทางเข้าสู่ขอบเขตการทำงาน และคีย์เวิร์ด pub เพื่อกำหนดให้ไอเท็มนั้น ๆ เป็นแบบสาธารณะ นอกจากนี้เรายังจะอภิปรายเกี่ยวกับคีย์เวิร์ด as, แพ็กเกจภายนอก (external packages) และตัวดำเนินการดอกจัน (glob operator) อีกด้วย

สรุปย่อการใช้งานโมดูล (Modules Cheat Sheet)

ก่อนที่เราจะลงลึกถึงรายละเอียดของโมดูลและเส้นทาง เราขอสรุปข้อมูลอ้างอิงอย่างรวดเร็วเกี่ยวกับวิธีการทำงานของโมดูล, เส้นทาง, คีย์เวิร์ด use และคีย์เวิร์ด pub ในคอมไพเลอร์ รวมถึงวิธีการจัดระเบียบโค้ดที่นักพัฒนาส่วนใหญ่นิยมใช้ เราจะศึกษาตัวอย่างของกฎแต่ละข้อในบทนี้ แต่หัวข้อนี้จะเป็นจุดอ้างอิงที่ดีในการช่วยเตือนความจำเกี่ยวกับหลักการทำงานของโมดูล

  • เริ่มต้นจากรากเครต (crate root): เมื่อทำการคอมไพล์เครต ตัวคอมไพเลอร์จะมองหาไฟล์รากเครตเป็นอันดับแรกสุดเพื่อคอมไพล์โค้ด (โดยทั่วไปคือไฟล์ src/lib.rs สำหรับเครตห้องสมุด และไฟล์ src/main.rs สำหรับเครตฐานสอง)
  • การประกาศโมดูล: ในไฟล์รากเครต คุณสามารถเขียนประกาศโมดูลใหม่ได้ เช่น การประกาศโมดูลสวน “garden” ด้วยคำสั่ง mod garden; ทางคอมไพเลอร์จะทำการค้นหาโค้ดของโมดูลดังกล่าวในสถานที่เหล่านี้:
    • เขียนแบบอินไลน์ (inline) ภายในวงเล็บปีกกาที่เขียนขึ้นมาแทนเครื่องหมายอัฒภาค (semicolon) ต่อจากคำว่า mod garden
    • ภายในไฟล์ src/garden.rs
    • ภายในไฟล์ src/garden/mod.rs
  • การประกาศโมดูลย่อย (submodules): ในไฟล์อื่น ๆ ที่ไม่ใช่ไฟล์รากเครต คุณสามารถประกาศโมดูลย่อยได้ ตัวอย่างเช่น คุณอาจเขียนประกาศคำสั่ง mod vegetables; ไว้ในไฟล์ src/garden.rs คอมไพเลอร์จะทำการค้นหาโค้ดของโมดูลย่อยดังกล่าวภายในไดเรกทอรีที่มีชื่อเดียวกับโมดูลหลัก (parent module) ในสถานที่เหล่านี้:
    • เขียนแบบอินไลน์ต่อจากคำว่า mod vegetables โดยใช้วงเล็บปีกกาแทนที่เครื่องหมายอัฒภาค
    • ภายในไฟล์ src/garden/vegetables.rs
    • ภายในไฟล์ src/garden/vegetables/mod.rs
  • เส้นทางไปยังโค้ดในโมดูล: เมื่อโมดูลเข้ามาเป็นส่วนหนึ่งของเครตของคุณแล้ว คุณจะสามารถอ้างอิงถึงโค้ดในโมดูลนั้นจากจุดใด ๆ ในเครตเดียวกันได้ ตราบใดที่กฎความเป็นส่วนตัวอนุญาต โดยใช้เส้นทาง (path) ไปยังโค้ดนั้น ตัวอย่างเช่น ชนิดข้อมูล Asparagus ในโมดูลผักสวนครัวจะเข้าถึงได้ทางเส้นทาง crate::garden::vegetables::Asparagus
  • ความเป็นส่วนตัวและสาธารณะ (Private vs. public): โดยปกติแล้ว โค้ดที่อยู่ภายในโมดูลจะเป็นแบบส่วนตัว (private) จากโมดูลหลัก (parent modules) เสมอ หากต้องการกำหนดให้โมดูลเป็นแบบสาธารณะ ให้ใช้คำสั่งประกาศเป็น pub mod แทนคำว่า mod และหากต้องการให้ไอเท็มภายในโมดูลสาธารณะเป็นแบบสาธารณะด้วย ให้ใส่คีย์เวิร์ด pub ไว้หน้าคำประกาศของไอเท็มเหล่านั้น
  • คีย์เวิร์ด use: ภายในขอบเขตการทำงาน คีย์เวิร์ด use จะใช้สร้างทางลัด (shortcuts) ไปยังไอเท็มเพื่อลดการเขียนเส้นทางยาว ๆ ซ้ำไปซ้ำมา ในขอบเขตใด ๆ ที่สามารถเข้าถึง crate::garden::vegetables::Asparagus ได้ คุณสามารถสร้างทางลัดได้ด้วยคำสั่ง use crate::garden::vegetables::Asparagus; และหลังจากนั้นคุณเพียงแค่พิมพ์คำว่า Asparagus เพื่อใช้งานชนิดข้อมูลนั้นภายในขอบเขตได้เลย

ด้านล่างนี้ เราได้สร้างเครตฐานสองชื่อ backyard เพื่อแสดงหลักการของกฎเหล่านี้ ไดเรกทอรีของเครตซึ่งใช้ชื่อว่า backyard เช่นกัน จะบรรจุไฟล์และไดเรกทอรีต่อไปนี้:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

ไฟล์รากเครตในกรณีนี้คือ src/main.rs ซึ่งมีข้อมูลภายในดังนี้:

use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

บรรทัด pub mod garden; จะทำหน้าที่แจ้งให้คอมไพเลอร์นำโค้ดที่พบในไฟล์ src/garden.rs เข้ามารวม ซึ่งโค้ดดังกล่าวคือ:

pub mod vegetables;

ในที่นี้ pub mod vegetables; หมายถึงการนำโค้ดในไฟล์ src/garden/vegetables.rs เข้ามารวมด้วยเช่นกัน ซึ่งโค้ดนั้นคือ:

#[derive(Debug)]
pub struct Asparagus {}

คราวนี้เรามาเจาะลึกถึงรายละเอียดของกฎเหล่านี้และสัมผัสการทำงานจริงกันเลย!

โมดูล (Modules) ช่วยให้เราสามารถจัดระเบียบโค้ดภายในเครตเพื่อให้อ่านเข้าใจง่ายและนำกลับมาใช้ใหม่ได้สะดวก นอกจากนี้โมดูลยังช่วยควบคุม ความเป็นส่วนตัว (privacy) ของไอเท็มต่าง ๆ ได้ เนื่องจากโดยปกติแล้ว โค้ดที่อยู่ภายในโมดูลจะเป็นแบบส่วนตัว (private) เสมอ ไอเท็มส่วนตัวจัดเป็นรายละเอียดการทำงานภายในที่ไม่มีให้สำหรับภายนอกนำไปใช้งานต่อ แต่เราสามารถเลือกที่จะทำให้โมดูลรวมถึงไอเท็มที่อยู่ภายในเป็นแบบสาธารณะ (public) ได้ ซึ่งจะเป็นการเปิดเผยเพื่อให้โค้ดภายนอกสามารถเรียกใช้และอ้างอิงเพื่อนำไปเชื่อมโยงใช้ต่อได้

ตัวอย่างเช่น เรามาลองเขียนเครตห้องสมุดที่ให้บริการระบบของร้านอาหาร โดยเราจะระบุเพียงหัวประกาศ (signatures) ของฟังก์ชันต่าง ๆ แต่จะเว้นว่างเนื้อตัวฟังก์ชัน (bodies) ไว้ก่อน เพื่อที่เราจะได้ให้ความสำคัญกับการจัดระเบียบโครงสร้างโค้ด แทนที่จะเป็นรายละเอียดการทำงานจริงของร้านอาหาร

ในธุรกิจร้านอาหาร ชิ้นส่วนบางแห่งของร้านจะเรียกว่าส่วนหน้า (front of house) และส่วนอื่น ๆ จะเรียกว่าส่วนหลัง (back of house) โดย ส่วนหน้า (Front of house) คือส่วนที่ลูกค้าเข้ามาใช้บริการ ซึ่งครอบคลุมจุดต้อนรับลูกค้าที่พนักงานพาไปนั่งโต๊ะ พนักงานเสิร์ฟรับออเดอร์และเก็บเงิน และบาร์เทนเดอร์ผสมเครื่องดื่ม ส่วน ส่วนหลัง (Back of house) คือจุดที่เชฟและกุ๊กปรุงอาหารในครัว พนักงานล้างจานทำความสะอาด และผู้จัดการทำงานเอกสารธุรการ

เพื่อจัดระเบียบเครตของเราในรูปแบบนี้ เราสามารถจัดกลุ่มฟังก์ชันต่าง ๆ ให้อยู่ในรูปแบบโมดูลย่อยซ้อนกันได้ ให้คุณสร้างห้องสมุดใหม่ที่ชื่อว่า restaurant โดยรันคำสั่ง cargo new restaurant --lib จากนั้นนำโค้ดในรายการโค้ดที่ 7-1 ไปบันทึกในไฟล์ src/lib.rs เพื่อกำหนดโมดูลและหัวประกาศของฟังก์ชันต่าง ๆ ซึ่งโค้ดชุดนี้คือส่วนหน้าของร้านอาหาร

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

เรากำหนดโมดูลด้วยการพิมพ์คีย์เวิร์ด mod ตามด้วยชื่อของโมดูล (ในกรณีนี้คือ front_of_house) เนื้อความภายในโมดูลจะเขียนอยู่ภายในวงเล็บปีกกา ภายในโมดูลเราสามารถนำเอาโมดูลอื่น ๆ ไปเขียนบรรจุไว้ได้ ดังเช่นกรณีนี้ที่มีโมดูล hosting และ serving นอกจากนี้โมดูลยังสามารถจัดเก็บคำประกาศสำหรับไอเท็มอื่น ๆ ได้ เช่น structs, enums, ค่าคงที่, traits และรวมถึงฟังก์ชันต่าง ๆ เช่นที่ทำในรายการโค้ดที่ 7-1

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

ก่อนหน้านี้เราได้พูดถึงไปว่าไฟล์ src/main.rs และ src/lib.rs จะถูกเรียกว่า รากเครต (crate roots) เหตุผลที่เรียกแบบนี้เพราะเนื้อหาข้อมูลใด ๆ ภายในไฟล์ทั้งสองไฟล์นี้จะทำหน้าที่สร้างโมดูลชื่อ crate ที่อยู่บริเวณฐานรากของโครงสร้างโมดูลของเครต หรือที่เรียกว่า ต้นไม้โมดูล (module tree)

รายการโค้ดที่ 7-2 แสดงโครงสร้างต้นไม้โมดูลสำหรับนิยามในรายการโค้ดที่ 7-1

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

โครงสร้างต้นไม้นี้แสดงถึงวิธีการที่บางโมดูลซ้อนอยู่ภายในโมดูลอื่น ๆ ตัวอย่างเช่น hosting ซ้อนอยู่ใน front_of_house และยังแสดงว่าบางโมดูลมีสถานะเป็น พี่น้อง (siblings) กัน ซึ่งหมายความว่าพวกมันถูกกำหนดไว้ภายใต้โมดูลหลักตัวเดียวกัน โดย hosting และ serving เป็นพี่น้องกันที่ถูกนิยามไว้ใน front_of_house หากโมดูล A อยู่ภายในโมดูล B เราจะกล่าวว่าโมดูล A เป็น ลูก (child) ของโมดูล B และโมดูล B เป็น หลัก (parent) ของโมดูล A โปรดสังเกตว่าต้นไม้โมดูลทั้งหมดจะหยั่งรากอยู่ภายใต้โมดูลสมมติ (implicit module) ที่ชื่อว่า crate

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

Paths for Referring to an Item in the Module Tree

เส้นทางสำหรับอ้างอิงถึงไอเท็มในต้นไม้โมดูล (Paths for Referring to an Item in the Module Tree)

ในการชี้บอก Rust ว่าจะค้นหาไอเท็มในโครงสร้างต้นไม้โมดูลได้จากที่ไหน เราจะใช้วิธีระบุเส้นทาง (path) ในลักษณะเดียวกับการนำทางระบบไฟล์ในคอมพิวเตอร์ของคุณ โดยการเรียกใช้งานฟังก์ชันใด ๆ เราจำเป็นต้องทราบเส้นทางของมัน

เส้นทางสามารถมีรูปแบบได้สองแบบ ได้แก่:

  • เส้นทางแบบสัมบูรณ์ (absolute path): คือเส้นทางแบบเต็มที่เริ่มต้นจากรากเครต (crate root) สำหรับโค้ดที่มาจากเครตภายนอก เส้นทางแบบสัมบูรณ์จะขึ้นต้นด้วยชื่อเครตนั้น ๆ และสำหรับโค้ดที่อยู่ในเครตปัจจุบัน จะขึ้นต้นด้วยคำว่า crate
  • เส้นทางแบบสัมพัทธ์ (relative path): คือเส้นทางที่เริ่มต้นจากโมดูลปัจจุบัน โดยใช้คำว่า self, super หรือตัวระบุชื่อที่อยู่ภายในโมดูลปัจจุบัน

ทั้งเส้นทางแบบสัมบูรณ์และแบบสัมพัทธ์จะถูกต่อท้ายด้วยตัวระบุชื่อหนึ่งตัวหรือมากกว่า ซึ่งคั่นกลางด้วยเครื่องหมายโคลอนคู่ (::)

ย้อนกลับไปพิจารณารายการโค้ดที่ 7-1 สมมติว่าเราต้องการเรียกใช้งานฟังก์ชัน add_to_waitlist ซึ่งก็เทียบเท่ากับคำถามที่ว่า: เส้นทางของฟังก์ชัน add_to_waitlist คืออะไร? รายการโค้ดที่ 7-3 คือโค้ดในรายการโค้ดที่ 7-1 ที่ได้ตัดโมดูลและฟังก์ชันบางส่วนที่ไม่เกี่ยวข้องออกไป

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

ฟังก์ชัน eat_at_restaurant เป็นส่วนหนึ่งของ API สาธารณะของเครตห้องสมุดของเรา ดังนั้นเราจึงทำเครื่องหมายมันไว้ด้วยคีย์เวิร์ด pub โดยเราจะเจาะลึกรายละเอียดเกี่ยวกับ pub เพิ่มเติมในหัวข้อ“เปิดเผยเส้นทางด้วยคีย์เวิร์ด pub

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

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

ในครั้งที่สองที่เราเรียกใช้ฟังก์ชัน add_to_waitlist ใน eat_at_restaurant เราใช้เส้นทางแบบสัมพัทธ์ โดยเริ่มเขียนจาก front_of_house ซึ่งเป็นชื่อโมดูลที่ประกาศไว้ในระดับเดียวกันกับ eat_at_restaurant บนต้นไม้โมดูล ในที่นี้หากเปรียบเทียบกับระบบไฟล์จะเทียบเท่ากับการใช้เส้นทาง front_of_house/hosting/add_to_waitlist การเขียนเริ่มต้นด้วยชื่อโมดูลจึงสื่อความหมายว่าเส้นทางนั้นเป็นแบบสัมพัทธ์

การตัดสินใจว่าจะเลือกใช้เส้นทางแบบสัมพัทธ์หรือแบบสัมบูรณ์เป็นเรื่องที่คุณต้องพิจารณาตามลักษณะของโปรเจกต์ ซึ่งขึ้นอยู่กับว่าคุณมีแนวโน้มที่จะย้ายโค้ดส่วนที่ประกาศนิยามไอเท็มแยกออกจากกัน หรือย้ายไปพร้อม ๆ กันกับโค้ดส่วนที่เรียกใช้ไอเท็มนั้น ตัวอย่างเช่น หากเราตัดสินใจย้ายโมดูล front_of_house และฟังก์ชัน eat_at_restaurant เข้าไปอยู่ภายใต้โมดูลใหม่ชื่อ customer_experience เราจำเป็นจะต้องเข้าไปแก้ไขปรับปรุงเส้นทางแบบสัมบูรณ์ของ add_to_waitlist แต่ทว่าเส้นทางแบบสัมพัทธ์จะยังคงใช้งานได้ปกติโดยไม่ต้องแก้ไขอะไรเลย แต่ในทางกลับกัน หากเราย้ายเฉพาะฟังก์ชัน eat_at_restaurant แยกออกมาไว้ในโมดูลชื่อ dining เส้นทางแบบสัมบูรณ์ของการเรียก add_to_waitlist จะยังคงเดิมไม่เปลี่ยนแปลง แต่เราจำเป็นต้องเข้าไปอัปเดตเส้นทางแบบสัมพัทธ์แทน โดยทั่วไปแล้วความนิยมส่วนตัวของเราคือการระบุเส้นทางแบบสัมบูรณ์ เนื่องจากมีโอกาสมากกว่าที่เราจะย้ายตำแหน่งการประกาศโค้ดและการเรียกใช้ไอเท็มอย่างเป็นอิสระต่อกัน

เรามาลองคอมไพล์โค้ดในรายการโค้ดที่ 7-3 เพื่อหาสาเหตุว่าทำไมมันถึงยังคอมไพล์ไม่ผ่านกัน! ข้อผิดพลาดที่ได้รับแสดงในรายการโค้ดที่ 7-4

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
 2 |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

ข้อความแจ้งข้อผิดพลาดระบุว่าโมดูล hosting เป็นแบบส่วนตัว (private) พูดอีกนัยหนึ่งคือ เราเขียนเส้นทางไปยังโมดูล hosting และฟังก์ชัน add_to_waitlist ได้อย่างถูกต้องดีแล้ว แต่ทว่า Rust ไม่อนุญาตให้เราใช้งานพวกมันเนื่องจากระบบไม่ได้รับสิทธิ์ให้เข้าถึงส่วนที่เป็นส่วนตัว ในภาษา Rust ไอเท็มทั้งหมด (ฟังก์ชัน, เมธอด, structs, enums, โมดูล และค่าคงที่) จะถูกตั้งค่าเริ่มต้นให้เป็นแบบส่วนตัวจากโมดูลหลักเสมอ หากคุณต้องการกำหนดให้ไอเท็มอย่างฟังก์ชันหรือ struct เป็นแบบส่วนตัว คุณก็เพียงนำมันไปเก็บไว้ในโมดูล

ไอเท็มที่อยู่ในโมดูลหลักจะไม่สามารถใช้งานไอเท็มส่วนตัวที่อยู่ภายในโมดูลย่อย (child modules) ได้ แต่ไอเท็มในโมดูลย่อยจะสามารถเรียกใช้งานไอเท็มที่ประกาศไว้ในโมดูลบรรพบุรุษ (ancestor modules) ของพวกมันได้ ที่เป็นเช่นนี้เพราะโมดูลย่อยมีหน้าที่ห่อหุ้มและปกปิดรายละเอียดการทำงานภายใน แต่โมดูลย่อยเหล่านั้นยังมองเห็นบริบทแวดล้อมที่มันถูกนิยามขึ้นมาได้ หากใช้การเปรียบเทียบกับร้านอาหารเดิม ให้จินตนาการว่ากฎความเป็นส่วนตัวเปรียบเสมือนห้องผู้จัดการหลังร้าน ซึ่งสิ่งที่เกิดขึ้นในนั้นจะเป็นเรื่องส่วนตัวที่ลูกค้าของร้านไม่สามารถรับรู้ได้ แต่ผู้จัดการร้านสามารถมองเห็นและทำทุกอย่างในร้านอาหารที่เขาดูแลได้

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

เปิดเผยเส้นทางด้วยคีย์เวิร์ด pub (Exposing Paths with the pub Keyword)

เรามาย้อนกลับมาพิจารณาข้อผิดพลาดในรายการโค้ดที่ 7-4 ที่แจ้งว่าโมดูล hosting เป็นแบบส่วนตัวกัน เราต้องการให้ฟังก์ชัน eat_at_restaurant ในโมดูลหลักมีสิทธิ์เข้าถึงฟังก์ชัน add_to_waitlist ในโมดูลย่อยได้ เราจึงเขียนคีย์เวิร์ด pub กำกับไว้หน้าโมดูล hosting ดังแสดงในรายการโค้ดที่ 7-5

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

แต่น่าเสียดายที่โค้ดในรายการโค้ดที่ 7-5 ก็ยังคงส่งผลให้เกิดข้อผิดพลาดในการคอมไพล์อยู่ดี ดังแสดงในรายการโค้ดที่ 7-6

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

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

ข้อผิดพลาดในรายการโค้ดที่ 7-6 ระบุว่าฟังก์ชัน add_to_waitlist เป็นแบบส่วนตัว ซึ่งกฎความเป็นส่วนตัวนี้จะบังคับใช้กับทั้ง structs, enums, ฟังก์ชัน, เมธอด และโมดูลเช่นเดียวกัน

เรามาทำให้ฟังก์ชัน add_to_waitlist กลายเป็นสาธารณะด้วยการเพิ่มคีย์เวิร์ด pub ไว้หน้าคำจำกัดความของมันกันเถอะ ดังแสดงในรายการโค้ดที่ 7-7

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

ในตอนนี้โค้ดจะสามารถคอมไพล์ผ่านแล้ว! เพื่อทำความเข้าใจว่าทำไมการเพิ่มคีย์เวิร์ด pub จึงทำให้เราสามารถใช้เส้นทางเหล่านี้ใน eat_at_restaurant ได้โดยสอดคล้องกับกฎความเป็นส่วนตัว เรามาลองดูการทำงานของเส้นทางแบบสัมบูรณ์และแบบสัมพัทธ์กัน

สำหรับเส้นทางแบบสัมบูรณ์เราเริ่มต้นจาก crate ซึ่งเป็นรากของโครงสร้างต้นไม้โมดูลของเครต โมดูล front_of_house ถูกนิยามไว้ที่ระดับรากเครต แม้ว่า front_of_house จะไม่เป็นแบบสาธารณะ แต่เนื่องจากฟังก์ชัน eat_at_restaurant ถูกประกาศไว้ในโมดูลเดียวกับ front_of_house (นั่นคือ eat_at_restaurant และ front_of_house มีสถานะเป็นพี่น้องกัน) เราจึงอ้างอิงถึง front_of_house จาก eat_at_restaurant ได้ ถัดไปคือโมดูล hosting ซึ่งทำเครื่องหมายด้วย pub เนื่องจากเรามีสิทธิ์เข้าถึงโมดูลหลักของ hosting ได้อยู่แล้ว เราจึงเข้าถึง hosting ได้ และสุดท้ายฟังก์ชัน add_to_waitlist ถูกกำหนดด้วย pub และเราเข้าถึงโมดูลหลักของมันได้เช่นกัน การเรียกใช้งานฟังก์ชันจุดนี้จึงทำงานได้สำเร็จ!

ในเส้นทางแบบสัมพัทธ์ ตรรกะการวิเคราะห์จะคล้ายกับเส้นทางแบบสัมบูรณ์ ยกเว้นขั้นตอนแรกสุดที่แทนที่จะเริ่มค้นหาจากรากเครต จะเริ่มค้นหาจากโมดูล front_of_house แทน เนื่องจากโมดูล front_of_house ประกาศไว้ในระดับเดียวกันกับ eat_at_restaurant เส้นทางแบบสัมพัทธ์ที่เริ่มจากโมดูลที่เป็นที่อยู่ของ eat_at_restaurant จึงทำงานได้ จากนั้นเพราะ hosting และ add_to_waitlist ถูกกำหนดด้วยคีย์เวิร์ด pub เส้นทางที่เหลือจึงถูกต้อง และทำให้การเรียกใช้ฟังก์ชันนี้เสร็จสิ้นอย่างสมบูรณ์!

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

แนวทางปฏิบัติที่ดีที่สุดสำหรับแพ็กเกจที่มีทั้งไฟล์ระบบฐานสองและห้องสมุด (Best Practices for Packages with a Binary and a Library)

เรามีระบุไปว่าแพ็กเกจหนึ่งสามารถมีทั้งรากเครตฐานสองอย่าง src/main.rs และรากเครตห้องสมุดอย่าง src/lib.rs ควบคู่กันได้ และเครตทั้งสองจะใช้ชื่อร่วมตามชื่อแพ็กเกจเป็นค่าเริ่มต้น โดยทั่วไป แพ็กเกจที่ใช้โครงสร้างแบบมีทั้งห้องสมุดและไฟล์ประมวลผลนี้ จะเน้นเขียนโค้ดในเครตฐานสองไว้เพียงเล็กน้อยเฉพาะเท่าที่จำเป็นสำหรับรันเป็นไฟล์ประมวลผลที่จะทำหน้าที่กระโดดไปเรียกใช้โค้ดที่กำหนดไว้ในเครตห้องสมุดอีกทีหนึ่ง วิธีนี้ช่วยให้โปรเจกต์อื่น ๆ ได้รับประโยชน์สูงสุดจากฟังก์ชันที่แพ็กเกจนำเสนอ เนื่องจากโค้ดของเครตห้องสมุดจะสามารถนำไปแชร์ใช้งานร่วมกันได้ง่าย

โครงสร้างต้นไม้โมดูลควรได้รับการประกาศนิยามไว้ในไฟล์ src/lib.rs หลังจากนั้น ไอเท็มสาธารณะใด ๆ ก็จะสามารถถูกเรียกใช้ภายในเครตฐานสองได้โดยระบุเส้นทางเริ่มต้นด้วยชื่อของแพ็กเกจ ซึ่งเครตฐานสองจะมีสถานะเป็นเสมือนผู้ใช้งานเครตห้องสมุดทั่วไป เช่นเดียวกับเครตภายนอกอื่น ๆ ที่จะเข้ามาขอดึงห้องสมุดนี้ไปใช้ คือมีสิทธิ์เรียกใช้งานได้เฉพาะส่วนของ API สาธารณะเท่านั้น ซึ่งจุดนี้จะช่วยให้คุณออกแบบ API ที่ดีได้ เพราะนอกจากคุณจะเป็นผู้เขียนแล้ว คุณยังเป็นลูกค้าผู้ใช้งานเองด้วย!

ในบทที่ 12 เราจะสาธิตการจัดระเบียบรูปแบบนี้ผ่านโปรแกรมแบบบรรทัดคำสั่ง (CLI) ที่จะประกอบไปด้วยทั้งเครตฐานสองและเครตห้องสมุดร่วมกัน

การเขียนเริ่มต้นเส้นทางแบบสัมพัทธ์ด้วย super (Starting Relative Paths with super)

เราสามารถเขียนเส้นทางแบบสัมพัทธ์ที่เริ่มจากโมดูลหลัก (parent module) แทนที่จะเริ่มจากโมดูลปัจจุบันหรือรากเครตได้ โดยการใส่คำว่า super ไว้ที่ด้านหน้าสุดของเส้นทาง สิ่งนี้เปรียบเสมือนการเขียนเส้นทางในระบบไฟล์ด้วยเครื่องหมาย .. เพื่อระบุให้ย้อนขึ้นไปที่ไดเรกทอรีด้านบน การใช้ super ช่วยให้เราอ้างอิงถึงไอเท็มที่เราทราบดีว่าถูกประกาศไว้ในโมดูลหลักได้ ซึ่งจะทำให้การปรับเปลี่ยนโครงสร้างต้นไม้โมดูลในอนาคตทำได้ง่ายขึ้น ในยามที่โมดูลย่อยมีความผูกพันใกล้ชิดกับโมดูลหลัก แต่โมดูลหลักนั้นอาจถูกโยกย้ายไปอยู่ที่ตำแหน่งอื่นในอนาคต

ลองพิจารณาโค้ดในรายการโค้ดที่ 7-8 ที่จำลองสถานการณ์ที่พ่อครัวตรวจพบความผิดพลาดของออเดอร์และทำหน้าที่แก้ไขออเดอร์นั้นพร้อมนำไปเสิร์ฟให้ลูกค้าด้วยตนเอง ฟังก์ชัน fix_incorrect_order ที่อยู่ในโมดูล back_of_house จะเรียกใช้ฟังก์ชัน deliver_order ที่อยู่ในโมดูลหลักภายนอก โดยระบุเส้นทางไปยัง deliver_order ที่เริ่มต้นด้วยคำว่า super

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

ฟังก์ชัน fix_incorrect_order อยู่ภายในโมดูล back_of_house ดังนั้นเราจึงสามารถใช้ super เพื่อถอยออกไปยังโมดูลหลักของ back_of_house ซึ่งในกรณีนี้คือ crate (รากเครต) และจากรากเครตนี้เราก็จะมองหาฟังก์ชัน deliver_order จนพบได้สำเร็จ! เราคาดการณ์ว่าโมดูล back_of_house และฟังก์ชัน deliver_order น่าจะมีความสัมพันธ์เชิงโครงสร้างแบบนี้อยู่เสมอ และจะถูกโยกย้ายไปด้วยกันหากมีการจัดระเบียบโครงสร้างต้นไม้โมดูลใหม่ ด้วยเหตุนี้การเลือกใช้ super จะช่วยลดจุดที่เราต้องเข้าไปแก้ปรับปรุงโค้ดในอนาคตได้หากมีการย้ายโค้ดชุดนี้ไปไว้ในโมดูลอื่น

การกำหนดให้ Struct และ Enum เป็นแบบสาธารณะ (Making Structs and Enums Public)

เราสามารถใช้ pub เพื่อกำหนดให้โครงสร้างข้อมูล (structs) และ enums กลายเป็นสาธารณะได้เช่นกัน แต่ทว่าจะมีรายละเอียดเฉพาะตัวสำหรับการใช้ pub ร่วมกับ structs และ enums อยู่บ้าง หากเราใช้ pub ไว้หน้าคำประกาศ struct จะส่งผลให้ตัว struct นั้นกลายเป็นสาธารณะ แต่ทว่าฟิลด์ (fields) ภายใน struct จะยังคงมีสถานะเป็นส่วนตัว (private) อยู่เหมือนเดิม โดยเราสามารถเลือกที่จะกำหนดให้แต่ละฟิลด์เป็นสาธารณะหรือไม่แยกเป็นกรณี ๆ ไปได้ ในรายการโค้ดที่ 7-9 เราได้กำหนด struct back_of_house::Breakfast แบบสาธารณะ โดยมีฟิลด์ toast เป็นสาธารณะ แต่ฟิลด์ seasonal_fruit เป็นส่วนตัว ซึ่งจำลองสถานการณ์ในร้านอาหารที่ลูกค้าสามารถสั่งระบุประเภทของขนมปังที่จะทานคู่กับมื้ออาหารได้ แต่พ่อครัวจะเป็นผู้ตัดสินใจว่าผลไม้ตามฤดูกาลชนิดใดที่จะนำมาเสิร์ฟให้ โดยอ้างอิงจากผลไม้ที่มีอยู่และวัตถุดิบในสต็อก เนื่องจากรายการผลไม้มีการเปลี่ยนแปลงได้ง่าย ลูกค้าจึงไม่สามารถเลือกผลไม้หรือมองเห็นก่อนได้ว่าจะได้ผลไม้อะไร

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}

เนื่องจากฟิลด์ toast ภายใน struct back_of_house::Breakfast เป็นแบบสาธารณะ ทำให้ในฟังก์ชัน eat_at_restaurant เราสามารถเขียนและอ่านข้อมูลของฟิลด์ toast ได้โดยตรงผ่านการเขียนระบุด้วยสัญลักษณ์จุด (.) ทั้งนี้สังเกตว่าเราไม่สามารถเข้าถึงฟิลด์ seasonal_fruit ใน eat_at_restaurant ได้ เนื่องจาก seasonal_fruit เป็นส่วนตัว ลองเปิดคอมเมนต์บรรทัดที่ทำการแก้ไขค่าของ seasonal_fruit ดูเพื่อตรวจสอบข้อผิดพลาดที่จะเกิดขึ้นได้!

นอกจากนี้ สังเกตด้วยว่าเพราะ struct back_of_house::Breakfast มีฟิลด์แบบส่วนตัวอยู่ภายใน ตัว struct จึงจำเป็นต้องจัดเตรียมฟังก์ชันที่เกี่ยวเนื่อง (associated function) แบบสาธารณะเพื่อช่วยทำหน้าที่สร้างอินสแตนซ์ของ Breakfast ขึ้นมา (โดยในที่นี้เราตั้งชื่อมันว่า summer) หาก Breakfast ไม่มีฟังก์ชันสำหรับช่วยสร้างดังกล่าว เราก็จะไม่สามารถสร้างอินสแตนซ์ของ Breakfast ภายใน eat_at_restaurant ได้เลย เพราะเราไม่มีสิทธิ์เข้าไปตั้งค่าเริ่มต้นของฟิลด์ seasonal_fruit ที่เป็นส่วนตัวนั่นเอง

ในทางตรงกันข้าม หากเรากำหนดให้อีนัม (enum) เป็นแบบสาธารณะ จะส่งผลให้รูปแบบย่อยทั้งหมดภายในอีนัมกลายเป็นสาธารณะไปด้วยโดยอัตโนมัติ โดยเราต้องการเพียงแค่คำว่า pub นำหน้าคีย์เวิร์ด enum เท่านั้น ดังแสดงในรายการโค้ดที่ 7-10

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}

เนื่องจากเรากำหนดให้อีนัม Appetizer เป็นแบบสาธารณะ เราจึงสามารถนำเอารูปแบบ Soup และ Salad ไปเรียกเขียนใช้งานต่อใน eat_at_restaurant ได้โดยไม่มีปัญหา

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

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

Bringing Paths Into Scope with the use Keyword

นำเข้าเส้นทางสู่ขอบเขตการทำงานด้วยคีย์เวิร์ด use (Bringing Paths into Scope with the use Keyword)

การที่ต้องพิมพ์เขียนเส้นทางเต็ม ๆ ทุกครั้งที่ต้องการเรียกใช้งานฟังก์ชันอาจทำให้รู้สึกไม่สะดวกและมีความซ้ำซากจำเจ ในรายการโค้ดที่ 7-7 ไม่ว่าเราจะเลือกใช้เส้นทางแบบสัมบูรณ์หรือแบบสัมพัทธ์ไปยังฟังก์ชัน add_to_waitlist ทุกครั้งที่เราต้องการเรียกใช้งานฟังก์ชัน add_to_waitlist เราก็จำเป็นต้องพิมพ์คำว่า front_of_house และ hosting นำหน้าอยู่ดี แต่ทว่าโชคดีที่ภาษานี้มีวิธีการทำให้ขั้นตอนนี้ง่ายขึ้น: เราสามารถสร้างทางลัด (shortcut) ไปยังเส้นทางใด ๆ ด้วยการใช้คีย์เวิร์ด use เพียงครั้งเดียว และหลังจากนั้นจะสามารถเรียกใช้ชื่อย่อที่สั้นลงได้ในทุกจุดในขอบเขตการทำงาน (scope) นั้น

ในรายการโค้ดที่ 7-11 เราจะทำการนำโมดูล crate::front_of_house::hosting เข้าสู่ขอบเขตการทำงานของฟังก์ชัน eat_at_restaurant ซึ่งจะส่งผลให้เราเหลือเพียงระบุเส้นทางสั้น ๆ แค่ hosting::add_to_waitlist ในการเรียกใช้งานฟังก์ชัน add_to_waitlist ภายใน eat_at_restaurant

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

การใส่คำสั่ง use พร้อมระบุเส้นทางในขอบเขตการทำงานเปรียบเสมือนการสร้างลิงก์สัญลักษณ์ (symbolic link หรือ symlink) ในระบบไฟล์ข้อมูล ด้วยการเขียนคำสั่ง use crate::front_of_house::hosting ไว้ที่รากเครต ส่งผลให้ชื่อ hosting กลายเป็นชื่อที่ถูกต้องและเรียกใช้งานได้ในขอบเขตดังกล่าว เสมือนหนึ่งว่าโมดูล hosting นั้นได้รับการนิยามไว้ที่ระดับรากเครตโดยตรง และเส้นทางข้อมูลที่ถูกดึงเข้ามาใช้ผ่าน use ก็ยังคงผ่านการตรวจสอบความเป็นส่วนตัวตามปกติเหมือนกับเส้นทางอื่น ๆ

ข้อสังเกตคือ คีย์เวิร์ด use จะทำหน้าที่สร้างทางลัดเฉพาะสำหรับขอบเขตการทำงานเจาะจงที่มันปรากฏอยู่เท่านั้น ในรายการโค้ดที่ 7-12 ได้มีการย้ายฟังก์ชัน eat_at_restaurant เข้าไปอยู่ภายในโมดูลย่อยใหม่ชื่อ customer ซึ่งส่งผลให้มันอยู่คนละขอบเขตการทำงานกับคำสั่ง use ดังนั้นตัวฟังก์ชันจึงจะไม่สามารถคอมไพล์ผ่านได้

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}

ข้อผิดพลาดจากคอมไพเลอร์แสดงให้เห็นว่าทางลัดดังกล่าวไม่มีผลใช้งานภายในโมดูล customer อีกต่อไป:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: cannot find module or crate `hosting` in this scope
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of unresolved module or unlinked crate `hosting`
   |
   = help: if you wanted to use a crate named `hosting`, use `cargo add hosting` to add it to your `Cargo.toml`
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

สังเกตว่าจะมีข้อความแจ้งเตือน (warning) ระบุว่าคำสั่ง use นั้นไม่มีการเรียกใช้งานภายในขอบเขตของมันแล้วเช่นกัน! ในการแก้ไขปัญหานี้ ให้คุณโยกย้ายบรรทัดคำสั่ง use เข้าไปเขียนไว้ภายในโมดูล customer ด้วย หรือไม่ก็อ้างอิงถึงทางลัดในโมดูลหลักภายนอกด้วยการใช้เส้นทาง super::hosting จากภายในโมดูลย่อย customer แทน

การสร้างเส้นทางด้วย use ตามรูปแบบที่เหมาะสม (Creating Idiomatic use Paths)

จากรายการโค้ดที่ 7-11 คุณอาจจะสงสัยว่าทำไมเราจึงกำหนด use crate::front_of_house::hosting แล้วค่อยสั่งเรียก hosting::add_to_waitlist ใน eat_at_restaurant แทนที่จะระบุเส้นทางใน use ยาวไปจนถึงตัวฟังก์ชัน add_to_waitlist โดยตรงเพื่อให้ได้ผลลัพธ์แบบเดียวกัน ดังแสดงในรายการโค้ดที่ 7-13

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}

แม้ว่าโค้ดทั้งในรายการโค้ดที่ 7-11 และรายการโค้ดที่ 7-13 จะสามารถทำงานได้สำเร็จลุล่วงเหมือนกัน แต่ทว่ารายการโค้ดที่ 7-11 ถือเป็นวิธีที่เหมาะสมตามหลักปฏิบัตินิยม (idiomatic way) ในการนำฟังก์ชันเข้าสู่ขอบเขตการทำงานด้วย use การนำโมดูลหลักของฟังก์ชันเข้าสู่ขอบเขตการทำงานหมายถึงเราต้องเขียนระบุชื่อโมดูลหลักนำหน้าฟังก์ชันเมื่อเรียกใช้ การทำแบบนี้จะทำให้โค้ดระบุได้อย่างชัดเจนว่าฟังก์ชันดังกล่าวไม่ได้ประกาศนิยามอยู่ภายในพื้นที่ปัจจุบันของมันเอง ในขณะที่ยังคงช่วยประหยัดการเขียนพิมพ์เส้นทางเต็ม ๆ ซ้ำซาก โค้ดในรายการโค้ดที่ 7-13 นั้นไม่ชัดเจนว่าตัวฟังก์ชัน add_to_waitlist ถูกนิยามไว้ที่ส่วนใดของระบบ

ในทางกลับกัน เมื่อคุณต้องการนำเข้า structs, enums หรือไอเท็มข้อมูลอื่น ๆ เข้ามาด้วย use วิธีปฏิบัติที่เหมาะสมคือการระบุเส้นทางไปจนถึงตัวไอเท็มชิ้นนั้น ๆ โดยตรงเลย รายการโค้ดที่ 7-14 แสดงวิธีการนำ struct HashMap จากไลบรารีมาตรฐานเข้าสู่ขอบเขตการทำงานของเครตฐานสองตามรูปแบบที่นิยมปฏิบัติกัน:

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

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

ข้อยกเว้นเพียงอย่างเดียวของแนวทางปฏิบัตินี้คือ ยามที่เราจำเป็นต้องนำเข้าไอเท็มสองชิ้นที่มีชื่อพ้องกันเข้าสู่ขอบเขตการทำงานเดียวกันด้วยคำสั่ง use เนื่องจากระบบของ Rust ไม่อนุญาตให้ทำเช่นนั้นได้ รายการโค้ดที่ 7-15 แสดงวิธีการนำเข้าชนิดข้อมูล Result สองตัวที่มีชื่อเหมือนกันแต่มีโมดูลหลักต่างกันเข้าสู่ขอบเขตเดียวกัน และวิธีการเขียนเพื่อเรียกใช้งานพวกมัน:

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}

ดังที่เห็น การใช้โมดูลหลักนำหน้าจะช่วยคั่นแบ่งชนิดข้อมูล Result ทั้งสองประเภทออกจากกันได้ชัดเจน หากเราเขียนระบุเป็น use std::fmt::Result and use std::io::Result คู่กัน เราจะมีชนิดข้อมูล Result สองตัวปนอยู่ในขอบเขตเดียวกัน ซึ่งจะส่งผลให้ Rust ไม่สามารถจำแนกออกได้ว่าเราหมายถึงตัวใดเมื่อมีการเรียกคำว่า Result

การตั้งชื่อใหม่ให้กับชนิดข้อมูลด้วยคีย์เวิร์ด as (Providing New Names with the as Keyword)

มีอีกหนึ่งแนวทางในการแก้ไขปัญหาเรื่องชื่อที่พ้องกันยามนำเข้าสู่ขอบเขตเดียวกันผ่าน use คือ การพิมพ์เขียนคำสั่ง as ต่อท้ายเส้นทางเพื่อระบุชื่อเรียกใหม่เฉพาะที่ หรือ นามแฝง (alias) ให้แก่ชนิดข้อมูลนั้น ๆ รายการโค้ดที่ 7-16 แสดงวิธีการเขียนโค้ดในรายการโค้ดที่ 7-15 อีกแบบ โดยการเปลี่ยนชื่อเรียกของหนึ่งในชนิดข้อมูล Result ผ่านคีย์เวิร์ด as:

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}

ในคำสั่ง use บรรทัดที่สอง เราได้เลือกตั้งชื่อใหม่ว่า IoResult ให้กับชนิดข้อมูล std::io::Result ซึ่งจะไม่ขัดแย้งพ้องกับชื่อ Result จากโมดูล std::fmt ที่เรานำเข้ามาด้วย ทั้งแนวทางในรายการโค้ดที่ 7-15 และรายการโค้ดที่ 7-16 ล้วนได้รับการยอมรับว่าถูกต้องและเหมาะสมตามหลักปฏิบัติ ดังนั้นการเลือกใช้งานจึงขึ้นอยู่กับความชอบส่วนบุคคลของคุณเอง!

การส่งออกชื่อไอเท็มซ้ำด้วย pub use (Re-exporting Names with pub use)

เมื่อเรานำเข้าชื่อไอเท็มสู่ขอบเขตการทำงานผ่านคีย์เวิร์ด use ชื่อดังกล่าวจะมีสถานะเป็นแบบส่วนตัวเฉพาะสำหรับขอบเขตที่ได้รับนำเข้าเท่านั้น เพื่ออนุญาตให้โค้ดภายนอกขอบเขตสามารถอ้างอิงและเรียกใช้ชื่อนั้นได้เสมือนว่าชื่อดังกล่าวได้รับการประกาศนิยามขึ้นภายในขอบเขตนั้นตั้งแต่แรก เราสามารถนำเอาคีย์เวิร์ด pub และ use มารวมพลังใช้งานร่วมกัน เทคนิคนี้เรียกว่า การส่งออกซ้ำ (re-exporting) เนื่องจากเราทำหน้าที่นำไอเท็มเข้าสู่ขอบเขตการทำงานพร้อมทั้งเปิดทางให้บุคคลอื่นสามารถดึงเอาไอเท็มนั้นไปใช้ในขอบเขตการทำงานของตนเองได้ด้วย

รายการโค้ดที่ 7-17 แสดงโค้ดในรายการโค้ดที่ 7-11 โดยเปลี่ยนคำสั่ง use ในโมดูลรากเครตให้กลายเป็น pub use

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

ก่อนที่จะทำการแก้ไขเรื่องนี้ โค้ดภายนอกจำเป็นจะต้องเรียกใช้งานฟังก์ชัน add_to_waitlist ผ่านทางเส้นทางยาว ๆ อย่าง restaurant::front_of_house::hosting::add_to_waitlist() ซึ่งหมายความว่าจะต้องกำหนดให้โมดูล front_of_house เป็นแบบสาธารณะ (pub) ด้วย แต่เมื่อเรานำเอา pub use มาใช้ส่งออกโมดูล hosting ซ้ำจากระดับโมดูลรากแล้ว โค้ดภายนอกจะสามารถหันมาเรียกใช้ผ่านเส้นทางที่สั้นลงอย่าง restaurant::hosting::add_to_waitlist() ได้ทันที

การส่งออกซ้ำมีประโยชน์อย่างมากในยามที่โครงสร้างการจัดเก็บโค้ดภายในระบบของคุณ มีความแตกต่างไปจากมุมมองที่โปรแกรมเมอร์ผู้นำโค้ดของคุณไปใช้คาดเดาไว้ ตัวอย่างเช่น ในการเปรียบเทียบเรื่องร้านอาหารนี้ บุคลากรผู้ดูแลร้านจะคิดถึงโครงสร้างแบบ “หน้าร้าน (front of house)” และ “หลังร้าน (back of house)” แต่ทว่าลูกค้าที่เดินเข้าร้านคงจะไม่ได้คิดหรือมองเห็นโครงสร้างร้านอาหารในแง่มุมเหล่านั้น การเลือกใช้ pub use จึงช่วยให้เราสามารถออกแบบโครงสร้างโค้ดภายในรูปแบบหนึ่ง แต่เลือกแสดงผลให้ภายนอกเห็นเป็นโครงสร้างอีกรูปแบบหนึ่งได้ ซึ่งการทำเช่นนี้จะช่วยให้เครตห้องสมุดของเรามีความเป็นระเบียบเรียบร้อยทั้งสำหรับโปรแกรมเมอร์ผู้ทำหน้าที่พัฒนาห้องสมุด และโปรแกรมเมอร์ผู้นำห้องสมุดไปเรียกใช้งาน เราจะมาศึกษาตัวอย่างของการใช้ pub use และวิธีการส่งผลกระทบต่อเอกสารอ้างอิง (documentation) ของเครตของคุณเพิ่มเติมในหัวข้อ“ส่งออก API สาธารณะเพื่อความสะดวกในการใช้งาน” ในบทที่ 14

การใช้งานแพ็กเกจภายนอก (Using External Packages)

ในบทที่ 2 เราได้เคยร่วมกันพัฒนาโปรแกรมทายตัวเลขซึ่งมีระบบดึงเอาแพ็กเกจภายนอกที่ชื่อว่า rand มาช่วยในการสุ่มตัวเลข โดยในการเรียกใช้งาน rand ในโปรเจกต์ของเรานั้น เราได้พิมพ์เขียนบรรทัดด้านล่างนี้ลงในไฟล์ Cargo.toml:

rand = "0.10.1"

การใส่ชื่อ rand ในฟิลด์ dependencies ของไฟล์ Cargo.toml จะเป็นการแจ้งบอก Cargo ให้ทำหน้าที่ดาวน์โหลดแพ็กเกจ rand และโปรแกรมที่เกี่ยวข้องอื่น ๆ จากฐานข้อมูลกลาง crates.io และทำหน้าที่จัดเตรียม rand ให้พร้อมสำหรับใช้ในโปรเจกต์ของเรา

จากนั้น ในการนำคำประกาศของ rand เข้าสู่ขอบเขตการทำงานของแพ็กเกจเรา เราได้เขียนบรรทัดคำสั่ง use ที่เริ่มต้นด้วยชื่อของเครตคือ rand และระบุรายการไอเท็มย่อยที่เราต้องการนำเข้ามา ย้อนกลับไปพิจารณาเนื้อหาในส่วน“สร้างการสุ่มตัวเลข” ในบทที่ 2 เราได้ดึงเอาโมดูลย่อย rand::prelude เข้าสู่ขอบเขตการทำงานและสั่งเรียกใช้ฟังก์ชัน rand::rng:

use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

สมาชิกในชุมชนของภาษา Rust ได้มีการแบ่งปันผลงานแพ็กเกจต่าง ๆ มากมายไว้บนฐานข้อมูลกลาง crates.io ซึ่งการดึงเอาสิ่งเหล่านั้นเข้ามาใช้งานในโปรเจกต์ของคุณจะมีขั้นตอนการปฏิบัติที่คล้ายคลึงกัน คือ การระบุรายชื่อพวกมันลงในไฟล์ Cargo.toml ของคุณ และเขียนเรียกใช้ use เพื่อดึงเอาไอเท็มจากเครตเหล่านั้นเข้าสู่ขอบเขตการทำงาน

โปรดทราบว่าห้องสมุดมาตรฐาน std ก็มีสถานะเป็นเครตหนึ่งที่อยู่ภายนอกแพ็กเกจของเราเช่นเดียวกัน แต่เนื่องจากห้องสมุดมาตรฐานจะถูกแนบจัดส่งมาพร้อมกับตัวภาษา Rust ตั้งแต่แรกแล้ว เราจึงไม่มีความจำเป็นต้องไปเขียนแก้ไขไฟล์ Cargo.toml เพื่อดึงเอา std เข้ามาเพิ่ม แต่อย่างไรก็ตาม เรายังคงต้องเขียนระบุคำสั่ง use เพื่อดึงเอาไอเท็มข้อมูลต่าง ๆ ของมันเข้าสู่ขอบเขตงานของแพ็กเกจเรา ตัวอย่างเช่นการใช้ struct HashMap เราจะเขียนบรรทัดนี้:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

นี่คือตัวอย่างของเส้นทางแบบสัมบูรณ์ที่ขึ้นต้นด้วยคำว่า std ซึ่งเป็นชื่อเฉพาะของเครตห้องสมุดมาตรฐาน

การใช้เส้นทางแบบซ้อนเพื่อจัดระเบียบรายการคำสั่ง use (Using Nested Paths to Clean Up use Lists)

หากโปรเจกต์ของเรามีการเรียกใช้งานไอเท็มข้อมูลหลายชิ้นที่มาจากเครตหรือโมดูลหลักตัวเดียวกัน การเขียนระบุบรรทัดคำสั่ง use แยกแยกบรรทัดกันจะใช้พื้นที่ในแนวตั้งของไฟล์ซอร์สโค้ดไปค่อนข้างมาก ตัวอย่างเช่น บรรทัดคำสั่ง use สองบรรทัดที่เราใช้ในโปรแกรมเกมทายตัวเลขในรายการโค้ดที่ 2-4 ที่ทำหน้าที่นำเข้าไอเท็มข้อมูลจาก std:

// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

แต่ในทางกลับกัน เราสามารถเลือกใช้ระบบเส้นทางแบบซ้อน (nested paths) เพื่อดึงเอาไอเท็มเหล่านั้นเข้ามาสู่ขอบเขตงานได้พร้อมกันภายในบรรทัดเดียว โดยเราจะเขียนระบุส่วนของเส้นทางที่เป็นจุดร่วมกัน (common part) ตามด้วยโคลอนคู่ และวงเล็บปีกกาครอบกลุ่มไอเท็มย่อยที่มีความแตกต่างกันไว้ ดังที่แสดงในรายการโค้ดที่ 7-18

use rand::prelude::*;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

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

เราสามารถใช้ประโยชน์จากเส้นทางแบบซ้อนในระดับใด ๆ ของเส้นทางก็ได้ ซึ่งจะมีประโยชน์มากเมื่อรวมคำสั่ง use สองบรรทัดที่มีเส้นทางย่อยร่วมกัน ตัวอย่างเช่น รายการโค้ดที่ 7-19 แสดงบรรทัดคำสั่ง use สองบรรทัด: บรรทัดแรกนำเข้า std::io และบรรทัดที่สองนำเข้า std::io::Write

use std::io;
use std::io::Write;

ส่วนเส้นทางที่เป็นจุดร่วมของทั้งคู่คือ std::io ซึ่งก็คือเส้นทางแบบเต็มในบรรทัดแรก ในการรวมทั้งสองเส้นทางนี้ให้กลายเป็นคำสั่ง use เพียงบรรทัดเดียว เราสามารถเขียนคำว่า self ในระบบเส้นทางซ้อนได้ ดังแสดงในรายการโค้ดที่ 7-20

use std::io::{self, Write};

คำสั่งบรรทัดนี้จะนำเข้าทั้ง std::io และ std::io::Write เข้าสู่ขอบเขตการทำงานพร้อมกัน

การนำเข้าไอเท็มต่าง ๆ ด้วยตัวดำเนินการดอกจัน (Importing Items with the Glob Operator)

หากเรามีความประสงค์ต้องการจะนำเข้าไอเท็มสาธารณะ ทั้งหมด ที่อยู่ภายในเส้นทางดังกล่าวเข้าสู่ขอบเขตการทำงาน เราสามารถระบุเส้นทางและตามหลังด้วยสัญลักษณ์ดอกจัน * (glob operator) ได้ดังนี้:

#![allow(unused)]
fn main() {
use std::collections::*;
}

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

ตัวดำเนินการดอกจันนี้มักถูกนำไปประยุกต์ใช้งานในขั้นตอนการเขียนชุดทดสอบ (testing) เพื่อดึงเอาทุกองค์ประกอบที่เกี่ยวข้องเข้ามาไว้ในโมดูล tests ซึ่งเราจะอธิบายเพิ่มเติมในหัวข้อ“วิธีการเขียนชุดทดสอบ” ในบทที่ 11 นอกจากนี้บางครั้งมันยังถูกนำไปใช้ร่วมกับรูปแบบโครงสร้าง prelude อีกด้วย สามารถเปิดอ่านรายละเอียดเพิ่มเติมเกี่ยวกับรูปแบบดังกล่าวได้ในเอกสารอ้างอิงของห้องสมุดมาตรฐาน

Separating Modules into Different Files

การแยกโมดูลออกเป็นหลายไฟล์ (Separating Modules into Different Files)

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

ตัวอย่างเช่น เรามาเริ่มปรับปรุงจากโค้ดในรายการโค้ดที่ 7-17 ที่มีหลายโมดูลจำลองระบบร้านอาหารอยู่ร่วมกัน เราจะทำการดึงเอาโมดูลเหล่านั้นแยกออกไปสร้างเป็นไฟล์ แทนที่จะเขียนนิยามโมดูลทั้งหมดรวมกันอยู่ในไฟล์รากเครต ซึ่งไฟล์รากเครตในกรณีนี้คือ src/lib.rs แต่อย่างไรก็ตาม ขั้นตอนปฏิบัติเหล่านี้จะทำงานได้เหมือนกันกับเครตฐานสองที่มีไฟล์รากเครตเป็น src/main.rs

ขั้นตอนแรกสุด เราจะทำการดึงเอาโมดูล front_of_house ออกไปสร้างเป็นไฟล์ของมันเอง ให้คุณลบโค้ดที่อยู่ภายในวงเล็บปีกกาของโมดูล front_of_house ออกทั้งหมด โดยเหลือทิ้งไว้เพียงการประกาศ mod front_of_house; เท่านั้น ซึ่งจะส่งผลให้ไฟล์ src/lib.rs เหลือเนื้อหาโค้ดดังแสดงในรายการโค้ดที่ 7-21 สังเกตว่าโค้ดจุดนี้จะยังคงคอมไพล์ไม่ผ่านจนกว่าเราจะสร้างไฟล์ src/front_of_house.rs ในรายการโค้ดที่ 7-22 สำเร็จ

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

ถัดไป ให้นำเอาโค้ดส่วนที่เคยอยู่ภายในวงเล็บปีกกานั้นไปจัดเก็บไว้ในไฟล์ใหม่ที่สร้างขึ้นชื่อ src/front_of_house.rs ดังที่แสดงในรายการโค้ดที่ 7-22 ทางคอมไพเลอร์จะทราบดีว่าต้องเข้าไปค้นหาไฟล์นี้เนื่องจากมันพบคำสั่งประกาศโมดูลชื่อ front_of_house ปรากฏอยู่ในรากเครต

pub mod hosting {
    pub fn add_to_waitlist() {}
}

โปรดจำไว้ว่า คุณจำเป็นต้องโหลดไฟล์เข้ามาประมวลผลผ่านคำสั่งประกาศ mod เพียง ครั้งเดียว เท่านั้นในโครงสร้างต้นไม้โมดูลของคุณ เมื่อคอมไพเลอร์รับรู้แล้วว่าไฟล์ดังกล่าวเป็นส่วนหนึ่งของโปรเจกต์ (และทราบตำแหน่งที่โค้ดประดิษฐานอยู่บนต้นไม้โมดูลตามจุดที่คุณวางคำสั่ง mod ไว้) ไฟล์อื่น ๆ ในโปรเจกต์ของคุณก็ควรใช้วิธีอ้างอิงถึงโค้ดในไฟล์นั้นผ่านเส้นทาง (path) ไปยังจุดที่มันประกาศไว้ ดังที่อภิปรายไปในหัวข้อ“เส้นทางสำหรับอ้างอิงถึงไอเท็มในต้นไม้โมดูล” พูดอีกนัยหนึ่งคือ คำสั่ง mod ไม่ใช่ กระบวนการ “include” ดึงไฟล์ซ้ำซ้อนอย่างที่คุณอาจเคยพบเห็นในบางภาษาโปรแกรมอื่น ๆ

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

ในการเริ่มย้าย hosting เราจะแก้ไขไฟล์ src/front_of_house.rs ให้หลงเหลือเพียงคำประกาศโมดูล hosting เท่านั้น:

pub mod hosting;

จากนั้น ให้สร้างไดเรกทอรี src/front_of_house ขึ้นมา และสร้างไฟล์ชื่อ hosting.rs เพื่อเก็บบรรจุเนื้อหาคำจำกัดความต่าง ๆ ของโมดูล hosting ไว้ด้านใน:

pub fn add_to_waitlist() {}

หากเราเลือกนำไฟล์ hosting.rs ไปวางไว้ในไดเรกทอรี src แทน คอมไพเลอร์จะเข้าใจผิดและคาดหวังว่าโค้ดในไฟล์ hosting.rs จะต้องสังกัดโมดูล hosting ที่ประกาศไว้ที่ระดับรากเครตโดยตรง ไม่ใช่ประกาศเป็นลูกของโมดูล front_of_house กฎของคอมไพเลอร์ที่ใช้เลือกตรวจจับความสัมพันธ์ระหว่างไฟล์และโมดูลนี้ จะช่วยให้ไดเรกทอรีและไฟล์ต่าง ๆ ในคอมพิวเตอร์ของคุณจับคู่พ้องตรงตามแผนภาพต้นไม้โมดูลได้ดียิ่งขึ้น

เส้นทางอ้างอิงไฟล์ในรูปแบบทางเลือก (Alternate File Paths)

จนถึงจุดนี้เราได้พูดคุยถึงรูปแบบเส้นทางไฟล์ยอดนิยมที่คอมไพเลอร์ Rust เลือกใช้เป็นหลัก แต่อย่างไรก็ตาม Rust ก็ยังคงสนับสนุนรูปแบบอ้างอิงไฟล์แบบโบราณอยู่เช่นกัน โดยสำหรับโมดูลชื่อ front_of_house ที่ประกาศไว้ที่ระดับรากเครต คอมไพเลอร์จะมองหาไฟล์โค้ดในตำแหน่งเหล่านี้:

  • src/front_of_house.rs (วิธีหลักที่เราศึกษาไป)
  • src/front_of_house/mod.rs (รูปแบบดั้งเดิมที่ระบบยังคงรองรับอยู่)

และสำหรับโมดูลชื่อ hosting ซึ่งเป็นโมดูลย่อยของ front_of_house คอมไพเลอร์จะมองหาไฟล์โค้ดในตำแหน่งเหล่านี้:

  • src/front_of_house/hosting.rs (วิธีหลักที่เราศึกษาไป)
  • src/front_of_house/hosting/mod.rs (รูปแบบดั้งเดิมที่ระบบยังคงรองรับอยู่)

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

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

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

โปรดสังเกตว่า บรรทัดคำสั่ง pub use crate::front_of_house::hosting ในไฟล์ src/lib.rs ก็ไม่มีความจำเป็นต้องปรับปรุงแก้ไขอะไร และการใช้ use ก็ไม่ได้มีผลกระทบใด ๆ ต่อกระบวนการเลือกไฟล์ที่จะนำมาคอมไพล์รวมในเครต คีย์เวิร์ด mod มีหน้าที่ทำตามคำประกาศนิยามโมดูล และ Rust จะทำหน้าที่วิ่งไปหาไฟล์ที่มีชื่อเดียวกับชื่อโมดูลนั้นเพื่อดึงเอาโค้ดเข้ามาบรรจุทำงาน

สรุป

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

ในบทถัดไป เราจะหันมาศึกษาโครงสร้างข้อมูลแบบคอลเลกชัน (collection data structures) ในไลบรารีมาตรฐาน ซึ่งคุณสามารถหยิบมาใช้งานร่วมกับโค้ดที่เป็นระเบียบเรียบร้อยของคุณได้

คอลเลกชันทั่วไป (Common Collections)

ห้องสมุดมาตรฐานของภาษา Rust ประกอบไปด้วยโครงสร้างข้อมูลที่มีประโยชน์อย่างมากกลุ่มหนึ่งที่เรียกว่า คอลเลกชัน (collections) ชนิดข้อมูลอื่น ๆ ส่วนใหญ่มักแสดงผลแทนค่าข้อมูลเพียงค่าเดียวเจาะจง แต่ทว่าคอลเลกชันสามารถเก็บบรรจุข้อมูลได้หลากหลายค่าร่วมกัน และแตกต่างจากชนิดข้อมูลแบบอาเรย์ (array) และทูเพิล (tuple) ในตัวภาษา เพราะข้อมูลที่คอลเลกชันเหล่านี้อ้างอิงไปถึงจะถูกจัดเก็บไว้บนฮีป (heap) ซึ่งนั่นหมายความว่าขนาดปริมาณของข้อมูลไม่จำเป็นต้องทราบล่วงหน้าในขณะคอมไพล์ และสามารถขยายหรือหดขนาดได้ตามการรันของโปรแกรม คอลเลกชันแต่ละประเภทจะมีความสามารถและต้นทุนทรัพยากรที่แตกต่างกัน ซึ่งการเลือกใช้ประเภทที่เหมาะสมกับสถานการณ์ปัจจุบันของคุณเป็นทักษะที่คุณจะพัฒนาขึ้นเมื่อเวลาผ่านไป ในบทนี้เราจะพูดถึงคอลเลกชันสามประเภทที่นิยมเรียกใช้งานบ่อยที่สุดในโปรแกรมภาษา Rust ได้แก่:

  • เวกเตอร์ (vector): ช่วยให้คุณสามารถจัดเก็บข้อมูลจำนวนที่ไม่คงที่เรียงต่อกันได้
  • สตริง (string): คือคอลเลกชันของตัวอักษร เราเคยพูดถึงชนิดข้อมูล String ไปบ้างแล้วก่อนหน้านี้ แต่ในบทนี้เราจะมาเจาะลึกรายละเอียดเกี่ยวกับมันอย่างละเอียด
  • แฮชแมป (hash map): ช่วยให้คุณเชื่อมโยงข้อมูลเข้ากับคีย์ (key) เฉพาะเจาะจงได้ ซึ่งมันจัดเป็นหนึ่งในรูปแบบการนำไปใช้งานจริงของโครงสร้างข้อมูลทั่วไปที่เรียกว่า แมป (map)

ในการศึกษาเรียนรู้เกี่ยวกับคอลเลกชันประเภทอื่น ๆ ที่จัดเตรียมไว้ให้ในห้องสมุดมาตรฐาน สามารถเปิดดูได้ในเอกสารอ้างอิงของระบบ

เราจะมาพูดคุยถึงวิธีการสร้างและอัปเดตข้อมูลเวกเตอร์, สตริง และแฮชแมป รวมถึงสิ่งที่ทำให้แต่ละประเภทมีความพิเศษเฉพาะตัว

Storing Lists of Values with Vectors

การจัดเก็บข้อมูลเป็นรายการด้วยเวกเตอร์ (Storing Lists of Values with Vectors)

คอลเลกชันประเภทแรกที่เราจะมาดูกันคือ Vec<T> หรือที่รู้จักกันในชื่อเวกเตอร์ (vector) เวกเตอร์ช่วยให้คุณสามารถจัดเก็บข้อมูลได้มากกว่าหนึ่งค่าไว้ในโครงสร้างข้อมูลเดียว โดยระบบจะจัดเก็บค่าทั้งหมดให้อยู่ตำแหน่งติดกันในหน่วยความจำ เวกเตอร์สามารถจัดเก็บได้เฉพาะค่าที่เป็นประเภทข้อมูล (type) เดียวกันเท่านั้น เวกเตอร์จะมีประโยชน์มากเมื่อคุณมีรายการสิ่งของต่าง ๆ เช่น บรรทัดข้อความในไฟล์ หรือราคาสินค้าในรถเข็นช้อปปิ้ง

การสร้างเวกเตอร์ใหม่ (Creating a New Vector)

ในการสร้างเวกเตอร์ใหม่ที่ยังไม่มีข้อมูล เราจะเรียกใช้ฟังก์ชัน Vec::new ดังที่แสดงในตัวอย่างที่ 8-1 (Listing 8-1)

fn main() {
    let v: Vec<i32> = Vec::new();
}

สังเกตว่าเราได้เพิ่มการระบุประเภทข้อมูล (type annotation) ไว้ที่นี่ เนื่องจากเรายังไม่ได้ใส่ค่าใด ๆ ลงไปในเวกเตอร์นี้ Rust จึงไม่รู้ว่าเราต้องการจัดเก็บข้อมูลประเภทใด นี่คือจุดสำคัญ เวกเตอร์ถูกเขียนขึ้นโดยใช้เจเนอริก (generics) ซึ่งเราจะมาเรียนรูวิธีกำหนดเจเนอริกกับประเภทข้อมูลของเราเองในบทที่ 10 สำหรับตอนนี้ ขอให้ทราบว่าประเภทข้อมูล Vec<T> ที่จัดเตรียมไว้โดยไลบรารีมาตรฐาน (standard library) สามารถเก็บข้อมูลประเภทใดก็ได้ เมื่อเราสร้างเวกเตอร์เพื่อจัดเก็บประเภทข้อมูลเฉพาะเจาะจง เราสามารถระบุประเภทข้อมูลนั้นไว้ในวงเล็บสามเหลี่ยม (angle brackets) ได้ ในตัวอย่างที่ 8-1 เราบอก Rust ว่า Vec<T> ใน v จะเก็บข้อมูลประเภท i32

แต่ส่วนใหญ่แล้ว คุณมักจะสร้าง Vec<T> พร้อมกับค่าเริ่มต้น และ Rust จะสามารถคาดเดา (infer) ประเภทข้อมูลที่คุณต้องการจัดเก็บได้เอง ดังนั้นคุณจึงไม่จำเป็นต้องระบุประเภทข้อมูลนี้บ่อยนัก Rust ได้จัดเตรียมมาโคร vec! เพื่อความสะดวก ซึ่งจะช่วยสร้างเวกเตอร์ใหม่พร้อมเก็บค่าที่คุณกำหนดให้ ตัวอย่างที่ 8-2 จะสร้าง Vec<i32> ใหม่ที่เก็บค่า 1, 2 และ 3 ประเภทของจำนวนเต็มคือ i32 เนื่องจากเป็นประเภทจำนวนเต็มเริ่มต้น ดังที่เราได้พูดคุยกันในส่วน “ประเภทข้อมูล (Data Types)” ของบทที่ 3

fn main() {
    let v = vec![1, 2, 3];
}

เนื่องจากเราได้ให้ค่าเริ่มต้นที่เป็น i32 แล้ว Rust จึงสามารถคาดเดาได้ว่าประเภทข้อมูลของ v คือ Vec<i32> และไม่จำเป็นต้องระบุประเภทข้อมูลอีก ถัดไปเราจะมาดูวิธีการแก้ไขข้อมูลในเวกเตอร์

การอัปเดตเวกเตอร์ (Updating a Vector)

ในการสร้างเวกเตอร์แล้วเพิ่มข้อมูลลงไป เราสามารถใช้เมธอด push ได้ ดังที่แสดงในตัวอย่างที่ 8-3

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

เช่นเดียวกับตัวแปรอื่น ๆ หากเราต้องการแก้ไขค่าของมันได้ เราจำเป็นต้องทำให้มันสามารถเปลี่ยนแปลงค่าได้ (mutable) โดยใช้คีย์เวิร์ด mut ดังที่กล่าวไปในบทที่ 3 ตัวเลขที่เราใส่ไว้ข้างในล้วนเป็นประเภท i32 ซึ่ง Rust คาดเดาได้จากข้อมูล ดังนั้นเราจึงไม่จำเป็นต้องใส่การระบุประเภท Vec<i32>

การอ่านข้อมูลจากเวกเตอร์ (Reading Elements of Vectors)

มีสองวิธีในการอ้างอิงถึงค่าที่จัดเก็บอยู่ในเวกเตอร์ ได้แก่ การใช้ดัชนี (indexing) หรือการใช้เมธอด get ในตัวอย่างต่อไปนี้ เราได้ระบุประเภทข้อมูลของค่าที่ส่งกลับมาจากฟังก์ชันเหล่านี้เพื่อความชัดเจนยิ่งขึ้น

ตัวอย่างที่ 8-4 แสดงทั้งสองวิธีในการเข้าถึงค่าในเวกเตอร์ โดยใช้ไวยากรณ์ดัชนี (indexing) และเมธอด get

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}

สังเกตรายละเอียดเล็กน้อยตรงนี้ เราใช้ดัชนีค่า 2 เพื่อดึงข้อมูลตัวที่สาม เนื่องจากเวกเตอร์อ้างอิงด้วยดัชนีตัวเลขที่เริ่มต้นด้วยศูนย์ การใช้ & และ [] จะให้ข้อมูลอ้างอิง (reference) ไปยังข้อมูลตำแหน่งดัชนีนั้น เมื่อเราใช้เมธอด get โดยส่งดัชนีเป็นพารามิเตอร์ เราจะได้ผลลัพธ์เป็น Option<&T> ซึ่งเราสามารถนำไปใช้กับ match ได้

Rust ได้จัดเตรียมสองวิธีนี้ในการอ้างอิงข้อมูลเพื่อให้คุณสามารถเลือกได้ว่า จะให้โปรแกรมทำงานอย่างไรเมื่อพยายามใช้ค่าดัชนีที่อยู่นอกช่วงข้อมูลที่มีอยู่ ตัวอย่างเช่น มาดูกันว่าจะเกิดอะไรขึ้นเมื่อเรามีเวกเตอร์ที่มีสมาชิก 5 ตัว แล้วเราพยายามเข้าถึงข้อมูลที่ดัชนี 100 ด้วยแต่ละวิธี ดังที่แสดงในตัวอย่างที่ 8-5

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

เมื่อเราสั่งรันโค้ดนี้ วิธีแรกที่ใช้ [] จะทำให้โปรแกรมหยุดทำงานกะทันหัน (panic) เนื่องจากมีการอ้างอิงถึงข้อมูลที่ไม่มีอยู่จริง วิธีนี้เหมาะอย่างยิ่งเมื่อคุณต้องการให้โปรแกรมของคุณทำงานล้มเหลว (crash) หากมีการพยายามเข้าถึงข้อมูลที่เกินขอบเขตของเวกเตอร์

แต่เมื่อเราส่งดัชนีที่อยู่นอกขอบเขตของเวกเตอร์ให้กับเมธอด get มันจะส่งกลับค่า None โดยไม่เกิด panic คุณควรเลือกใช้วิธีนี้หากการเข้าถึงข้อมูลนอกช่วงของเวกเตอร์อาจเกิดขึ้นได้ในสถานการณ์ปกติทั่วไป จากนั้นโค้ดของคุณก็จะมีตรรกะสำหรับจัดการกับกรณีที่เป็น Some(&element) หรือ None ตามที่คุยกันในบทที่ 6 ตัวอย่างเช่น ดัชนีดังกล่าวอาจมาจากผู้ใช้ป้อนตัวเลขเข้ามา หากพวกเขาเผลอป้อนตัวเลขที่มากเกินไปและโปรแกรมได้รับค่า None คุณก็สามารถแจ้งผู้ใช้ได้ว่าในเวกเตอร์มีจำนวนสมาชิกทั้งหมดเท่าใด และเปิดโอกาสให้พวกเขากรอกค่าใหม่อีกครั้ง ซึ่งวิธีนี้จะดูเป็นมิตรกับผู้ใช้มากกว่าการปล่อยให้โปรแกรมพังไปเพียงเพราะผู้ใช้พิมพ์ผิด!

เมื่อโปรแกรมมีข้อมูลอ้างอิงที่ถูกต้อง ระบบตรวจสอบการยืม (borrow checker) จะบังคับใช้กฎความเป็นเจ้าของและการยืม (ซึ่งได้อธิบายไว้ในบทที่ 4) เพื่อให้มั่นใจได้ว่าข้อมูลอ้างอิงนี้และข้อมูลอ้างอิงอื่น ๆ ที่ชี้ไปยังข้อมูลในเวกเตอร์ยังคงถูกต้องสมบูรณ์ ย้อนกลับไปดูกฎที่ว่า คุณไม่สามารถมีข้อมูลอ้างอิงแบบเปลี่ยนค่าได้ (mutable reference) และอ้างอิงแบบเปลี่ยนค่าไม่ได้ (immutable reference) อยู่ในขอบเขต (scope) เดียวกันได้ กฎนี้ถูกนำมาใช้ในตัวอย่างที่ 8-6 ที่เราถือครองข้อมูลอ้างอิงแบบเปลี่ยนค่าไม่ได้ของสมาชิกตัวแรกของเวกเตอร์ แล้วพยายามเพิ่มข้อมูลตัวใหม่เข้าไปที่ท้ายเวกเตอร์ โปรแกรมนี้จะทำงานไม่ได้ หากเราพยายามอ้างอิงถึงสมาชิกตัวแรกนั้นอีกครั้งในส่วนหลังจากนั้นของฟังก์ชัน

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}

การคอมไพล์โค้ดนี้จะส่งผลให้เกิดข้อผิดพลาดต่อไปนี้:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                      ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

โค้ดในตัวอย่างที่ 8-6 อาจดูเหมือนควรจะทำงานได้: ทำไมข้อมูลอ้างอิงถึงสมาชิกตัวแรกถึงต้องมาสนใจกับการเปลี่ยนแปลงที่ท้ายเวกเตอร์ด้วยล่ะ? ข้อผิดพลาดนี้เกิดจากหลักการทำงานของเวกเตอร์: เนื่องจากเวกเตอร์จะวางข้อมูลเรียงต่อกันในหน่วยความจำ การเพิ่มสมาชิกใหม่เข้าไปที่ท้ายเวกเตอร์อาจทำให้ต้องจัดสรรพื้นที่หน่วยความจำใหม่ (allocating new memory) และคัดลอกสมาชิกตัวเก่าไปยังพื้นที่ใหม่ หากพื้นที่เดิมที่เวกเตอร์จัดเก็บอยู่ไม่มีที่ว่างเพียงพอที่จะวางสมาชิกทั้งหมดเรียงต่อกันได้ ในกรณีเช่นนั้น ข้อมูลอ้างอิงถึงสมาชิกตัวแรกก็จะไปชี้ตำแหน่งหน่วยความจำที่ถูกคืนสิทธิ์ไปแล้ว (deallocated memory) กฎการยืมจึงเข้ามาเพื่อป้องกันไม่ให้โปรแกรมต้องไปตกอยู่ในสถานการณ์เช่นนั้น

หมายเหตุ: หากต้องการดูรายละเอียดเพิ่มเติมเกี่ยวกับการทำงานเบื้องหลังของประเภทข้อมูล Vec<T> โปรดดูที่ “The Rustonomicon”

การวนลูปผ่านค่าต่าง ๆ ในเวกเตอร์ (Iterating Over the Values in a Vector)

เพื่อเข้าถึงแต่ละสมาชิกในเวกเตอร์ตามลำดับ เราจะวนลูปผ่านสมาชิกทั้งหมดแทนที่จะใช้ดัชนีเข้าถึงทีละตัว ตัวอย่างที่ 8-7 แสดงวิธีการใช้ลูป for เพื่อรับข้อมูลอ้างอิงแบบแก้ไขไม่ได้ไปยังแต่ละสมาชิกของเวกเตอร์ประเภท i32 แล้วนำมาแสดงผล

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}

นอกจากนี้ เรายังสามารถวนลูปผ่านข้อมูลอ้างอิงแบบแก้ไขได้ (mutable reference) ของแต่ละสมาชิกในเวกเตอร์ที่สามารถแก้ไขได้ เพื่อเปลี่ยนแปลงข้อมูลของสมาชิกทุกตัวได้ด้วย ลูป for ในตัวอย่างที่ 8-8 จะบวกค่าเพิ่มเข้าไป 50 ในสมาชิกแต่ละตัว

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

ในการเปลี่ยนค่าที่ข้อมูลอ้างอิงแบบแก้ไขได้ชี้อยู่ เราจำเป็นต้องใช้ตัวดำเนินการหาค่าจากตำแหน่งอ้างอิง (dereference operator) * เพื่อเข้าถึงค่าจริงในตัวแปร i ก่อน จากนั้นเราจึงจะสามารถใช้ตัวดำเนินการ += ได้ เราจะอธิบายรายละเอียดเกี่ยวกับตัวดำเนินการ dereference เพิ่มเติมในส่วน “การตามรอยข้อมูลอ้างอิงไปยังค่าจริง (Following the Reference to the Value)” ของบทที่ 15

การวนลูปเข้าถึงข้อมูลในเวกเตอร์ ไม่ว่าจะเป็นแบบอ่านอย่างเดียวหรือแบบแก้ไขได้ จะมีความปลอดภัยสูงเนื่องจากกฎของ borrow checker หากเราพยายามใส่ข้อมูลเพิ่มหรือนำข้อมูลออกภายในโครงสร้างของลูป for ในตัวอย่างที่ 8-7 และตัวอย่างที่ 8-8 เราจะเจอกับข้อผิดพลาดของคอมไพเลอร์ที่คล้ายกับข้อผิดพลาดที่พบจากโค้ดในตัวอย่างที่ 8-6 ข้อมูลอ้างอิงของเวกเตอร์ที่ลูป for ถือครองไว้นั้นจะช่วยป้องกันไม่ให้เกิดการแก้ไขตัวเวกเตอร์ทั้งหมดไปพร้อม ๆ กัน

การใช้ Enum เพื่อเก็บข้อมูลหลายประเภท (Using an Enum to Store Multiple Types)

เวกเตอร์สามารถจัดเก็บได้เฉพาะข้อมูลที่เป็นประเภทเดียวกันเท่านั้น ซึ่งอาจจะไม่สะดวกในบางกรณี เพราะมีสถานการณ์ที่เราต้องการจัดเก็บรายการของข้อมูลที่มีหลายประเภทผสมกันอย่างแน่นอน โชคดีที่ค่าตัวแปรย่อย (variants) ของ enum นั้นถูกกำหนดไว้ภายใต้ประเภท enum เดียวกัน ดังนั้นเมื่อเราต้องการประเภทข้อมูลเดียวเพื่อเป็นตัวแทนของข้อมูลหลายประเภท เราจึงสามารถกำหนดและใช้งาน enum ได้!

ตัวอย่างเช่น สมมติว่าเราต้องการดึงข้อมูลจากแถวหนึ่งในสเปรดชีต (spreadsheet) ซึ่งบางคอลัมน์ในแถวนั้นอาจเก็บจำนวนเต็ม บางคอลัมน์เก็บตัวเลขทศนิยม และบางคอลัมน์เก็บข้อความ เราสามารถกำหนด enum ที่มีตัวแปรย่อยต่าง ๆ เพื่อจัดเก็บข้อมูลประเภทที่แตกต่างกันเหล่านี้ได้ และตัวแปรย่อยทั้งหมดของ enum นั้นจะถูกมองว่าเป็นประเภทข้อมูลเดียวกัน นั่นคือประเภทข้อมูลของ enum นั้นเอง จากนั้น เราจะสามารถสร้างเวกเตอร์สำหรับจัดเก็บ enum ดังกล่าวได้ ซึ่งท้ายที่สุดแล้วก็จะช่วยให้เก็บข้อมูลหลากประเภทได้นั่นเอง ดังที่แสดงไว้ในตัวอย่างที่ 8-9

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}

Rust จำเป็นต้องรู้ว่าจะมีประเภทข้อมูลใดบ้างอยู่ในเวกเตอร์ในขั้นตอนการคอมไพล์ (compile time) เพื่อที่มันจะได้รู้ขนาดหน่วยความจำบนฮีป (heap) ที่ต้องจัดสรรได้อย่างแม่นยำสำหรับใช้จัดเก็บสมาชิกแต่ละตัว นอกจากนี้ เราต้องระบุอย่างชัดเจนว่าประเภทข้อมูลใดบ้างที่ได้รับอนุญาตให้เก็บอยู่ในเวกเตอร์นี้ หาก Rust ยอมให้เวกเตอร์เก็บข้อมูลประเภทใดก็ได้ ก็มีความเสี่ยงที่ประเภทข้อมูลบางตัวอาจก่อให้เกิดข้อผิดพลาดเมื่อทำตามคำสั่งกับสมาชิกต่าง ๆ ของเวกเตอร์นั้น การใช้งาน enum ร่วมกับนิพจน์ match จะช่วยให้ Rust มั่นใจได้ตั้งแต่ตอนคอมไพล์ว่าทุกกรณีที่เป็นไปได้จะถูกจัดการอย่างถูกต้อง ดังที่เราพูดคุยกันในบทที่ 6

หากคุณไม่ทราบประเภทข้อมูลทั้งหมดล่วงหน้าในระหว่างการพัฒนาเพื่อนำมาเก็บในเวกเตอร์ตอนรันโปรแกรม (runtime) เทคนิคการใช้ enum จะไม่สามารถตอบโจทย์ได้ ในกรณีนั้น คุณสามารถเลือกใช้งานทริตอ็อบเจกต์ (trait object) แทนได้ ซึ่งเราจะมาศึกษาในบทที่ 18

เมื่อเราได้พูดคุยเกี่ยวกับวิธีการยอดนิยมในการใช้งานเวกเตอร์กันไปแล้ว อย่าลืมเข้าไปศึกษา เอกสารอ้างอิง API (API documentation) เพิ่มเติมเกี่ยวกับเมธอดที่มีประโยชน์อื่น ๆ อีกมากมายสำหรับ Vec<T> ที่ไลบรารีมาตรฐานกำหนดไว้ ตัวอย่างเช่น นอกเหนือจากเมธอด push แล้ว ยังมีเมธอด pop ที่จะนำสมาชิกตัวสุดท้ายออกจากเวกเตอร์และส่งค่ากลับคืนมาให้ด้วย

การทำลายเวกเตอร์จะเป็นการทำลายสมาชิกในเวกเตอร์ไปด้วย (Dropping a Vector Drops Its Elements)

เช่นเดียวกับโครงสร้างข้อมูล (struct) อื่น ๆ เวกเตอร์จะถูกคืนพื้นที่หน่วยความจำเมื่อมันอยู่นอกขอบเขตการใช้งาน (out of scope) ดังที่ระบุไว้ในตัวอย่างที่ 8-10

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

เมื่อเวกเตอร์ถูกทำลาย ข้อมูลทั้งหมดภายในเวกเตอร์จะถูกทำลายไปด้วย นั่นหมายความว่าค่าจำนวนเต็มต่าง ๆ ที่ถูกเก็บไว้จะถูกกวาดล้างและคืนหน่วยความจำ ระบบ borrow checker จะเป็นผู้รับประกันว่า ข้อมูลอ้างอิงใด ๆ ที่ชี้ไปยังข้อมูลของเวกเตอร์นั้นจะถูกใช้งานเฉพาะตอนที่ตัวเวกเตอร์เองยังมีสถานะใช้งานได้อยู่เท่านั้น

มาต่อกันที่คอลเลกชันประเภทถัดไปกันเลย นั่นก็คือ String!

Storing UTF-8 Encoded Text with Strings

การจัดเก็บข้อความที่เข้ารหัสแบบ UTF-8 ด้วยสตริง (Storing UTF-8 Encoded Text with Strings)

เราได้พูดคุยเกี่ยวกับสตริงไปแล้วในบทที่ 4 แต่ตอนนี้เราจะมาดูกันในรายละเอียดที่ลึกขึ้น ผู้ที่เริ่มศึกษาภาษา Rust (New Rustaceans) มักจะเจอปัญหาและสับสนเกี่ยวกับเรื่องสตริงบ่อยครั้งด้วยเหตุผล 3 ประการรวมกัน ได้แก่ แนวโน้มของ Rust ที่จะเปิดเผยข้อผิดพลาดที่อาจเกิดขึ้นทั้งหมด, ความเป็นจริงที่ว่าสตริงเป็นโครงสร้างข้อมูลที่ซับซ้อนกว่าที่นักโปรแกรมหลายคนคิดไว้, และการเข้ารหัสแบบ UTF-8 ปัจจัยเหล่านี้รวมกันทำให้สตริงในภาษา Rust ดูยากเมื่อเทียบกับภาษาโปรแกรมอื่น ๆ ที่คุณคุ้นเคย

เรานำสตริงมาพูดคุยกันในบริบทของคอลเลกชัน เนื่องจากสตริงถูกพัฒนาขึ้นมาจากคอลเลกชันของไบต์ (bytes) บวกกับเมธอดบางตัวที่ช่วยให้การทำงานสะดวกขึ้นเมื่อแปลไบต์เหล่านั้นเป็นข้อความ ในส่วนนี้ เราจะมาคุยเกี่ยวกับแนวทางการทำงานกับ String ที่คอลเลกชันทุกประเภทมี เช่น การสร้าง การอัปเดต และการอ่านข้อมูล นอกจากนี้ เรายังจะมาดูกันว่า String มีความแตกต่างจากคอลเลกชันอื่น ๆ อย่างไร โดยเฉพาะอย่างยิ่งในเรื่องของการเข้าถึงข้อมูลด้วยดัชนี (indexing) ของ String นั้นมีความซับซ้อนขึ้นเนื่องจากความต่างระหว่างการแปลความหมายข้อมูลสตริงของมนุษย์กับคอมพิวเตอร์

การนิยามสตริง (Defining Strings)

ขั้นแรกเราจะมานิยามความหมายของคำว่า สตริง (string) กันก่อน ภาษา Rust มีประเภทข้อมูลสตริงเพียงแบบเดียวในภาษาแกนหลัก (core language) ซึ่งก็คือส่วนย่อยของสตริง (string slice) str ซึ่งมักจะเห็นในรูปแบบของการยืมใช้งานนั่นคือ &str ในบทที่ 4 เราได้คุยกันเรื่อง string slices ซึ่งเป็นข้อมูลอ้างอิงไปยังข้อมูลสตริงที่เข้ารหัสแบบ UTF-8 ที่ถูกจัดเก็บไว้ที่อื่น ตัวอย่างเช่น ข้อความตายตัว (string literals) จะถูกเก็บไว้ในไบนารีของโปรแกรม ดังนั้นจึงมีฐานะเป็น string slices

ประเภทข้อมูล String นั้นจัดเตรียมไว้ให้โดยไลบรารีมาตรฐานของ Rust (standard library) ไม่ได้ฝังอยู่ในภาษาแกนหลัก โดย String จะเป็นประเภทข้อมูลสตริงที่สามารถขยายขนาดได้, แก้ไขค่าได้, มีความเป็นเจ้าของ (owned) และเข้ารหัสแบบ UTF-8 เมื่อผู้ใช้ภาษา Rust พูดถึง “สตริง” ในภาษา Rust พวกเขาอาจจะหมายถึงประเภทข้อมูล String หรือ string slice &str ก็ได้ ไม่ใช่แค่ประเภทใดประเภทหนึ่งเท่านั้น แม้ว่าเนื้อหาในส่วนนี้จะเน้นเรื่อง String เป็นหลัก แต่ทั้งสองประเภทนี้ก็ถูกใช้งานอย่างหนักในไลบรารีมาตรฐานของ Rust และทั้ง String รวมถึง string slices ต่างก็มีการเข้ารหัสข้อมูลเป็น UTF-8 ทั้งคู่

การสร้างสตริงใหม่ (Creating a New String)

การทำงานหลาย ๆ อย่างที่ใช้กับ Vec<T> ได้นั้นก็สามารถใช้กับ String ได้เช่นเดียวกัน เนื่องจากแท้จริงแล้ว String ถูกสร้างขึ้นโดยครอบทับอยู่บนเวกเตอร์ของไบต์ (vector of bytes) พร้อมกับมีการรับประกัน ข้อจำกัด และความสามารถเพิ่มเติมบางประการ ตัวอย่างหนึ่งของฟังก์ชันที่ทำงานเหมือนกันใน Vec<T> และ String คือฟังก์ชัน new ที่ใช้สร้างออบเจกต์ขึ้นมาใหม่ ดังแสดงในตัวอย่างที่ 8-11

fn main() {
    let mut s = String::new();
}

บรรทัดนี้จะสร้างสตริงใหม่ที่ว่างเปล่าชื่อว่า s ซึ่งหลังจากนั้นเราจะสามารถนำข้อมูลมาใส่ได้ บ่อยครั้งที่เราต้องการสร้างสตริงโดยมีข้อมูลเริ่มต้นมาด้วย สำหรับกรณีนี้เราจะใช้เมธอด to_string ซึ่งใช้ได้กับประเภทข้อมูลใด ๆ ก็ตามที่นำเสนอ (implement) ทริต Display เช่นเดียวกับที่ข้อความตายตัว (string literals) ทำได้ ตัวอย่างที่ 8-12 แสดงให้เห็นถึงสองตัวอย่าง

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}

โค้ดนี้จะสร้างสตริงที่ประกอบไปด้วยข้อความ initial contents

นอกจากนี้เรายังสามารถใช้ฟังก์ชัน String::from เพื่อสร้าง String จากข้อความตายตัวได้เช่นกัน โค้ดในตัวอย่างที่ 8-13 มีผลลัพธ์เทียบเท่ากับโค้ดในตัวอย่างที่ 8-12 ที่ใช้เมธอด to_string

fn main() {
    let s = String::from("initial contents");
}

เนื่องจากสตริงถูกนำไปใช้ในหลายสิ่งหลายอย่าง เราจึงมี API แบบเจเนอริก (generic APIs) มากมายให้เลือกใช้สำหรับสตริง ซึ่งช่วยให้เรามีตัวเลือกหลากหลาย บางวิธีอาจดูเหมือนซ้ำซ้อนกัน แต่ทุกวิธีล้วนมีบทบาทหน้าที่ของมันเอง! ในกรณีนี้ String::from และ to_string ทำงานแบบเดียวกันเป๊ะ ดังนั้นการเลือกใช้วิธีไหนจึงขึ้นอยู่กับสไตล์ความชอบและการอ่านง่ายของโค้ดเป็นหลัก

โปรดจำไว้ว่าสตริงเข้ารหัสแบบ UTF-8 ดังนั้นเราจึงสามารถใส่ข้อมูลที่เข้ารหัสอย่างถูกต้องลงไปได้ ดังที่แสดงในตัวอย่างที่ 8-14

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

ค่าทั้งหมดข้างต้นนี้ถือเป็นค่า String ที่ถูกต้องสมบูรณ์

การอัปเดตสตริง (Updating a String)

String สามารถขยายขนาดได้และข้อมูลภายในสามารถเปลี่ยนแปลงได้ เช่นเดียวกับข้อมูลใน Vec<T> หากคุณใส่ข้อมูลเพิ่มเข้าไป นอกจากนี้ คุณสามารถใช้ตัวดำเนินการ + หรือมาโคร format! เพื่อรวมข้อมูล (concatenate) ของ String ได้อย่างสะดวกสบาย

การต่อท้ายข้อความด้วย push_str หรือ push (Appending with push_str or push)

เราสามารถเพิ่มความยาวของ String ได้โดยการใช้เมธอด push_str เพื่อต่อท้ายด้วย string slice ดังที่แสดงในตัวอย่างที่ 8-15

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

หลังจากโค้ดสองบรรทัดนี้ทำงานเสร็จ ตัวแปร s จะมีข้อความ foobar เมธอด push_str จะรับพารามิเตอร์เป็น string slice เนื่องจากเราไม่ต้องการโอนย้ายสิทธิ์ความเป็นเจ้าของ (ownership) ของพารามิเตอร์นั้น ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 8-16 เราต้องการที่จะสามารถนำตัวแปร s2 ไปใช้ต่อได้หลังจากนำเนื้อหาไปต่อท้าย s1 แล้ว

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

หากเมธอด push_str ดึงความเป็นเจ้าของของ s2 ไป เราก็จะไม่สามารถแสดงผลค่าของมันในบรรทัดสุดท้ายได้ อย่างไรก็ตาม โค้ดนี้ทำงานได้ตามปกติที่เราคาดหวังไว้!

เมธอด push จะรับพารามิเตอร์เป็นตัวอักษรเดียว (single character) แล้วเพิ่มตัวอักษรนั้นต่อท้ายเข้าไปใน String ตัวอย่างที่ 8-17 จะเพิ่มตัวอักษร l ลงใน String โดยใช้เมธอด push

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

ผลลัพธ์คือตัวแปร s จะเก็บข้อความ lol

การรวมข้อความด้วย + หรือ format! (Concatenating with + or format!)

บ่อยครั้งที่คุณต้องการรวมสองสตริงเข้าด้วยกัน วิธีหนึ่งคือการใช้ตัวดำเนินการ + ดังแสดงในตัวอย่างที่ 8-18

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

ตัวแปร s3 จะเก็บข้อความ Hello, world! เหตุผลที่ตัวแปร s1 จะใช้งานไม่ได้อีกต่อไปหลังจากการบวกนี้ และเหตุผลที่เราต้องส่งข้อมูลอ้างอิงไปยัง s2 (คือการใส่เครื่องหมาย &) มีความเกี่ยวโยงกับโครงสร้างซิกเนเจอร์ (signature) ของเมธอดที่จะถูกเรียกเมื่อใช้ตัวดำเนินการ + ตัวดำเนินการ + นี้ทำงานผ่านเมธอด add ซึ่งมีหน้าตาซิกเนเจอร์ประมาณนี้:

fn add(self, s: &str) -> String {

ในไลบรารีมาตรฐาน คุณจะเห็นว่าเมธอด add ถูกนิยามโดยใช้เจเนอริกและประเภทข้อมูลที่เชื่อมโยงกัน (associated types) แต่ในที่นี้เรานำประเภทข้อมูลรูปธรรม (concrete types) มาใส่แทนเพื่อให้เห็นภาพเวลาเราเรียกใช้งานเมธอดนี้ด้วยค่าที่เป็น String เราจะมาพูดคุยเกี่ยวกับเจเนอริกกันต่อในบทที่ 10 โครงสร้างซิกเนเจอร์นี้ช่วยบอกใบ้ให้เราเข้าใจจุดที่ค่อนข้างซับซ้อนของตัวดำเนินการ + ได้อย่างชัดเจน

ประการแรก s2 มีเครื่องหมาย & หมายความว่าเรากำลังบวกข้อมูลอ้างอิงของสตริงที่สองเข้ากับสตริงแรก เนื่องจากพารามิเตอร์ s ในฟังก์ชัน add กำหนดไว้ว่า เราสามารถบวกได้เฉพาะ string slice เข้ากับ String เท่านั้น เราไม่สามารถนำสองค่าที่เป็น String มาบวกกันตรง ๆ ได้ แต่เดี๋ยวก่อน ประเภทของ &s2 คือ &String ไม่ใช่ &str ตามที่ระบุไว้ในพารามิเตอร์ตัวที่สองของ add แล้วเหตุใดตัวอย่างที่ 8-18 ถึงคอมไพล์ผ่านล่ะ?

เหตุผลที่เราสามารถนำ &s2 มาใส่ในเมธอด add ได้นั้นเป็นเพราะคอมไพเลอร์สามารถแปลงค่าอาร์กิวเมนต์แบบ &String ให้กลายเป็น &str ได้โดยอัตโนมัติ (coerce) เมื่อเราเรียกใช้เมธอด add ตัวภาษา Rust จะใช้กระบวนการแปลงชนิดข้อมูลอ้างอิง (deref coercion) ซึ่งในกรณีนี้จะเปลี่ยน &s2 ให้กลายเป็น &s2[..] เราจะมาเรียนรู้เรื่อง deref coercion อย่างละเอียดในบทที่ 15 และเนื่องจากเมธอด add ไม่ได้แย่งสิทธิ์ความเป็นเจ้าของของพารามิเตอร์ s ไป ตัวแปร s2 จึงยังคงเป็น String ที่ใช้การได้ต่อไปหลังจากการคำนวณนี้

ประการที่สอง เราจะเห็นจากโครงสร้างซิกเนเจอร์ว่าเมธอด add จะยึดสิทธิ์ความเป็นเจ้าของของ self ไป เนื่องจาก self นั้น ไม่มี เครื่องหมาย & อยู่ข้างหน้า นั่นหมายความว่า s1 ในตัวอย่างที่ 8-18 จะถูกย้าย (move) เข้าไปในเมธอด add และจะไม่สามารถใช้งานได้อีกหลังจากนั้น ดังนั้นถึงแม้โค้ดบรรทัด let s3 = s1 + &s2; จะดูเหมือนการคัดลอกสตริงทั้งสองเพื่อสร้างสตริงตัวใหม่ขึ้นมา แต่ความจริงแล้วคำสั่งนี้จะยึดสิทธิ์ความเป็นเจ้าของของ s1 ไป แล้วนำเนื้อหาของ s2 มาคัดลอกเพิ่มเข้าไปที่ด้านท้าย จากนั้นจึงส่งสิทธิ์ความเป็นเจ้าของของผลลัพธ์กลับคืนมา กล่าวอีกนัยหนึ่งคือ มันดูเหมือนจะสร้างข้อมูลชุดใหม่หลายชุด แต่ความจริงแล้วไม่ใช่ กระบวนการทำงานจริงมีประสิทธิภาพสูงกว่าการสร้างสำเนามาก

หากเราต้องการเชื่อมต่อสตริงหลาย ๆ ตัวเข้าด้วยกัน พฤติกรรมของตัวดำเนินการ + จะเริ่มอ่านยากและวุ่นวาย:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

ณ จุดนี้ ตัวแปร s จะเก็บค่า tic-tac-toe ซึ่งการมองหาตัวดำเนินการ + และเครื่องหมายคำพูด " ที่กระจายอยู่เต็มไปหมดแบบนี้ทำให้ดูออกยากว่าโค้ดทำอะไร สำหรับการรวมสตริงที่มีโครงสร้างซับซ้อนกว่านี้ เราสามารถเปลี่ยนมาใช้มาโคร format! ได้แทน:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

โค้ดนี้จะกำหนดให้ตัวแปร s เก็บค่า tic-tac-toe เช่นเดียวกัน มาโคร format! มีหลักการทำงานเหมือนกับ println! เพียงแต่แทนที่จะพ่นผลลัพธ์ออกมาแสดงบนหน้าจอ มันจะส่งกลับคืนค่ามาเป็น String ที่มีเนื้อหานั้น ๆ แทน ซึ่งการเขียนโค้ดเวอร์ชันที่ใช้ format! จะช่วยให้อ่านง่ายขึ้นมาก อีกทั้งโค้ดที่สร้างขึ้นจากมาโคร format! จะใช้ข้อมูลอ้างอิง (references) ดังนั้นการเรียกใช้คำสั่งนี้จะไม่ไปแย่งความเป็นเจ้าของของพารามิเตอร์ใด ๆ ของมันเลย

การเข้าถึงข้อมูลสตริงด้วยดัชนี (Indexing into Strings)

ในภาษาโปรแกรมอื่น ๆ จำนวนมาก การเข้าถึงแต่ละตัวอักษรในสตริงผ่านการอ้างอิงดัชนีเป็นเรื่องปกติธรรมดาและทำได้ทั่วไป อย่างไรก็ตาม หากคุณพยายามเข้าถึงบางส่วนของ String โดยใช้ไวยากรณ์ดัชนี (indexing syntax) ในภาษา Rust คุณจะเจอกับข้อผิดพลาด ลองดูโค้ดที่ไม่ถูกต้องในตัวอย่างที่ 8-19

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

โค้ดนี้จะส่งผลให้เกิดข้อผิดพลาดต่อไปนี้:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: `usize` implements trait `SliceIndex<T>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/slice/index.rs:214:0
  |
  = note: `SliceIndex<[T]>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/bstr/traits.rs:197:0
  |
  = note: `SliceIndex<ByteStr>`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

ข้อความแจ้งข้อผิดพลาดได้บอกเหตุผลไว้ชัดเจนแล้วว่า สตริงในภาษา Rust ไม่รองรับการเข้าถึงด้วยดัชนี (indexing) แต่ทำไมล่ะ? เพื่อจะตอบคำถามนั้น เราจำเป็นต้องลงลึกไปดูรูปแบบที่ Rust จัดเก็บสตริงไว้ในหน่วยความจำกัน

การแทนข้อมูลภายใน (Internal Representation)

String แท้จริงแล้วเป็นโครงสร้างครอบทับอยู่บนเวกเตอร์ประเภท Vec<u8> ลองมาดูตัวอย่างข้อความสตริงที่เข้ารหัสแบบ UTF-8 อย่างถูกต้องจากตัวอย่างที่ 8-14 ประการแรก คือตัวอย่างนี้:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

ในกรณีนี้ ค่าของ len จะเป็น 4 ซึ่งหมายความว่าเวกเตอร์ที่เก็บสตริง "Hola" มีความยาวเท่ากับ 4 ไบต์ ตัวอักษรแต่ละตัวจะใช้พื้นที่ 1 ไบต์เมื่อเข้ารหัสเป็น UTF-8 อย่างไรก็ตาม ตัวอย่างบรรทัดถัดไปอาจทำให้คุณประหลาดใจ (สังเกตว่าสตริงนี้เริ่มต้นด้วยอักษรซีริลลิกตัวพิมพ์ใหญ่ Ze ไม่ใช่เลข 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

หากถามว่าสตริงนี้มีความยาวเท่าใด คุณอาจจะตอบว่า 12 ตัวอักษร แต่แท้จริงแล้วคำตอบของ Rust คือ 24 นั่นคือจำนวนไบต์ที่ใช้ในการเข้ารหัสคำว่า “Здравствуйте” ในแบบ UTF-8 เนื่องจากค่า Unicode scalar value แต่ละตัวในสตริงนั้นต้องการพื้นที่จัดเก็บ 2 ไบต์ ดังนั้น ดัชนีที่ชี้เข้าไปยังไบต์ของสตริงจึงไม่ได้ตรงกับค่า Unicode scalar value ที่ถูกต้องเสมอไป เพื่อแสดงให้เห็นภาพ ลองพิจารณาโค้ด Rust ที่ไม่ถูกต้องบรรทัดนี้:

let hello = "Здравствуйте";
let answer = &hello[0];

คุณรู้อยู่แล้วว่าค่าของ answer จะไม่ใช่ตัวอักษรแรกนั่นคือ З เมื่อเข้ารหัสในแบบ UTF-8 ไบต์แรกของ З จะมีค่าเป็น 208 และไบต์ที่สองคือ 151 ดังนั้นดูเหมือนว่า answer ควรจะเป็นค่า 208 แต่ทว่าค่า 208 เดี่ยว ๆ นั้นไม่ใช่ตัวอักษรที่สมบูรณ์และใช้งานได้ในตัวมันเอง การส่งค่ากลับมาเป็น 208 จึงไม่ใช่สิ่งที่ผู้ใช้อ้างอิงต้องการแน่นอนหากพวกเขาถามหาอักษรตัวแรกของข้อความนี้ แต่อย่างไรก็ดี นั่นคือข้อมูลเดียวที่ Rust มีเก็บไว้ที่ดัชนีไบต์ตำแหน่ง 0 ปกติแล้วผู้ใช้ไม่ได้ต้องการค่าไบต์ส่งกลับมา ถึงแม้ว่าสตริงนั้นจะมีแต่ตัวอักษรละตินก็ตาม: หากโค้ด &"hi"[0] ทำงานได้และส่งค่าระดับไบต์กลับมา มันจะส่งค่า 104 ไม่ใช่ตัวอักษร h

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

ไบต์, ค่าสเกลาร์, และคลัสเตอร์กราฟีม (Bytes, Scalar Values, and Grapheme Clusters)

อีกแง่มุมหนึ่งเกี่ยวกับ UTF-8 คือความจริงแล้ว มี 3 มุมมองหลัก ๆ ในการมองข้อมูลสตริงจากมุมมองของภาษา Rust ได้แก่ ไบต์ (bytes), ค่าสเกลาร์ (scalar values), และคลัสเตอร์กราฟีม (grapheme clusters - สิ่งที่ใกล้เคียงที่สุดกับคำว่า ตัวอักษร ในความเข้าใจของมนุษย์)

หากเราพิจารณาคำในภาษาฮินดี “नमस्ते” ซึ่งเขียนด้วยอักษรเทวนาครี มันจะถูกจัดเก็บเป็นเวกเตอร์ของค่า u8 หน้าตาแบบนี้:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

นั่นคือทั้งหมด 18 ไบต์ และนี่คือวิธีที่คอมพิวเตอร์เก็บข้อมูลนี้ไว้เบื้องหลัง แต่ถ้าเราพิจารณาพวกมันในฐานะ Unicode scalar values ซึ่งก็คือข้อมูลประเภท char ในภาษา Rust ไบต์เหล่านั้นจะมีหน้าตาแบบนี้:

['न', 'ม', 'ส', '्', 'ต', 'े']

มีค่า char ทั้งหมด 6 ตัวอยู่ตรงนี้ แต่ทว่าตัวที่สี่และตัวที่หกนั้นไม่ใช่ตัวอักษรเดี่ยว ๆ: พวกมันคือเครื่องหมายกำกับการออกเสียง (diacritics) ที่ไม่มีความหมายหากแสดงผลแยกอยู่ตัวคนเดียว สุดท้ายนี้ หากเราพิจารณาข้อมูลในฐานะคลัสเตอร์กราฟีม (grapheme clusters) เราจะได้คำตอบที่เป็นตัวอักษร 4 ตัวตามความเข้าใจของมนุษย์ที่ประกอบกันเป็นคำภาษาฮินดีนี้:

["न", "ม", "สฺ", "เต"]

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

เหตุผลสุดท้ายที่ Rust ไม่อนุญาตให้เราเข้าถึงสตริงด้วยดัชนีเพื่อดึงตัวอักษรออกมาก็คือ คำสั่งการเข้าถึงด้วยดัชนี (indexing operations) ควรจะทำงานด้วยเวลาคงที่ (O(1)) เสมอ แต่ทว่ามันเป็นไปไม่ได้ที่จะรับประกันประสิทธิภาพเช่นนั้นกับประเภทข้อมูล String เนื่องจาก Rust จำเป็นต้องไล่ตรวจเช็คข้อมูลตั้งแต่จุดเริ่มต้นไปจนถึงดัชนีเป้าหมายเพื่อหาว่ามีอักษรที่สมบูรณ์กี่ตัวอักษร

การหั่นแบ่งสตริง (Slicing Strings)

การดึงข้อมูลตัวอักษรจากสตริงผ่านดัชนีตรง ๆ มักจะเป็นสิ่งที่ไม่ควรทำเนื่องจากไม่ชัดเจนว่าค่าที่ส่งกลับจากคำสั่งดึงดัชนีสตริงควรเป็นประเภทใดกันแน่: ค่าไบต์, ตัวอักษร, คลัสเตอร์กราฟีม หรือ string slice ดังนั้น หากคุณมีความจำเป็นต้องใช้ค่าดัชนีเพื่อทำสตริงส่วนย่อย (string slices) จริง ๆ Rust จะกำหนดให้คุณระบุรายละเอียดที่เจาะจงยิ่งขึ้น

แทนที่จะใช้วงเล็บ [] ร่วมกับตัวเลขเพียงตัวเดียว คุณสามารถใช้ [] ร่วมกับช่วงตัวเลข (range) เพื่อสร้างส่วนย่อยของสตริงที่มีขอบเขตเฉพาะไบต์ที่ต้องการได้:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

ในกรณีนี้ s จะมีฐานะเป็น &str ที่เก็บข้อมูล 4 ไบต์แรกของสตริง ก่อนหน้านี้เราได้กล่าวไปแล้วว่าตัวอักษรเหล่านี้แต่ละตัวใช้พื้นที่ 2 ไบต์ นั่นหมายความว่าค่าใน s จะเท่ากับ Зд

หากเราพยายามดึงส่วนย่อยเฉพาะแค่บางส่วนของไบต์ในตัวอักษรตัวเดียวกัน เช่น &hello[0..1] ตัวภาษา Rust จะเกิดข้อผิดพลาดกะทันหัน (panic) ในตอนรันโปรแกรม (runtime) เช่นเดียวกับกรณีที่คุณเข้าถึงดัชนีที่ไม่มีจริงในเวกเตอร์:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' (6017738) panicked at src/main.rs:4:19:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

ดังนั้นคุณควรระมัดระวังอย่างมากเมื่อสร้าง string slices ด้วยช่วงตัวเลข (ranges) เพราะการทำเช่นนั้นอาจทำให้โปรแกรมของคุณพังได้

การวนลูปผ่านสตริง (Iterating Over Strings)

วิธีที่ดีที่สุดในการจัดการกับบางส่วนของสตริงคือการเจาะจงลงไปให้ชัดเจนว่าคุณต้องการข้อมูลในรูปแบบตัวอักษรหรือรูปแบบไบต์ สำหรับค่า Unicode scalar value แต่ละตัว ให้ใช้เมธอด chars การเรียกใช้ chars บนข้อความ “Зд” จะแยกองค์ประกอบและส่งกลับค่าออกมาสองตัวซึ่งมีประเภทข้อมูลเป็น char และคุณสามารถวนลูปผ่านผลลัพธ์ดังกล่าวเพื่อเข้าถึงแต่ละสมาชิกได้:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

โค้ดนี้จะแสดงผลดังต่อไปนี้:

З
д

อีกทางเลือกหนึ่ง เมธอด bytes จะส่งค่าแต่ละไบต์ดิบกลับมาให้ ซึ่งอาจมีความเหมาะสมกับการนำไปใช้งานในโปรแกรมของคุณ:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

โค้ดนี้จะแสดงผลค่าไบต์ทั้ง 4 ไบต์ที่รวมกันกลายเป็นข้อความนี้:

208
151
208
180

แต่อย่าลืมว่า ค่า Unicode scalar value ที่ถูกต้องสามารถประกอบขึ้นจากข้อมูลได้มากกว่า 1 ไบต์

การดึงคลัสเตอร์กราฟีม (grapheme clusters) จากสตริง เช่น ในภาษาเทวนาครี มีความซับซ้อนอย่างมาก ดังนั้นฟังก์ชันนี้จึงไม่ได้มีจัดเตรียมไว้ให้ในไลบรารีมาตรฐาน อย่างไรก็ตาม มีเคตส์ (crates) อื่น ๆ ที่พร้อมใช้งานบน crates.io หากนี่เป็นฟังก์ชันการทำงานที่คุณต้องการใช้

การรับมือกับความซับซ้อนของสตริง (Handling the Complexities of Strings)

สรุปสั้น ๆ คือ สตริงนั้นเป็นเรื่องที่ซับซ้อน ภาษาโปรแกรมต่าง ๆ มีทางเลือกและการตัดสินใจที่แตกต่างกันในการเสนอความซับซ้อนเหล่านี้ให้กับนักพัฒนา สำหรับภาษา Rust เราได้ตัดสินใจกำหนดให้การจัดการข้อมูล String อย่างถูกต้องเป็นพฤติกรรมเริ่มต้น (default behavior) ของโปรแกรม Rust ทั้งหมด ซึ่งหมายความว่านักพัฒนาจำเป็นต้องคิดไตร่ตรองให้ถี่ถ้วนเกี่ยวกับการจัดการข้อมูลแบบ UTF-8 ตั้งแต่เริ่มต้น การแลกเปลี่ยน (trade-off) ในจุดนี้ทำให้ความซับซ้อนของสตริงเด่นชัดกว่าภาษาอื่น ๆ แต่มันจะช่วยป้องกันคุณจากการตามล้างตามแก้ปัญหาที่เกี่ยวข้องกับตัวอักษรที่ไม่ใช่ ASCII (non-ASCII characters) ในระหว่างขั้นตอนการพัฒนาและวางระบบภายหลัง

ข่าวดีคือไลบรารีมาตรฐานมีการจัดเตรียมเมธอดการใช้งานไว้มากมายบนรากฐานของประเภทข้อมูล String และ &str เพื่อช่วยจัดการกับสถานการณ์อันซับซ้อนเหล่านี้ได้อย่างถูกต้อง อย่าลืมเข้าไปศึกษาเอกสารอ้างอิงเพื่อเรียนรู้เมธอดที่มีประโยชน์อย่าง contains สำหรับใช้ค้นหาคำในสตริง และ replace สำหรับใช้แทนที่บางส่วนของสตริงด้วยสตริงอื่น

ทีนี้เราลองเปลี่ยนไปดูหัวข้อที่มีความซับซ้อนน้อยกว่านี้กันสักเล็กน้อย นั่นก็คือ แฮชแมป (hash maps) กันครับ!

Storing Keys with Associated Values in Hash Maps

การจัดเก็บคีย์คู่กับค่าที่เกี่ยวข้องในแฮชแมป (Storing Keys with Associated Values in Hash Maps)

คอลเลกชันทั่วไปประเภทสุดท้ายที่เราจะมาพูดถึงคือแฮชแมป (hash map) ประเภทข้อมูล HashMap<K, V> จะใช้จัดเก็บการจับคู่ของคีย์ประเภท K เข้ากับค่าประเภท V โดยทำงานผ่าน ฟังก์ชันแฮช (hashing function) ซึ่งเป็นตัวกำหนดวิธีการจัดเก็บคีย์และค่าเหล่านั้นลงในหน่วยความจำ ภาษาโปรแกรมหลาย ๆ ภาษาต่างก็รองรับโครงสร้างข้อมูลลักษณะนี้ แต่อาจจะใช้ชื่อเรียกที่แตกต่างกันไป เช่น hash, map, object, hash table, dictionary หรือ associative array เป็นต้น

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

ในส่วนนี้เราจะกล่าวถึงการทำงานพื้นฐาน (basic API) ของแฮชแมป แต่ทว่ายังมีฟังก์ชันเจ๋ง ๆ อีกมากมายที่ซ่อนอยู่ในเมธอดของ HashMap<K, V> ซึ่งจัดเตรียมไว้โดยไลบรารีมาตรฐาน และเช่นเคย คุณสามารถตรวจสอบข้อมูลเพิ่มเติมได้จากเอกสารของไลบรารีมาตรฐาน

การสร้างแฮชแมปใหม่ (Creating a New Hash Map)

วิธีหนึ่งในการสร้างแฮชแมปที่ว่างเปล่าคือการเรียกใช้ฟังก์ชัน new แล้วค่อยเพิ่มสมาชิกเข้าไปด้วยเมธอด insert ในตัวอย่างที่ 8-20 เราจะบันทึกคะแนนของทีมสองทีมซึ่งมีชื่อทีมว่า Blue และ Yellow โดยทีม Blue เริ่มต้นที่ 10 คะแนน และทีม Yellow เริ่มต้นที่ 50 คะแนน

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

สังเกตว่าก่อนอื่นเราต้องเรียกใช้งาน (use) ตัว HashMap จากกลุ่มของคอลเลกชัน (collections) ในไลบรารีมาตรฐานก่อน ในบรรดาคอลเลกชันทั่วไปทั้งสามประเภทที่เราเรียนรู้ไป แฮชแมปถือเป็นตัวที่ถูกเรียกใช้งานน้อยที่สุด ดังนั้นมันจึงไม่ได้ถูกดึงเข้ามาในขอบเขตการใช้งานโดยอัตโนมัติ (prelude) นอกจากนี้แฮชแมปยังมีการสนับสนุนจากไลบรารีมาตรฐานน้อยกว่า เช่น ไม่มีมาโครในตัวสําหรับสร้างแฮชแมปโดยเฉพาะ

ข้อมูลในแฮชแมปจะถูกจัดเก็บไว้บนพื้นที่หน่วยความจำฮีป (heap) เช่นเดียวกับเวกเตอร์ ตัว HashMap นี้มีคีย์เป็นประเภท String และค่าเป็นประเภท i32 และคุณสมบัติของแฮชแมปก็เหมือนกับเวกเตอร์คือเป็นข้อมูลประเภทเดียวกัน (homogeneous): นั่นคือคีย์ทั้งหมดต้องเป็นประเภทเดียวกัน และค่าทั้งหมดก็ต้องเป็นประเภทเดียวกันด้วย

การเข้าถึงค่าต่าง ๆ ในแฮชแมป (Accessing Values in a Hash Map)

เราสามารถดึงข้อมูลออกจากแฮชแมปได้โดยการส่งคีย์ไปให้เมธอด get ดังที่แสดงในตัวอย่างที่ 8-21

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}

ในที่นี้ score จะเก็บค่าคะแนนที่ผูกกับทีม Blue ซึ่งผลลัพธ์ก็คือ 10 เมธอด get จะส่งค่ากลับมาเป็นประเภท Option<&V> หากไม่มีคีย์ดังกล่าวอยู่ในแฮชแมป เมธอด get จะคืนค่าเป็น None โปรแกรมนี้จะจัดการกับค่า Option โดยการเรียกใช้เมธอด copied เพื่อให้ได้ค่าเป็น Option<i32> แทนที่จะเป็น Option<&i32> จากนั้นจึงใช้เมธอด unwrap_or เพื่อกำหนดค่าของ score ให้เป็นศูนย์ หากในแฮชแมป scores ไม่มีข้อมูลสำหรับคีย์นั้น

เราสามารถวนลูปผ่านคู่คีย์-ค่า (key-value pair) แต่ละคู่ในแฮชแมปได้ในลักษณะเดียวกันกับการวนลูปในเวกเตอร์ โดยใช้ลูป for:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

โค้ดนี้จะแสดงผลลัพธ์แต่ละคู่คีย์-ค่าในแบบที่ไม่เรียงลำดับแน่นอน (arbitrary order):

Yellow: 50
Blue: 10

การจัดการความเป็นเจ้าของในแฮชแมป (Managing Ownership in Hash Maps)

สำหรับประเภทข้อมูลที่สืบทอด (implement) ทริต Copy เช่น i32 ค่าต่าง ๆ จะถูกคัดลอก (copy) เข้าไปยังแฮชแมป สำหรับข้อมูลที่เป็นเจ้าของตัวเองอย่าง String ค่าเหล่านั้นจะถูกย้ายสิทธิ์ (move) และตัวแฮชแมปจะกลายมาเป็นเจ้าของค่าเหล่านั้นแทน ดังที่แสดงในตัวอย่างที่ 8-22

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}

เราจะไม่สามารถใช้งานตัวแปร field_name และ field_value ได้อีกต่อไปหลังจากที่มันถูกย้ายสิทธิ์เข้าไปในแฮชแมปผ่านคำสั่งเรียกใช้งาน insert แล้ว

หากเราใส่ข้อมูลอ้างอิง (references) ของค่าต่าง ๆ เข้าไปในแฮชแมป ค่าเหล่านั้นจะไม่ถูกย้ายสิทธิ์เข้าไปในแฮชแมป แต่ทว่าค่าจริงที่ข้อมูลอ้างอิงชี้อยู่นั้นจะต้องมีอายุใช้งาน (valid) นานอย่างน้อยที่สุดเท่าที่แฮชแมปนั้นยังมีอายุใช้งานอยู่ เราจะพูดคุยประเด็นเหล่านี้เพิ่มเติมในหัวข้อ “การตรวจสอบความถูกต้องของข้อมูลอ้างอิงด้วยอายุการใช้งาน (Validating References with Lifetimes)” ของบทที่ 10

การอัปเดตข้อมูลในแฮชแมป (Updating a Hash Map)

ถึงแม้ว่าคู่คีย์และค่าจะสามารถเพิ่มจำนวนขึ้นได้เรื่อย ๆ แต่คีย์ที่มีความเป็นเอกลักษณ์ (unique key) แต่ละตัวจะมีค่าที่เชื่อมโยงอยู่ด้วยได้เพียงครั้งละหนึ่งค่าเท่านั้น (แต่ในทางกลับกันไม่ใช่ เช่น ทั้งทีม Blue และทีม Yellow ต่างก็มีค่าคะแนนเป็น 10 เก็บไว้ในแฮชแมป scores ได้พร้อมกัน)

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

การเขียนทับค่าเดิม (Overwriting a Value)

หากเราใส่คีย์และค่าลงไปในแฮชแมป จากนั้นใส่คีย์เดิมซ้ำเข้าไปอีกครั้งด้วยค่าที่แตกต่างออกไป ค่าที่ผูกไว้กับคีย์ดังกล่าวจะถูกเปลี่ยนแทนที่ทันที แม้ว่าโค้ดในตัวอย่างที่ 8-23 จะมีการเรียกใช้งาน insert สองรอบ แต่แฮชแมปจะเก็บคู่คีย์-ค่าไว้เพียงคู่เดียวเท่านั้น เนื่องจากเราสั่งใส่ค่าสำหรับคีย์ของทีม Blue ทั้งสองรอบ

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}

โค้ดนี้จะแสดงผลลัพธ์เป็น {"Blue": 25} ค่าเดิมที่เป็น 10 ได้ถูกเขียนทับไปแล้ว

การเพิ่มคู่คีย์และค่าเฉพาะเมื่อยังไม่มีคีย์นั้นอยู่ (Adding a Key and Value Only If a Key Isn’t Present)

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

แฮชแมปมี API พิเศษสำหรับการทำงานนี้ซึ่งมีชื่อว่า entry โดยจะรับพารามิเตอร์เป็นคีย์ที่คุณต้องการตรวจสอบ ค่าส่งกลับคืนของเมธอด entry นี้คือ enum ที่ชื่อว่า Entry ซึ่งเป็นตัวแทนของค่าที่อาจจะมีหรือไม่มีอยู่จริง สมมติว่าเราต้องการตรวจสอบว่าคีย์ของทีม Yellow มีคะแนนผูกไว้แล้วหรือไม่ หากยังไม่มี เราต้องการใส่ค่า 50 เข้าไป และทำแบบเดียวกันกับทีม Blue ด้วย การใช้ API entry จะทำให้เขียนโค้ดได้ดังตัวอย่างที่ 8-24

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}

เมธอด or_insert บน Entry ถูกกำหนดมาให้ทำหน้าที่คืนค่าข้อมูลอ้างอิงแบบแก้ไขได้ (mutable reference) ไปยังค่าของคีย์ Entry นั้น ๆ หากมีอยู่จริง และถ้าไม่มี คีย์ดังกล่าวก็จะถูกกำหนดค่าใหม่ตามอาร์กิวเมนต์ที่ส่งไปและคืนข้อมูลอ้างอิงแบบแก้ไขได้ของค่านั้นกลับมา เทคนิคนี้มีความเรียบร้อยสวยงามกว่าการเขียนตรรกะเงื่อนไขขึ้นมาเอง อีกทั้งยังทำงานร่วมกับระบบตรวจสอบสิทธิ์การยืม (borrow checker) ได้อย่างราบรื่นมาก

เมื่อสั่งรันโค้ดในตัวอย่างที่ 8-24 จะได้ผลลัพธ์แสดงออกมาเป็น {"Yellow": 50, "Blue": 10} การเรียกใช้เมธอด entry รอบแรกจะใส่คีย์ของทีม Yellow พร้อมค่า 50 เนื่องจากเดิมทียังไม่มีค่าของทีม Yellow อยู่ในแฮชแมป ส่วนการเรียกใช้รอบที่สองจะไม่ทำให้แฮชแมปเปลี่ยนแปลงใด ๆ เนื่องจากทีม Blue มีค่า 10 อยู่แล้วก่อนหน้านี้

การอัปเดตค่าโดยอิงจากค่าเดิม (Updating a Value Based on the Old Value)

การใช้งานแฮชแมปยอดนิยมอีกกรณีหนึ่งคือการค้นหาค่าของคีย์เดิมแล้วทำการอัปเดตค่านั้นโดยอ้างอิงจากค่าเดิม ตัวอย่างเช่น ตัวอย่างที่ 8-25 แสดงโค้ดสำหรับนับจำนวนครั้งที่คำแต่ละคำปรากฏขึ้นในข้อความ เราจะใช้แฮชแมปโดยมีคำศัพท์เป็นคีย์และเพิ่มตัวเลขค่าเพื่อนับว่าคำดังกล่าวปรากฏขึ้นมาแล้วกี่ครั้ง หากเป็นการพบคำศัพท์นั้นเป็นครั้งแรก เราจะตั้งต้นใส่ค่าเริ่มต้นเป็น 0 ก่อน

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}

โค้ดนี้จะแสดงผลลัพธ์เป็น {"world": 2, "hello": 1, "wonderful": 1} คุณอาจจะเห็นคู่คีย์-ค่าคู่เดียวกันนี้แสดงเรียงลำดับสลับที่กันได้: โปรดจำกฎจากหัวข้อ “การเข้าถึงค่าต่าง ๆ ในแฮชแมป (Accessing Values in a Hash Map)” ที่ระบุว่าการวนลูปผ่านแฮชแมปจะสุ่มผลลัพธ์แบบไม่เรียงลำดับแน่นอน

เมธอด split_whitespace จะคืนค่าตัววนลูป (iterator) ของสตริงย่อย (subslices) ที่คั่นด้วยช่องว่างของข้อความในตัวแปร text เมธอด or_insert จะคืนค่าข้อมูลอ้างอิงแบบแก้ไขได้ (&mut V) ไปยังค่าที่เก็บไว้สำหรับคีย์ที่กำหนด ซึ่งที่นี่เราจะจัดเก็บข้อมูลอ้างอิงแบบแก้ไขได้นี้ไว้ในตัวแปร count ดังนั้นในการแก้ไขค่าดังกล่าว เราต้องทำการเข้าถึงค่าจริงจากข้อมูลอ้างอิง (dereference) ของตัวแปร count เสียก่อนด้วยการใส่เครื่องหมายดอกจัน (*) โดยข้อมูลอ้างอิงแบบแก้ไขได้จะหลุดออกนอกขอบเขตการใช้งาน (out of scope) เมื่อสิ้นสุดแต่ละรอบของลูป for ดังนั้นการเปลี่ยนแปลงทั้งหมดนี้จึงมีความปลอดภัยและเป็นไปตามกฎระบบตรวจสอบสิทธิ์การยืม

ฟังก์ชันแฮช (Hashing Functions)

ตามพฤติกรรมเริ่มต้น ตัวแปรแฮชแมป HashMap จะใช้ฟังก์ชันแฮชที่มีชื่อว่า SipHash ซึ่งมีประสิทธิภาพในการป้องกันการโจมตีแบบปฏิเสธการให้บริการ (Denial-of-Service หรือ DoS) ที่พุ่งเป้ามายังตารางแฮช1 ซึ่งนี่อาจจะไม่ใช่รูทีนอัลกอริทึมแฮชที่เร็วที่สุดเท่าที่มีอยู่ แต่การแลกประสิทธิภาพที่ลดลงไปเพียงเล็กน้อยเพื่อให้ได้ความปลอดภัยที่ดียิ่งขึ้นนั้นถือว่าคุ้มค่า หากคุณลองวิเคราะห์ประสิทธิภาพโค้ด (profile) แล้วพบว่าฟังก์ชันแฮชเริ่มต้นนี้ทำงานช้าเกินไปสำหรับการใช้งานของคุณ คุณสามารถเปลี่ยนไปใช้ฟังก์ชันแฮชตัวอื่นได้โดยระบุตัวคำนวณแฮช (hasher) แบบอื่น ซึ่ง hasher คือประเภทข้อมูลใด ๆ ที่สืบทอดทริต BuildHasher เราจะพูดคุยเรื่องทริตและการนำไปใช้งานต่อใน บทที่ 10 ทั้งนี้คุณไม่จำเป็นต้องสร้าง hasher ของคุณเองใหม่ทั้งหมดตั้งแต่เริ่มต้น เนื่องจากบนเว็บไซต์ crates.io มีไลบรารีที่นักพัฒนาภาษา Rust คนอื่นแบ่งปันไว้ซึ่งมี hasher ที่ใช้อัลกอริทึมแฮชยอดนิยมแบบต่าง ๆ ให้พร้อมใช้งาน

สรุป (Summary)

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

  1. กำหนดรายการตัวเลขจำนวนเต็มมาให้ ให้ใช้เวกเตอร์เพื่อค้นหาและส่งค่ามัธยฐาน (median - ค่าที่อยู่ตำแหน่งตรงกลางเมื่อทำการเรียงลำดับแล้ว) และฐานนิยม (mode - ค่าที่ปรากฏขึ้นบ่อยครั้งที่สุด โดยการใช้แฮชแมปจะช่วยในโจทย์ข้อนี้นะครับ) ของรายการข้อมูลนั้น
  2. แปลงสตริงให้กลายเป็น Pig Latin โดยตัวพยัญชนะตัวแรกของคำจะถูกย้ายไปไว้ที่ท้ายคำแล้วตามด้วยคำว่า ay เช่น คำว่า first จะเปลี่ยนเป็น irst-fay ส่วนคำศัพท์ใดที่เริ่มต้นด้วยสระจะนำคำว่า hay ไปเติมไว้ที่ท้ายคำแทน (เช่น apple จะเปลี่ยนเป็น apple-hay) ทั้งนี้อย่าลืมคำนึงถึงรายละเอียดเกี่ยวกับการเข้ารหัส UTF-8 ด้วยนะครับ!
  3. ใช้แฮชแมปและเวกเตอร์เพื่อสร้างอินเทอร์เฟซแบบข้อความ (text interface) ที่อนุญาตให้ผู้ใช้เพิ่มรายชื่อพนักงานเข้าสู่แผนกต่าง ๆ ของบริษัท เช่น “Add Sally to Engineering” หรือ “Add Amir to Sales” จากนั้นให้ระบบดึงรายชื่อพนักงานทั้งหมดของแผนกนั้น ๆ หรือรายชื่อทุกคนในบริษัทแยกตามแผนกออกมาแสดงผลโดยเรียงลำดับตามตัวอักษร

ข้อมูลเอกสารอ้างอิง API ของไลบรารีมาตรฐานมีคำอธิบายเมธอดต่าง ๆ ของเวกเตอร์ สตริง และแฮชแมป ซึ่งจะมีประโยชน์อย่างมากในการช่วยทำแบบฝึกหัดเหล่านี้!

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


  1. https://en.wikipedia.org/wiki/SipHash

การจัดการข้อผิดพลาด (Error Handling)

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

Rust แบ่งประเภทของข้อผิดพลาดออกเป็นสองกลุ่มใหญ่ ๆ ได้แก่ ข้อผิดพลาดที่สามารถแก้ไขได้ (recoverable errors) และข้อผิดพลาดที่ไม่สามารถแก้ไขได้ (unrecoverable errors) สำหรับ ข้อผิดพลาดที่สามารถแก้ไขได้ เช่น ข้อผิดพลาดประเภท ไม่พบไฟล์ (file not found) ส่วนใหญ่แล้วเราก็แค่อยากแจ้งปัญหาให้ผู้ใช้ทราบและทดลองทำคำสั่งนั้นใหม่อีกครั้ง ส่วน ข้อผิดพลาดที่ไม่สามารถแก้ไขได้ มักจะเป็นสัญญาณบ่งชี้ถึงบั๊กในระบบเสมอ เช่น พยายามเข้าถึงตำแหน่งข้อมูลที่อยู่นอกขอบเขตของอาร์เรย์ ดังนั้นในกรณีเช่นนี้เราจึงต้องการหยุดการทำงานของโปรแกรมทันที

ภาษาโปรแกรมส่วนใหญ่มักไม่ได้แยกแยะความแตกต่างระหว่างข้อผิดพลาดสองประเภทนี้ และจัดการข้อผิดพลาดทั้งสองแบบในลักษณะเดียวกันผ่านกลไกต่าง ๆ เช่น ข้อยกเว้น (exceptions) แต่ทว่า Rust ไม่มีระบบ exceptions แต่จะมีประเภทข้อมูล Result<T, E> สำหรับจัดการข้อผิดพลาดที่แก้ไขได้ และใช้มาโคร panic! เพื่อหยุดการทำงานของโปรแกรมทันทีเมื่อเจอกับข้อผิดพลาดที่แก้ไขไม่ได้ บทนี้จะอธิบายเริ่มจากการเรียกใช้งาน panic! ก่อน จากนั้นจึงพูดคุยเกี่ยวกับการส่งคืนค่าแบบ Result<T, E> นอกจากนี้ เราจะมาเจาะลึกประเด็นที่ต้องพิจารณาเมื่อต้องตัดสินใจเลือกว่าควรจะพยายามกู้คืนระบบจากข้อผิดพลาด หรือหยุดการทำงานของโปรแกรมไปเลย

Unrecoverable Errors with panic!

ข้อผิดพลาดที่ไม่สามารถแก้ไขได้ด้วย panic! (Unrecoverable Errors with panic!)

บางครั้งสิ่งแย่ ๆ ก็เกิดขึ้นในโค้ดของคุณ และคุณไม่สามารถทำอะไรกับมันได้เลย ในกรณีเหล่านี้ ภาษา Rust มีมาโคร panic! ไว้รองรับ ในทางปฏิบัติ มีสองวิธีที่ทำให้เกิดการ panic ได้แก่: การกระทำคำสั่งที่ส่งผลให้โค้ดของเราเกิด panic (เช่น การเข้าถึงสมาชิกอาร์เรย์นอกขอบเขต) หรือการเรียกใช้มาโคร panic! โดยตรง ทั้งสองวิธีนี้ต่างก็ก่อให้เกิดการ panic ในโปรแกรมของเรา ตามค่าเริ่มต้น กระบวนการ panic เหล่านี้จะแสดงข้อความแจ้งความล้มเหลว (failure message), ทำการย้อนกลับโครงสร้างสแตก (unwind), กวาดล้างข้อมูลในสแตก และปิดการทำงานของโปรแกรมลง นอกจากนี้ คุณยังสามารถกำหนดค่าตัวแปรสภาพแวดล้อม (environment variable) เพื่อสั่งให้ Rust แสดงลำดับสแตกรันไทม์ (call stack) เมื่อเกิดการ panic เพื่อช่วยให้ค้นหาต้นตอของการ panic ได้ง่ายขึ้น

การย้อนกลับสแตกหรือการยกเลิกโปรแกรมทันทีเมื่อเกิด Panic (Unwinding the Stack or Aborting in Response to a Panic)

ตามค่าเริ่มต้น เมื่อเกิด panic โปรแกรมจะเริ่มต้นกระบวนการ ย้อนกลับสแตก (unwinding) ซึ่งหมายความว่า Rust จะไล่ย้อนลำดับสแตกขึ้นไปและทำการกวาดล้างข้อมูลต่าง ๆ ของฟังก์ชันแต่ละตัวที่พบเจอ ทว่าการย้อนรอยกลับไปและเคลียร์ข้อมูลนั้นเป็นงานที่ต้องใช้พลังงานประมวลผลสูง ด้วยเหตุนี้ Rust จึงเปิดโอกาสให้คุณมีทางเลือกอื่น นั่นคือการ ยกเลิกการทำงานทันที (aborting) ซึ่งจะทำให้โปรแกรมยุติลงโดยไม่มีการเคลียร์ข้อมูลใด ๆ เลย

หน่วยความจำที่โปรแกรมเคยใช้งานอยู่จะถูกเคลียร์สิทธิ์โดยระบบปฏิบัติการ (OS) แทน หากโปรเจกต์ของคุณจำเป็นต้องบีบอัดขนาดไฟล์ไบนารีที่ได้ให้มีขนาดเล็กที่สุดเท่าที่จะทำได้ คุณสามารถสลับพฤติกรรมจาก unwinding มาเป็น aborting ทันทีเมื่อเกิด panic ได้โดยการเพิ่มบรรทัด panic = 'abort' ลงในส่วน [profile] ที่ต้องการในไฟล์ Cargo.toml ตัวอย่างเช่น หากคุณต้องการยกเลิกการทำงานทันทีเมื่อเกิด panic ในโหมด release ให้เพิ่มส่วนนี้เข้าไป:

[profile.release]
panic = 'abort'

เรามาลองเรียกใช้งาน panic! ในโปรแกรมง่าย ๆ กันครับ:

fn main() {
    panic!("crash and burn");
}

เมื่อคุณรันโปรแกรมนี้ คุณจะเจอกับผลลัพธ์หน้าตาประมาณนี้:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' (6018279) panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

การเรียกใช้คำสั่ง panic! ส่งผลให้เกิดข้อความแสดงข้อผิดพลาดที่ปรากฏในสองบรรทัดสุดท้าย บรรทัดแรกจะแสดงข้อความ panic ของเรา และตำแหน่งในซอร์สโค้ดที่เกิดการ panic ขึ้น: ค่า src/main.rs:2:5 บ่งบอกว่ามันคือบรรทัดที่สอง ตัวอักษรตัวที่ห้า ในไฟล์ src/main.rs ของเรานั่นเอง

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

เราสามารถใช้ประโยชน์จากการย้อนรอยการเรียกฟังก์ชัน (backtrace) ที่ส่งผลให้เกิดการ panic! เพื่อสืบหาบรรทัดในโค้ดของเราที่เป็นตัวก่อปัญหา เพื่อทำความเข้าใจวิธีการใช้งาน panic! backtrace ลองมาดูตัวอย่างถัดไปเพื่อศึกษาว่าจะเกิดอะไรขึ้นหากการสั่ง panic! ถูกเรียกใช้งานจากโค้ดไลบรารีอื่นเนื่องจากบั๊กในโปรแกรมของเรา แทนที่จะเป็นการเรียกมาโครตรง ๆ จากโค้ดเราเอง ตัวอย่างที่ 9-1 มีโค้ดที่พยายามเข้าถึงค่าดัชนีในเวกเตอร์ที่เกินช่วงดัชนีที่ใช้งานได้จริง

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

ในจุดนี้ เรากำลังพยายามเข้าถึงสมาชิกตัวที่ 100 ของเวกเตอร์ (ซึ่งอยู่ที่ดัชนี 99 เนื่องจากดัชนีเริ่มต้นด้วยศูนย์) แต่ทว่าตัวเวกเตอร์มีสมาชิกอยู่เพียงแค่สามตัวเท่านั้น ในสถานการณ์นี้ Rust จะเกิดการ panic ขึ้น การใช้งาน [] คาดหวังว่าจะคืนค่าสมาชิกตัวหนึ่งกลับมา แต่หากคุณส่งค่าดัชนีที่ไม่ถูกต้องไป ก็จะไม่มีสมาชิกตัวใดที่ Rust สามารถส่งกลับคืนมาได้อย่างถูกต้องสมบูรณ์

ในภาษา C การพยายามอ่านข้อมูลที่อยู่นอกขอบเขตของโครงสร้างข้อมูลจะถือเป็นพฤติกรรมที่ไม่ได้กำหนดไว้ (undefined behavior) คุณอาจจะได้รับค่าใด ๆ ก็ตามที่อยู่ในตำแหน่งหน่วยความจำซึ่งตรงกับตำแหน่งของสมาชิกนั้นในโครงสร้างข้อมูล ถึงแม้ว่าหน่วยความจำจุดนั้นจะไม่ได้เป็นของโครงสร้างข้อมูลดังกล่าวก็ตาม ปัญหานี้เรียกว่า การอ่านข้อมูลล้นบัฟเฟอร์ (buffer overread) และสามารถนำไปสู่ช่องโหว่ด้านความปลอดภัยได้ หากผู้ไม่ประสงค์ดีสามารถควบคุมค่าดัชนีในลักษณะที่ทำให้อ่านข้อมูลที่พวกเขาไม่มีสิทธิ์เข้าถึงซึ่งเก็บอยู่ต่อจากโครงสร้างข้อมูลนั้นได้

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

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' (6017887) panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

ข้อผิดพลาดนี้ชี้ไปที่บรรทัดที่ 4 ในไฟล์ main.rs ของเรา ซึ่งเป็นจุดที่เราพยายามเข้าถึงดัชนี 99 ของเวกเตอร์ในตัวแปร v

บรรทัด note: แจ้งให้เราทราบว่าเราสามารถกำหนดค่าตัวแปรสภาพแวดล้อม RUST_BACKTRACE เพื่อรับรายงาน backtrace เพื่อดูลำดับขั้นตอนที่ส่งผลให้เกิดข้อผิดพลาดขึ้นอย่างละเอียดได้ โดย backtrace คือรายการของฟังก์ชันทั้งหมดที่มีการเรียกใช้งานเพื่อดำเนินขั้นตอนมาจนถึงจุดเกิดเหตุนี้ การรายงาน backtrace ในภาษา Rust ทำงานคล้ายกับภาษาอื่น ๆ คือ: กุญแจสำคัญในการอ่าน backtrace คือการไล่อ่านจากบนสุดลงมาเรื่อย ๆ จนกระทั่งเจอชื่อไฟล์ที่คุณเขียนขึ้นมา นั่นคือจุดที่ข้อผิดพลาดนั้นมีจุดเริ่มต้นขึ้น บรรทัดที่อยู่เหนือจุดนั้นคือโค้ดภายนอกที่โค้ดของคุณทำการเรียกใช้งาน ส่วนบรรทัดที่อยู่ต่ำกว่าคือโค้ดที่เป็นตัวเรียกใช้งานโค้ดของคุณอีกที บรรทัดก่อนหน้าและหลังจากนั้นอาจรวมถึงโค้ดแกนหลักของ Rust (core Rust), โค้ดไลบรารีมาตรฐาน (standard library) หรือเครตส์ (crates) ต่าง ๆ ที่คุณใช้งานอยู่ ลองรับรายงาน backtrace โดยกำหนดค่าตัวแปรสภาพแวดล้อม RUST_BACKTRACE เป็นค่าใดก็ได้ยกเว้น 0 ตัวอย่างที่ 9-2 แสดงผลลัพธ์ที่คล้ายกับที่คุณจะพบเจอ

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

นั่นเป็นผลลัพธ์ที่ยาวมาก! ผลลัพธ์จริงที่คุณเจออาจแตกต่างออกไปเล็กน้อยขึ้นอยู่กับระบบปฏิบัติการและเวอร์ชันภาษา Rust ของคุณ เพื่อที่จะรับรายงาน backtrace ที่มีรายละเอียดข้อมูลเหล่านี้ได้ คุณจำเป็นต้องเปิดใช้งานการเก็บสัญลักษณ์ดีบั๊ก (debug symbols) ซึ่งสัญลักษณ์ดีบั๊กนี้จะถูกเปิดใช้งานไว้เป็นปกติอยู่แล้วเมื่อเราใช้คำสั่ง cargo build หรือ cargo run โดยไม่มีแฟล็ก --release ดังเช่นที่เราสั่งไปที่นี่

ในผลลัพธ์จากตัวอย่างที่ 9-2 บรรทัดที่ 6 ของ backtrace ชี้ตรงไปยังบรรทัดในโปรเจกต์ของเราที่เป็นต้นเหตุของปัญหา: บรรทัดที่ 4 ของไฟล์ src/main.rs หากเราไม่ต้องการให้โปรแกรมของเราเกิด panic เราควรเริ่มต้นกระบวนการหาสาเหตุที่ตำแหน่งข้อมูลบรรทัดแรกที่ระบุชื่อไฟล์ที่เราเป็นคนเขียนขึ้นเอง ในตัวอย่างที่ 9-1 ที่เราตั้งใจเขียนโค้ดเพื่อให้เกิด panic ขึ้นมา แนวทางแก้ไขไม่ให้เกิด panic คือการระมัดระวังไม่เรียกขอข้อมูลของสมาชิกนอกช่วงดัชนีของเวกเตอร์ เมื่อโค้ดของคุณเกิด panic ขึ้นอีกในอนาคต คุณจำเป็นต้องวิเคราะห์ว่าโค้ดนั้นกำลังกระทำคำสั่งใด ด้วยค่าข้อมูลใดที่ส่งผลให้เกิดการ panic และโค้ดควรทำสิ่งใดแทนที่คำสั่งเดิม

เราจะย้อนกลับมาพูดถึงเรื่อง panic! และการพิจารณาว่าเมื่อใดที่เราควรหรือควรหลีกเลี่ยงการใช้ panic! เพื่อรับมือกับข้อผิดพลาดต่าง ๆ ในหัวข้อ “ควรจะ panic! หรือไม่ควรดี? (To panic! or Not to panic! ในช่วงท้ายของบทนี้ ต่อไปเราจะมาดูกันว่ากระบวนการกู้คืนการทำงานจากข้อผิดพลาดโดยใช้ Result ทำอย่างไรได้บ้าง

Recoverable Errors with Result

ข้อผิดพลาดที่สามารถแก้ไขได้ด้วย Result (Recoverable Errors with Result)

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

ย้อนความจากหัวข้อ “การจัดการความล้มเหลวที่เป็นไปได้ด้วย Result ในบทที่ 2 ว่าตัว enum Result นั้นถูกกำหนดขึ้นมาให้มีค่าตัวแปรย่อยสองตัว ได้แก่ Ok และ Err ดังนี้:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

ในจุดนี้ T และ E คือพารามิเตอร์ประเภทข้อมูลแบบเจเนอริก (generic type parameters) ซึ่งเราจะอธิบายเรื่องเจเนอริกในรายละเอียดเพิ่มเติมในบทที่ 10 สิ่งที่คุณจำเป็นต้องทราบในตอนนี้คือ T จะเป็นตัวแทนของประเภทข้อมูลที่จะส่งกลับคืนมาในกรณีที่คำสั่งทำสำเร็จภายใต้ตัวแปรย่อย Ok และ E จะเป็นตัวแทนของประเภทข้อผิดพลาดที่จะส่งกลับคืนมาในกรณีที่ล้มเหลวภายใต้ตัวแปรย่อย Err เนื่องจาก Result มีพารามิเตอร์ชนิดเจเนอริกเหล่านี้ เราจึงสามารถใช้ประเภทข้อมูล Result และเมธอดต่าง ๆ ที่ถูกสร้างขึ้นมาคู่กับมันได้ในสถานการณ์ที่หลากหลาย ซึ่งค่าของผลสำเร็จและค่าของข้อผิดพลาดที่เราต้องการส่งกลับคืนอาจจะแตกต่างกันไป

เรามาลองเรียกฟังก์ชันที่ส่งคืนค่าเป็น Result กันครับ เนื่องจากฟังก์ชันนี้อาจมีความล้มเหลวเกิดขึ้นได้ ในตัวอย่างที่ 9-3 เราจะพยายามเปิดไฟล์ไฟล์หนึ่ง

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

ประเภทข้อมูลที่ส่งกลับคืนจาก File::open คือ Result<T, E> โดยพารามิเตอร์เจเนอริก T จะถูกกำหนดในกระบวนการทำงานของ File::open ให้ใช้ประเภทข้อมูลของผลลัพธ์ที่สำเร็จคือ std::fs::File ซึ่งเป็นข้อมูลอ้างอิงจัดการไฟล์ (file handle) และประเภทข้อมูล E ที่ใช้สำหรับข้อผิดพลาดคือ std::io::Error ประเภทข้อมูลส่งคืนนี้มีความหมายว่า การเรียกใช้งาน File::open อาจสำเร็จและส่งกลับ file handle ที่เราสามารถอ่านหรือเขียนข้อมูลได้ หรือฟังก์ชันนี้อาจทำงานล้มเหลวได้เช่นกัน ตัวอย่างเช่น ไม่มีไฟล์นั้นอยู่ หรือเราไม่มีสิทธิ์เข้าใช้งานไฟล์นั้น ฟังก์ชัน File::open จำเป็นต้องมีหนทางบอกให้เราทราบว่าทำงานสำเร็จหรือล้มเหลว และในขณะเดียวกันก็ต้องมอบ file handle หรือข้อมูลความล้มเหลวมาให้เราด้วย ข้อมูลเหล่านี้คือสิ่งที่ enum Result นำเสนออย่างตรงไปตรงมา

ในกรณีที่ File::open ทำงานสำเร็จ ค่าในตัวแปร greeting_file_result จะเป็นออบเจกต์ Ok ที่มีข้อมูล file handle อยู่ภายใน ในกรณีที่ทำงานล้มเหลว ค่าในตัวแปร greeting_file_result จะเป็นออบเจกต์ Err ที่มีข้อมูลรายละเอียดเพิ่มเติมเกี่ยวกับข้อผิดพลาดที่เกิดขึ้น

เราจำเป็นต้องต่อยอดโค้ดจากตัวอย่างที่ 9-3 เพื่อดำเนินการคำสั่งต่าง ๆ กันไปตามข้อมูลค่าที่ File::open ส่งคืนมา ตัวอย่างที่ 9-4 แสดงวิธีการหนึ่งในการจัดการกับ Result โดยใช้เครื่องมือพื้นฐาน นั่นคือนิพจน์ match ที่เราได้เรียนรู้กันไปในบทที่ 6

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

สังเกตว่า เช่นเดียวกับ enum Option ตัวแปร enum Result และตัวแปรย่อยต่าง ๆ ของมันจะถูกนำเข้าสู่ขอบเขตการใช้งานโดยอัตโนมัติ (prelude) แล้ว ดังนั้นเราจึงไม่จำเป็นต้องระบุคีย์เวิร์ด Result:: นำหน้า Ok และ Err ในแต่ละกิ่งคำสั่งของ match

เมื่อผลลัพธ์เป็น Ok โค้ดนี้จะดึงค่า file ที่อยู่ข้างในตัวแปรย่อย Ok ออกมา และเราจะนำข้อมูลค่า file handle นั้นไปกำหนดลงในตัวแปร greeting_file หลังจากโครงสร้างคำสั่ง match นี้สิ้นสุดลง เราจะนำ file handle ดังกล่าวไปประมวลผลคำสั่งอ่านหรือเขียนข้อมูลต่อได้

ส่วนอีกกิ่งคำสั่งหนึ่งของ match จะจัดการในกรณีที่เราได้ค่าเป็น Err จากคำสั่ง File::open ในตัวอย่างนี้เราเลือกที่จะเรียกใช้งานมาโคร panic! หากไม่มีไฟล์ชื่อ hello.txt ในไดเรกทอรีปัจจุบันของเราและเราสั่งรันโค้ดนี้ เราจะพบผลรายงานจากมาโคร panic! ดังต่อไปนี้:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' (6018048) panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

และเช่นเคย ผลลัพธ์ที่แสดงจะบอกรายละเอียดอย่างชัดเจนว่ามีอะไรผิดพลาดขึ้นมา

การตรวจสอบเงื่อนไขข้อผิดพลาดประเภทต่าง ๆ (Matching on Different Errors)

โค้ดในตัวอย่างที่ 9-4 จะทำคำสั่ง panic! เสมอ ไม่ว่าฟังก์ชัน File::open จะล้มเหลวด้วยสาเหตุใดก็ตาม ทว่าเราต้องการดำเนินการคำสั่งแตกต่างกันไปตามเหตุผลของความล้มเหลวนั้น หาก File::open ล้มเหลวเนื่องจากไม่มีไฟล์นั้นอยู่จริง เราต้องการสร้างไฟล์ใหม่ขึ้นมาแล้วส่งคืนค่า file handle ของไฟล์ใหม่นั้น แต่หาก File::open ล้มเหลวจากสาเหตุอื่น ๆ เช่น เราไม่มีสิทธิ์เข้าเปิดไฟล์นั้น เรายังคงต้องการให้โค้ดรันคำสั่ง panic! เช่นเดิมเหมือนในตัวอย่างที่ 9-4 สำหรับกรณีนี้เราจะเพิ่มนิพจน์ match ซ้อนอยู่ด้านใน ดังที่แสดงในตัวอย่างที่ 9-5

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

ประเภทข้อมูลที่ File::open ส่งคืนมาภายในตัวแปรย่อย Err คือ io::Error ซึ่งเป็นโครงสร้างข้อมูล (struct) ที่เตรียมไว้โดยไลบรารีมาตรฐาน struct นี้มีเมธอดชื่อว่า kind ซึ่งเราสามารถเรียกใช้เพื่อนำมาตรวจสอบค่า io::ErrorKind ได้ ตัวแปร enum io::ErrorKind นี้จะถูกเตรียมไว้โดยไลบรารีมาตรฐานและมีตัวแปรย่อย ๆ แสดงแทนลักษณะข้อผิดพลาดต่าง ๆ ที่อาจเกิดขึ้นจากกระบวนการทำงานระดับ io โดยตัวแปรย่อยที่เราต้องการนำมาใช้คือ ErrorKind::NotFound ซึ่งบ่งชี้ว่าไฟล์ที่เราพยายามเปิดยังไม่มีอยู่จริง ดังนั้นเราจึงทำการ match กับตัวแปร greeting_file_result แต่ก็ใส่เงื่อนไข match ซ้อนอยู่ข้างในกับค่าของ error.kind() ด้วย

เงื่อนไขที่เราต้องการตรวจสอบใน match ด้านในคือ ค่าที่ส่งกลับมาจาก error.kind() นั้นเป็นตัวแปรย่อย NotFound ของ enum ErrorKind หรือไม่ หากใช่ เราจะพยายามสร้างไฟล์ขึ้นมาโดยใช้คำสั่ง File::create แอยางไรก็ตาม เนื่องจากคำสั่ง File::create ก็อาจจะทำงานล้มเหลวได้เช่นกัน เราจึงจำเป็นต้องใส่กิ่งคำสั่งที่สองไว้ในโครงสร้าง match ด้านในนี้ด้วยเพื่อพิมพ์ข้อความแจ้งความล้มเหลวกรณีสร้างไฟล์ไม่ได้ ส่วนกิ่งคำสั่งที่สองของโครงสร้าง match ด้านนอกยังคงมีโครงสร้างเหมือนเดิม เพื่อให้โปรแกรมเกิด panic เมื่อมีข้อผิดพลาดประเภทอื่นนอกเหนือจากกรณีหาไฟล์ไม่เจอ

ทางเลือกอื่นนอกเหนือจากการใช้ match กับ Result<T, E> (Alternatives to Using match with Result<T, E>)

นั่นมีโครงสร้างคำสั่ง match อยู่เยอะมาก! แม้นิพจน์ match จะมีประโยชน์อย่างยิ่ง แต่มันก็ยังถือเป็นเครื่องมือระดับพื้นฐาน (primitive) ในบทที่ 13 คุณจะได้เรียนรู้เกี่ยวกับโคลเชอร์ (closures) ซึ่งถูกนำไปใช้งานร่วมกับเมธอดจำนวนมากที่กำหนดไว้สำหรับ Result<T, E> เมธอดเหล่านี้สามารถเขียนได้กระชับมากขึ้นกว่าการใช้ match เมื่อต้องรับมือกับค่าแบบ Result<T, E> ในโค้ดของคุณ

ตัวอย่างเช่น นี่คืออีกวิธีหนึ่งในการเขียนตรรกะแบบเดียวกับตัวอย่างที่ 9-5 โดยครั้งนี้เปลี่ยนมาใช้งานโคลเชอร์ร่วมกับเมธอด unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

ถึงแม้ว่าโค้ดนี้จะมีผลลัพธ์การทำงานเหมือนกับตัวอย่างที่ 9-5 ทุกประการ แต่มันไม่มีการใช้นิพจน์ match เลย และยังดูสะอาดสะอ้านอ่านง่ายกว่า คุณสามารถย้อนกลับมาดูตัวอย่างนี้อีกครั้งหลังจากอ่านบทที่ 13 จบแล้ว และลองค้นหาข้อมูลเมธอด unwrap_or_else ในเอกสารไลบรารีมาตรฐานดูครับ มีเมธอดทำนองนี้อีกหลายตัวที่จะช่วยเคลียร์นิพจน์ match ที่ซ้อนกันหลาย ๆ ชั้นให้เรียบร้อยขึ้นเมื่อต้องจัดการกับข้อผิดพลาด

ทางลัดในการเรียก Panic เมื่อเกิดข้อผิดพลาด (Shortcuts for Panic on Error)

การใช้งานนิพจน์ match ทำงานได้ดีพอสมควร แต่อาจจะเวิ่นเว้อไปนิดและไม่ได้สื่อสารเจตนาในการเขียนโค้ดได้ดีเสมอไป ประเภทข้อมูล Result<T, E> มีเมธอดผู้ช่วย (helper methods) จำนวนมากที่กำหนดไว้สำหรับทำงานเฉพาะทางต่าง ๆ เมธอด unwrap เป็นเมธอดทางลัดที่ถูกเขียนขึ้นให้มีโครงสร้างเหมือนกับคำสั่งนิพจน์ match ในตัวอย่างที่ 9-4 หากค่าของ Result เป็นตัวแปรย่อย Ok เมธอด unwrap จะส่งคืนค่าที่อยู่ภายใน Ok นั้นออกไป แต่หากค่าของ Result เป็นตัวแปรย่อย Err เมธอด unwrap จะเรียกใช้งานมาโคร panic! แทนเราทันที ตัวอย่างการทำงานจริงของ unwrap เป็นดังนี้:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

หากเราสั่งรันโค้ดนี้โดยไม่มีไฟล์ hello.txt อยู่จริง เราจะเจอกับข้อความแสดงข้อผิดพลาดจากการสั่ง panic! ที่เมธอด unwrap ดำเนินการให้:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

ทำนองเดียวกัน เมธอด expect ช่วยให้เรากำหนดข้อความผิดพลาดในการเรียก panic! ได้เองด้วย การเลือกใช้ expect แทนที่ unwrap พร้อมทั้งใส่ข้อความบอกรายละเอียดข้อผิดพลาดที่ดีจะช่วยสื่อเจตนาของโค้ดและทำให้การสืบหาจุดกำเนิดของ panic ง่ายขึ้น ไวยากรณ์ของ expect มีลักษณะดังนี้:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

เราใช้งาน expect ในลักษณะเดียวกับ unwrap คือ: ใช้คืนค่า file handle หรือสั่งรันมาโคร panic! ข้อความผิดพลาดที่ expect จะใช้ป้อนให้กับ panic! คืออาร์กิวเมนต์ที่เราส่งเข้าไปในวงเล็บของ expect แทนที่จะใช้ข้อความแจ้งเตือนตามค่าเริ่มต้นของ unwrap หน้าตาของผลลัพธ์จะเป็นดังนี้:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

สำหรับโค้ดที่เตรียมไว้ใช้งานจริง (production-quality code) นักพัฒนา Rust ส่วนใหญ่จะเลือกใช้งาน expect มากกว่า unwrap และพยายามให้ข้อมูลแวดล้อมเพิ่มเติมว่าเหตุใดขั้นตอนการทำงานดังกล่าวจึงคาดหวังว่าจะทำงานสำเร็จอยู่เสมอ ด้วยวิธีนี้ หากสมมติฐานของคุณผิดพลาดขึ้นมาในวันใดวันหนึ่ง คุณก็จะมีข้อมูลแวดล้อมที่เป็นประโยชน์ในการสืบค้นหาสาเหตุและแก้ไขบั๊กเพิ่มขึ้น

การส่งต่อข้อผิดพลาด (Propagating Errors)

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

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

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

ฟังก์ชันนี้สามารถเขียนให้กระชับขึ้นได้มาก แต่เราจะขอเริ่มต้นจากการเขียนแบบทำมือเพื่อสำรวจขั้นตอนของระบบจัดการข้อผิดพลาดกันก่อน จากนั้นในช่วงท้ายจึงจะนำเสนอวิธีที่สั้นลง ลองมาดูประเภทข้อมูลส่งคืนของฟังก์ชันกันก่อน: Result<String, io::Error> บ่งบอกว่าฟังก์ชันนี้จะส่งคืนค่าประเภท Result<T, E> โดยมีพารามิเตอร์เจเนอริก T เป็นประเภทรูปธรรม String และพารามิเตอร์เจเนอริก E เป็นประเภทรูปธรรม io::Error

หากฟังก์ชันนี้ทำงานสำเร็จโดยไม่พบอุปสรรคใด ๆ โค้ดที่เป็นตัวเรียกใช้งานฟังก์ชันจะได้รับค่า Ok ที่มีข้อมูล String อยู่ข้างใน ซึ่งก็คือ username ที่ฟังก์ชันนี้ได้อ่านมาจากไฟล์ แต่หากเกิดปัญหาใด ๆ โค้ดผู้เรียกจะได้รับค่า Err ที่เก็บออบเจกต์ io::Error ซึ่งมีรายละเอียดความล้มเหลวไว้ เหตุผลที่เราเลือกประเภท io::Error เป็นชนิดข้อมูลส่งคืนของฟังก์ชันนี้ เนื่องจากบังเอิญว่าข้อผิดพลาดที่อาจเกิดขึ้นจากสองคำสั่งหลักในฟังก์ชันนี้ต่างก็ส่งคืนประเภทข้อมูลเดียวกัน นั่นคือฟังก์ชัน File::open และเมธอด read_to_string

โครงสร้างการทำงานเริ่มด้วยการเรียกฟังก์ชัน File::open จากนั้นเราจะรับมือค่า Result ด้วยคำสั่ง match คล้ายกับในตัวอย่างที่ 9-4 หากสำเร็จ ตัวแปรจัดการไฟล์ในรูปแบบตัวแปรแพทเทิร์น file จะถูกกำหนดให้กับตัวแปรแก้ไขได้ username_file และฟังก์ชันจะทำงานต่อ แต่หากเกิดกรณี Err แทนที่จะเรียกใช้งาน panic! เราจะใช้คีย์เวิร์ด return เพื่อจบฟังก์ชันเร็วกว่าปกติทันที และส่งคืนค่าข้อผิดพลาดจากคำสั่ง File::open ซึ่งจัดเก็บอยู่ในตัวแปรแพทเทิร์น e กลับไปยังโค้ดผู้เรียกใช้งานเป็นผลข้อผิดพลาดของฟังก์ชันนี้

ดังนั้น เมื่อเราได้ file handle มาเก็บไว้ใน username_file เรียบร้อยแล้ว ฟังก์ชันจะสร้างตัวแปร username ประเภท String ขึ้นมาใหม่ และเรียกใช้เมธอด read_to_string กับ username_file เพื่ออ่านเนื้อหาจากไฟล์มาใส่ใน username เนื่องจากเมธอด read_to_string ก็อาจทำงานล้มเหลวได้เช่นกัน (แม้ว่า File::open จะสำเร็จก็ตาม) เราจึงจำเป็นต้องใส่ match อีกตัวเพื่อรับมือกับ Result นั้น: หาก read_to_string ทำงานสำเร็จ ฟังก์ชันของเราก็จะถือว่าทำหน้าที่เสร็จสมบูรณ์ และส่งค่าชื่อผู้ใช้งานที่อ่านได้จากไฟล์ซึ่งตอนนี้ถูกเก็บอยู่ใน username โดยนำไปห่อหุ้มไว้ในออบเจกต์ Ok กลับไป แต่ถ้าล้มเหลวเราจะส่งคืนค่าข้อผิดพลาดในลักษณะเดียวกับเงื่อนไข match แรก อย่างไรก็ตามเราไม่จำเป็นต้องใส่คำนำหน้า return ตรงกิ่งสุดท้ายนี้ เนื่องจากเป็นนิพจน์คำสั่งสุดท้ายของฟังก์ชัน

จากนั้นโค้ดระดับบนที่เป็นตัวเรียกใช้งานฟังก์ชันนี้จะคอยรับมือผลลัพธ์ที่จะเข้ามา ซึ่งอาจเป็น Ok ที่เก็บชื่อผู้ใช้ หรือ Err ที่เก็บข้อผิดพลาด io::Error การตัดสินใจว่าจะดำเนินการอย่างไรกับผลลัพธ์นี้จะขึ้นอยู่กับโค้ดระดับบน เช่น โค้ดผู้เรียกอาจสั่ง panic! เพื่อให้โปรแกรมหยุดทำงาน หรือเลือกใช้ชื่อผู้ใช้เริ่มต้น หรือพยายามไปดึงชื่อผู้ใช้จากแหล่งอื่นนอกจากไฟล์แทน เราไม่มีข้อมูลเพียงพอในแง่ของเจตนาการทำงานของโค้ดผู้เรียก ดังนั้นเราจึงใช้วิธีส่งต่อความสำเร็จหรือข้อผิดพลาดทั้งหมดขึ้นไปด้านบนเพื่อให้โค้ดผู้เรียกรับหน้าที่ไปจัดการต่อตามความเหมาะสม

รูปแบบของการส่งต่อข้อผิดพลาดนี้พบได้บ่อยมากในภาษา Rust จนทางผู้สร้างภาษาได้เตรียมตัวดำเนินการเครื่องหมายคำถาม ? เพื่ออำนวยความสะดวกในการเขียนโค้ด

ทางลัดการส่งต่อข้อผิดพลาดด้วยตัวดำเนินการ ? (The ? Operator Shortcut)

ตัวอย่างที่ 9-7 แสดงการเขียนฟังก์ชัน read_username_from_file ซึ่งให้ผลลัพธ์การทำงานแบบเดียวกับในตัวอย่างที่ 9-6 ทุกประการ แต่เวอร์ชันนี้จะหันมาใช้งานตัวดำเนินการ ? แทน

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

การเติมเครื่องหมาย ? ต่อท้ายผลลัพธ์ที่เป็น Result ถูกกำหนดขึ้นมาให้ทำงานในลักษณะเกือบจะเทียบเท่ากับการเขียนประโยคเงื่อนไข match เพื่อรับมือค่า Result ในตัวอย่างที่ 9-6 กล่าวคือ หากค่าของ Result มีสถานะเป็น Ok ค่าจริงที่ห่ออยู่ใน Ok นั้นจะถูกสกัดส่งคืนมาจากนิพจน์คำสั่งนี้เพื่อให้โปรแกรมประมวลผลคำสั่งบรรทัดต่อไป แต่หากค่าเป็น Err ข้อผิดพลาด Err นั้นจะถูกส่งคืนออกจากขอบเขตของฟังก์ชันทั้งหมดในทันที ราวกับว่าเราได้พิมพ์สั่ง return เพื่อส่งต่อข้อผิดพลาดนั้นขึ้นไปให้โค้ดระดับบนเป็นผู้รับหน้าที่จัดการ

อย่างไรก็ตาม มีข้อแตกต่างประการหนึ่งระหว่างการใช้นิพจน์คำสั่ง match จากตัวอย่างที่ 9-6 กับพฤติกรรมของตัวดำเนินการ ? คือ: ค่าข้อผิดพลาดที่ถูกดำเนินการด้วยเครื่องหมาย ? จะถูกนำไปประมวลผลผ่านฟังก์ชัน from ซึ่งเป็นฟังก์ชันที่นิยามไว้ในทริต From ของไลบรารีมาตรฐาน ซึ่งมีหน้าที่แปลงชนิดข้อมูลประเภทหนึ่งไปเป็นอีกประเภทหนึ่ง เมื่อตัวดำเนินการ ? เรียกใช้งานฟังก์ชัน from ชนิดข้อผิดพลาดที่เกิดจะถูกแปลงไปเป็นชนิดข้อผิดพลาดที่สอดคล้องกับรายละเอียดประเภทข้อมูลส่งคืน (return type) ของฟังก์ชันปัจจุบันโดยอัตโนมัติ ซึ่งฟีเจอร์นี้จะมีประโยชน์มากหากตัวฟังก์ชันหลักต้องการคืนค่าข้อมูลชนิดข้อผิดพลาดแบบเดียวเพื่อเป็นตัวแทนของความล้มเหลวทุกรูปแบบ แม้ว่าคำสั่งย่อยภายในอาจจะพบเจอข้อผิดพลาดที่ต่างประเภทกันก็ตาม

ตัวอย่างเช่น เราสามารถดัดแปลงฟังก์ชัน read_username_from_file ในตัวอย่างที่ 9-7 เพื่อให้ส่งคืนข้อผิดพลาดประเภทเฉพาะเจาะจงที่เราเขียนขึ้นเองชื่อว่า OurError ได้ หากเราเขียนนิยามการสืบทอด impl From<io::Error> for OurError เพื่อสร้างข้อมูล OurError จากออบเจกต์ io::Error แล้ว คำสั่งเรียกใช้งานเครื่องหมาย ? ภายในฟังก์ชัน read_username_from_file จะเรียกใช้เมธอด from เพื่อแปลงประเภทข้อผิดพลาดเหล่านั้นให้เป็นชนิดที่ต้องการโดยอัตโนมัติโดยที่ไม่ต้องใส่โค้ดจัดการแปลงข้อมูลเพิ่มเติมลงในตัวฟังก์ชันหลักเลย

เมื่อพิจารณาในบริบทของตัวอย่างที่ 9-7 การใช้เครื่องหมาย ? ต่อท้ายคำสั่งเรียก File::open จะทำหน้าที่ส่งค่าที่อยู่ใน Ok ไปกำหนดให้กับตัวแปร username_file หากล้มเหลวตัวดำเนินการ ? จะหยุดการทำงานของฟังก์ชันทันทีและส่งออบเจกต์ Err ออกไปให้โค้ดส่วนที่เรียกใช้ฟังก์ชัน และหลักการแบบเดียวกันนี้จะถูกนำมาใช้กับเครื่องหมาย ? ที่ต่อท้ายคำสั่งเรียก read_to_string ด้วยเช่นกัน

ตัวดำเนินการ ? ช่วยตัดทอนโค้ดโครงสร้างที่เขียนซ้ำซาก (boilerplate) และทำให้ขั้นตอนการประมวลผลของฟังก์ชันสั้นลงอย่างเห็นได้ชัด เรายังสามารถย่อโค้ดนี้ให้กระชับลงได้อีกโดยการเชื่อมต่อคำสั่งเมธอด (method chaining) ต่อท้ายเครื่องหมาย ? ทันที ดังที่แสดงในตัวอย่างที่ 9-8

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

เราได้ย้ายคำสั่งสร้าง String ใหม่ของตัวแปร username ไปไว้ที่ตอนเริ่มต้นของฟังก์ชัน ส่วนนั้นยังเหมือนเดิม แต่แทนที่เราจะต้องเขียนคำสั่งเพื่อเก็บค่าตัวแปรพัก username_file เราจะเปลี่ยนมาเขียนเชื่อมโยงเมธอด read_to_string ต่อท้ายผลลัพธ์ของ File::open("hello.txt")? ตรง ๆ ไปเลย เรายังคงมีเครื่องหมาย ? ต่อท้ายคำสั่ง read_to_string และยังคงคืนค่า Ok(username) เมื่อทั้ง File::open และ read_to_string ทำงานสำเร็จ ซึ่งฟังก์ชันการทำงานทุกอย่างมีผลเทียบเท่ากับโค้ดในตัวอย่างที่ 9-6 และ 9-7 แต่อยู่ในรูปแบบการเขียนที่สะดวกกระชับขึ้น

ตัวอย่างที่ 9-9 แสดงวิธีหั่นโค้ดนี้ให้สั้นกระชับขึ้นไปอีกขั้นโดยเลือกใช้งานฟังก์ชัน fs::read_to_string

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

ขั้นตอนอ่านเนื้อหาจากไฟล์มาบันทึกในรูปของสตริงถือเป็นพฤติกรรมการทำงานที่พบได้เป็นปกติทั่วไป ทางไลบรารีมาตรฐานจึงจัดเตรียมฟังก์ชันที่ใช้งานง่ายอย่าง fs::read_to_string ซึ่งจะทำหน้าที่เปิดไฟล์, สร้าง String ใหม่, อ่านเนื้อหาไฟล์มาบันทึกเก็บใน String นั้น แล้วส่งข้อมูลกลับคืนมาให้เสร็จสรรพ แน่นอนว่าการเลือกใช้ fs::read_to_string ตรง ๆ จะทำให้เราไม่มีโอกาสได้อธิบายระบบจัดการข้อผิดพลาดทีละขั้น ดังนั้นเราจึงอธิบายแนวทางแบบยาวให้เห็นภาพกันก่อน

ฟังก์ชันที่สามารถใช้งานตัวดำเนินการ ? ได้ (Where to Use the ? Operator)

ตัวดำเนินการ ? สามารถนำไปใช้ได้เฉพาะกับฟังก์ชันที่มีชนิดของประเภทข้อมูลส่งคืนที่เข้ากันได้ (compatible) กับประเภทข้อมูลที่เครื่องหมาย ? ไปประมวลผล เนื่องจากตัวดำเนินการ ? ถูกกำหนดพฤติกรรมมาให้ทำงานในลักษณะจบคำสั่งฟังก์ชันก่อนกำหนดและส่งค่าผลลัพธ์ออกจากฟังก์ชันทันที เช่นเดียวกับประโยคนิพจน์คำสั่ง match ในตัวอย่างที่ 9-6 ซึ่งโครงสร้าง match ในจุดนั้นทำงานกับออบเจกต์ Result และส่วนของกิ่งคำสั่งจบฟังก์ชันก่อนกำหนดทำการคืนค่าเป็น Err(e) ออกไป ดังนั้นประเภทข้อมูลส่งคืนของตัวฟังก์ชันหลักจึงจำเป็นต้องเป็นชนิด Result เพื่อให้สอดรับเข้ากันกับค่า return นี้

ในตัวอย่างที่ 9-10 ลองมาศึกษาข้อความแสดงข้อผิดพลาดที่เราจะเจอกันหากเราฝืนใช้งานตัวดำเนินการ ? ภายในฟังก์ชัน main ที่มีชนิดประเภทข้อมูลส่งคืนที่ไม่สอดรับกับชนิดข้อมูลที่เรานำ ? ไปเติมต่อท้าย

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

โค้ดนี้จะเปิดไฟล์ซึ่งอาจเกิดความล้มเหลวได้ ตัวดำเนินการ ? จะประมวลผลต่อท้ายค่า Result ที่ส่งกลับมาจาก File::open แต่ทว่าฟังก์ชัน main นี้มีประเภทข้อมูลส่งคืนเป็น () ไม่ใช่ Result เมื่อเราทำการคอมไพล์โค้ดนี้ เราจะพบข้อความแจ้งเตือนข้อผิดพลาดดังนี้:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

ข้อผิดพลาดนี้ระบุชัดเจนว่า เราได้รับอนุญาตให้ใช้ตัวดำเนินการ ? ได้เฉพาะกับฟังก์ชันที่ส่งคืนค่าเป็นชนิด Result, Option หรือประเภทข้อมูลอื่นใดที่มีการนำเสนอ (implement) ทริต FromResidual เท่านั้น

ในการแก้ไขข้อผิดพลาดดังกล่าว คุณมีทางเลือก 2 ทางเลือก ทางเลือกแรกคือการปรับเปลี่ยนชนิดข้อมูลส่งคืนของฟังก์ชันหลักให้เข้ากันกับชนิดข้อมูลที่คุณนำตัวดำเนินการ ? ไปใช้งาน ตราบเท่าที่คุณไม่มีข้อจำกัดภายนอกใด ๆ ที่มาห้ามปรับโครงสร้างฟังก์ชัน ส่วนทางเลือกที่สองคือหันไปเขียนคำสั่ง match หรือเมธอดอื่นใดของ Result<T, E> เพื่อจัดการกับผลลัพธ์ของ Result<T, E> ด้วยวิธีกระบวนการที่เหมาะสมทดแทน

ข้อความแจ้งข้อผิดพลาดดังกล่าวยังได้ระบุถึงความจริงที่ว่า เครื่องหมาย ? สามารถใช้งานกับข้อมูลชนิด Option<T> ได้ด้วยเช่นกัน และเฉกเช่นเดียวกับการใช้เครื่องหมาย ? กับ Result คุณจะสามารถใช้ ? กับข้อมูลประเภท Option ได้ก็ต่อเมื่อฟังก์ชันดังกล่าวมีประเภทข้อมูลส่งคืนเป็น Option เท่านั้น พฤติกรรมของตัวดำเนินการ ? เมื่อถูกนำไปใช้กับ Option<T> จะคล้ายคลึงกับกรณีที่ใช้กับ Result<T, E> คือ: หากค่าของข้อมูลเป็น None ตัวแปร None จะถูกส่งคืนออกจากฟังก์ชันเร็วกว่าปกติทันที ณ จุดคำสั่งนั้น แต่ถ้าค่าเป็น Some ข้อมูลภายใน Some จะเป็นค่าที่ถูกส่งออกมาจากนิพจน์นั้นแล้วโปรแกรมจะประมวลผลคำสั่งบรรทัดต่อไป ตัวอย่างที่ 9-11 แสดงตัวอย่างของฟังก์ชันที่ทำหน้าที่สืบค้นหาตัวอักษรสุดท้ายของบรรทัดแรกในข้อความที่ป้อนเข้ามา

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

ฟังก์ชันนี้ส่งคืนข้อมูลเป็นประเภท Option<char> เนื่องจากเป็นไปได้ว่าอาจจะมีตัวอักษรอยู่ตรงนั้น หรืออาจจะไม่มีอยู่จริงก็ได้ โค้ดนี้จะรับพารามิเตอร์อาร์กิวเมนต์ string slice ของตัวแปร text และเรียกใช้เมธอด lines ซึ่งจะส่งคืนตัววนลูป (iterator) ของบรรทัดต่าง ๆ ในสตริง เนื่องจากฟังก์ชันนี้ต้องการตรวจสอบข้อมูลบรรทัดแรก มันจึงทำการเรียกใช้เมธอด next กับตัว iterator เพื่อดึงข้อมูลตัวแรกออกมา หากตัวแปร text มีค่าเป็นสตริงว่าง คำสั่งเรียกใช้ next จะคืนค่ากลับมาเป็น None ซึ่งในกรณีนี้การใช้เครื่องหมาย ? จะสั่งหยุดการทำงานและคืนค่า None ออกไปจากฟังก์ชัน last_char_of_first_line ทันที แต่หาก text ไม่ใช่สตริงว่าง เมธอด next จะส่งค่ากลับคืนมาเป็นออบเจกต์ Some ที่เก็บ string slice ของบรรทัดแรกใน text เอาไว้

เครื่องหมาย ? จะทำหน้าที่สกัดเอาตัว string slice นั้นออกมา จากนั้นเราสามารถเรียกใช้เมธอด chars กับ string slice ดังกล่าวเพื่อดึงเอา iterator ของอักษรแต่ละตัวออกมา เนื่องจากเราสนใจเฉพาะอักษรตัวสุดท้ายในบรรทัดแรกนี้ เราจึงเรียกใช้เมธอด last เพื่อคืนค่าอักษรตัวสุดท้ายของ iterator ซึ่งผลลัพธ์จะเป็นประเภท Option เนื่องจากมีโอกาสที่บรรทัดแรกนั้นอาจเป็นสตริงว่างได้ ตัวอย่างเช่น สตริงในตัวแปร text เริ่มต้นด้วยบรรทัดว่างแต่มีตัวอักษรอยู่ในบรรทัดอื่น ๆ เช่น "\nhi" แต่อย่างไรก็ดี หากมีตัวอักษรตัวสุดท้ายในบรรทัดแรกอยู่จริง ค่าอักษรดังกล่าวจะถูกคืนกลับมาในรูปของตัวแปรย่อย Some การเขียนคำสั่งตัวดำเนินการ ? ไว้ระหว่างกระบวนการทำงานช่วยให้เราสามารถเขียนอธิบายขั้นตอนเหล่านี้ได้อย่างกระชับจนจบได้ในบรรทัดเดียว หากเราไม่สามารถใช้งานเครื่องหมาย ? กับ Option ได้ เราก็คงต้องใช้วิธีเรียกเมธอดหลายรอบหรือเขียนนิพจน์ match ขึ้นมารับมือแทน

ข้อพึงระวังคือ คุณสามารถใช้ตัวดำเนินการ ? กับ Result ในฟังก์ชันที่ส่งคืนเป็น Result ได้ และสามารถใช้ตัวดำเนินการ ? กับ Option ในฟังก์ชันที่ส่งคืนเป็น Option ได้ แต่คุณไม่สามารถนำมาใช้ปะปนสลับข้ามฝั่งกันตรง ๆ ได้ ตัวดำเนินการ ? จะไม่ช่วยแปลงค่า Result ไปเป็น Option หรือแปลง Option ไปเป็น Result ให้คุณโดยอัตโนมัติ ในกรณีที่คุณต้องการแปลงสลับประเภทข้อมูลเหล่านั้น คุณจำเป็นต้องใช้เมธอดเฉพาะทางช่วยอย่างเช่น เมธอด ok บน Result หรือเมธอด ok_or บน Option เพื่อระบุกระบวนการแปลงชนิดข้อมูลให้ชัดเจน

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

โชคดีที่ฟังก์ชัน main สามารถคืนค่าเป็นประเภท Result<(), E> ได้ด้วยเช่นกัน ตัวอย่างที่ 9-12 ดัดแปลงโครงสร้างโค้ดจากตัวอย่างที่ 9-10 โดยปรับเปลี่ยนชนิดข้อมูลส่งคืนของ main ให้เป็น Result<(), Box<dyn Error>> และเพิ่มคำสั่งส่งคืนค่า Ok(()) ไว้ที่บรรทัดสุดท้าย ซึ่งโค้ดชุดนี้จะสามารถคอมไพล์ผ่านได้แล้วในตอนนี้

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

ชนิดข้อมูล Box<dyn Error> คือทริตอ็อบเจกต์ (trait object) ซึ่งเราจะมาพูดคุยรายละเอียดกันต่อในหัวข้อ “การใช้งานทริตอ็อบเจกต์เพื่อสร้างรูปแบบนามธรรมทับพฤติกรรมร่วม (Using Trait Objects to Abstract over Shared Behavior)” ในบทที่ 18 สำหรับตอนนี้ คุณสามารถมอง Box<dyn Error> ให้เข้าใจง่าย ๆ ในความหมายที่ว่า “ข้อผิดพลาดประเภทใดก็ได้” การใช้เครื่องหมาย ? กับค่า Result ในฟังก์ชัน main ที่ระบุข้อผิดพลาดเป็นประเภท Box<dyn Error> ได้รับการอนุญาตเพราะช่วยให้สามารถส่งคืนค่า Err ใด ๆ ออกไปก่อนกำหนดได้ ถึงแม้ว่าในโค้ดการทำงานจริงของฟังก์ชัน main นี้จะส่งคืนเฉพาะข้อผิดพลาดประเภท std::io::Error เท่านั้น แต่การระบุ Box<dyn Error> ในส่วนซิกเนเจอร์นี้จะช่วยรองรับโครงสร้างโปรแกรมให้ถูกต้องต่อไปแม้ในอนาคตจะมีโค้ดเพิ่มเติมที่อาจคืนข้อผิดพลาดรูปแบบอื่น ๆ เข้ามาก็ตาม

เมื่อฟังก์ชัน main มีประเภทข้อมูลส่งคืนเป็น Result<(), E> ตัวโปรแกรมจะส่งค่าสิ้นสุดกระบวนการทำงานกลับไปยังระบบ (exit code) เป็น 0 หากคำสั่ง main คืนค่ากลับมาเป็น Ok(()) และจะส่งค่าที่ไม่ใช่ศูนย์กลับไปหาก main ส่งคืนค่าที่เป็น Err โปรแกรมรันระบบที่เขียนด้วยภาษา C จะส่งตัวเลขจำนวนเต็มกลับมาเสมอเมื่อปิดตัวลง: โดยโปรแกรมที่ปิดตัวสำเร็จจะส่งค่า 0 และโปรแกรมที่จบการทำงานด้วยข้อผิดพลาดจะส่งเลขจำนวนเต็มอื่นที่ไม่ใช่ 0 ระบบภาษา Rust จึงเลือกคืนค่าจำนวนเต็มจากไฟล์โปรแกรมในลักษณะเดียวกันนี้เพื่อให้ทำงานเข้ากันได้ตามขนบข้อตกลงสากล

ฟังก์ชัน main อาจเลือกคืนค่าเป็นประเภทข้อมูลใด ๆ ก็ได้ตราบเท่าที่มีการสืบทอด ทริต std::process::Termination ซึ่งภายในจะมีเมธอด report ที่ทำหน้าที่ส่งคืนค่า ExitCode คุณสามารถค้นหาข้อมูลเพิ่มเติมจากเอกสารประกอบของไลบรารีมาตรฐานเพื่อศึกษาวิธีการสืบทอดทริต Termination สำหรับใช้กับประเภทข้อมูลของคุณเองได้

เมื่อเราได้พูดถึงรายละเอียดเกี่ยวกับการสั่ง panic! หรือการส่งคืนค่า Result ไปครบแล้ว เราลองมากลับเข้าสู่หัวข้อถัดไปเพื่อพิจารณาร่วมกันว่า ในกรณีและสถานการณ์ใดที่เราควรจะเลือกใช้งานเครื่องมือแบบใดจึงจะมีความเหมาะสมที่สุด

To panic! or Not to panic!

ควรจะ panic! หรือไม่ควรดี? (To panic! or Not to panic!)

คำถามคือ คุณจะตัดสินใจอย่างไรว่าเมื่อใดควรเรียกใช้ panic! และเมื่อใดควรส่งคืนค่าเป็น Result? เมื่อโค้ดเกิดการ panic จะไม่มีทางกู้คืนการทำงานกลับมาได้เลย คุณสามารถเลือกที่จะเรียก panic! ได้ในทุกสถานการณ์ที่เกิดข้อผิดพลาด ไม่ว่าจะมีหนทางกู้คืนได้หรือไม่ก็ตาม แต่การทำเช่นนั้นคือการตัดสินใจแทนโค้ดผู้เรียกใช้งานไปแล้วว่าสถานการณ์นั้นไม่สามารถกู้คืนได้ แต่เมื่อคุณเลือกที่จะส่งคืนค่าแบบ Result คุณจะมอบทางเลือกให้กับโค้ดผู้เรียก โดยโค้ดผู้เรียกสามารถเลือกที่จะพยายามกู้คืนระบบในลักษณะที่เหมาะสมกับสถานการณ์ของตนเอง หรือตัดสินใจว่าออบเจกต์ Err ในกรณีนี้ไม่สามารถกู้คืนได้ แล้วจึงสั่งเรียกใช้ panic! เพื่อเปลี่ยนข้อผิดพลาดที่แก้ไขได้ของคุณให้กลายเป็นข้อผิดพลาดที่แก้ไขไม่ได้แทน ดังนั้น การส่งคืนค่าแบบ Result จึงเป็นตัวเลือกเริ่มต้นที่ดีเมื่อคุณกำลังนิยามฟังก์ชันที่มีโอกาสทำงานล้มเหลว

สำหรับสถานการณ์บางอย่าง เช่น โค้ดตัวอย่าง, โค้ดต้นแบบ (prototype), และการเขียนชุดการทดสอบ (tests) การเขียนโค้ดเพื่อให้เกิดการ panic จะมีความเหมาะสมมากกว่าการส่งคืนค่าแบบ Result เรามาศึกษากันว่าเพราะอะไร จากนั้นจึงมาอภิปรายถึงสถานการณ์ที่คอมไพเลอร์ไม่สามารถระบุได้ว่าความล้มเหลวนั้นเป็นไปไม่ได้ แต่คุณในฐานะมนุษย์สามารถบอกได้ และบทนี้จะปิดท้ายด้วยคำแนะนำทั่วไปเกี่ยวกับแนวทางการตัดสินใจว่าเมื่อใดควรใช้ panic ในโค้ดประเภทไลบรารี

โค้ดตัวอย่าง, โค้ดต้นแบบ, และชุดการทดสอบ (Examples, Prototype Code, and Tests)

เมื่อคุณเขียนโค้ดตัวอย่างเพื่ออธิบายแนวคิดบางประการ การใส่โค้ดจัดการข้อผิดพลาดที่แข็งแกร่ง (robust error-handling) เข้าไปด้วยอาจทำให้โค้ดตัวอย่างดูไม่ชัดเจนและเข้าใจยาก ในโค้ดตัวอย่าง เป็นที่เข้าใจกันว่าการเรียกใช้เมธอดอย่าง unwrap ซึ่งมีโอกาสเกิด panic ได้นั้น มีไว้เพื่อเป็นตัวสำรอง (placeholder) แทนวิธีการจัดการข้อผิดพลาดจริงที่คุณต้องการใช้ในแอปพลิเคชันของคุณ ซึ่งกระบวนการจัดการนี้อาจแตกต่างกันออกไปขึ้นอยู่กับว่าส่วนที่เหลือของโค้ดทำหน้าที่อะไร

ทำนองเดียวกัน เมธอด unwrap และ expect จะมีประโยชน์อย่างมากเมื่อคุณกำลังเขียนโค้ดต้นแบบ (prototyping) และยังไม่พร้อมที่จะตัดสินใจว่าจะจัดการกับข้อผิดพลาดอย่างไร พวกมันทำหน้าที่เป็นเครื่องหมายสัญลักษณ์ระบุตำแหน่งในโค้ดไว้อย่างชัดเจน เพื่อให้คุณกลับมาแก้ไขปรับโปรแกรมให้แข็งแกร่งยิ่งขึ้นเมื่อพร้อม

หากคำสั่งเรียกเมธอดล้มเหลวในการทดสอบ (test) คุณจะต้องการให้การทดสอบทั้งหมดมีสถานะล้มเหลว (fail) ทันที แม้ว่าเมธอดดังกล่าวจะไม่ได้เป็นฟังก์ชันการทำงานที่อยู่ภายใต้การทดสอบนั้นโดยตรงก็ตาม เนื่องจากระบบการทดสอบจะระบุว่าล้มเหลวผ่านทาง panic! ดังนั้นการเลือกเรียกใช้เมธอด unwrap หรือ expect จึงเป็นสิ่งที่สมควรดำเนินขั้นตอนในกรณีนี้อย่างถูกต้องที่สุด

เมื่อคุณมีข้อมูลรายละเอียดมากกว่าคอมไพเลอร์ (When You Have More Information Than the Compiler)

การเลือกเรียกใช้งาน expect จะมีความเหมาะสมเช่นเดียวกันเมื่อคุณมีตรรกะในส่วนอื่น ๆ ที่ช่วยยืนยันได้ว่าออบเจกต์ Result จะคืนค่าเป็น Ok แน่นอน แต่ตรรกะนั้นอยู่นอกเหนือความเข้าใจของคอมไพเลอร์ อย่างไรก็ดีคุณยังคงมีออบเจกต์ Result ที่ต้องคอยเขียนโค้ดจัดการอยู่: เพราะคำสั่งประมวลผลใด ๆ ที่คุณเรียกใช้ก็ยังคงมีโอกาสเกิดความล้มเหลวได้ในกรณีทั่วไป แม้ว่าในสถานการณ์เฉพาะเจาะจงของคุณมันจะเป็นไปไม่ได้ในทางตรรกะก็ตาม หากคุณตรวจสอบโค้ดด้วยตนเองจนมั่นใจเต็มร้อยว่าไม่มีวันเกิดกรณีตัวแปรย่อย Err แน่นอน คุณก็สามารถเลือกเรียกใช้ expect ได้อย่างไม่มีปัญหา และใส่บันทึกอธิบายเหตุผลที่คุณคิดว่ามันไม่มีวันเกิดข้อผิดพลาดแบบ Err ไว้ในข้อความอาร์กิวเมนต์ตัวแปรได้ ตัวอย่างเช่น:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

เราสร้างออบเจกต์ IpAddr ขึ้นมาจากกระบวนการแปลงค่า (parsing) ข้อความสตริงที่ถูกกำหนดไว้ตายตัว (hardcoded string) เราจะเห็นได้อย่างชัดเจนว่า "127.0.0.1" เป็นที่อยู่ IP ที่ถูกต้องสมบูรณ์ ดังนั้นการเลือกใช้เมธอด expect ที่นี่จึงยอมรับได้ อย่างไรก็ตาม การใช้สตริงที่ถูกต้องแบบ hardcode ไม่ได้ไปเปลี่ยนชนิดประเภทข้อมูลส่งคืนของเมธอด parse แต่อย่างใด: เรายังคงได้รับค่ากลับคืนมาเป็น Result และคอมไพเลอร์ก็ยังคงบังคับให้เราจัดการกับ Result นั้นเสมือนว่ามีโอกาสเกิดตัวแปรย่อย Err ได้อยู่ดี เนื่องจากคอมไพเลอร์ไม่ได้ฉลาดพอที่จะตรวจวิเคราะห์ได้ว่าข้อความสตริงนี้คือ IP address ที่ถูกต้องเสมอ หากข้อความ IP address นั้นมาจากผู้ใช้งานป้อนข้อมูลเข้ามา แทนที่จะเป็นการฝังค่าตายตัวลงไปในโปรแกรม และส่งผลให้มีโอกาสล้มเหลวได้จริง ในกรณีนั้นเราจำเป็นต้องจัดการกับ Result ด้วยแนวทางที่แข็งแกร่งเป็นสัดส่วนมากกว่านี้อย่างแน่นอน การเขียนบันทึกกำกับสมมติฐานว่า IP address นี้ถูก hardcode เอาไว้ จะช่วยเตือนใจให้เรากลับมาเปลี่ยนจากเมธอด expect เป็นโค้ดจัดการข้อผิดพลาดที่ดีขึ้นได้ในอนาคต หากเราจำเป็นต้องเปลี่ยนไปดึงข้อมูล IP จากแหล่งข้อมูลอื่นแทน

แนวทางปฏิบัติสำหรับการจัดการข้อผิดพลาด (Guidelines for Error Handling)

เป็นการสมควรที่จะกำหนดให้โค้ดเกิด panic เมื่อมีโอกาสที่ขั้นตอนการทำงานของโปรแกรมจะตกไปอยู่ในสถานะที่ย่ำแย่ (bad state) ในบริบทนี้ สถานะที่ย่ำแย่ หมายถึงสถานการณ์ที่สมมติฐาน การรับประกัน ข้อตกลงการทำงาน (contract) หรือกฎเกณฑ์ที่ไม่แปรเปลี่ยน (invariant) ได้ถูกฝ่าฝืนและทำลายลง เช่น การมีค่าที่ไม่ถูกต้อง, ค่าที่ขัดแย้งกันเอง หรือค่าที่ขาดหายไปถูกส่งเข้ามายังโค้ดของคุณ รวมถึงตรงตามเงื่อนไขข้อใดข้อหนึ่งหรือมากกว่าดังต่อไปนี้:

  • สถานะที่ย่ำแย่นั้นเป็นสิ่งที่ไม่คาดคิด ต่างจากสิ่งที่มีโอกาสเกิดขึ้นได้เป็นครั้งคราวตามธรรมชาติ เช่น ผู้ใช้กรอกข้อมูลผิดรูปแบบ
  • โค้ดของคุณหลังจากจุดนี้ไปจำเป็นต้องพึ่งพาสมมติฐานว่าระบบไม่ได้อยู่ในสถานะที่ย่ำแย่นี้ แทนที่จะต้องคอยเขียนโค้ดตรวจสอบเงื่อนไขปัญหาในทุก ๆ บรรทัดคำสั่ง
  • ไม่มีแนวทางที่ดีในการแปลงข้อมูลรายละเอียดข้อจำกัดนี้ให้อยู่ในรูปของโครงสร้างประเภทข้อมูลที่คุณเลือกใช้งาน เราจะนำเสนอตัวอย่างของแนวคิดนี้ในหัวข้อ “การบันทึกสถานะและพฤติกรรมในรูปของประเภทข้อมูล (Encoding States and Behavior as Types)” ในบทที่ 18

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

อย่างไรก็ตาม เมื่อความล้มเหลวเป็นเรื่องที่สามารถคาดหมายได้ทั่วไป การส่งคืนค่าแบบ Result จะมีความเหมาะสมมากกว่าการสั่ง panic! ตัวอย่างเช่น เครื่องวิเคราะห์ข้อมูล (parser) ได้รับข้อมูลที่ผิดรูปแบบ หรือคำขอ HTTP (HTTP request) ส่งคืนรหัสสถานะที่ระบุว่าคุณเข้าใช้งานเกินขีดจำกัดความเร็ว (rate limit) ในสถานการณ์เหล่านี้ การส่งคืนค่า Result บ่งบอกว่าความล้มเหลวเป็นทางเลือกที่มีโอกาสเกิดขึ้นตามปกติทั่วไปซึ่งโค้ดผู้เรียกต้องเป็นฝ่ายรับหน้าที่ไปตัดสินใจเองว่าจะจัดการรับมืออย่างไร

เมื่อโค้ดของคุณทำขั้นตอนคำสั่งประมวลผลที่อาจทำให้ผู้ใช้ตกอยู่ในความเสี่ยงหากมีการเรียกใช้ด้วยค่าที่ไม่ถูกต้อง โค้ดของคุณควรตรวจสอบก่อนว่าค่าต่าง ๆ ถูกต้องสมบูรณ์ดี และสั่ง panic หากพบว่าไม่ถูกต้อง เหตุผลนี้สำคัญอย่างยิ่งในแง่ความปลอดภัย: การฝืนประมวลผลคำสั่งกับข้อมูลที่ไม่ถูกต้องอาจเปิดช่องโหว่ความเสี่ยงให้กับโค้ดของคุณได้ และนี่คือเหตุผลหลักที่ทำไมไลบรารีมาตรฐานจะเรียกใช้ panic! หากคุณพยายามเข้าถึงตำแหน่งข้อมูลหน่วยความจำที่เกินขอบเขต (out-of-bounds access): การพยายามเข้าถึงหน่วยความจำที่ไม่ได้เป็นของโครงสร้างข้อมูลปัจจุบันถือเป็นปัญหาความปลอดภัยที่พบบ่อยมาก บ่อยครั้งที่ฟังก์ชันจะมี ข้อตกลงการทำงาน (contracts): พฤติกรรมของฟังก์ชันจะได้รับการรับประกันเฉพาะเมื่อข้อมูลนำเข้าเป็นไปตามข้อกำหนดเฉพาะที่ตั้งไว้ การสั่งให้เกิด panic เมื่อข้อตกลงนั้นถูกฝ่าฝืนจึงมีความเหมาะสม เพราะการละเมิดข้อตกลงจะบ่งบอกถึงบั๊กจากฝั่งผู้เรียกใช้งานเสมอ และมันไม่ใช่รูปแบบข้อผิดพลาดที่คุณจะต้องการปล่อยให้โค้ดผู้เรียกต้องคอยเขียนประโยรับมืออย่างเปิดเผย แท้จริงแล้วไม่มีวิธีที่สมเหตุสมผลใด ๆ ที่โค้ดผู้เรียกจะสามารถกู้คืนการทำงานกลับมาได้ แต่นักพัฒนาที่เป็นผู้เขียนโค้ดจำเป็นต้องเข้ามาแก้ไขโค้ดต่างหาก ข้อกำหนดข้อตกลงการทำงานสำหรับฟังก์ชัน โดยเฉพาะอย่างยิ่งกรณีที่การฝ่าฝืนจะส่งผลให้เกิด panic ควรระบุและอธิบายไว้ให้ชัดเจนในส่วนเอกสาร API ของฟังก์ชันนั้น ๆ

อย่างไรก็ตาม การใส่คำสั่งตรวจสอบข้อผิดพลาดจำนวนมากไว้ในฟังก์ชันทั้งหมดของคุณจะทำให้โค้ดเยิ่นเย้อและน่ารำคาญ โชคดีที่คุณสามารถใช้ประโยชน์จากระบบประเภทข้อมูลของ Rust (และรวมถึงระบบตรวจเช็คประเภทข้อมูลของคอมไพเลอร์) เพื่อทำหน้าที่ตรวจสอบหลาย ๆ สิ่งแทนคุณได้ หากฟังก์ชันของคุณกำหนดประเภทข้อมูลเฉพาะเจาะจงเป็นพารามิเตอร์ คุณก็จะเขียนตรรกะการประมวลผลต่อไปได้ทันทีโดยอิงตามสมมติฐานว่าคอมไพเลอร์ได้ตรวจสอบความถูกต้องของค่านั้นมาให้แล้ว ตัวอย่างเช่น หากคุณระบุโครงสร้างประเภทข้อมูลโดยตรงแทนที่จะใส่ใน Option ตัวโปรแกรมจะคาดหวังว่าต้องมี ข้อมูลบางอย่าง อยู่จริง ไม่ใช่ความว่างเปล่า โค้ดของคุณจึงไม่ต้องคอยจัดการกับสองกรณีแยกสำหรับตัวแปรย่อย Some และ None อีกต่อไป: จะเหลือเพียงกรณีเดียวคือต้องมีค่าข้อมูลอยู่แน่นอน โค้ดใด ๆ ที่พยายามจะส่งค่าความว่างเปล่ามายังฟังก์ชันนี้จะไม่สามารถคอมไพล์ผ่านตั้งแต่แรก ดังนั้นฟังก์ชันของคุณจึงไม่ต้องทำคำสั่งตรวจสอบจุดนี้ในตอนรันไทม์ (runtime) อีกตัวอย่างหนึ่งคือการใช้ประเภทจำนวนเต็มแบบไม่มีเครื่องหมาย เช่น u32 ซึ่งจะช่วยรับประกันได้ว่าค่าพารามิเตอร์จะไม่มีวันติดลบแน่นอน

การสร้างประเภทข้อมูลขึ้นเองเพื่อใช้ตรวจสอบความถูกต้อง (Custom Types for Validation)

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

แนวทางหนึ่งคือการเปลี่ยนมาทำการแปลงผลลัพธ์คำทาย (parse) ให้เป็นชนิด i32 แทนที่จะเป็น u32 เพียงอย่างเดียวเพื่อเปิดโอกาสให้รองรับค่าตัวเลขติดลบได้ จากนั้นค่อยใส่คำสั่งเช็คเงื่อนไขว่าตัวเลขอยู่ในช่วงเป้าหมายหรือไม่ ดังนี้:

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

คำสั่งนิพจน์ if จะตรวจสอบว่าค่าของเราอยู่นอกช่วงเป้าหมายหรือไม่ แจ้งปัญหาให้ผู้ใช้ทราบ และเรียกใช้เมธอด continue เพื่อเริ่มต้นรอบถัดไปของลูปและบอกให้ทายใหม่อีกครั้ง หลังจากโครงสร้างคำสั่ง if นี้ไป เราสามารถเปรียบเทียบระหว่างค่า guess กับตัวเลขปริศนาต่อไปได้อย่างมั่นใจว่าค่าของ guess จะอยู่ระหว่าง 1 ถึง 100 แน่นอน

อย่างไรก็ตาม วิธีนี้ยังไม่ใช่วิธีแก้ปัญหาที่ดีที่สุด: หากตัวโปรแกรมมีความจำเป็นระดับวิกฤตที่ต้องทำงานเฉพาะกับค่าระหว่าง 1 ถึง 100 เท่านั้น และโปรแกรมมีฟังก์ชันจำนวนมากที่มีข้อกำหนดแบบเดียวกันนี้ การเขียนตรวจสอบโครงสร้างเงื่อนไขแบบนี้ในทุก ๆ ฟังก์ชันจะกลายเป็นงานที่น่าเบื่อเหนื่อยหน่าย (และอาจจะส่งผลเสียต่อประสิทธิภาพการประมวลผลด้วย)

ทางเลือกอื่นที่ดีกว่าคือ เราสามารถสร้างประเภทข้อมูลใหม่ในมอดูล (module) ที่แยกออกไปโดยเฉพาะ และรวมศูนย์เงื่อนไขการตรวจสอบทั้งหมดไว้ในฟังก์ชันสร้างอินสแตนซ์ของประเภทข้อมูลนั้นแทนที่จะต้องเขียนเงื่อนไขการตรวจสอบซ้ำ ๆ ทั่วกระบวนการ ด้วยวิธีนี้ แต่ละฟังก์ชันจะนำประเภทข้อมูลใหม่นั้นไปใช้ในส่วนซิกเนเจอร์พารามิเตอร์ได้อย่างปลอดภัย และนำค่าที่ได้รับไปใช้งานต่อได้อย่างมั่นใจ ตัวอย่างที่ 9-13 แสดงวิธีการหนึ่งในการกำหนดประเภทข้อมูล Guess ซึ่งจะยอมสร้างอินสแตนซ์ของ Guess ขึ้นมาเฉพาะเมื่อฟังก์ชัน new ได้รับข้อมูลตัวเลขระหว่าง 1 ถึง 100 เท่านั้น

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

สังเกตว่าโค้ดชุดนี้ในไฟล์ src/guessing_game.rs ขึ้นตรงกับการเพิ่มบรรทัดประกาศมอดูล mod guessing_game; ในไฟล์ src/lib.rs ซึ่งเราไม่ได้แสดงให้ดู ณ ที่นี้ ภายในไฟล์มอดูลใหม่นี้เรานิยาม struct ชื่อว่า Guess ซึ่งมีฟิลด์ข้อมูลชื่อว่า value ชนิดประเภทข้อมูล i32 จุดนี้คือตำแหน่งที่ใช้บันทึกจัดเก็บค่าตัวเลขจริง

จากนั้น เราเขียนคำสั่งนำเสนอ (implement) ฟังก์ชันที่เกี่ยวข้อง (associated function) ชื่อว่า new ให้กับ Guess เพื่อใช้สำหรับสร้างออบเจกต์ค่า Guess ขึ้นมา ฟังก์ชัน new นี้ถูกระบุให้รับพารามิเตอร์ตัวหนึ่งชื่อว่า value ประเภท i32 และคืนค่าเป็น Guess ส่วนของโค้ดการทำงานภายในฟังก์ชัน new จะทำหน้าที่ตรวจสอบเงื่อนไขของตัวแปร value เพื่อให้มั่นใจได้เต็มที่ว่าค่าอยู่ระหว่าง 1 ถึง 100 หากตัวแปร value สอบตกจากเงื่อนไขตรวจสอบนี้ เราจะสั่งประมวลผล panic! ซึ่งจะทำหน้าที่เตือนนักพัฒนาที่เป็นคนเขียนโค้ดฝั่งเรียกใช้ฟังก์ชันว่ามีจุดบกพร่องที่เขาต้องเข้าไปแก้ไข เนื่องจากเงื่อนไขการสร้างอินสแตนซ์ Guess โดยมีค่า value นอกขอบเขตเป้าหมายจะถือเป็นการทำลายข้อตกลงการทำงาน (contract) ที่ Guess::new คาดหวังและพึ่งพิงไว้ โดยข้อมูลเงื่อนไขรายละเอียดที่อาจทำให้ Guess::new เกิด panic ได้ควรระบุชี้แจงไว้ในเอกสาร API สำหรับเผยแพร่สู่ภายนอก ซึ่งเราจะมาศึกษาขนบการจัดทำเอกสารระบุรายละเอียดโอกาสเกิด panic! ของเอกสาร API ที่คุณสร้างขึ้นกันในบทที่ 14 หากตัวแปร value สอบผ่านจากเงื่อนไขตรวจสอบ เราจะสร้างอินสแตนซ์ Guess ตัวใหม่ขึ้นมาโดยระบุฟิลด์ value ให้เก็บค่าจากพารามิเตอร์ value และส่งออบเจกต์ Guess คืนกลับไป

ถัดไป เราเขียนนำเสนอเมธอดชื่อว่า value ซึ่งทำการยืมใช้งาน self โดยไม่มีการรับพารามิเตอร์ตัวแปรอื่น และคืนค่ากลับมาเป็นประเภท i32 เมธอดลักษณะเฉพาะเจาะจงแบบนี้มักเรียกกันว่า เกตเตอร์ (getter) เนื่องจากเป้าหมายมีไว้เพื่อดึงเอาข้อมูลบางอย่างจากฟิลด์ภายในส่งกลับคืนออกไป การเตรียมเมธอดสาธารณะ (public method) นี้มีความจำเป็นอย่างมากเนื่องจากฟิลด์ข้อมูล value ของ struct Guess มีสถานะเป็นฟิลด์ส่วนตัว (private field) จุดสำคัญคือฟิลด์ value จำเป็นต้องเป็นฟิลด์ส่วนตัวเพื่อป้องกันไม่ให้โค้ดอื่นภายนอกที่ดึง struct Guess ไปใช้งานแอบเข้ามากำหนดค่าตัวแปร value ได้โดยตรง: โค้ดใด ๆ ภายนอกมอดูล guessing_game ต้อง เรียกผ่านฟังก์ชัน Guess::new เท่านั้นเมื่อต้องการสร้างอินสแตนซ์ของ Guess ซึ่งกระบวนการนี้จะช่วยรับประกันได้อย่างสมบูรณ์แบบว่าออบเจกต์ Guess จะไม่มีวันเก็บค่า value ที่เล็ดลอดการตรวจสอบเงื่อนไขของฟังก์ชัน Guess::new ไปได้เลย

ดังนั้น ฟังก์ชันที่มีความต้องการพารามิเตอร์หรือส่งคืนเฉพาะตัวเลขระหว่าง 1 ถึง 100 ก็จะระบุในส่วนซิกเนเจอร์ประกาศพารามิเตอร์หรือข้อมูลส่งคืนได้เลยว่าเป็นชนิด Guess แทนที่จะเป็น i32 ทั่วไป และตัวฟังก์ชันเองก็ไม่จำเป็นต้องใส่ตรรกะเงื่อนไขตรวจสอบใด ๆ เพิ่มเติมไว้ในโค้ดการทำงานหลักอีกเลย

สรุป (Summary)

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

เมื่อคุณได้เห็นรูปแบบแนวทางที่มีประโยชน์ในการเลือกประยุกต์ใช้งานระบบเจเนอริก (generics) ร่วมกับ enum Option และ Result ของไลบรารีมาตรฐานแล้ว ถัดไปเราจะมาเรียนรู้เรื่องกลไกการทำงานของเจเนอริก และวิธีการนำระบบนี้ไปเขียนใช้งานจริงในโค้ดของคุณกันครับ

ประเภทข้อมูลแบบเจเนอริก, ทริต, และอายุการใช้งาน (Generic Types, Traits, and Lifetimes)

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

ฟังก์ชันต่าง ๆ สามารถรับพารามิเตอร์ที่เป็นประเภทข้อมูลเจเนอริกได้ แทนที่จะระบุประเภทรูปธรรมคงที่อย่าง i32 หรือ String ซึ่งจะเปรียบเสมือนกับการที่ฟังก์ชันรับพารามิเตอร์ที่มีค่าไม่แน่นอนเพื่อรันโค้ดชุดเดียวกันกับค่าข้อมูลรูปธรรมที่หลากหลาย ในความเป็นจริง เราได้ใช้งานเจเนอริกกันไปบ้างแล้วในบทที่ 6 กับประเภทข้อมูล Option<T> ในบทที่ 8 กับ Vec<T> และ HashMap<K, V> รวมถึงในบทที่ 9 กับ Result<T, E> ในบทนี้คุณจะได้ศึกษาเรียนรู้วิธีการนิยามประเภทข้อมูล, ฟังก์ชัน, และเมธอดต่าง ๆ ด้วยระบบเจเนอริกของตัวคุณเอง!

เริ่มแรก เราจะทบทวนวิธีการแยกฟังก์ชัน (extracting a function) เพื่อช่วยลดความซ้ำซ้อนของโค้ด จากนั้นเราจะใช้เทคนิคกระบวนการเดียวกันนี้ในการสร้างฟังก์ชันแบบเจเนอริกจากฟังก์ชันสองตัวที่แตกต่างกันเพียงแค่ประเภทข้อมูลของพารามิเตอร์ นอกจากนี้เราจะมาอธิบายวิธีใช้งานประเภทข้อมูลเจเนอริกในส่วนนิยามของ struct และ enum ด้วย

จากนั้น คุณจะได้เรียนรู้วิธีการใช้ทริต (traits) เพื่อกำหนดพฤติกรรม (behavior) ในรูปแบบเจเนอริก คุณสามารถนำทริตมาใช้ผสมผสานร่วมกับเจเนอริกเพื่อจำกัดเงื่อนไข (constraint) ให้เจเนอริกยอมรับเฉพาะประเภทข้อมูลที่มีพฤติกรรมตามที่กำหนดเท่านั้น แทนที่จะยอมรับประเภทข้อมูลใด ๆ ก็ได้ทั่วไป

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

การลดโค้ดซ้ำซ้อนด้วยการแยกฟังก์ชัน (Removing Duplication by Extracting a Function)

เจเนอริกช่วยให้เราสามารถแทนที่ประเภทข้อมูลเฉพาะเจาะจงด้วยตัวยึดตำแหน่ง (placeholder) ซึ่งเป็นตัวแทนของข้อมูลประเภทต่าง ๆ เพื่อลดความซ้ำซ้อนของโค้ด แต่ก่อนจะดำดิ่งไปเรียนรู้ไวยากรณ์ของเจเนอริก เราลองมาศึกษาวิธีการลดโค้ดซ้ำซ้อนในรูปแบบที่ยังไม่มีส่วนเกี่ยวข้องกับเจเนอริกกันก่อน โดยใช้วิธีแยกฟังก์ชันที่จะทำหน้าที่แปลงค่าเฉพาะเจาะจงต่าง ๆ ให้เหลือเพียงตัวแปรยึดตำแหน่งที่เป็นตัวแทนของค่าอื่น ๆ ได้หลากหลาย จากนั้นเราจะใช้เทคนิคเดียวกันนี้เพื่อสร้างฟังก์ชันเจเนอริกออกมา! การได้ฝึกสังเกตและจดจำรูปแบบโค้ดที่ซ้ำซ้อนซึ่งสามารถแยกออกเป็นฟังก์ชันได้ จะช่วยให้คุณเริ่มมองเห็นว่าโค้ดส่วนใดที่สามารถนำเจเนอริกเข้ามาประยุกต์ใช้งานได้ในภายหลัง

เราจะเริ่มต้นศึกษาจากโปรแกรมขนาดเล็กในตัวอย่างที่ 10-1 ซึ่งมีหน้าที่ค้นหาตัวเลขที่มีค่ามากที่สุดในรายการข้อมูล

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}

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

ขณะนี้เราได้รับโจทย์ให้เขียนคำสั่งเพื่อค้นหาตัวเลขที่มีค่ามากที่สุดจากรายการข้อมูลสองชุดที่แตกต่างกัน ในการทำเช่นนั้น เราอาจเลือกเขียนโค้ดซ้ำแบบตัวอย่างที่ 10-1 ไปวางไว้ในตำแหน่งการทำงานที่ต่างกันสองจุดในโปรแกรม ดังที่แสดงในตัวอย่างที่ 10-2

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}

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

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

ในตัวอย่างที่ 10-3 เราได้แยกโค้ดส่วนที่ค้นหาตัวเลขที่มีค่ามากที่สุดออกไปเป็นฟังก์ชันชื่อว่า largest จากนั้นเราทำการเรียกใช้ฟังก์ชันดังกล่าวเพื่อหาตัวเลขที่มีค่ามากที่สุดในรายการตัวเลขทั้งสองชุดจากตัวอย่างที่ 10-2 นอกจากนี้เรายังนำฟังก์ชันนี้ไปเรียกใช้กับรายการข้อมูล i32 ใด ๆ ที่อาจเกิดขึ้นในอนาคตได้อย่างไม่มีข้อจำกัด

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}

ฟังก์ชัน largest มีพารามิเตอร์ชื่อว่า list ซึ่งทำหน้าที่เป็นตัวแทนเชิงนามธรรมสำหรับข้อมูล slice ของ i32 ชิ้นใดก็ได้ที่ถูกส่งเข้ามา ผลลัพธ์คือเมื่อเราสั่งเรียกใช้ฟังก์ชัน โค้ดภายในจะเริ่มประมวลผลคำสั่งจริงกับค่าเฉพาะเจาะจงที่เราป้อนเข้าไป

สรุปคือ นี่คือขั้นตอนต่าง ๆ ที่เราได้ปรับเปลี่ยนโครงสร้างโค้ดจากตัวอย่างที่ 10-2 เป็นตัวอย่างที่ 10-3:

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

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

ตัวอย่างเช่น สมมติว่าเรามีฟังก์ชัน 2 ฟังก์ชัน ได้แก่ ฟังก์ชันหนึ่งสำหรับค้นหาข้อมูลที่มีค่ามากที่สุดใน slice ของข้อมูล i32 และอีกฟังก์ชันสำหรับค้นหาข้อมูลที่ใหญ่ที่สุดใน slice ของข้อมูล char เราจะกำจัดโค้ดซ้ำซ้อนคู่นี้ได้อย่างไร? เรามาหาคำตอบกันครับ!

Generic Data Types

ประเภทข้อมูลแบบเจเนอริก (Generic Data Types)

เราใช้เจเนอริกเพื่อสร้างคำนิยามสำหรับสิ่งต่าง ๆ เช่น ซิกเนเจอร์ของฟังก์ชัน หรือ struct ซึ่งเราจะสามารถนำไปใช้งานร่วมกับประเภทข้อมูลรูปธรรมที่หลากหลายได้ในภายหลัง ก่อนอื่นเรามาศึกษาวิธีการนิยามฟังก์ชัน, struct, enum และเมธอดต่าง ๆ โดยใช้เจเนอริกกัน จากนั้นเราจะอภิปรายถึงเรื่องเจเนอริกส่งผลต่อประสิทธิภาพการทำงานของโค้ดอย่างไร

ในนิยามของฟังก์ชัน (In Function Definitions)

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

มาต่อกันที่ฟังก์ชัน largest ของเรากันครับ ตัวอย่างที่ 10-4 แสดงฟังก์ชันสองตัวที่ต่างก็ทำหน้าที่ค้นหาค่าที่มากที่สุดใน slice จากนั้นเราจะนำสองฟังก์ชันนี้มารวมกันให้เหลือเพียงฟังก์ชันเดียวโดยอาศัยเจเนอริก

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}

ฟังก์ชัน largest_i32 คือตัวที่เราสกัดออกมาในตัวอย่างที่ 10-3 เพื่อหาตัวเลข i32 ที่ใหญ่ที่สุดใน slice ส่วนฟังก์ชัน largest_char ทำหน้าที่หาตัวอักษร char ที่ใหญ่ที่สุดใน slice ตัวโครงสร้างภายในของฟังก์ชันมีโค้ดที่เหมือนกันทุกประการ ดังนั้นเรามากำจัดจุดซ้ำซ้อนนี้โดยเพิ่มพารามิเตอร์ประเภทข้อมูลเจเนอริกเข้าไปในฟังก์ชันเดียว

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

เมื่อเราใช้งานตัวแปรพารามิเตอร์ภายในฟังก์ชัน เราจำเป็นต้องประกาศชื่อพารามิเตอร์นั้นในส่วนซิกเนเจอร์เพื่อให้คอมไพเลอร์เข้าใจความหมายของชื่อดังกล่าว ในทำนองเดียวกัน เมื่อเราใช้ชื่อพารามิเตอร์ประเภทข้อมูลในส่วนซิกเนเจอร์ของฟังก์ชัน เราก็ต้องประกาศตัวแปรพารามิเตอร์ประเภทข้อมูลนั้นก่อนนำไปใช้งานจริง สำหรับการนิยามฟังก์ชันเจเนอริก largest เราจะวางประกาศประเภทข้อมูลนี้ไว้ภายในเครื่องหมายวงเล็บสามเหลี่ยม <> ระหว่างชื่อของฟังก์ชันกับรายการตัวแปรพารามิเตอร์ ดังนี้:

fn largest<T>(list: &[T]) -> &T {

เราจะอ่านคำนิยามบรรทัดนี้ได้ว่า “ฟังก์ชัน largest ทำหน้าที่เป็นเจเนอริกครอบคลุมประเภทข้อมูล T ใด ๆ” ฟังก์ชันนี้มีพารามิเตอร์ตัวหนึ่งชื่อว่า list ซึ่งมีรูปแบบเป็น slice ของค่าข้อมูลประเภท T และตัวฟังก์ชัน largest จะส่งคืนข้อมูลอ้างอิงของค่าข้อมูลประเภท T ชนิดเดียวกันนั้นกลับมา

ตัวอย่างที่ 10-5 แสดงนิยามของฟังก์ชัน largest ที่รวมกันแล้วโดยใช้ประเภทข้อมูลเจเนอริกในส่วนซิกเนเจอร์ ตัวอย่างนี้ยังแสดงวิธีเรียกใช้ฟังก์ชันกับ slice ของข้อมูลประเภท i32 หรือข้อมูลประเภท char ได้อย่างอิสระ สังเกตว่าโค้ดชุดนี้จะยังคอมไพล์ไม่ผ่าน ณ ตอนนี้

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

หากเราสั่งคอมไพล์โค้ดนี้ตอนนี้ เราจะพบข้อความแจ้งข้อผิดพลาดดังนี้:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

ข้อความแนะนำได้ระบุคำว่า std::cmp::PartialOrd ซึ่งเป็นข้อมูลทริต (trait) และเรากำลังจะพูดถึงเรื่องทริตกันต่อในส่วนถัดไป สำหรับตอนนี้ ขอให้เข้าใจว่าข้อผิดพลาดนี้ระบุว่า โค้ดหลักภายในฟังก์ชัน largest จะไม่ทำงานครอบคลุมประเภทข้อมูลทั้งหมดที่ T สามารถเป็นได้ เนื่องจากเราต้องการนำค่าประเภท T ไปทำการเปรียบเทียบขนาด (comparison) ภายในฟังก์ชัน เราจึงจำเป็นต้องใช้ประเภทข้อมูลที่สามารถระบุลำดับก่อนหลังได้เท่านั้น เพื่อเปิดโอกาสให้ประมวลผลคำสั่งเปรียบเทียบได้ ไลบรารีมาตรฐานจึงได้กำหนดทริต std::cmp::PartialOrd ขึ้นมาเพื่อนำเสนอให้กับประเภทข้อมูลต่าง ๆ ได้สืบทอด (ดูภาคผนวก C สำหรับรายละเอียดทริตนี้) ในการแก้ไขโค้ดจากตัวอย่างที่ 10-5 เราทำตามคำแนะนำโดยจำกัดขอบเขตของประเภทข้อมูล T ให้ยอมรับเฉพาะประเภทข้อมูลที่มีการสืบทอดทริต PartialOrd เท่านั้น จากนั้นตัวโปรแกรมจะสามารถคอมไพล์ผ่านได้ เนื่องจากไลบรารีมาตรฐานได้สืบทอดทริต PartialOrd ไว้ให้กับประเภทข้อมูลทั้ง i32 และ char อยู่แล้ว

ในนิยามของ Struct (In Struct Definitions)

เราสามารถกำหนดนิยามให้ struct ใช้ประโยชน์จากพารามิเตอร์ประเภทข้อมูลเจเนอริกสำหรับฟิลด์ข้อมูลต่าง ๆ ได้โดยใช้ไวยากรณ์ <> เช่นเดียวกัน ตัวอย่างที่ 10-6 นิยาม struct Point<T> เพื่อทำหน้าที่จัดเก็บพิกัดแกน x และ y ของประเภทข้อมูลใด ๆ ก็ตาม

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

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

ข้อพึงสังเกตคือ เนื่องจากเราเลือกใช้ประเภทข้อมูลเจเนอริกเพียงตัวเดียวในนิยามของ Point<T> นิยามนี้จึงระบุว่า struct Point<T> จะเป็นเจเนอริกครอบคลุมชนิดประเภทข้อมูล T ใด ๆ และทั้งฟิลด์ x และ y ต้องเป็น ชนิดข้อมูลเดียวกันนั้นเสมอไม่ว่าข้อมูลดังกล่าวจะเป็นชนิดใดก็ตาม หากเราพยายามสร้างอินสแตนซ์ของ Point<T> โดยใส่ค่าข้อมูลที่ต่างประเภทกันดังแสดงในตัวอย่างที่ 10-7 โค้ดของเราจะไม่สามารถคอมไพล์ผ่านได้

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

ในตัวอย่างนี้ เมื่อเรากำหนดจำนวนเต็ม 5 ให้กับ x เราจะบอกให้คอมไพเลอร์รู้ว่าประเภทข้อมูลเจเนอริก T ของอินสแตนซ์ Point<T> ตัวนี้จะเป็นชนิดจำนวนเต็ม แต่เมื่อเรากำหนดค่า 4.0 ให้กับ y ซึ่งเราได้นิยามให้เป็นชนิดข้อมูลเดียวกันกับ x เราจะเจอกับข้อผิดพลาดประเภทข้อมูลไม่ตรงกัน (type mismatch error) ดังต่อไปนี้:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

หากเราต้องการกำหนดนิยาม struct Point โดยระบุให้แกน x และ y ต่างก็ใช้เจเนอริกแต่ยอมให้เป็นชนิดประเภทข้อมูลที่ต่างกันได้ เราสามารถเลือกประกาศพารามิเตอร์ประเภทข้อมูลเจเนอริกเพิ่มได้หลายตัว ตัวอย่างเช่น ในตัวอย่างที่ 10-8 เราปรับเปลี่ยนนิยามของ Point ให้ครอบคลุมเจเนอริกของประเภทข้อมูล T และ U โดยกำหนดให้ x เป็นประเภท T และ y เป็นประเภท U

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

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

ในนิยามของ Enum (In Enum Definitions)

ในลักษณะเดียวกับ struct เราสามารถกำหนดนิยาม enum เพื่อเก็บประเภทข้อมูลเจเนอริกในส่วนตัวแปรย่อย (variants) ของมันได้ ลองย้อนกลับมาทบทวนนิยามของ enum Option<T> ที่มีเตรียมไว้ในไลบรารีมาตรฐานซึ่งเราได้ใช้งานไปแล้วในบทที่ 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

คำนิยามบรรทัดนี้น่าจะดูเข้าใจง่ายขึ้นสำหรับคุณแล้วในตอนนี้ ดังที่คุณเห็น enum Option<T> เป็นเจเนอริกครอบคลุมประเภทข้อมูล T และมีตัวแปรย่อยสองตัว ได้แก่ Some ซึ่งห่อหุ้มค่าข้อมูลชนิด T เอาไว้หนึ่งค่า และตัวแปรย่อย None ซึ่งไม่ได้เก็บค่าข้อมูลใด ๆ การใช้งาน enum Option<T> ช่วยให้เราสามารถระบุคุณสมบัตินามธรรมของค่าข้อมูลที่อาจจะมีอยู่หรือไม่มีก็ได้ (optional value) และเนื่องจาก Option<T> ถูกระบุให้ทำงานแบบเจเนอริก เราจึงนำคุณสมบัตินามธรรมนี้ไปประยุกต์ใช้ได้กับข้อมูลทุกชนิดโดยไม่เกี่ยงว่าข้อมูลตัวเลือกนั้นจะเป็นประเภทใด

ตัวแปร enum สามารถประกาศใช้งานเจเนอริกเพิ่มได้หลายตัวแปรเช่นกัน ตัวอย่างหนึ่งคือคำนิยามของ enum Result ที่เราเคยใช้งานกันไปในบทที่ 9:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

ตัวแปร enum Result นี้ใช้เจเนอริกร่วมกันสองตัว ได้แก่ T และ E และมีตัวแปรย่อยสองตัวคือ Ok ซึ่งเก็บค่าข้อมูลประเภท T และ Err ซึ่งเก็บค่าข้อผิดพลาดประเภท E โครงสร้างนิยามนี้ช่วยให้เรานำ Result ไปเรียกใช้งานได้อย่างสะดวกในการทำงานต่าง ๆ ที่อาจประสบผลสำเร็จ (และส่งผลลัพธ์ข้อมูลประเภท T กลับมา) หรือล้มเหลว (และส่งข้อมูลข้อผิดพลาดประเภท E กลับคืนมา) ตัวอย่างเช่น โค้ดสำหรับเปิดไฟล์ในตัวอย่างที่ 9-3 ซึ่งกำหนดให้ T ถูกแทนที่ด้วยประเภทข้อมูล std::fs::File เมื่อเปิดไฟล์สำเร็จ และ E ถูกแทนที่ด้วยประเภทข้อมูล std::io::Error เมื่อตรวจพบข้อผิดพลาดระหว่างกระบวนการเปิดไฟล์

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

ในนิยามของ เมธอด (In Method Definitions)

เราสามารถเขียนคำสั่งนำเสนอเมธอดต่าง ๆ ให้กับ struct และ enum (แบบที่เราเรียนกันไปในบทที่ 5) และเรียกใช้งานประเภทข้อมูลเจเนอริกในการระบุนิยามได้เช่นเดียวกัน ตัวอย่างที่ 10-9 แสดงการสืบทอด struct Point<T> จากตัวอย่างที่ 10-6 โดยมีการสร้างเมธอดชื่อว่า x เข้าไปด้วย

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

ที่จุดนี้ เรานิยามเมธอดชื่อว่า x ให้กับ Point<T> ซึ่งจะคอยส่งคืนข้อมูลอ้างอิงของข้อมูลในฟิลด์ x กลับออกไป

สังเกตว่าเราต้องระบุตัวแปร T ไว้ถัดจากคำสำคัญ impl ทันที เพื่อให้ระบบนำ T ไปประมวลผลต่อว่าเราต้องการนำเสนอเมธอดให้กับประเภทข้อมูล Point<T> การประกาศระบุ T เป็นชนิดเจเนอริกต่อท้าย impl จะช่วยให้ Rust วิเคราะห์ได้ถูกต้องว่าข้อมูลภายในเครื่องหมายวงเล็บสามเหลี่ยมของคำว่า Point คือเจเนอริก ไม่ใช่ประเภทข้อมูลรูปธรรมคงที่ ความจริงแล้วเราสามารถกำหนดชื่อพารามิเตอร์เจเนอริกตรงจุดนี้ให้ต่างจากชื่อเจเนอริกในส่วนนิยาม struct ก็ได้ แต่ขนบทั่วไปจะเลือกใช้ชื่อเดียวกันเพื่อความไม่งง หากคุณเขียนสร้างเมธอดภายในบล็อก impl ที่ประกาศใช้เจเนอริก เมธอดดังกล่าวจะพร้อมถูกเรียกใช้งานในทุกอินสแตนซ์ของประเภทข้อมูลนั้น ๆ โดยไม่เกี่ยวว่าตัวแปรเจเนอริกดังกล่าวจะถูกแทนที่ด้วยประเภทรูปธรรมชนิดใดในภายหลัง

นอกจากนี้เรายังสามารถระบุข้อจำกัด (constraints) เพิ่มเติมให้กับเจเนอริกของประเภทข้อมูลเมื่อเราจะนิยามเมธอดได้ด้วย ตัวอย่างเช่น เราอาจต้องการเขียนสร้างเมธอดเฉพาะให้กับอินสแตนซ์ Point<f32> เท่านั้น แทนที่จะนำเสนอให้กับ Point<T> ทั่วไปของข้อมูลทุกชนิด ในตัวอย่างที่ 10-10 เราเลือกกำหนดชนิดข้อมูลรูปธรรมเป็น f32 ส่งผลให้เราไม่ต้องระบุประเภทเจเนอริกใด ๆ ต่อท้ายคำสั่ง impl เลย

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

โค้ดนี้ส่งผลให้เฉพาะประเภทข้อมูล Point<f32> เท่านั้นที่จะมีเมธอด distance_from_origin ไว้ใช้งาน ส่วนอินสแตนซ์ของ Point<T> อื่น ๆ ที่ T ไม่ได้มีประเภทเป็น f32 จะไม่มีการนิยามเมธอดนี้ไว้ เมธอดดังกล่าวทำหน้าที่คำนวณวัดระยะห่างระหว่างจุดพิกัดปัจจุบันกับจุดพิกัดเริ่มต้น (0.0, 0.0) ซึ่งมีการใช้ตรรกะกระบวนการคำนวณทางคณิตศาสตร์ที่มีสิทธิ์เรียกใช้เฉพาะกับประเภทตัวเลขทศนิยมเท่านั้น

พารามิเตอร์ประเภทข้อมูลเจเนอริกในนิยามของ struct ไม่จำเป็นต้องเหมือนกับพารามิเตอร์เจเนอริกที่ใช้ในส่วนซิกเนเจอร์ของเมธอดภายใน struct เสมอไป ตัวอย่างที่ 10-11 เลือกใช้ตัวแปรประเภทข้อมูลเจเนอริก X1 และ Y1 สำหรับ struct Point และเลือกใช้ X2 และ Y2 สำหรับซิกเนเจอร์ของเมธอด mixup เพื่ออธิบายตัวอย่างให้แจ่มชัด เมธอดนี้ทำหน้าที่สร้างอินสแตนซ์ Point ตัวใหม่ขึ้นมาโดยดึงค่า x มาจาก self Point (ซึ่งมีชนิดข้อมูลเป็น X1) และดึงค่า y มาจากอินพุต Point ที่ส่งเข้ามา (ซึ่งมีชนิดข้อมูลเป็น Y2)

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

ในฟังก์ชัน main เรานิยาม Point ตัวแรกให้มีฟิลด์ x เป็นประเภท i32 (เก็บค่า 5) และฟิลด์ y เป็นประเภท f64 (เก็บค่า 10.4) ส่วนตัวแปร p2 เป็น struct Point ที่มีแกน x เป็นประเภท string slice (เก็บข้อความ "Hello") และแกน y เป็นประเภท char (เก็บอักษร c) การเรียกใช้เมธอด mixup บนตัวแปร p1 โดยส่งอาร์กิวเมนต์เป็น p2 จะส่งผลให้ได้ผลลัพธ์กลับมาเป็น p3 ซึ่งจะมีแกน x เป็นประเภท i32 เนื่องจากได้ข้อมูลตั้งต้นมาจาก p1 และตัวแปร p3 จะมีแกน y เป็นประเภท char เนื่องจากดึงข้อมูลต้นทางมาจาก p2 มาโคร println! จะแสดงผลลัพธ์ลู่เข้าหาค่า p3.x = 5, p3.y = c

เป้าหมายของโค้ดตัวอย่างนี้เพื่อแสดงสถานการณ์ที่มีการประกาศพารามิเตอร์เจเนอริกบางส่วนไว้กับคำสั่ง impl และประกาศเจเนอริกอีกส่วนซ้อนอยู่กับการกำหนดนิยามเมธอดแยกเฉพาะ ในจุดนี้ พารามิเตอร์เจเนอริก X1 และ Y1 ถูกประกาศไว้ต่อท้าย impl เนื่องจากมีความสัมพันธ์โดยตรงกับนิยามของ struct หลัก ส่วนพารามิเตอร์เจเนอริก X2 และ Y2 จะประกาศไว้หลัง fn mixup เพราะมีบทบาทเกี่ยวข้องเฉพาะกับตัวเมธอดนี้เท่านั้น

ประสิทธิภาพการประมวลผลของโค้ดเมื่อมีการใช้งานเจเนอริก (Performance of Code Using Generics)

คุณอาจจะกำลังสงสัยอยู่ว่ามีค่าใช้จ่ายด้านประสิทธิภาพการทำงานในตอนประมวลผลโปรแกรม (runtime cost) หรือไม่เมื่อเลือกใช้งานพารามิเตอร์ประเภทข้อมูลเจเนอริก ข่าวดีคือการใช้งานประเภทข้อมูลเจเนอริกจะไม่ทำให้โปรแกรมของคุณทำงานช้าลงเลยเมื่อเทียบกับการเขียนระบุประเภทข้อมูลรูปธรรมแบบคงที่

ภาษา Rust ประสบผลสำเร็จในแง่นนี้ได้โดยทำกระบวนการแปลงโค้ดจากรูปแบบนามธรรมให้เป็นโค้ดเฉพาะเจาะจง (monomorphization) ในระหว่างขั้นตอนการคอมไพล์โค้ด Monomorphization คือกระบวนการแปลงโค้ดเจเนอริกเชิงนามธรรมให้กลายเป็นโค้ดเฉพาะทางโดยนำประเภทข้อมูลรูปธรรมจริง ๆ ที่ตรวจพบในโปรแกรมมาเติมใส่ตำแหน่งยึดในขั้นตอนคอมไพล์ ในกระบวนการนี้ คอมไพเลอร์จะดำเนินขั้นตอนย้อนกลับกับสิ่งที่เราทำตอนเขียนฟังก์ชันเจเนอริกในตัวอย่างที่ 10-5 กล่าวคือ: คอมไพเลอร์จะไล่สำรวจดูตำแหน่งทั้งหมดในโค้ดที่มีการเรียกใช้งานโค้ดเจเนอริก และสร้างชุดคำสั่งเฉพาะขึ้นมารองรับแต่ละชนิดประเภทข้อมูลรูปธรรมที่มีการเรียกใช้งานจริงขึ้นมาแทน

ลองมาศึกษาการทำงานจริงโดยอ้างอิงจาก enum Option<T> แบบเจเนอริกของไลบรารีมาตรฐาน:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

เมื่อ Rust สั่งคอมไพล์โค้ดชุดนี้ ระบบจะดำเนินการ monomorphization ในระหว่างกระบวนการนั้น คอมไพเลอร์จะอ่านข้อมูลค่าตัวแปรต่าง ๆ ที่มีการเรียกใช้งานในอินสแตนซ์ของ Option<T> และวิเคราะห์ว่าตรวจพบ Option<T> สองประเภท: ชนิดแรกเป็น i32 และชนิดที่สองเป็น f64 จากนั้นระบบจะขยายร่างนิยามโครงสร้างเจเนอริกของ Option<T> ออกมาเป็นคำนิยามแยกเฉพาะสองรูปแบบสำหรับ i32 และ f64 ส่งผลให้นิยามแบบเจเนอริกเดิมถูกแปลงเปลี่ยนเป็นนิยามชนิดข้อมูลเจาะจงทดแทนในทันที

ตัวโปรแกรมเวอร์ชันที่ผ่านกระบวนการ monomorphization เรียบร้อยแล้วจะมีหน้าตาโครงสร้างประมาณนี้ (แน่นอนว่าชื่อของระบบที่คอมไพเลอร์สร้างขึ้นมาจริงจะต่างไปจากชื่อที่เราใช้สมมติในตัวอย่างนี้):

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

คำนิยามแบบเจเนอริก Option<T> จะถูกเขียนแทนที่ด้วยคำนิยามแบบเฉพาะทางที่คอมไพเลอร์คอยดำเนินการสร้างให้โดยอัตโนมัติ เนื่องจาก Rust สั่งคอมไพล์โค้ดเจเนอริกออกมาเป็นภาษาเครื่องระดับโค้ดที่ระบุประเภทข้อมูลเฉพาะเจาะจงในแต่ละอินสแตนซ์แล้ว เราจึงไม่ต้องจ่ายต้นทุนประสิทธิภาพในการรันโปรแกรมเลยสำหรับการใช้งานเจเนอริก เมื่อโค้ดเริ่มรันการประมวลผล ประสิทธิภาพจะดีเท่ากับกรณีที่เราเขียนคัดลอกนิยามฟังก์ชันเหล่านั้นทีละฟังก์ชันด้วยตนเอง กระบวนการ monomorphization นี้ทำให้ระบบเจเนอริกของ Rust มีความเสถียรและทรงพลังในแง่ความเร็วรันไทม์เป็นอย่างมาก

Defining Shared Behavior with Traits

การกำหนดพฤติกรรมร่วมด้วยทริต (Defining Shared Behavior with Traits)

ทริต (trait) จะทำหน้าที่กำหนดฟังก์ชันการทำงานที่ประเภทข้อมูลใดประเภทข้อมูลหนึ่งมี และสามารถแบ่งปันให้กับประเภทข้อมูลอื่น ๆ ได้ด้วย เราสามารถประยุกต์ใช้งานทริตเพื่อเขียนกำหนดพฤติกรรมร่วม (shared behavior) ในรูปแบบเชิงนามธรรมได้ และเราสามารถใช้ ขอบเขตของทริต (trait bounds) เพื่อระบุเฉพาะเจาะจงว่าประเภทข้อมูลเจเนอริกนั้นสามารถเป็นประเภทข้อมูลใด ๆ ก็ได้ที่มีพฤติกรรมบางประการตามข้อกำหนด

หมายเหตุ: ทริตมีคุณลักษณะการทำงานที่คล้ายคลึงกับคุณสมบัติที่มักเรียกกันว่า อินเทอร์เฟซ (interfaces) ในภาษาอื่น ๆ แม้ว่าจะมีจุดแตกต่างกันอยู่บ้างบางส่วนก็ตาม

การนิยามทริต (Defining a Trait)

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

ตัวอย่างเช่น สมมติว่าเรามี struct หลายตัวที่ทำหน้าที่เก็บข้อมูลข้อความประเภทและขนาดที่ต่างกัน: ได้แก่ struct NewsArticle ที่ใช้จัดเก็บเนื้อข่าวที่รายงานจากพิกัดตำแหน่งใดพิกัดหนึ่ง และ struct SocialPost ที่เก็บข้อมูลตัวอักษรได้ไม่เกิน 280 ตัวอักษร พร้อมทั้งมีข้อมูลเมทาดาตา (metadata) บ่งบอกสถานะว่าเป็นโพสต์ใหม่ โพสต์ซ้ำ หรือเป็นโพสต์ตอบกลับโพสต์อื่น

เราต้องการสร้างเครตประเภทไลบรารีสำหรับรวบรวมสื่อข่าวสาร (media aggregator library crate) ชื่อว่า aggregator ซึ่งพร้อมทำหน้าที่แสดงผลสรุปของข้อมูลที่อาจจะจัดเก็บอยู่ภายใต้อินสแตนซ์ของ NewsArticle หรือ SocialPost ในการทำเช่นนี้ เราต้องการสรุปความจากข้อมูลแต่ละประเภท และเราจะดึงข้อมูลสรุปดังกล่าวโดยสั่งเรียกเมธอด summarize บนอินสแตนซ์เหล่านั้น ตัวอย่างที่ 10-12 แสดงนิยามของทริตสาธารณะ Summary ที่แสดงถึงพฤติกรรมนี้

pub trait Summary {
    fn summarize(&self) -> String;
}

ที่จุดนี้ เราประกาศสร้างทริตขึ้นมาโดยใช้คีย์เวิร์ด trait ตามด้วยชื่อของทริต ซึ่งในที่นี้คือ Summary นอกจากนี้เรายังประกาศระบุทริตนี้เป็นแบบสาธารณะ pub เพื่อเปิดโอกาสให้เครตส์ (crates) อื่นที่อ้างอิงเครตนี้สามารถดึงทริตไปเขียนใช้งานได้ด้วยดังที่จะแสดงในตัวอย่างถัด ๆ ไป ภายในเครื่องหมายวงเล็บปีกกา เราจะเขียนประกาศตัวแปรซิกเนเจอร์ของเมธอดต่าง ๆ เพื่อแสดงชุดพฤติกรรมของประเภทข้อมูลที่จะนำทริตนี้ไปสืบทอดใช้งาน ซึ่งในที่นี้คือคำสั่ง fn summarize(&self) -> String

ต่อท้ายซิกเนเจอร์ของเมธอด แทนที่เราจะเขียนรายละเอียดการทำงานภายในเครื่องหมายวงเล็บปีกกา เราจะปิดท้ายด้วยเครื่องหมายอัฒภาค (semicolon) ชนิดประเภทข้อมูลใด ๆ ที่นำทริตนี้ไปสืบทอดประยุกต์ใช้จะต้องเป็นฝ่ายเขียนนิยามรายละเอียดการทำงานในส่วนบอดี้ของเมธอดด้วยตนเอง ตัวคอมไพเลอร์จะคอยบังคับตรวจสอบให้มั่นใจว่าประเภทข้อมูลใด ๆ ที่สืบทอดทริต Summary จะต้องมีคำนิยามเมธอด summarize ที่มีโครงสร้างซิกเนเจอร์ตรงกับเงื่อนไขนี้อย่างถูกต้องเป๊ะ

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

การสืบทอดทริตให้กับประเภทข้อมูล (Implementing a Trait on a Type)

เมื่อเรานิยามซิกเนเจอร์ที่ต้องการให้กับเมธอดของทริต Summary เรียบร้อยแล้ว เราจะสามารถเริ่มสืบทอดให้กับประเภทข้อมูลต่าง ๆ ในโครงการ media aggregator ของเราได้ ตัวอย่างที่ 10-13 แสดงการสืบทอดทริต Summary ให้กับ struct NewsArticle โดยดึงหัวข้อข่าว ผู้เขียน และสถานที่มาร้อยเรียงสร้างเป็นข้อความสำหรับส่งคืนค่าจาก summarize ส่วนในฝั่งของ struct SocialPost เราจะนิยามเมธอด summarize ให้ส่งค่าเป็นชื่อผู้ใช้ตามด้วยเนื้อความของโพสต์ทั้งหมด โดยตั้งสมมติฐานว่าโพสต์ดังกล่าวถูกจำกัดความยาวไว้ไม่เกิน 280 ตัวอักษรเรียบร้อยแล้ว

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

การสืบทอดทริตให้กับประเภทข้อมูลใด ๆ จะมีหลักการเขียนคล้ายกับการสืบทอดเมธอดทั่วไป จุดต่างคือ หลังคำสำคัญ impl เราจะต้องใส่ชื่อทริตที่เราต้องการนำมาสืบทอดใช้งาน จากนั้นพิมพ์คีย์เวิร์ด for แล้วจึงระบุชื่อประเภทข้อมูลที่เราต้องการสร้างทริตนี้ให้ ภายในบล็อกคำสั่ง impl เราจะนำซิกเนเจอร์ของเมธอดต่าง ๆ ที่ถูกประกาศไว้ในนิยามทริตมาใส่ แทนที่จะใช้เครื่องหมาย semicolon ปิดท้ายแต่ละซิกเนเจอร์ เราจะเปลี่ยนมาเขียนบล็อกวงเล็บปีกกาและเติมรายละเอียดเนื้อความบอดี้ของเมธอดเพื่อกำหนดพฤติกรรมการทำงานเฉพาะทางที่ประเภทข้อมูลนั้นต้องการนำเสนอสำหรับทริต

เมื่อตัวไลบรารีได้นำเสนอทริต Summary ให้แก่ NewsArticle และ SocialPost เรียบร้อยแล้ว ผู้ใช้งานเครตนี้จะสามารถเรียกใช้งานเมธอดต่าง ๆ ของทริตบนอินสแตนซ์ของ NewsArticle และ SocialPost ได้ทันทีแบบเดียวกับการเรียกใช้งานเมธอดปกติทั่วไป จุดต่างเพียงอย่างเดียวคือตัวผู้ใช้งานต้องทำการดึงเอาตัวแปรทริตเข้ามาไว้ในขอบเขตการใช้งาน (scope) นอกเหนือจากตัวแปรประเภทข้อมูล ตัวอย่างเช่น โค้ดของ binary crate ที่เรียกใช้ library crate aggregator ของเรามีลักษณะดังนี้:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

โค้ดนี้จะแสดงผลออกมาเป็น: 1 new post: horse_ebooks: of course, as you probably already know, people

เครตอื่น ๆ ที่ทำงานขึ้นตรงต่อเครต aggregator สามารถดึงทริต Summary เข้ามาไว้ในขอบเขตการใช้งานเพื่อสืบทอด Summary ให้กับประเภทข้อมูลของตนเองได้เช่นเดียวกัน อย่างไรก็ตาม มีข้อจำกัดประการหนึ่งที่ต้องตระหนักไว้คือ เราสามารถเลือกเขียนสืบทอดทริตให้กับประเภทข้อมูลใด ๆ ได้ก็ต่อเมื่อ ทริตดังกล่าว หรือประเภทข้อมูลดังกล่าว หรือทั้งคู่ มีฐานะเป็นทรัพยากรภายใน (local) ของเครตเราเองเท่านั้น ตัวอย่างเช่น เราสามารถนำทริตของไลบรารีมาตรฐานอย่าง Display มาสืบทอดให้ประเภทข้อมูลเฉพาะอย่าง SocialPost ได้เพราะ struct SocialPost เป็นทรัพยากรภายในเครต aggregator ของเรา หรือเราอาจสืบทอดทริต Summary ให้แก่โครงสร้างข้อมูล Vec<T> ได้เช่นกันเพราะทริต Summary เป็นทรัพยากรท้องถิ่นในเครต aggregator ของเรา

แต่ทว่าเราไม่สามารถสืบทอดทริตภายนอก (external traits) ให้กับประเภทข้อมูลภายนอก (external types) ได้ ตัวอย่างเช่น เราไม่สามารถเขียนสืบทอดทริต Display ให้แก่ Vec<T> ภายในเครต aggregator ของเราได้ เนื่องจากทั้งทริต Display และ Vec<T> ต่างก็ถูกกำหนดไว้ในไลบรารีมาตรฐานและไม่ได้มีฐานะเป็นทรัพยากรภายในเครต aggregator ข้อจำกัดนี้เป็นส่วนหนึ่งของกฎเกณฑ์ที่เรียกว่า ความสอดคล้อง (coherence) หรือที่เรียกเฉพาะเจาะจงว่า กฎกำพร้า (orphan rule - ได้ชื่อนี้มาเพราะประเภทข้อมูลแม่ไม่ได้อยู่ในเครตเดียวกัน) กฎนี้ช่วยให้มั่นใจได้ว่าโค้ดของผู้อื่นจะไม่ไปสร้างผลกระทบต่อโค้ดของคุณ และโค้ดของคุณจะไม่ไปทำลายโค้ดของคนอื่น หากปราศจากกฎนี้ เครตสองตัวอาจจะเขียนสืบทอดทริตเดียวกันให้ประเภทข้อมูลชนิดเดียวกัน ซึ่งจะส่งผลให้ Rust ตัดสินใจเลือกไม่ถูกว่าควรจะเรียกใช้งานโค้ดของเครตใด

การใช้งานพฤติกรรมเริ่มต้นของทริต (Using Default Implementations)

ในบางกรณี การกำหนดพฤติกรรมการทำงานเริ่มต้น (default behavior) ให้แก่เมธอดบางส่วนหรือทั้งหมดในทริตจะมีประโยชน์อย่างยิ่ง เพื่อช่วยลดความจำเป็นที่ต้องเขียนนิยามรายละเอียดการทำงานของทุกเมธอดซ้ำ ๆ สำหรับทุกประเภทข้อมูล จากนั้นเมื่อเรานำทริตไปสืบทอดให้กับประเภทข้อมูลเฉพาะ เราจะเลือกใช้การทำงานเริ่มต้นนั้น หรือจะเขียนนิยามใหม่ทับ (override) พฤติกรรมเริ่มต้นของเมธอดนั้น ๆ ก็ได้ตามต้องการ

ในตัวอย่างที่ 10-14 เราได้ระบุค่าข้อความสตริงเริ่มต้นสำหรับเมธอด summarize ของทริต Summary แทนที่จะเขียนเฉพาะประกาศซิกเนเจอร์แบบเดิมในตัวอย่างที่ 10-12

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

หากต้องการนำเอาการทำงานเริ่มต้นนี้ไปใช้สำหรับสรุปผลลัพธ์ของอินสแตนซ์ NewsArticle เราเพียงแค่ประกาศบล็อกคำสั่ง impl ที่ว่างเปล่าดังนี้ impl Summary for NewsArticle {}

แม้ว่าเราจะไม่ได้ระบุเขียนรายละเอียดการทำงานของเมธอด summarize บน NewsArticle โดยตรง แต่ทว่าเนื่องจากเราเตรียมโครงสร้างการทำงานเริ่มต้นไว้แล้วและระบุว่า NewsArticle มีการสืบทอดทริต Summary ส่งผลให้เรายังคงสามารถสั่งเรียกใช้เมธอด summarize บนอินสแตนซ์ของ NewsArticle ได้เช่นเดิม ดังตัวอย่าง:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

โค้ดนี้จะแสดงผลออกมาเป็น: New article available! (Read more...)

การจัดทำโครงสร้างพฤติกรรมเริ่มต้นนี้ไม่ได้ส่งผลให้เราต้องไปแก้ไขโค้ดการสืบทอดทริต Summary บน struct SocialPost ในตัวอย่างที่ 10-13 แต่อย่างใด เนื่องจากไวยากรณ์ที่ใช้สำหรับเขียนทับ (overriding) พฤติกรรมเริ่มต้นนั้นมีลักษณะแบบเดียวกันเป๊ะกับไวยากรณ์สำหรับนิยามการสืบทอดเมธอดที่ไม่มีค่าเริ่มต้นอยู่ก่อน

พฤติกรรมเริ่มต้นของเมธอดสามารถเข้าไปสั่งเรียกใช้งานเมธอดตัวอื่น ๆ ภายในทริตเดียวกันได้ แม้ว่าเมธอดตัวอื่นนั้นจะไม่ได้นิยามพฤติกรรมเริ่มต้นไว้ก็ตาม ด้วยคุณสมบัตินี้ ทริตจึงพร้อมมอบชุดคำสั่งการทำงานที่มีประโยชน์จำนวนมากและช่วยให้นักพัฒนาเขียนสืบทอดส่วนที่เหลือเพียงเล็กน้อยเท่านั้น ตัวอย่างเช่น เราสามารถนิยามทริต Summary โดยระบุว่าจำเป็นต้องมีการเขียนสืบทอดเมธอด summarize_author จากนั้นจึงเขียนสร้างเมธอด summarize ที่มีพฤติกรรมเริ่มต้นคอยสั่งเรียกใช้งานเมธอด summarize_author อีกที:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

ในการใช้งานทริต Summary เวอร์ชันนี้ เราจึงจำเป็นต้องระบุนิยามเฉพาะของเมธอด summarize_author เมื่อเราทำการสืบทอดทริตให้กับประเภทข้อมูลใด ๆ:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

หลังจากที่เราเขียนนิยามเมธอด summarize_author เรียบร้อยแล้ว เราจะสามารถสั่งเรียกใช้ summarize บนอินสแตนซ์ของ struct SocialPost ได้ทันที และตัวระบบพฤติกรรมเริ่มต้นของ summarize จะทำหน้าที่สืบค้นเรียกใช้งานเมธอด summarize_author ที่เราเขียนส่งขึ้นไปให้โดยอัตโนมัติ การที่เราสืบทอด summarize_author จึงทำให้ทริต Summary มอบชุดคำสั่งของเมธอด summarize มาให้เราทันทีโดยที่เราไม่ต้องเขียนโค้ดเพิ่มเติมเลย หน้าตาผลลัพธ์เป็นดังนี้:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

โค้ดนี้จะแสดงผลเป็น: 1 new post: (Read more from @horse_ebooks...)

หมายเหตุ: เราไม่สามารถสั่งเรียกพฤติกรรมเริ่มต้น (default implementation) จากภายในเมธอดเวอร์ชันที่เราเขียนคำสั่งทับ (override) ของเมธอดตัวเดียวกันนั้นได้

การใช้งานทริตเป็นพารามิเตอร์ (Using Traits as Parameters)

เมื่อคุณรู้วิธีการกำหนดและสืบทอดทริตแล้ว เราสามารถนำทริตมาประยุกต์ใช้ในการเขียนนิยามฟังก์ชันที่ยินดีต้อนรับประเภทข้อมูลที่หลากหลายได้ เราจะดึงทริต Summary ที่เราเคยเขียนสืบทอดให้กับประเภทข้อมูล NewsArticle และ SocialPost ในตัวอย่างที่ 10-13 มาใช้ในการนิยามฟังก์ชัน notify ซึ่งทำหน้าที่เรียกเมธอด summarize บนตัวแปรพารามิเตอร์ item ซึ่งเป็นประเภทข้อมูลใด ๆ ก็ตามที่สืบทอดทริต Summary ในการเขียนคำสั่งดังกล่าวเราจะหันมาใช้งานไวยากรณ์ impl Trait ดังนี้:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

แทนที่จะระบุประเภทข้อมูลรูปธรรมแบบคงที่ให้แก่พารามิเตอร์ item เราจะใส่คีย์เวิร์ด impl นำหน้าชื่อของทริตแทน พารามิเตอร์นี้จึงยอมรับประเภทข้อมูลใด ๆ ก็ตามที่สืบทอดทริตตามที่ระบุไว้ ภายในส่วนการประมวลผลของฟังก์ชัน notify เราจะมีสิทธิ์สั่งเรียกใช้เมธอดต่าง ๆ บนตัวแปร item ที่ผูกอยู่กับทริต Summary เช่น เมธอด summarize เราสามารถส่งอินสแตนซ์ของ NewsArticle หรือ SocialPost เข้าไปในฟังก์ชัน notify ได้อย่างอิสระ ส่วนโค้ดใด ๆ ที่พยายามจะส่งข้อมูลประเภทอื่น เช่น String หรือ i32 เข้าไปในฟังก์ชันนี้จะไม่สามารถคอมไพล์ผ่านได้ เนื่องจากประเภทเหล่านั้นไม่ได้เขียนสืบทอดทริต Summary เอาไว้

ไวยากรณ์ขอบเขตของทริต (Trait Bound Syntax)

ไวยากรณ์ impl Trait ทำงานได้ดีในกรณีทั่วไปที่ตรงไปตรงมา แต่แท้จริงแล้วมันคือการลดรูปไวยากรณ์ (syntax sugar) ของรูปแบบที่ยาวกว่าซึ่งเรียกว่า ขอบเขตของทริต (trait bound) ซึ่งมีหน้าตาไวยากรณ์ดังนี้:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

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

การใช้ไวยากรณ์ impl Trait จะสะดวกและให้โค้ดที่สั้นในกรณีทั่วไปที่ไม่ซับซ้อน ส่วนการเขียนแบบ trait bound เต็มรูปแบบจะทรงพลังและรองรับกรณีที่โครงสร้างเงื่อนไขซับซ้อนได้ดีกว่า ตัวอย่างเช่น เราอาจต้องการให้พารามิเตอร์สองตัวสืบทอดทริต Summary หากเขียนด้วยไวยากรณ์ impl Trait จะมีลักษณะดังนี้:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

การเลือกใช้ impl Trait แบบนี้จะเหมาะสมหากเราประสงค์ให้ฟังก์ชันยอมรับให้ item1 และ item2 สามารถเก็บประเภทข้อมูลต่างชนิดกันได้ (ตราบใดที่ทั้งสองประเภทนั้นต่างก็สืบทอดทริต Summary) แต่ทว่าหากคุณต้องการบังคับให้พารามิเตอร์ทั้งสองตัวต้องมีประเภทข้อมูลที่เหมือนกันทุกประการ คุณจำเป็นต้องสลับมาเขียนขอบเขตของทริตแบบ trait bound ดังนี้:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

ประเภทข้อมูลเจเนอริก T ที่ระบุเป็นชนิดของพารามิเตอร์ item1 และ item2 จะทำหน้าที่บีบข้อจำกัดให้ตัวคอมไพเลอร์คอยเช็คว่าประเภทข้อมูลรูปธรรมของอาร์กิวเมนต์ที่ส่งเข้ามาสำหรับ item1 และ item2 จะต้องเป็นประเภทเดียวกันเท่านั้น

การระบุขอบเขตทริตหลายตัวร่วมกันด้วยเครื่องหมาย + (Multiple Trait Bounds with the + Syntax)

เราสามารถกำหนดขอบเขตของทริตหลายตัวเข้าด้วยกันได้ สมมติว่าเราต้องการให้ฟังก์ชัน notify สามารถแสดงผลข้อมูลข้อความผ่านตัวยึดอักขระนอกเหนือจากการสรุปความด้วยเมธอด summarize บนตัวแปร item ได้: เราจะเขียนระบุในคำนิยาม notify ว่าตัวแปร item จำเป็นต้องสืบทอดทริตทั้ง Display และ Summary ซึ่งเราประยุกต์ใช้งานเครื่องหมายบวก + ได้ดังนี้:

pub fn notify(item: &(impl Summary + Display)) {

ไวยากรณ์เครื่องหมายบวก + นี้ยังนำมาใช้งานกับขอบเขตทริตของประเภทเจเนอริกได้เช่นเดียวกัน:

pub fn notify<T: Summary + Display>(item: &T) {

เมื่อมีการระบุขอบเขตของทริตทั้งสองตัวเรียบร้อยแล้ว ภายในโครงสร้างการทำงานของ notify จะได้รับอนุญาตให้เรียกใช้เมธอด summarize และประยุกต์ใช้งานเครื่องหมาย {} เพื่อจัดการแสดงผลพารามิเตอร์ item ได้

การเขียนระบุขอบเขตของทริตให้สะอาดขึ้นด้วยบล็อก where (Clearer Trait Bounds with where Clauses)

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

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

เราสามารถสลับมาใช้งานบล็อก where ได้ดังนี้:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

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

การส่งคืนประเภทข้อมูลที่สืบทอดทริต (Returning Types That Implement Traits)

เราสามารถนำไวยากรณ์ impl Trait ไปวางไว้ตรงตำแหน่งผลลัพธ์การส่งคืนค่าของฟังก์ชัน เพื่อระบุว่าฟังก์ชันนี้จะส่งคืนออบเจกต์ประเภทใดก็ได้ที่สืบทอดทริตตามต้องการ ดังแสดงไว้ที่นี่:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

การเลือกเขียนระบุประเภทข้อมูลส่งคืนเป็น impl Summary หมายความว่า ฟังก์ชัน returns_summarizable จะส่งคืนออบเจกต์ประเภทข้อมูลใดก็ปานที่ต้องมีการสืบทอดทริต Summary โดยไม่จำเป็นต้องเอ่ยชื่อประเภทข้อมูลรูปธรรมนั้นออกมาตรง ๆ ในตัวอย่างนี้ ฟังก์ชัน returns_summarizable ส่งค่ากลับออกมาเป็น SocialPost แต่โค้ดระดับบนที่เป็นคนเรียกใช้ฟังก์ชันไม่จำเป็นต้องรู้รายละเอียดส่วนลึกข้อนี้เลย

ความสามารถในการระบุชนิดข้อมูลส่งคืนด้วยเงื่อนไขทริตที่สืบทอดจะมีประโยชน์อย่างยิ่งสำหรับบริบทของโคลเชอร์ (closures) และตัววนลูป (iterators) ซึ่งเราจะมาศึกษาต่อในบทที่ 13 เนื่องจากกระบวนการของโคลเชอร์และตัววนลูปมักสร้างประเภทข้อมูลแปลกใหม่ที่คอมไพเลอร์เข้าใจเพียงผู้เดียว หรือสร้างประเภทข้อมูลที่มีความยาวจนเขียนอธิบายได้ลำบาก ไวยากรณ์ impl Trait จึงเข้ามาช่วยให้นักพัฒนาเขียนอธิบายได้อย่างสั้นกระชับว่าฟังก์ชันนี้จะส่งคืนข้อมูลบางประเภทที่สืบทอดทริต Iterator โดยไม่ต้องพิมพ์อธิบายชนิดประเภทข้อมูลที่ยาวเหยียด

อย่างไรก็ตาม เงื่อนไขคือคุณสามารถใช้ไวยากรณ์ impl Trait ได้ก็ต่อเมื่อตัวฟังก์ชันหลักมีการส่งคืนประเภทข้อมูลรูปธรรมเพียงชนิดเดียวเท่านั้น ตัวอย่างเช่น โค้ดชุดนี้ที่พยายามส่งคืนค่าเป็น NewsArticle หรือไม่ก็ SocialPost สลับกันตามเงื่อนไข แต่ระบุชนิดข้อมูลส่งคืนเป็น impl Summary จะไม่สามารถคอมไพล์ผ่านได้:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

การส่งคืนค่าแบบสลับกันระหว่าง NewsArticle กับ SocialPost ภายใต้ไวยากรณ์นี้ยังไม่ได้รับสิทธิ์ให้ผ่านได้เนื่องจากข้อจำกัดในการประมวลผลไวยากรณ์ impl Trait ในขั้นตอนทำหน้าที่ของคอมไพเลอร์ เราจะมาเรียนรู้แนวทางการเขียนโครงสร้างฟังก์ชันเพื่อตอบสนองพฤเบตเป้าหมายความต้องการนี้ร่วมกันในหัวข้อ “การใช้งานทริตอ็อบเจกต์เพื่อสร้างรูปแบบนามธรรมทับพฤติกรรมร่วม (Using Trait Objects to Abstract over Shared Behavior)” ในบทที่ 18

การใช้ขอบเขตของทริตเพื่อเลือกสร้างเมธอดตามเงื่อนไข (Using Trait Bounds to Conditionally Implement Methods)

การเลือกใส่ขอบเขตของทริตจำกัดร่วมกับบล็อกคำสั่ง impl ที่มีพารามิเตอร์ประเภทข้อมูลเจเนอริก ช่วยให้เราสามารถสร้างเมธอดเฉพาะตามเงื่อนไขที่กำหนดให้แก่ประเภทข้อมูลที่สืบทอดทริตที่ระบุไว้ได้ ตัวอย่างเช่น ประเภทข้อมูล Pair<T> ในตัวอย่างที่ 10-15 จะสืบทอดฟังก์ชัน new เสมอเพื่อส่งคืนอินสแตนซ์ของ Pair<T> ออกมา (ย้อนความจากหัวข้อ “ไวยากรณ์ของเมธอด (Method Syntax)” ในบทที่ 5 ว่าคำคีย์เวิร์ด Self คือประเภทข้อมูลสมญา (type alias) สำหรับประเภทข้อมูลในบล็อกคำสั่ง impl ซึ่งในที่นี้คือ Pair<T>) แต่ทว่าในบล็อกคำสั่ง impl ถัดไป ตัว Pair<T> จะได้รับการยอมรับให้สร้างเมธอด cmp_display ขึ้นมาได้ก็ต่อเมื่อ ประเภทข้อมูลภายใน T มีคุณสมบัติสืบทอดทริต PartialOrd สำหรับการเปรียบเทียบขนาด และ สืบทอดทริต Display สำหรับสั่งแสดงผลข้อมูลเรียบร้อยแล้วเท่านั้น

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

นอกจากนี้เรายังสามารถเขียนคำสั่งเพื่อสืบทอดทริตตัวหนึ่งให้แก่ประเภทข้อมูลใด ๆ ที่มีการสืบทอดทริตอีกตัวหนึ่งอยู่ก่อนหน้าแล้วตามเงื่อนไข การสืบทอดทริตให้กับทุกประเภทข้อมูลที่เป็นไปตามเงื่อนไขจำกัดเรียกว่า การสืบทอดคลุมทั้งหมด (blanket implementations) ซึ่งถูกประยุกต์ใช้งานอย่างกว้างขวางในคลังไลบรารีมาตรฐานของ Rust ตัวอย่างเช่น ไลบรารีมาตรฐานจะเขียนคำสั่งสืบทอดทริต ToString ให้แก่ทุกประเภทข้อมูลที่สืบทอดทริต Display อยู่ก่อน บล็อกคำสั่ง impl ในไลบรารีมาตรฐานมีโครงสร้างหน้าตาประมาณโค้ดบรรทัดนี้:

impl<T: Display> ToString for T {
    // --snip--
}

เพราะไลบรารีมาตรฐานได้ทำ blanket implementation นี้ไว้ให้เรียบร้อยแล้ว เราจึงพร้อมสั่งเรียกใช้เมธอด to_string ที่นิยามอยู่ภายใต้ทริต ToString บนประเภทข้อมูลใด ๆ ก็ตามที่สืบทอดทริต Display ได้ทันที ตัวอย่างเช่น เราสามารถแปลงข้อมูลประเภทตัวเลขจำนวนเต็มให้เป็นออบเจกต์สตริง String ได้อย่างง่ายดายด้วยคำสั่งดังนี้ เนื่องจากจำนวนเต็มใน Rust มีคุณสมบัติสืบทอดทริต Display อยู่แล้ว:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

การเขียนโครงสร้าง blanket implementations จะแสดงรายละเอียดอธิบายไว้ในส่วนของเอกสารทริตตรงหัวข้อ “Implementors”

ทริตและขอบเขตของทริตช่วยให้นักพัฒนาเขียนโค้ดที่ใช้พารามิเตอร์ประเภทเจเนอริกเพื่อช่วยประหยัดบรรทัดโค้ดซ้ำซ้อน แต่ในขณะเดียวกันก็ช่วยส่งมอบข้อมูลจำกัดความต้องการแก่คอมไพเลอร์ว่าเราประสงค์ให้ประเภทข้อมูลเจเนอริกมีพฤติกรรมรองรับแบบใด จากนั้นคอมไพเลอร์จะนำข้อมูลเงื่อนไขจำกัดดังกล่าวไปใช้ในการตรวจสอบให้มั่นใจเต็มร้อยว่า ทุกประเภทข้อมูลรูปธรรมที่ถูกนำเข้ามาประมวลผลร่วมกับโค้ดของเรามีพฤติกรรมที่ถูกต้องครบถ้วนจริง ในภาษาโปรแกรมระบบ Dynamic typing เราจะพบเจอข้อผิดพลาดที่รันไทม์ (runtime error) ทันทีหากโปรแกรมไปสั่งเรียกใช้เมธอดที่ประเภทข้อมูลนั้นไม่ได้เขียนเตรียมไว้ แต่ภาษา Rust ได้ดึงเอาข้อผิดพลาดเหล่านี้ขึ้นมาแสดงในขั้นตอนคอมไพล์ เพื่อบังคับให้นักพัฒนาแก้ไขและกำจัดปัญหาเหล่านั้นออกไปให้หมดก่อนที่ตัวโปรแกรมจะเริ่มทำงานเสียด้วยซ้ำ ส่งผลให้เราไม่จำเป็นต้องเขียนโค้ดคอยตรวจสอบเงื่อนไขพฤติกรรมในตอนโปรแกรมรันอีกต่อไป ซึ่งกระบวนการนี้จะช่วยยกระดับความเร็วในการประมวลผลเพิ่มขึ้นโดยไม่ต้องยอมสละความยืดหยุ่นของระบบเจเนอริกไป

Validating References with Lifetimes

การตรวจสอบความถูกต้องของข้อมูลอ้างอิงด้วยอายุการใช้งาน (Validating References with Lifetimes)

อายุการใช้งาน หรือ ไลฟ์ไทม์ (lifetimes) คือเจเนอริกอีกประเภทหนึ่งที่เราได้เขียนใช้งานกันไปก่อนหน้านี้แล้ว แทนที่จะคอยตรวจสอบเงื่อนไขว่าประเภทข้อมูลมีพฤติกรรมตามที่เราต้องการหรือไม่ ไลฟ์ไทม์จะทำหน้าที่ช่วยรับประกันว่า ข้อมูลอ้างอิง (references) จะยังคงมีความถูกต้องสมบูรณ์ (valid) ตราบเท่านานที่เรามีความจำเป็นต้องใช้งานมัน

รายละเอียดประการหนึ่งที่เราไม่ได้พูดคุยกันในหัวข้อ “ข้อมูลอ้างอิงและการยืมใช้งาน (References and Borrowing)” ในบทที่ 4 ก็คือ ทุก ๆ ข้อมูลอ้างอิงในภาษา Rust ต่างก็มีอายุการใช้งานเป็นของตัวเอง ซึ่งก็คือขอบเขตการใช้งาน (scope) ที่ข้อมูลอ้างอิงดังกล่าวจะยังคงมีความถูกต้องสมบูรณ์ โดยปกติทั่วไป ไลฟ์ไทม์จะมีลักษณะแฝงและถูกคาดเดา (infer) โดยอัตโนมัติ เช่นเดียวกับกระบวนการวิเคราะห์ประเภทข้อมูลทั่วไป เรามีหน้าที่ต้องเขียนระบุประเภทข้อมูลเฉพาะตอนที่มีความเป็นไปได้ว่าข้อมูลอาจเป็นได้หลายประเภท ในลักษณะเดียวกัน เราจำเป็นต้องเขียนระบุไลฟ์ไทม์เฉพาะเมื่ออายุการใช้งานของข้อมูลอ้างอิงแต่ละตัวมีความเชื่อมโยงเกี่ยวพันกันได้หลายรูปแบบ ภาษา Rust กำหนดให้เราเขียนระบุความสัมพันธ์นี้ผ่านทางพารามิเตอร์ไลฟ์ไทม์เจเนอริก (generic lifetime parameters) เพื่อให้มั่นใจได้อย่างเด็ดขาดว่าข้อมูลอ้างอิงที่จะถูกนำไปใช้งานจริงในตอนรันไทม์ (runtime) จะใช้งานได้จริงและถูกต้องสมบูรณ์

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

ข้อมูลอ้างอิงที่ห้อยเคว้ง (Dangling References)

เป้าหมายหลักของการนำไลฟ์ไทม์มาใช้งานคือเพื่อป้องกันการเกิดข้อมูลอ้างอิงห้อยเคว้ง (dangling references) ซึ่งหากปล่อยให้เกิดขึ้นมา จะส่งผลให้ตัวโปรแกรมชี้ข้อมูลอ้างอิงไปยังตำแหน่งหน่วยความจำอื่นนอกเหนือจากข้อมูลที่ควรจะเป็น ลองพิจารณาตัวโปรแกรมในตัวอย่างที่ 10-16 ซึ่งมีขอบเขตการทำงานด้านนอก (outer scope) และขอบเขตการทำงานด้านใน (inner scope)

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}

หมายเหตุ: ตัวอย่างในบทเรียนที่ 10-16, 10-17, และ 10-23 มีการเขียนประกาศตัวแปรโดยไม่มีการป้อนค่าเริ่มต้น ส่งผลให้ชื่อตัวแปรนั้นมีตัวตนอยู่ในขอบเขตด้านนอก ในแวบแรก โครงสร้างนี้อาจดูเหมือนขัดแย้งกับหลักการของ Rust ที่ห้ามไม่ให้มีค่าว่างเปล่า (null values) อย่างไรก็ตาม หากเราพยายามดึงตัวแปรมาใช้งานก่อนที่จะทำการป้อนค่าให้แก่ตัวแปรนั้น ระบบจะแสดงข้อความผิดพลาดในขั้นตอนคอมไพล์ ซึ่งเป็นสิ่งยืนยันว่า Rust ไม่อนุญาตให้มีค่า null จริง ๆ

ขอบเขตด้านนอกทำการประกาศตัวแปรชื่อว่า r โดยยังไม่มีการกำหนดค่าเริ่มต้น และขอบเขตด้านในประกาศตัวแปรชื่อว่า x พร้อมป้อนค่าเริ่มต้นเป็น 5 ภายในขอบเขตด้านใน เราพยายามสั่งกำหนดให้ r อ้างอิงชี้ไปยัง x จากนั้น เมื่อสิ้นสุดการทำงานของขอบเขตด้านใน เราพยายามจะพิมพ์ค่าในตัวแปร r ออกมาแสดงผล โค้ดนี้จะไม่สามารถคอมไพล์ผ่านได้ เนื่องจากค่าต้นทางที่ r ชี้อยู่นั้นได้หลุดออกนอกขอบเขตการใช้งานไปเรียบร้อยแล้วก่อนที่เราจะสั่งดึงมาใช้งาน ข้อความแจ้งข้อผิดพลาดเป็นดังนี้:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                   - borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

ข้อความผิดพลาดระบุว่าตัวแปร x “มีอายุไม่ยาวนานพอ” (does not live long enough) เหตุผลคือ x จะหลุดออกนอกขอบเขตเมื่อขอบเขตด้านในสิ้นสุดลงที่บรรทัดที่ 7 แต่ทว่าตัวแปร r ยังคงมีสถานะใช้งานได้ในขอบเขตด้านนอก เนื่องจากขอบเขตของมันมีขนาดกว้างใหญ่กว่า เราจึงเรียกว่ามัน “มีอายุยาวนานกว่า” หาก Rust ยอมปล่อยให้โค้ดชุดนี้ทำงานได้ ตัวแปร r จะไปอ้างอิงตำแหน่งหน่วยความจำที่ถูกคืนสิทธิ์คืนระบบ (deallocated) ไปแล้วเมื่อตอน x หลุดขอบเขต และขั้นตอนการทำงานใด ๆ ที่เราพยายามประมวลผลต่อกับ r จะไม่สามารถทำงานได้อย่างถูกต้อง ดังนั้น Rust ตรวจวิเคราะห์ได้อย่างไรว่าโค้ดนี้ไม่มีความถูกต้อง? คำตอบคือมันใช้งานระบบตรวจสอบสิทธิ์การยืม (borrow checker)

ระบบตรวจสอบสิทธิ์การยืม (The Borrow Checker)

ตัวคอมไพเลอร์ Rust มีระบบหนึ่งชื่อว่า borrow checker ซึ่งทำหน้าที่เปรียบเทียบขนาดของขอบเขตข้อมูลเพื่อวิเคราะห์ว่าการยืมใช้งาน (borrows) ทั้งหมดในโปรแกรมมีความถูกต้องดีหรือไม่ ตัวอย่างที่ 10-17 แสดงโค้ดชุดเดิมจากตัวอย่างที่ 10-16 แต่มีการเขียนบันทึกจำลองให้เห็นขอบเขตอายุการใช้งานของตัวแปรแต่ละตัว

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+

ที่จุดนี้ เราสมมติให้อายุการใช้งานของ r แทนด้วย 'a และอายุการใช้งานของ x แทนด้วย 'b ดังที่คุณสังเกตเห็น บล็อกด้านใน 'b มีขนาดเล็กกว่าบล็อกไลฟ์ไทม์ด้านนอก 'a เป็นอย่างมาก ในขั้นตอนคอมไพล์ Rust จะเปรียบเทียบขนาดอายุการใช้งานทั้งสองตัวและวิเคราะห์ว่า r มีไลฟ์ไทม์ระดับ 'a แต่ดันไปชี้อ้างอิงตำแหน่งหน่วยความจำที่มีไลฟ์ไทม์ระดับ 'b ตัวโปรแกรมจึงถูกปฏิเสธไม่ให้ผ่านเนื่องจากอายุใช้งาน 'b มีความสั้นกว่าอายุใช้งานของตัวแปรอ้างอิง 'a ซึ่งก็คือตัวข้อมูลอ้างอิงต้นทางมีอายุสั้นกว่าข้อมูลอ้างอิงหลัก

ตัวอย่างที่ 10-18 แสดงวิธีการแก้ไขโค้ดเพื่อไม่ให้เกิดปัญหาข้อมูลอ้างอิงห้อยเคว้ง และช่วยให้คอมไพล์ผ่านโดยไม่มีข้อผิดพลาด

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+

ในกรณีนี้ x มีอายุใช้งานระดับ 'b ซึ่งกว้างใหญ่ครอบคลุมกว่า 'a ส่งผลให้ r สามารถชี้อ้างอิงไปยัง x ได้อย่างไร้อุปสรรค เนื่องจาก Rust มั่นใจได้ว่าข้อมูลอ้างอิงใน r จะยังถูกต้องใช้งานได้ตลอดเวลาตราบเท่าที่ x ยังคงมีสถานะใช้งานอยู่

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

พารามิเตอร์ไลฟ์ไทม์เจเนอริกในฟังก์ชัน (Generic Lifetimes in Functions)

เราจะลองเขียนฟังก์ชันหนึ่งที่มีหน้าที่เปรียบเทียบและส่งคืนสตริงย่อย (string slice) ที่มีความยาวมากกว่าระหว่างสองตัวแปร ฟังก์ชันนี้จะรับพารามิเตอร์เป็น string slices สองตัวและส่งผลลัพธ์กลับมาเป็นหนึ่ง string slice หลังจากเราเขียนฟังก์ชัน longest เสร็จสมบูรณ์แล้ว โค้ดในตัวอย่างที่ 10-19 ควรจะแสดงผลลัพธ์เป็น The longest string is abcd

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

ข้อสังเกตคือ เราต้องการให้ฟังก์ชันรับพารามิเตอร์ในรูปของ string slices ซึ่งทำหน้าที่อ้างอิงข้อมูล แทนที่จะส่งค่าออบเจกต์สตริงตรง ๆ เนื่องจากเราไม่ประสงค์ให้ฟังก์ชัน longest มาแย่งสิทธิ์ความเป็นเจ้าของ (ownership) ของข้อมูลพารามิเตอร์เหล่านั้นไป โปรดดูข้อมูลในหัวข้อ “สตริงย่อยเป็นตัวแปรพารามิเตอร์ (String Slices as Parameters)” ในบทที่ 4 สำหรับเหตุผลที่อธิบายรายละเอียดว่าทำไมตัวแปรพารามิเตอร์ในตัวอย่างที่ 10-19 จึงเป็นรูปแบบที่เราต้องการใช้งาน

หากเราทดลองเขียนฟังก์ชัน longest ที่ไม่ถูกต้องและคอมไพล์ไม่ผ่านบรรทัดนี้ในตัวอย่างที่ 10-20 โค้ดชุดนี้จะยังไม่สามารถคอมไพล์ผ่านได้

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

แต่ทว่าเราจะได้พบข้อความแจ้งเตือนความผิดพลาดเกี่ยวกับไลฟ์ไทม์ดังนี้:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

ข้อความแนะนำแจ้งรายละเอียดให้ทราบว่า ข้อมูลชนิดส่งคืนจำเป็นต้องได้รับการระบุพารามิเตอร์ไลฟ์ไทม์เจเนอริกเพิ่มเติม เนื่องจากตัวภาษา Rust ไม่สามารถประเมินได้ว่าข้อมูลอ้างอิงที่จะถูกส่งคืนกลับออกไปนั้นแท้จริงแล้วชี้ไปยังตัวแปร x หรือ y กันแน่ ซึ่งในความจริงแล้วตัวเราเองก็บอกไม่ได้เหมือนกัน เพราะโค้ดกิ่ง if ในฟังก์ชันจะส่งข้อมูลอ้างอิงไปยัง x และกิ่ง else จะส่งข้อมูลอ้างอิงไปยัง y!

เมื่อตอนที่เรากำลังนิยามโครงสร้างการทำงานของฟังก์ชันนี้ เราย่อมไม่รู้ว่าค่าข้อมูลรูปธรรมที่จะถูกส่งเข้ามาจริงคืออะไร ส่งผลให้ตอบไม่ได้ว่าตรรกะในบล็อก if หรือบล็อก else จะทำงาน และเราก็ไม่รู้ขอบเขตไลฟ์ไทม์รูปธรรมของข้อมูลอ้างอิงที่จะป้อนเข้ามาด้วย เราจึงไม่สามารถตรวจสอบวิเคราะห์ขอบเขตแบบวิธีดั้งเดิมในตัวอย่างที่ 10-17 และ 10-18 เพื่อการันตีว่าค่าอ้างอิงที่ส่งคืนกลับออกไปจะถูกต้องปลอดภัยเสมอ และระบบ borrow checker ก็ประเมินไม่ได้ด้วยเช่นกันเนื่องจากไม่ทราบความเชื่อมโยงเกี่ยวเนื่องกันระหว่างไลฟ์ไทม์ของ x และ y กับไลฟ์ไทม์ของผลลัพธ์ข้อมูลส่งคืน ในการแก้ไขปัญหานี้ เราจะเพิ่มพารามิเตอร์ไลฟ์ไทม์เจเนอริกเพื่อเขียนนิยามความสัมพันธ์ของข้อมูลอ้างอิงเหล่านั้นให้ชัดเจน เพื่อเปิดโอกาสให้ borrow checker ทำการตรวจวิเคราะห์ได้อย่างถูกต้อง

ไวยากรณ์ของการบันทึกระบุไลฟ์ไทม์ (Lifetime Annotation Syntax)

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

ไวยากรณ์ของการระบุไลฟ์ไทม์มีหน้าตาที่ค่อนข้างแปลกตาเล็กน้อย: โดยชื่อของพารามิเตอร์ไลฟ์ไทม์จำเป็นต้องเริ่มต้นด้วยเครื่องหมายอัญประกาศเดี่ยว (apostrophe: ') และมักนิยมเลือกใช้ตัวอักษรพิมพ์เล็กที่มีขนาดสั้นคล้ายกับกรณีเจเนอริกทั่วไป นักพัฒนาส่วนใหญ่จะเลือกตั้งชื่อ 'a เป็นตัวระบุไลฟ์ไทม์ตัวแรก เราจะนำตัวแปรพารามิเตอร์ไลฟ์ไทม์ไปเขียนวางไว้ต่อท้ายเครื่องหมาย & ของข้อมูลอ้างอิง และเคาะช่องว่าง (space) หนึ่งเคาะเพื่อคั่นแยกระหว่างตัวไลฟ์ไทม์กับประเภทข้อมูลอ้างอิงนั้น

นี่คือตัวอย่างเปรียบเทียบ—ข้อมูลอ้างอิงของ i32 ที่ไม่ได้ระบุไลฟ์ไทม์, ข้อมูลอ้างอิงของ i32 ที่ระบุไลฟ์ไทม์ชื่อ 'a และข้อมูลอ้างอิงแบบแก้ไขได้ (mutable reference) ของ i32 ที่ระบุไลฟ์ไทม์เป็น 'a เช่นเดียวกัน:

&i32        // ข้อมูลอ้างอิงทั่วไป
&'a i32     // ข้อมูลอ้างอิงที่ระบุไลฟ์ไทม์อย่างชัดแจ้ง
&'a mut i32 // ข้อมูลอ้างอิงแบบแก้ไขได้ที่ระบุไลฟ์ไทม์อย่างชัดแจ้ง

การเขียนบันทึกระบุไลฟ์ไทม์เพียงตัวเดียวโดด ๆ จะไม่ได้มีความหมายใด ๆ เลย เนื่องจากเป้าหมายของการเขียนคือการบอกให้ Rust ทราบว่าพารามิเตอร์ไลฟ์ไทม์เจเนอริกของตัวแปรอ้างอิงหลาย ๆ ตัวมีความสัมพันธ์เกี่ยวเนื่องกันอย่างไร เราลองมาวิเคราะห์กันว่าไลฟ์ไทม์เหล่านี้เกี่ยวโยงกันอย่างไรในบริบทของฟังก์ชัน longest

ในส่วนซิกเนเจอร์ของฟังก์ชัน (In Function Signatures)

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

เราต้องการให้ซิกเนเจอร์เขียนอธิบายข้อจำกัดดังนี้: ข้อมูลอ้างอิงที่ส่งคืนกลับออกไปจะมีสถานะความถูกต้องใช้งานได้ยาวนานเท่าที่พารามิเตอร์นำเข้าทั้งสองตัวยังมีอายุใช้งานอยู่จริง นี่คือความสัมพันธ์ของไลฟ์ไทม์ระหว่างตัวแปรพารามิเตอร์นำเข้ากับผลลัพธ์ข้อมูลส่งคืน เราจะตั้งชื่อพารามิเตอร์ไลฟ์ไทม์นี้ว่า 'a แล้วนำไปเขียนต่อท้ายสำหรับข้อมูลอ้างอิงแต่ละตัว ดังแสดงในตัวอย่างที่ 10-21

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

โค้ดนี้จะช่วยให้คอมไพล์ผ่านและแสดงผลลัพธ์ตามที่เราต้องการเมื่อนำไปเรียกประมวลผลคำสั่งร่วมกับฟังก์ชัน main ในตัวอย่างที่ 10-19

คราวนี้ซิกเนเจอร์ของฟังก์ชันจะทำหน้าที่บอก Rust ว่า สำหรับช่วงเวลาไลฟ์ไทม์ 'a ใด ๆ ฟังก์ชันนี้จะรับพารามิเตอร์สองตัวซึ่งต่างก็เป็น string slices ที่มีอายุใช้งานอย่างน้อยที่สุดเท่ากับช่วงไลฟ์ไทม์ 'a และซิกเนเจอร์ของฟังก์ชันยังบอก Rust ต่ออีกว่า สตริงย่อยที่จะส่งคืนออกไปจากฟังก์ชันก็จะมีอายุใช้งานนานอย่างน้อยที่สุดเท่ากับช่วงไลฟ์ไทม์ 'a เช่นเดียวกัน ในทางปฏิบัติ นั่นหมายความว่า อายุการใช้งานของข้อมูลอ้างอิงที่ส่งคืนกลับมาจากฟังก์ชัน longest จะเท่ากับความยาวขอบเขตไลฟ์ไทม์ที่สั้นที่สุดระหว่างค่าอาร์กิวเมนต์ตัวแปรทั้งสองที่ป้อนเข้ามา ความสัมพันธ์เกี่ยวเนื่องเหล่านี้คือสิ่งที่ประสงค์ให้ Rust นำไปใช้ในขั้นตอนวิเคราะห์โค้ด

โปรดจำไว้ว่า เมื่อเราเขียนระบุพารามิเตอร์ไลฟ์ไทม์ในซิกเนเจอร์ฟังก์ชัน เราไม่ได้ไปสั่งแก้ไขเปลี่ยนแปลงไลฟ์ไทม์จริงของค่าข้อมูลที่ส่งเข้ามาหรือส่งกลับไปเลย แต่สิ่งที่เราทำคือการมอบหมายให้ระบบ borrow checker คอยแจ้งเตือนปฏิเสธค่าข้อมูลใดก็ตามที่ไม่สอดคล้องตรงตามข้อจำกัดจำพวกนี้ สังเกตว่าตัวฟังก์ชัน longest เองไม่จำเป็นต้องสืบทราบเลยว่าแท้จริงแล้ว x และ y จะมีชีวิตนานกี่บรรทัด รู้เพียงแต่ว่าต้องหาขอบเขตสเปซหนึ่งมาแทนที่ตำแหน่ง 'a เพื่อตอบสนองความต้องการซิกเนเจอร์นี้ได้ก็พอ

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

เมื่อเราส่งตัวแปรอ้างอิงรูปธรรมจริงเข้าไปในฟังก์ชัน longest ตัวแปรอายุใช้งานรูปธรรมที่จะเข้ามาแทนที่ในตำแหน่ง 'a คือพื้นที่ขอบเขตการทำงานของ x ส่วนที่ซ้อนทับเหลื่อมอยู่กับขอบเขตการทำงานของ y กล่าวอีกนัยหนึ่งคือ พารามิเตอร์ไลฟ์ไทม์เจเนอริก 'a จะมีค่าอายุการใช้งานรูปธรรมยาวนานเทียบเท่ากับไลฟ์ไทม์ที่สั้นที่สุดระหว่าง x และ y และเนื่องจากเราเขียนบันทึกกำกับค่าอ้างอิงส่งคืนด้วยตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ตัวเดียวกันนี้ ข้อมูลอ้างอิงที่ส่งกลับออกไปจึงใช้งานได้อย่างถูกต้องยาวนานเท่ากับความยาวขอบเขตไลฟ์ไทม์ที่สั้นกว่าของ x และ y ด้วยเช่นกัน

ลองมาศึกษาวิธีการที่บันทึกระบุไลฟ์ไทม์เข้ามาจัดสรรจำกัดการทำงานของฟังก์ชัน longest เมื่อเราป้อนข้อมูลอ้างอิงที่มีอายุการใช้งานต่างประเภทกัน ตัวอย่างที่ 10-22 แสดงโค้ดอย่างง่ายเพื่อแสดงให้เห็นภาพ

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

ในตัวอย่างนี้ ตัวแปร string1 มีอายุใช้งานครอบคลุมยาวนานไปจนสิ้นสุดขอบเขตด้านนอก ตัวแปร string2 มีอายุใช้งานไปจนสิ้นสุดเพียงแค่ขอบเขตด้านใน และผลลัพธ์ใน result อ้างอิงชี้ไปยังค่าที่มีสิทธิ์ใช้ได้ยาวนานจนสิ้นสุดขอบเขตด้านใน เมื่อคุณสั่งรันโค้ดชุดนี้จะพบว่าระบบ borrow checker ยินดีปล่อยให้ผ่าน โค้ดคอมไพล์สำเร็จและพิมพ์ข้อความ The longest string is long string is long

ถัดไป ลองมาดูตัวอย่างที่แสดงให้เห็นเด่นชัดว่าอายุใช้งานของข้อมูลอ้างอิงใน result จำเป็นต้องยึดตามค่าอายุการใช้งานที่สั้นที่สุดของอาร์กิวเมนต์ทั้งสองตัว เราจะทำการย้ายบรรทัดประกาศตัวแปร result ออกไปอยู่นอกขอบเขตด้านใน แต่ยังคงเขียนขั้นตอนกำหนดค่าให้แก่ตัวแปร result ซ้อนอยู่ภายในขอบเขตด้านในเดียวกับ string2 จากนั้น เราย้ายคำสั่งเรียก println! ที่ใช้ตัวแปร result ออกไปอยู่นอกขอบเขตด้านใน หลังจากที่ขอบเขตด้านในปิดการทำงานลงเรียบร้อยแล้ว ซึ่งโค้ดชุดนี้ในตัวอย่างที่ 10-23 จะไม่สามารถคอมไพล์ผ่านได้

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

เมื่อเราสั่งคอมไพล์โค้ดชุดนี้ เราจะพบข้อความแจ้งข้อผิดพลาดดังนี้:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                      ------ borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

ข้อผิดพลาดชี้วัดว่า เพื่อทำให้ตัวแปร result สามารถดึงมาประมวลผลคำสั่งในบรรทัด println! ได้อย่างถูกต้องนั้น ตัวแปร string2 จำเป็นต้องคงอายุใช้งานได้ยาวนานไปจนจบขอบเขตการทำงานด้านนอก ซึ่ง Rust ทราบกฎเกณฑ์ข้อนี้ได้เนื่องจากเราบันทึกจำกัดไลฟ์ไทม์ของพารามิเตอร์นำเข้าและข้อมูลส่งคืนของฟังก์ชันไว้โดยใช้ตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ตัวเดียวกัน

ในฐานะมนุษย์ เราสามารถไล่อ่านโค้ดและประเมินได้สบายว่า string1 มีความยาวมากกว่า string2 ดังนั้น ตัวแปร result จะต้องเก็บค่าอ้างอิงชี้ไปยัง string1 แน่นอน และเนื่องจาก string1 ยังไม่หลุดออกนอกขอบเขตการใช้งาน ข้อมูลอ้างอิงที่ชี้ไปยัง string1 จึงควรทำงานได้อย่างสมบูรณ์ดีในบรรทัด println! ทว่าตัวคอมไพเลอร์ไม่ได้มีความสามารถที่จะวิเคราะห์ข้อมูลลึกซึ้งได้ถึงขนาดนั้นในกรณีนี้ เราได้แจ้ง Rust ไปว่าอายุการใช้งานของข้อมูลอ้างอิงที่ส่งคืนกลับมาจากฟังก์ชัน longest จะต้องสอดคล้องเทียบเท่ากับอายุใช้งานที่สั้นที่สุดระหว่างอาร์กิวเมนต์นำเข้าทั้งสองตัว ดังนั้น borrow checker จึงจำต้องสั่งระงับไม่ให้โค้ดในตัวอย่างที่ 10-23 ผ่านไปได้เนื่องจากเล็งเห็นโอกาสเสี่ยงที่จะเกิดข้อมูลอ้างอิงไม่ถูกต้อง

คุณสามารถทดลองเขียนออกแบบกรณีการทดสอบเพิ่มเติมโดยลองปรับเปลี่ยนค่าและอายุการใช้งานของตัวแปรอ้างอิงที่ส่งเข้าไปในฟังก์ชัน longest ร่วมกับการดึงค่าส่งคืนไปใช้งาน ลองเขียนสมมติฐานไว้ล่วงหน้าก่อนสั่งคอมไพล์ว่าโค้ดนั้นจะผ่าน borrow checker ได้หรือไม่ แล้วลองเช็คดูว่าคุณประเมินได้ถูกต้องตรงกันไหมครับ!

ความสัมพันธ์เชิงโครงสร้าง (Relationships)

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

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

เรากำหนดระบุพารามิเตอร์ไลฟ์ไทม์ 'a ไว้เฉพาะเจาะจงให้แก่พารามิเตอร์ x และประเภทข้อมูลผลลัพธ์ส่งคืน แต่ทิ้งตัวแปรพารามิเตอร์ y ไว้โดยไม่ได้ระบุ เนื่องจากช่วงอายุการใช้งานของ y ไม่ได้มีความสัมพันธ์เกี่ยวพันกันกับอายุการใช้งานของ x หรือของค่าส่งคืนเลย

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

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

ในจุดนี้ แม้ว่าเราจะระบุตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ให้แก่ประเภทข้อมูลส่งคืนเรียบร้อยแล้ว แต่โค้ดนำเสนอนี้จะล้มเหลวในการคอมไพล์เนื่องจากไลฟ์ไทม์ของข้อมูลส่งคืนไม่มีจุดเกี่ยวเนื่องเชื่อมโยงใด ๆ กับไลฟ์ไทม์ของพารามิเตอร์นำเข้าเลย ข้อความแจ้งเตือนข้อผิดพลาดที่เราจะเจอก็คือ:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

ปัญหาของโครงสร้างนี้คือ ตัวแปร result จะหลุดออกนอกขอบเขตการใช้งานและถูกเคลียร์คืนหน่วยความจำทันทีเมื่อฟังก์ชัน longest สิ้นสุดคำสั่งลง ในขณะเดียวกันเราก็พยายามจะส่งข้อมูลอ้างอิงชี้ไปยัง result นั้นกลับออกไปนอกฟังก์ชัน ไม่มีทางหนทางไวยากรณ์ใด ๆ ที่เราจะเขียนระบุพารามิเตอร์ไลฟ์ไทม์เพื่อเข้าไปเยียวยาแก้ไขปัญหาข้อมูลอ้างอิงห้อยเคว้งตรงจุดนี้ได้ และ Rust ปฏิเสธการสร้างโค้ดที่มีความเสี่ยงลักษณะนี้โดยเด็ดขาด แนวทางแก้ไขที่ดีที่สุดสำหรับสถานการณ์นี้คือการเปลี่ยนมาส่งคืนประเภทข้อมูลที่เป็นเจ้าของตัวเอง (owned data type) แทนที่จะส่งค่าข้อมูลอ้างอิง เพื่อโยนความรับผิดชอบในการกวาดล้างดูแลหน่วยความจำข้อมูลไปให้แก่ฟังก์ชันผู้เรียกใช้งานส่วนปลายทางรับหน้าที่ไปจัดการต่อแทน

สรุปสาระสำคัญคือ ไวยากรณ์ไลฟ์ไทม์มีจุดประสงค์หลักเพื่อใช้ถักทอเชื่อมโยงขอบเขตอายุการใช้งานของพารามิเตอร์นำเข้าต่าง ๆ กับข้อมูลผลลัพธ์ส่งคืนของฟังก์ชันเข้าไว้ด้วยกัน เมื่อความสัมพันธ์ดังกล่าวถูกเชื่อมต่อเรียบร้อยแล้ว Rust ก็จะมีรายละเอียดเพียงพอที่จะตรวจสอบและรับประกันว่าโปรแกรมจะดำเนินคำสั่งที่ปลอดภัยต่อหน่วยความจำ (memory-safe) เสมอ และสั่งระงับขัดขวางคำสั่งใด ๆ ที่อาจก่อเกิดตัวชี้ห้อยเคว้ง (dangling pointers) หรือจุดฝ่าฝืนข้อกำหนดความปลอดภัยของหน่วยความจำ

ในส่วนนิยามของ Struct (In Struct Definitions)

ที่ผ่านมา struct ต่าง ๆ ที่เรากำหนดนิยามขึ้นมาล้วนมีพฤติกรรมจัดเก็บเฉพาะประเภทข้อมูลที่เป็นเจ้าของตัวเอง (owned types) ความจริงแล้วเราสามารถเขียนกำหนดนิยาม struct ให้เก็บข้อมูลอ้างอิงได้เช่นเดียวกัน แต่อย่างไรก็ดีในสถานการณ์เช่นนั้น เราจำเป็นต้องใส่ระบุไลฟ์ไทม์ให้แก่ข้อมูลอ้างอิงทุกตัวที่มีการนิยามไว้ใน struct ตัวอย่างที่ 10-24 แสดง struct ชื่อว่า ImportantExcerpt ที่ทำหน้าที่จัดเก็บสตริงย่อย

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

struct นี้มีฟิลด์ข้อมูลตัวเดียวคือ part ซึ่งเก็บข้อมูลสตริงย่อยที่มีฐานะเป็นข้อมูลอ้างอิง เช่นเดียวกับการเขียนเจเนอริกของประเภทข้อมูลทั่วไป เราประกาศชื่อพารามิเตอร์ไลฟ์ไทม์เจเนอริกไว้ในวงเล็บสามเหลี่ยมคั่นต่อท้ายชื่อของ struct เพื่อที่เราจะดึงไลฟ์ไทม์นี้ไประบุพารามิเตอร์ภายในนิยามฟิลด์ของ struct บันทึกกำกับไลฟ์ไทม์ในจุดนี้มีความหมายว่า อินสแตนซ์ของ ImportantExcerpt จะไม่มีสิทธิ์อยู่ยงคงกระพันยาวนานเกินกว่าขอบเขตอายุใช้งานของข้อมูลอ้างอิงที่มันจัดเก็บไว้ในฟิลด์ part

ฟังก์ชัน main ในตัวอย่างนี้ทำหน้าที่สร้างอินสแตนซ์ของ struct ImportantExcerpt ซึ่งมีพฤติกรรมจัดเก็บข้อมูลอ้างอิงชี้ไปยังประโยคแรกสุดของออบเจกต์ String ที่ครอบครองสิทธิ์โดยตัวแปร novel สังเกตว่า ข้อมูลในตัวแปร novel ได้รับการสร้างไว้และมีตัวตนอยู่ก่อนแล้วที่อินสแตนซ์ ImportantExcerpt จะถือกำเนิดขึ้นมา นอกจากนี้ ตัวแปร novel จะยังไม่หลุดออกนอกขอบเขตจนกว่าตัวแปร ImportantExcerpt จะทำงานสิ้นสุดและหลุดนอกขอบเขตไปก่อน ส่งผลให้ข้อมูลอ้างอิงภายในอินสแตนซ์ ImportantExcerpt ยังคงถูกต้องปลอดภัยดี

กฎการละเว้นการระบุไลฟ์ไทม์ (Lifetime Elision)

คุณได้เรียนรู้ไปแล้วว่า ข้อมูลอ้างอิงทุกตัวใน Rust ต่างก็มีขอบเขตอายุการใช้งาน และคุณจำเป็นต้องเขียนระบุพารามิเตอร์ไลฟ์ไทม์ให้แก่ฟังก์ชันหรือ struct ที่หยิบยืมข้อมูลอ้างอิงไปประมวลผลคำสั่ง อย่างไรก็ตาม เราเคยมีฟังก์ชันหนึ่งในตัวอย่างที่ 4-9 ซึ่งนำมาแสดงให้เห็นอีกครั้งในตัวอย่างที่ 10-25 ที่สามารถผ่านกระบวนการคอมไพล์ได้อย่างราบรื่นโดยที่เราไม่จำเป็นต้องระบุพารามิเตอร์ไลฟ์ไทม์ใด ๆ เลย

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

เหตุผลที่ฟังก์ชันนี้สามารถผ่านขั้นตอนคอมไพล์ได้ฉลุยโดยไม่ต้องบันทึกระบุไลฟ์ไทม์มีประเด็นประวัติศาสตร์แฝงอยู่: ในตัวโปรแกรม Rust เวอร์ชันแรก ๆ (ยุคก่อน 1.0) โค้ดชุดนี้จะไม่มีวันคอมไพล์ผ่านเด็ดขาด เนื่องจากข้อกำหนดดั้งเดิมระบุว่า ข้อมูลอ้างอิงทุกตัวจำเป็นต้องระบุไลฟ์ไทม์อย่างชัดเจนเสมอ ในช่วงเวลานั้น ตัวซิกเนเจอร์ของฟังก์ชันจะจำต้องเขียนระบุไวยากรณ์โครงสร้างแบบนี้:

fn first_word<'a>(s: &'a str) -> &'a str {

หลังจากที่ทีมนักพัฒนา Rust ได้มีโอกาสเขียนโค้ดจำนวนมหาศาล พวกเขาตรวจพบพฤติกรรมที่ว่า นักพัฒนาจำต้องคอยพิมพ์บันทึกระบุไลฟ์ไทม์เดิมซ้ำไปซ้ำมาในสถานการณ์แบบเดิมบ่อยครั้ง ซึ่งรูปแบบความสัมพันธ์เหล่านั้นมีความแน่นอน คาดเดาได้ง่าย และมีแพทเทิร์นที่เป็นระบบ ทางทีมพัฒนาจึงได้เขียนชุดเงื่อนไขบรรจุแพทเทิร์นเหล่านี้ฝังลงไปในระบบของตัวคอมไพเลอร์โดยตรง เพื่อช่วยให้ borrow checker คาดเดาสรุปไลฟ์ไทม์ในสถานการณ์ดังกล่าวแทนเราได้โดยอัตโนมัติ ส่งผลให้ผู้ใช้ไม่ต้องเขียนพิมพ์อธิบายไลฟ์ไทม์ในโค้ดอีกต่อไป

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

กลุ่มเงื่อนไขรูปแบบที่ถูกบรรจุฝังไว้ในตัววิเคราะห์ข้อมูลอ้างอิงของ Rust เรียกว่า กฎการละเว้นการระบุไลฟ์ไทม์ (lifetime elision rules) ซึ่งกฎจำพวกนี้ไม่ใช่กฎข้อบังคับที่กำหนดให้นักพัฒนาโปรแกรมต้องคอยประพฤติปฏิบัติตามแต่อย่างใด แต่คือกลุ่มเงื่อนไขกรณีตัวอย่างที่คอมไพเลอร์จะหยิบยกไปใช้ตรวจสอบ หากโครงสร้างโค้ดของคุณตรงสอดคล้องตามเงื่อนไขเหล่านี้ คุณก็จะไม่ต้องพิมพ์อธิบายไลฟ์ไทม์ลงไปในโค้ดเลย

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

อายุการใช้งานของข้อมูลอ้างอิงที่ระบุกับพารามิเตอร์ของฟังก์ชันหรือเมธอดเรียกว่า ไลฟ์ไทม์นำเข้า (input lifetimes) และอายุใช้งานของผลลัพธ์ข้อมูลส่งคืนเรียกว่า ไลฟ์ไทม์ส่งออก (output lifetimes)

ตัวคอมไพเลอร์จะนำเอากฎเกณฑ์ 3 ข้อมาพิจารณาเพื่อตามหาสรุปไลฟ์ไทม์ของข้อมูลอ้างอิงในจุดที่ผู้เขียนโค้ดไม่ได้ระบุไว้ กฎข้อแรกจะใช้จัดสรรประเมินกับกลุ่มไลฟ์ไทม์นำเข้า ส่วนกฎข้อที่สองและสามจะใช้ประเมินกับฝั่งไลฟ์ไทม์ส่งออก หากคอมไพเลอร์ลองไล่ตรวจดูจนครบทั้งสามกฎเกณฑ์แล้วยังพบตัวแปรอ้างอิงที่ยังสรุปไลฟ์ไทม์ไม่ได้ คอมไพเลอร์จะยุติกระบวนการและแสดงข้อความผิดพลาดทันที ซึ่งกฎเหล่านี้จะถูกนำไปใช้ประเมินกับนิยามโครงสร้าง fn รวมถึงในบล็อกคำสั่ง impl ด้วยเช่นกัน

กฎข้อแรกระบุว่า คอมไพเลอร์จะทำหน้าที่แจกจ่ายตัวแปรพารามิเตอร์ไลฟ์ไทม์เฉพาะให้แก่พารามิเตอร์นำเข้าทุกตัวที่เป็นข้อมูลอ้างอิง กล่าวคือ ฟังก์ชันที่มีพารามิเตอร์ตัวเดียวจะได้รับพารามิเตอร์ไลฟ์ไทม์หนึ่งตัว: fn foo<'a>(x: &'a i32) ส่วนฟังก์ชันที่มีพารามิเตอร์นำเข้าสองตัวจะได้รับพารามิเตอร์ไลฟ์ไทม์แยกกันสองตัว: fn foo<'a, 'b>(x: &'a i32, y: &'b i32) และดำเนินพฤติกรรมนี้ต่อไปเรื่อย ๆ ตามจำนวนตัวแปร

กฎข้อที่สองระบุว่า หากในกลุ่มพารามิเตอร์นำเข้ามีตัวแปรไลฟ์ไทม์เพียงตัวเดียวถ้วน ไลฟ์ไทม์ดังกล่าวจะถูกนำไปผูกกำหนดให้กับตัวแปรไลฟ์ไทม์ส่งออกทั้งหมดโดยอัตโนมัติ: fn foo<'a>(x: &'a i32) -> &'a i32

กฎข้อที่สามระบุว่า หากในกลุ่มพารามิเตอร์นำเข้ามีพารามิเตอร์ไลฟ์ไทม์หลายตัว แต่ทว่าหนึ่งในพารามิเตอร์เหล่านั้นคือ &self หรือ &mut self เนื่องจากฟังก์ชันดังกล่าวมีฐานะเป็นเมธอด ไลฟ์ไทม์ของ self จะถูกนำไปผูกกำหนดให้กับตัวแปรไลฟ์ไทม์ส่งออกทั้งหมดทันที กฎข้อที่สามนี้ช่วยให้เมธอดต่าง ๆ อ่านและเขียนได้สบายตาขึ้นอย่างมาก เนื่องจากไม่จำเป็นต้องใส่เครื่องหมายกำกับพารามิเตอร์ยิบย่อย

เราลองมาสวมบทบาทสมมติเป็นตัวคอมไพเลอร์และลองนำกฎเกณฑ์เหล่านี้มาตรวจสอบหาคำตอบไลฟ์ไทม์ของข้อมูลอ้างอิงในซิกเนเจอร์ของฟังก์ชัน first_word ในตัวอย่างที่ 10-25 กันครับ โดยเริ่มแรก ตัวซิกเนเจอร์จะไม่มีข้อมูลไลฟ์ไทม์ระบุอยู่กับค่าอ้างอิงเลย:

fn first_word(s: &str) -> &str {

จากนั้น คอมไพเลอร์จะนำเอากฎข้อแรกมาพิจารณา ซึ่งกำหนดให้ตัวแปรพารามิเตอร์แต่ละตัวได้รับสิทธิ์ไลฟ์ไทม์เฉพาะของตัวเอง เราจะตั้งชื่อขอบเขตมันว่า 'a เช่นเดิม ส่งผลให้ได้ซิกเนเจอร์หน้าตาแบบนี้:

fn first_word<'a>(s: &'a str) -> &str {

ขั้นต่อไป ระบบตรวจวิเคราะห์ว่าสามารถนำกฎข้อที่สองมาประยุกต์ใช้ได้ เนื่องจากในกลุ่มพารามิเตอร์นำเข้ามีจำนวนไลฟ์ไทม์เพียงหนึ่งตัวถ้วน ซึ่งกฎข้อที่สองระบุว่าไลฟ์ไทม์พารามิเตอร์นำเข้าเดี่ยวนั้นจะถูกนำไปกำหนดให้กับชนิดข้อมูลส่งคืนทันที ส่งผลให้ได้ซิกเนเจอร์เวอร์ชันสุดท้ายดังนี้:

fn first_word<'a>(s: &'a str) -> &'a str {

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

ลองมาดูอีกหนึ่งตัวอย่าง ครั้งนี้เราหยิบฟังก์ชัน longest ที่ไม่มีข้อมูลพารามิเตอร์ไลฟ์ไทม์ติดตัวมาตั้งแต่แรกในตัวอย่างที่ 10-20 มาประเมินกันครับ:

fn longest(x: &str, y: &str) -> &str {

ลองเริ่มจากกฎข้อแรก: กำหนดให้พารามิเตอร์นำเข้าแต่ละตัวมีอายุการใช้งานเฉพาะของตัวเอง ครั้งนี้เรามีตัวแปรอ้างอิงนำเข้าสองตัวแทนที่จะเป็นตัวเดียว ส่งผลให้ได้ขอบเขตไลฟ์ไทม์สองตัวแยกกันดังนี้:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

คุณจะพบได้ทันทีว่ากฎข้อที่สองไม่สามารถนำมาประยุกต์ใช้งานได้ เนื่องจากตรวจพบพารามิเตอร์ไลฟ์ไทม์นำเข้ามากกว่าหนึ่งตัว และกฎข้อที่สามก็ใช้ไม่ได้เช่นเดียวกัน เนื่องจากฟังก์ชัน longest เป็นฟังก์ชันทั่วไป ไม่ใช่เมธอด จึงไม่มีตัวแปรพารามิเตอร์ใดที่เป็น self เลย หลังจากไล่ประเมินกฎเกณฑ์จนสิ้นสุดครบทั้ง 3 ข้อ เราก็ยังไม่สามารถสรุปได้ว่าประเภทข้อมูลผลลัพธ์ส่งคืนของฟังก์ชันนี้จะมีขอบเขตไลฟ์ไทม์อย่างไร และนี่คือเหตุผลเบื้องหลังที่ทำไมคอมไพเลอร์จึงแสดงข้อผิดพลาดเมื่อพยายามคอมไพล์โค้ดในตัวอย่างที่ 10-20: เพราะคอมไพเลอร์ได้พยายามไล่กฎการละเว้นระบุไลฟ์ไทม์จนครบแล้วแต่ก็ยังไม่สามารถประเมินหาคำตอบไลฟ์ไทม์ของข้อมูลอ้างอิงในซิกเนเจอร์ให้เสร็จสิ้นได้

เนื่องจากกฎข้อที่สามมีบทบาทใช้งานเฉพาะกับส่วนซิกเนเจอร์ของเมธอด ถัดไปเราจะมาเรียนรู้เรื่องไลฟ์ไทม์ในบริบทของเมธอดเพื่อศึกษาว่าทำไมกฎข้อที่สามจึงช่วยลดภาระให้เราไม่ต้องพิมพ์กำกับระบุไลฟ์ไทม์ในเมธอดบ่อยครั้ง

ในส่วนนิยามของ เมธอด (In Method Definitions)

เมื่อเราเขียนคำสั่งนำเสนอเมธอดต่าง ๆ ให้แก่ struct ที่มีพารามิเตอร์ไลฟ์ไทม์ระบุอยู่ เราจะใช้งานไวยากรณ์ในลักษณะเดียวกับกรณีของพารามิเตอร์ประเภทข้อมูลเจเนอริกดังแสดงในตัวอย่างที่ 10-11 ส่วนรายละเอียดจุดประกาศตัวแปรและการนำไลฟ์ไทม์ไปใช้งานจะขึ้นตรงกับว่าพวกมันมีความเกี่ยวข้องผูกพันกับฟิลด์ภายใน struct หรือพารามิเตอร์และผลลัพธ์ข้อมูลส่งคืนของเมธอดเป็นหลัก

การประกาศระบุชื่อไลฟ์ไทม์สำหรับฟิลด์ภายในของ struct จำเป็นต้องเขียนประกาศต่อท้ายคำสำคัญ impl และนำไปกำกับต่อท้ายชื่อของ struct เสมอ เนื่องจากไลฟ์ไทม์เหล่านั้นถือเป็นองค์ประกอบหลักของชนิดประเภทข้อมูลของ struct

ภายในซิกเนเจอร์ของเมธอดที่อยู่ในบล็อก impl ข้อมูลอ้างอิงต่าง ๆ อาจได้รับสิทธิ์ให้เชื่อมต่อผูกพันกับอายุการใช้งานของฟิลด์ภายใน struct หรืออาจเป็นอิสระต่อกันไม่เกี่ยวกันเลยก็ได้ นอกจากนี้ กฎการละเว้นระบุไลฟ์ไทม์มักช่วยจัดการเคลียร์ภาระให้เราไม่ต้องเขียนบันทึกกำกับไลฟ์ไทม์ในซิกเนเจอร์เมธอดอยู่บ่อยครั้ง ลองมาวิเคราะห์ตัวอย่างโดยอิงจาก struct ImportantExcerpt ที่เราได้นิยามไปในตัวอย่างที่ 10-24 กันครับ

เริ่มแรก เราเขียนเมธอดชื่อว่า level ซึ่งรับพารามิเตอร์เพียงตัวเดียวคือข้อมูลอ้างอิงชี้ไปยัง self และส่งค่ากลับคืนมาเป็นชนิด i32 ซึ่งไม่ใช่ข้อมูลอ้างอิงชี้ไปยังสิ่งใดเลย:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

การเขียนประกาศตัวแปรพารามิเตอร์ไลฟ์ไทม์ต่อท้าย impl และกำกับใช้งานหลังชื่อ struct ยังคงมีความจำเป็นต้องเขียนระบุ แต่เนื่องจากอานิสงส์ของกฎการละเว้นข้อแรก ส่งผลให้เราไม่ต้องเขียนบันทึกกำกับไลฟ์ไทม์ใด ๆ ให้แก่ตัวแปรอ้างอิง self เลย

นี่คืออีกหนึ่งตัวอย่างประยุกต์ใช้งานซึ่งตรงตามกฎการละเว้นข้อที่สาม:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

ในจุดนี้ตรวจพบตัวแปรอ้างอิงนำเข้าสองตัว ดังนั้น Rust จะใช้กฎการละเว้นข้อแรกและแจกจ่ายตัวแปรพารามิเตอร์ไลฟ์ไทม์เฉพาะให้แก่ &self และ announcement แยกกัน จากนั้น เนื่องจากตรวจพบว่าหนึ่งในพารามิเตอร์นำเข้ามีฐานะเป็น &self ข้อมูลชนิดส่งคืนของฟังก์ชันจึงได้รับสิทธิ์ผูกกำหนดไลฟ์ไทม์ให้สอดรับตรงกันกับไลฟ์ไทม์ของ &self ส่งผลให้ข้อมูลความสัมพันธ์ของไลฟ์ไทม์ทุกตัวได้รับการจัดสรรสรุปจนเสร็จสิ้นเรียบร้อย

อายุการใช้งานแบบสแตติก (The Static Lifetime)

ไลฟ์ไทม์พิเศษเฉพาะประการหนึ่งที่เราต้องนำมาอภิปรายคือ 'static ซึ่งบ่งบอกพฤติกรรมว่าข้อมูลอ้างอิงดังกล่าวสามารถมีอายุใช้งานยาวนานคงอยู่ได้ตลอดช่วงระยะเวลาการทำงานของโปรแกรมทั้งหมด ข้อความสตริงตายตัว (string literals) ทุกตัวต่างก็มีอายุใช้งานระดับ 'static ทั้งสิ้น ซึ่งเราเขียนบันทึกกำกับระบุได้ดังนี้:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

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

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

พารามิเตอร์ประเภทข้อมูลเจเนอริก, ขอบเขตทริต, และไลฟ์ไทม์ร่วมกัน (Generic Type Parameters, Trait Bounds, and Lifetimes)

เราลองมาศึกษาภาพรวมไวยากรณ์ในการเขียนระบุทั้งพารามิเตอร์ประเภทข้อมูลเจเนอริก, ขอบเขตของทริต, และไลฟ์ไทม์รวมกันอยู่ภายใต้ฟังก์ชันเดียวกันแบบกระชับกันครับ!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

นี่คือฟังก์ชัน longest เดิมจากตัวอย่างที่ 10-21 ซึ่งส่งคืนข้อมูลสตริงย่อยที่ยาวกว่าระหว่างสองตัวแปร แต่ทว่ารอบนี้มีการเพิ่มเติมพารามิเตอร์ตัวใหม่เข้ามาชื่อว่า ann ประเภทเจเนอริก T ซึ่งยินดีต้อนรับประเภทข้อมูลใด ๆ ก็ปานที่สืบทอดทริต Display ตามเงื่อนไขที่ระบุไว้ในบล็อกคำสั่ง where พารามิเตอร์เสริมตัวนี้จะถูกเรียกมาพิมพ์ประมวลผลผ่านสัญลักษณ์ {} ซึ่งเป็นเหตุผลว่าทำไมการผูกขอบเขตทริต Display จึงมีความสำคัญ เนื่องจากไลฟ์ไทม์มีฐานะเป็นเจเนอริกรูปแบบหนึ่ง บรรทัดประกาศพารามิเตอร์ไลฟ์ไทม์ 'a และพารามิเตอร์ประเภทเจเนอริก T จึงจะถูกจัดหมวดหมู่เขียนรวมกันอยู่ภายใต้เครื่องหมายวงเล็บสามเหลี่ยมต่อท้ายชื่อฟังก์ชัน

สรุป (Summary)

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

เชื่อหรือไม่ว่า ยังมีเนื้อหาที่น่าสนใจและลึกซึ้งรอให้ไปเรียนรู้ต่อยอดอีกมากมายจากหัวข้อที่เราอภิปรายกันไปในบทนี้: โดยในบทที่ 18 จะนำเสนอหัวข้อทริตอ็อบเจกต์ (trait objects) ซึ่งเป็นอีกหนึ่งแง่มุมของการใช้ประโยชน์จากทริต และยังมีกรณีการใช้งานไลฟ์ไทม์ที่มีความซับซ้อนขึ้นไปอีกขั้นซึ่งมีความจำเป็นเฉพาะในสถานการณ์ขั้นสูงมาก ๆ ซึ่งคุณสามารถหาอ่านศึกษารายละเอียดต่อยอดได้ที่ Rust Reference แต่ทว่าในบทถัดไป คุณจะได้เรียนรู้วิธีการเขียนชุดทดสอบ (tests) ในภาษา Rust เพื่อช่วยตรวจสอบรับประกันความถูกต้องให้มั่นใจได้เต็มเปี่ยมว่าโค้ดโปรแกรมของคุณทำงานได้ตรงตามเป้าหมาย

การเขียนชุดทดสอบอัตโนมัติ (Writing Automated Tests)

ในเรียงความปี 1972 หัวข้อ “The Humble Programmer” เอ็ดสเคอร์ ดับเบิลยู. ไดก์สตรา (Edsger W. Dijkstra) เคยกล่าวไว้ว่า “การทดสอบโปรแกรมสามารถเป็นวิธีการที่มีประสิทธิภาพอย่างยิ่งในการแสดงให้เห็นถึงการมีอยู่ของบั๊ก (bugs) แต่ทว่ามันไม่มีทางเพียงพอที่จะแสดงให้เห็นถึงการไม่มีอยู่ของมันได้เลย” ถึงกระนั้น นั่นก็ไม่ได้หมายความว่าเราไม่ควรพยายามทดสอบระบบให้มากที่สุดเท่าที่เราจะสามารถทำได้!

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

สมมติว่าเราเขียนฟังก์ชันหนึ่งชื่อว่า add_two ซึ่งมีพฤติกรรมบวกค่าเพิ่มไปอีก 2 จากตัวเลขใด ๆ ก็ตามที่ส่งเข้ามา ซิกเนเจอร์ของฟังก์ชันนี้จะรับพารามิเตอร์นำเข้าเป็นเลขจำนวนเต็มและส่งค่ากลับคืนมาเป็นจำนวนเต็ม เมื่อเราเริ่มเขียนคำสั่งและคอมไพล์ฟังก์ชันนั้น Rust จะช่วยตรวจสอบประเภทข้อมูล (type checking) และตรวจสอบสิทธิ์การยืมใช้งาน (borrow checking) ทั้งหมดตามที่เราได้เรียนรู้กันมา เพื่อช่วยรับประกันว่าเราไม่ได้เผลอส่งข้อความ String หรือส่งข้อมูลอ้างอิงที่ไม่ถูกต้องเข้ามาในฟังก์ชันนี้ ทว่า Rust ไม่สามารถ เข้าไปช่วยตรวจสอบได้ว่าฟังก์ชันนี้จะทำงานได้ตรงตามเป้าหมายวัตถุประสงค์ของเราเป๊ะ ๆ (ซึ่งก็คือการนำพารามิเตอร์มาบวก 2 ไม่ใช่บวก 10 หรือลบ 50!) และนี่คือจุดที่ชุดทดสอบ (tests) เข้ามามีบทบาท

เราสามารถเขียนทดสอบเพื่อตรวจเช็คยืนยัน (assert) ตัวอย่างเช่น เมื่อเราป้อนค่า 3 เข้าไปในฟังก์ชัน add_two ผลลัพธ์ส่งคืนที่ควรได้จะต้องเป็น 5 เราจะสามารถสั่งรันชุดทดสอบเหล่านี้ได้ทุกครั้งที่มีการปรับเปลี่ยนแก้ไขโค้ด เพื่อให้มั่นใจได้เต็มเปี่ยมว่าพฤติกรรมการทำงานเดิมที่ถูกต้องอยู่ก่อนแล้วจะไม่มีจุดใดเปลี่ยนแปลงหรือทำงานผิดเพี้ยนไป

การทดสอบคือทักษะความสามารถที่มีความซับซ้อน: แม้ว่าเราจะไม่สามารถบรรจุอธิบายรายละเอียดทั้งหมดเกี่ยวกับแนวทางการเขียนชุดทดสอบที่ดีให้อ่านจบครบถ้วนได้ในบทเดียว แต่ในบทนี้เราจะเน้นอภิปรายถึงกลไกการทำงานพื้นฐานของระบบจัดทำชุดทดสอบของ Rust เราจะพูดคุยเกี่ยวกับสัญลักษณ์กำกับ (annotations) และมาโคร (macros) ต่าง ๆ ที่มีเตรียมไว้ให้ใช้งานสำหรับสร้างชุดทดสอบ พฤติกรรมการทำงานเริ่มต้นรวมถึงอ็อพชันต่าง ๆ ในการสั่งรันชุดทดสอบ และแนวทางการจัดหมวดหมู่ประเภทชุดทดสอบออกเป็น ชุดทดสอบระดับย่อย (unit tests) และ ชุดทดสอบระดับรวมระบบ (integration tests)

How to Write Tests

วิธีการเขียนการทดสอบ (How to Write Tests)

การทดสอบ (Tests) คือฟังก์ชัน Rust ที่ทำหน้าที่ตรวจสอบว่าโค้ดที่ไม่ใช่ส่วนของการทดสอบทำงานได้อย่างถูกต้องตามที่คาดหวังไว้ โดยทั่วไป ตัวฟังก์ชันทดสอบจะดำเนินการ 3 อย่างนี้:

  • จัดเตรียมข้อมูลหรือสถานะ (state) ที่จำเป็น
  • รันโค้ดที่คุณต้องการทดสอบ
  • ยืนยัน (Assert) ว่าผลลัพธ์เป็นไปตามที่คุณคาดหวัง

เรามาดูฟีเจอร์ที่ Rust ให้มาโดยเฉพาะสำหรับการเขียนการทดสอบ ซึ่งประกอบด้วย attribute test, มาโครบางตัว และ attribute should_panic

การจัดโครงสร้างฟังก์ชันการทดสอบ (Structuring Test Functions)

ในรูปแบบที่เรียบง่ายที่สุด การทดสอบใน Rust คือฟังก์ชันที่ถูกระบุด้วย attribute test สำหรับ attributes นั้นคือเมทาดาตา (metadata) เกี่ยวกับชิ้นส่วนโค้ด Rust ตัวอย่างหนึ่งคือ attribute derive ที่เราใช้กับสตรักต์ในบทที่ 5 การเปลี่ยนฟังก์ชันให้กลายเป็นฟังก์ชันการทดสอบ ทำได้โดยการเพิ่ม #[test] ในบรรทัดก่อนหน้า fn เมื่อคุณรันการทดสอบด้วยคำสั่ง cargo test ตัว Rust จะบิลด์ไบนารีทดสอบ (test runner binary) ที่จะเรียกใช้ฟังก์ชันที่มี attribute ดังกล่าวและรายงานว่าฟังก์ชันการทดสอบแต่ละตัวผ่าน (pass) หรือล้มเหลว (fail)

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

เราจะสำรวจการทำงานของการทดสอบโดยการทดลองกับเทมเพลตการทดสอบ ก่อนที่เราจะทดสอบโค้ดจริง จากนั้น เราจะเขียนการทดสอบในโลกจริงเพื่อเรียกใช้โค้ดที่เราเขียนขึ้นและตรวจสอบว่าพฤติกรรมของมันถูกต้อง

ลองสร้างโปรเจกต์ไลบรารีใหม่ชื่อ adder ที่จะทำหน้าที่บวกเลขสองจำนวน:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

เนื้อหาของไฟล์ src/lib.rs ในไลบรารี adder ของคุณควรจะมีหน้าตาเหมือน Listing 11-1

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

ไฟล์นี้เริ่มต้นด้วยฟังก์ชันตัวอย่าง add เพื่อให้เรามีบางอย่างไว้ทดสอบ

สำหรับตอนนี้ ให้เรามุ่งเน้นไปที่ฟังก์ชัน it_works สังเกตการระบุ #[test]: attribute นี้ระบุว่านี่คือฟังก์ชันการทดสอบ เพื่อให้ตัวรันการทดสอบทราบว่าต้องปฏิบัติต่อฟังก์ชันนี้ในฐานะการทดสอบ เราอาจมีฟังก์ชันที่ไม่ใช่การทดสอบอยู่ในโมดูล tests เพื่อช่วยตั้งค่าสถานการณ์ทั่วไปหรือดำเนินการทั่วไป ดังนั้นเราจึงจำเป็นต้องระบุเสมอว่าฟังก์ชันใดเป็นการทดสอบ

ตัวอย่างเนื้อหาฟังก์ชันใช้มาโคร assert_eq! เพื่อตรวจสอบว่า result ซึ่งบรรจุผลลัพธ์จากการเรียก add ด้วย 2 และ 2 นั้นเท่ากับ 4 หรือไม่ การยืนยันนี้เป็นตัวอย่างรูปแบบการทดสอบทั่วไป ลองรันมันเพื่อดูว่าการทดสอบนี้ผ่านหรือไม่

คำสั่ง cargo test จะรันการทดสอบทั้งหมดในโปรเจกต์ของเรา ดังแสดงใน Listing 11-2

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo ได้คอมไพล์และรันการทดสอบแล้ว เราจะเห็นบรรทัด running 1 test บรรทัดถัดไปแสดงชื่อของฟังก์ชันการทดสอบที่สร้างขึ้นคือ tests::it_works และผลลัพธ์ของการรันการทดสอบนั้นคือ ok สรุปโดยรวม test result: ok. หมายความว่าการทดสอบทั้งหมดผ่าน และส่วนที่อ่านว่า 1 passed; 0 failed เป็นการรวมจำนวนการทดสอบที่ผ่านและล้มเหลว

เราสามารถทำเครื่องหมายข้ามการทดสอบ (ignored) เพื่อไม่ให้รันในบางกรณีได้ ซึ่งเราจะพูดถึงในหัวข้อ “การข้ามการทดสอบเว้นแต่จะมีการร้องขอเป็นพิเศษ” ในบทนี้ เนื่องจากเรายังไม่ได้ทำเช่นนั้นในที่นี้ สรุปจึงแสดงเป็น 0 ignored เรายังสามารถส่งอาร์กิวเมนต์ไปยังคำสั่ง cargo test เพื่อรันเฉพาะการทดสอบที่มีชื่อตรงกับข้อความที่กำหนดได้ ซึ่งเรียกว่า การกรอง (filtering) และเราจะพูดถึงในหัวข้อ “การรันส่วนย่อยของการทดสอบตามชื่อ” ในที่นี้เราไม่ได้กรองการทดสอบที่จะรัน ดังนั้นตอนท้ายของสรุปจึงแสดง 0 filtered out

สถิติ 0 measured สำหรับการทดสอบเปรียบเทียบประสิทธิภาพ (benchmark tests) ซึ่งใช้วัดประสิทธิภาพการทำงาน การทดสอบวัดประสิทธิภาพ ณ ขณะที่เขียนนี้ มีให้ใช้งานใน nightly Rust เท่านั้น ดู เอกสารเกี่ยวกับการทดสอบ benchmark เพื่อเรียนรู้เพิ่มเติม

ส่วนถัดไปของผลลัพธ์การทดสอบที่เริ่มต้นด้วย Doc-tests adder คือผลลัพธ์ของการทดสอบเอกสาร (documentation tests) เรายังไม่มีการทดสอบเอกสารใดๆ แต่ Rust สามารถคอมไพล์ตัวอย่างโค้ดที่ปรากฏในเอกสาร API ของเราได้ ฟีเจอร์นี้ช่วยให้เอกสารและโค้ดของคุณสอดคล้องกันอยู่เสมอ! เราจะหารือถึงวิธีเขียนการทดสอบเอกสารในหัวข้อ “ความคิดเห็นเอกสารในฐานะการทดสอบ” ของบทที่ 14 สำหรับตอนนี้ เราจะข้ามผลลัพธ์ของ Doc-tests ไปก่อน

ลองเริ่มปรับแต่งการทดสอบให้ตรงกับความต้องการของเรา ขั้นแรก ให้เปลี่ยนชื่อฟังก์ชัน it_works เป็นชื่ออื่น เช่น exploration ดังนี้:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

จากนั้น รัน cargo test อีกครั้ง ตอนนี้ผลลัพธ์จะแสดง exploration แทนที่จะเป็น it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ตอนนี้เราจะเพิ่มการทดสอบอีกตัวหนึ่ง แต่ครั้งนี้เราจะทำให้การทดสอบล้มเหลว! การทดสอบจะล้มเหลวเมื่อมีบางอย่างในฟังก์ชันการทดสอบเกิด panic การทดสอบแต่ละตัวจะถูกรันในเธรดใหม่ และเมื่อเธรดหลักพบว่าเธรดทดสอบได้ตายลง การทดสอบนั้นจะถูกทำเครื่องหมายว่าล้มเหลว ในบทที่ 9 เราได้พูดถึงวิธีที่ง่ายที่สุดในการทำให้เกิด panic นั่นคือการเรียกใช้มาโคร panic! ให้ใส่การทดสอบใหม่เป็นฟังก์ชันชื่อ another เพื่อให้ไฟล์ src/lib.rs ของคุณมีหน้าตาเหมือน Listing 11-3

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

รันการทดสอบอีกครั้งโดยใช้ cargo test ผลลัพธ์ควรจะเหมือนกับ Listing 11-4 ซึ่งแสดงว่าการทดสอบ exploration ผ่าน และ another ล้มเหลว

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' (6019162) panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

แทนที่จะแสดง ok บรรทัด test tests::another กลับแสดง FAILED มีสองส่วนใหม่ปรากฏขึ้นระหว่างผลลัพธ์ย่อยและผลสรุป: ส่วนแรกแสดงเหตุผลโดยละเอียดของความล้มเหลวในการทดสอบแต่ละรายการ ในกรณีนี้ เราได้รายละเอียดว่า tests::another ล้มเหลวเนื่องจากเกิด panic พร้อมข้อความ Make this test fail ที่บรรทัด 17 ในไฟล์ src/lib.rs ส่วนถัดไปจะแสดงเฉพาะชื่อของการทดสอบทั้งหมดที่ล้มเหลว ซึ่งมีประโยชน์เมื่อมีรายการทดสอบและผลลัพธ์การทดสอบที่ล้มเหลวอย่างละเอียดจำนวนมาก เราสามารถใช้ชื่อของการทดสอบที่ล้มเหลวเพื่อรันเฉพาะการทดสอบนั้นเพื่อดีบักได้ง่ายขึ้น เราจะพูดเพิ่มเติมเกี่ยวกับวิธีรันการทดสอบในหัวข้อ “การควบคุมวิธีรันการทดสอบ”

บรรทัดสรุปแสดงอยู่ที่ตอนท้าย: โดยรวมแล้ว ผลการทดสอบของเราคือ FAILED เรามีการทดสอบผ่านหนึ่งรายการและล้มเหลวหนึ่งรายการ

เมื่อคุณได้เห็นว่าผลการทดสอบมีลักษณะอย่างไรในสถานการณ์ต่างๆ แล้ว เรามาดูมาโครอื่นๆ นอกเหนือจาก panic! ที่มีประโยชน์ในการทดสอบกัน

การตรวจสอบผลลัพธ์ด้วย assert!

มาโคร assert! ที่จัดสรรโดยไลบรารีมาตรฐาน มีประโยชน์เมื่อคุณต้องการตรวจสอบว่าเงื่อนไขบางอย่างในการทดสอบประเมินค่าออกมาเป็น true หรือไม่ เราส่งอาร์กิวเมนต์ที่ประเมินค่าเป็นบูลีน (Boolean) ให้กับมาโคร assert! หากค่านั้นเป็น true จะไม่มีอะไรเกิดขึ้นและการทดสอบจะผ่าน หากค่านั้นเป็น false มาโคร assert! จะเรียก panic! เพื่อทำให้การทดสอบล้มเหลว การใช้มาโคร assert! ช่วยให้เราตรวจสอบได้ว่าโค้ดของเราทำงานในรูปแบบที่เราตั้งใจไว้หรือไม่

ในบทที่ 5 Listing 5-15 เราใช้สตรักต์ Rectangle และเมธอด can_hold ซึ่งถูกนำมาแสดงซ้ำใน Listing 11-5 ให้เรานำโค้ดนี้ใส่ในไฟล์ src/lib.rs แล้วเขียนการทดสอบโดยใช้มาโคร assert!

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

เมธอด can_hold คืนค่าเป็นบูลีน ซึ่งหมายความว่ามันเป็นกรณีการใช้งานที่สมบูรณ์แบบสำหรับมาโคร assert! ใน Listing 11-6 เราได้เขียนการทดสอบเพื่อใช้เมธอด can_hold โดยสร้างอินสแตนซ์ของ Rectangle ที่มีความกว้าง 8 และความสูง 7 และยืนยันว่าสามารถบรรจุอีกอินสแตนซ์หนึ่งของ Rectangle ที่มีความกว้าง 5 และความสูง 1 ได้

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

สังเกตบรรทัด use super::*; ภายในโมดูล tests โมดูล tests เป็นโมดูลปกติที่ปฏิบัติตามกฎการมองเห็น (visibility rules) ตามปกติที่เราได้ครอบคลุมไปในบทที่ 7 ในหัวข้อ “พาธสำหรับอ้างอิงถึงรายการในโครงสร้างโมดูล” เนื่องจากโมดูล tests เป็นโมดูลภายใน เราจึงต้องนำโค้ดที่ต้องการทดสอบในโมดูลภายนอกเข้ามายังขอบเขต (scope) ของโมดูลภายใน เราใช้ glob (*) ในที่นี้ เพื่อให้ทุกอย่างที่เรานิยามไว้ในโมดูลภายนอกพร้อมใช้งานในโมดูล tests นี้

เราได้ตั้งชื่อการทดสอบของเราว่า larger_can_hold_smaller และเราได้สร้างอินสแตนซ์ Rectangle สองตัวที่ต้องการ จากนั้นเราเรียกใช้มาโคร assert! และส่งผลลัพธ์ของการเรียก larger.can_hold(&smaller) นิพจน์นี้ควรคืนค่า true ดังนั้นการทดสอบของเราจึงควรผ่าน มาลองดูกัน!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

มันผ่านจริงด้วย! ลองเพิ่มการทดสอบอีกตัว โดยคราวนี้ยืนยันว่าสี่เหลี่ยมผืนผ้าที่เล็กกว่าไม่สามารถบรรจุสี่เหลี่ยมผืนผ้าที่ใหญ่กว่าได้:

Filename: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

เนื่องจากผลลัพธ์ที่ถูกต้องของฟังก์ชัน can_hold ในกรณีนี้คือ false เราจึงต้องกลับค่า (negate) ผลลัพธ์นั้นก่อนที่จะส่งไปยังมาโคร assert! เป็นผลให้การทดสอบของเราจะผ่านหาก can_hold คืนค่าเป็น false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

สองการทดสอบผ่านแล้ว! ตอนนี้มาดูกันว่าจะเกิดอะไรขึ้นกับผลการทดสอบเมื่อเราใส่บั๊ก (bug) เข้าไปในโค้ด เราจะเปลี่ยนการทำงานของเมธอด can_hold โดยแทนที่เครื่องหมายมากกว่า (>) ด้วยเครื่องหมายน้อยกว่า (<) เมื่อทำการเปรียบเทียบความกว้าง:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

การรันการทดสอบตอนนี้จะได้ผลลัพธ์ดังนี้:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' (6020788) panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

การทดสอบของเราดักจับบั๊กได้! เนื่องจาก larger.width คือ 8 และ smaller.width คือ 5 การเปรียบเทียบความกว้างใน can_hold จึงคืนค่า false: 8 ไม่ได้น้อยกว่า 5

การทดสอบความเท่ากันด้วยมาโคร assert_eq! และ assert_ne!

วิธีทั่วไปในการตรวจสอบฟังก์ชันการทำงานคือการทดสอบความเท่ากันระหว่างผลลัพธ์ของโค้ดที่อยู่ภายใต้การทดสอบกับค่าที่คุณคาดหวังว่าโค้ดจะคืนกลับมา คุณสามารถทำได้โดยใช้มาโคร assert! และส่งนิพจน์ที่ใช้ตัวดำเนินการ == อย่างไรก็ตาม เรื่องนี้เป็นรูปแบบการทดสอบที่พบบ่อยมาก ไลบรารีมาตรฐานจึงจัดเตรียมคู่มาโครได้แก่ assert_eq! และ assert_ne! เพื่อดำเนินการทดสอบนี้อย่างสะดวกยิ่งขึ้น มาโครเหล่านี้จะเปรียบเทียบอาร์กิวเมนต์สองตัวว่าเท่ากันหรือไม่เท่ากันตามลำดับ นอกจากนี้ยังพิมพ์ค่าทั้งสองออกมารายงานหากการยืนยันล้มเหลว ซึ่งช่วยให้เห็นได้ง่ายขึ้นว่า ทำไม การทดสอบจึงล้มเหลว ในทางกลับกัน มาโคร assert! จะระบุเพียงแค่ว่าได้รับค่า false สำหรับนิพจน์ == โดยไม่ได้พิมพ์ค่าที่นำไปสู่ค่า false นั้นออกมา

ใน Listing 11-7 เราเขียนฟังก์ชันชื่อ add_two ที่บวกค่า 2 ให้กับพารามิเตอร์ และทดสอบฟังก์ชันนี้โดยใช้มาโคร assert_eq!

pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

มาตรวจสอบกันว่ามันผ่านหรือไม่!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

เราสร้างตัวแปรชื่อ result ที่เก็บผลลัพธ์ของการเรียก add_two(2) จากนั้นเราส่ง result และ 4 เป็นอาร์กิวเมนต์ให้กับมาโคร assert_eq! บรรทัดผลลัพธ์สำหรับการทดสอบนี้คือ test tests::it_adds_two ... ok และข้อความ ok ระบุว่าการทดสอบของเราผ่าน!

ลองเพิ่มบั๊กเข้าไปในโค้ดของเรา เพื่อดูว่า assert_eq! เป็นอย่างไรเมื่อล้มเหลว เปลี่ยนการทำงานของฟังก์ชัน add_two ให้กลายเป็นบวกด้วย 3 แทน:

pub fn add_two(a: u64) -> u64 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

รันการทดสอบอีกครั้ง:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' (6020955) panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

การทดสอบของเราดักจับบั๊กได้! การทดสอบ tests::it_adds_two ล้มเหลว และข้อความแจ้งเตือนเราว่าการยืนยันที่ล้มเหลวคือ left == right และแสดงค่าของ left และ right ข้อความนี้ช่วยให้เราเริ่มดีบักได้: อาร์กิวเมนต์ทางด้านซ้าย (left) ซึ่งเราได้รับผลลัพธ์จากการเรียก add_two(2) คือ 5 แต่อาร์กิวเมนต์ทางด้านขวา (right) คือ 4 คุณสามารถจินตนาการได้เลยว่าสิ่งนี้มีประโยชน์อย่างยิ่งเมื่อเรามีการทดสอบจำนวนมากที่กำลังรันอยู่

พึงสังเกตว่าในบางภาษาและเฟรมเวิร์กการทดสอบ พารามิเตอร์ของฟังก์ชันการยืนยันความเท่ากันจะเรียกว่า expected (สิ่งที่คาดหวัง) และ actual (สิ่งที่ได้จริง) และลำดับการระบุอาร์กิวเมนต์จะมีความสำคัญ อย่างไรก็ตาม ใน Rust จะเรียกว่า left และ right และลำดับในการระบุค่าที่เราคาดหวังกับค่าที่โค้ดสร้างขึ้นนั้นไม่มีความแตกต่างกัน เราสามารถเขียนการยืนยันในการทดสอบนี้เป็น assert_eq!(4, result) ซึ่งจะส่งผลให้ได้ข้อความแสดงความล้มเหลวแบบเดียวกันว่า assertion `left == right` failed

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

เบื้องหลังการทำงาน มาโคร assert_eq! และ assert_ne! ใช้ตัวดำเนินการ == และ != ตามลำดับ เมื่อการยืนยันล้มเหลว มาโครเหล่านี้จะพิมพ์อาร์กิวเมนต์โดยใช้การจัดรูปแบบดีบัก (debug formatting) ซึ่งหมายความว่าค่าที่นำมาเปรียบเทียบจะต้องปรับใช้ trait PartialEq และ Debug ชนิดข้อมูลพื้นฐานทั้งหมดและชนิดข้อมูลส่วนใหญ่ในไลบรารีมาตรฐานมีการ implement trait เหล่านี้แล้ว สำหรับสตรักต์และเอเนิมที่คุณกำหนดขึ้นเอง คุณจะต้อง implement PartialEq เพื่อยืนยันความเท่ากันของชนิดข้อมูลเหล่านั้น คุณจะต้อง implement Debug เพื่อพิมพ์ค่าเมื่อการยืนยันล้มเหลวด้วย เนื่องจากทั้งสอง trait เป็น trait ที่สามารถ derive ได้ ดังที่ได้กล่าวไว้ใน Listing 5-12 ในบทที่ 5 โดยทั่วไปจึงทำได้ง่ายๆ เพียงเพิ่ม attribute #[derive(PartialEq, Debug)] ลงในคำนิยามสตรักต์หรือเอเนิมของคุณ ดูภาคผนวก C “Derivable Traits” สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ trait เหล่านี้และ trait ที่ derive ได้อื่นๆ

การเพิ่มข้อความความล้มเหลวแบบกำหนดเอง (Adding Custom Failure Messages)

คุณยังสามารถเพิ่มข้อความแบบกำหนดเองให้พิมพ์ออกมาพร้อมกับข้อความแสดงความล้มเหลวได้ โดยระบุเป็นอาร์กิวเมนต์เสริมให้กับมาโคร assert!, assert_eq!, และ assert_ne! อาร์กิวเมนต์ใดๆ ที่ระบุตามหลังอาร์กิวเมนต์ที่จำเป็นจะถูกส่งต่อไปยังมาโคร format! (ซึ่งพูดถึงใน “การเชื่อมต่อข้อความด้วย + หรือ format! ในบทที่ 8) ดังนั้นคุณจึงสามารถส่งสตริงรูปแบบที่มีตัวแทน {} และค่าที่จะใส่ลงในตัวแทนเหล่านั้นได้ ข้อความแบบกำหนดเองมีประโยชน์สำหรับการอธิบายความหมายของการยืนยัน เมื่อการทดสอบล้มเหลว คุณจะเข้าใจสาเหตุของปัญหาในโค้ดได้ดียิ่งขึ้น

ตัวอย่างเช่น สมมติว่าเรามีฟังก์ชันที่ทักทายผู้คนด้วยชื่อ และเราต้องการทดสอบว่าชื่อที่เราส่งไปยังฟังก์ชันปรากฏในผลลัพธ์หรือไม่:

Filename: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

ข้อกำหนดของโปรแกรมนี้ยังไม่ได้ตกลงกันอย่างเป็นทางการ และเราค่อนข้างแน่ใจว่าข้อความ Hello ที่จุดเริ่มต้นของคำทักทายอาจมีการเปลี่ยนแปลง เราตัดสินใจว่าเราไม่อยากคอยอัปเดตการทดสอบเมื่อข้อกำหนดเปลี่ยน ดังนั้นแทนที่จะตรวจสอบความเท่ากันกับค่าที่ส่งกลับจากฟังก์ชัน greeting แบบเป๊ะๆ เราจะเพียงแค่ยืนยันว่าผลลัพธ์มีข้อความของพารามิเตอร์อินพุตอยู่หรือไม่

คราวนี้ลองใส่บั๊กในโค้ดนี้โดยเปลี่ยน greeting ให้เว้น name ออกไป เพื่อดูว่าข้อความความล้มเหลวของการทดสอบเริ่มต้นมีหน้าตาอย่างไร:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

การรันการทดสอบนี้จะได้ผลลัพธ์ดังนี้:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' (6021143) panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

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

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

คราวนี้เมื่อเรารันการทดสอบ เราจะได้ข้อความข้อผิดพลาดที่มีข้อมูลที่เป็นประโยชน์มากขึ้น:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' (6021333) panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

เราสามารถเห็นค่าที่เราได้รับจริงในการทดสอบ ซึ่งช่วยให้เราดีบักสิ่งเกิดขึ้นได้ แทนที่จะเห็นเฉพาะสิ่งที่เราคาดหวังให้เกิดขึ้น

การตรวจสอบการเกิด Panic ด้วย should_panic

นอกเหนือจากการตรวจสอบค่าที่คืนกลับมาแล้ว การตรวจสอบว่าโค้ดของเราจัดการกับเงื่อนไขข้อผิดพลาดตามที่เราคาดหวังหรือไม่ก็เป็นสิ่งสำคัญ ตัวอย่างเช่น พิจารณาประเภท Guess ที่เราสร้างขึ้นในบทที่ 9 Listing 9-13 โค้ดอื่นๆ ที่ใช้ Guess จะพึ่งพาการรับประกันว่าอินสแตนซ์ของ Guess จะมีค่าระหว่าง 1 ถึง 100 เท่านั้น เราสามารถเขียนการทดสอบเพื่อรับประกันว่าการพยายามสร้างอินสแตนซ์ Guess ด้วยค่านอกช่วงนั้นจะเกิด panic

เราทำได้โดยการเพิ่ม attribute should_panic ลงในฟังก์ชันการทดสอบของเรา การทดสอบจะผ่านหากโค้ดภายในฟังก์ชันเกิด panic การทดสอบจะล้มเหลวหากโค้ดภายในฟังก์ชันไม่เกิด panic

Listing 11-8 แสดงการทดสอบที่ตรวจสอบว่าเงื่อนไขข้อผิดพลาดของ Guess::new เกิดขึ้นตามที่เราคาดหวังไว้หรือไม่

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

เราวาง attribute #[should_panic] ไว้หลัง attribute #[test] และก่อนฟังก์ชันการทดสอบที่มันนำไปใช้ มาดูผลลัพธ์เมื่อการทดสอบนี้ผ่าน:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ดูดีทีเดียว! ตอนนี้ลองใส่บั๊กในโค้ดของเราโดยลบเงื่อนไขที่ฟังก์ชัน new จะ panic หากค่ามากกว่า 100 ออกไป:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

เมื่อเรารันการทดสอบใน Listing 11-8 การทดสอบจะล้มเหลว:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

เราไม่ได้ข้อความที่มีประโยชน์มากนักในกรณีนี้ แต่เมื่อเราดูที่ฟังก์ชันการทดสอบ เราจะเห็นว่ามันถูกระบุด้วย #[should_panic] ความล้มเหลวที่เราได้รับหมายความว่าโค้ดในฟังก์ชันการทดสอบไม่ได้ทำให้เกิด panic

การทดสอบที่ใช้ should_panic อาจขาดความแม่นยำได้ การทดสอบ should_panic จะผ่านแม้ว่าการทดสอบจะเกิด panic ด้วยเหตุผลอื่นที่ต่างจากสิ่งที่เราคาดหวัง เพื่อให้การทดสอบ should_panic มีความแม่นยำมากขึ้น เราสามารถเพิ่มพารามิเตอร์เสริม expected ให้กับ attribute should_panic ได้ ตัวรันการทดสอบจะตรวจสอบว่าข้อความความล้มเหลวมีข้อความที่กำหนดอยู่หรือไม่ ตัวอย่างเช่น พิจารณาโค้ดดัดแปลงสำหรับ Guess ใน Listing 11-9 ซึ่งฟังก์ชัน new จะ panic ด้วยข้อความที่ต่างกันขึ้นอยู่กับว่าค่านั้นเล็กเกินไปหรือใหญ่เกินไป

pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

การทดสอบนี้จะผ่านเนื่องจากค่าที่เราใส่ในพารามิเตอร์ expected ของ attribute should_panic เป็นส่วนหนึ่งของข้อความที่ฟังก์ชัน Guess::new เกิด panic เราอาจระบุข้อความ panic ทั้งหมดที่เราคาดหวังก็ได้ ซึ่งในกรณีนี้คือ Guess value must be less than or equal to 100, got 200 สิ่งที่คุณเลือกที่จะระบุจะขึ้นอยู่กับว่าข้อความ panic มีความเฉพาะตัวหรือเป็นแบบไดนามิกมากน้อยเพียงใด และคุณต้องการให้การทดสอบของคุณแม่นยำแค่ไหน ในกรณีนี้ สตริงย่อยของข้อความ panic เพียงพอที่จะรับประกันว่าโค้ดในฟังก์ชันการทดสอบทำงานในกรณี else if value > 100

เพื่อดูว่าจะเกิดอะไรขึ้นเมื่อการทดสอบ should_panic พร้อมข้อความ expected ล้มเหลว ให้เราลองใส่บั๊กในโค้ดอีกครั้งโดยการสลับเนื้อหาของบล็อก if value < 1 และ else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

คราวนี้เมื่อเรารันการทดสอบ should_panic การทดสอบจะล้มเหลว:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' (6021675) panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: "Guess value must be greater than or equal to 1, got 200."
 expected substring: "less than or equal to 100"

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

ข้อความความล้มเหลวระบุว่าการทดสอบนี้เกิด panic จริงตามที่เราคาดไว้ แต่ข้อความ panic ไม่ได้มีสตริงที่คาดหวังคือ less than or equal to 100 ข้อความ panic ที่เราได้รับจริงในกรณีนี้คือ Guess value must be greater than or equal to 1, got 200 ตอนนี้เราก็สามารถเริ่มหาสาเหตุว่าบั๊กของเราอยู่ที่ไหนได้แล้ว!

การใช้ Result<T, E> ในการทดสอบ

การทดสอบทั้งหมดของเราจนถึงตอนนี้จะเกิด panic เมื่อล้มเหลว เรายังสามารถเขียนการทดสอบที่ใช้ Result<T, E> ได้ด้วย! นี่คือการทดสอบจาก Listing 11-1 ที่ถูกเขียนใหม่ให้ใช้ Result<T, E> และคืนค่า Err แทนการเกิด panic:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

ตอนนี้ฟังก์ชัน it_works มีประเภทการคืนค่าเป็น Result<(), String> ภายในตัวฟังก์ชัน แทนที่จะเรียกใช้มาโคร assert_eq! เราจะคืนค่า Ok(()) เมื่อการทดสอบผ่าน และคืนค่า Err ที่บรรจุ String ไว้ภายในเมื่อการทดสอบล้มเหลว

การเขียนการทดสอบให้คืนค่า Result<T, E> ช่วยให้คุณสามารถใช้ตัวดำเนินการเครื่องหมายคำถาม (?) ในเนื้อหาของการทดสอบได้ ซึ่งเป็นวิธีที่สะดวกในการเขียนการทดสอบที่ควรล้มเหลวหากการทำงานใดๆ ภายในทดสอบคืนค่าออกมาเป็นประเภท Err

คุณไม่สามารถใช้ attribute #[should_panic] กับการทดสอบที่ใช้ Result<T, E> ได้ หากต้องการยืนยันว่าการทำงานนั้นคืนค่าประเภท Err อย่า ใช้ตัวดำเนินการเครื่องหมายคำถามกับค่า Result<T, E> แต่ให้ใช้ assert!(value.is_err()) แทน

เมื่อคุณได้เรียนรู้วิธีการเขียนการทดสอบหลายๆ แบบแล้ว เรามาดูกันว่าจะเกิดอะไรขึ้นเมื่อเรารันการทดสอบ และสำรวจตัวเลือกต่างๆ ที่เราสามารถใช้กับ cargo test ได้

Controlling How Tests Are Run

การควบคุมวิธีรันการทดสอบ (Controlling How Tests Are Run)

เช่นเดียวกับที่ cargo run คอมไพล์โค้ดของคุณแล้วรันไบนารีที่ได้ คำสั่ง cargo test จะคอมไพล์โค้ดของคุณในโหมดการทดสอบและรันไบนารีทดสอบที่ได้ พฤติกรรมเริ่มต้นของไบนารีที่สร้างโดย cargo test คือการรันการทดสอบทั้งหมดแบบขนาน (parallel) และดักจับผลลัพธ์ (capture output) ที่เกิดขึ้นระหว่างการรันการทดสอบ ซึ่งจะป้องกันไม่ให้ผลลัพธ์ถูกแสดงออกมาและช่วยให้การอ่านผลการทดสอบง่ายยิ่งขึ้น อย่างไรก็ตาม คุณสามารถระบุตัวเลือกบรรทัดคำสั่ง (command line options) เพื่อเปลี่ยนแปลงพฤติกรรมเริ่มต้นนี้ได้

ตัวเลือกบรรทัดคำสั่งบางตัวส่งไปยัง cargo test และบางตัวส่งไปยังไบนารีทดสอบที่ได้ เพื่อแยกแยะอาร์กิวเมนต์สองประเภทนี้ คุณจะต้องแสดงรายการอาร์กิวเมนต์ที่ส่งไปยัง cargo test ตามด้วยตัวแยก -- จากนั้นตามด้วยอาร์กิวเมนต์ที่จะส่งไปยังไบนารีทดสอบ การรัน cargo test --help จะแสดงตัวเลือกที่คุณสามารถใช้กับ cargo test ได้ และการรัน cargo test -- --help จะแสดงตัวเลือกที่คุณสามารถใช้หลังตัวแยก อ็อปชันเหล่านี้ยังมีอธิบายในเอกสาร ส่วน “Tests” ของหนังสือ The rustc Book

การรันการทดสอบแบบขนานหรือแบบตามลำดับ (Running Tests in Parallel or Consecutively)

เมื่อคุณรันการทดสอบหลายรายการ ตามค่าเริ่มต้น การทดสอบจะถูกรันแบบขนานโดยใช้เธรด (threads) ซึ่งหมายความว่าจะทำงานเสร็จเร็วขึ้นและคุณจะได้รับข้อติชม (feedback) เร็วยิ่งขึ้น เนื่องจากรายการทดสอบกำลังรันพร้อมๆ กัน คุณต้องตรวจสอบให้แน่ใจว่าการทดสอบของคุณไม่ได้พึ่งพากันและกัน หรือพึ่งพาสถานะที่ใช้ร่วมกัน (shared state) รวมถึงสภาพแวดล้อมที่ใช้ร่วมกัน เช่น ไดเรกทอรีทำงานปัจจุบันหรือตัวแปรสภาพแวดล้อม (environment variables)

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

หากคุณไม่อยากรันการทดสอบแบบขนาน หรือต้องการการควบคุมจำนวนเธรดที่ใช้อย่างละเอียดมากขึ้น คุณสามารถส่งแฟล็ก --test-threads และจำนวนเธรดที่คุณต้องการใช้ไปยังไบนารีทดสอบได้ ลองดูตัวอย่างต่อไปนี้:

$ cargo test -- --test-threads=1

เราตั้งค่าจำนวนเธรดทดสอบเป็น 1 เพื่อบอกโปรแกรมว่าไม่ต้องใช้การทำงานแบบขนาน การรันการทดสอบโดยใช้เธรดเดียวจะใช้เวลานานกว่าการรันแบบขนาน แต่การทดสอบจะไม่แทรกแซงกันและกันหากมีการใช้สถานะร่วมกัน

การแสดงผลลัพธ์ของฟังก์ชัน (Showing Function Output)

ตามค่าเริ่มต้น หากการทดสอบผ่าน ไลบรารีการทดสอบของ Rust จะดักจับ (capture) สิ่งใดก็ตามที่พิมพ์ไปยัง standard output ตัวอย่างเช่น หากเราเรียกใช้ println! ในการทดสอบและการทดสอบนั้นผ่าน เราจะไม่เห็นผลลัพธ์ของ println! ในเทอร์มินัล เราจะเห็นเพียงบรรทัดที่ระบุว่าการทดสอบผ่าน หากการทดสอบล้มเหลว เราจะเห็นสิ่งที่ถูกพิมพ์ไปยัง standard output พร้อมกับส่วนที่เหลือของข้อความความล้มเหลว

ตัวอย่างเช่น Listing 11-10 มีฟังก์ชันสมมติที่พิมพ์ค่าของพารามิเตอร์และคืนค่า 10 รวมถึงการทดสอบที่ผ่านและการทดสอบที่ล้มเหลว

fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}

เมื่อเรารันการทดสอบเหล่านี้ด้วย cargo test เราจะเห็นผลลัพธ์ดังนี้:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' (6019863) panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

สังเกตว่าไม่มีส่วนใดในผลลัพธ์นี้ที่แสดง I got the value 4 ซึ่งถูกพิมพ์เมื่อการทดสอบที่ผ่านรัน ผลลัพธ์นั้นถูกดักจับไว้ ส่วนผลลัพธ์จากการทดสอบที่ล้มเหลว I got the value 8 จะปรากฏในส่วนสรุปผลการทดสอบ ซึ่งแสดงสาเหตุของการล้มเหลวด้วย

หากเราต้องการเห็นค่าที่พิมพ์สำหรับการทดสอบที่ผ่านด้วย เราสามารถบอกให้ Rust แสดงผลลัพธ์ของการทดสอบที่สำเร็จได้ด้วย --show-output:

$ cargo test -- --show-output

เมื่อเรารันการทดสอบใน Listing 11-10 อีกครั้งพร้อมกับแฟล็ก --show-output เราจะเห็นผลลัพธ์ดังต่อไปนี้:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' (6022313) panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

การรันส่วนย่อยของการทดสอบตามชื่อ (Running a Subset of Tests by Name)

การรันชุดการทดสอบทั้งหมด (full test suite) ในบางครั้งอาจใช้เวลานาน หากคุณกำลังทำงานกับโค้ดในส่วนใดส่วนหนึ่ง คุณอาจต้องการรันเฉพาะการทดสอบที่เกี่ยวข้องกับโค้ดส่วนนั้น คุณสามารถเลือกการทดสอบที่จะรันได้โดยการส่งชื่อการทดสอบที่คุณต้องการรันเป็นอาร์กิวเมนต์ไปยัง cargo test

เพื่อสาธิตวิธีรันส่วนย่อยของการทดสอบ ขั้นแรกเราจะสร้างการทดสอบสามรายการสำหรับฟังก์ชัน add_two ของเรา ดังแสดงใน Listing 11-11 และเลือกตัวที่จะรัน

pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}

หากเรารันการทดสอบโดยไม่ส่งอาร์กิวเมนต์ใดๆ ดังที่เราเห็นก่อนหน้านี้ การทดสอบทั้งหมดจะรันแบบขนาน:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

การรันการทดสอบเดี่ยว (Running Single Tests)

เราสามารถส่งชื่อฟังก์ชันการทดสอบใดๆ ไปยัง cargo test เพื่อรันเฉพาะการทดสอบนั้นได้:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

มีเพียงการทดสอบที่มีชื่อ one_hundred เท่านั้นที่รัน การทดสอบอีกสองรายการไม่ตรงกับชื่อนั้น ผลลัพธ์การทดสอบแจ้งให้เราทราบว่ามีรายการทดสอบที่ไม่ได้รันเพิ่มเติมโดยแสดง 2 filtered out ที่ตอนท้าย

เราไม่สามารถระบุชื่อของการทดสอบหลายรายการด้วยวิธีนี้ ค่าแรกที่ส่งไปยัง cargo test เท่านั้นที่จะถูกนำไปใช้ แต่มันมีวิธีในการรันการทดสอบหลายรายการอยู่

การกรองเพื่อรันการทดสอบหลายรายการ (Filtering to Run Multiple Tests)

เราสามารถระบุส่วนหนึ่งของชื่อการทดสอบ และการทดสอบใดๆ ที่มีชื่อตรงกับค่านั้นจะถูกรัน ตัวอย่างเช่น เนื่องจากชื่อการทดสอบสองรายการของเรามีคำว่า add เราสามารถรันสองรายการนั้นได้โดยรัน cargo test add:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

คำสั่งนี้รันการทดสอบทั้งหมดที่มีคำว่า add อยู่ในชื่อ และกรองการทดสอบชื่อ one_hundred ออก นอกจากนี้ ให้สังเกตว่าโมดูลที่การทดสอบปรากฏอยู่จะกลายเป็นส่วนหนึ่งของชื่อการทดสอบ ดังนั้นเราจึงสามารถรันการทดสอบทั้งหมดในโมดูลได้โดยกรองตามชื่อของโมดูล

การข้ามการทดสอบเว้นแต่จะมีการร้องขอเป็นพิเศษ (Ignoring Tests Unless Specifically Requested)

บางครั้งการทดสอบบางรายการอาจใช้เวลารันนานมาก คุณจึงอาจต้องการยกเว้นรายการเหล่านั้นในระหว่างการรัน cargo test ตามปกติ แทนที่จะระบุรายการทดสอบทั้งหมดที่คุณต้องการรันเป็นอาร์กิวเมนต์ คุณสามารถระบุ attribute ignore ให้กับการทดสอบที่ใช้เวลานานเพื่อยกเว้นพวกมันได้ ดังแสดงที่นี่:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

หลัง #[test] เราจะเพิ่มบรรทัด #[ignore] ลงในการทดสอบที่เราต้องการยกเว้น คราวนี้เมื่อเรารันการทดสอบ it_works จะรัน แต่ expensive_test จะไม่รัน:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ฟังก์ชัน expensive_test ถูกระบุว่าเป็น ignored หากเราต้องการรันเฉพาะการทดสอบที่ถูกข้าม เราสามารถใช้ cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

การควบคุมรายการทดสอบที่จะรัน จะทำให้คุณมั่นใจได้ว่าผลลัพธ์ของ cargo test จะถูกส่งกลับมาอย่างรวดเร็ว เมื่อคุณอยู่ในจุดที่สมควรตรวจสอบผลลัพธ์ของ ignored และมีเวลาคอยผลลัพธ์ คุณสามารถรัน cargo test -- --ignored แทนได้ หากคุณต้องการรันการทดสอบทั้งหมด ไม่ว่าจะถูกข้ามหรือไม่ก็ตาม คุณสามารถรัน cargo test -- --include-ignored

Test Organization

การจัดโครงสร้างการทดสอบ (Test Organization)

ดังที่ได้กล่าวไว้ในตอนต้นของบทนี้ การทดสอบเป็นศาสตร์ที่มีความซับซ้อน และแต่ละคนใช้ศัพท์และวิธีการจัดโครงสร้างที่แตกต่างกัน ชุมชน Rust มองการทดสอบออกเป็นสองหมวดหมู่หลัก: การทดสอบหน่วย (unit tests) และการทดสอบรวมระบบ (integration tests) การทดสอบหน่วย (Unit tests) มีขนาดเล็กและมุ่งเน้นการทดสอบโมดูลเดียวแบบแยกส่วน ณ เวลาหนึ่งๆ และสามารถทดสอบอินเทอร์เฟซส่วนตัว (private interfaces) ได้ การทดสอบรวมระบบ (Integration tests) เป็นการทดสอบภายนอกไลบรารีของคุณโดยสิ้นเชิง และใช้โค้ดของคุณในรูปแบบเดียวกับที่โค้ดภายนอกอื่นๆ จะใช้งาน โดยใช้เฉพาะอินเทอร์เฟซสาธารณะ (public interface) และอาจประมวลผลหลายโมดูลต่อหนึ่งการทดสอบ

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

การทดสอบหน่วย (Unit Tests)

วัตถุประสงค์ของการทดสอบหน่วยคือการทดสอบแต่ละหน่วยของโค้ดแบบแยกออกจากส่วนที่เหลือ เพื่อชี้จุดได้อย่างรวดเร็วว่าโค้ดส่วนใดทำงานและส่วนใดไม่ทำงานตามที่คาดหวัง คุณจะวางการทดสอบหน่วยไว้ในไดเรกทอรี src ในแต่ละไฟล์ที่มีโค้ดที่ต้องการทดสอบ แนวทางปฏิบัติทั่วไปคือการสร้างโมดูลชื่อ tests ในแต่ละไฟล์เพื่อบรรจุฟังก์ชันการทดสอบ และกำกับโมดูลด้วย cfg(test)

โมดูล tests และ #[cfg(test)]

การระบุ #[cfg(test)] บนโมดูล tests บอกให้ Rust คอมไพล์และรันโค้ดทดสอบเฉพาะเมื่อคุณรัน cargo test ไม่ใช่ตอนที่รัน cargo build สิ่งนี้ช่วยประหยัดเวลาในการคอมไพล์เมื่อคุณต้องการบิลด์เฉพาะไลบรารี และช่วยประหยัดพื้นที่ในไฟล์ไบนารีผลลัพธ์ (compiled artifact) เนื่องจากไม่ได้รวมการทดสอบเข้าไปด้วย คุณจะเห็นว่าการทดสอบรวมระบบอยู่ในไดเรกทอรีที่ต่างออกไป ดังนั้นจึงไม่จำเป็นต้องระบุ #[cfg(test)] อย่างไรก็ตาม เนื่องจากทดสอบหน่วยอยู่ในไฟล์เดียวกับโค้ด คุณจึงต้องใช้ #[cfg(test)] เพื่อระบุว่าไม่ควรรวมพวกมันไว้ในผลลัพธ์การคอมไพล์

ย้อนกลับไปเมื่อตอนที่เราสร้างโปรเจกต์ adder ใหม่ในหัวข้อแรกของบทนี้ Cargo ได้สร้างโค้ดนี้ให้กับเรา:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

ในโมดูล tests ที่ถูกสร้างขึ้นโดยอัตโนมัติ attribute cfg ย่อมาจาก configuration (การกำหนดค่า) และบอก Rust ว่ารายการถัดไปควรรวมอยู่เฉพาะเมื่อมีการกำหนดค่าตัวเลือกตามที่ระบุ ในกรณีนี้ ตัวเลือกการกำหนดค่าคือ test ซึ่ง Rust ให้ไว้สำหรับการคอมไพล์และรันการทดสอบ การใช้ attribute cfg ทำให้ Cargo คอมไพล์โค้ดทดสอบของเราก็ต่อเมื่อเรารันการทดสอบด้วย cargo test อย่างตั้งใจเท่านั้น สิ่งนี้นับรวมถึงฟังก์ชันช่วยเหลือ (helper functions) ใดๆ ที่อาจอยู่ภายในโมดูลนี้ เพิ่มเติมจากฟังก์ชันที่ระบุด้วย #[test]

การทดสอบฟังก์ชันส่วนตัว (Private Function Tests)

มีการถกเถียงกันในชุมชนการทดสอบว่าควรจะทดสอบฟังก์ชันส่วนตัว (private functions) โดยตรงหรือไม่ และภาษาอื่นๆ ทำให้การทดสอบฟังก์ชันส่วนตัวทำได้ยากหรือเป็นไปไม่ได้ ไม่ว่าคุณจะยึดถือแนวคิดการทดสอบแบบใด กฎความเป็นส่วนตัวของ Rust อนุญาตให้คุณทดสอบฟังก์ชันส่วนตัวได้ พิจารณาโค้ดใน Listing 11-12 ที่มีฟังก์ชันส่วนตัว internal_adder

pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}

สังเกตว่าฟังก์ชัน internal_adder ไม่ได้ระบุเป็น pub การทดสอบเป็นเพียงโค้ด Rust และโมดูล tests ก็เป็นเพียงอีกโมดูลหนึ่ง ดังที่เราได้หารือในหัวข้อ “พาธสำหรับอ้างอิงถึงรายการในโครงสร้างโมดูล” รายการในโมดูลลูกสามารถใช้รายการในโมดูลบรรพบุรุษได้ ในการทดสอบนี้ เรานำรายการทั้งหมดที่เป็นของโมดูลแม่ของ tests เข้ามาสู่ขอบเขตด้วย use super::* จากนั้นการทดสอบจึงสามารถเรียก internal_adder ได้ หากคุณคิดว่าไม่ควรทดสอบฟังก์ชันส่วนตัว ก็ไม่มีอะไรใน Rust ที่จะบังคับให้คุณต้องทำเช่นนั้น

การทดสอบรวมระบบ (Integration Tests)

ใน Rust การทดสอบรวมระบบเป็นการทดสอบภายนอกไลบรารีของคุณโดยสิ้นเชิง พวกมันใช้ไลบรารีของคุณในรูปแบบเดียวกับที่โค้ดอื่นๆ จะใช้งาน ซึ่งหมายความว่าพวกมันสามารถเรียกใช้ได้เฉพาะฟังก์ชันที่เป็นส่วนหนึ่งของ public API ของไลบรารีของคุณเท่านั้น วัตถุประสงค์ของการทดสอบรวมระบบคือเพื่อทดสอบว่าหลายๆ ส่วนของไลบรารีทำงานร่วมกันได้อย่างถูกต้องหรือไม่ หน่วยของโค้ดที่ทำงานได้ถูกต้องด้วยตัวมันเองอาจมีปัญหาได้เมื่อนำมารวมกัน ดังนั้นความครอบคลุมของการทดสอบ (test coverage) สำหรับโค้ดที่นำมารวมกันจึงมีความสำคัญเช่นกัน ในการสร้างการทดสอบรวมระบบ ก่อนอื่นคุณต้องมีไดเรกทอรี tests

ไดเรกทอรี tests

เราสร้างไดเรกทอรี tests ไว้ที่ระดับบนสุดของไดเรกทอรีโปรเจกต์ ถัดจาก src Cargo รู้อยู่แล้วว่าจะมองหาไฟล์การทดสอบรวมระบบในไดเรกทอรีนี้ จากนั้นเราจะสร้างไฟล์ทดสอบกี่ไฟล์ก็ได้ตามต้องการ และ Cargo จะคอมไพล์แต่ละไฟล์เป็น crate แยกกัน

มาลองสร้างการทดสอบรวมระบบกัน โดยมีโค้ดใน Listing 11-12 อยู่ในไฟล์ src/lib.rs ให้สร้างไดเรกทอรี tests และสร้างไฟล์ใหม่ชื่อ tests/integration_test.rs โครงสร้างไดเรกทอรีของคุณควรมีหน้าตาดังนี้:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

ป้อนโค้ดใน Listing 11-13 ลงในไฟล์ tests/integration_test.rs

use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}

แต่ละไฟล์ในไดเรกทอรี tests เป็น crate แยกกัน ดังนั้นเราจึงต้องนำไลบรารีของเราเข้าสู่ขอบเขตของ crate ทดสอบแต่ละตัว ด้วยเหตุนี้เราจึงเพิ่ม use adder::add_two; ไว้ที่ด้านบนของโค้ด ซึ่งเราไม่จำเป็นต้องทำในการทดสอบหน่วย

เราไม่จำเป็นต้องระบุโค้ดใดๆ ใน tests/integration_test.rs ด้วย #[cfg(test)] Cargo ปฏิบัติต่อไดเรกทอรี tests เป็นพิเศษ และคอมไพล์ไฟล์ในไดเรกทอรีนี้เฉพาะเมื่อเรารัน cargo test เท่านั้น รัน cargo test ตอนนี้:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ผลลัพธ์ทั้งสามส่วนประกอบด้วย การทดสอบหน่วย (unit tests), การทดสอบรวมระบบ (integration test) และ การทดสอบเอกสาร (doc tests) สังเกตว่าหากการทดสอบใดๆ ในส่วนหนึ่งล้มเหลว ส่วนถัดไปจะไม่ถูกรัน ตัวอย่างเช่น หากการทดสอบหน่วยล้มเหลว จะไม่มีผลลัพธ์สำหรับการทดสอบรวมระบบและการทดสอบเอกสาร เนื่องจากทดสอบเหล่านั้นจะรันก็ต่อเมื่อการทดสอบหน่วยทั้งหมดผ่านแล้วเท่านั้น

ส่วนแรกสำหรับการทดสอบหน่วยจะเหมือนกับที่เราเห็นก่อนหน้านี้: หนึ่งบรรทัดสำหรับแต่ละการทดสอบหน่วย (ตัวหนึ่งชื่อ internal ที่เราเพิ่มใน Listing 11-12) แล้วจึงเป็นบรรทัดสรุปสำหรับการทดสอบหน่วย

ส่วนการทดสอบรวมระบบเริ่มต้นด้วยบรรทัด Running tests/integration_test.rs ถัดไปคือบรรทัดสำหรับแต่ละฟังก์ชันการทดสอบในการทดสอบรวมระบบนั้น และบรรทัดสรุปผลลัพธ์ของการทดสอบรวมระบบก่อนที่ส่วน Doc-tests adder จะเริ่มต้น

ไฟล์ทดสอบรวมระบบแต่ละไฟล์มีส่วนของตัวเอง ดังนั้นหากเราเพิ่มไฟล์ในไดเรกทอรี tests เพิ่มเติม ก็จะมีส่วนการทดสอบรวมระบบมากขึ้น

เรายังคงสามารถรันฟังก์ชันการทดสอบรวมระบบเฉพาะได้โดยระบุชื่อฟังก์ชันการทดสอบเป็นอาร์กิวเมนต์ให้กับ cargo test และหากต้องการรันการทดสอบทั้งหมดในไฟล์ทดสอบรวมระบบไฟล์ใดไฟล์หนึ่ง ให้ใช้อาร์กิวเมนต์ --test ของ cargo test ตามด้วยชื่อไฟล์:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

คำสั่งนี้รันเฉพาะการทดสอบในไฟล์ tests/integration_test.rs

โมดูลย่อยในการทดสอบรวมระบบ (Submodules in Integration Tests)

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

พฤติกรรมที่แตกต่างกันของไฟล์ในไดเรกทอรี tests จะเห็นได้ชัดเจนที่สุดเมื่อคุณมีชุดฟังก์ชันช่วยเหลือที่ต้องการใช้ในไฟล์ทดสอบรวมระบบหลายๆ ไฟล์ และคุณพยายามทำตามขั้นตอนในหัวข้อ “การแยกโมดูลออกเป็นไฟล์ต่างๆ” ในบทที่ 7 เพื่อดึงพวกมันออกมาเป็นโมดูลส่วนกลาง ตัวอย่างเช่น หากเราสร้าง tests/common.rs และวางฟังก์ชันชื่อ setup ไว้ในนั้น เราจะสามารถเพิ่มโค้ดบางอย่างใน setup ที่เราต้องการเรียกใช้จากหลายๆ ฟังก์ชันการทดสอบในหลายๆ ไฟล์ทดสอบ:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

เมื่อเรารันการทดสอบอีกครั้ง เราจะเห็นส่วนใหม่ในผลลัพธ์การทดสอบสำหรับไฟล์ common.rs แม้ว่าไฟล์นี้จะไม่ได้บรรจุฟังก์ชันการทดสอบใดๆ และเราก็ไม่ได้เรียกใช้ฟังก์ชัน setup จากที่ใดเลยก็ตาม:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

การที่มี common ปรากฏในผลการทดสอบโดยแสดง running 0 tests ไม่ใช่สิ่งที่เราต้องการ เราเพียงแค่ต้องการแชร์โค้ดบางส่วนกับไฟล์ทดสอบรวมระบบอื่นๆ เพื่อหลีกเลี่ยงไม่ให้ common ปรากฏในผลลัพธ์การทดสอบ แทนที่จะสร้าง tests/common.rs เราจะสร้าง tests/common/mod.rs แทน ไดเรกทอรีโปรเจกต์ตอนนี้จะมีหน้าตาดังนี้:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

นี่คือข้อตกลงการตั้งชื่อแบบเก่าที่ Rust เข้าใจเช่นกัน ซึ่งเราได้กล่าวไว้ในหัวข้อ “พาธไฟล์ทางเลือก” ในบทที่ 7 การตั้งชื่อไฟล์ด้วยวิธีนี้บอก Rust ไม่ให้ปฏิบัติต่อโมดูล common ในฐานะไฟล์การทดสอบรวมระบบ เมื่อเราย้ายโค้ดฟังก์ชัน setup เข้าไปใน tests/common/mod.rs และลบไฟล์ tests/common.rs ออกไป ส่วนดังกล่าวกระทั่งในผลลัพธ์การทดสอบจะไม่ปรากฏอีกต่อไป ไฟล์ในไดเรกทอรีย่อยของไดเรกทอรี tests จะไม่ถูกคอมไพล์เป็น crate แยกกัน หรือมีส่วนของตัวเองในผลลัพธ์การทดสอบ

หลังจากที่เราสร้าง tests/common/mod.rs แล้ว เราสามารถใช้งานมันในฐานะโมดูลจากไฟล์การทดสอบรวมระบบใดก็ได้ นี่คือตัวอย่างการเรียกใช้ฟังก์ชัน setup จากการทดสอบ it_adds_two ใน tests/integration_test.rs:

Filename: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

สังเกตว่าการประกาศ mod common; จะเหมือนกับการประกาศโมดูลที่เราสาธิตใน Listing 7-21 จากนั้น ในฟังก์ชันการทดสอบ เราจะสามารถเรียกฟังก์ชัน common::setup() ได้

การทดสอบรวมระบบสำหรับ Binary Crates

หากโปรเจกต์ของเราเป็น binary crate ที่มีเฉพาะไฟล์ src/main.rs และไม่มีไฟล์ src/lib.rs เราจะไม่สามารถสร้างการทดสอบรวมระบบในไดเรกทอรี tests และนำฟังก์ชันที่กำหนดไว้ในไฟล์ src/main.rs เข้าสู่ขอบเขตด้วยคำสั่ง use ได้ มีเพียง library crates เท่านั้นที่จะเปิดเผยฟังก์ชันที่ crate อื่นสามารถใช้ได้ โดย binary crates ถูกออกแบบมาให้รันด้วยตัวของมันเอง

นี่เป็นหนึ่งในเหตุผลที่โปรเจกต์ Rust ที่มีไบนารีจะมีไฟล์ src/main.rs ที่ตรงไปตรงมา ซึ่งทำหน้าที่เรียกใช้ตรรกะที่อยู่ในไฟล์ src/lib.rs โดยใช้โครงสร้างนั้น การทดสอบรวมระบบ จะสามารถ ทดสอบ library crate ด้วย use เพื่อให้ฟังก์ชันการทำงานที่สำคัญพร้อมใช้งาน หากฟังก์ชันการทำงานที่สำคัญทำงานได้ถูกต้อง โค้ดส่วนเล็กๆ ในไฟล์ src/main.rs ก็จะทำงานได้ถูกต้องเช่นกัน และโค้ดส่วนเล็กๆ นั้นก็ไม่จำเป็นต้องถูกทดสอบ

สรุป (Summary)

ฟีเจอร์การทดสอบของ Rust มอบวิธีระบุว่าโค้ดควรทำงานอย่างไร เพื่อให้แน่ใจว่าโค้ดจะยังคงทำงานตามที่คุณคาดหวังแม้ว่าจะมีการเปลี่ยนแปลง การทดสอบหน่วยทดสอบแต่ละส่วนของไลบรารีแยกกัน และสามารถทดสอบรายละเอียดการใช้งานส่วนตัวได้ การทดสอบรวมระบบตรวจสอบว่าหลายๆ ส่วนของไลบรารีทำงานร่วมกันได้อย่างถูกต้อง และใช้ public API ของไลบรารีเพื่อทดสอบโค้ดในรูปแบบเดียวกับที่โค้ดภายนอกจะใช้งาน แม้ว่าระบบประเภทข้อมูล (type system) และกฎความเป็นเจ้าของ (ownership rules) ของ Rust จะช่วยป้องกันบั๊กบางประเภทได้ แต่การทดสอบก็ยังมีความสำคัญในการลดบั๊กทางตรรกะที่เกี่ยวกับพฤติกรรมที่คาดหวังของโค้ด

ลองนำความรู้ที่คุณได้เรียนรู้ในบทนี้และในบทก่อนหน้ามารวมกันเพื่อทำงานในโปรเจกต์กันเลย!

โปรเจกต์ I/O: การสร้างโปรแกรมบรรทัดคำสั่ง (An I/O Project: Building a Command Line Program)

บทนี้เป็นการทบทวนทักษะต่างๆ มากมายที่คุณได้เรียนรู้มาจนถึงตอนนี้ และเป็นการสำรวจฟีเจอร์ของไลบรารีมาตรฐานเพิ่มเติมนิดหน่อย เราจะสร้างเครื่องมือบรรทัดคำสั่ง (command line tool) ที่ปฏิสัมพันธ์กับการป้อนข้อมูลและการส่งออกข้อมูล (input/output) ของไฟล์และบรรทัดคำสั่ง เพื่อฝึกฝนคอนเซปต์ของ Rust ที่คุณมีติดตัวแล้ว

ความเร็ว ความปลอดภัย การส่งออกเป็นไฟล์ไบนารีเดี่ยว และการรองรับหลายแพลตฟอร์มของ Rust ทำให้มันเป็นภาษาที่เหมาะอย่างยิ่งสำหรับการสร้างเครื่องมือบรรทัดคำสั่ง ดังนั้นสำหรับโปรเจกต์ของเรา เราจะทำเครื่องมือค้นหาบรรทัดคำสั่งคลาสสิกเวอร์ชันของเราเองนั่นคือ grep (globally search a regular expression and print) ในกรณีการใช้งานที่เรียบง่ายที่สุด grep จะค้นหาสตริงที่ระบุในไฟล์ที่ระบุ ในการทำเช่นนั้น grep จะรับพารามิเตอร์เป็นพาธไฟล์และสตริง จากนั้นมันจะอ่านไฟล์ ค้นหาบรรทัดในไฟล์นั้นที่มีสตริงตรงกับอาร์กิวเมนต์ แล้วพิมพ์บรรทัดเหล่านั้นออกมา

ในระหว่างทาง เราจะแสดงวิธีทำให้เครื่องมือบรรทัดคำสั่งของเราใช้ฟีเจอร์เทอร์มินัลที่เครื่องมือบรรทัดคำสั่งอื่นๆ หลายตัวใช้งาน เราจะอ่านค่าของตัวแปรสภาพแวดล้อม (environment variable) เพื่อให้ผู้ใช้สามารถกำหนดค่าพฤติกรรมของเครื่องมือเราได้ นอกจากนี้เราจะพิมพ์ข้อความข้อผิดพลาดไปยังสตรีมคอนโซลข้อผิดพลาดมาตรฐาน (stderr) แทนที่จะเป็นสตรีมผลลัพธ์มาตรฐาน (stdout) เพื่อให้ผู้ใช้สามารถส่งทิศทางผลลัพธ์ที่สำเร็จไปยังไฟล์ในขณะที่ยังมองเห็นข้อความข้อผิดพลาดบนหน้าจอได้

สมาชิกคนหนึ่งในชุมชน Rust ชื่อ Andrew Gallant ได้สร้าง grep เวอร์ชันที่สมบูรณ์และรวดเร็วมากไว้แล้วเรียกว่า ripgrep เมื่อเปรียบเทียบกันแล้ว เวอร์ชันของเราจะค่อนข้างเรียบง่าย แต่บทนี้จะให้ความรู้พื้นฐานที่คุณจำเป็นต้องใช้ในการเข้าใจโปรเจกต์ในโลกจริง เช่น ripgrep

โปรเจกต์ grep ของเราจะรวมแนวคิดต่างๆ ที่คุณได้เรียนรู้มาจนถึงตอนนี้เข้าด้วยกัน:

เราจะแนะนำ closures, iterators และ trait objects สั้นๆ ด้วย ซึ่ง บทที่ 13 และ บทที่ 18 จะอธิบายอย่างละเอียด

Accepting Command Line Arguments

การรับอาร์กิวเมนต์จากบรรทัดคำสั่ง (Accepting Command Line Arguments)

มาเริ่มสร้างโปรเจกต์ใหม่ด้วยคำสั่ง cargo new กันเหมือนเช่นเคย เราจะตั้งชื่อโปรเจกต์ของเราว่า minigrep เพื่อแยกความแตกต่างจากเครื่องมือ grep ที่คุณอาจมีอยู่แล้วในระบบของคุณ:

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

งานแรกคือทำให้ minigrep ยอมรับอาร์กิวเมนต์บรรทัดคำสั่งสองตัว ได้แก่ พาธของไฟล์ และสตริงที่จะค้นหา นั่นคือ เราต้องการรันโปรแกรมด้วย cargo run ตามด้วยเครื่องหมายยัติภังค์สองตัว (--) เพื่อระบุว่าอาร์กิวเมนต์ถัดไปเป็นของโปรแกรมเรา ไม่ใช่ของ cargo ตามด้วยสตริงที่ต้องการค้นหา และพาธไปยังไฟล์ที่จะค้นหา เช่นนี้:

$ cargo run -- searchstring example-filename.txt

ในตอนนี้ โปรแกรมที่สร้างโดย cargo new ยังไม่สามารถประมวลผลอาร์กิวเมนต์ที่เราส่งให้ได้ ไลบรารีที่มีอยู่ใน crates.io บางตัวสามารถช่วยเขียนโปรแกรมที่รับอาร์กิวเมนต์บรรทัดคำสั่งได้ แต่เนื่องจากคุณเพิ่งเริ่มเรียนรู้แนวคิดนี้ เรามาเขียนความสามารถนี้ด้วยตัวเองกันดีกว่า

การอ่านค่าของอาร์กิวเมนต์ (Reading the Argument Values)

เพื่อให้ minigrep สามารถอ่านค่าของอาร์กิวเมนต์บรรทัดคำสั่งที่เราส่งให้ได้ เราจะต้องใช้ฟังก์ชัน std::env::args ที่มีอยู่ในไลบรารีมาตรฐานของ Rust ฟังก์ชันนี้จะคืนค่าเป็น iterator ของอาร์กิวเมนต์บรรทัดคำสั่งที่ส่งไปยัง minigrep เราจะครอบคลุมเรื่อง iterator อย่างเต็มรูปแบบใน บทที่ 13 สำหรับตอนนี้ คุณต้องรู้เพียงรายละเอียดสองอย่างเกี่ยวกับ iterator: Iterator จะสร้างลำดับของค่า และเราสามารถเรียกใช้เมธอด collect บน iterator เพื่อเปลี่ยนให้เป็น collection เช่น เวกเตอร์ ซึ่งบรรจุสมาชิกทั้งหมดที่ iterator สร้างขึ้น

โค้ดใน Listing 12-1 ช่วยให้โปรแกรม minigrep ของคุณสามารถอ่านอาร์กิวเมนต์บรรทัดคำสั่งใดๆ ที่ส่งเข้ามา แล้วรวบรวมค่านั้นลงในเวกเตอร์

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}

ขั้นแรก เรานำโมดูล std::env เข้ามาสู่ขอบเขตด้วยคำสั่ง use เพื่อให้สามารถใช้ฟังก์ชัน args ได้ สังเกตว่าฟังก์ชัน std::env::args นั้นซ้อนอยู่ในโมดูลสองระดับ ดังที่เราได้หารือกันใน บทที่ 7 ในกรณีที่ฟังก์ชันที่ต้องการซ้อนอยู่ในโมดูลมากกว่าหนึ่งระดับ เราเลือกที่จะนำโมดูลแม่เข้ามาสู่ขอบเขตแทนที่จะเป็นตัวฟังก์ชันโดยตรง การทำเช่นนี้ทำให้เราสามารถใช้ฟังก์ชันอื่นจาก std::env ได้ง่ายขึ้น และยังลดความคลุมเครือมากกว่าการเขียน use std::env::args แล้วเรียกใช้ฟังก์ชันด้วย args เพียงอย่างเดียว เพราะ args อาจถูกเข้าใจผิดว่าเป็นฟังก์ชันที่นิยามไว้ในโมดูลปัจจุบันได้ง่าย

ฟังก์ชัน args และ Unicode ที่ไม่ถูกต้อง

พึงสังเกตว่า std::env::args จะ เกิด panic หากอาร์กิวเมนต์ใดๆ บรรจุ Unicode ที่ไม่ถูกต้อง หากโปรแกรมของคุณจำเป็นต้องรับอาร์กิวเมนต์ที่มี Unicode ที่ไม่ถูกต้อง ให้ใช้ std::env::args_os แทน ฟังก์ชันนั้นจะคืนค่าเป็น iterator ที่สร้างค่า OsString แทนค่า String เราเลือกใช้ std::env::args ในที่นี้เพื่อความเรียบง่าย เนื่องจากค่า OsString จะแตกต่างกันไปตามแต่ละแพลตฟอร์มและทำงานด้วยซับซ้อนกว่าค่า String

ในบรรทัดแรกของ main เราเรียกใช้ env::args และใช้ collect ทันทีเพื่อเปลี่ยน iterator ให้เป็นเวกเตอร์ที่บรรจุค่าทั้งหมดที่ถูกสร้างโดย iterator เราสามารถใช้ฟังก์ชัน collect เพื่อสร้าง collection ได้หลายประเภท ดังนั้นเราจึงต้องระบุประเภทข้อมูลของ args อย่างชัดเจนว่าต้องการเวกเตอร์ของสตริง แม้ว่าคุณจะแทบไม่ต้องระบุประเภทข้อมูลใน Rust แต่ collect เป็นฟังก์ชันหนึ่งที่คุณมักจะต้องระบุประเภทข้อมูล เนื่องจาก Rust ไม่สามารถคาดเดาประเภทของ collection ที่คุณต้องการได้

สุดท้าย เราจะพิมพ์เวกเตอร์โดยใช้มาโครดีบัก ลองรันโค้ดครั้งแรกโดยไม่ใส่อาร์กิวเมนต์ และครั้งที่สองโดยใส่อาร์กิวเมนต์สองตัว:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

สังเกตว่าค่าแรกในเวกเตอร์คือ "target/debug/minigrep" ซึ่งเป็นชื่อของไฟล์ไบนารีของเรา สิ่งนี้สอดคล้องกับพฤติกรรมของรายการอาร์กิวเมนต์ในภาษา C ทำให้โปรแกรมสามารถใช้ชื่อที่ถูกเรียกใช้ในการประมวลผลได้ มักจะสะดวกที่มีการเข้าถึงชื่อโปรแกรมเผื่อกรณีที่คุณต้องการพิมพ์ในข้อความแจ้งเตือน หรือเปลี่ยนพฤติกรรมของโปรแกรมตามนามแฝง (alias) บรรทัดคำสั่งที่ใช้เรียกโปรแกรม แต่สำหรับวัตถุประสงค์ของบทนี้ เราจะข้ามมันไปและบันทึกเฉพาะอาร์กิวเมนต์สองตัวที่เราต้องการเท่านั้น

การบันทึกค่าอาร์กิวเมนต์ไว้ในตัวแปร (Saving the Argument Values in Variables)

ตอนนี้โปรแกรมสามารถเข้าถึงค่าที่ระบุเป็นอาร์กิวเมนต์บรรทัดคำสั่งได้แล้ว คราวนี้เราจำเป็นต้องบันทึกค่าของอาร์กิวเมนต์ทั้งสองตัวไว้ในตัวแปร เพื่อให้เราสามารถใช้ค่านั้นได้ตลอดส่วนที่เหลือของโปรแกรม เราทำเช่นนั้นใน Listing 12-2

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}

ดังที่เราเห็นเมื่อตอนพิมพ์เวกเตอร์ ชื่อของโปรแกรมจะจับจองค่าแรกในเวกเตอร์ที่ args[0] ดังนั้นเราจึงเริ่มใส่อาร์กิวเมนต์ที่ดัชนี 1 อาร์กิวเมนต์แรกที่ minigrep รับมาคือสตริงที่เรากำลังค้นหา ดังนั้นเราจึงวาง reference ของอาร์กิวเมนต์แรกไว้ในตัวแปร query ส่วนอาร์กิวเมนต์ที่สองจะเป็นพาธไฟล์ ดังนั้นเราจึงวาง reference ของอาร์กิวเมนต์ที่สองไว้ในตัวแปร file_path

เราพิมพ์ค่าของตัวแปรเหล่านี้ชั่วคราวเพื่อพิสูจน์ว่าโค้ดทำงานตามที่เราตั้งใจไว้ ลองรันโปรแกรมนี้อีกครั้งพร้อมกับอาร์กิวเมนต์ test และ sample.txt:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

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

Reading a File

การอ่านไฟล์ (Reading a File)

คราวนี้เราจะเพิ่มฟังก์ชันการทำงานเพื่ออ่านไฟล์ที่ระบุในอาร์กิวเมนต์ file_path ขั้นแรก เราต้องมีไฟล์ตัวอย่างเพื่อใช้นำมาทดสอบ: เราจะใช้ไฟล์ที่มีข้อความสั้นๆ หลายบรรทัดพร้อมคำที่ซ้ำกันบางคำ Listing 12-3 มีบทกวีของ Emily Dickinson ซึ่งจะเหมาะมากสำหรับใช้ทดสอบ! ให้สร้างไฟล์ชื่อ poem.txt ไว้ที่ระดับบนสุด (root level) ของโปรเจกต์ของคุณ และใส่บทกวี “I’m Nobody! Who are you?” ลงไป

I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

เมื่อใส่ข้อความเรียบร้อยแล้ว ให้แก้ไข src/main.rs และเพิ่มโค้ดสำหรับอ่านไฟล์ ดังแสดงใน Listing 12-4

use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

ขั้นแรก เรานำส่วนที่เกี่ยวข้องของไลบรารีมาตรฐานเข้ามาด้วยคำสั่ง use: เราจำเป็นต้องใช้ std::fs เพื่อจัดการกับไฟล์

ใน main คำสั่งใหม่ fs::read_to_string จะรับค่า file_path เปิดไฟล์นั้น และคืนค่าเป็นประเภท std::io::Result<String> ซึ่งบรรจุเนื้อหาของไฟล์อยู่ภายใน

หลังจากนั้น เราเพิ่มคำสั่ง println! ชั่วคราวอีกครั้งเพื่อพิมพ์ค่าของ contents หลังจากอ่านไฟล์แล้ว เพื่อที่เราจะได้ตรวจสอบว่าโปรแกรมทำงานได้ถูกต้องในตอนนี้

ลองรันโค้ดนี้โดยใช้สตริงใดก็ได้เป็นอาร์กิวเมนต์แรกของบรรทัดคำสั่ง (เนื่องจากเรายังไม่ได้ปรับใช้ส่วนการค้นหา) และใช้ไฟล์ poem.txt เป็นอาร์กิวเมนต์ตัวที่สอง:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

เยี่ยมเลย! โค้ดสามารถอ่านและพิมพ์เนื้อหาของไฟล์ออกมาได้แล้ว แต่โค้ดนี้ยังมีข้อบกพร่องอยู่บ้าง ในขณะนี้ ฟังก์ชัน main มีหน้าที่รับผิดชอบหลายอย่าง: โดยทั่วไปแล้ว ฟังก์ชันจะชัดเจนและบำรุงรักษาได้ง่ายกว่า หากแต่ละฟังก์ชันรับผิดชอบเพียงแนวคิดเดียวเท่านั้น ปัญหาใหญ่อีกประการหนึ่งคือเรายังไม่ได้จัดการกับข้อผิดพลาดดีเท่าที่ควร โปรแกรมยังคงมีขนาดเล็ก ข้อบกพร่องเหล่านี้จึงยังไม่เป็นปัญหาใหญ่ แต่เมื่อโปรแกรมขยายใหญ่ขึ้น การแก้ไขข้อบกพร่องเหล่านี้อย่างเป็นระเบียบจะทำได้ยากขึ้น ถือเป็นแนวทางปฏิบัติที่ดีที่จะเริ่มทำการรีแฟกเตอร์ (refactoring) ตั้งแต่เนิ่นๆ ในระหว่างการพัฒนาโปรแกรม เพราะการรีแฟกเตอร์โค้ดจำนวนน้อยนั้นทำได้ง่ายกว่ามาก เราจะทำสิ่งนั้นในหัวข้อถัดไป

Refactoring to Improve Modularity and Error Handling

การรีแฟกเตอร์เพื่อปรับปรุงมอดุลาลิตี้และการจัดการข้อผิดพลาด (Refactoring to Improve Modularity and Error Handling)

เพื่อปรับปรุงโปรแกรมของเรา เราจะแก้ไขปัญหา 4 ประการที่เกี่ยวข้องกับโครงสร้างของโปรแกรมและวิธีจัดการกับข้อผิดพลาดที่อาจเกิดขึ้น ประการแรก ฟังก์ชัน main ของเราตอนนี้ทำหน้าที่ 2 อย่างคือ: แกะอาร์กิวเมนต์ (parse arguments) และอ่านไฟล์ เมื่อโปรแกรมของเราเติบโตขึ้น จำนวนงานย่อยที่ฟังก์ชัน main ต้องจัดการจะเพิ่มมากขึ้น เมื่อฟังก์ชันรับภาระหน้าที่เพิ่มขึ้น มันจะกลายเป็นเรื่องยากที่จะทำความเข้าใจ ยากต่อการทดสอบ และยากต่อการเปลี่ยนแปลงโดยไม่ทำให้ส่วนใดส่วนหนึ่งพัง การแยกการทำงานออกจากกันเพื่อให้แต่ละฟังก์ชันรับผิดชอบเพียงงานเดียวจึงเป็นวิธีที่ดีที่สุด

ประเด็นนี้ยังเชื่อมโยงกับปัญหาประการที่สอง: แม้ว่า query และ file_path จะเป็นตัวแปรการกำหนดค่า (configuration variables) ของโปรแกรม แต่ตัวแปรอย่าง contents ถูกใช้เพื่อดำเนินการตามตรรกะของโปรแกรม ยิ่ง main ยาวเท่าไร เราก็ยิ่งต้องนำตัวแปรเข้าสู่ขอบเขต (scope) มากขึ้นเท่านั้น ยิ่งเรามีตัวแปรในขอบเขตมากเท่าไร การติดตามวัตถุประสงค์ของแต่ละตัวแปรก็จะยิ่งยากขึ้นเท่านั้น การจัดกลุ่มตัวแปรการกำหนดค่าไว้ในโครงสร้างเดียวเพื่อให้อธิบายวัตถุประสงค์ได้อย่างชัดเจนจึงเป็นวิธีที่ดีที่สุด

ปัญหาประการที่สามคือ เราได้ใช้ expect เพื่อพิมพ์ข้อความข้อผิดพลาดเมื่ออ่านไฟล์ล้มเหลว แต่ข้อความข้อผิดพลาดพิมพ์เพียงแค่ Should have been able to read the file การอ่านไฟล์สามารถล้มเหลวได้จากหลายสาเหตุ: ตัวอย่างเช่น ไฟล์อาจจะหายไป หรือเราอาจไม่มีสิทธิ์เปิดไฟล์ ในตอนนี้ ไม่ว่าสถานการณ์จะเป็นอย่างไร เราจะพิมพ์ข้อความข้อผิดพลาดเดียวกันสำหรับทุกอย่าง ซึ่งไม่ได้ให้ข้อมูลใดๆ แก่ผู้ใช้เลย!

ประการที่สี่ เราใช้ expect เพื่อจัดการกับข้อผิดพลาด และหากผู้ใช้รันโปรแกรมของเราโดยไม่ได้ระบุอาร์กิวเมนต์ที่เพียงพอ พวกเขาจะได้ข้อผิดพลาด index out of bounds จาก Rust ซึ่งไม่ได้อธิบายปัญหาอย่างชัดเจน มันจะดีที่สุดหากโค้ดจัดการข้อผิดพลาดทั้งหมดอยู่ในที่เดียว เพื่อให้ผู้ดูแลโค้ดในอนาคตมีเพียงที่เดียวที่ต้องอ้างอิงโค้ดหากตรรกะการจัดการข้อผิดพลาดจำเป็นต้องเปลี่ยนแปลง การมีโค้ดจัดการข้อผิดพลาดทั้งหมดอยู่ในที่เดียวยังช่วยให้มั่นใจได้ว่าเรากำลังพิมพ์ข้อความที่มีความหมายต่อผู้ใช้ปลายทางของเรา

ลองมาแก้ปัญหาทั้ง 4 ประการนี้โดยการรีแฟกเตอร์โปรเจกต์ของเรากัน

การแยกส่วนความรับผิดชอบในโปรเจกต์ไบนารี (Separating Concerns in Binary Projects)

ปัญหาในการจัดระเบียบของการมอบหมายความรับผิดชอบหลายๆ งานให้กับฟังก์ชัน main นั้นพบได้บ่อยในโปรเจกต์ไบนารีจำนวนมาก ด้วยเหตุนี้ โปรแกรมเมอร์ Rust จำนวนมากจึงพบว่ามันมีประโยชน์ที่จะแยกส่วนความรับผิดชอบที่แตกต่างกันของโปรแกรมไบนารีออกเมื่อฟังก์ชัน main เริ่มมีขนาดใหญ่ขึ้น กระบวนการนี้มีขั้นตอนดังต่อไปนี้:

  • แยกโปรแกรมของคุณออกเป็นไฟล์ main.rs และไฟล์ lib.rs แล้วย้ายตรรกะของโปรแกรมไปยัง lib.rs
  • ตราบใดที่ตรรกะการแกะบรรทัดคำสั่งยังมีขนาดเล็ก มันสามารถอยู่ในฟังก์ชัน main ต่อไปได้
  • เมื่อตรรกะการแกะบรรทัดคำสั่งเริ่มซับซ้อน ให้สกัดมันออกจากฟังก์ชัน main ไปยังฟังก์ชันหรือประเภทข้อมูลอื่น

ความรับผิดชอบที่ยังคงอยู่ในฟังก์ชัน main หลังจากกระบวนการนี้ควรจำกัดไว้เพียงต่อไปนี้:

  • การเรียกใช้ตรรกะการแกะบรรทัดคำสั่งด้วยค่าอาร์กิวเมนต์
  • การตั้งค่าการกำหนดค่าอื่นๆ
  • การเรียกใช้ฟังก์ชัน run ใน lib.rs
  • การจัดการกับข้อผิดพลาดหาก run คืนค่าข้อผิดพลาดกลับมา

รูปแบบนี้เป็นเรื่องของการแยกส่วนความรับผิดชอบ (Separation of Concerns): main.rs จัดการการรันโปรแกรม และ lib.rs จัดการตรรกะทั้งหมดของงานที่มีอยู่ เนื่องจากคุณไม่สามารถทดสอบฟังก์ชัน main ได้โดยตรง โครงสร้างนี้จึงช่วยให้คุณทดสอบตรรกะทั้งหมดของโปรแกรมได้โดยการย้ายมันออกจากฟังก์ชัน main โค้ดที่เหลืออยู่ในฟังก์ชัน main จะมีขนาดเล็กพอที่จะตรวจสอบความถูกต้องได้เพียงแค่การอ่าน ลองทำโปรแกรมของเราใหม่โดยทำตามกระบวนการนี้กัน

การสกัดตัวแกะอาร์กิวเมนต์ (Extracting the Argument Parser)

เราจะสกัดฟังก์ชันการทำงานสำหรับการแกะอาร์กิวเมนต์ออกเป็นฟังก์ชันที่ main จะเรียกใช้ Listing 12-5 แสดงจุดเริ่มต้นใหม่ของฟังก์ชัน main ที่เรียกใช้ฟังก์ชันใหม่ชื่อ parse_config ซึ่งเราจะนิยามใน src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

เรายังคงรวบรวมอาร์กิวเมนต์บรรทัดคำสั่งลงในเวกเตอร์เหมือนเดิม แต่แทนที่จะกำหนดค่าอาร์กิวเมนต์ที่ดัชนี 1 ให้กับตัวแปร query และค่าอาร์กิวเมนต์ที่ดัชนี 2 ให้กับตัวแปร file_path ภายในฟังก์ชัน main เราจะส่งเวกเตอร์ทั้งหมดไปยังฟังก์ชัน parse_config จากนั้นฟังก์ชัน parse_config จะถือตรรกะที่กำหนดว่าอาร์กิวเมนต์ใดไปที่ตัวแปรใด แล้วส่งค่านั้นกลับไปยัง main เรายังคงสร้างตัวแปร query และ file_path ใน main แต่ main จะไม่มีหน้าที่ในการกำหนดว่าอาร์กิวเมนต์บรรทัดคำสั่งและตัวแปรสอดคล้องกันอย่างไรอีกต่อไป

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

การจัดกลุ่มค่าการกำหนดค่า (Grouping Configuration Values)

เราสามารถทำขั้นตอนเล็กๆ อีกขั้นหนึ่งเพื่อปรับปรุงฟังก์ชัน parse_config ให้ดียิ่งขึ้น ในขณะนี้ เรากำลังคืนค่าทูเพิล (tuple) แต่จากนั้นเราก็กระจายทูเพิลนั้นออกเป็นส่วนๆ อีกทันที นี่เป็นสัญญาณว่าบางทีเราอาจยังไม่มีแนวนามธรรม (abstraction) ที่เหมาะสม

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

Listing 12-6 แสดงการปรับปรุงฟังก์ชัน parse_config

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

เราได้เพิ่มสตรักต์ชื่อ Config ที่นิยามให้มีฟิลด์ชื่อ query และ file_path ซิกเนเจอร์ของ parse_config ตอนนี้ระบุว่ามันคืนค่าเป็น Config ในเนื้อหาของ parse_config จากเดิมที่เราเคยคืนค่า string slice ที่อ้างอิงถึงค่า String ใน args ตอนนี้เราได้นิยาม Config ให้บรรจุค่า String ที่เป็นเจ้าของเอง ตัวแปร args ใน main เป็นเจ้าของค่าอาร์กิวเมนต์และยอมให้ฟังก์ชัน parse_config ยืมพวกมันไปเท่านั้น ซึ่งหมายความว่าเราจะละเมิดกฎการยืมของ Rust หาก Config พยายามที่จะรับเอาความเป็นเจ้าของของค่าใน args

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

ข้อแลกเปลี่ยนของการใช้ clone

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

เราได้อัปเดต main เพื่อให้วางอินสแตนซ์ของ Config ที่คืนค่าโดย parse_config ลงในตัวแปรชื่อ config และเราได้อัปเดตโค้ดที่เคยใช้ตัวแปร query และ file_path แยกกัน เพื่อให้ตอนนี้ใช้ฟิลด์บนสตรักต์ Config แทน

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

การสร้าง Constructor สำหรับ Config

จนถึงตอนนี้ เราได้สกัดตรรกะที่รับผิดชอบในการแกะอาร์กิวเมนต์บรรทัดคำสั่งออกจาก main และวางไว้ในฟังก์ชัน parse_config การทำเช่นนั้นช่วยให้เราเห็นว่าค่า query และ file_path เกี่ยวข้องกัน และความสัมพันธ์นั้นควรจะถูกสื่อสารในโค้ดของเรา จากนั้นเราจึงเพิ่มสตรักต์ Config เพื่อตั้งชื่อวัตถุประสงค์ที่เกี่ยวข้องกันของ query และ file_path และเพื่อให้สามารถคืนค่าชื่อของค่านั้นเป็นชื่อฟิลด์ของสตรักต์จากฟังก์ชัน parse_config

ดังนั้น ตอนนี้เมื่อวัตถุประสงค์ของฟังก์ชัน parse_config คือการสร้างอินสแตนซ์ Config เราสามารถเปลี่ยน parse_config จากฟังก์ชันธรรมดาให้กลายเป็นฟังก์ชันชื่อ new ที่เกี่ยวข้องกับสตรักต์ Config ได้ การทำการเปลี่ยนแปลงนี้จะทำให้โค้ดมีความเป็นธรรมชาติ (idiomatic) ยิ่งขึ้น เราสามารถสร้างอินสแตนซ์ของประเภทข้อมูลในไลบรารีมาตรฐาน เช่น String ได้โดยการเรียก String::new ในทำนองเดียวกัน โดยการเปลี่ยน parse_config ให้เป็นฟังก์ชัน new ที่เกี่ยวข้องกับ Config เราจะสามารถสร้างอินสแตนซ์ของ Config ได้โดยการเรียก Config::new Listing 12-7 แสดงการเปลี่ยนแปลงที่เราต้องทำ

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

เราได้อัปเดต main ที่เราเคยเรียก parse_config ให้เรียก Config::new แทน เราได้เปลี่ยนชื่อของ parse_config เป็น new และย้ายมันเข้าไปในบล็อก impl ซึ่งเชื่อมโยงฟังก์ชัน new กับ Config ลองคอมไพล์โค้ดนี้อีกครั้งเพื่อให้แน่ใจว่ามันทำงานได้

การแก้ไขการจัดการข้อผิดพลาด (Fixing the Error Handling)

ตอนนี้เราจะทำงานเกี่ยวกับการแก้ไขการจัดการข้อผิดพลาดของเรา ย้อนกลับไปว่าการพยายามเข้าถึงค่าในเวกเตอร์ args ที่ดัชนี 1 หรือดัชนี 2 จะทำให้โปรแกรมเกิด panic หากเวกเตอร์มีสมาชิกรวมน้อยกว่าสามรายการ ลองรันโปรแกรมโดยไม่ใส่อาร์กิวเมนต์ จะได้ผลลัพธ์ดังนี้:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' (6023615) panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

บรรทัด index out of bounds: the len is 1 but the index is 1 เป็นข้อความข้อผิดพลาดที่ระบุไว้สำหรับโปรแกรมเมอร์ มันไม่ได้ช่วยให้ผู้ใช้ปลายทางเข้าใจสิ่งที่พวกเขาควรทำแทน ลองแก้ไขสิ่งนี้กัน

การปรับปรุงข้อความข้อผิดพลาด (Improving the Error Message)

ใน Listing 12-8 เราได้เพิ่มการตรวจสอบในฟังก์ชัน new เพื่อตรวจสอบว่า slice มีความยาวเพียงพอหรือไม่ก่อนที่จะเข้าถึงดัชนี 1 และดัชนี 2 หาก slice มีความยาวไม่เพียงพอ โปรแกรมจะ เกิด panic และแสดงข้อความข้อผิดพลาดที่ดีกว่า

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

โค้ดนี้คล้ายกับ ฟังก์ชัน Guess::new ที่เราเขียนใน Listing 9-13 ที่เราเรียก panic! เมื่ออาร์กิวเมนต์ value อยู่นอกช่วงของค่าที่ถูกต้อง แทนที่จะตรวจสอบช่วงของค่าในที่นี้ เรากำลังตรวจสอบว่าความยาวของ args มีอย่างน้อย 3 หรือไม่ และส่วนที่เหลือของฟังก์ชันสามารถทำงานภายใต้ข้อสมมติว่าเงื่อนไขนี้ได้รับการตอบสนองแล้ว หาก args มีน้อยกว่าสามรายการ เงื่อนไขนี้จะเป็น true และเราจะเรียกมาโคร panic! เพื่อยุติโปรแกรมทันที

ด้วยโค้ดเพิ่มเติมไม่กี่บรรทัดใน new ให้เรารันโปรแกรมโดยไม่ใส่อาร์กิวเมนต์อีกครั้งเพื่อดูว่าข้อผิดพลาดตอนนี้มีหน้าตาอย่างไร:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' (6023776) panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

ผลลัพธ์นี้ดีขึ้น: ตอนนี้เรามีข้อความข้อผิดพลาดที่เหมาะสมแล้ว อย่างไรก็ตาม เรายังมีข้อมูลที่ไม่จำเป็นที่เราไม่อยากให้แก่ผู้ใช้ บางทีเทคนิคที่เราใช้ใน Listing 9-13 อาจไม่ใช่เทคนิคที่ดีที่สุดที่จะใช้ในที่นี้: การเรียกใช้ panic! เหมาะสมกว่าสำหรับปัญหาการโปรแกรม มากกว่าปัญหาการใช้งาน ดังที่ได้หารือกันในบทที่ 9 แทนที่จะทำเช่นนั้น เราจะใช้เทคนิคอื่นที่คุณได้เรียนรู้ในบทที่ 9 นั่นคือ—การคืนค่า Result ที่ระบุว่าสำเร็จหรือเกิดข้อผิดพลาด

การคืนค่า Result แทนการเรียกใช้ panic!

เราสามารถคืนค่า Result ซึ่งจะบรรจุอินสแตนซ์ Config ในกรณีที่สำเร็จ และจะอธิบายปัญหาในกรณีที่เกิดข้อผิดพลาด นอกจากนี้ เรายังจะเปลี่ยนชื่อฟังก์ชันจาก new เป็น build เนื่องจากโปรแกรมเมอร์หลายคนคาดหวังว่าฟังก์ชัน new จะไม่มีวันล้มเหลว เมื่อ Config::build สื่อสารกับ main เราสามารถใช้ประเภทข้อมูล Result เพื่อส่งสัญญาณว่ามีปัญหาเกิดขึ้น จากนั้นเราจะสามารถเปลี่ยน main เพื่อแปลงตัวแปร Err ให้เป็นข้อผิดพลาดที่เหมาะกับการใช้งานจริงมากขึ้นสำหรับผู้ใช้ โดยไม่มีข้อความแวดล้อมเกี่ยวกับ thread 'main' และ RUST_BACKTRACE ที่เกิดจากการเรียกใช้ panic!

Listing 12-9 แสดงการเปลี่ยนแปลงที่เราต้องทำต่อประเภทคืนค่าของฟังก์ชันที่เราเรียกว่า Config::build และเนื้อหาของฟังก์ชันที่จำเป็นในการคืนค่า Result สังเกตว่าสิ่งนี้จะไม่คอมไพล์จนกว่าเราจะอัปเดต main ด้วย ซึ่งเราจะทำใน listing ถัดไป

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

ฟังก์ชัน build ของเรา คืนค่า Result พร้อมอินสแตนซ์ Config ในกรณีที่สำเร็จ และคืนค่า string literal ในกรณีที่เกิดข้อผิดพลาด ค่าข้อผิดพลาดของเราจะเป็น string literal ที่มี lifetime เป็น 'static เสมอ

เราได้ทำการเปลี่ยนแปลงสองอย่างในเนื้อหาของฟังก์ชัน: แทนที่จะเรียกใช้ panic! เมื่อผู้ใช้ไม่ได้ส่งอาร์กิวเมนต์ที่เพียงพอ ตอนนี้เราคืนค่า Err และเราได้ห่อค่าคืนกลับ Config ไว้ใน Ok การเปลี่ยนแปลงเหล่านี้ทำให้ฟังก์ชันสอดคล้องกับประเภทคืนค่าใหม่

การคืนค่า Err จาก Config::build ช่วยให้ฟังก์ชัน main สามารถจัดการกับค่า Result ที่คืนกลับมาจากฟังก์ชัน build และออกจากโปรเซสได้อย่างเป็นระเบียบมากขึ้นในกรณีเกิดข้อผิดพลาด

การเรียกใช้ Config::build และการจัดการข้อผิดพลาด

เพื่อจัดการกับกรณีข้อผิดพลาดและพิมพ์ข้อความที่เป็นมิตรกับผู้ใช้ เราจำเป็นต้องอัปเดต main เพื่อจัดการกับ Result ที่ถูกคืนกลับมาโดย Config::build ดังแสดงใน Listing 12-10 เรายังจะดึงความรับผิดชอบในการออกจากเครื่องมือบรรทัดคำสั่งด้วยรหัสข้อผิดพลาดที่ไม่ใช่ศูนย์ออกจาก panic! แล้วมาเขียนจัดการเอง รหัสสถานะการออกที่ไม่ใช่ศูนย์เป็นข้อตกลงในการส่งสัญญาณไปยังโปรเซสที่เรียกใช้โปรแกรมของเราว่าโปรแกรมจบการทำงานด้วยสถานะข้อผิดพลาด

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

ใน listing นี้ เราได้ใช้เมธอดที่เรายังไม่ได้ครอบคลุมในรายละเอียดมาก่อน: unwrap_or_else ซึ่งถูกนิยามไว้บน Result<T, E> โดยไลบรารีมาตรฐาน การใช้ unwrap_or_else ช่วยให้เรากำหนดการจัดการข้อผิดพลาดแบบกำหนดเองที่ไม่ใช่ panic! ได้ หาก Result เป็นค่า Ok พฤติกรรมของเมธอดนี้จะคล้ายกับ unwrap: มันจะคืนค่าภายในที่ Ok ห่อหุ้มไว้ อย่างไรก็ตาม หากค่านั้นเป็น Err เมธอดนี้จะเรียกใช้โค้ดใน closure ซึ่งเป็นฟังก์ชันไม่มีชื่อที่เรานิยามและส่งเป็นอาร์กิวเมนต์ไปยัง unwrap_or_else เราจะครอบคลุมเรื่อง closure โดยละเอียดใน บทที่ 13 สำหรับตอนนี้ คุณแค่ต้องรู้ว่า unwrap_or_else จะส่งค่าภายในของ Err ซึ่งในกรณีนี้คือ static string "not enough arguments" ที่เราเพิ่มใน Listing 12-9 ไปยัง closure ในอาร์กิวเมนต์ err ที่ปรากฏระหว่างเครื่องหมายแนวตั้ง (|err|) โค้ดใน closure สามารถใช้ค่า err นั้นได้เมื่อรัน

เราได้เพิ่มบรรทัด use ใหม่เพื่อนำ process จากไลบรารีมาตรฐานเข้ามาสู่ขอบเขต โค้ดใน closure ที่จะรันในกรณีเกิดข้อผิดพลาดมีเพียงสองบรรทัด: เราพิมพ์ค่า err ออกมาแล้วเรียกใช้ process::exit ฟังก์ชัน process::exit จะหยุดโปรแกรมทันทีและคืนค่าตัวเลขที่ถูกส่งเป็นรหัสสถานะการออก สิ่งนี้คล้ายกับการจัดการตาม panic! ที่เราใช้ใน Listing 12-8 แต่เราไม่ต้องได้รับผลลัพธ์ส่วนเกินทั้งหมด ลองรันกันเลย:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

เยี่ยมเลย! ผลลัพธ์นี้เป็นมิตรกับผู้ใช้ของเรามากขึ้นมาก

การสกัดตรรกะออกจาก main

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

Listing 12-11 แสดงการปรับปรุงย่อยในการสกัดฟังก์ชัน run

use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

ฟังก์ชัน run ตอนนี้บรรจุตรรกะที่เหลือทั้งหมดจาก main เริ่มตั้งแต่การอ่านไฟล์ ฟังก์ชัน run รับอินสแตนซ์ Config เป็นอาร์กิวเมนต์

การคืนค่าข้อผิดพลาดจาก run

เมื่อตรรกะโปรแกรมที่เหลือถูกแยกออกเป็นฟังก์ชัน run แล้ว เราสามารถปรับปรุงการจัดการข้อผิดพลาดได้ เช่นเดียวกับที่เราทำกับ Config::build ใน Listing 12-9 แทนที่จะปล่อยให้โปรแกรม เกิด panic โดยการเรียกใช้ expect ฟังก์ชัน run จะคืนค่า Result<T, E> เมื่อมีสิ่งผิดปกติเกิดขึ้น สิ่งนี้จะช่วยให้เราสามารถรวมตรรกะเกี่ยวกับการจัดการข้อผิดพลาดเข้าสู่ main ในรูปแบบที่เป็นมิตรกับผู้ใช้มากยิ่งขึ้น Listing 12-12 แสดงการเปลี่ยนแปลงที่เราต้องทำต่อซิกเนเจอร์และเนื้อหาของ run

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

เราได้ทำการเปลี่ยนแปลงที่สำคัญสามประการในที่นี้ ขั้นแรก เราเปลี่ยนประเภทคืนค่าของฟังก์ชัน run เป็น Result<(), Box<dyn Error>> ฟังก์ชันนี้ก่อนหน้านี้คืนค่า unit type () และเรายังคงรักษาค่านั้นไว้เป็นค่าที่คืนกลับในกรณี Ok

สำหรับประเภทข้อผิดพลาด เราใช้ trait object Box<dyn Error> (และเรานำ std::error::Error เข้าสู่ขอบเขตด้วยคำสั่ง use ที่ด้านบน) เราจะครอบคลุม trait objects ใน บทที่ 18 สำหรับตอนนี้ แค่รู้ว่า Box<dyn Error> หมายความว่าฟังก์ชันจะคืนค่าประเภทข้อมูลที่ปรับใช้ trait Error แต่เราไม่ต้องระบุประเภทข้อมูลเฉพาะใดประเภทหนึ่งที่ค่าคืนกลับจะเป็น สิ่งนี้ทำให้เรามีความยืดหยุ่นในการคืนค่าข้อผิดพลาดที่อาจเป็นประเภทข้อมูลที่แตกต่างกันในกรณีข้อผิดพลาดที่แตกต่างกัน คำว่า dyn ย่อมาจาก dynamic

ประการที่สอง เราได้ลบการเรียกใช้ expect ออกแล้วแทนที่ด้วยตัวดำเนินการ ? ดังที่เราได้พูดถึงใน บทที่ 9 แทนที่จะ panic! เมื่อเกิดข้อผิดพลาด ตัวดำเนินการ ? จะคืนค่าข้อผิดพลาดจากฟังก์ชันปัจจุบันเพื่อให้ผู้เรียกใช้งานจัดการ

ประการที่สาม ฟังก์ชัน run ตอนนี้คืนค่า Ok ในกรณีที่สำเร็จ เราได้ประกาศประเภทความสำเร็จของฟังก์ชัน run เป็น () ในซิกเนเจอร์ ซึ่งหมายความว่าเราจำเป็นต้องห่อค่า unit type ไว้ในค่า Ok ไวยากรณ์ Ok(()) นี้อาจดูแปลกนิดหน่อยในตอนแรก แต่การใช้ () แบบนี้เป็นวิธีที่เป็นธรรมชาติในการระบุว่าเราเรียกใช้ run เพื่อผลข้างเคียง (side effects) เท่านั้น มันไม่ได้คืนค่าที่เราจำเป็นต้องใช้

เมื่อคุณรันโค้ดนี้ มันจะคอมไพล์ได้แต่จะแสดงคำเตือน:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust บอกเราว่าโค้ดของเราเพิกเฉยต่อค่า Result และค่า Result อาจระบุว่าเกิดข้อผิดพลาดขึ้น แต่เราไม่ได้ตรวจสอบว่าเกิดข้อผิดพลาดหรือไม่ และคอมไพเลอร์เตือนเราว่าเราคงตั้งใจจะมีโค้ดจัดการข้อผิดพลาดอยู่ที่นี่! มาแก้ไขปัญหานั้นกัน

การจัดการข้อผิดพลาดที่คืนมาจาก run ใน main

เราจะตรวจสอบข้อผิดพลาดและจัดการโดยใช้เทคนิคที่คล้ายกับเทคนิคที่เราใช้กับ Config::build ใน Listing 12-10 แต่มีความแตกต่างกันเล็กน้อย:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

เราใช้ if let แทน unwrap_or_else เพื่อตรวจสอบว่า run คืนค่า Err หรือไม่ และเรียกใช้ process::exit(1) หากเป็นเช่นนั้น ฟังก์ชัน run ไม่ได้คืนค่าที่เราต้องการ unwrap ในลักษณะเดียวกับที่ Config::build คืนค่าอินสแตนซ์ Config เนื่องจาก run คืนค่า () ในกรณีสำเร็จ เราจึงใส่ใจเฉพาะการตรวจจับข้อผิดพลาดเท่านั้น ดังนั้นเราจึงไม่จำเป็นต้องใช้ unwrap_or_else เพื่อคืนค่าที่แกะออกมา ซึ่งจะเป็นเพียง () เท่านั้น

เนื้อหาของ if let และฟังก์ชัน unwrap_or_else จะเหมือนกันในทั้งสองกรณี: เราพิมพ์ข้อผิดพลาดออกมาและออกจากโปรแกรม

การแยกโค้ดออกเป็น Library Crate

โปรเจกต์ minigrep ของเราดูดีขึ้นมากแล้วจนถึงตอนนี้! คราวนี้เราจะแยกไฟล์ src/main.rs และวางโค้ดบางส่วนไว้ในไฟล์ src/lib.rs ด้วยวิธีนี้ เราจะสามารถทดสอบโค้ดและมีไฟล์ src/main.rs ที่มีภาระรับผิดชอบน้อยลงได้

มานิยามโค้ดที่รับผิดชอบในการค้นหาข้อความใน src/lib.rs แทนที่จะเป็น src/main.rs ซึ่งจะช่วยให้เรา (หรือใครก็ตามที่ใช้ไลบรารี minigrep ของเรา) สาสารถเรียกใช้ฟังก์ชันการค้นหาจากบริบทอื่นๆ ได้มากกว่าเพียงแค่ไบนารี minigrep ของเรา

ขั้นแรก มานิยามซิกเนเจอร์ของฟังก์ชัน search ใน src/lib.rs ดังแสดงใน Listing 12-13 โดยมีเนื้อหาที่เรียกใช้มาโคร unimplemented! เราจะอธิบายซิกเนเจอร์โดยละเอียดมากขึ้นเมื่อเราเขียนส่วนการปรับใช้จริง

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

เราใช้คำสั่ง pub ในคำนิยามฟังก์ชันเพื่อกำหนดให้ search เป็นส่วนหนึ่งของ public API ของ library crate ของเรา ตอนนี้เรามี library crate ที่เราสามารถใช้จาก binary crate ของเราและสามารถทดสอบได้แล้ว!

ตอนนี้เราจำเป็นต้องนำโค้ดที่นิยามใน src/lib.rs เข้าสู่ขอบเขตของ binary crate ใน src/main.rs แล้วเรียกใช้มัน ดังแสดงใน Listing 12-14

use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

// --snip--


struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

เราเพิ่มบรรทัด use minigrep::search เพื่อนำฟังก์ชัน search จาก library crate เข้าสู่ขอบเขตของ binary crate จากนั้น ในฟังก์ชัน run แทนที่จะพิมพ์เนื้อหาของไฟล์ออกมา เราจะเรียกใช้ฟังก์ชัน search แล้วส่งค่า config.query และ contents เป็นอาร์กิวเมนต์ จากนั้น run จะใช้ลูป for เพื่อพิมพ์แต่ละบรรทัดที่คืนกลับมาจาก search ที่ตรงกับคำค้นหา นี่เป็นเวลาที่ดีที่จะลบการเรียก println! ในฟังก์ชัน main ที่เคยแสดงคำค้นหาและพาธไฟล์ เพื่อให้โปรแกรมของเราพิมพ์เฉพาะผลลัพธ์การค้นหาเท่านั้น (หากไม่เกิดข้อผิดพลาด)

สังเกตว่าฟังก์ชัน search จะรวบรวมผลลัพธ์ทั้งหมดลงในเวกเตอร์ที่มันคืนกลับมาก่อนที่จะมีการพิมพ์ใดๆ เกิดขึ้น การปรับใช้นี้อาจช้าในการแสดงผลลัพธ์เมื่อค้นหาไฟล์ขนาดใหญ่ เนื่องจากผลลัพธ์จะไม่ถูกพิมพ์ออกมาทันทีที่พบ เราจะหารือถึงวิธีที่เป็นไปได้ในการแก้ไขปัญหานี้โดยใช้ iterators ในบทที่ 13

เฮ้อ! นั่นเป็นงานจำนวนมาก แต่เราได้เตรียมพร้อมสำหรับความสำเร็จในอนาคตเรียบร้อยแล้ว ตอนนี้การจัดการข้อผิดพลาดง่ายขึ้นมาก และเราทำให้โค้ดมีความเป็นมอดูลาร์มากขึ้น งานเกือบทั้งหมดของเราต่อจากนี้จะทำใน src/lib.rs

ลองใช้ประโยชน์จากความเป็นมอดูลาร์ที่พบใหม่นี้โดยการทำสิ่งที่จะทำได้ยากกับโค้ดเก่า แต่ทำได้ง่ายด้วยโค้ดใหม่: เราจะเขียนการทดสอบกัน!

Adding Functionality with Test Driven Development

การเพิ่มฟังก์ชันการทำงานด้วย Test-Driven Development (Adding Functionality with Test-Driven Development)

เมื่อเรามีตรรกะการค้นหาใน src/lib.rs แยกต่างหากจากฟังก์ชัน main แล้ว มันจะง่ายขึ้นมากในการเขียนการทดสอบสำหรับฟังก์ชันหลักของโค้ดเรา เราสามารถเรียกใช้ฟังก์ชันได้โดยตรงด้วยอาร์กิวเมนต์ที่หลากหลายและตรวจสอบค่าคืนกลับได้ โดยไม่ต้องเรียกใช้ไฟล์ไบนารีจากบรรทัดคำสั่ง

ในหัวข้อนี้ เราจะเพิ่มตรรกะการค้นหาให้กับโปรแกรม minigrep โดยใช้กระบวนการ Test-Driven Development (TDD) ซึ่งมีขั้นตอนดังต่อไปนี้:

  1. เขียนการทดสอบที่ล้มเหลวและรันมัน เพื่อให้แน่ใจว่ามันล้มเหลวด้วยเหตุผลที่คุณคาดไว้
  2. เขียนหรือแก้ไขโค้ดเพียงเท่าที่จำเป็นเพื่อทำให้การทดสอบใหม่ผ่าน
  3. รีแฟกเตอร์โค้ดที่คุณเพิ่งเพิ่มหรือเปลี่ยนแปลง และตรวจสอบให้แน่ใจว่าการทดสอบยังคงผ่านต่อไป
  4. ทำซ้ำตั้งแต่ขั้นตอนที่ 1!

แม้ว่าจะเป็นเพียงหนึ่งในหลายๆ วิธีในการเขียนซอฟต์แวร์ แต่ TDD สามารถช่วยขับเคลื่อนการออกแบบโค้ดได้ การเขียนการทดสอบก่อนที่คุณจะเขียนโค้ดที่ทำให้การทดสอบผ่าน ช่วยรักษาความครอบคลุมของการทดสอบ (test coverage) ให้อยู่ในระดับสูงตลอดกระบวนการ

เราจะใช้ TDD ในการพัฒนาส่วนการปรับใช้ฟังก์ชันการทำงาน ที่จะทำการค้นหาสตริงข้อความค้นหาในเนื้อหาของไฟล์จริง และสร้างรายการของบรรทัดที่ตรงกับคำค้นหา เราจะเพิ่มฟังก์ชันการทำงานนี้ไว้ในฟังก์ชันที่ชื่อว่า search

การเขียนการทดสอบที่ล้มเหลว (Writing a Failing Test)

ใน src/lib.rs เราจะเพิ่มโมดูล tests พร้อมด้วยฟังก์ชันการทดสอบ เช่นเดียวกับที่เราทำใน บทที่ 11 ฟังก์ชันการทดสอบระบุพฤติกรรมที่เราต้องการให้ฟังก์ชัน search มี นั่นคือ: มันจะรับข้อความค้นหาและข้อความที่จะค้นหา แล้วคืนค่าเฉพาะบรรทัดจากข้อความที่มีข้อความค้นหาอยู่เท่านั้น Listing 12-15 แสดงการทดสอบนี้

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

การทดสอบนี้ค้นหาสตริง "duct" ข้อความที่เรากำลังค้นหามีสามบรรทัด โดยมีเพียงบรรทัดเดียวเท่านั้นที่มีคำว่า "duct" (สังเกตว่าเครื่องหมายแบ็กสแลชหลังเครื่องหมายอัญประกาศเปิด บอก Rust ไม่ให้ใส่ตัวอักษรขึ้นบรรทัดใหม่ที่จุดเริ่มต้นของเนื้อหาสตริง literal นี้) เรารายงานยืนยันว่าค่าที่คืนจากฟังก์ชัน search บรรจุเฉพาะบรรทัดที่เราคาดหวังไว้

หากเรารันการทดสอบนี้ มันจะล้มเหลวในปัจจุบัน เนื่องจากมาโคร unimplemented! จะเกิด panic พร้อมข้อความ “not implemented” เพื่อให้เป็นไปตามหลักการของ TDD เราจะทำขั้นตอนเล็กๆ โดยเพิ่มโค้ดเพียงเท่าที่จำเป็นเพื่อให้การทดสอบไม่เกิด panic เมื่อเรียกใช้ฟังก์ชัน โดยกำหนดให้ฟังก์ชัน search คืนค่าเป็นเวกเตอร์ว่างเสมอ ดังแสดงใน Listing 12-16 จากนั้น การทดสอบควรจะคอมไพล์ได้และล้มเหลว เนื่องจากเวกเตอร์ว่างไม่ตรงกับเวกเตอร์ที่มีบรรทัด "safe, fast, productive."

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

ตอนนี้ เรามาพูดถึงสาเหตุที่เราจำเป็นต้องกำหนด lifetime 'a ที่ชัดเจนในซิกเนเจอร์ของ search และใช้ lifetime นั้นกับอาร์กิวเมนต์ contents และค่าคืนกลับ ย้อนกลับไปใน บทที่ 10 ที่พารามิเตอร์ lifetime ระบุว่า lifetime ของอาร์กิวเมนต์ตัวใดเชื่อมโยงกับ lifetime ของค่าคืนกลับ ในกรณีนี้ เราระบุว่าเวกเตอร์ที่ คืนกลับมาควรบรรจุ string slice ที่อ้างอิงถึง slice ของอาร์กิวเมนต์ contents (แทนที่จะเป็นอาร์กิวเมนต์ query)

กล่าวอีกนัยหนึ่ง เราบอก Rust ว่าข้อมูลที่คืนกลับโดยฟังก์ชัน search จะมีอายุอยู่ตราบเท่าที่ข้อมูลที่ส่งเข้าไปยังฟังก์ชัน search ในอาร์กิวเมนต์ contents มีอายุอยู่ นี่เป็นสิ่งสำคัญ! ข้อมูลที่ถูกอ้างอิง โดย slice จำเป็นต้องมีความถูกต้องเพื่อให้การอ้างอิงถูกต้อง หากคอมไพเลอร์ทายว่าเรากำลังทำ string slice ของ query แทนที่จะเป็น contents มันจะทำการตรวจสอบความปลอดภัยผิดพลาด

หากเราลืมสัญลักษณ์ lifetime และพยายามคอมไพล์ฟังก์ชันนี้ เราจะได้ข้อผิดพลาดนี้:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

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

ภาษาโปรแกรมอื่นๆ ไม่ได้กำหนดให้คุณต้องเชื่อมโยงอาร์กิวเมนต์กับค่าคืนกลับในซิกเนเจอร์ แต่วิธีปฏิบัตินี้จะง่ายขึ้นตามกาลเวลา คุณอาจต้องการเปรียบเทียบตัวอย่างนี้กับตัวอย่างในหัวข้อ “การตรวจสอบการอ้างอิงด้วย Lifetimes” ในบทที่ 10

การเขียนโค้ดเพื่อให้การทดสอบผ่าน (Writing Code to Pass the Test)

ในปัจจุบัน การทดสอบของเราล้มเหลวเนื่องจากเราคืนค่าเวกเตอร์ว่างเสมอ เพื่อแก้ไขปัญหานั้นและปรับใช้ search โปรแกรมของเราจำเป็นต้องทำตามขั้นตอนเหล่านี้:

  1. วนลูปผ่านแต่ละบรรทัดของเนื้อหา (contents)
  2. ตรวจสอบว่าบรรทัดนั้นมีสตริงข้อความค้นหาของเราหรือไม่
  3. หากมี ให้เพิ่มบรรทัดนั้นลงในรายการของค่าที่เราจะคืนกลับ
  4. หากไม่มี ให้ไม่ต้องทำอะไร
  5. คืนค่ารายการผลลัพธ์ที่ตรงกัน

มาทำตามแต่ละขั้นตอนกัน โดยเริ่มจากการวนลูปผ่านบรรทัดต่างๆ

การวนลูปผ่านบรรทัดต่างๆ ด้วยเมธอด lines (Iterating Through Lines with the lines Method)

Rust มีเมธอดที่มีประโยชน์ในการจัดการการวนลูปของสตริงแบบบรรทัดต่อบรรทัด ชื่อว่า lines ซึ่งทำงานดังแสดงใน Listing 12-17 สังเกตว่าสิ่งนี้ยังคอมไพล์ไม่ได้ในตอนนี้

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

เมธอด lines คืนค่าเป็น iterator เราจะพูดถึง iterator โดยละเอียดใน บทที่ 13 แต่ย้อนกลับไปว่าคุณได้เห็นวิธีการใช้ iterator แบบนี้แล้วใน Listing 3-5 ที่เราใช้ลูป for ร่วมกับ iterator เพื่อรันโค้ดกับสมาชิกแต่ละตัวใน collection

การค้นหาแต่ละบรรทัดด้วยข้อความค้นหา (Searching Each Line for the Query)

ถัดไป เราจะตรวจสอบว่าบรรทัดปัจจุบันมีสตริงข้อความค้นหาของเราหรือไม่ โชคดีที่สตริงมีเมธอดที่มีประโยชน์ชื่อ contains ที่ทำสิ่งนี้ให้เรา! เพิ่มการเรียกเมธอด contains ในฟังก์ชัน search ดังแสดงใน Listing 12-18 สังเกตว่าสิ่งนี้ยังคงคอมไพล์ไม่ได้ในตอนนี้

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

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

การจัดเก็บบรรทัดที่ตรงกัน (Storing Matching Lines)

เพื่อให้ฟังก์ชันนี้เสร็จสมบูรณ์ เราต้องการวิธีจัดเก็บบรรทัดที่ตรงกันที่เราต้องการคืนกลับมา สำหรับสิ่งนั้น เราสามารถสร้างเวกเตอร์แบบเปลี่ยนแปลงค่าได้ (mutable vector) ก่อนลูป for และเรียกใช้เมธอด push เพื่อจัดเก็บ line ลงในเวกเตอร์ หลังลูป for เราจะคืนค่าเวกเตอร์นั้น ดังแสดงใน Listing 12-19

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

ตอนนี้ฟังก์ชัน search ควรจะคืนค่าเฉพาะบรรทัดที่มี query อยู่ และการทดสอบของเราควรจะผ่าน ลองรันการทดสอบกันเลย:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

การทดสอบของเราผ่านแล้ว ดังนั้นเรารู้ว่ามันทำงานได้!

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

ตอนนี้โปรแกรมทั้งหมดควรร่วมกันทำงานได้แล้ว! มาลองทดสอบกัน ขั้นแรกด้วยคำที่ควรคืนค่าตรงหนึ่งบรรทัดจากบทกวีของ Emily Dickinson นั่นคือคำว่า: frog

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

เยี่ยมเลย! คราวนี้มาลองคำที่ตรงกับหลายบรรทัด ดูบ้าง เช่น body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

และสุดท้าย มาตรวจสอบให้แน่ใจว่าเราไม่ได้บรรทัดใดๆ เลยเมื่อเราค้นหาคำที่ไม่มีอยู่เลยในบทกวี เช่น monomorphization:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

ยอดเยี่ยม! เราได้สร้างเครื่องมือคลาสสิกเวอร์ชันมินิของเราเองแล้ว และได้เรียนรู้หลายอย่างเกี่ยวกับวิธีจัดโครงสร้างแอปพลิเคชัน เรายังได้เรียนรู้นิดหน่อยเกี่ยวกับ input/output ของไฟล์, lifetimes, การทดสอบ และการแกะอาร์กิวเมนต์บรรทัดคำสั่ง

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

Working with Environment Variables

การทำงานกับตัวแปรสภาพแวดล้อม (Working with Environment Variables)

เราจะปรับปรุงไฟล์ไบนารี minigrep โดยการเพิ่มฟีเจอร์พิเศษ: ตัวเลือกสำหรับการค้นหาแบบไม่สนอักษรตัวพิมพ์ใหญ่-เล็ก (case-insensitive searching) ที่ผู้ใช้สามารถเปิดใช้งานผ่านตัวแปรสภาพแวดล้อม (environment variable) ได้ เราอาจจะทำให้ฟีเจอร์นี้เป็นตัวเลือกบรรทัดคำสั่งและต้องป้อนทุกครั้งที่ผู้ใช้ต้องการให้มีผล แต่นำมาทำให้เป็นตัวแปรสภาพแวดล้อมแทน ทำให้ผู้ใช้ของเราสามารถตั้งค่าตัวแปรสภาพแวดล้อมเพียงครั้งเดียว และให้การค้นหาทั้งหมดของพวกเขากลายเป็นการค้นหาที่ไม่สนตัวพิมพ์ใหญ่-เล็กตลอดทั้งเซสชันเทอร์มินัลนั้นได้

ขั้นแรกเราเพิ่มฟังก์ชัน search_case_insensitive ใหม่ไปยังไลบรารี minigrep ซึ่งจะถูกเรียกใช้เมื่อตัวแปรสภาพแวดล้อมมีค่า เราจะยังคงปฏิบัติตามกระบวนการ TDD ต่อไป ดังนั้นขั้นตอนแรกคือการเขียนการทดสอบที่ล้มเหลวอีกครั้ง เราจะเพิ่มการทดสอบใหม่สำหรับฟังก์ชัน search_case_insensitive ใหม่ และเปลี่ยนชื่อการทดสอบเก่าจาก one_result เป็น case_sensitive เพื่อชี้แจงความแตกต่างระหว่างสองการทดสอบ ดังแสดงใน Listing 12-20

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

สังเกตว่าเราได้แก้ไข contents ของการทดสอบเก่าด้วยเช่นกัน เราได้เพิ่มบรรทัดใหม่ด้วยข้อความ "Duct tape." ที่ใช้ตัว D พิมพ์ใหญ่ ซึ่งไม่ควรตรงกับคำค้นหา "duct" เมื่อเราค้นหาแบบสนตัวพิมพ์ใหญ่-เล็ก การเปลี่ยนการทดสอบเก่าด้วยวิธีนี้ช่วยรับประกันว่าเราจะไม่เผลอทำให้ฟังก์ชันการค้นหาแบบสนตัวพิมพ์ใหญ่-เล็กที่เราได้ปรับใช้ไปแล้วพัง การทดสอบนี้ควรจะผ่านในตอนนี้และควรจะผ่านต่อไปในขณะที่เราทำงานกับการค้นหาแบบไม่สนตัวพิมพ์ใหญ่-เล็ก

การทดสอบใหม่สำหรับการค้นหาแบบ ไม่สน ตัวพิมพ์ใหญ่-เล็ก ใช้คำค้นหาเป็น "rUsT" ในฟังก์ชัน search_case_insensitive ที่เรากำลังจะเพิ่ม คำค้นหา "rUsT" ควรจะตรงกับบรรทัดที่มีคำว่า "Rust:" ที่ใช้ตัว R พิมพ์ใหญ่ และตรงกับบรรทัด "Trust me." แม้ว่าทั้งคู่จะมีตัวพิมพ์ที่แตกต่างจากคำค้นหา นี่คือการทดสอบที่ล้มเหลวของเรา และมันจะล้มเหลวในการคอมไพล์เนื่องจากเรายังไม่ได้นิยามฟังก์ชัน search_case_insensitive คุณสามารถเพิ่มโครงสร้างเบื้องต้นที่คืนค่าเวกเตอร์ว่างเสมอได้ คล้ายกับวิธีที่เราทำกับฟังก์ชัน search ใน Listing 12-16 เพื่อดูการทดสอบคอมไพล์และล้มเหลว

การปรับใช้ฟังก์ชัน search_case_insensitive (Implementing the search_case_insensitive Function)

ฟังก์ชัน search_case_insensitive ดังแสดงใน Listing 12-21 จะเกือบเหมือนกับฟังก์ชัน search ทุกประการ ข้อแตกต่างเพียงอย่างเดียวคือเราจะแปลง query และแต่ละ line ให้เป็นตัวพิมพ์เล็กทั้งหมด เพื่อไม่ว่าค่าของอาร์กิวเมนต์อินพุตจะเป็นตัวพิมพ์แบบใด มันจะกลายเป็นตัวพิมพ์เดียวกันเมื่อเราตรวจสอบว่าบรรทัดนั้นมีคำค้นหาหรือไม่

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

ขั้นแรก เราแปลงสตริง query ให้เป็นตัวพิมพ์เล็กและเก็บไว้ในตัวแปรใหม่ที่มีชื่อเดียวกัน ซึ่งจะบัง (shadow) query เดิม การเรียกใช้ to_lowercase บนคำค้นหาถือเป็นสิ่งจำเป็น เพื่อไม่ว่าคำค้นหาของผู้ใช้จะเป็น "rust", "RUST", "Rust", หรือ "rUsT" เราจะปฏิบัติต่อคำค้นหาประหนึ่งว่าเป็น "rust" และไม่สนใจตัวพิมพ์ แม้ว่า to_lowercase จะจัดการกับ Unicode พื้นฐานได้ แต่ก็ไม่ได้แม่นยำ 100 เปอร์เซ็นต์ หากเรากำลังเขียนแอปพลิเคชันจริง เราอาจต้องการทำเพิ่มเติมตรงนี้อีกนิด แต่บทนี้เป็นเรื่องเกี่ยวกับตัวแปรสภาพแวดล้อม ไม่ใช่ Unicode ดังนั้นเราจะคงไว้เท่านี้

สังเกตว่าตอนนี้ query เป็น String แทนที่จะเป็น string slice เนื่องจากผลการเรียก to_lowercase จะสร้างข้อมูลใหม่ขึ้นมาแทนที่จะเป็นการอ้างอิงข้อมูลที่มีอยู่ สมมติว่าคำค้นหาคือ "rUsT" เป็นตัวอย่าง: string slice นั้นไม่ได้มีตัว u หรือ t พิมพ์เล็กไว้ให้เราใช้ ดังนั้นเราจึงต้องจัดสรรหน่วยความจำสำหรับ String ใหม่ที่มี "rust" เมื่อเราส่ง query เป็นอาร์กิวเมนต์ให้กับเมธอด contains ในตอนนี้ เราจำเป็นต้องเพิ่ม ampersand (&) เนื่องจากซิกเนเจอร์ของ contains ถูกนิยามให้รับ string slice

ถัดไป เราเพิ่มการเรียกใช้ to_lowercase กับแต่ละ line เพื่อแปลงตัวอักษรทั้งหมดให้เป็นตัวพิมพ์เล็ก ตอนนี้เราได้แปลง line และ query ให้เป็นตัวพิมพ์เล็กแล้ว เราจะพบรายการที่ตรงกันไม่ว่าคำค้นหาจะเป็นตัวพิมพ์แบบใดก็ตาม

มาดูกันว่าการปรับใช้นี้ผ่านการทดสอบหรือไม่:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

เยี่ยมเลย! การทดสอบผ่าน คราวนี้มาเรียกใช้ฟังก์ชัน search_case_insensitive ใหม่จากฟังก์ชัน run กัน ขั้นแรก เราจะเพิ่มตัวเลือกการกำหนดค่าให้กับสตรักต์ Config เพื่อสลับระหว่างการค้นหาแบบสนตัวพิมพ์ใหญ่-เล็กและการค้นหาแบบไม่สนตัวพิมพ์ใหญ่-เล็ก การเพิ่มฟิลด์นี้จะทำให้เกิดข้อผิดพลาดของคอมไพเลอร์ เนื่องจากเรายังไม่ได้กำหนดค่าเริ่มต้นให้กับฟิลด์นี้ในที่ใดเลย:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

เราเพิ่มฟิลด์ ignore_case ที่เก็บค่าบูลีน (Boolean) ถัดไป เราจำเป็นต้องให้ฟังก์ชัน run ตรวจสอบค่าของฟิลด์ ignore_case และใช้ค่านั้นในการตัดสินใจว่าจะเรียกใช้ฟังก์ชัน search หรือฟังก์ชัน search_case_insensitive ดังแสดงใน Listing 12-22 สิ่งนี้จะยังคงคอมไพล์ไม่ได้ในตอนนี้

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

สุดท้าย เราจำเป็นต้องตรวจสอบตัวแปรสภาพแวดล้อม ฟังก์ชันสำหรับการทำงานกับตัวแปรสภาพแวดล้อมอยู่ในโมดูล env ในไลบรารีมาตรฐาน ซึ่งถูกนำเข้าสู่ขอบเขตไว้แล้วที่ด้านบนของ src/main.rs เราจะใช้ฟังก์ชัน var จากโมดูล env เพื่อตรวจสอบดูว่ามีการตั้งค่าใดๆ สำหรับตัวแปรสภาพแวดล้อมที่ชื่อว่า IGNORE_CASE หรือไม่ ดังแสดงใน Listing 12-23

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ในที่นี้ เราสร้างตัวแปรใหม่ชื่อ ignore_case ในการตั้งค่าของมัน เราเรียกใช้ฟังก์ชัน env::var และส่งชื่อของตัวแปรสภาพแวดล้อม IGNORE_CASE ไปให้มัน ฟังก์ชัน env::var คืนค่าเป็น Result ซึ่งจะเป็นตัวแปร Ok ที่สำเร็จและบรรจุค่าของตัวแปรสภาพแวดล้อมหากมีการตั้งค่าตัวแปรสภาพแวดล้อมนั้นเป็นค่าใดก็ตาม และจะคืนค่าเป็นตัวแปร Err หากตัวแปรสภาพแวดล้อมไม่ได้ถูกตั้งค่าไว้

เราใช้เมธอด is_ok บน Result เพื่อตรวจสอบว่าตัวแปรสภาพแวดล้อมถูกตั้งค่าไว้หรือไม่ ซึ่งหมายความว่าโปรแกรมควรทำการค้นหาแบบไม่สนตัวพิมพ์ใหญ่-เล็ก หากตัวแปรสภาพแวดล้อม IGNORE_CASE ไม่ได้ถูกตั้งค่าเป็นสิ่งใดเลย is_ok จะคืนค่าเป็น false และโปรแกรมจะทำการค้นหาแบบสนตัวพิมพ์ใหญ่-เล็ก เราไม่ได้สนใจ ค่า ของตัวแปรสภาพแวดล้อม สนใจเพียงว่ามันถูกตั้งค่าไว้หรือไม่ตั้งค่าเท่านั้น ดังนั้นเราจึงตรวจสอบ is_ok แทนที่จะใช้ unwrap, expect หรือเมธอดอื่นๆ ที่เราเคยเห็นบน Result

เราส่งค่าในตัวแปร ignore_case ไปยังอินสแตนซ์ Config เพื่อให้ฟังก์ชัน run สามารถอ่านค่านั้นและตัดสินใจว่าจะเรียกใช้ search_case_insensitive หรือ search ตามที่เราได้ปรับใช้ใน Listing 12-22

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

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

ดูเหมือนว่าจะยังคงทำงานได้! ตอนนี้มารันโปรแกรมโดยตั้งค่า IGNORE_CASE เป็น 1 แต่ใช้คำค้นหา to เดียวกัน:

$ IGNORE_CASE=1 cargo run -- to poem.txt

หากคุณกำลังใช้ PowerShell คุณจะต้องตั้งค่าตัวแปรสภาพแวดล้อมและรันโปรแกรมแยกเป็นคนละคำสั่ง:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

สิ่งนี้จะทำให้ IGNORE_CASE ยังคงอยู่ตลอดช่วงเวลาที่เหลือของเชลล์เซสชันของคุณ มันสามารถยกเลิกการตั้งค่าได้ด้วย cmdlet Remove-Item:

PS> Remove-Item Env:IGNORE_CASE

เราควรจะได้บรรทัดที่มีคำว่า to ซึ่งอาจมีตัวอักษรพิมพ์ใหญ่:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

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

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

โมดูล std::env บรรจุฟีเจอร์ที่มีประโยชน์มากกว่านี้อีกมากมายสำหรับการทำงานกับตัวแปรสภาพแวดล้อม: ลองดูเอกสารประกอบเพื่อดูว่ามีอะไรให้ใช้งานบ้าง

Redirecting Errors to Standard Error

การเปลี่ยนทิศทางข้อผิดพลาดไปยัง Standard Error (Redirecting Errors to Standard Error)

ในตอนนี้ เรากำลังเขียนผลลัพธ์ทั้งหมดของเราไปยังเทอร์มินัลโดยใช้มาโคร println! ในเทอร์มินัลส่วนใหญ่ มีผลลัพธ์สองประเภทคือ: ผลลัพธ์มาตรฐาน (standard output - stdout) สำหรับข้อมูลทั่วไป และ ข้อผิดพลาดมาตรฐาน (standard error - stderr) สำหรับข้อความข้อผิดพลาด ความแตกต่างนี้ช่วยให้ผู้ใช้สามารถเลือกส่งทิศทางผลลัพธ์การทำงานที่สำเร็จของโปรแกรมไปยังไฟล์ ในขณะที่ยังคงพิมพ์ข้อความข้อผิดพลาดบนหน้าจอได้

มาโคร println! มีความสามารถเพียงแค่พิมพ์ไปยัง standard output เท่านั้น ดังนั้นเราจึงต้องใช้อย่างอื่นเพื่อพิมพ์ไปยัง standard error

การตรวจสอบจุดที่ข้อผิดพลาดถูกเขียนออกไป (Checking Where Errors Are Written)

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

โปรแกรมบรรทัดคำสั่งคาดว่าจะส่งข้อความข้อผิดพลาดไปยังสตรีม standard error เพื่อให้เรายังคงมองเห็นข้อความข้อผิดพลาดบนหน้าจอ แม้ว่าเราจะเปลี่ยนทิศทางสตรีม standard output ไปยังไฟล์ก็ตาม โปรแกรมของเราในตอนนี้ยังทำงานได้ไม่ดีนัก: เรากำลังจะเห็นว่ามันบันทึกข้อความข้อผิดพลาดลงในไฟล์แทน!

เพื่อสาธิตพฤติกรรมนี้ เราจะรันโปรแกรมด้วย > และพาธไฟล์ output.txt ที่เราต้องการเปลี่ยนทิศทางสตรีม standard output ไป เราจะไม่ส่งอาร์กิวเมนต์ใดๆ ซึ่งควรจะทำให้เกิดข้อผิดพลาด:

$ cargo run > output.txt

ไวยากรณ์ > บอกให้เชลล์เขียนเนื้อหาของ standard output ลงใน output.txt แทนที่จะเป็นหน้าจอ เราไม่เห็นข้อความข้อผิดพลาดที่เราคาดว่าจะพิมพ์บนหน้าจอ ซึ่งหมายความว่ามันต้องไปจบลงในไฟล์ นี่คือสิ่งที่ output.txt บรรจุอยู่:

Problem parsing arguments: not enough arguments

ใช่แล้ว ข้อความข้อผิดพลาดของเรากำลังถูกพิมพ์ไปยัง standard output การพิมพ์ข้อความข้อผิดพลาดประเภทนี้ไปยัง standard error จะมีประโยชน์กว่ามาก เพื่อให้เฉพาะข้อมูลจากการรันที่สำเร็จเท่านั้นไปจบลงในไฟล์ เราจะเปลี่ยนสิ่งนั้น

การพิมพ์ข้อผิดพลาดไปยัง Standard Error (Printing Errors to Standard Error)

เราจะใช้โค้ดใน Listing 12-24 เพื่อเปลี่ยนวิธีพิมพ์ข้อความข้อผิดพลาด เนื่องจากการรีแฟกเตอร์ที่เราทำในตอนต้นของบทนี้ โค้ดทั้งหมดที่พิมพ์ข้อความข้อผิดพลาดจึงอยู่ในฟังก์ชันเดียวคือ main ไลบรารีมาตรฐานมีมาโคร eprintln! ที่พิมพ์ไปยังสตรีม standard error ดังนั้นให้เราเปลี่ยนจุดสองจุดที่เราเคยเรียกใช้ println! เพื่อพิมพ์ข้อผิดพลาด ให้มาใช้ eprintln! แทน

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

คราวนี้มารันโปรแกรมอีกครั้งในรูปแบบเดิม โดยไม่ใส่อาร์กิวเมนต์ใดๆ และเปลี่ยนทิศทาง standard output ด้วย >:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

ตอนนี้เราเห็นข้อผิดพลาดบนหน้าจอ และไฟล์ output.txt ไม่มีอะไรเลย ซึ่งเป็นพฤติกรรมที่เราคาดหวังจากโปรแกรมบรรทัดคำสั่ง

ลองรันโปรแกรมอีกครั้งพร้อมกับอาร์กิวเมนต์ที่ไม่ทำให้เกิดข้อผิดพลาด แต่ยังคงเปลี่ยนทิศทาง standard output ไปยังไฟล์ เช่นนี้:

$ cargo run -- to poem.txt > output.txt

เราจะไม่เห็นผลลัพธ์ใดๆ ในเทอร์มินัล และ output.txt จะบรรจุผลลัพธ์ของเรา:

Filename: output.txt

Are you nobody, too?
How dreary to be somebody!

สิ่งนี้แสดงให้เห็นว่าตอนนี้เราใช้ standard output สำหรับผลลัพธ์ที่สำเร็จ และใช้ standard error สำหรับผลลัพธ์ข้อผิดพลาดอย่างเหมาะสมแล้ว

สรุป (Summary)

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

ถัดไป เราจะสำรวจฟีเจอร์ของ Rust ที่ได้รับอิทธิพลจากภาษาสไตล์ฟังก์ชันนัล (functional languages): closures และ iterators

ฟีเจอร์ภาษาเชิงฟังก์ชัน: ตัวซ้ำและโคลเชอร์ (Functional Language Features: Iterators and Closures)

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

ในบทนี้ เราจะไม่ถกเถียงกันในประเด็นที่ว่าการเขียนโปรแกรมเชิงฟังก์ชันคืออะไรหรือไม่ใช่อะไร แต่เราจะหารือเกี่ยวกับฟีเจอร์บางประการของ Rust ที่มีความคล้ายคลึงกับฟีเจอร์ในหลายภาษาซึ่งมักถูกอ้างถึงว่าเป็นแบบฟังก์ชันนัล

โดยเฉพาะอย่างยิ่ง เราจะครอบคลุมเรื่อง:

  • โคลเชอร์ (Closures) โครงสร้างที่เหมือนฟังก์ชันซึ่งคุณสามารถจัดเก็บไว้ในตัวแปรได้
  • ตัวซ้ำ (Iterators) วิธีการประมวลผลชุดของสมาชิก
  • วิธีใช้โคลเชอร์และตัวซ้ำเพื่อปรับปรุงโปรเจกต์ I/O ในบทที่ 12
  • ประสิทธิภาพของโคลเชอร์และตัวซ้ำ (สปอยล์: พวกมันทำงานเร็วกว่าที่คุณคิด!)

เราได้ครอบคลุมฟีเจอร์อื่นๆ ของ Rust ไปบ้างแล้ว เช่น การจับคู่รูปแบบ (pattern matching) และ enums ซึ่งได้รับอิทธิพลจากสไตล์ฟังก์ชันนัลด้วยเช่นกัน เนื่องจากเก่งกาจในเรื่องโคลเชอร์และตัวซ้ำเป็นส่วนสำคัญในการเขียนโค้ด Rust ที่รวดเร็วและมีความเป็นธรรมชาติ (idiomatic) เราจึงมอบทั้งบทนี้ให้แก่พวกมัน

Closures

โคลเชอร์ (Closures)

โคลเชอร์ (Closures) ใน Rust คือฟังก์ชันนิรนาม (anonymous functions) ที่คุณสามารถบันทึกไว้ในตัวแปรหรือส่งเป็นอาร์กิวเมนต์ให้กับฟังก์ชันอื่นได้ คุณสามารถสร้างโคลเชอร์ไว้ที่จุดหนึ่ง แล้วเรียกใช้โคลเชอร์นั้นที่จุดอื่นเพื่อประมวลผล (evaluate) ในบริบทที่แตกต่างออกไปได้ ต่างจากฟังก์ชันตรงที่โคลเชอร์สามารถดึงค่า (capture values) จากขอบเขต (scope) ที่พวกมันถูกนิยามไว้ได้ เราจะแสดงให้เห็นว่าฟีเจอร์ของโคลเชอร์เหล่านี้ช่วยให้เกิดการใช้โค้ดซ้ำ (code reuse) และการปรับแต่งพฤติกรรมได้อย่างไร

การดึงค่าจากสภาพแวดล้อม (Capturing the Environment)

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

มีหลายวิธีในการเขียนโค้ดสำหรับระบบนี้ สำหรับตัวอย่างนี้ เราจะใช้ enum ชื่อ ShirtColor ที่มีตัวแปรย่อย (variants) คือ Red และ Blue (จำกัดจำนวนสีไว้เพื่อความง่าย) เราแทนคลังสินค้าของบริษัทด้วย struct Inventory ที่มีฟิลด์ชื่อ shirts ซึ่งบรรจุ Vec<ShirtColor> เพื่อแทนสีเสื้อที่มีอยู่ในสต็อก ณ ตอนนั้น เมธอด giveaway ที่นิยามไว้บน Inventory จะรับตัวเลือกสีเสื้อที่ชอบของผู้ชนะเลิศ และจะคืนค่าสีเสื้อที่คน ๆ นั้นจะได้รับ โครงสร้างนี้แสดงอยู่ในโค้ดตัวอย่างที่ 13-1

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}

ในฟังก์ชัน main ตัวแปร store มีเสื้อสีน้ำเงินเหลือ 2 ตัวและเสื้อสีแดงเหลือ 1 ตัวสำหรับโปรโมชันลิมิเต็ดเอดิชันนี้ เราเรียกใช้เมธอด giveaway สำหรับผู้ใช้ที่ชอบเสื้อสีแดง และผู้ใช้ที่ไม่ระบุสีที่ชอบ

ขอย้ำอีกครั้งว่าโค้ดนี้สามารถเขียนได้หลายวิธี และในที่นี้ เพื่อเน้นไปที่เรื่องโคลเชอร์ เราจึงเลือกใช้แนวคิดที่คุณได้เรียนรู้มาแล้ว ยกเว้นในส่วนของเนื้อหาในเมธอด giveaway ที่มีการใช้โคลเชอร์ ในเมธอด giveaway เรารับค่าความชอบของผู้ใช้เป็นพารามิเตอร์ชนิด Option<ShirtColor> และเรียกใช้เมธอด unwrap_or_else บน user_preference เมธอด unwrap_or_else บน Option<T> ถูกนิยามไว้ในไลบรารีมาตรฐาน (standard library) โดยรับอาร์กิวเมนต์หนึ่งตัว คือโคลเชอร์ที่ไม่รับพารามิเตอร์ใด ๆ และคืนค่าประเภท T (ชนิดข้อมูลเดียวกับที่เก็บบรรจุอยู่ในตัวแปรย่อย Some ของ Option<T> ซึ่งในที่นี้คือ ShirtColor) หาก Option<T> เป็นตัวแปรย่อย Some เมธอด unwrap_or_else จะคืนค่าที่อยู่ภายใน Some นั้น แต่ถ้า Option<T> เป็นตัวแปรย่อย None เมธอด unwrap_or_else จะเรียกใช้โคลเชอร์และคืนค่าที่ได้จากโคลเชอร์นั้น

เราระบุพจน์โคลเชอร์ (closure expression) || self.most_stocked() เป็นอาร์กิวเมนต์ให้กับ unwrap_or_else นี่คือโคลเชอร์ที่ไม่รับพารามิเตอร์ใด ๆ (หากโคลเชอร์มีพารามิเตอร์ พารามิเตอร์เหล่านั้นจะปรากฏอยู่ระหว่างแนวตั้งสองเส้น ||) เนื้อหาภายในโคลเชอร์จะเรียกใช้ self.most_stocked() เรากำลังนิยามโคลเชอร์ไว้ตรงนี้ และการทำงานของ unwrap_or_else จะประมวลผลโคลเชอร์ในภายหลังหากจำเป็นต้องใช้ผลลัพธ์

เมื่อรันโค้ดนี้จะพิมพ์ผลลัพธ์ดังนี้:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

ประเด็นที่น่าสนใจประการหนึ่งตรงนี้คือ เราได้ส่งโคลเชอร์ที่เรียกใช้ self.most_stocked() บนอินสแตนซ์ Inventory ปัจจุบัน โดยที่ไลบรารีมาตรฐานไม่จำเป็นต้องรู้จักชนิดข้อมูล Inventory หรือ ShirtColor ที่เรานิยามขึ้น หรือตรรกะที่เราต้องการใช้ในสถานการณ์นี้เลย โคลเชอร์จะดึงการอ้างอิงแบบแก้ไขไม่ได้ (immutable reference) ไปยังอินสแตนซ์ self ของ Inventory และส่งต่อมันพร้อมกับโค้ดที่เรากำหนดไปยังเมธอด unwrap_or_else ในขณะที่ฟังก์ชันทั่วไปไม่สามารถดึงค่าจากสภาพแวดล้อมในลักษณะนี้ได้

การอนุมานและการระบุชนิดข้อมูลของโคลเชอร์ (Inferring and Annotating Closure Types)

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

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

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

use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

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

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

บรรทัดแรกแสดงการนิยามฟังก์ชัน และบรรทัดที่สองแสดงการนิยามโคลเชอร์แบบระบุชนิดข้อมูลอย่างสมบูรณ์ ในบรรทัดที่สาม เราถอดการระบุชนิดข้อมูลออกจากนิยามโคลเชอร์ ในบรรทัดที่สี่ เราถอดเครื่องหมายปีกกาออก ซึ่งสามารถละได้เพราะเนื้อหาของโคลเชอร์มีเพียงพจน์ (expression) เดียว ทั้งหมดนี้เป็นการนิยามที่ถูกต้องและจะให้พฤติกรรมเดียวกันเมื่อถูกเรียกใช้ บรรทัด add_one_v3 และ add_one_v4 จำเป็นต้องให้โคลเชอร์ถูกประมวลผลจึงจะสามารถคอมไพล์ได้ เพราะชนิดข้อมูลจะถูกอนุมานจากการใช้งาน ซึ่งคล้ายกับ let v = Vec::new(); ที่ต้องมีทั้งการระบุชนิดข้อมูลหรือการใส่ค่าบางชนิดลงใน Vec เพื่อให้ Rust สามารถอนุมานชนิดข้อมูลได้

สำหรับการนิยามโคลเชอร์ คอมไพเลอร์จะอนุมานชนิดข้อมูลที่เป็นรูปธรรม (concrete type) เพียงชนิดเดียวสำหรับพารามิเตอร์แต่ละตัวและสำหรับค่าคืนกลับ ตัวอย่างเช่น โค้ดตัวอย่างที่ 13-3 แสดงการนิยามโคลเชอร์สั้น ๆ ที่ทำเพียงคืนค่าที่ได้รับมาเป็นพารามิเตอร์ โคลเชอร์นี้ไม่ได้มีประโยชน์มากนักเว้นแต่เพื่อวัตถุประสงค์ของตัวอย่างนี้ โปรดสังเกตว่าเราไม่ได้เพิ่มการระบุชนิดข้อมูลใด ๆ ลงในการนิยาม เนื่องจากไม่มีการระบุชนิดข้อมูล เราจึงสามารถเรียกใช้โคลเชอร์ด้วยชนิดข้อมูลใดก็ได้ ซึ่งในครั้งแรกเราทำด้วย String หากเราพยายามเรียกใช้ example_closure ด้วยจำนวนเต็มในบรรทัดถัดมา เราจะได้ข้อผิดพลาด (error)

fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}

คอมไพเลอร์แจ้งข้อผิดพลาดนี้แก่เรา:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^ expected `String`, found integer
  |             |
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^
help: try using a conversion method
  |
5 |     let n = example_closure(5.to_string());
  |                              ++++++++++++

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

ครั้งแรกที่เราเรียกใช้ example_closure ด้วยค่า String คอมไพเลอร์จะอนุมานชนิดข้อมูลของ x และชนิดข้อมูลของค่าคืนกลับของโคลเชอร์ว่าเป็น String ชนิดข้อมูลเหล่านั้นจะถูกล็อกไว้กับโคลเชอร์ใน example_closure และเราจะได้ข้อผิดพลาดเรื่องชนิดข้อมูล (type error) เมื่อเราพยายามใช้ชนิดข้อมูลอื่นกับโคลเชอร์เดิมในครั้งถัดไป

การยืมอ้างอิงและการย้ายความเป็นเจ้าของ (Capturing References or Moving Ownership)

โคลเชอร์สามารถดึงค่าจากสภาพแวดล้อมของมันได้ 3 วิธี ซึ่งสอดคล้องโดยตรงกับ 3 วิธีที่ฟังก์ชันรับพารามิเตอร์ ได้แก่ การยืมแบบแก้ไขไม่ได้ (borrowing immutably) การยืมแบบแก้ไขได้ (borrowing mutably) และการรับความเป็นเจ้าของ (taking ownership) โคลเชอร์จะตัดสินใจว่าจะใช้วิธีใดโดยพิจารณาจากสิ่งที่เนื้อหาของฟังก์ชันทำกับค่าที่ดึงมา

ในโค้ดตัวอย่างที่ 13-4 เรานิยามโคลเชอร์ที่ดึงการอ้างอิงแบบแก้ไขไม่ได้ไปยังเวกเตอร์ชื่อ list เนื่องจากมันต้องการเพียงการอ้างอิงแบบแก้ไขไม่ได้เพื่อพิมพ์ค่าเท่านั้น

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}

ตัวอย่างนี้ยังแสดงให้เห็นว่าตัวแปรสามารถผูกเข้ากับการนิยามโคลเชอร์ได้ และในภายหลังเราสามารถเรียกใช้โคลเชอร์โดยใช้ชื่อตัวแปรและวงเล็บราวกับว่าชื่อตัวแปรนั้นเป็นชื่อฟังก์ชัน

เนื่องจากเราสามารถมีการอ้างอิงแบบแก้ไขไม่ได้หลายตำแหน่งไปยัง list พร้อมกันได้ list จึงยังคงเข้าถึงได้จากโค้ดก่อนหน้าการนิยามโคลเชอร์ หลังการนิยามโคลเชอร์แต่ก่อนที่โคลเชอร์จะถูกเรียก และหลังจากที่โคลเชอร์ถูกเรียก โค้ดนี้สามารถคอมไพล์ รัน และพิมพ์ผลลัพธ์ได้:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

ถัดมา ในโค้ดตัวอย่างที่ 13-5 เราเปลี่ยนเนื้อหาโคลเชอร์เพื่อให้เพิ่มสมาชิกเข้าไปในเวกเตอร์ list ตอนนี้โคลเชอร์จะดึงการอ้างอิงแบบแก้ไขได้ (mutable reference)

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}

โค้ดนี้สามารถคอมไพล์ รัน และพิมพ์ผลลัพธ์ได้:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

สังเกตว่าไม่มี println! อยู่ระหว่างการนิยามและการเรียกใช้โคลเชอร์ borrows_mutably อีกต่อไป เมื่อ borrows_mutably ถูกนิยาม มันจะดึงการอ้างอิงแบบแก้ไขได้ไปยัง list เราไม่ได้ใช้โคลเชอร์นี้อีกหลังจากเรียกใช้โคลเชอร์แล้ว ดังนั้นการยืมแบบแก้ไขได้จึงสิ้นสุดลง ในช่วงระหว่างการนิยามโคลเชอร์และการเรียกใช้โคลเชอร์ การยืมแบบแก้ไขไม่ได้เพื่อพิมพ์ค่าจะไม่ได้รับอนุญาต เพราะไม่อนุญาตให้มีการยืมอื่น ๆ เมื่อมีการยืมแบบแก้ไขอยู่นั้น ลองเพิ่ม println! ในจุดนั้นเพื่อดูข้อผิดพลาดที่คุณจะได้รับ!

หากคุณต้องการบังคับให้โคลเชอร์ครอบครองความเป็นเจ้าของ (take ownership) ของค่าที่ใช้ในสภาพแวดล้อม แม้ว่าเนื้อหาของโคลเชอร์จะไม่จำเป็นต้องใช้ความเป็นเจ้าของอย่างเคร่งครัดก็ตาม คุณสามารถใช้คีย์เวิร์ด move ไว้หน้า รายการพารามิเตอร์ ได้

เทคนิคนี้มีประโยชน์อย่างมากเมื่อส่งโคลเชอร์ไปยังเธรดใหม่เพื่อย้ายข้อมูลให้เธรดใหม่เป็นเจ้าของ เราจะหารือเกี่ยวกับเธรดและเหตุผลที่คุณต้องการใช้งานเธรดอย่างละเอียดในบทที่ 16 เมื่อเราพูดถึงเรื่องการทำงานพร้อมกัน (concurrency) แต่สำหรับตอนนี้ เรามาลองสำรวจการสร้างเธรดใหม่โดยใช้โคลเชอร์ที่ต้องใช้คีย์เวิร์ด move กันคร่าว ๆ โค้ดตัวอย่างที่ 13-6 แสดงโค้ดตัวอย่างที่ 13-4 ที่ถูกปรับเปลี่ยนเพื่อพิมพ์เวกเตอร์ในเธรดใหม่แทนที่จะเป็นเธรดหลัก (main thread)

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}

เราสร้างเธรดใหม่ โดยส่งโคลเชอร์ให้เธรดทำงานเป็นอาร์กิวเมนต์ เนื้อหาของโคลเชอร์จะพิมพ์ค่าในลิสต์ ในโค้ดตัวอย่างที่ 13-4 โคลเชอร์เพียงดึง list โดยใช้การอ้างอิงแบบแก้ไขไม่ได้ เพราะนั่นคือการเข้าถึงน้อยที่สุดที่จำเป็นสำหรับการพิมพ์ค่า ในตัวอย่างนี้ แม้ว่าเนื้อหาของโคลเชอร์จะยังคงต้องการเพียงการอ้างอิงแบบแก้ไขไม่ได้ แต่เราจำเป็นต้องระบุว่า list ควรถูกย้ายเข้าไปในโคลเชอร์โดยใส่คีย์เวิร์ด move ไว้ตอนเริ่มต้นของการนิยามโคลเชอร์ หากเธรดหลักทำงานอื่นเพิ่มเติมก่อนจะเรียก join บนเธรดใหม่ เธรดใหม่อาจจะทำงานเสร็จก่อนส่วนที่เหลือของเธรดหลัก หรือเธรดหลักอาจจะทำงานเสร็จก่อนก็ได้ หากเธรดหลักยังคงครอบครองความเป็นเจ้าของ list แต่สิ้นสุดการทำงานก่อนเธรดใหม่และคืนคืนหน่วยกิต/คืนค่า (drops) list การอ้างอิงแบบแก้ไขไม่ได้ในเธรดจะกลายเป็นค่าที่ไม่ถูกต้อง (invalid) ดังนั้น คอมไพเลอร์จึงกำหนดให้ list ถูกย้ายเข้าไปในโคลเชอร์ที่มอบให้แก่เธรดใหม่เพื่อการอ้างอิงนั้นจะถูกต้องเสมอ ลองถอดคีย์เวิร์ด move ออก หรือลองใช้ list ในเธรดหลักหลังจากนิยามโคลเชอร์ เพื่อดูว่าคุณได้รับข้อผิดพลาดใดจากคอมไพเลอร์!

การย้ายค่าที่ดึงมาออกจากโคลเชอร์ (Moving Captured Values Out of Closures)

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

เนื้อหาของโคลเชอร์สามารถทำสิ่งใดสิ่งหนึ่งดังต่อไปนี้: ย้ายค่าที่ดึงมาออกจากโคลเชอร์, เปลี่ยนแปลงค่าที่ดึงมา, ไม่ย้ายและไม่เปลี่ยนแปลงค่า, หรือไม่ดึงสิ่งใดจากสภาพแวดล้อมเลยตั้งแต่แรก

วิธีการที่โคลเชอร์ดึงและจัดการกับค่าจากสภาพแวดล้อมจะส่งผลต่อเทรต (traits) ที่โคลเชอร์นั้นประยุกต์ใช้ (implement) และเทรตคือวิธีที่ฟังก์ชันและ struct สามารถระบุชนิดของโคลเชอร์ที่พวกมันสามารถใช้งานได้ โคลเชอร์จะประยุกต์ใช้เทรต Fn หนึ่ง สอง หรือทั้งสามเทรตโดยอัตโนมัติ ในลักษณะเพิ่มขยาย (additive) ขึ้นอยู่กับวิธีที่เนื้อหาของโคลเชอร์จัดการกับค่าเหล่านั้น:

  • FnOnce นำไปใช้กับโคลเชอร์ที่สามารถเรียกได้ครั้งเดียว โคลเชอร์ทั้งหมดจะประยุกต์ใช้เทรตนี้เป็นอย่างน้อยเนื่องจากโคลเชอร์ทั้งหมดสามารถถูกเรียกได้ โคลเชอร์ที่ย้ายค่าที่ดึงมาออกจากเนื้อหาของมัน จะประยุกต์ใช้เพียง FnOnce เท่านั้น และจะไม่ประยุกต์ใช้เทรต Fn อื่น ๆ เพราะมันสามารถเรียกได้เพียงครั้งเดียว
  • FnMut นำไปใช้กับโคลเชอร์ที่ไม่ย้ายค่าที่ดึงมาออกจากเนื้อหา แต่ไม่อาจเปลี่ยนแปลง (mutate) ค่าที่ดึงมาได้ โคลเชอร์เหล่านี้สามารถเรียกได้มากกว่าหนึ่งครั้ง
  • Fn นำไปใช้กับโคลเชอร์ที่ไม่ย้ายค่าที่ดึงมาออกจากเนื้อหาและไม่เปลี่ยนแปลงค่าที่ดึงมา รวมถึงโคลเชอร์ที่ไม่ดึงสิ่งใดจากสภาพแวดล้อมเลย โคลเชอร์เหล่านี้สามารถเรียกได้มากกว่าหนึ่งครั้งโดยไม่เปลี่ยนแปลงสภาพแวดล้อม ซึ่งมีความสำคัญในกรณีต่าง ๆ เช่น การเรียกใช้โคลเชอร์หลายครั้งพร้อมกัน (concurrently)

มาดูการนิยามเมธอด unwrap_or_else บน Option<T> ที่เราใช้ในโค้ดตัวอย่างที่ 13-1:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

โปรดระลึกว่า T คือชนิดข้อมูลทั่วไป (generic type) ที่แทนชนิดข้อมูลของค่าในตัวแปรย่อย Some ของ Option ชนิดข้อมูล T นั้นยังเป็นชนิดข้อมูลของค่าคืนกลับของฟังก์ชัน unwrap_or_else อีกด้วย: ตัวอย่างเช่น โค้ดที่เรียกใช้ unwrap_or_else บน Option<String> จะได้รับ String

ถัดมา สังเกตว่าฟังก์ชัน unwrap_or_else มีพารามิเตอร์ชนิดข้อมูลทั่วไปเพิ่มเติมคือ F โดยชนิดข้อมูล F เป็นชนิดข้อมูลของพารามิเตอร์ชื่อ f ซึ่งเป็นโคลเชอร์ที่เราส่งให้เมื่อเรียกใช้ unwrap_or_else

ข้อกำหนดเทรต (trait bound) ที่ระบุบนชนิดข้อมูลทั่วไป F คือ FnOnce() -> T ซึ่งหมายความว่า F ต้องสามารถถูกเรียกใช้ได้ครั้งเดียว ไม่รับอาร์กิวเมนต์ และคืนค่า T การใช้ FnOnce ในขอบเขตของเทรตเป็นการแสดงถึงข้อจำกัดว่า unwrap_or_else จะไม่เรียกใช้ f มากกว่าหนึ่งครั้ง ในเนื้อหาของ unwrap_or_else เราสามารถเห็นได้ว่าหาก Option เป็น Some ตัวแปร f จะไม่ถูกเรียก แต่ถ้า Option เป็น None ตัวแปร f จะถูกเรียกหนึ่งครั้ง เนื่องจากโคลเชอร์ทั้งหมดประยุกต์ใช้ FnOnce เมธอด unwrap_or_else จึงยอมรับโคลเชอร์ทั้งสามประเภทและมีความยืดหยุ่นมากที่สุดเท่าที่จะเป็นไปได้

หมายเหตุ: หากสิ่งที่เราต้องการทำไม่จำเป็นต้องดึงค่าจากสภาพแวดล้อม เราสามารถใช้ชื่อของฟังก์ชันแทนโคลเชอร์ในจุดที่ต้องการสิ่งที่ประยุกต์ใช้เทรตใดเทรตหนึ่งของ Fn ได้ ตัวอย่างเช่น บนค่าประเภท Option<Vec<T>> เราสามารถเรียก unwrap_or_else(Vec::new) เพื่อรับเวกเตอร์ใหม่ที่ว่างเปล่าได้หากค่านั้นเป็น None คอมไพเลอร์จะประยุกต์ใช้เทรต Fn ที่เหมาะสมกับการนิยามฟังก์ชันนั้นให้อย่างอัตโนมัติ

คราวนี้มาดูเมธอดในไลบรารีมาตรฐานชื่อ sort_by_key ซึ่งนิยามไว้บน slice เพื่อดูว่าแตกต่างจาก unwrap_or_else อย่างไร และเหตุใด sort_by_key จึงใช้ FnMut แทนที่จะเป็น FnOnce สำหรับขอบเขตของเทรต โคลเชอร์จะรับอาร์กิวเมนต์หนึ่งตัวในรูปของการอ้างอิงไปยังสมาชิกปัจจุบันใน slice ที่กำลังพิจารณา และคืนค่าชนิดข้อมูล K ที่สามารถจัดเรียงลำดับได้ ฟังก์ชันนี้มีประโยชน์เมื่อคุณต้องการเรียงลำดับ slice ตามคุณลักษณะเฉพาะของแต่ละสมาชิก ในโค้ดตัวอย่างที่ 13-7 เรามีรายการอินสแตนซ์ของ Rectangle และเราใช้ sort_by_key เพื่อเรียงลำดับพวกมันตามคุณลักษณะความกว้าง (width) จากน้อยไปมาก

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}

โค้ดนี้พิมพ์ผลลัพธ์:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

เหตุผลที่ sort_by_key ถูกนิยามมาให้รับโคลเชอร์ FnMut คือมันจะเรียกใช้โคลเชอร์หลายครั้ง: หนึ่งครั้งสำหรับแต่ละสมาชิกใน slice โคลเชอร์ |r| r.width ไม่ได้ดึง เปลี่ยนแปลง หรือย้ายสิ่งใดออกจากสภาพแวดล้อมของมัน ดังนั้นมันจึงเป็นไปตามข้อกำหนดของขอบเขตเทรต

ในทางกลับกัน โค้ดตัวอย่างที่ 13-8 แสดงตัวอย่างของโคลเชอร์ที่ประยุกต์ใช้เพียงเทรต FnOnce เท่านั้น เนื่องจากมันย้ายค่าออกจากสภาพแวดล้อม คอมไพเลอร์จะไม่ยินยอมให้เราใช้โคลเชอร์นี้กับ sort_by_key

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}

นี่คือวิธีประดิษฐ์ซับซ้อน (ที่ไม่ทำงาน) ในการพยายามนับจำนวนครั้งที่ sort_by_key เรียกใช้โคลเชอร์ขณะเรียงลำดับ list โค้ดนี้พยายามทำนับจำนวนโดยการดัน (push) ค่า value—ซึ่งเป็น String จากสภาพแวดล้อมของโคลเชอร์—เข้าไปในเวกเตอร์ sort_operations โคลเชอร์จะดึง value แล้วย้าย value ออกจากโคลเชอร์โดยการโอนย้ายความเป็นเจ้าของของ value ไปยังเวกเตอร์ sort_operations โคลเชอร์นี้สามารถเรียกได้ครั้งเดียว การพยายามเรียกใช้เป็นครั้งที่สองจะไม่ทำงาน เพราะ value ไม่ได้อยู่ในสภาพแวดล้อมที่จะถูกดันเข้าไปใน sort_operations อีกต่อไป! ดังนั้น โคลเชอร์นี้จึงประยุกต์ใช้เพียง FnOnce เมื่อเราพยายามคอมไพล์โค้ดนี้ เราจะได้ข้อผิดพลาดว่า value ไม่สามารถถูกย้ายออกจากโคลเชอร์ได้เนื่องจากโคลเชอร์ต้องประยุกต์ใช้ FnMut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         -----   ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |         |
   |         captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ `value` is moved here
   |
help: `Fn` and `FnMut` closures require captured values to be able to be consumed multiple times, but `FnOnce` closures may consume them only once
  --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/alloc/src/slice.rs:249:11
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error

ข้อผิดพลาดจะชี้ไปที่บรรทัดในเนื้อหาของโคลเชอร์ที่ย้าย value ออกจากสภาพแวดล้อม ในการแก้ไข ปัญหานี้ เราต้องเปลี่ยนเนื้อหาของโคลเชอร์ไม่ให้ย้ายค่าออกจากสภาพแวดล้อม การเก็บตัวนับไว้ในสภาพแวดล้อมและเพิ่มค่าตัวนับในเนื้อหาของโคลเชอร์เป็นวิธีที่ตรงไปตรงมามากกว่าในการนับจำนวนครั้งที่โคลเชอร์ถูกเรียก โคลเชอร์ในโค้ดตัวอย่างที่ 13-9 ทำงานร่วมกับ sort_by_key ได้เพราะมันเพียงแค่ดึงการอ้างอิงแบบแก้ไขได้ไปยังตัวนับ num_sort_operations จึงสามารถเรียกใช้ได้มากกว่าหนึ่งครั้ง

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}

เทรตกลุ่ม Fn มีความสำคัญเมื่อนิยามหรือใช้ฟังก์ชันหรือชนิดข้อมูลที่มีการใช้โคลเชอร์ ในส่วนถัดไป เราจะพูดถึงตัวซ้ำ (iterators) เมธอดของตัวซ้ำจำนวนมากรับอาร์กิวเมนต์เป็นโคลเชอร์ ดังนั้นโปรดคำนึงถึงรายละเอียดโคลเชอร์เหล่านี้เมื่อเราเรียนรู้ต่อไป!

Processing a Series of Items with Iterators

การประมวลผลลำดับข้อมูลด้วยตัวซ้ำ (Iterators)

รูปแบบการออกแบบตัวซ้ำ (Iterator pattern) ช่วยให้คุณสามารถทำงานบางอย่างกับลำดับของสมาชิกตามลำดับได้ ตัวซ้ำ (Iterator) มีหน้าที่รับผิดชอบตรรกะในการวนซ้ำไปตามแต่ละสมาชิกและตัดสินว่าลำดับข้อมูลนั้นสิ้นสุดลงเมื่อใด เมื่อคุณใช้ตัวซ้ำ คุณไม่จำเป็นต้องเขียนตรรกะนั้นขึ้นใหม่ด้วยตัวเอง

ในภาษา Rust ตัวซ้ำจะมีลักษณะเกียจคร้าน (lazy) ซึ่งหมายความว่าตัวซ้ำจะยังไม่มีผลใด ๆ จนกว่าคุณจะเรียกใช้เมธอดที่บริโภคตัวซ้ำนั้นเพื่อนำไปใช้งานจนหมด ตัวอย่างเช่น โค้ดในโค้ดตัวอย่างที่ 13-10 ได้สร้างตัวซ้ำเหนือสมาชิกในเวกเตอร์ v1 โดยการเรียกใช้เมธอด iter ที่นิยามไว้บน Vec<T> โค้ดนี้เพียงอย่างเดียวไม่ได้ทำสิ่งที่เป็นประโยชน์ใด ๆ

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}

ตัวซ้ำนี้ถูกเก็บไว้ในตัวแปร v1_iter เมื่อเราสร้างตัวซ้ำขึ้นมาแล้ว เราสามารถนำมันไปใช้งานได้หลากหลายวิธี ในโค้ดตัวอย่างที่ 3-5 เราได้วนซ้ำเหนืออาเรย์โดยใช้ลูป for เพื่อประมวลผลโค้ดบางอย่างกับแต่ละสมาชิก ภายใต้ฉากหลัง ลูป for จะสร้างและบริโภคตัวซ้ำอย่างไม่เปิดเผย (implicitly) แต่เราได้ข้ามรายละเอียดการทำงานที่ชัดเจนไปจนถึงตอนนี้

ในตัวอย่างโค้ดตัวอย่างที่ 13-11 เราได้แยกการสร้างตัวซ้ำออกจากใช้งานตัวซ้ำในลูป for เมื่อลูป for ถูกเรียกใช้โดยใช้ตัวซ้ำใน v1_iter สมาชิกแต่ละตัวในตัวซ้ำจะถูกใช้ในการวนซ้ำรอบหนึ่งของลูป ซึ่งจะพิมพ์แต่ละค่าออกมา

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}

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

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

เทรต Iterator และเมธอด next

ตัวซ้ำทั้งหมดจะประยุกต์ใช้ (implement) เทรตที่ชื่อว่า Iterator ซึ่งถูกนิยามไว้ในไลบรารีมาตรฐาน นิยามของเทรตมีลักษณะดังนี้:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // methods with default implementations elided
}
}

สังเกตว่านิยามนี้ใช้ไวยากรณ์ใหม่บางอย่าง ได้แก่ type Item และ Self::Item ซึ่งเป็นการกำหนดชนิดข้อมูลที่เชื่อมโยง (associated type) ให้กับเทรตนี้ เราจะพูดถึงชนิดข้อมูลที่เชื่อมโยงอย่างละเอียดในบทที่ 20 สำหรับตอนนี้ สิ่งที่คุณต้องรู้คือ โค้ดนี้บอกว่าการประยุกต์ใช้เทรต Iterator กำหนดให้คุณต้องนิยามชนิดข้อมูล Item ด้วย และชนิดข้อมูล Item นี้ถูกใช้ในชนิดข้อมูลค่าคืนกลับของเมธอด next หรือกล่าวอีกนัยหนึ่ง ชนิดข้อมูล Item จะเป็นชนิดข้อมูลที่ถูกคืนกลับมาจากตัวซ้ำ

เทรต Iterator กำหนดให้ผู้ประยุกต์ใช้นิยามเพียงเมธอดเดียวเท่านั้น นั่นคือเมธอด next ซึ่งจะคืนค่าทีละหนึ่งสมาชิกของตัวซ้ำ โดยห่อหุ้มใน Some และเมื่อการวนซ้ำสิ้นสุดลง จะคืนค่า None

เราสามารถเรียกใช้เมธอด next บนตัวซ้ำได้โดยตรง โค้ดตัวอย่างที่ 13-12 แสดงให้เห็นว่าค่าใดบ้างที่ถูกคืนกลับมาจากการเรียก next ซ้ำ ๆ บนตัวซ้ำที่สร้างขึ้นจากเวกเตอร์

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}

สังเกตว่าเราจำเป็นต้องทำให้ v1_iter สามารถแก้ไขได้ (mutable): การเรียกใช้เมธอด next บนตัวซ้ำจะเปลี่ยนแปลงสถานะภายในที่ตัวซ้ำใช้ติดตามตำแหน่งปัจจุบันในลำดับข้อมูล หรือกล่าวอีกนัยหนึ่ง โค้ดนี้ได้บริโภค (consumes) หรือใช้ตัวซ้ำไปจนหมด การเรียก next แต่ละครั้งจะกินสมาชิกไปหนึ่งตัวจากตัวซ้ำ เราไม่จำเป็นต้องทำให้ v1_iter เป็นชนิดที่แก้ไขได้เมื่อเราใช้ลูป for เพราะลูปจะรับความเป็นเจ้าของ v1_iter และทำให้มันสามารถแก้ไขได้อยู่เบื้องหลัง

นอกจากนี้ สังเกตว่าค่าที่เราได้จากการเรียก next คือการอ้างอิงแบบแก้ไขไม่ได้ (immutable reference) ไปยังค่าในเวกเตอร์ เมธอด iter จะสร้างตัวซ้ำเหนือการอ้างอิงแบบแก้ไขไม่ได้ หากเราต้องการสร้างตัวซ้ำที่รับความเป็นเจ้าของ v1 และคืนค่าที่เป็นเจ้าของเอง เราสามารถเรียก into_iter แทน iter ได้ ในทำนองเดียวกัน หากเราต้องการวนซ้ำเหนือการอ้างอิงแบบแก้ไขได้ เราสามารถเรียก iter_mut แทน iter ได้

เมธอดที่บริโภคตัวซ้ำ (Methods That Consume the Iterator)

เทรต Iterator มีเมธอดต่าง ๆ มากมายพร้อมการประยุกต์ใช้งานเริ่มต้น (default implementations) ที่จัดเตรียมโดยไลบรารีมาตรฐาน คุณสามารถค้นหาเมธอดเหล่านี้ได้โดยดูจากเอกสาร API ไลบรารีมาตรฐานสำหรับเทรต Iterator เมธอดเหล่านี้บางตัวเรียกใช้เมธอด next ในการนิยามของพวกมัน ซึ่งเป็นเหตุผลว่าทำไมคุณจึงจำเป็นต้องประยุกต์ใช้เมธอด next เมื่อประยุกต์ใช้เทรต Iterator

เมธอดที่เรียกใช้ next ถูกเรียกว่า ตัวปรับแต่งที่บริโภค (consuming adapters) เนื่องจากตัวมันเรียกใช้งานจนตัวซ้ำถูกใช้หมดไป ตัวอย่างหนึ่งคือเมธอด sum ซึ่งจะรับความเป็นเจ้าของของตัวซ้ำและวนซ้ำตามสมาชิกโดยการเรียก next ซ้ำ ๆ จึงเป็นการบริโภคตัวซ้ำ ในขณะที่มันวนซ้ำไป มันจะบวกแต่ละสมาชิกเข้ากับผลรวมสะสม และคืนค่าผลรวมเมื่อการวนซ้ำเสร็จสมบูรณ์ โค้ดตัวอย่างที่ 13-13 แสดงการทดสอบที่ใช้เมธอด sum

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}

เราไม่ได้รับอนุญาตให้ใช้ v1_iter หลังจากเรียกใช้ sum เพราะ sum ได้รับความเป็นเจ้าของของตัวซ้ำที่เราเรียกใช้ไปแล้ว

เมธอดที่สร้างตัวซ้ำอื่นขึ้นมา (Methods That Produce Other Iterators)

ตัวปรับแต่งตัวซ้ำ (Iterator adapters) คือเมธอดที่นิยามไว้บนเทรต Iterator ที่ไม่บริโภคตัวซ้ำ แต่พวกมันจะสร้างตัวซ้ำอื่นที่แตกต่างออกไปขึ้นมาโดยการเปลี่ยนแง่มุมบางอย่างของตัวซ้ำดั้งเดิม

โค้ดตัวอย่างที่ 13-14 แสดงตัวอย่างการเรียกใช้เมธอดตัวปรับแต่งตัวซ้ำ map ซึ่งรับโคลเชอร์เพื่อเรียกใช้กับแต่ละสมาชิกขณะวนซ้ำ เมธอด map จะคืนค่าตัวซ้ำใหม่ที่ให้สมาชิกที่ถูกปรับเปลี่ยนแล้ว โคลเชอร์ในที่นี้จะสร้างตัวซ้ำใหม่ที่สมาชิกแต่ละตัวจากเวกเตอร์จะถูกเพิ่มค่าขึ้น 1

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}

อย่างไรก็ตาม โค้ดนี้จะสร้างคำเตือน (warning):

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

โค้ดในโค้ดตัวอย่างที่ 13-14 ไม่ได้ทำสิ่งใดเลย โคลเชอร์ที่เรากำหนดไว้ไม่เคยถูกเรียกใช้ คำเตือนเตือนเราถึงเหตุผลว่า: ตัวปรับแต่งตัวซ้ำมีลักษณะเกียจคร้าน (lazy) และเราจำเป็นต้องบริโภคตัวซ้ำตรงนี้

ในการแก้ไขคำเตือนนี้และบริโภคตัวซ้ำ เราจะใช้เมธอด collect ซึ่งเราเคยใช้กับ env::args ในโค้ดตัวอย่างที่ 12-1 เมธอดนี้จะบริโภคตัวซ้ำและรวบรวมค่าที่ได้ลงในชนิดข้อมูลประเภทคอลเลกชัน (collection)

ในโค้ดตัวอย่างที่ 13-15 เรา รวบรวมผลลัพธ์ของการวนซ้ำเหนือตัวซ้ำที่ถูกคืนกลับมาจากการเรียก map ลงในเวกเตอร์ เวกเตอร์นี้จะจบลงด้วยการบรรจุสมาชิกแต่ละตัวจากเวกเตอร์เดิมที่ถูกบวกเพิ่มขึ้น 1

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}

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

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

โคลเชอร์ที่ดึงค่าจากสภาพแวดล้อมของพวกมัน (Closures That Capture Their Environment)

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

สำหรับตัวอย่างนี้ เราจะใช้เมธอด filter ที่รับโคลเชอร์ โคลเชอร์จะได้สมาชิกจากตัวซ้ำและคืนค่าเป็น bool หากโคลเชอร์คืนค่า true ค่านั้นจะถูกรวมไว้ในตัวซ้ำที่สร้างขึ้นโดย filter แต่หากโคลเชอร์คืนค่า false ค่านั้นจะไม่ถูกรวมอยู่ด้วย

ในโค้ดตัวอย่างที่ 13-16 เราใช้ filter ร่วมกับโคลเชอร์ที่ดึงค่าจากตัวแปร shoe_size ในสภาพแวดล้อมของมัน เพื่อวนซ้ำเหนือคอลเลกชันของอินสแตนซ์ struct Shoe มันจะคืนค่าเฉพาะรองเท้าที่มีขนาดตามที่ระบุไว้เท่านั้น

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}

ฟังก์ชัน shoes_in_size รับความเป็นเจ้าของของเวกเตอร์ของรองเท้าและขนาดรองเท้าเป็นพารามิเตอร์ มันคืนค่าเป็นเวกเตอร์ที่บรรจุเฉพาะรองเท้าตามขนาดที่ระบุไว้เท่านั้น

ในเนื้อหาของ shoes_in_size เราเรียก into_iter เพื่อสร้างตัวซ้ำที่รับความเป็นเจ้าของของเวกเตอร์ จากนั้นเราเรียก filter เพื่อปรับแต่งตัวซ้ำนั้นเป็นตัวซ้ำใหม่ที่มีเฉพาะสมาชิกที่โคลเชอร์คืนค่าเป็น true

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

การทดสอบแสดงให้เห็นว่าเมื่อเราเรียกใช้ shoes_in_size เราจะได้กลับมาเฉพาะรองเท้าที่มีขนาดเดียวกับค่าที่เราระบุไว้เท่านั้น

Improving Our I/O Project

การปรับปรุงโปรเจกต์ I/O ของเรา

ด้วยความรู้ใหม่เกี่ยวกับตัวซ้ำนี้ เราสามารถปรับปรุงโปรเจกต์ I/O ในบทที่ 12 ได้ โดยการใช้ตัวซ้ำเพื่อทำให้จุดต่าง ๆ ในโค้ดมีความชัดเจนและกระชับยิ่งขึ้น มาดูกันว่าตัวซ้ำสามารถปรับปรุงการประยุกต์ใช้งานฟังก์ชัน Config::build และฟังก์ชัน search ของเราได้อย่างไร

การถอด clone ออกโดยใช้ตัวซ้ำ

ในโค้ดตัวอย่างที่ 12-6 เราได้เพิ่มโค้ดที่รับ slice ของค่า String และสร้างอินสแตนซ์ของ struct Config โดยการเข้าถึงดัชนีใน slice และทำสำเนา (clone) ค่านั้น ๆ เพื่อให้ struct Config เป็นเจ้าของค่าเหล่านั้น ในโค้ดตัวอย่างที่ 13-17 เราได้แสดงการประยุกต์ใช้ฟังก์ชัน Config::build ซ้ำอีกครั้งตามที่เคยอยู่ในโค้ดตัวอย่างที่ 12-23

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ในเวลานั้น เราได้บอกว่าอย่าเพิ่งกังวลเกี่ยวกับการเรียก clone ที่ไม่มีประสิทธิภาพ เพราะเราจะถอดออกในอนาคต และตอนนี้เวลานั้นก็มาถึงแล้ว!

เราจำเป็นต้องใช้ clone ในจุดนี้เพราะเรามี slice ที่มีสมาชิกเป็น String ในพารามิเตอร์ args แต่ฟังก์ชัน build ไม่ได้เป็นเจ้าของ args การที่จะคืนความเป็นเจ้าของของอินสแตนซ์ Config ออกไป เราต้องทำสำเนาค่าจากฟิลด์ query และ file_path ของ Config เพื่อให้อินสแตนซ์ Config สามารถครอบครองความเป็นเจ้าของค่าของมันเองได้

ด้วยความรู้ใหม่เกี่ยวกับตัวซ้ำ เราสามารถเปลี่ยนฟังก์ชัน build ให้รับความเป็นเจ้าของของตัวซ้ำเป็นอาร์กิวเมนต์แทนที่จะเป็นการยืม slice เราจะใช้ฟังก์ชันการทำงานของตัวซ้ำแทนโค้ดที่ตรวจสอบความยาวของ slice และเข้าถึงดัชนีในตำแหน่งที่ระบุ ซึ่งจะช่วยให้สิ่งที่ฟังก์ชัน Config::build ทำมีความชัดเจนยิ่งขึ้นเพราะตัวซ้ำจะเป็นผู้เข้าถึงค่าต่าง ๆ เอง

เมื่อ Config::build รับความเป็นเจ้าของของตัวซ้ำและเลิกใช้การดำเนินการเข้าถึงดัชนีที่ทำการยืมแล้ว เราจะสามารถย้ายค่า String จากตัวซ้ำเข้าไปใน Config ได้โดยตรง แทนที่จะต้องเรียก clone และสร้างการจัดสรรหน่วยความจำ (allocation) ใหม่

การใช้ตัวซ้ำที่ถูกคืนกลับมาโดยตรง

เปิดไฟล์ src/main.rs ของโปรเจกต์ I/O ของคุณ ซึ่งควรจะดูเป็นดังนี้:

Filename: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

อันดับแรก เราจะเปลี่ยนส่วนเริ่มต้นของฟังก์ชัน main ที่เรามีในโค้ดตัวอย่างที่ 12-24 เป็นโค้ดในโค้ดตัวอย่างที่ 13-18 ซึ่งในครั้งนี้จะใช้ตัวซ้ำ โค้ดนี้ยังไม่สามารถคอมไพล์ได้จนกว่าเราจะอัปเดต Config::build ด้วยเช่นกัน

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

ฟังก์ชัน env::args จะคืนค่าเป็นตัวซ้ำ! แทนที่จะรวบรวมค่าของตัวซ้ำลงในเวกเตอร์แล้วส่ง slice ให้กับ Config::build ตอนนี้เราส่งต่อความเป็นเจ้าของของตัวซ้ำที่ได้จาก env::args ให้กับ Config::build โดยตรง

ถัดไป เราจำเป็นต้องอัปเดตนิยามของ Config::build มาเปลี่ยนลายเซ็น (signature) ของ Config::build ให้ดูเหมือนโค้ดตัวอย่างที่ 13-19 โค้ดนี้ยังคงไม่สามารถคอมไพล์ได้เพราะเราต้องอัปเดตเนื้อหาของฟังก์ชันด้วย

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

เอกสารไลบรารีมาตรฐานสำหรับฟังก์ชัน env::args แสดงให้เห็นว่าชนิดข้อมูลของตัวซ้ำที่มันคืนค่าคือ std::env::Args และชนิดข้อมูลนั้นประยุกต์ใช้เทรต Iterator และคืนค่าเป็น String

เราได้อัปเดตลายเซ็นของฟังก์ชัน Config::build เพื่อให้พารามิเตอร์ args มีชนิดข้อมูลทั่วไป (generic type) พร้อมขอบเขตของเทรต (trait bound) คือ impl Iterator<Item = String> แทนที่จะเป็น &[String] การใช้ไวยากรณ์ impl Trait นี้ที่เราได้พูดถึงในหัวข้อ “การใช้เทรตเป็นพารามิเตอร์” ของบทที่ 10 หมายความว่า args สามารถเป็นชนิดข้อมูลใดก็ได้ที่ประยุกต์ใช้เทรต Iterator และคืนค่าสมาชิกเป็น String

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

การใช้เมธอดของเทรต Iterator

ถัดไป เราจะแก้ไขเนื้อหาของ Config::build เนื่องจาก args ประยุกต์ใช้เทรต Iterator เราจึงรู้ว่าเราสามารถเรียกใช้เมธอด next บนตัวมันได้! โค้ดตัวอย่างที่ 13-20 จะอัปเดตโค้ดจากโค้ดตัวอย่างที่ 12-23 ให้ใช้เมธอด next

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

โปรดจำไว้ว่าค่าแรกในค่าคืนกลับของ env::args คือชื่อของโปรแกรม เราต้องการข้ามค่านั้นและไปยังค่าถัดไป ดังนั้นอันดับแรกเราจึงเรียก next โดยไม่ต้องทำอะไรกับค่าที่ได้ จากนั้น เราเรียก next อีกครั้งเพื่อดึงค่าที่เราต้องการใส่ลงในฟิลด์ query ของ Config หาก next คืนค่า Some เราจะใช้ match เพื่อดึงค่านั้นออกมา แต่ถ้าคืนค่า None หมายความว่าให้พารามิเตอร์มาไม่เพียงพอ และเราจะคืนค่ากลับล่วงหน้าด้วยค่า Err เราทำเช่นเดียวกันสำหรับค่า file_path

การทำให้โค้ดชัดเจนขึ้นด้วยตัวปรับแต่งตัวซ้ำ

เรายังสามารถใช้ประโยชน์จากตัวซ้ำในฟังก์ชัน search ของโปรเจกต์ I/O ของเราได้ด้วย ซึ่งฉายซ้ำไว้ที่นี่ในโค้ดตัวอย่างที่ 13-21 ตามที่เคยเป็นในโค้ดตัวอย่างที่ 12-19

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

เราสามารถเขียนโค้ดนี้ให้กระชับยิ่งขึ้นโดยใช้เมธอดตัวปรับแต่งตัวซ้ำ การทำเช่นนี้ยังช่วยให้เราหลีกเลี่ยงการมีเวกเตอร์ชั่วคราวแบบแก้ไขได้อย่าง results รูปแบบการเขียนโปรแกรมเชิงฟังก์ชัน (functional programming style) นิยมที่จะลดการมีสถานะที่แก้ไขได้ให้น้อยที่สุดเพื่อให้โค้ดชัดเจนขึ้น การถอดสถานะที่แก้ไขได้ออกอาจช่วยให้สามารถปรับปรุงในอนาคตเพื่อทำให้การค้นหาเกิดขึ้นแบบขนาน (in parallel) ได้ เพราะเราไม่ต้องจัดการการเข้าถึงเวกเตอร์ results พร้อมกันหลายเธรด โค้ดตัวอย่างที่ 13-22 แสดงการเปลี่ยนแปลงนี้

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

โปรดระลึกว่าจุดประสงค์ของฟังก์ชัน search คือการคืนค่าทุกบรรทัดใน contents ที่มี query บรรจุอยู่ คล้ายกับตัวอย่าง filter ในโค้ดตัวอย่างที่ 13-16 โค้ดนี้ใช้ตัวปรับแต่ง filter เพื่อเก็บเฉพาะบรรทัดที่ line.contains(query) คืนค่าเป็น true จากนั้นเรารวบรวมบรรทัดที่ตรงกันลงในอีกเวกเตอร์หนึ่งด้วย collect ง่ายขึ้นมาก! ลองเปลี่ยนไปใช้เมธอดตัวซ้ำในฟังก์ชัน search_case_insensitive ด้วยเช่นกันได้เลย

สำหรับการปรับปรุงเพิ่มเติมอีกขั้น ให้คืนค่าเป็นตัวซ้ำจากฟังก์ชัน search โดยการถอดการเรียก collect ออก และเปลี่ยนชนิดข้อมูลค่าคืนกลับเป็น impl Iterator<Item = &'a str> เพื่อให้ฟังก์ชันกลายเป็นตัวปรับแต่งตัวซ้ำ สังเกตว่าคุณจำเป็นต้องอัปเดตการทดสอบด้วย! ลองค้นหาในไฟล์ขนาดใหญ่โดยใช้เครื่องมือ minigrep ของคุณก่อนและหลังการเปลี่ยนแปลงนี้เพื่อสังเกตความแตกต่างของพฤติกรรม ก่อนการเปลี่ยนแปลงนี้ โปรแกรมจะไม่พิมพ์ผลลัพธ์ใด ๆ จนกว่าจะรวบรวมผลลัพธ์ทั้งหมดเสร็จสิ้น แต่หลังจากเปลี่ยนแปลง ผลลัพธ์จะถูกพิมพ์ทันทีที่พบบรรทัดที่ตรงกันแต่ละบรรทัด เนื่องจากลูป for ในฟังก์ชัน run สามารถใช้ประโยชน์จากความเกียจคร้าน (laziness) ของตัวซ้ำได้

การเลือกระหว่างลูปและตัวซ้ำ

คำถามถัดไปที่สมเหตุสมผลคือคุณควรเลือกรูปแบบใดในโค้ดของคุณเองและเพราะเหตุใด: ระหว่างการประยุกต์ใช้แบบดั้งเดิมในโค้ดตัวอย่างที่ 13-21 หรือเวอร์ชันที่ใช้ตัวซ้ำในโค้ดตัวอย่างที่ 13-22 (โดยสมมติว่าเรารวบรวมผลลัพธ์ทั้งหมดก่อนจะคืนค่ากลับแทนที่จะคืนค่าตัวซ้ำออกมา) โปรแกรมเมอร์ Rust ส่วนใหญ่นิยมใช้รูปแบบตัวซ้ำ มันอาจจะเข้าใจยากกว่าเล็กน้อยในตอนแรก แต่เมื่อคุณคุ้นเคยกับตัวปรับแต่งตัวซ้ำแบบต่าง ๆ และสิ่งที่พวกมันทำแล้ว ตัวซ้ำอาจจะเข้าใจได้ง่ายกว่า แทนที่จะต้องยุ่งกับรายละเอียดของการลูปและการสร้างเวกเตอร์ใหม่ โค้ดจะมุ่งเน้นไปที่เป้าหมายระดับสูงของลูป สิ่งนี้จะช่วยซ่อนโค้ดพื้นฐานทั่วไปเพื่อให้อ่านแนวคิดที่เป็นเอกลักษณ์ของโค้ดนี้ได้ง่ายขึ้น เช่น เงื่อนไขการกรองที่สมาชิกแต่ละตัวในตัวซ้ำต้องผ่าน

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

Performance in Loops vs. Iterators

ประสิทธิภาพของการใช้ลูปเทียบกับตัวซ้ำ (Performance in Loops vs. Iterators)

เพื่อที่จะตัดสินใจว่าควรใช้ลูปหรือตัวซ้ำ คุณจำเป็นต้องรู้ว่าการประยุกต์ใช้งานแบบใดเร็วกว่ากัน ระหว่างฟังก์ชัน search เวอร์ชันที่ใช้ลูป for แบบชัดเจน กับเวอร์ชันที่ใช้ตัวซ้ำ

เราได้ทดสอบประสิทธิภาพ (benchmark) โดยการโหลดเนื้อหาทั้งหมดของหนังสือ The Adventures of Sherlock Holmes โดย Sir Arthur Conan Doyle เข้าไปใน String และค้นหาคำว่า the ในเนื้อหานั้น นี่คือผลการทดสอบประสิทธิภาพของฟังก์ชัน search เวอร์ชันที่ใช้ลูป for และเวอร์ชันที่ใช้ตัวซ้ำ:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

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

สำหรับการทดสอบประสิทธิภาพที่ครอบคลุมยิ่งขึ้น คุณควรทดสอบโดยใช้ข้อความต่าง ๆ ที่มีขนาดหลากหลายเป็น contents ใช้คำต่าง ๆ และความยาวคำที่แตกต่างกันเป็น query รวมถึงรูปแบบการทดสอบอื่น ๆ อีกมากมาย แต่ประเด็นสำคัญคือ: ตัวซ้ำแม้จะเป็นนามธรรมระดับสูง (high-level abstraction) แต่ก็ถูกคอมไพล์ลงไปเป็นโค้ดในระดับเดียวกับที่คุณเขียนโค้ดระดับต่ำด้วยตัวเอง ตัวซ้ำเป็นหนึ่งในนามธรรมไร้ต้นทุน (zero-cost abstractions) ของ Rust ซึ่งหมายความว่าการใช้นามธรรมนี้จะไม่สร้างภาระส่วนเกิน (overhead) เพิ่มเติมในการทำงานขณะรันไทม์ เรื่องนี้คล้ายคลึงกับวิธีที่ Bjarne Stroustrup ผู้ออกแบบและผู้ประยุกต์ใช้งาน C++ คนแรก ได้ให้นิยามคำว่าภาระส่วนเกินเป็นศูนย์ไว้ในการนำเสนอ ETAPS keynote ปี 2012 หัวข้อ “Foundations of C++”:

โดยทั่วไปแล้ว การประยุกต์ใช้งานใน C++ จะปฏิบัติตามหลักการภาระส่วนเกินเป็นศูนย์ (zero-overhead principle): สิ่งที่คุณไม่ได้ใช้ คุณจะไม่ต้องจ่าย (ภาระส่วนเกิน) และยิ่งไปกว่านั้น: สิ่งที่คุณนำมาใช้งาน คุณก็ไม่สามารถเขียนโค้ดด้วยมือให้ดีไปกว่านี้ได้แล้ว

ในหลาย ๆ กรณี โค้ด Rust ที่ใช้ตัวซ้ำจะถูกคอมไพล์เป็นภาษาแอสเซมบลี (assembly) เดียวกับที่คุณจะเขียนด้วยมือ การปรับปรุงประสิทธิภาพ (optimizations) เช่น การคลี่ลูป (loop unrolling) และการยกเลิกการตรวจสอบขอบเขต (bounds checking) ในการเข้าถึงอาเรย์จะถูกนำมาใช้ และทำให้โค้ดผลลัพธ์มีประสิทธิภาพสูงอย่างยิ่ง ตอนนี้เมื่อคุณทราบเรื่องนี้แล้ว คุณก็สามารถใช้ตัวซ้ำและโคลเชอร์ได้อย่างหมดกังวล! พวกมันทำให้โค้ดดูเหมือนอยู่ในระดับที่สูงขึ้น แต่ไม่ได้สร้างภาระต่อประสิทธิภาพขณะรันไทม์เลย

สรุป

โคลเชอร์และตัวซ้ำเป็นฟีเจอร์ของ Rust ที่ได้รับแรงบันดาลใจมาจากแนวคิดของภาษาโปรแกรมเชิงฟังก์ชัน (functional programming languages) พวกมันมีส่วนช่วยให้ Rust มีความสามารถในการสื่อสารแนวคิดระดับสูงได้อย่างชัดเจนโดยได้ประสิทธิภาพในระดับต่ำ การประยุกต์ใช้งานโคลเชอร์และตัวซ้ำได้รับการออกแบบมาจนประสิทธิภาพขณะรันไทม์ไม่ได้รับผลกระทบใด ๆ ซึ่งเป็นส่วนหนึ่งของเป้าหมายของ Rust ที่มุ่งมั่นจะมอบนามธรรมไร้ต้นทุน (zero-cost abstractions) ให้แก่นักพัฒนา

ตอนนี้เมื่อเราได้ปรับปรุงความสามารถในการแสดงออกทางโค้ดของโปรเจกต์ I/O ของเราแล้ว เรามาดูฟีเจอร์เพิ่มเติมของ cargo ที่จะช่วยให้เราแบ่งปันโปรเจกต์กับโลกภายนอกได้กันเถอะ

ข้อมูลเพิ่มเติมเกี่ยวกับ Cargo และ Crates.io

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

  • ปรับแต่งการบิลด์ของคุณผ่านโปรไฟล์การรีลีส (release profiles)
  • เผยแพร่ไลบรารีบน crates.io
  • จัดระเบียบโปรเจกต์ขนาดใหญ่ด้วยพื้นที่ทำงาน (workspaces)
  • ติดตั้งไฟล์ไบนารีจาก crates.io
  • ขยายความสามารถของ Cargo โดยใช้คำสั่งกำหนดเอง (custom commands)

Cargo สามารถทำสิ่งต่าง ๆ ได้มากกว่าฟังก์ชันการทำงานที่เราครอบคลุมในบทนี้ ดังนั้นสำหรับคำอธิบายฟีเจอร์ทั้งหมดอย่างสมบูรณ์ สามารถดูได้ที่ เอกสารของ Cargo

Customizing Builds with Release Profiles

การปรับแต่งการบิลด์ด้วยโปรไฟล์การรีลีส (Release Profiles)

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

Cargo มีโปรไฟล์หลัก 2 โปรไฟล์ คือ โปรไฟล์ dev ซึ่ง Cargo จะใช้งานเมื่อคุณรัน cargo build และโปรไฟล์ release ซึ่ง Cargo จะใช้งานเมื่อคุณรัน cargo build --release โปรไฟล์ dev ถูกกำหนดไว้พร้อมค่าเริ่มต้นที่ดีสำหรับการพัฒนา และโปรไฟล์ release ก็มีค่าเริ่มต้นที่ดีสำหรับการบิลด์เพื่อรีลีสใช้งานจริง

ชื่อโปรไฟล์เหล่านี้อาจจะคุ้นเคยจากผลลัพธ์การบิลด์ของคุณ:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

dev และ release เป็นโปรไฟล์ที่แตกต่างกันซึ่งถูกใช้งานโดยคอมไพเลอร์

Cargo มีการตั้งค่าเริ่มต้นสำหรับแต่ละโปรไฟล์ ซึ่งจะถูกนำมาใช้เมื่อคุณไม่ได้เพิ่มส่วน [profile.*] ลงในไฟล์ Cargo.toml ของโปรเจกต์อย่างชัดเจน การเพิ่มส่วน [profile.*] สำหรับโปรไฟล์ที่คุณต้องการปรับแต่ง จะเป็นการเขียนทับ (override) ส่วนย่อยของการตั้งค่าเริ่มต้น ตัวอย่างเช่น นี่คือค่าเริ่มต้นของตั้งค่า opt-level สำหรับโปรไฟล์ dev และ release:

ชื่อไฟล์: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

การตั้งค่า opt-level จะควบคุมจำนวนการปรับปรุงประสิทธิภาพ (optimizations) ที่ Rust จะนำมาใช้กับโค้ดของคุณ โดยมีช่วงตั้งแต่ 0 ถึง 3 การใช้นวัตกรรมการปรับปรุงประสิทธิภาพที่มากขึ้นจะเพิ่มเวลาในการคอมไพล์ ดังนั้น หากคุณอยู่ในช่วงการพัฒนาและคอมไพล์โค้ดบ่อยครั้ง คุณจะต้องการการปรับปรุงประสิทธิภาพที่น้อยลงเพื่อให้คอมไพล์ได้เร็วขึ้น แม้ว่าโค้ดผลลัพธ์จะทำงานช้าลงก็ตาม ค่าเริ่มต้นของ opt-level สำหรับ dev จึงเป็น 0 เมื่อคุณพร้อมที่จะรีลีสโค้ดของคุณ การใช้เวลาคอมไพล์มากขึ้นจะเป็นเรื่องที่ดีที่สุด เพราะคุณจะคอมไพล์ในโหมดรีลีสเพียงครั้งเดียว แต่จะรันโปรแกรมที่คอมไพล์แล้วหลายครั้ง โหมดรีลีสจึงยอมแลกเวลาคอมไพล์ที่นานขึ้นเพื่อให้โค้ดทำงานได้เร็วขึ้น นั่นคือเหตุผลที่ค่าเริ่มต้นของ opt-level สำหรับโปรไฟล์ release จึงเป็น 3

คุณสามารถเขียนทับการตั้งค่าเริ่มต้นได้โดยการเพิ่มค่าอื่นสำหรับมันใน Cargo.toml ตัวอย่างเช่น หากเราต้องการใช้ระดับการปรับปรุงประสิทธิภาพที่ 1 ในโปรไฟล์การพัฒนา เราสามารถเพิ่มสองบรรทัดนี้ลงในไฟล์ Cargo.toml ของโปรเจกต์ของเรา:

ชื่อไฟล์: Cargo.toml

[profile.dev]
opt-level = 1

โค้ดนี้จะเขียนทับการตั้งค่าเริ่มต้นที่เป็น 0 ตอนนี้เมื่อเรารัน cargo build Cargo จะใช้ค่าเริ่มต้นสำหรับโปรไฟล์ dev บวกกับการปรับแต่ง opt-level ของเรา เนื่องจากเราตั้งค่า opt-level เป็น 1 Cargo จึงจะใช้นวัตกรรมการปรับปรุงประสิทธิภาพมากกว่าค่าเริ่มต้น แต่จะไม่มากเท่ากับในโหมดบิลด์รีลีส

สำหรับรายการตัวเลือกการตั้งค่าทั้งหมดและค่าเริ่มต้นสำหรับแต่ละโปรไฟล์ โปรดดูที่ เอกสารของ Cargo

Publishing a Crate to Crates.io

การเผยแพร่ Crate ไปยัง Crates.io

เราได้ใช้แพ็กเกจจาก crates.io เป็นทรัพยากรภายนอก (dependencies) ในโปรเจกต์ของเรา แต่นอกจากนี้คุณยังสามารถแบ่งปันโค้ดของคุณให้กับผู้อื่นได้โดยการเผยแพร่แพ็กเกจของคุณเอง คลังเก็บ crate (crate registry) ที่ crates.io จะแจกจ่ายซอร์สโค้ดของแพ็กเกจของคุณ ดังนั้นมันจึงโฮสต์โค้ดที่เป็นโอเพนซอร์สเป็นหลัก

Rust และ Cargo มีฟีเจอร์ที่ช่วยให้แพ็กเกจที่คุณเผยแพร่ถูกค้นหาและใช้งานได้ง่ายขึ้นสำหรับผู้อื่น เราจะพูดถึงฟีเจอร์เหล่านี้ในหัวข้อถัดไป จากนั้นจะอธิบายวิธีเผยแพร่แพ็กเกจ

การเขียนคอมเมนต์เอกสารที่เป็นประโยชน์

การเขียนเอกสารอธิบายแพ็กเกจของคุณอย่างถูกต้องจะช่วยให้ผู้ใช้คนอื่น ๆ ทราบวิธีและเวลาที่ควรนำไปใช้งาน ดังนั้นจึงคุ้มค่าที่จะลงทุนเวลาในการเขียนเอกสาร ในบทที่ 3 เราได้พูดถึงการคอมเมนต์โค้ด Rust โดยใช้เครื่องหมายทับสองอัน // นอกจากนี้ Rust ยังมีคอมเมนต์ชนิดพิเศษสำหรับการทำเอกสารประกอบ ซึ่งรู้จักกันในชื่อ คอมเมนต์เอกสาร (documentation comment) ซึ่งจะสร้างเอกสารในรูปแบบ HTML ออกมา โดย HTML จะแสดงเนื้อหาของคอมเมนต์เอกสารสำหรับรายการ Public API ที่มีไว้สำหรับโปรแกรมเมอร์ที่สนใจต้องการทราบวิธีใช้งาน crate ของคุณ ซึ่งต่างจากวิธีที่ crate ของคุณถูกประยุกต์ใช้งาน (implemented) ภายใน

คอมเมนต์เอกสารจะใช้เครื่องหมายทับสามอัน /// แทนที่จะเป็นสองอัน และรองรับไวยากรณ์ Markdown สำหรับการจัดรูปแบบข้อความ โดยวางคอมเมนต์เอกสารไว้หน้ารายการที่ต้องการทำเอกสารประกอบทันที โค้ดตัวอย่างที่ 14-1 แสดงคอมเมนต์เอกสารสำหรับฟังก์ชัน add_one ใน crate ที่ชื่อว่า my_crate

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

ในที่นี้ เราให้คำอธิบายว่าฟังก์ชัน add_one ทำหน้าที่อะไร เริ่มต้นหัวข้อด้วยหัวเรื่อง Examples แล้วจัดเตรียมโค้ดที่สาธิตวิธีใช้งานฟังก์ชัน add_one เราสามารถสร้างเอกสาร HTML จากคอมเมนต์เอกสารนี้ได้โดยการรันคำสั่ง cargo doc คำสั่งนี้จะรันเครื่องมือ rustdoc ที่แจกจ่ายมาพร้อมกับ Rust และวางเอกสาร HTML ที่สร้างขึ้นไว้ในไดเรกทอรี target/doc

เพื่อความสะดวก การรันคำสั่ง cargo doc --open จะบิลด์ HTML สำหรับเอกสารประกอบของ crate ปัจจุบันของคุณ (รวมถึงเอกสารประกอบสำหรับไลบรารีภายนอกทั้งหมดของ crate คุณ) และเปิดผลลัพธ์ในเว็บเบราว์เซอร์ เมื่อนำทางไปยังฟังก์ชัน add_one คุณจะเห็นว่าข้อความในคอมเมนต์เอกสารนั้นถูกแสดงผลอย่างไร ดังแสดงในรูปภาพที่ 14-1

เอกสาร HTML ที่ถูกแสดงผลสำหรับฟังก์ชัน add_one ของ my_crate

รูปภาพที่ 14-1: เอกสาร HTML สำหรับฟังก์ชัน add_one

หัวข้อที่นิยมใช้งานบ่อย

เราใช้หัวเรื่อง Markdown # Examples ในโค้ดตัวอย่างที่ 14-1 เพื่อสร้างหัวข้อใน HTML ที่มีชื่อว่า “Examples” นี่คือหัวข้ออื่น ๆ ที่ผู้เขียน crate นิยมใช้กันบ่อยในเอกสารประกอบ:

  • Panics: สถานการณ์ที่ฟังก์ชันซึ่งถูกเขียนเอกสารประกอบไว้อาจเกิดตื่นตระหนก (panic) ขึ้นได้ ผู้เรียกใช้ฟังก์ชันที่ไม่ต้องการให้โปรแกรมของตนตื่นตระหนกควรตรวจสอบให้แน่ใจว่าตนเองไม่ได้เรียกใช้ฟังก์ชันในสถานการณ์เหล่านี้
  • Errors: หากฟังก์ชันคืนค่าเป็น Result การอธิบายชนิดของข้อผิดพลาดที่อาจเกิดขึ้น และเงื่อนไขใดที่อาจทำให้เกิดข้อผิดพลาดเหล่านั้น คืนกลับไป จะมีประโยชน์ต่อผู้เรียกใช้เพื่อให้พวกเขาสามารถเขียนโค้ดจัดการข้อผิดพลาดประเภทต่าง ๆ ในรูปแบบที่แตกต่างกันได้
  • Safety: หากฟังก์ชันนั้นเป็นแบบ unsafe ที่จะเรียกใช้ (เราจะพูดถึงความไม่ปลอดภัยในบทที่ 20) ควรมีหัวข้อที่อธิบายว่าเหตุใดฟังก์ชันจึงไม่ปลอดภัย และครอบคลุมถึงเงื่อนไขคงที่ (invariants) ที่ฟังก์ชันคาดหวังให้ผู้เรียกปฏิบัติตาม

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

คอมเมนต์เอกสารทำหน้าที่เป็นแบบทดสอบ

การเพิ่มบล็อกโค้ดตัวอย่างในคอมเมนต์เอกสารของคุณจะช่วยสาธิตวิธีใช้งานไลบรารีของคุณ และมีโบนัสเพิ่มเติมคือ: การรัน cargo test จะรันโค้ดตัวอย่างในเอกสารประกอบของคุณเป็นการทดสอบด้วย! ไม่มีอะไรดีไปกว่าเอกสารที่มีตัวอย่าง แต่ก็ไม่มีอะไรแย่ไปกว่าตัวอย่างที่ไม่ทำงานเพราะโค้ดเปลี่ยนไปนับตั้งแต่เขียนเอกสาร หากเรารัน cargo test กับเอกสารประกอบสำหรับฟังก์ชัน add_one จากโค้ดตัวอย่างที่ 14-1 เราจะเห็นส่วนในผลลัพธ์การทดสอบที่ดูเป็นดังนี้:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

ตอนนี้ หากเราเปลี่ยนฟังก์ชัน หรือเปลี่ยนตัวอย่างจนกระทั่ง assert_eq! ในตัวอย่างเกิด panic ขึ้น และรัน cargo test อีกครั้ง เราจะเห็นว่าการทดสอบ doc tests ช่วยตรวจจับได้ว่าตัวอย่างและโค้ดนั้นไม่ตรงกันแล้ว!

คอมเมนต์ครอบคลุมรายการภายใน

รูปแบบคอมเมนต์เอกสาร //! จะเพิ่มเอกสารประกอบให้กับรายการที่บรรจุคอมเมนต์นั้นไว้ แทนที่จะเป็นรายการที่ตามหลังคอมเมนต์ โดยทั่วไปเราจะใช้คอมเมนต์เอกสารชนิดนี้ภายในไฟล์รากของ crate (โดยธรรมเนียมคือ src/lib.rs) หรือภายในมอดูลเพื่อทำเอกสารประกอบให้กับ crate หรือมอดูลโดยรวม

ตัวอย่างเช่น หากต้องการเพิ่มเอกสารที่อธิบายวัตถุประสงค์ของ crate my_crate ที่บรรจุฟังก์ชัน add_one เราจะเพิ่มคอมเมนต์เอกสารที่เริ่มต้นด้วย //! ไว้ที่จุดเริ่มต้นของไฟล์ src/lib.rs ดังแสดงในโค้ดตัวอย่างที่ 14-2

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

สังเกตว่าไม่มีโค้ดใด ๆ ตามหลังบรรทัดสุดท้ายที่เริ่มต้นด้วย //! เนื่องจากเราเริ่มต้นคอมเมนต์ด้วย //! แทนที่จะเป็น /// เราจึงกำลังเขียนเอกสารอธิบายรายการที่บรรจุคอมเมนต์นี้ไว้ แทนที่จะเป็นรายการที่ตามหลังคอมเมนต์นี้ ซึ่งในกรณีนี้ รายการนั้นคือไฟล์ src/lib.rs ซึ่งเป็นรากของ crate (crate root) คอมเมนต์เหล่านี้จึงอธิบายทั้ง crate โดยรวม

เมื่อเรารัน cargo doc --open คอมเมนต์เหล่านี้จะแสดงผลในหน้าแรกของเอกสารประกอบสำหรับ my_crate เหนือรายการที่เป็น public ใน crate ดังแสดงในรูปภาพที่ 14-2

คอมเมนต์เอกสารภายในรายการมีประโยชน์อย่างยิ่งสำหรับการอธิบาย crate และมอดูล ใช้พวกมันเพื่ออธิบายวัตถุประสงค์โดยรวมของตัวบรรจุ (container) เพื่อช่วยให้ผู้ใช้ของคุณเข้าใจการจัดระเบียบของ crate

เอกสาร HTML ที่ถูกแสดงผลพร้อมคอมเมนต์สำหรับ crate โดยรวม

รูปภาพที่ 14-2: เอกสารที่ถูกแสดงผลสำหรับ my_crate รวมถึงคอมเมนต์อธิบาย crate โดยรวม

การส่งออก Public API ที่สะดวกต่อการใช้งาน

โครงสร้างของ Public API ของคุณคือข้อพิจารณาที่สำคัญเมื่อจะเผยแพร่ crate ผู้ที่ใช้งาน crate ของคุณจะคุ้นเคยกับโครงสร้างน้อยกว่าคุณ และอาจมีปัญหาในการค้นหาส่วนประกอบที่ตนเองต้องการใช้หาก crate ของคุณมีลำดับชั้นมอดูลขนาดใหญ่

ในบทที่ 7 เราได้พูดถึงการทำรายการให้เป็น public โดยใช้คีย์เวิร์ด pub และการนำรายการเข้ามาใน scope โดยใช้คีย์เวิร์ด use อย่างไรก็ตาม โครงสร้างที่ดูสมเหตุสมผลสำหรับคุณในขณะพัฒนา crate อาจจะไม่ค่อยสะดวกสำหรับผู้ใช้งานของคุณ คุณอาจต้องการจัดระเบียบ struct ไว้ในลำดับชั้นที่มีหลายระดับ แต่ผู้ที่ต้องการใช้ชนิดข้อมูลที่คุณนิยามไว้ลึกลงไปในลำดับชั้นอาจจะพบลำบากว่าชนิดข้อมูลนั้นมีอยู่ นอกจากนี้ พวกเขาอาจจะรู้สึกรำคาญใจที่ต้องพิมพ์ use my_crate::some_module::another_module::UsefulType; แทนที่จะเป็น use my_crate::UsefulType;

ข่าวดีก็คือหากโครงสร้างไม่ได้สะดวกสำหรับผู้อื่นในการใช้งานจากไลบรารีอื่น คุณไม่จำเป็นต้องจัดระเบียบโครงสร้างภายในใหม่ แต่คุณสามารถส่งออกรายการใหม่อีกครั้ง (re-export) เพื่อสร้างโครงสร้าง public ที่แตกต่างจากโครงสร้าง private ภายในของคุณได้โดยใช้ pub use การส่งออกใหม่อีกครั้ง (re-exporting) จะนำรายการที่เป็น public ในตำแหน่งหนึ่ง มาทำให้เป็น public ในอีกตำแหน่งหนึ่ง เสมือนว่ามันถูกนิยามไว้ในอีกตำแหน่งนั้นแทน

ตัวอย่างเช่น สมมติว่าเราสร้างไลบรารีชื่อ art สำหรับจำลองแนวคิดทางศิลปะ ภายในไลบรารีนี้มี 2 มอดูล ได้แก่ มอดูล kinds ซึ่งบรรจุ enum 2 ตัวชื่อ PrimaryColor และ SecondaryColor และมอดูล utils ซึ่งบรรจุฟังก์ชันชื่อ mix ดังแสดงในโค้ดตัวอย่างที่ 14-3

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}

รูปภาพที่ 14-3 แสดงว่าหน้าแรกของเอกสารสำหรับ crate นี้ที่สร้างโดย cargo doc จะมีลักษณะอย่างไร

เอกสารที่ถูกแสดงผลสำหรับ crate art ที่แสดงมอดูล kinds และ utils

รูปภาพที่ 14-3: หน้าแรกของเอกสารสำหรับ art ที่แสดงมอดูล kinds และ utils

สังเกตว่าชนิดข้อมูล PrimaryColor และ SecondaryColor ไม่ได้แสดงอยู่ในหน้าแรก และฟังก์ชัน mix ก็เช่นกัน เราต้องคลิก kinds และ utils เพื่อเข้าไปดูพวกมัน

อีก crate หนึ่งที่พึ่งพาไลบรารีนี้จะต้องมีคำสั่ง use ที่นำรายการจาก art เข้ามาใน scope โดยระบุโครงสร้างมอดูลตามที่ถูกนิยามไว้ในปัจจุบัน โค้ดตัวอย่างที่ 14-4 แสดงตัวอย่าง crate ที่ใช้รายการ PrimaryColor และ mix จาก crate art

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

ผู้เขียนโค้ดในโค้ดตัวอย่างที่ 14-4 ซึ่งใช้ crate art ต้องค้นหาว่า PrimaryColor อยู่ในมอดูล kinds และ mix อยู่ในมอดูล utils โครงสร้างมอดูลของ crate art นั้นมีความเกี่ยวข้องกับนักพัฒนาที่ทำงานพัฒนา crate art มากกว่าผู้ที่นำไปใช้งาน โครงสร้างภายในไม่ได้บรรจุข้อมูลที่มีประโยชน์สำหรับผู้ที่พยายามทำความเข้าใจวิธีใช้งาน crate art แต่มักจะสร้างความสับสนเนื่องจากนักพัฒนาที่นำไปใช้ต้องค้นหาว่าจะดูได้ที่ไหน และต้องระบุชื่อมอดูลในคำสั่ง use

ในการถอดการจัดระเบียบภายในออกจาก Public API เราสามารถแก้ไขโค้ด crate art ในโค้ดตัวอย่างที่ 14-3 โดยเพิ่มคำสั่ง pub use เพื่อส่งออกรายการใหม่อีกครั้งที่ระดับบนสุด ดังแสดงในโค้ดตัวอย่างที่ 14-5

//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}

เอกสาร API ที่ cargo doc สร้างขึ้นสำหรับ crate นี้จะแสดงรายการและลิงก์การส่งออกใหม่อีกครั้งบนหน้าแรกแล้ว ดังแสดงในรูปภาพที่ 14-4 ซึ่งช่วยให้ชนิดข้อมูล PrimaryColor และ SecondaryColor รวมถึงฟังก์ชัน mix ค้นหาได้ง่ายขึ้นมาก

เอกสารที่ถูกแสดงผลสำหรับ crate art พร้อมการส่งออกใหม่อีกครั้งบนหน้าแรก

รูปภาพที่ 14-4: หน้าแรกของเอกสารสำหรับ art ที่แสดงรายการที่ถูกส่งออกใหม่อีกครั้ง

ผู้ใช้ crate art ยังคงสามารถมองเห็นและใช้โครงสร้างภายในจากโค้ดตัวอย่างที่ 14-3 ตามที่สาธิตในโค้ดตัวอย่างที่ 14-4 ได้ หรือสามารถใช้โครงสร้างที่สะดวกกว่าในโค้ดตัวอย่างที่ 14-5 ดังแสดงในโค้ดตัวอย่างที่ 14-6

use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

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

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

การตั้งค่าบัญชี Crates.io

ก่อนที่คุณจะสามารถเผยแพร่ crate ใด ๆ ได้ คุณจะต้องสร้างบัญชีบน crates.io และรับโทเค็น API ก่อน ในการทำเช่นนั้น ให้เยี่ยมชมหน้าแรกที่ crates.io แล้วเข้าสู่ระบบผ่านบัญชี GitHub (ขณะนี้จำเป็นต้องมีบัญชี GitHub แต่ในอนาคตเว็บไซต์อาจรองรับวิธีสร้างบัญชีแบบอื่น) เมื่อเข้าสู่ระบบเรียบร้อยแล้ว ให้ไปที่การตั้งค่าบัญชีของคุณที่ https://crates.io/me/ แล้วคัดลอกคีย์ API ของคุณ จากนั้น รันคำสั่ง cargo login แล้ววางคีย์ API เมื่อได้รับแจ้ง ดังนี้:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

คำสั่งนี้จะแจ้ง Cargo ถึงโทเค็น API ของคุณและจัดเก็บไว้ในเครื่องที่ ~/.cargo/credentials.toml โปรดจำไว้ว่าโทเค็นนี้เป็นความลับ: ห้ามแบ่งปันให้ผู้อื่นเด็ดขาด หากคุณแบ่งปันให้ใครด้วยเหตุผลใดก็ตาม คุณควรรีบยกเลิกโทเค็นนั้นและสร้างโทเค็นใหม่บน crates.io

การเพิ่มข้อมูลอธิบาย (Metadata) ลงใน Crate ใหม่

สมมติว่าคุณมี crate ที่ต้องการเผยแพร่ ก่อนที่จะเผยแพร่ คุณจะต้องเพิ่มข้อมูลอธิบาย (metadata) บางอย่างลงในส่วน [package] ของไฟล์ Cargo.toml ใน crate นั้น

crate ของคุณจะต้องมีชื่อที่ไม่ซ้ำใคร ขณะที่คุณทำงานกับ crate ในเครื่อง คุณสามารถตั้งชื่อ crate เป็นอะไรก็ได้ตามต้องการ อย่างไรก็ตาม ชื่อ crate บน crates.io จะถูกจัดสรรตามลำดับมาก่อนได้ก่อน (first-come, first-served) เมื่อมีผู้ใช้ชื่อ crate ใดไปแล้ว จะไม่มีใครสามารถเผยแพร่ crate ด้วยชื่อนั้นได้อีก ก่อนที่จะพยายามเผยแพร่ crate ให้ค้นหาชื่อที่คุณต้องการใช้ หากชื่อนั้นถูกใช้ไปแล้ว คุณจะต้องหาชื่ออื่นและแก้ไขฟิลด์ name ในไฟล์ Cargo.toml ภายใต้ส่วน [package] เพื่อใช้ชื่อใหม่สำหรับการเผยแพร่ ดังนี้:

ชื่อไฟล์: Cargo.toml

[package]
name = "guessing_game"

แม้ว่าคุณจะเลือกชื่อที่ไม่ซ้ำใครแล้ว แต่เมื่อคุณรัน cargo publish เพื่อเผยแพร่ crate ในจุดนี้ คุณจะได้คำเตือนแล้วตามด้วยข้อผิดพลาด:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

ผลลัพธ์นี้ทำให้เกิดข้อผิดพลาดเพราะคุณขาดข้อมูลสำคัญบางประการ: จำเป็นต้องมีคำอธิบาย (description) และใบอนุญาต (license) เพื่อให้ผู้ใช้ทราบว่า crate ของคุณทำอะไรและอยู่ภายใต้เงื่อนไขใดที่พวกเขาสามารถนำไปใช้ได้ ใน Cargo.toml ให้เพิ่มคำอธิบายสั้น ๆ แค่ประโยคสองประโยค เพราะมันจะปรากฏร่วมกับ crate ของคุณในผลการค้นหา สำหรับฟิลด์ license คุณจะต้องให้ค่าระบุใบอนุญาต (license identifier value) โดยรายการ Software Package Data Exchange (SPDX) ของ Linux Foundation จะระบุค่านำทางที่คุณสามารถใช้ได้ สำหรับค่านี้ ตัวอย่างเช่น หากต้องการระบุว่าคุณให้ใบอนุญาต crate ของคุณโดยใช้ใบอนุญาต MIT ให้เพิ่มตัวระบุ MIT:

ชื่อไฟล์: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

หากคุณต้องการใช้ใบอนุญาตที่ไม่ปรากฏใน SPDX คุณต้องวางข้อความของใบอนุญาตนั้นไว้ในไฟล์ รวมไฟล์นั้นไว้ในโปรเจกต์ของคุณ แล้วใช้ license-file เพื่อระบุชื่อไฟล์นั้นแทนการใช้คีย์ license

คำแนะนำเกี่ยวกับใบอนุญาตใดที่เหมาะสมกับโปรเจกต์ของคุณอยู่นอกเหนือขอบเขตของหนังสือเล่มนี้ หลายคนในชุมชน Rust ให้ใบอนุญาตโปรเจกต์ของตนในลักษณะเดียวกับ Rust โดยใช้ใบอนุญาตแบบคู่คือ MIT OR Apache-2.0 การปฏิบัตินี้แสดงให้เห็นว่าคุณสามารถระบุตัวระบุใบอนุญาตหลายตัวแยกกันด้วย OR เพื่อมีหลายใบอนุญาตสำหรับโปรเจกต์ของคุณได้เช่นกัน

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

ชื่อไฟล์: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

เอกสารของ Cargo ได้อธิบายเกี่ยวกับข้อมูลอธิบายอื่น ๆ ที่คุณสามารถระบุเพื่อให้ผู้อื่นค้นพบและใช้ crate ของคุณได้ง่ายยิ่งขึ้น

การเผยแพร่ไปยัง Crates.io

ตอนนี้เมื่อคุณสร้างบัญชี บันทึกโทเค็น API เลือกชื่อสำหรับ crate ของคุณ และระบุข้อมูลอธิบายที่จำเป็นเรียบร้อยแล้ว คุณก็พร้อมที่จะเผยแพร่แล้ว! การเผยแพร่ crate จะอัปโหลดเวอร์ชันเฉพาะไปยัง crates.io เพื่อให้ผู้อื่นนำไปใช้งาน

โปรดใช้ความระมัดระวัง เพราะการเผยแพร่นั้นเป็นแบบถาวร (permanent) เวอร์ชันนั้นจะไม่สามารถถูกเขียนทับได้ และโค้ดไม่สามารถลบออกได้ เว้นแต่ในสถานการณ์บางอย่าง เป้าหมายหลักประการหนึ่งของ Crates.io คือการทำหน้าที่เป็นคลังจัดเก็บโค้ดแบบถาวร เพื่อให้การบิลด์ของทุกโปรเจกต์ที่พึ่งพา crate จาก crates.io ยังคงทำงานต่อไปได้ การอนุญาตให้ลบเวอร์ชันออกจะทำให้ไม่สามารถบรรลุเป้าหมายนั้นได้ อย่างไรก็ตาม ไม่มีข้อจำกัดเกี่ยวกับจำนวนเวอร์ชันของ crate ที่คุณสามารถเผยแพร่ได้

รันคำสั่ง cargo publish อีกครั้ง ครั้งนี้น่าจะสำเร็จแล้ว:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
    Packaged 6 files, 1.2KiB (895.0B compressed)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)
    Uploaded guessing_game v0.1.0 to registry `crates-io`
note: waiting for `guessing_game v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published guessing_game v0.1.0 at registry `crates-io`

ยินดีด้วย! ตอนนี้คุณได้แบ่งปันโค้ดของคุณกับชุมชน Rust เรียบร้อยแล้ว และทุกคนสามารถเพิ่ม crate ของคุณเป็นทรัพยากรภายนอกของโปรเจกต์ของตนได้อย่างง่ายดาย

การเผยแพร่เวอร์ชันใหม่ของ Crate ที่มีอยู่แล้ว

เมื่อคุณทำการเปลี่ยนแปลงใน crate ของคุณและพร้อมที่จะออกเวอร์ชันใหม่ คุณสามารถเปลี่ยนค่า version ที่ระบุไว้ในไฟล์ Cargo.toml ของคุณ แล้วทำการเผยแพร่อีกครั้ง ใช้ กฎการกำหนดเวอร์ชันตามความหมาย (Semantic Versioning rules) เพื่อตัดสินใจว่าหมายเลขเวอร์ชันถัดไปที่เหมาะสมคืออะไร โดยพิจารณาจากชนิดของการเปลี่ยนแปลงที่คุณได้ทำ จากนั้น รันคำสั่ง cargo publish เพื่ออัปโหลดเวอร์ชันใหม่

การยกเลิกใช้งานเวอร์ชัน (Yanking) บน Crates.io

แม้ว่าคุณจะไม่สามารถลบเวอร์ชันก่อนหน้าของ crate ออกได้ แต่คุณสามารถป้องกันไม่ให้โปรเจกต์ในอนาคตเพิ่มเวอร์ชันนั้นเป็นทรัพยากรภายนอกใหม่ได้ สิ่งนี้มีประโยชน์เมื่อเวอร์ชันของ crate เกิดปัญหาด้วยเหตุผลใดก็ตาม ในสถานการณ์เช่นนี้ Cargo รองรับการปลดดึงเวอร์ชัน (yanking) ของ crate

การปลดดึง (yanking) เวอร์ชัน จะป้องกันไม่ให้โปรเจกต์ใหม่ ๆ พึ่งพาเวอร์ชันนั้น ในขณะที่ยังคงอนุญาตให้โปรเจกต์เดิมทั้งหมดที่พึ่งพาเวอร์ชันนั้นสามารถทำงานต่อไปได้ โดยพื้นฐานแล้ว การปลดดึงหมายความว่าโปรเจกต์ทั้งหมดที่มีไฟล์ Cargo.lock จะไม่พัง และไฟล์ Cargo.lock ใด ๆ ที่สร้างขึ้นในอนาคตจะไม่ใช้เวอร์ชันที่ถูกปลดดึงไปแล้ว

ในการปลดดึงเวอร์ชันของ crate ให้ไปที่ไดเรกทอรีของ crate ที่คุณเคยเผยแพร่ไว้ก่อนหน้านี้ แล้วรันคำสั่ง cargo yank โดยระบุเวอร์ชันที่คุณต้องการปลดดึง ตัวอย่างเช่น หากเราเผยแพร่ crate ชื่อ guessing_game เวอร์ชัน 1.0.1 แล้วเราต้องการปลดดึงมันออก เราจะรันคำสั่งต่อไปนี้ในไดเรกทอรีโปรเจกต์สำหรับ guessing_game:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

การเพิ่ม --undo ลงในคำสั่ง คุณยังสามารถยกเลิกการปลดดึง (unyank) และอนุญาตให้โปรเจกต์ต่าง ๆ เริ่มพึ่งพาเวอร์ชันนั้นได้อีกครั้ง:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

การปลดดึง (yank) ไม่ได้ลบโค้ดใด ๆ ออก ตัวอย่างเช่น มันไม่สามารถลบความลับ (secrets) ที่อัปโหลดโดยบังเอิญออกได้ หากเกิดเหตุการณ์เช่นนั้นขึ้น คุณต้องรีเซ็ตความลับเหล่านั้นทันที

Cargo Workspaces

พื้นที่ทำงานของ Cargo (Cargo Workspaces)

ในบทที่ 12 เราได้สร้างแพ็กเกจที่ประกอบด้วยไบนารี crate และไลบรารี crate เมื่อโปรเจกต์ของคุณได้รับการพัฒนาอย่างต่อเนื่อง คุณอาจพบว่าไลบรารี crate มีขนาดใหญ่ขึ้นเรื่อย ๆ และคุณต้องการแบ่งแพ็กเกจของคุณออกเป็นหลาย ๆ ไลบรารี crate Cargo จึงมีฟีเจอร์ที่เรียกว่า พื้นที่ทำงาน (workspaces) ซึ่งสามารถช่วยจัดการหลาย ๆ แพ็กเกจที่เกี่ยวข้องกันและถูกพัฒนาไปพร้อมกันได้

การสร้างพื้นที่ทำงาน (Workspace)

พื้นที่ทำงาน (workspace) คือชุดของแพ็กเกจที่ใช้ไฟล์ Cargo.lock และไดเรกทอรีผลลัพธ์ (output directory) ร่วมกัน มาลองสร้างโปรเจกต์โดยใช้พื้นที่ทำงานกัน โดยเราจะใช้โค้ดแบบง่าย ๆ เพื่อให้สามารถมุ่งเน้นไปที่โครงสร้างของพื้นที่ทำงานได้ มีหลายวิธีในการจัดโครงสร้างพื้นที่ทำงาน ดังนั้นเราจะแสดงวิธีที่พบบ่อยวิธีหนึ่ง โดยเราจะมีพื้นที่ทำงานที่ประกอบด้วยหนึ่งไบนารีและสองไลบรารี ไบนารีซึ่งจะทำหน้าที่หลักจะพึ่งพาไลบรารีทั้งสองนั้น ไลบรารีหนึ่งจะให้บริการฟังก์ชัน add_one และอีกไลบรารีหนึ่งจะให้บริการฟังก์ชัน add_two ทั้งสาม crate นี้จะอยู่ภายใต้พื้นที่ทำงานเดียวกัน เราจะเริ่มจากการสร้างไดเรกทอรีใหม่สำหรับพื้นที่ทำงาน:

$ mkdir add
$ cd add

ถัดไป ภายในไดเรกทอรี add เราจะสร้างไฟล์ Cargo.toml ที่จะกำหนดโครงร่างพื้นที่ทำงานทั้งหมด ไฟล์นี้จะไม่มีส่วน [package] แต่จะเริ่มต้นด้วยส่วน [workspace] ซึ่งจะอนุญาตให้เราเพิ่มสมาชิกเข้าไปในพื้นที่ทำงานได้ เรายังระบุให้ใช้อัลกอริทึม Resolver เวอร์ชันล่าสุดและดีที่สุดของ Cargo ในพื้นที่ทำงานของเราโดยตั้งค่า resolver เป็น "3":

ชื่อไฟล์: Cargo.toml

[workspace]
resolver = "3"

ถัดไป เราจะสร้างไบนารี crate ชื่อ adder โดยการรัน cargo new ภายในไดเรกทอรี add:

$ cargo new adder
     Created binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

การรัน cargo new ภายในพื้นที่ทำงานจะเพิ่มแพ็กเกจที่สร้างขึ้นใหม่เข้าไปในคีย์ members ของส่วน [workspace] ในไฟล์ Cargo.toml ของพื้นที่ทำงานโดยให้อัตโนมัติ เช่นนี้:

[workspace]
resolver = "3"
members = ["adder"]

ณ จุดนี้ เราสามารถบิลด์พื้นที่ทำงานได้โดยการรัน cargo build ไฟล์ในไดเรกทอรี add ของคุณควรจะดูเป็นดังนี้:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

พื้นที่ทำงานมีไดเรกทอรี target เพียงอันเดียวที่ระดับบนสุด ซึ่งชิ้นงานที่คอมไพล์แล้ว (compiled artifacts) จะถูกจัดวางไว้ในนั้น แพ็กเกจ adder จึงไม่มีไดเรกทอรี target เป็นของตัวเอง แม้ว่าเราจะรัน cargo build จากภายในไดเรกทอรี adder ชิ้นงานที่คอมไพล์แล้วก็ยังคงไปอยู่ที่ add/target แทนที่จะเป็น add/adder/target Cargo จัดโครงสร้างไดเรกทอรี target ในพื้นที่ทำงานในลักษณะนี้เพราะ crate ในพื้นที่ทำงานถูกออกแบบมาให้พึ่งพากัน หากแต่ละ crate มีไดเรกทอรี target ของตนเอง แต่ละ crate จะต้องคอมไพล์ crate อื่น ๆ ทั้งหมดในพื้นที่ทำงานซ้ำเพื่อวางชิ้นงานไว้ในไดเรกทอรี target ของตนเอง การแชร์ไดเรกทอรี target ร่วมกันจึงช่วยให้ crate ต่าง ๆ หลีกเลี่ยงการบิลด์ซ้ำโดยไม่จำเป็นได้

การสร้างแพ็กเกจที่สองในพื้นที่ทำงาน

ถัดไป มาสร้างแพ็กเกจสมาชิกตัวที่สองในพื้นที่ทำงานโดยตั้งชื่อว่า add_one ให้สร้างไลบรารี crate ใหม่ชื่อ add_one:

$ cargo new add_one --lib
     Created library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

ไฟล์ Cargo.toml ที่ระดับบนสุดจะรวมพาธ add_one เข้าไปในรายการ members แล้ว:

ชื่อไฟล์: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

ไดเรกทอรี add ของคุณควรจะมีไดเรกทอรีและไฟล์เหล่านี้แล้ว:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

ในไฟล์ add_one/src/lib.rs มาเพิ่มฟังก์ชัน add_one กัน:

ชื่อไฟล์: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

ตอนนี้เราสามารถกำหนดให้แพ็กเกจ adder ซึ่งมีไบนารีของเรา พึ่งพาแพ็กเกจ add_one ซึ่งมีไลบรารีของเราได้แล้ว อันดับแรก เราต้องเพิ่มพาธพึ่งพา (path dependency) บน add_one ลงใน adder/Cargo.toml

ชื่อไฟล์: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo ไม่ได้ทัศนคติล่วงหน้าว่า crate ต่าง ๆ ในพื้นที่ทำงานจะพึ่งพากันเอง ดังนั้นเราจึงต้องระบุความสัมพันธ์ของการพึ่งพาให้ชัดเจน

ถัดไป มาใช้ฟังก์ชัน add_one (จาก crate add_one) ใน crate adder ให้เปิดไฟล์ adder/src/main.rs แล้วเปลี่ยนฟังก์ชัน main เพื่อเรียกใช้ฟังก์ชัน add_one ดังในโค้ดตัวอย่างที่ 14-7

fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}

มาบิลด์พื้นที่ทำงานโดยการรัน cargo build ในไดเรกทอรี add ที่ระดับบนสุดกัน!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

ในการรันไบนารี crate จากไดเรกทอรี add เราสามารถระบุแพ็กเกจในพื้นที่ทำงานที่เราต้องการรันได้โดยใช้อาร์กิวเมนต์ -p และตามด้วยชื่อแพ็กเกจร่วมกับ cargo run:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
      Running `target/debug/adder`
Hello, world! 10 plus one is 11!

การทำงานนี้จะรันโค้ดใน adder/src/main.rs ซึ่งพึ่งพา crate add_one

การพึ่งพาแพ็กเกจภายนอก

สังเกตว่าพื้นที่ทำงานมีไฟล์ Cargo.lock เพียงไฟล์เดียวที่ระดับบนสุด แทนที่จะมีไฟล์ Cargo.lock ในแต่ละไดเรกทอรีของ crate สิ่งนี้ช่วยให้มั่นใจได้ว่าทุก crate จะใช้เวอร์ชันเดียวกันของทรัพยากรภายนอกทั้งหมด หากเราเพิ่มแพ็กเกจ rand ลงในไฟล์ adder/Cargo.toml และ add_one/Cargo.toml Cargo จะแก้ไขค่าทรัพยากรภายนอกทั้งสองนั้นให้เป็น rand เวอร์ชันเดียว และบันทึกค่านั้นไว้ในไฟล์ Cargo.lock เพียงไฟล์เดียว การทำให้ทุก crate ในพื้นที่ทำงานใช้ทรัพยากรภายนอกเดียวกันหมายความว่า crate จะสามารถทำงานร่วมกันได้อย่างสมบูรณ์เสมอ ให้เราเพิ่ม crate rand ลงในส่วน [dependencies] ในไฟล์ add_one/Cargo.toml เพื่อให้เราสามารถใช้ crate rand ใน crate add_one ได้:

ชื่อไฟล์: add_one/Cargo.toml

[dependencies]
rand = "0.10.1"

ตอนนี้เราสามารถเพิ่ม use rand; ลงในไฟล์ add_one/src/lib.rs ได้แล้ว และการบิลด์พื้นที่ทำงานทั้งหมดโดยการรัน cargo build ในไดเรกทอรี add จะดึงและคอมไพล์ crate rand เข้ามา เราจะได้คำเตือนหนึ่งคำเตือนเพราะเรายังไม่ได้อ้างอิงถึง rand ที่เรานำเข้ามาใน scope:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.10.1
   --snip--
   Compiling rand v0.10.1
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
  --> add_one/src/lib.rs:1:5
   |
1 | use rand;
   |     ^^^^
   |
   = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

ตอนนี้ไฟล์ Cargo.lock ที่ระดับบนสุดจะบรรจุข้อมูลเกี่ยวกับการพึ่งพาของ add_one บน rand อย่างไรก็ตาม แม้ว่า rand จะถูกใช้ที่ใดที่หนึ่งในพื้นที่ทำงาน แต่เราก็ไม่สามารถใช้มันใน crate อื่น ๆ ในพื้นที่ทำงานได้ เว้นแต่เราจะเพิ่ม rand ลงในไฟล์ Cargo.toml ของ crate เหล่านั้นด้วย ตัวอย่างเช่น หากเราเพิ่ม use rand; ลงในไฟล์ adder/src/main.rs สำหรับแพ็กเกจ adder เราจะได้ข้อผิดพลาด:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
  --> adder/src/main.rs:2:5
   |
2 | use rand;
   |     ^^^^ no external crate `rand`

เพื่อแก้ไขปัญหานี้ ให้แก้ไขไฟล์ Cargo.toml สำหรับแพ็กเกจ adder และระบุว่า rand เป็นทรัพยากรพึ่งพาสำหรับมันด้วย การบิลด์แพ็กเกจ adder จะเพิ่ม rand ลงในรายการทรัพยากรพึ่งพาสำหรับ adder ใน Cargo.lock แต่จะไม่มีการดาวน์โหลดสำเนาของ rand เพิ่มเติม Cargo จะช่วยให้มั่นใจว่าทุก crate ในทุกแพ็กเกจของพื้นที่ทำงานที่ใช้แพ็กเกจ rand จะใช้เวอร์ชันเดียวกันตราบใดที่พวกมันระบุเวอร์ชันของ rand ที่เข้ากันได้ ซึ่งช่วยประหยัดพื้นที่และมั่นใจได้ว่า crate ในพื้นที่ทำงานจะเข้ากันได้ซึ่งกันและกัน

หาก crate ในพื้นที่ทำงานระบุเวอร์ชันของทรัพยากรพึ่งพาเดียวกันที่ไม่เข้ากันได้ Cargo จะแก้ปัญหาของแต่ละ crate แต่จะยังคงพยายามแก้ปัญหาให้ใช้เวอร์ชันน้อยที่สุดเท่าที่จะเป็นไปได้

การเพิ่มการทดสอบลงในพื้นที่ทำงาน

สำหรับการปรับปรุงเพิ่มเติม มาเพิ่มการทดสอบฟังก์ชัน add_one::add_one ภายใน crate add_one:

ชื่อไฟล์: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

ตอนนี้ ให้รัน cargo test ในไดเรกทอรี add ที่ระดับบนสุด การรัน cargo test ในพื้นที่ทำงานที่จัดโครงสร้างเหมือนอันนี้ จะรันการทดสอบสำหรับทุก crate ในพื้นที่ทำงาน:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
      Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

      Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ส่วนแรกของผลลัพธ์แสดงว่าการทดสอบ it_works ใน crate add_one ผ่านการทดสอบ ส่วนถัดไปแสดงว่าไม่พบการทดสอบใด ๆ ใน crate adder และส่วนสุดท้ายแสดงว่าไม่พบการทดสอบเอกสารประกอบใด ๆ ใน crate add_one

เรายังสามารถรันการทดสอบสำหรับ crate เฉพาะเจาะจงในพื้นที่ทำงานจากไดเรกทอรีระดับบนสุดได้โดยใช้แฟล็ก -p และระบุชื่อของ crate ที่เราต้องการทดสอบ:

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
      Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ผลลัพธ์นี้แสดงว่า cargo test รันเฉพาะการทดสอบสำหรับ crate add_one เท่านั้น และไม่ได้รันการทดสอบของ crate adder

หากคุณเผยแพร่ crate ในพื้นที่ทำงานไปยัง crates.io แต่ละ crate ในพื้นที่ทำงานจะต้องถูกเผยแพร่แยกกัน เช่นเดียวกับ cargo test เราสามารถเผยแพร่ crate เฉพาะเจาะจงในพื้นที่ทำงานของเราได้โดยใช้แฟล็ก -p และระบุชื่อของ crate ที่เราต้องการเผยแพร่

สำหรับการฝึกฝนเพิ่มเติม ลองเพิ่ม crate add_two ลงในพื้นที่ทำงานนี้ในลักษณะที่คล้ายกับ crate add_one ดูสิ!

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

Installing Binaries with cargo install

การติดตั้งไฟล์ไบนารีด้วย cargo install

คำสั่ง cargo install ช่วยให้คุณสามารถติดตั้งและใช้งานไบนารี crate ในเครื่องของคุณได้ สิ่งนี้ไม่ได้มีไว้เพื่อทดแทนแพ็กเกจระบบ แต่มีไว้เพื่อเป็นวิธีที่สะดวกสำหรับนักพัฒนา Rust ในการติดตั้งเครื่องมือที่ผู้อื่นได้แบ่งปันไว้บน crates.io โปรดทราบว่าคุณสามารถติดตั้งเฉพาะแพ็กเกจที่มีไบนารีเป้าหมาย (binary targets) เท่านั้น โดย ไบนารีเป้าหมาย คือโปรแกรมที่สามารถรันได้ซึ่งถูกสร้างขึ้นเมื่อ crate มีไฟล์ src/main.rs หรือไฟล์อื่นที่ถูกระบุว่าเป็นไบนารี ซึ่งต่างจากไลบรารีเป้าหมาย (library target) ที่ไม่สามารถรันได้ด้วยตัวเอง แต่เหมาะสำหรับการนำไปรวมไว้ในโปรแกรมอื่น โดยทั่วไป crate จะมีข้อมูลในไฟล์ README เกี่ยวกับว่า crate นั้นเป็นไลบรารี มีไบนารีเป้าหมาย หรือมีทั้งสองอย่าง

ไฟล์ไบนารีทั้งหมดที่ถูกติดตั้งด้วย cargo install จะถูกจัดเก็บไว้ในโฟลเดอร์ bin ของไดเรกทอรีรากการติดตั้ง (installation root) หากคุณติดตั้ง Rust โดยใช้ rustup.rs และไม่ได้ตั้งค่าปรับแต่งใด ๆ ไดเรกทอรีนี้จะเป็น $HOME/.cargo/bin ตรวจสอบให้แน่ใจว่าไดเรกทอรีนี้อยู่ใน $PATH ของคุณเพื่อให้สามารถรันโปรแกรมที่คุณติดตั้งด้วย cargo install ได้

ตัวอย่างเช่น ในบทที่ 12 เราได้กล่าวถึงว่ามีการประยุกต์ใช้งานเครื่องมือ grep ในภาษา Rust ที่ชื่อว่า ripgrep สำหรับการค้นหาไฟล์ ในการติดตั้ง ripgrep เราสามารถรันคำสั่งต่อไปนี้:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
    Installed package `ripgrep v14.1.1` (executable `rg`)

บรรทัดรองสุดท้ายของผลลัพธ์แสดงถึงตำแหน่งและชื่อของไบนารีที่ถูกติดตั้ง ซึ่งในกรณีของ ripgrep คือ rg ตราบใดที่ไดเรกทอรีการติดตั้งอยู่ใน $PATH ของคุณตามที่กล่าวไว้ก่อนหน้านี้ คุณก็สามารถรัน rg --help และเริ่มใช้เครื่องมือค้นหาไฟล์ที่รวดเร็วและเป็นสไตล์ Rust ยิ่งขึ้นได้แล้ว!

Extending Cargo with Custom Commands

การขยายความสามารถของ Cargo ด้วยคำสั่งกำหนดเอง (Custom Commands)

Cargo ถูกออกแบบมาเพื่อให้คุณสามารถขยายความสามารถด้วยคำสั่งย่อย (subcommands) ใหม่ ๆ ได้โดยไม่ต้องแก้ไขตัว Cargo เอง หากไบนารีใน $PATH ของคุณมีชื่อว่า cargo-something คุณสามารถรันมันราวกับว่ามันเป็นคำสั่งย่อยของ Cargo ได้โดยการรัน cargo something คำสั่งกำหนดเองในลักษณะนี้จะถูกแสดงอยู่ในรายการด้วยเมื่อคุณรัน cargo --list การสามารถใช้ cargo install เพื่อติดตั้งส่วนขยาย แล้วรันพวกมันได้เหมือนกับเครื่องมือในตัวของ Cargo ถือเป็นประโยชน์ที่สะดวกสบายอย่างยิ่งจากการออกแบบของ Cargo!

สรุป

การแบ่งปันโค้ดด้วย Cargo และ crates.io เป็นส่วนหนึ่งที่ทำให้ระบบนิเวศของ Rust มีประโยชน์ต่องานที่หลากหลาย ไลบรารีมาตรฐานของ Rust มีขนาดเล็กและมีความเสถียร แต่ crate ต่าง ๆ สามารถแบ่งปัน นำไปใช้ และปรับปรุงได้ง่ายในกรอบเวลาที่แตกต่างจากตัวภาษา รบกวนอย่าเขินอายที่จะแบ่งปันโค้ดที่มีประโยชน์ต่อคุณบน crates.io เพราะมีความเป็นไปได้สูงว่ามันจะมีประโยชน์ต่อผู้อื่นด้วยเช่นกัน!

พอยน์เตอร์อัจฉริยะ (Smart Pointers)

พอยน์เตอร์ (Pointer) เป็นแนวคิดทั่วไปสำหรับตัวแปรที่บรรจุที่อยู่ (address) ในหน่วยความจำ โดยที่อยู่นี้จะอ้างอิงถึง หรือ “ชี้ไปที่” ข้อมูลอื่น ชนิดพอยน์เตอร์ที่พบบ่อยที่สุดใน Rust คือ การอ้างอิง (reference) ซึ่งคุณได้เรียนรู้ไปแล้วในบทที่ 4 การอ้างอิงจะระบุด้วยสัญลักษณ์ & และทำการยืมค่าที่มันชี้ไป พวกมันไม่มีความสามารถพิเศษอื่นใดนอกจากการอ้างอิงถึงข้อมูล และไม่มีภาระส่วนเกิน (overhead)

ในทางกลับกัน พอยน์เตอร์อัจฉริยะ (Smart pointers) คือโครงสร้างข้อมูลที่ทำหน้าที่เหมือนพอยน์เตอร์ แต่ก็มีข้อมูลอธิบาย (metadata) และความสามารถเพิ่มเติม แนวคิดเรื่องพอยน์เตอร์อัจฉริยะไม่ได้มีแค่ใน Rust เท่านั้น พอยน์เตอร์อัจฉริยะมีต้นกำเนิดมาจาก C++ และมีอยู่ในภาษาอื่น ๆ เช่นกัน Rust มีพอยน์เตอร์อัจฉริยะหลากหลายชนิดที่นิยามไว้ในไลบรารีมาตรฐาน ซึ่งมอบคุณสมบัติการทำงานนอกเหนือไปจากสิ่งที่การอ้างอิงทั่วไปมอบให้ เพื่อสำรวจแนวคิดทั่วไปนี้ เราจะดูตัวอย่างต่าง ๆ ของพอยน์เตอร์อัจฉริยะ รวมถึงชนิดพอยน์เตอร์อัจฉริยะแบบนับจำนวนการอ้างอิง (reference counting) พอยน์เตอร์ชนิดนี้ช่วยให้คุณยินยอมให้ข้อมูลมีเจ้าของหลายคนได้โดยการติดตามจำนวนเจ้าของ และเมื่อไม่เหลือเจ้าของแล้ว ก็จะทำการล้างข้อมูลนั้นออก

ใน Rust ด้วยแนวคิดเรื่องความเป็นเจ้าของและการยืม มีความแตกต่างเพิ่มเติมระหว่างการอ้างอิงและพอยน์เตอร์อัจฉริยะคือ ในขณะที่การอ้างอิงจะยืมเฉพาะข้อมูล ในหลาย ๆ กรณีพอยน์เตอร์อัจฉริยะจะเป็นเจ้าของ (own) ข้อมูลที่พวกมันชี้ไป

พอยน์เตอร์อัจฉริยะมักจะถูกประยุกต์ใช้งานโดยใช้ struct ต่างจาก struct ทั่วไป ตรงที่พอยน์เตอร์อัจฉริยะจะประยุกต์ใช้เทรต Deref และ Drop เทรต Deref ช่วยให้อินสแตนซ์ของ struct พอยน์เตอร์อัจฉริยะทำตัวเหมือนการอ้างอิงได้ เพื่อให้คุณสามารถเขียนโค้ดเพื่อทำงานได้ทั้งกับการอ้างอิงหรือพอยน์เตอร์อัจฉริยะ เทรต Drop ช่วยให้คุณปรับแต่งโค้ดที่รันเมื่ออินสแตนซ์ของพอยน์เตอร์อัจฉริยะหลุดออกจากขอบเขต (scope) ในบทนี้ เราจะพูดถึงเทรตทั้งสองนี้และแสดงให้เห็นว่าเหตุใดพวกมันจึงมีความสำคัญต่อพอยน์เตอร์อัจฉริยะ

เนื่องจากรูปแบบพอยน์เตอร์อัจฉริยะเป็นรูปแบบการออกแบบทั่วไปที่ใช้บ่อยใน Rust บทนี้จะไม่ครอบคลุมพอยน์เตอร์อัจฉริยะที่มีอยู่ทั้งหมด ไลบรารีหลายแห่งมีพอยน์เตอร์อัจฉริยะของตนเอง และคุณยังสามารถเขียนขึ้นเองได้ด้วย เราจะครอบคลุมพอยน์เตอร์อัจฉริยะที่พบบ่อยที่สุดในไลบรารีมาตรฐาน:

  • Box<T>, สำหรับการจัดสรรค่าบนฮีป (heap)
  • Rc<T>, ชนิดข้อมูลนับจำนวนการอ้างอิงที่ช่วยให้มีความเป็นเจ้าของหลายคนได้
  • Ref<T> และ RefMut<T>, เข้าถึงผ่าน RefCell<T> ซึ่งเป็นชนิดข้อมูลที่บังคับใช้กฎการยืมขณะรันไทม์แทนที่จะเป็นขณะคอมไพล์

นอกจากนี้ เราจะครอบคลุมรูปแบบการแก้ไขภายใน (interior mutability) ซึ่งเป็นชนิดข้อมูลที่ไม่สามารถแก้ไขได้ แต่เปิดเผย API สำหรับการแก้ไขค่าภายในได้ เราจะพูดถึงวงจรการอ้างอิง (reference cycles) ด้วยเช่นกัน: ว่าพวกมันทำให้เกิดหน่วยความจำรั่วไหล (memory leak) ได้อย่างไร และจะป้องกันได้อย่างไร

มาลุยกันเลย!

Using Box<T> to Point to Data on the Heap

การใช้ Box<T> เพื่อชี้ไปยังข้อมูลบนฮีป (Heap)

พอยน์เตอร์อัจฉริยะที่ตรงไปตรงมาที่สุดคือ box ซึ่งชนิดของมันเขียนว่า Box<T> Boxes ช่วยให้คุณเก็บข้อมูลไว้บนฮีป (heap) แทนที่จะเป็นสแต็ก (stack) สิ่งที่ยังคงอยู่บนสแต็กคือพอยน์เตอร์ที่ชี้ไปยังข้อมูลบนฮีป อ้างอิงถึงบทที่ 4 เพื่อทบทวนความแตกต่างระหว่างสแต็กและฮีป

Box ไม่มีภาระส่วนเกิน (overhead) ด้านประสิทธิภาพ นอกจากการเก็บข้อมูลไว้บนฮีปแทนที่จะเป็นบนสแต็ก แต่พวกมันก็ไม่ได้มีความสามารถพิเศษเพิ่มเติมมากมายเช่นกัน คุณจะได้ใช้พวกมันบ่อยที่สุดในสถานการณ์เหล่านี้:

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

เราจะสาธิตสถานการณ์แรกในหัวข้อ “การรองรับชนิดข้อมูลแบบวนซ้ำ (Recursive Types) ด้วย Box” ในกรณีที่สอง การโอนย้ายความเป็นเจ้าของของข้อมูลจำนวนมากอาจใช้เวลานานเพราะข้อมูลจะถูกคัดลอกไปมาบนสแต็ก เพื่อปรับปรุงประสิทธิภาพในสถานการณ์นี้ เราสามารถเก็บข้อมูลจำนวนมากไว้บนฮีปใน box ได้ จากนั้นจะมีการคัดลอกเฉพาะข้อมูลพอยน์เตอร์ที่มีขนาดเล็กไปมาบนสแต็ก ในขณะที่ข้อมูลที่มันอ้างอิงถึงจะยังคงอยู่ในตำแหน่งเดิมบนฮีป กรณีที่สามรู้จักกันในชื่อ วัตถุเทรต (trait object) และหัวข้อ “การใช้วัตถุเทรตเพื่อใช้นามธรรมครอบพฤติกรรมร่วมกัน” ในบทที่ 18 จะอธิบายถึงหัวข้อนั้นโดยเฉพาะ ดังนั้นสิ่งที่คุณเรียนรู้ที่นี่จะถูกนำไปใช้อีกครั้งในส่วนนั้น!

การเก็บข้อมูลไว้บนฮีป

ก่อนที่เราจะพูดถึงกรณีการใช้งานการจัดเก็บข้อมูลบนฮีปสำหรับ Box<T> เราจะครอบคลุมไวยากรณ์และวิธีโต้ตอบกับค่าที่ถูกเก็บไว้ภายใน Box<T>

โค้ดตัวอย่างที่ 15-1 แสดงวิธีใช้ box เพื่อเก็บค่า i32 ไว้บนฮีป

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

เรานิยามตัวแปร b ให้มีค่าเป็น Box ที่ชี้ไปยังค่า 5 ซึ่งถูกจัดสรรไว้บนฮีป โปรแกรมนี้จะพิมพ์ b = 5 ในกรณีนี้ เราสามารถเข้าถึงข้อมูลใน box ได้คล้ายกับวิธีที่เราทำหากข้อมูลนี้อยู่บนสแต็ก เช่นเดียวกับค่าที่มีเจ้าของใด ๆ เมื่อ box หลุดออกจากขอบเขต (scope) เช่นเดียวกับที่ b ทำตอนสิ้นสุด main มันจะถูกคืนหน่วยความจำ (deallocated) การคืนหน่วยความจำจะเกิดขึ้นทั้งกับตัว box (ที่เก็บไว้บนสแต็ก) และข้อมูลที่มันชี้ไป (ที่เก็บไว้บนฮีป)

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

การรองรับชนิดข้อมูลแบบวนซ้ำ (Recursive Types) ด้วย Box

ค่าของ ชนิดข้อมูลแบบวนซ้ำ (recursive type) สามารถมีค่าอื่นที่เป็นชนิดข้อมูลเดียวกันเป็นส่วนหนึ่งของตัวมันเองได้ ชนิดข้อมูลแบบวนซ้ำทำให้เกิดปัญหาเนื่องจาก Rust จำเป็นต้องรู้ในขณะคอมไพล์ว่าชนิดข้อมูลนั้นใช้พื้นที่เท่าใด อย่างไรก็ตาม การซ้อนกันของค่าของชนิดข้อมูลแบบวนซ้ำในทางทฤษฎีสามารถดำเนินต่อไปได้อย่างอนันต์ ดังนั้น Rust จึงไม่สามารถรู้ได้ว่าค่านั้นต้องการพื้นที่เท่าใด เนื่องจาก box มีขนาดที่แน่นอน เราจึงสามารถรองรับชนิดข้อมูลแบบวนซ้ำได้โดยการแทรก box ลงในการนิยามชนิดข้อมูลแบบวนซ้ำ

ตัวอย่างของชนิดข้อมูลแบบวนซ้ำ มาสำรวจ cons list กัน นี่คือชนิดข้อมูลที่มักพบในภาษาโปรแกรมเชิงฟังก์ชัน ชนิดข้อมูล cons list ที่เราจะนิยามนั้นตรงไปตรงมาเว้นแต่เรื่องการวนซ้ำ (recursion) ดังนั้น แนวคิดในตัวอย่างที่เราจะทำงานด้วยจะมีประโยชน์ทุกครั้งที่คุณเข้าไปในสถานการณ์ที่ซับซ้อนขึ้นซึ่งเกี่ยวข้องกับชนิดข้อมูลแบบวนซ้ำ

ทำความเข้าใจ Cons List

cons list คือโครงสร้างข้อมูลที่มาจากภาษาโปรแกรม Lisp และภาษาในตระกูลเดียวกัน ประกอบด้วยคู่ซ้อนกัน และเป็นเวอร์ชันภาษา Lisp ของลิงก์ลิสต์ (linked list) ชื่อของมันมาจากฟังก์ชัน cons (ย่อมาจาก construct function) ใน Lisp ที่สร้างคู่ใหม่ขึ้นมาจากอาร์กิวเมนต์สองตัวของมัน โดยการเรียก cons กับคู่ที่ประกอบด้วยค่าหนึ่งค่าและอีกคู่หนึ่ง เราสามารถสร้าง cons list ที่ประกอบด้วยคู่แบบวนซ้ำได้

ตัวอย่างเช่น นี่คือการแสดงรหัสเทียม (pseudocode) ของ cons list ที่มีลิสต์ 1, 2, 3 โดยแต่ละคู่อยู่ในวงเล็บ:

(1, (2, (3, Nil)))

สมาชิกแต่ละตัวใน cons list บรรจุสององค์ประกอบ: ค่าของสมาชิกปัจจุบัน และสมาชิกถัดไป สมาชิกสุดท้ายในลิสต์บรรจุเพียงค่าที่เรียกว่า Nil โดยไม่มีสมาชิกถัดไป cons list ถูกสร้างขึ้นโดยการเรียกฟังก์ชัน cons ซ้ำ ๆ ชื่อตามหลักการที่ใช้แทนกรณีฐาน (base case) ของการวนซ้ำคือ Nil สังเกตว่าสิ่งนี้ไม่เหมือนกับแนวคิด “null” หรือ “nil” ที่พูดถึงในบทที่ 6 ซึ่งเป็นค่าที่ไม่ถูกต้องหรือไม่ปรากฏอยู่

cons list ไม่ใช่โครงสร้างข้อมูลที่ใช้บ่อยใน Rust ส่วนใหญ่เมื่อคุณมีรายการสมาชิกใน Rust Vec<T> จะเป็นตัวเลือกที่ดีกว่าในการใช้งาน ชนิดข้อมูลแบบวนซ้ำอื่น ๆ ที่ซับซ้อนกว่า มี ประโยชน์ในสถานการณ์ต่าง ๆ แต่การเริ่มต้นด้วย cons list ในบทนี้ เราสามารถสำรวจวิธีที่ box ช่วยให้เรานิยามชนิดข้อมูลแบบวนซ้ำได้โดยไม่มีสิ่งรบกวนมากนัก

โค้ดตัวอย่างที่ 15-2 บรรจุนิยาม enum สำหรับ cons list โปรดทราบว่าโค้ดนี้ยังไม่สามารถคอมไพล์ได้เนื่องจากชนิดข้อมูล List ไม่มีขนาดที่แน่นอน ซึ่งเราจะสาธิตให้เห็น

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

หมายเหตุ: เรากำลังประยุกต์ใช้ cons list ที่บรรจุเฉพาะค่า i32 เพื่อวัตถุประสงค์ของตัวอย่างนี้ เราอาจประยุกต์ใช้งานโดยใช้ generics ตามที่เราพูดถึงในบทที่ 10 เพื่อนิยามชนิดข้อมูล cons list ที่สามารถเก็บค่าของชนิดข้อมูลใดก็ได้

การใช้ชนิดข้อมูล List เพื่อเก็บลิสต์ 1, 2, 3 จะดูเหมือนโค้ดในโค้ดตัวอย่างที่ 15-3

enum List {
    Cons(i32, List),
    Nil,
}

// --snip--

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

ค่า Cons แรกถือครอง 1 และค่า List อีกค่าหนึ่ง ค่า List นี้เป็นค่า Cons อีกค่าหนึ่งที่ถือครอง 2 และค่า List อีกค่าหนึ่ง ค่า List นี้เป็นค่า Cons อีกหนึ่งค่าที่ถือครอง 3 และค่า List ซึ่งสุดท้ายคือ Nil ตัวแปรย่อยแบบไม่วนซ้ำที่ส่งสัญญาณสิ้นสุดลิสต์

หากเราพยายามคอมไพล์โค้ดในโค้ดตัวอย่างที่ 15-3 เราจะได้ข้อผิดพลาดดังแสดงในโค้ดตัวอย่างที่ 15-4

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

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

การคำนวณขนาดของชนิดข้อมูลแบบไม่วนซ้ำ

ทบทวน enum Message ที่เรานิยามไว้ในโค้ดตัวอย่างที่ 6-2 เมื่อเราพูดถึงนิยาม enum ในบทที่ 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

ในการพิจารณาว่าจะต้องจัดสรรพื้นที่เท่าใดสำหรับค่า Message Rust จะไล่ดูแต่ละตัวแปรย่อยเพื่อดูว่าตัวแปรย่อยใดต้องการพื้นที่มากที่สุด Rust เห็นว่า Message::Quit ไม่ต้องการพื้นที่เลย Message::Move ต้องการพื้นที่เพียงพอสำหรับเก็บค่า i32 สองค่า และอื่น ๆ เนื่องจากจะมีเพียงตัวแปรย่อยเดียวเท่านั้นที่จะถูกใช้งาน พื้นที่มากที่สุดที่ค่า Message จะต้องการจึงเป็นพื้นที่ที่ใช้สำหรับเก็บตัวแปรย่อยที่ใหญ่ที่สุด

ลองเปรียบเทียบสิ่งนี้กับสิ่งที่เกิดขึ้นเมื่อ Rust พยายามพิจารณาว่าชนิดข้อมูลแบบวนซ้ำอย่าง enum List ในโค้ดตัวอย่างที่ 15-2 ต้องการพื้นที่เท่าใด คอมไพเลอร์เริ่มจากดูที่ตัวแปรย่อย Cons ซึ่งถือครองค่าชนิด i32 และค่าชนิด List ดังนั้น Cons จึงต้องการพื้นที่เท่ากับขนาดของ i32 บวกกับขนาดของ List ในการหาว่าชนิดข้อมูล List ต้องการหน่วยความจำเท่าใด คอมไพเลอร์จะดูที่ตัวแปรย่อย โดยเริ่มจากตัวแปรย่อย Cons ตัวแปรย่อย Cons ถือครองค่าชนิด i32 และค่าชนิด List และกระบวนการนี้จะดำเนินต่อไปอย่างอนันต์ ดังแสดงในรูปภาพที่ 15-1

Cons list อนันต์: สี่เหลี่ยมผืนผ้าป้ายชื่อ 'Cons' แบ่งออกเป็นสี่เหลี่ยมผืนผ้าขนาดเล็กสองอัน อันแรกถือป้ายชื่อ 'i32' และอันที่สองถือป้ายชื่อ 'Cons' และเวอร์ชันเล็กกว่าของสี่เหลี่ยมผืนผ้า 'Cons' ด้านนอก สี่เหลี่ยมผืนผ้า 'Cons' ยังคงถือครองเวอร์ชันที่เล็กกว่าและเล็กกว่าไปเรื่อย ๆ จนกระทั่งสี่เหลี่ยมผืนผ้าที่เล็กที่สุดถือสัญลักษณ์อนันต์ แสดงว่าการทำซ้ำนี้ดำเนินไปตลอดกาล

รูปภาพที่ 15-1: List อนันต์ที่ประกอบด้วยตัวแปรย่อย Cons อนันต์

การได้มาซึ่งชนิดข้อมูลแบบวนซ้ำที่มีขนาดแน่นอน

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

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

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

เนื่องจาก Box<T> เป็นพอยน์เตอร์ Rust จึงรู้อยู่เสมอว่า Box<T> ต้องการพื้นที่เท่าใด: ขนาดของพอยน์เตอร์จะไม่เปลี่ยนแปลงตามปริมาณข้อมูลที่มันชี้ไป สิ่งนี้หมายความว่าเราสามารถใส่ Box<T> ไว้ภายในตัวแปรย่อย Cons แทนที่จะเป็นอีกค่า List โดยตรง Box<T> จะชี้ไปยังค่า List ถัดไปที่จะอยู่บนฮีปแทนที่จะอยู่ภายในตัวแปรย่อย Cons โดยหลักการแล้ว เรายังคงมีลิสต์ที่สร้างขึ้นด้วยลิสต์ที่ถือครองลิสต์อื่นอยู่ แต่การประยุกต์ใช้นี้ตอนนี้คล้ายกับการวางสมาชิกรอบ ๆ ข้างกันแทนที่จะวางไว้ข้างในกันและกัน

เราสามารถเปลี่ยนนิยามของ enum List ในโค้ดตัวอย่างที่ 15-2 และการใช้งาน List ในโค้ดตัวอย่างที่ 15-3 เป็นโค้ดในโค้ดตัวอย่างที่ 15-5 ซึ่งจะสามารถคอมไพล์ได้

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

ตัวแปรย่อย Cons ต้องการขนาดของ i32 บวกกับพื้นที่สำหรับเก็บข้อมูลพอยน์เตอร์ของ box ตัวแปรย่อย Nil ไม่ได้เก็บค่าใด ๆ ดังนั้นจึงต้องการพื้นที่บนสแต็กน้อยกว่าตัวแปรย่อย Cons ตอนนี้เรารู้แล้วว่าค่า List ใด ๆ จะใช้พื้นที่เท่ากับขนาดของ i32 บวกกับขนาดของข้อมูลพอยน์เตอร์ของ box การใช้ box ทำให้เราตัดห่วงโซ่แบบวนซ้ำที่เป็นอนันต์ออกไปได้ คอมไพเลอร์จึงสามารถคิดขนาดที่จำเป็นในการเก็บค่า List ได้ รูปภาพที่ 15-2 แสดงว่าตัวแปรย่อย Cons ดูเป็นอย่างไรตอนนี้

สี่เหลี่ยมผืนผ้าป้ายชื่อ 'Cons' แบ่งออกเป็นสี่เหลี่ยมผืนผ้าขนาดเล็กสองอัน อันแรกถือป้ายชื่อ 'i32' และอันที่สองถือป้ายชื่อ 'Box' พร้อมสี่เหลี่ยมผืนผ้าภายในหนึ่งอันที่บรรจุป้ายชื่อ 'usize' ซึ่งแทนขนาดจำกัดของพอยน์เตอร์ของ box

รูปภาพที่ 15-2: List ที่ไม่ได้มีขนาดอนันต์ เพราะ Cons ถือครอง Box

Box ให้เพียงการอ้อมและการจัดสรรบนฮีปเท่านั้น พวกมันไม่ได้มีความสามารถพิเศษอื่น ๆ เหมือนสิ่งที่เราจะได้เห็นกับชนิดพอยน์เตอร์อัจฉริยะอื่น ๆ พวกมันยังไม่มีภาระส่วนเกินด้านประสิทธิภาพที่ความสามารถพิเศษเหล่านั้นก่อขึ้น ดังนั้นจึงมีประโยชน์ในกรณีอย่าง cons list ที่การอ้อมเป็นเพียงฟีเจอร์เดียวที่เราต้องการ เราจะดูcases การใช้งานอื่น ๆ สำหรับ box ในบทที่ 18

ชนิดข้อมูล Box<T> เป็นพอยน์เตอร์อัจฉริยะเนื่องจากมันประยุกต์ใช้เทรต Deref ซึ่งช่วยให้ค่า Box<T> ถูกปฏิบัติเหมือนการอ้างอิงได้ เมื่อค่า Box<T> หลุดออกจากขอบเขต (scope) ข้อมูลบนฮีปที่ box ชี้ไปก็จะถูกล้างทำความสะอาดด้วยเช่นกันเนื่องจากการประยุกต์ใช้เทรต Drop เทรตทั้งสองนี้จะมีความสำคัญยิ่งขึ้นต่อฟังก์ชันการทำงานที่จัดเตรียมไว้โดยชนิดพอยน์เตอร์อัจฉริยะอื่น ๆ ที่เราจะพูดถึงในส่วนที่เหลือของบทนี้ มาสำรวจเทรตทั้งสองนี้โดยละเอียดกันเถอะ

Treating Smart Pointers Like Regular References

การปฏิบัติต่อพอยน์เตอร์อัจฉริยะเสมือนการอ้างอิงทั่วไป

การประยุกต์ใช้เทรต Deref ช่วยให้คุณสามารถปรับแต่งพฤติกรรมของตัวดำเนินการลดระดับการอ้างอิง (dereference operator) * ได้ (ไม่สับสนกับตัวดำเนินการคูณหรือตัวดำเนินการระดับสัญลักษณ์แบบไวด์การ์ด) การประยุกต์ใช้ Deref ในลักษณะที่ช่วยให้พอยน์เตอร์อัจฉริยะถูกปฏิบัติตัวเหมือนการอ้างอิงทั่วไป ทำให้คุณสามารถเขียนโค้ดที่ทำงานกับการอ้างอิงและนำโค้ดนั้นไปใช้กับพอยน์เตอร์อัจฉริยะได้ด้วยเช่นกัน

ก่อนอื่นมาดูกันว่าตัวดำเนินการลดระดับการอ้างอิงทำงานอย่างไรกับการอ้างอิงทั่วไป จากนั้น เราจะลองนิยามชนิดข้อมูลแบบกำหนดเองที่ทำตัวเหมือน Box<T> และดูว่าเหตุใดตัวดำเนินการลดระดับการอ้างอิงจึงไม่ทำงานเหมือนการอ้างอิงกับชนิดข้อมูลที่นิยามขึ้นใหม่ของเรา เราจะสำรวจว่าการประยุกต์ใช้เทรต Deref ช่วยให้พอยน์เตอร์อัจฉริยะทำงานในลักษณะที่คล้ายกับการอ้างอิงได้อย่างไร จากนั้น เราจะดูฟีเจอร์การบังคับชนิดข้อมูลของ deref (deref coercion) ของ Rust และวิธีที่มันช่วยให้เราทำงานได้ทั้งกับการอ้างอิงหรือพอยน์เตอร์อัจฉริยะ

การปฏิบัติตามการอ้างอิงไปยังค่า

การอ้างอิงทั่วไปเป็นชนิดพอยน์เตอร์แบบหนึ่ง และวิธีหนึ่งในการมองพอยน์เตอร์คือการมองเป็นลูกศรที่ชี้ไปยังค่าที่เก็บไว้ ณ ตำแหน่งอื่น ในโค้ดตัวอย่างที่ 15-6 เราสร้างการอ้างอิงไปยังค่า i32 จากนั้นใช้ตัวดำเนินการลดระดับการอ้างอิงเพื่อปฏิบัติตามการอ้างอิงไปยังค่านั้น

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

ตัวแปร x บรรจุค่า i32 เท่ากับ 5 เราตั้งค่า y ให้เท่ากับการอ้างอิงไปยัง x เราสามารถยืนยัน (assert) ได้ว่า x เท่ากับ 5 อย่างไรก็ตาม หากเราต้องการทำการยืนยันเกี่ยวกับค่าใน y เราต้องใช้ *y เพื่อปฏิบัติตามการอ้างอิงไปยังค่าที่มันชี้ไป (ดังนั้นจึงเรียกว่า การลดระดับการอ้างอิง หรือ dereference) เพื่อให้คอมไพเลอร์สามารถเปรียบเทียบค่าที่แท้จริงได้ เมื่อเราทำการลดระดับการอ้างอิง y แล้ว เราจะสามารถเข้าถึงค่าจำนวนเต็มที่ y ชี้ไปเพื่อนำมาเปรียบเทียบกับ 5 ได้

หากเราลองเขียน assert_eq!(5, y); แทน เราจะได้ข้อผิดพลาดในการคอมไพล์ดังนี้:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            f128
            f16
            f32
            f64
            i128
            i16
            i32
            i64
          and 8 others

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

การเปรียบเทียบตัวเลขกับการอ้างอิงไปยังตัวเลขนั้นไม่ได้รับอนุญาตเนื่องจากพวกมันเป็นคนละชนิดข้อมูลกัน เราต้องใช้ตัวดำเนินการลดระดับการอ้างอิงเพื่อปฏิบัติตามการอ้างอิงไปยังค่าที่มันชี้ไป

การใช้ Box<T> เสมือนการอ้างอิง

เราสามารถเขียนโค้ดในโค้ดตัวอย่างที่ 15-6 ใหม่โดยใช้ Box<T> แทนการอ้างอิงได้ ตัวดำเนินการลดระดับการอ้างอิงที่ใช้กับ Box<T> ในโค้ดตัวอย่างที่ 15-7 ทำงานในลักษณะเดียวกับตัวดำเนินการลดระดับการอ้างอิงที่ใช้กับการอ้างอิงในโค้ดตัวอย่างที่ 15-6

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

ความแตกต่างหลักระหว่างโค้ดตัวอย่างที่ 15-7 และโค้ดตัวอย่างที่ 15-6 คือในที่นี้เรากำหนดให้ y เป็นอินสแตนซ์ของ box ที่ชี้ไปยังค่าสำเนาของ x แทนที่จะเป็นการอ้างอิงที่ชี้ไปยังค่าของ x ในการยืนยันบรรทัดสุดท้าย เราสามารถใช้ตัวดำเนินการลดระดับการอ้างอิงเพื่อปฏิบัติตามพอยน์เตอร์ของ box ในลักษณะเดียวกับที่เราทำเมื่อ y เป็นการอ้างอิงได้ ถัดไป เราจะสำรวจสิ่งพิเศษเกี่ยวกับ Box<T> ที่ช่วยให้เราสามารถใช้ตัวดำเนินการลดระดับการอ้างอิงได้โดยการนิยามชนิดข้อมูล box ของเราเอง

การนิยามพอยน์เตอร์อัจฉริยะของเราเอง

มาสร้างชนิดข้อมูลครอบ (wrapper type) ที่คล้ายกับชนิดข้อมูล Box<T> ที่จัดเตรียมโดยไลบรารีมาตรฐาน เพื่อสัมผัสประสบการณ์ว่าชนิดพอยน์เตอร์อัจฉริยะมีพฤติกรรมแตกต่างจากการอ้างอิงโดยเริ่มต้นอย่างไร จากนั้น เราจะดูวิธีเพิ่มความสามารถในการใช้ตัวดำเนินการลดระดับการอ้างอิง

หมายเหตุ: มีความแตกต่างใหญ่ประการหนึ่งระหว่างชนิดข้อมูล MyBox<T> ที่เรากำลังจะสร้างกับ Box<T> ที่แท้จริง: เวอร์ชันของเราจะไม่เก็บข้อมูลไว้บนฮีป เรากำลังเน้นตัวอย่างนี้ไปที่เรื่อง Deref ดังนั้นตำแหน่งที่เก็บข้อมูลที่แท้จริงจึงมีความสำคัญน้อยกว่าพฤติกรรมในลักษณะพอยน์เตอร์

ชนิดข้อมูล Box<T> ถูกนิยามเป็น tuple struct ที่มีสมาชิกหนึ่งตัว ดังนั้นโค้ดตัวอย่างที่ 15-8 จึงนิยามชนิดข้อมูล MyBox<T> ในลักษณะเดียวกัน เราจะนิยามฟังก์ชัน new เพื่อให้ตรงกับฟังก์ชัน new ที่นิยามไว้บน Box<T> ด้วย

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

เรานิยาม struct ชื่อ MyBox และประกาศพารามิเตอร์ทั่วไป (generic parameter) T เพราะเราต้องการให้ชนิดข้อมูลของเราถือครองค่าของชนิดข้อมูลใดก็ได้ ชนิดข้อมูล MyBox เป็น tuple struct ที่มีสมาชิกหนึ่งตัวที่เป็นชนิด T ฟังก์ชัน MyBox::new รับพารามิเตอร์หนึ่งตัวที่เป็นชนิด T และคืนค่าอินสแตนซ์ของ MyBox ที่ถือครองค่าที่ส่งเข้ามา

ลองเพิ่มฟังก์ชัน main ในโค้ดตัวอย่างที่ 15-7 ลงในโค้ดตัวอย่างที่ 15-8 และเปลี่ยนมาใช้ชนิดข้อมูล MyBox<T> ที่เรานิยามไว้แทน Box<T> โค้ดในโค้ดตัวอย่างที่ 15-9 จะยังไม่สามารถคอมไพล์ได้เนื่องจาก Rust ไม่รู้วิธีการลดระดับการอ้างอิง MyBox

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

นี่คือข้อผิดพลาดในการคอมไพล์ที่เกิดขึ้น:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^ can't be dereferenced

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

ชนิดข้อมูล MyBox<T> ของเราไม่สามารถถูกลดระดับการอ้างอิงได้เพราะเรายังไม่ได้ประยุกต์ใช้ความสามารถนั้นบนชนิดข้อมูลของเรา ในการเปิดใช้งานการลดระดับการอ้างอิงด้วยตัวดำเนินการ * เราต้องประยุกต์ใช้เทรต Deref

การประยุกต์ใช้เทรต Deref

ตามที่ได้หารือในหัวข้อ “การประยุกต์ใช้เทรตบนชนิดข้อมูล” ในบทที่ 10 ในการประยุกต์ใช้เทรต เราจำเป็นต้องจัดเตรียมการประยุกต์ใช้งานสำหรับเมธอดที่เทรตต้องการ เทรต Deref ซึ่งจัดเตรียมโดยไลบรารีมาตรฐาน กำหนดให้เราต้องประยุกต์ใช้หนึ่งเมธอดชื่อ deref ที่ทำการยืม self และคืนค่าการอ้างอิงไปยังข้อมูลภายใน โค้ดตัวอย่างที่ 15-10 บรรจุการประยุกต์ใช้ Deref เพื่อเพิ่มลงในการนิยามของ MyBox<T>

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

ไวยากรณ์ type Target = T; นิยามชนิดข้อมูลที่เชื่อมโยง (associated type) สำหรับให้เทรต Deref ใช้งาน ชนิดข้อมูลที่เชื่อมโยงเป็นวิธีประกาศพารามิเตอร์ทั่วไปที่แตกต่างกันเล็กน้อย แต่คุณไม่จำเป็นต้องกังวลเกี่ยวกับพวกมันในตอนนี้ เราจะครอบคลุมรายละเอียดเพิ่มเติมในบทที่ 20

เราเติมเนื้อหาของเมธอด deref ด้วย &self.0 เพื่อให้ deref คืนค่าการอ้างอิงไปยังค่าที่เราต้องการเข้าถึงด้วยตัวดำเนินการ * โปรดจำจากหัวข้อ “การสร้างชนิดข้อมูลที่แตกต่างกันด้วย Tuple Structs” ในบทที่ 5 ว่า .0 จะเข้าถึงค่าแรกใน tuple struct ตอนนี้ฟังก์ชัน main ในโค้ดตัวอย่างที่ 15-9 ที่เรียกใช้ * บนค่า MyBox<T> สามารถคอมไพล์ได้แล้ว และการยืนยันผ่านการทดสอบ!

หากไม่มีเทรต Deref คอมไพเลอร์จะสามารถลดระดับการอ้างอิงได้เฉพาะการอ้างอิง & เท่านั้น เมธอด deref ช่วยให้คอมไพเลอร์มีความสามารถในการนำค่าของชนิดข้อมูลใดก็ตามที่ประยุกต์ใช้ Deref มาเรียกใช้เมธอด deref เพื่อให้ได้การอ้างอิงที่มันรู้วิธีการลดระดับการอ้างอิง

เมื่อเราพิมพ์ *y ในโค้ดตัวอย่างที่ 15-9 เบื้องหลังแล้ว Rust รันโค้ดนี้จริง ๆ:

*(y.deref())

Rust จะแทนที่ตัวดำเนินการ * ด้วยการเรียกใช้เมธอด deref แล้วตามด้วยการลดระดับการอ้างอิงแบบธรรมดา เพื่อให้เราไม่ต้องคิดว่าจำเป็นต้องเรียกใช้เมธอด deref หรือไม่ ฟีเจอร์นี้ของ Rust ช่วยให้เราเขียนโค้ดที่ทำงานได้เหมือนกันทุกประการ ไม่ว่าเราจะมี การอ้างอิงทั่วไปหรือชนิดข้อมูลที่ประยุกต์ใช้ Deref

เหตุผลที่เมธอด deref คืนค่าเป็นการอ้างอิงไปยังค่า และการลดระดับการอ้างอิงธรรมดานอกวงเล็บใน *(y.deref()) ยังคงมีความจำเป็น มีความเกี่ยวข้องกับระบบความเป็นเจ้าของ (ownership system) หากเมธอด deref คืนค่าเป็นค่านั้นโดยตรงแทนที่จะเป็น การอ้างอิงไปยังค่า ค่านั้นจะถูกย้ายออกไปจาก self เราไม่ได้ต้องการครอบครองความเป็นเจ้าของของค่าภายใน MyBox<T> ในกรณีนี้ หรือในกรณีส่วนใหญ่ที่เราใช้ตัวดำเนินการลดระดับการอ้างอิง

สังเกตว่าตัวดำเนินการ * ถูกแทนที่ด้วยการเรียกเมธอด deref แล้วตามด้วยการเรียกตัวดำเนินการ * เพียงครั้งเดียว ในแต่ละครั้งที่เราใช้ * ในโค้ดของเรา เนื่องจากการแทนที่ตัวดำเนินการ * ไม่ได้ทำงานแบบซ้ำเป็นอนันต์ เราจึงจบลงด้วยข้อมูลชนิด i32 ซึ่งตรงกับ 5 ใน assert_eq! ในโค้ดตัวอย่างที่ 15-9

การใช้ Deref Coercion ในฟังก์ชันและเมธอด

Deref coercion จะแปลงการอ้างอิงไปยังชนิดข้อมูลที่ประยุกต์ใช้เทรต Deref ให้กลายเป็นการอ้างอิงไปยังอีกชนิดข้อมูลหนึ่ง ตัวอย่างเช่น deref coercion สามารถแปลง &String เป็น &str ได้ เพราะ String ประยุกต์ใช้เทรต Deref ในลักษณะที่มันคืนค่าเป็น &str deref coercion เป็นความสะดวกสบายที่ Rust ปฏิบัติต่ออาร์กิวเมนต์ที่ส่งให้ฟังก์ชันและเมธอด และจะทำงานเฉพาะกับชนิดข้อมูลที่ประยุกต์ใช้เทรต Deref เท่านั้น สิ่งนี้จะเกิดขึ้นโดยอัตโนมัติเมื่อเราส่งการอ้างอิงไปยังค่าของชนิดข้อมูลเฉพาะเป็นอาร์กิวเมนต์ให้กับฟังก์ชันหรือเมธอดที่ไม่ตรงกับชนิดพารามิเตอร์ในการนิยามฟังก์ชันหรือเมธอดนั้น ลำดับของการเรียกใช้เมธอด deref จะแปลงชนิดข้อมูลที่เราจัดเตรียมไว้ให้กลายเป็นชนิดข้อมูลที่พารามิเตอร์ต้องการ

Deref coercion ถูกเพิ่มเข้ามาใน Rust เพื่อให้โปรแกรมเมอร์ที่เขียนการเรียกฟังก์ชันและเมธอดไม่ต้องเพิ่มการอ้างอิงและการลดระดับการอ้างอิงอย่างชัดเจนด้วย & และ * มากจนเกินไป ฟีเจอร์ deref coercion ยังช่วยให้เราเขียนโค้ดที่สามารถทำงานได้ทั้งสำหรับการอ้างอิงหรือพอยน์เตอร์อัจฉริยะได้มากขึ้น

ในการดู deref coercion ในการทำงานจริง ให้เราใช้ชนิดข้อมูล MyBox<T> ที่เรานิยามไว้ในโค้ดตัวอย่างที่ 15-8 รวมถึงการประยุกต์ใช้ Deref ที่เราเพิ่มลงไปในโค้ดตัวอย่างที่ 15-10 โค้ดตัวอย่างที่ 15-11 แสดงการนิยามฟังก์ชันที่มีพารามิเตอร์สไลซ์ข้อความ (string slice)

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}

เราสามารถเรียกใช้ฟังก์ชัน hello โดยใช้อาร์กิวเมนต์สไลซ์ข้อความได้ เช่น hello("Rust"); เป็นต้น Deref coercion ช่วยให้สามารถเรียกใช้ hello ด้วยการอ้างอิงไปยังค่าชนิด MyBox<String> ได้ ดังแสดงในโค้ดตัวอย่างที่ 15-12

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

ในที่นี้ เรากำลังเรียกใช้ฟังก์ชัน hello ด้วยอาร์กิวเมนต์ &m ซึ่งเป็นการอ้างอิงไปยังค่า MyBox<String> เนื่องจากเราได้ประยุกต์ใช้เทรต Deref บน MyBox<T> ในโค้ดตัวอย่างที่ 15-10 Rust จึงสามารถเปลี่ยน &MyBox<String> เป็น &String ได้โดยการเรียกใช้ deref ไลบรารีมาตรฐานได้จัดเตรียมการประยุกต์ใช้ Deref บน String ที่คืนค่าสไลซ์ข้อความไว้ให้ ซึ่งมีอยู่ในเอกสาร API สำหรับ Deref Rust จะเรียก deref อีกครั้งเพื่อเปลี่ยน &String ให้เป็น &str ซึ่งตรงกับการนิยามของฟังก์ชัน hello

หาก Rust ไม่ได้ประยุกต์ใช้ deref coercion เราจะต้องเขียนโค้ดในโค้ดตัวอย่างที่ 15-13 แทนโค้ดในโค้ดตัวอย่างที่ 15-12 เพื่อเรียกใช้ hello ด้วยค่าชนิด &MyBox<String>

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

ส่วน (*m) จะลดระดับการอ้างอิง MyBox<String> ให้กลายเป็น String จากนั้น & และ [..] จะรับสไลซ์ข้อความของ String นั้นซึ่งเท่ากับสตริงทั้งหมดเพื่อความตรงกับลายเซ็นของ hello โค้ดนี้หากไม่มี deref coercion จะอ่าน เขียน และทำความเข้าใจได้ยากกว่าเนื่องจากมีสัญลักษณ์ทั้งหมดเข้ามาเกี่ยวข้อง Deref coercion ช่วยให้ Rust จัดการการแปลงเหล่านี้ให้เราโดยอัตโนมัติ

เมื่อเทรต Deref ถูกนิยามขึ้นสำหรับชนิดข้อมูลที่เกี่ยวข้อง Rust จะวิเคราะห์ชนิดข้อมูลและใช้ Deref::deref บ่อยเท่าที่จำเป็นเพื่อให้ได้การอ้างอิงที่ตรงกับชนิดของพารามิเตอร์ จำนวนครั้งที่ต้องแทรก Deref::deref จะถูกตัดสินใจในขณะคอมไพล์ ดังนั้นจึงไม่มีบทลงโทษด้านเวลาทำงาน (runtime penalty) สำหรับการใช้ประโยชน์จาก deref coercion!

การจัดการ Deref Coercion กับการอ้างอิงแบบแก้ไขได้

คล้ายกับวิธีที่คุณใช้เทรต Deref เพื่อเขียนทับตัวดำเนินการ * บนการอ้างอิงแบบแก้ไขไม่ได้ คุณสามารถใช้เทรต DerefMut เพื่อเขียนทับตัวดำเนินการ * บนการอ้างอิงแบบแก้ไขได้ (mutable references) ได้

Rust จะทำ deref coercion เมื่อพบชนิดข้อมูลและการประยุกต์ใช้เทรตใน 3 กรณี:

  1. จาก &T เป็น &U เมื่อ T: Deref<Target=U>
  2. จาก &mut T เป็น &mut U เมื่อ T: DerefMut<Target=U>
  3. จาก &mut T เป็น &U เมื่อ T: Deref<Target=U>

สองกรณีแรกเหมือนกันยกเว้นว่ากรณีที่สองจะประยุกต์ใช้ความสามารถในการแก้ไขได้ กรณีแรกระบุว่าหากคุณมี &T และ T ประยุกต์ใช้ Deref ไปยังชนิดข้อมูล U บางชนิด คุณจะสามารถรับ &U ได้อย่างโปร่งใส กรณีที่สองระบุว่า deref coercion เดียวกันจะเกิดขึ้นสำหรับการอ้างอิงแบบแก้ไขได้

กรณีที่สามจะซับซ้อนกว่า: Rust จะบังคับชนิดข้อมูลจากการอ้างอิงแบบแก้ไขได้ไปเป็นการอ้างอิงแบบแก้ไขไม่ได้ด้วย แต่ทิศทางตรงกันข้ามจะไม่สามารถทำได้: การอ้างอิงแบบแก้ไขไม่ได้จะไม่มีวันบังคับชนิดข้อมูลไปเป็น การอ้างอิงแบบแก้ไขได้ เนื่องจากกฎการยืม (borrowing rules) หากคุณมีการอ้างอิงแบบแก้ไขได้ การอ้างอิงแบบแก้ไขได้นั้นจะต้องเป็น การอ้างอิงเดียวที่ชี้ไปยังข้อมูลนั้น (มิฉะนั้นโปรแกรมจะไม่คอมไพล์) การแปลงการอ้างอิงแบบแก้ไขได้หนึ่งอันไปเป็นการอ้างอิงแบบแก้ไขไม่ได้หนึ่งอันจะไม่มีวันละเมิดกฎการยืม แต่การแปลงการอ้างอิงแบบแก้ไขไม่ได้ไปเป็น การอ้างอิงแบบแก้ไขได้ จำเป็นต้องให้การอ้างอิงแบบแก้ไขไม่ได้เริ่มต้นเป็นเพียงการอ้างอิงเดียวที่ชี้ไปยังข้อมูลนั้น ซึ่งกฎการยืมไม่สามารถรับประกันสิ่งนั้นได้ ดังนั้น Rust จึงไม่สามารถตั้งสมมติฐานได้ว่าการแปลงการอ้างอิงแบบแก้ไขไม่ได้ไปเป็น การอ้างอิงแบบแก้ไขได้ นั้นเป็นไปได้

Running Code on Cleanup with the Drop Trait

การรันโค้ดเมื่อทำการล้างทำความสะอาดด้วยเทรต Drop

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

เรากำลังแนะนำ Drop ในบริบทของพอยน์เตอร์อัจฉริยะเพราะคุณสมบัติการทำงานของเทรต Drop เกือบทั้งหมดจะถูกใช้เมื่อประยุกต์ใช้งานพอยน์เตอร์อัจฉริยะ ตัวอย่างเช่น เมื่อ Box<T> ถูกคืนหน่วยความจำ (dropped) มันจะคืนพื้นที่บนฮีปที่ box ชี้ไป

ในบางภาษา สำหรับบางชนิดข้อมูล โปรแกรมเมอร์ต้องเรียกใช้โค้ดเพื่อคืนหน่วยความจำหรือทรัพยากรทุกครั้งเมื่อพวกเขาสูญเสียหรือเลิกใช้งานอินสแตนซ์ของชนิดข้อมูลเหล่านั้น ตัวอย่างเช่น ตัวจัดการไฟล์ (file handles), ซ็อกเก็ต (sockets), และล็อก (locks) หากโปรแกรมเมอร์ลืม ระบบอาจทำงานหนักเกินไปและล่มได้ ใน Rust คุณสามารถระบุให้โค้ดบางส่วนถูกรันทุกครั้งที่ค่าหลุดออกจากขอบเขต และคอมไพเลอร์จะแทรกโค้ดนี้ให้อย่างอัตโนมัติ เป็นผลให้คุณไม่จำเป็นต้องระมัดระวังในการวางโค้ดล้างทำความสะอาดไว้ทุกที่ในโปรแกรมเมื่ออินสแตนซ์ของชนิดข้อมูลนั้นใช้งานเสร็จแล้ว—และคุณจะไม่ทำทรัพยากรรั่วไหล!

คุณระบุโค้ดที่จะให้รันเมื่อค่าหลุดออกจากขอบเขตโดยการประยุกต์ใช้เทรต Drop เทรต Drop กำหนดให้คุณต้องประยุกต์ใช้หนึ่งเมธอดชื่อ drop ซึ่งรับการอ้างอิงแบบแก้ไขได้ไปยัง self ในการดูว่า Rust เรียกใช้ drop เมื่อใด ให้เราลองประยุกต์ใช้ drop ร่วมกับคำสั่ง println! สำหรับตอนนี้

โค้ดตัวอย่างที่ 15-14 แสดง struct CustomSmartPointer ซึ่งฟังก์ชันการทำงานที่กำหนดเองเพียงอย่างเดียวของมันคือจะพิมพ์ Dropping CustomSmartPointer! ออกมาเมื่ออินสแตนซ์หลุดออกจากขอบเขต เพื่อแสดงให้เห็นว่าเมื่อใดที่ Rust จะรันเมธอด drop

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created");
}

เทรต Drop ถูกรวมไว้ในพรีลูด (prelude) แล้ว ดังนั้นเราจึงไม่จำเป็นต้องนำมันเข้ามาในขอบเขตอย่างชัดเจน เราประยุกต์ใช้เทรต Drop บน CustomSmartPointer และจัดเตรียมการประยุกต์ใช้งานสำหรับเมธอด drop ที่เรียกใช้ println! เนื้อหาของเมธอด drop คือตำแหน่งที่คุณจะวางตรรกะใด ๆ ที่คุณต้องการให้รันเมื่ออินสแตนซ์ของชนิดข้อมูลของคุณหลุดออกจากขอบเขต เรากำลังพิมพ์ข้อความบางอย่างตรงนี้เพื่อสาธิตให้เห็นทางสายตาว่า Rust จะเรียกใช้ drop เมื่อใด

ใน main เราสร้างสองอินสแตนซ์ของ CustomSmartPointer แล้วพิมพ์ CustomSmartPointers created ณ จุดสิ้นสุดของ main อินสแตนซ์ของ CustomSmartPointer ของเราจะหลุดออกจากขอบเขต และ Rust จะเรียกใช้โค้ดที่เราใส่ไว้ในเมธอด drop เพื่อพิมพ์ข้อความสุดท้ายของเรา สังเกตว่าเราไม่จำเป็นต้องเรียกใช้เมธอด drop อย่างชัดเจนเลย

เมื่อเรารันโปรแกรมนี้ เราจะเห็นผลลัพธ์ต่อไปนี้:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

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

น่าเสียดายที่การปิดการทำงานของฟังก์ชัน drop อัตโนมัตินั้นทำได้ไม่ตรงไปตรงมา การปิดการทำงานของ drop มักไม่จำเป็น จุดประสงค์ทั้งหมดของเทรต Drop คือมันได้รับการดูแลโดยอัตโนมัติ อย่างไรก็ตาม ในบางครั้ง คุณอาจต้องการล้างทำความสะอาดค่าก่อนเวลา ตัวอย่างหนึ่งคือเมื่อใช้พอยน์เตอร์อัจฉริยะที่จัดการกับล็อก (locks): คุณอาจต้องการบังคับเมธอด drop เพื่อปลดล็อกเพื่อให้โค้ดอื่นในขอบเขตเดียวกันสามารถครอบครองล็อกได้ Rust ไม่ยอมให้คุณเรียกใช้เมธอด drop ของเทรต Drop ด้วยตนเอง แต่คุณต้องเรียกใช้ฟังก์ชัน std::mem::drop ที่จัดเตรียมโดยไลบรารีมาตรฐานหากคุณต้องการบังคับให้ค่านั้นถูกล้างออกก่อนที่จะถึงจุดสิ้นสุดของขอบเขตของมัน

การพยายามเรียกใช้เมธอด drop ของเทรต Drop ด้วยตนเองโดยการแก้ไขฟังก์ชัน main จากโค้ดตัวอย่างที่ 15-14 จะไม่ทำงาน ดังแสดงในโค้ดตัวอย่างที่ 15-15

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main");
}

เมื่อเราลองคอมไพล์โค้ดนี้ เราจะได้ข้อผิดพลาดนี้:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 -     c.drop();
16 +     drop(c);
   |

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error

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

Rust ไม่ยอมให้เราเรียกใช้ drop อย่างชัดเจนเพราะ Rust จะยังคงเรียกใช้ drop บนค่านั้นโดยอัตโนมัติอีกครั้ง ณ จุดสิ้นสุดของ main สิ่งนี้จะทำให้เกิดข้อผิดพลาดการคืนหน่วยความจำซ้ำซ้อน (double free error) เนื่องจาก Rust จะพยายามล้างทำความสะอาดค่าเดิมสองครั้ง

เราไม่สามารถปิดการแทรก drop อัตโนมัติเมื่อค่าหลุดออกจากขอบเขตได้ และเราไม่สามารถเรียกใช้เมธอด drop อย่างชัดเจนได้ ดังนั้น หากเราจำเป็นต้องบังคับให้ล้างทำความสะอาดค่าก่อนเวลา เราจึงใช้ฟังก์ชัน std::mem::drop

ฟังก์ชัน std::mem::drop มีความแตกต่างจากเมธอด drop ในเทรต Drop เราเรียกใช้มันโดยส่งค่าที่เราต้องการบังคับล้างเป็นอาร์กิวเมนต์ ฟังก์ชันนี้อยู่ในพรีลูด ดังนั้นเราจึงสามารถแก้ไข main ในโค้ดตัวอย่างที่ 15-15 เพื่อเรียกใช้ฟังก์ชัน drop ได้ ดังแสดงในโค้ดตัวอย่างที่ 15-16

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main");
}

การรันโค้ดนี้จะพิมพ์ผลลัพธ์ดังนี้:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main

ข้อความ Dropping CustomSmartPointer with data `some data`! จะถูกพิมพ์อยู่ระหว่างข้อความ CustomSmartPointer created และ CustomSmartPointer dropped before the end of main ซึ่งแสดงให้เห็นว่าโค้ดเมธอด drop ถูกเรียกใช้เพื่อคืนหน่วยความจำ c ณ จุดนั้น

คุณสามารถใช้โค้ดที่ระบุในการประยุกต์ใช้เทรต Drop ได้หลายวิธีเพื่อทำให้การล้างทำความสะอาดสะดวกและปลอดภัย: ตัวอย่างเช่น คุณสามารถใช้มันเพื่อสร้างตัวจัดสรรหน่วยความจำ (memory allocator) ของคุณเองได้! ด้วยเทรต Drop และระบบความเป็นเจ้าของของ Rust คุณไม่ต้องคอยจำว่าต้องล้างทำความสะอาด เพราะ Rust จะทำให้โดยอัตโนมัติ

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

ตอนนี้เมื่อเราได้ตรวจสอบ Box<T> และคุณลักษณะบางประการของพอยน์เตอร์อัจฉริยะแล้ว มาดูพอยน์เตอร์อัจฉริยะอื่น ๆ ที่นิยามไว้ในไลบรารีมาตรฐานกันบ้าง

Rc<T>, the Reference Counted Smart Pointer

Rc<T> พอยน์เตอร์อัจฉริยะแบบนับจำนวนการอ้างอิง

ในกรณีส่วนใหญ่ ความเป็นเจ้าของจะชัดเจน: คุณรู้ได้อย่างแม่นยำว่าตัวแปรใดเป็นเจ้าของค่าที่กำหนด อย่างไรก็ตาม มีบางกรณีที่ค่าเดียวอาจจะมีเจ้าของได้หลายคน ตัวอย่างเช่น ในโครงสร้างข้อมูลกราฟ (graph data structures) เส้นเชื่อม (edges) หลายเส้นอาจจะชี้ไปยังโหนด (node) เดียวกัน และโหนดนั้นตามแนวคิดแล้วก็ตกเป็นเจ้าของโดยเส้นเชื่อมทั้งหมดที่ชี้มายังโหนดนั้น โหนดไม่ควรถูกล้างทำความสะอาดเว้นแต่ว่าจะไม่มีเส้นเชื่อมใดชี้มาที่มันอีก และดังนั้นจึงไม่มีเจ้าของเหลืออยู่อีกต่อไป

คุณต้องเปิดใช้งานความเป็นเจ้าของแบบหลายคนอย่างชัดเจนโดยใช้ชนิดข้อมูล Rust ที่เรียกว่า Rc<T> ซึ่งเป็นตัวย่อมาจาก การนับจำนวนการอ้างอิง (reference counting) ชนิดข้อมูล Rc<T> จะคอยติดตามจำนวนการอ้างอิงไปยังค่าหนึ่ง ๆ เพื่อพิจารณาว่าค่านั้นยังถูกใช้งานอยู่หรือไม่ หากมีจำนวนการอ้างอิงไปยังค่านั้นเป็นศูนย์ ค่านั้นจะสามารถถูกล้างทำความสะอาดได้โดยไม่มีการอ้างอิงใด ๆ กลายเป็นค่าที่ไม่ถูกต้อง

ลองจินตนาการถึง Rc<T> เสมือนทีวีในห้องนั่งเล่นของครอบครัว เมื่อมีคนหนึ่งเดินเข้ามาดูทีวี พวกเขาจะเปิดทีวี ผู้อื่นสามารถเข้ามาในห้องและดูทีวีด้วยกันได้ เมื่อคนสุดท้ายออกจากห้องไป พวกเขาจะปิดทีวีเพราะไม่มีใครใช้งานแล้ว หากมีคนปิดทีวีในขณะที่ผู้อื่นยังคงดูอยู่ ก็คงจะเกิดการโวยวายจากผู้ชมทีวีที่เหลืออย่างแน่นอน!

เราใช้ชนิดข้อมูล Rc<T> เมื่อเราต้องการจัดสรรข้อมูลบางอย่างไว้บนฮีปเพื่อให้หลาย ๆ ส่วนของโปรแกรมของเราสามารถอ่านค่าได้ และเราไม่สามารถพิจารณาในขณะคอมไพล์ได้ว่าส่วนใดจะใช้งานข้อมูลเสร็จสิ้นเป็นส่วนสุดท้าย หากเรารู้ว่าส่วนใดจะเสร็จสิ้นเป็นส่วนสุดท้าย เราก็สามารถกำหนดให้ส่วนนั้นเป็นเจ้าของข้อมูลได้ และกฎความเป็นเจ้าของทั่วไปที่บังคับใช้ในขณะคอมไพล์ก็จะมีผลทำหน้าที่ไป

โปรดทราบว่า Rc<T> มีไว้สำหรับการใช้งานในสถานการณ์เธรดเดียว (single-threaded scenarios) เท่านั้น เมื่อเราพูดถึงการทำงานพร้อมกัน (concurrency) ในบทที่ 16 เราจะครอบคลุมวิธีนับจำนวนการอ้างอิงในโปรแกรมแบบหลายเธรด (multithreaded programs)

การแบ่งปันข้อมูล

กลับมาที่ตัวอย่าง cons list ของเราในโค้ดตัวอย่างที่ 15-5 โปรดระลึกว่าเราได้นิยามมันขึ้นโดยใช้ Box<T> ในครั้งนี้ เราจะสร้างสองลิสต์ที่ทั้งคู่แบ่งปันความเป็นเจ้าของของลิสต์ที่สามร่วมกัน ในทางแนวคิด สิ่งนี้ดูคล้ายกับรูปภาพที่ 15-3

ลิงก์ลิสต์ป้ายชื่อ 'a' ชี้ไปยังสามองค์ประกอบ องค์ประกอบแรกบรรจุจำนวนเต็ม 5 และชี้ไปยังองค์ประกอบที่สอง องค์ประกอบที่สองบรรจุจำนวนเต็ม 10 และชี้ไปยังองค์ประกอบที่สาม องค์ประกอบที่สามบรรจุค่า 'Nil' ที่แสดงถึงการสิ้นสุดของลิสต์ โดยไม่ชี้ไปที่ใด ลิงก์ลิสต์ป้ายชื่อ 'b' ชี้ไปยังองค์ประกอบที่บรรจุจำนวนเต็ม 3 และชี้ไปยังองค์ประกอบแรกของลิสต์ 'a' ลิงก์ลิสต์ป้ายชื่อ 'c' ชี้ไปยังองค์ประกอบที่บรรจุจำนวนเต็ม 4 และชี้ไปยังองค์ประกอบแรกของลิสต์ 'a' เช่นกัน เพื่อให้ส่วนหางของลิสต์ 'b' และ 'c' เป็นลิสต์ 'a' ทั้งคู่

รูปภาพที่ 15-3: สองลิสต์ คือ b และ c แบ่งปันความเป็นเจ้าของของลิสต์ที่สาม คือ a

เราจะสร้างลิสต์ a ที่บรรจุ 5 แล้วตามด้วย 10 จากนั้นเราจะสร้างอีกสองลิสต์: b ที่เริ่มต้นด้วย 3 และ c ที่เริ่มต้นด้วย 4 ลิสต์ทั้ง b และ c จะไปต่อที่ลิสต์ a แรกที่บรรจุ 5 และ 10 หรือกล่าวอีกนัยหนึ่ง ลิสต์ทั้งสองจะแบ่งปันลิสต์แรกที่บรรจุ 5 และ 10 ร่วมกัน

การพยายามประยุกต์ใช้สถานการณ์นี้โดยใช้นิยาม List ของเราด้วย Box<T> จะไม่ทำงาน ดังแสดงในโค้ดตัวอย่างที่ 15-17

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

เมื่อเราคอมไพล์โค้ดนี้ เราจะได้ข้อผิดพลาดนี้:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
 9 |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move
   |
note: if `List` implemented `Clone`, you could clone the value
  --> src/main.rs:1:1
   |
 1 | enum List {
   | ^^^^^^^^^ consider implementing `Clone` for this type
...
10 |     let b = Cons(3, Box::new(a));
   |                              - you could clone this value

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

ตัวแปรย่อย Cons เป็นเจ้าของข้อมูลที่มันถือครอง ดังนั้นเมื่อเราสร้างลิสต์ b ตัวแปร a จึงถูกย้าย (moved) เข้าไปใน b และ b จึงกลายเป็นเจ้าของ a จากนั้น เมื่อเราพยายามใช้ a อีกครั้งตอนสร้าง c เราจึงไม่ได้รับอนุญาตเนื่องจาก a ถูกย้ายไปแล้ว

เราอาจเปลี่ยนนิยามของ Cons ให้ถือครองการอ้างอิงแทนได้ แต่นั่นหมายความว่าเราจะต้องระบุพารามิเตอร์ช่วงชีวิต (lifetime parameters) การระบุพารามิเตอร์ช่วงชีวิตจะเป็นการระบุว่าสมาชิกทุกตัวในลิสต์จะคงมีชีวิตอยู่อย่างน้อยนานเท่ากับลิสต์ทั้งหมด ซึ่งเป็นกรณีสำหรับสมาชิกและลิสต์ในโค้ดตัวอย่างที่ 15-17 แต่ไม่ใช่ในทุกสถานการณ์

แทนที่จะทำเช่นนั้น เราจะเปลี่ยนนิยามของ List ให้ใช้ Rc<T> แทนที่ Box<T> ดังแสดงในโค้ดตัวอย่างที่ 15-18 แต่ละตัวแปรย่อย Cons ตอนนี้จะถือครองค่าหนึ่งค่าและ Rc<T> ที่ชี้ไปยัง List เมื่อเราสร้าง b แทนที่จะรับความเป็นเจ้าของของ a เราจะทำสำเนา (clone) Rc<List> ที่ a ถือครองไว้ ซึ่งจะเพิ่มจำนวนการอ้างอิงจากหนึ่งเป็นสอง และยอมให้ a และ b แบ่งปันความเป็นเจ้าของของข้อมูลใน Rc<List> นั้นร่วมกัน เราจะทำสำเนา a เช่นกันตอนสร้าง c ซึ่งจะเพิ่มจำนวนการอ้างอิงจากสองเป็นสาม ทุกครั้งที่เราเรียกใช้ Rc::clone จำนวนการอ้างอิงไปยังข้อมูลภายใน Rc<List> จะเพิ่มขึ้น และข้อมูลจะไม่ถูกล้างทำความสะอาดเว้นแต่จะเหลือจำนวนการอ้างอิงเป็นศูนย์

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

เราจำเป็นต้องเพิ่มคำสั่ง use เพื่อนำ Rc<T> เข้ามาในขอบเขตเพราะมันไม่ได้อยู่ในพรีลูด (prelude) ใน main เราสร้างลิสต์ที่บรรจุ 5 และ 10 แล้วเก็บไว้ใน Rc<List> ใหม่ใน a จากนั้น เมื่อเราสร้าง b และ c เราจะเรียกฟังก์ชัน Rc::clone และส่งการอ้างอิงไปยัง Rc<List> ใน a เป็นอาร์กิวเมนต์

เราอาจเรียกใช้ a.clone() แทนที่จะเป็น Rc::clone(&a) ได้ แต่ธรรมเนียมของ Rust คือการใช้ Rc::clone ในกรณีนี้ การประยุกต์ใช้ Rc::clone ไม่ได้สร้างสำเนาเชิงลึก (deep copy) ของข้อมูลทั้งหมดเหมือนการประยุกต์ใช้ clone ของชนิดข้อมูลส่วนใหญ่ การเรียกใช้ Rc::clone จะเพียงแค่เพิ่มจำนวนการอ้างอิงขึ้น ซึ่งไม่ได้ใช้เวลามาก การทำสำเนาข้อมูลเชิงลึกสามารถใช้เวลาได้มาก การใช้ Rc::clone สำหรับการนับจำนวนการอ้างอิงช่วยให้เราสามารถแยกแยะความแตกต่างทางสายตาระหว่างชนิดการทำสำเนาเชิงลึกกับการทำสำเนาที่เพิ่มจำนวนการอ้างอิงได้ เมื่อมองหาปัญหาด้านประสิทธิภาพในโค้ด เราจึงต้องพิจารณาเฉพาะการทำสำเนาเชิงลึกเท่านั้นและสามารถมองข้ามการเรียกใช้ Rc::clone ได้

การทำสำเนา (Clone) เพื่อเพิ่มจำนวนการอ้างอิง

มาเปลี่ยนตัวอย่างการทำงานของเราในโค้ดตัวอย่างที่ 15-18 เพื่อให้เราเห็นจำนวนการอ้างอิงที่เปลี่ยนแปลงไปขณะที่เราสร้างและปล่อยการอ้างอิงไปยัง Rc<List> ใน a

ในโค้ดตัวอย่างที่ 15-19 เราจะเปลี่ยน main ให้มีขอบเขตภายในล้อมรอบลิสต์ c จากนั้น เราสามารถเห็นได้ว่าจำนวนการอ้างอิงเปลี่ยนแปลงไปอย่างไรเมื่อ c หลุดออกจากขอบเขต

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

// --snip--

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

ณ แต่ละจุดในโปรแกรมที่จำนวนการอ้างอิงเปลี่ยนแปลง เราจะพิมพ์จำนวนการอ้างอิง ซึ่งเราได้จากการเรียกใช้ฟังก์ชัน Rc::strong_count ฟังก์ชันนี้ถูกตั้งชื่อว่า strong_count แทนที่จะเป็น count เพราะชนิดข้อมูล Rc<T> มี weak_count ด้วยเช่นกัน เราจะเห็นว่า weak_count ถูกใช้ทำอะไรในหัวข้อ “การป้องกันวงจรการอ้างอิงด้วยการใช้ Weak<T>

โค้ดนี้พิมพ์ผลลัพธ์ต่อไปนี้:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

เราจะเห็นว่า Rc<List> ใน a มีจำนวนการอ้างอิงเริ่มต้นเป็น 1 จากนั้น แต่ละครั้งที่เราเรียกใช้ clone จำนวนจะเพิ่มขึ้นทีละ 1 เมื่อ c หลุดออกจากขอบเขต จำนวนจะลดลงทีละ 1 เราไม่ต้องเรียกใช้ฟังก์ชันเพื่อลดจำนวนการอ้างอิงเหมือนที่เราต้องเรียกใช้ Rc::clone เพื่อเพิ่มจำนวนการอ้างอิง: การประยุกต์ใช้เทรต Drop จะลดจำนวนการอ้างอิงลงโดยอัตโนมัติเมื่อค่า Rc<T> หลุดออกจากขอบเขต

สิ่งที่เราไม่เห็นในตัวอย่างนี้คือเมื่อ b แล้วตามด้วย a หลุดออกจากขอบเขต ณ จุดสิ้นสุดของ main จำนวนการอ้างอิงจะเป็น 0 และ Rc<List> จะถูกล้างทำความสะอาดอย่างสมบูรณ์ การใช้ Rc<T> ยินยอมให้ค่าเดียวมีเจ้าของได้หลายคน และตัวนับจะช่วยให้มั่นใจว่าค่านั้นยังคงถูกต้องตราบเท่าที่ยังมีเจ้าของคนใดคนหนึ่งดำรงอยู่

ผ่านทางการอ้างอิงแบบแก้ไขไม่ได้ Rc<T> ช่วยให้คุณสามารถแบ่งปันข้อมูลระหว่างหลาย ๆ ส่วนของโปรแกรมสำหรับการอ่านเท่านั้น หาก Rc<T> ยินยอมให้คุณมีการอ้างอิงแบบแก้ไขได้หลายอันด้วย คุณอาจละเมิดกฎการยืมกฎข้อหนึ่งที่พูดถึงในบทที่ 4: การยืมแบบแก้ไขได้หลายตำแหน่งไปยังจุดเดียวกันอาจทำให้เกิดสภาวะแข่งขันของข้อมูล (data races) และความไม่สอดคล้องกันได้ แต่การสามารถแก้ไขข้อมูลได้ก็มีประโยชน์อย่างมาก! ในส่วนถัดไป เราจะพูดถึงรูปแบบการแก้ไขภายใน (interior mutability pattern) และชนิดข้อมูล RefCell<T> ที่คุณสามารถใช้ร่วมกับ Rc<T> เพื่อทำงานกับข้อจำกัดของการแก้ไขไม่ได้นี้

RefCell<T> and the Interior Mutability Pattern

RefCell<T> และรูปแบบการแก้ไขภายใน (Interior Mutability Pattern)

การแก้ไขภายใน (Interior mutability) คือรูปแบบการออกแบบ (design pattern) ใน Rust ที่ช่วยให้คุณสามารถแก้ไขข้อมูลได้ แม้ว่าจะมี การอ้างอิงแบบแก้ไขไม่ได้ไปยังข้อมูลนั้นก็ตาม โดยปกติแล้ว แอ็กชันนี้จะไม่ได้รับอนุญาตตามกฎการยืม ในการแก้ไขข้อมูล รูปแบบนี้จะใช้โค้ด unsafe ภายในโครงสร้างข้อมูลเพื่อผ่อนผันกฎปกติของ Rust ที่ควบคุมการแก้ไขและการยืม โค้ด Unsafe บ่งบอกต่อคอมไพเลอร์ว่าเรากำลังตรวจสอบกฎด้วยตนเองแทนที่จะพึ่งพาคอมไพเลอร์ในการตรวจสอบให้เรา เราจะพูดถึงโค้ด unsafe เพิ่มเติมในบทที่ 20

เราสามารถใช้ชนิดข้อมูลที่ใช้รูปแบบการแก้ไขภายในได้เฉพาะเมื่อเราสามารถมั่นใจได้ว่ากฎการยืมจะได้รับการปฏิบัติตามขณะรันไทม์ (runtime) แม้ว่าคอมไพเลอร์จะไม่สามารถรับประกันสิ่งนั้นในขณะคอมไพล์ได้ โค้ด unsafe ที่เกี่ยวข้องจะถูกห่อหุ้มไว้ใน API ที่ปลอดภัย (safe API) และชนิดข้อมูลภายนอกยังคงแก้ไขไม่ได้

มาสำรวจแนวคิดนี้โดยดูที่ชนิดข้อมูล RefCell<T> ซึ่งเป็นไปตามรูปแบบการแก้ไขภายใน

การบังคับใช้กฎการยืมขณะรันไทม์

ต่างจาก Rc<T> ชนิดข้อมูล RefCell<T> ตัวแทนของความเป็นเจ้าของคนเดียว (single ownership) บนข้อมูลที่มันถือครอง ดังนั้น สิ่งใดที่ทำให้ RefCell<T> แตกต่างจากชนิดข้อมูลอย่าง Box<T>? โปรดระลึกถึงกฎการยืมที่คุณได้เรียนรู้ในบทที่ 4:

  • ณ เวลาใดเวลาหนึ่ง คุณสามารถมีการอ้างอิงแบบแก้ไขได้หนึ่งอัน หรือมีการอ้างอิงแบบแก้ไขไม่ได้กี่อันก็ได้ (แต่ไม่ใช่มีทั้งสองแบบพร้อมกัน)
  • การอ้างอิงจะต้องถูกต้องอยู่เสมอ

สำหรับการอ้างอิงและ Box<T> เงื่อนไขคงที่ของกฎการยืมจะถูกบังคับใช้ในขณะคอมไพล์ (compile time) สำหรับ RefCell<T> เงื่อนไขคงที่เหล่านี้จะถูกบังคับใช้ขณะรันไทม์ (runtime) สำหรับการอ้างอิง หากคุณละเมิดกฎเหล่านี้ คุณจะได้ข้อผิดพลาดจากคอมไพเลอร์ สำหรับ RefCell<T> หากคุณละเมิดกฎเหล่านี้ โปรแกรมของคุณจะเกิด panic และตื่นตระหนกจนจบการทำงาน

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

ข้อดีของการตรวจสอบกฎการยืมขณะรันไทม์แทนคือ สถานการณ์ด้านความปลอดภัยของหน่วยความจำบางอย่างจะได้รับอนุญาต ในจุดที่พวกมันเคยถูกปฏิเสธโดยการตรวจสอบในขณะคอมไพล์ การวิเคราะห์แบบสแตติก (static analysis) เช่น คอมไพเลอร์ของ Rust โดยธรรมชาติแล้วจะเน้นความปลอดภัยไว้ก่อน (conservative) คุณสมบัติบางประการของโค้ดไม่สามารถตรวจจับได้โดยการวิเคราะห์โค้ด: ตัวอย่างที่มีชื่อเสียงที่สุดคือปัญหาการหยุดทำงาน (Halting Problem) ซึ่งอยู่นอกเหนือขอบเขตของหนังสือเล่มนี้ แต่เป็นหัวข้อที่น่าสนใจในการค้นคว้า

เนื่องจากการวิเคราะห์บางอย่างเป็นไปไม่ได้ หากคอมไพเลอร์ของ Rust ไม่สามารถแน่ใจได้ว่าโค้ดปฏิบัติตามกฎความเป็นเจ้าของ คอมไพเลอร์อาจจะปฏิเสธโปรแกรมที่ถูกต้อง ด้วยวิธีนี้ มันจึงเน้นความปลอดภัยไว้ก่อน หาก Rust ยอมรับโปรแกรมที่ไม่ถูกต้อง ผู้ใช้จะไม่สามารถไว้วางใจในการรับประกันที่ Rust มอบให้ได้ อย่างไรก็ตาม หาก Rust ปฏิเสธโปรแกรมที่ถูกต้อง โปรแกรมเมอร์อาจจะได้รับความไม่สะดวก แต่จะไม่มีเหตุการณ์หายนะเกิดขึ้น ชนิดข้อมูล RefCell<T> มีประโยชน์เมื่อคุณแน่ใจว่าโค้ดของคุณปฏิบัติตามกฎการยืม แต่คอมไพเลอร์ไม่สามารถเข้าใจและรับประกันสิ่งนั้นได้

คล้ายกับ Rc<T> ชนิดข้อมูล RefCell<T> มีไว้สำหรับใช้ในสถานการณ์เธรดเดียวเท่านั้น และจะให้ข้อผิดพลาดในขณะคอมไพล์หากคุณพยายามใช้มันในบริบทหลายเธรด เราจะพูดถึงวิธีรับฟังก์ชันการทำงานของ RefCell<T> ในโปรแกรมแบบหลายเธรดในบทที่ 16

นี่คือสรุปเหตุผลในการเลือกใช้ Box<T>, Rc<T>, หรือ RefCell<T>:

  • Rc<T> อนุญาตให้มีเจ้าของหลายคนสำหรับข้อมูลเดียวกัน; Box<T> และ RefCell<T> มีเจ้าของเพียงคนเดียว
  • Box<T> ยินยอมให้ยืมแบบแก้ไขไม่ได้หรือแก้ไขได้ซึ่งตรวจสอบในขณะคอมไพล์; Rc<T> ยินยอมเฉพาะการยืมแบบแก้ไขไม่ได้ซึ่งตรวจสอบในขณะคอมไพล์; RefCell<T> ยินยอมให้ยืมแบบแก้ไขไม่ได้หรือแก้ไขได้ซึ่งตรวจสอบขณะรันไทม์
  • เนื่องจาก RefCell<T> ยินยอมให้ยืมแบบแก้ไขได้ซึ่งตรวจสอบขณะรันไทม์ คุณจึงสามารถแก้ไขค่าภายใน RefCell<T> ได้ แม้ว่า RefCell<T> นั้นจะแก้ไขไม่ได้ก็ตาม

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

การใช้รูปแบบการแก้ไขภายใน

ผลลัพธ์ประการหนึ่งของกฎการยืมคือเมื่อคุณมีค่าที่ไม่สามารถแก้ไขได้ คุณจะไม่สามารถยืมมันแบบแก้ไขได้ ตัวอย่างเช่น โค้ดนี้จะไม่สามารถคอมไพล์ได้:

fn main() {
    let x = 5;
    let y = &mut x;
}

หากคุณลองคอมไพล์โค้ดนี้ คุณจะได้ข้อผิดพลาดต่อไปนี้:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

อย่างไรก็ตาม มีสถานการณ์ที่มีประโยชน์สำหรับค่าในการแก้ไขตัวเองในเมธอดของมัน แต่ปรากฏว่าแก้ไขไม่ได้ต่อโค้ดอื่น โค้ดภายนอกเมธอดของค่านั้นจะไม่สามารถแก้ไขค่านั้นได้ การใช้ RefCell<T> เป็นวิธีหนึ่งในการรับความสามารถในการแก้ไขภายใน แต่ RefCell<T> ไม่ได้ก้าวข้ามกฎการยืมไปโดยสิ้นเชิง: ตัวตรวจการยืม (borrow checker) ในคอมไพเลอร์ยินยอมการแก้ไขภายในนี้ และกฎการยืมจะถูกตรวจสอบขณะรันไทม์แทน หากคุณละเมิดกฎ คุณจะได้ panic! แทนที่จะเป็นข้อผิดพลาดจากคอมไพเลอร์

มาทำงานผ่านตัวอย่างเชิงปฏิบัติที่เราสามารถใช้ RefCell<T> เพื่อแก้ไขค่าที่แก้ไขไม่ได้ และดูว่าเหตุใดสิ่งนั้นจึงมีประโยชน์

การทดสอบด้วย Mock Objects

บางครั้งในการทดสอบ โปรแกรมเมอร์จะใช้ชนิดข้อมูลหนึ่งแทนอีกชนิดข้อมูลหนึ่ง เพื่อสังเกตพฤติกรรมเฉพาะและยืนยันว่ามันถูกประยุกต์ใช้อย่างถูกต้อง ชนิดข้อมูลตัวแทนนี้เรียกว่า ตัวแสดงแทนในการทดสอบ (test double) ให้ลองคิดในแง่ของตัวแสดงแทนสตันท์ (stunt double) ในการทำภาพยนตร์ ที่บุคคลหนึ่งก้าวเข้ามาแสดงแทนนักแสดงเพื่อทำฉากที่ยากเป็นพิเศษ ตัวแสดงแทนในการทดสอบจะทำหน้าที่แทนชนิดข้อมูลอื่นเมื่อเรารันการทดสอบ วัตถุจำลอง (Mock objects) เป็นชนิดข้อมูลเฉพาะของตัวแสดงแทนในการทดสอบที่จะบันทึกสิ่งที่เกิดขึ้นระหว่างการทดสอบ เพื่อให้คุณสามารถยืนยันได้ว่าแอ็กชันที่ถูกต้องได้เกิดขึ้นแล้ว

Rust ไม่มีวัตถุ (objects) ในความหมายเดียวกับที่ภาษาอื่นมี และ Rust ไม่มีฟังก์ชันการทำงานของวัตถุจำลองที่บิลต์อินมาในไลบรารีมาตรฐานเหมือนที่บางภาษาอื่นมี อย่างไรก็ตาม คุณสามารถสร้าง struct ที่จะทำหน้าที่เพื่อวัตถุประสงค์เดียวกับวัตถุจำลองได้อย่างแน่นอน

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

ไลบรารีของเราจะให้เฉพาะฟังก์ชันการทำงานในการติดตามว่าค่าใกล้อยู่ที่ค่าสูงสุดเท่าใด และข้อความควรจะเป็นอย่างไร ณ เวลาใด แอปพลิเคชันที่ใช้ไลบรารีของเราจะได้รับการคาดหวังให้จัดเตรียมกลไกสำหรับการส่งข้อความเอง: แอปพลิเคชันอาจแสดงข้อความให้ผู้ใช้เห็นโดยตรง ส่งอีเมล ส่งข้อความ SMS หรือทำสิ่งอื่น ไลบรารีไม่จำเป็นต้องรู้รายละเอียดนั้น สิ่งที่มันต้องการคือบางสิ่งที่ประยุกต์ใช้เทรตที่เราจะจัดเตรียมให้ ซึ่งเรียกว่า Messenger โค้ดตัวอย่างที่ 15-20 แสดงโค้ดของไลบรารี

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

ส่วนสำคัญประการหนึ่งของโค้ดนี้คือเทรต Messenger มีเมธอดหนึ่งชื่อ send ที่รับการอ้างอิงแบบแก้ไขไม่ได้ไปยัง self และข้อความ เทรตนี้เป็นอินเทอร์เฟซที่วัตถุจำลองของเราจำเป็นต้องประยุกต์ใช้เพื่อให้วัตถุจำลองสามารถใช้งานได้ในลักษณะเดียวกับวัตถุจริง ส่วนสำคัญอีกประการหนึ่งคือเราต้องการทดสอบพฤติกรรมของเมธอด set_value บน LimitTracker เราสามารถเปลี่ยนสิ่งที่เราส่งเข้าไปสำหรับพารามิเตอร์ value ได้ แต่ set_value ไม่ได้คืนค่าใด ๆ ให้เราทำข้อกำหนดการยืนยัน เราต้องการสามารถบอกได้ว่าหากเราสร้าง LimitTracker ด้วยบางสิ่งที่ประยุกต์ใช้เทรต Messenger และค่าเฉพาะสำหรับ max ตัวส่งข้อความจะได้รับการบอกให้ส่งข้อความที่เหมาะสมเมื่อเราส่งตัวเลขต่าง ๆ สำหรับ value

เราต้องการวัตถุจำลองที่ แทนที่จะส่งอีเมลหรือข้อความ SMS เมื่อเราเรียก send จะเพียงแค่คอยติดตามข้อความที่มันได้รับการบอกให้ส่ง เราสามารถสร้างอินสแตนซ์ใหม่ของวัตถุจำลอง สร้าง LimitTracker ที่ใช้วัตถุจำลองนั้น เรียกเมธอด set_value บน LimitTracker จากนั้นตรวจสอบว่าวัตถุจำลองมีข้อความที่เราคาดหวังไว้หรือไม่ โค้ดตัวอย่างที่ 15-21 แสดงความพยายามประยุกต์ใช้วัตถุจำลองเพื่อทำเช่นนั้น แต่ตัวตรวจการยืมจะไม่ยินยอม

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}

โค้ดการทดสอบนี้นิยาม struct MockMessenger ที่มีฟิลด์ sent_messages เป็น Vec ของค่า String เพื่อคอยติดตามข้อความที่มันได้รับการบอกให้ส่ง เรายังนิยามฟังก์ชันที่เชื่อมโยง new เพื่อให้สะดวกในการสร้างค่า MockMessenger ใหม่ที่เริ่มต้นด้วยรายการข้อความที่ว่างเปล่า จากนั้นเราประยุกต์ใช้เทรต Messenger สำหรับ MockMessenger เพื่อให้เราสามารถส่ง MockMessenger ให้กับ LimitTracker ได้ ในการนิยามเมธอด send เรารับข้อความที่ส่งเข้ามาเป็นพารามิเตอร์และเก็บไว้ในรายการ sent_messages ของ MockMessenger

ในการทดสอบ เรากำลังทดสอบว่าจะเกิดอะไรขึ้นเมื่อ LimitTracker ถูกบอกให้ตั้งค่า value เป็นสิ่งที่มากกว่า 75 เปอร์เซ็นต์ของค่า max อันดับแรก เราสร้าง MockMessenger ใหม่ ซึ่งจะเริ่มด้วยรายการข้อความที่ว่างเปล่า จากนั้นเราสร้าง LimitTracker ใหม่ และให้การอ้างอิงไปยัง MockMessenger ใหม่ และค่า max เท่ากับ 100 เราเรียกเมธอด set_value บน LimitTracker ด้วยค่า 80 ซึ่งมากกว่า 75 เปอร์เซ็นต์ของ 100 จากนั้นเรายืนยันว่ารายการข้อความที่ MockMessenger คอยติดตามควรจะมีข้อความหนึ่งข้อความในนั้นแล้ว

อย่างไรก็ตาม มีปัญหาหนึ่งประการกับการทดสอบนี้ ดังแสดงที่นี่:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so it cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
 2 ~     fn send(&mut self, msg: &str);
 3 | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

เราไม่สามารถแก้ไข MockMessenger เพื่อติดตามข้อความได้ เพราะเมธอด send รับการอ้างอิงแบบแก้ไขไม่ได้ไปยัง self เรายังไม่สามารถรับคำแนะนำจากข้อความผิดพลาดให้ใช้ &mut self ทั้งในเมธอด impl และนิยามเทรตได้ เราไม่ต้องการเปลี่ยนเทรต Messenger เพียงเพื่อการทดสอบเท่านั้น แทนที่จะเป็นเช่นนั้น เราจำเป็นต้องหาวิธีทำให้โค้ดการทดสอบของเราทำงานได้อย่างถูกต้องกับดีไซน์เดิมที่เรามี

นี่คือสถานการณ์ที่การแก้ไขภายในสามารถช่วยได้! เราจะเก็บ sent_messages ไว้ภายใน RefCell<T> จากนั้นเมธอด send จะสามารถแก้ไข sent_messages เพื่อเก็บข้อความที่เราพบได้ โค้ดตัวอย่างที่ 15-22 แสดงว่าหน้าตาเป็นอย่างไร

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

ตอนนี้ฟิลด์ sent_messages เป็นชนิด RefCell<Vec<String>> แทนที่จะเป็น Vec<String> ในฟังก์ชัน new เราสร้างอินสแตนซ์ RefCell<Vec<String>> ใหม่ล้อมรอบเวกเตอร์ที่ว่างเปล่า

สำหรับการประยุกต์ใช้เมธอด send พารามิเตอร์แรกยังคงเป็นการยืมแบบแก้ไขไม่ได้ของ self ซึ่งตรงกับนิยามเทรต เราเรียก borrow_mut บน RefCell<Vec<String>> ใน self.sent_messages เพื่อรับการอ้างอิงแบบแก้ไขได้ไปยังค่าภายใน RefCell<Vec<String>> ซึ่งก็คือเวกเตอร์ จากนั้นเราสามารถเรียก push บนการอ้างอิงแบบแก้ไขได้ของเวกเตอร์เพื่อติดตามข้อความที่ถูกส่งระหว่างการทดสอบได้

การเปลี่ยนแปลงสุดท้ายที่เราต้องทำคือในการยืนยัน: ในการดูว่ามีสมาชิกกี่ตัวในเวกเตอร์ภายใน เราเรียก borrow บน RefCell<Vec<String>> เพื่อรับการอ้างอิงแบบแก้ไขไม่ได้ไปยังเวกเตอร์

ตอนนี้เมื่อคุณได้เห็นวิธีใช้ RefCell<T> แล้ว มาเจาะลึกว่ามันทำงานอย่างไร!

การติดตามการยืมขณะรันไทม์

เมื่อสร้างการอ้างอิงแบบแก้ไขไม่ได้และแบบแก้ไขได้ เราใช้ไวยากรณ์ & และ &mut ตามลำดับ สำหรับ RefCell<T> เราใช้เมธอด borrow และ borrow_mut ซึ่งเป็นส่วนหนึ่งของ API ที่ปลอดภัยของ RefCell<T> เมธอด borrow จะคืนค่าชนิดพอยน์เตอร์อัจฉริยะ Ref<T> และ borrow_mut จะคืนค่าชนิดพอยน์เตอร์อัจฉริยะ RefMut<T> ทั้งสองชนิดประยุกต์ใช้ Deref ดังนั้นเราจึงสามารถปฏิบัติต่อพวกมันเหมือนการอ้างอิงทั่วไปได้

RefCell<T> จะคอยติดตามจำนวนพอยน์เตอร์อัจฉริยะ Ref<T> และ RefMut<T> ที่ใช้งานอยู่ในปัจจุบัน ทุกครั้งที่เราเรียก borrow RefCell<T> จะเพิ่มตัวนับการยืมแบบแก้ไขไม่ได้ที่ใช้งานอยู่ขึ้น 1 เมื่อค่า Ref<T> หลุดออกจากขอบเขต ตัวนับการยืมแบบแก้ไขไม่ได้จะลดลงทีละ 1 เช่นเดียวกับกฎการยืมในขณะคอมไพล์ RefCell<T> ยินยอมให้เรามีการยืมแบบแก้ไขไม่ได้หลายอัน หรือการยืมแบบแก้ไขได้อันเดียว ณ จุดใด ๆ ในเวลา

หากเราพยายามละเมิดกฎเหล่านี้ แทนที่จะได้ข้อผิดพลาดจากคอมไพเลอร์เหมือนที่เราจะได้กับการอ้างอิง การประยุกต์ใช้งานของ RefCell<T> จะเกิด panic ขณะรันไทม์แทน โค้ดตัวอย่างที่ 15-23 แสดงการปรับเปลี่ยนการประยุกต์ใช้ send ในโค้ดตัวอย่างที่ 15-22 เราพยายามเจตนาสร้างการยืมแบบแก้ไขได้สองอันที่ใช้งานในขอบเขตเดียวกันเพื่อแสดงให้เห็นว่า RefCell<T> ป้องกันไม่ให้เราทำเช่นนี้ขณะรันไทม์

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

เราสร้างตัวแปร one_borrow สำหรับพอยน์เตอร์อัจฉริยะ RefMut<T> ที่คืนมาจาก borrow_mut จากนั้นเราสร้างการยืมแบบแก้ไขได้อีกอันในลักษณะเดียวกันในตัวแปร two_borrow สิ่งนี้ทำให้เกิดการอ้างอิงแบบแก้ไขได้สองอันในขอบเขตเดียวกัน ซึ่งไม่ได้รับอนุญาต เมื่อเรารันการทดสอบสำหรับไลบรารีของเรา โค้ดในโค้ดตัวอย่างที่ 15-23 จะคอมไพล์ผ่านโดยไม่มีข้อผิดพลาดใด ๆ แต่การทดสอบจะล้มเหลว:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' (6028024) panicked at src/lib.rs:60:53:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

สังเกตว่าโค้ดเกิด panic ด้วยข้อความ already borrowed: BorrowMutError นี่คือวิธีที่ RefCell<T> จัดการกับการละเมิดกฎการยืมขณะรันไทม์

การเลือกตรวจจับข้อผิดพลาดการยืมขณะรันไทม์แทนที่จะเป็นขณะคอมไพล์ตามที่เราทำตรงนี้ หมายความว่าคุณอาจจะพบข้อผิดพลาดในโค้ดของคุณในภายหลังของกระบวนการพัฒนา: อาจจะไม่พบจนกว่าโค้ดของคุณถูกปรับใช้ไปยังสภาพแวดล้อมจริง (production) นอกจากนี้ โค้ดของคุณยังต้องแลกกับบทลงโทษด้านประสิทธิภาพเล็กน้อยขณะรันไทม์ซึ่งเป็นผลจากการคอยติดตามการยืมขณะรันไทม์แทนที่จะเป็นขณะคอมไพล์ อย่างไรก็ตาม การใช้ RefCell<T> ช่วยให้สามารถเขียนวัตถุจำลองที่สามารถแก้ไขตัวเองเพื่อติดตามข้อความที่พบได้ ขณะที่คุณกำลังใช้งานมันในบริบทที่อนุญาตเฉพาะค่าที่ไม่สามารถแก้ไขได้เท่านั้น คุณสามารถใช้ RefCell<T> แม้จะมีข้อแลกเปลี่ยน เพื่อให้ได้ฟังก์ชันการทำงานที่มากกว่าการอ้างอิงทั่วไปมอบคุณให้

การยินยอมให้มีเจ้าของหลายคนสำหรับข้อมูลที่แก้ไขได้

วิธีทั่วไปในการใช้ RefCell<T> คือใช้ร่วมกับ Rc<T> โปรดระลึกว่า Rc<T> ช่วยให้คุณมีเจ้าของหลายคนสำหรับข้อมูลบางอย่างได้ แต่มันให้เฉพาะการเข้าถึงแบบแก้ไขไม่ได้ไปยังข้อมูลนั้น หากคุณมี Rc<T> ที่ถือครอง RefCell<T> คุณจะได้ค่าที่สามารถมีเจ้าของได้หลายคน และ ที่คุณสามารถแก้ไขได้!

ตัวอย่างเช่น ทบทวนตัวอย่าง cons list ในโค้ดตัวอย่างที่ 15-18 ที่เราใช้ Rc<T> เพื่อยินยอมให้หลายลิสต์แบ่งปันความเป็นเจ้าของของอีกลิสต์หนึ่ง เนื่องจาก Rc<T> ถือครองเฉพาะค่าที่ไม่สามารถแก้ไขได้ เราจึงไม่สามารถเปลี่ยนค่าใด ๆ ในลิสต์ได้เมื่อเราสร้างพวกมันขึ้นมาแล้ว มาเพิ่ม RefCell<T> สำหรับความสามารถในการเปลี่ยนค่าในลิสต์กันเถอะ โค้ดตัวอย่างที่ 15-24 แสดงให้เห็นว่าการใช้ RefCell<T> ในนิยาม Cons ช่วยให้เราสามารถแก้ไขค่าที่เก็บไว้ในลิสต์ทั้งหมดได้

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}

เราสร้างค่าที่เป็นอินสแตนซ์ของ Rc<RefCell<i32>> และเก็บไว้ในตัวแปรชื่อ value เพื่อให้เราสามารถเข้าถึงมันได้โดยตรงในภายหลัง จากนั้นเราสร้าง List ใน a ด้วยตัวแปรย่อย Cons ที่ถือครอง value เราจำเป็นต้องทำสำเนา value เพื่อให้ทั้ง a และ value มีความเป็นเจ้าของของค่า 5 ภายใน แทนที่จะโอนย้ายความเป็นเจ้าของจาก value ไปยัง a หรือให้ a ยืมจาก value

เราห่อหุ้มลิสต์ a ไว้ใน Rc<T> เพื่อให้เมื่อเราสร้างลิสต์ b และ c ทั้งคู่สามารถอ้างอิงถึง aได้ ซึ่งเป็นสิ่งที่เราทำในโค้ดตัวอย่างที่ 15-18

หลังจากเราสร้างลิสต์ใน a, b, และ c แล้ว เราต้องการบวก 10 ให้กับค่าใน value เราทำสิ่งนี้โดยเรียก borrow_mut บน value ซึ่งใช้ฟีเจอร์การลดระดับการอ้างอิงอัตโนมัติที่เราพูดถึงในหัวข้อ “ตัวดำเนินการ -> อยู่ที่ไหน?” ในบทที่ 5 เพื่อลดระดับการอ้างอิง Rc<T> ไปยังค่า RefCell<T> ภายใน เมธอด borrow_mut จะคืนค่าพอยน์เตอร์อัจฉริยะ RefMut<T> และเราใช้ตัวดำเนินการลดระดับการอ้างอิงบนมันและเปลี่ยนค่าภายใน

เมื่อเราพิมพ์ a, b, และ c เราเห็นได้ว่าพวกมันทั้งหมดมีค่าที่ปรับเปลี่ยนเป็น 15 แทนที่จะเป็น 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

เทคนิคนี้ยอดเยี่ยมมาก! การใช้ RefCell<T> ช่วยให้เรามีค่า List ที่ภายนอกดูเหมือนแก้ไขไม่ได้ แต่เราสามารถใช้เมธอดบน RefCell<T> ที่ให้การเข้าถึงการแก้ไขภายในของมัน เพื่อให้เราสามารถแก้ไขข้อมูลของเราเมื่อเราจำเป็นต้องทำได้ การตรวจสอบกฎการยืมขณะรันไทม์ช่วยปกป้องเราจากสภาวะแข่งขันของข้อมูล (data races) และบางครั้งก็คุ้มค่าที่จะแลกความเร็วเล็กน้อยสำหรับความยืดหยุ่นในโครงสร้างข้อมูลของเรา โปรดทราบว่า RefCell<T> จะไม่ทำงานสำหรับโค้ดหลายเธรด! Mutex<T> คือเวอร์ชันที่ปลอดภัยสำหรับเธรดของ RefCell<T> และเราจะพูดถึง Mutex<T> ในบทที่ 16

Reference Cycles Can Leak Memory

วงจรการอ้างอิงสามารถทำให้เกิดหน่วยความจำรั่วไหลได้

การรับประกันความปลอดภัยของหน่วยความจำใน Rust ทำให้เกิดกรณีโดยไม่ตั้งใจได้ยาก แต่ก็ใช่ว่าจะเกิดขึ้นไม่ได้เลยในการสร้างหน่วยความจำที่ไม่ถูกล้างทำความสะอาด (เรียกว่า หน่วยความจำรั่วไหล หรือ memory leak) การป้องกันหน่วยความจำรั่วไหลโดยสมบูรณ์ไม่ได้เป็นหนึ่งในการรับประกันของ Rust ซึ่งหมายความว่าหน่วยความจำรั่วไหลนั้นปลอดภัยต่อหน่วยความจำในมุมมองของ Rust เราสามารถเห็นว่า Rust อนุญาตให้เกิดหน่วยความจำรั่วไหลได้โดยใช้ Rc<T> และ RefCell<T>: มันเป็นไปได้ที่จะสร้างการอ้างอิงที่รายการต่าง ๆ อ้างอิงถึงกันและกันในลักษณะเป็นวงจร สิ่งนี้ทำให้เกิดหน่วยความจำรั่วไหลเนื่องจากจำนวนการอ้างอิงของแต่ละรายการในวงจรจะไม่มีวันลดลงถึง 0 และค่าต่าง ๆ จะไม่มีวันถูกล้างทำความสะอาด (dropped)

การสร้างวงจรการอ้างอิง

มาดูกันว่าวงจรการอ้างอิงอาจเกิดขึ้นได้อย่างไรและจะป้องกันได้อย่างไร โดยเริ่มจากนิยามของ enum List และเมธอด tail ในโค้ดตัวอย่างที่ 15-25

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}

เรากำลังใช้รูปแบบอื่นของนิยาม List จากโค้ดตัวอย่างที่ 15-5 องค์ประกอบที่สองในตัวแปรย่อย Cons ตอนนี้คือ RefCell<Rc<List>> ซึ่งหมายความว่าแทนที่จะมีความสามารถในการแก้ไขค่า i32 เหมือนที่เราทำในโค้ดตัวอย่างที่ 15-24 เราต้องการแก้ไขค่า List ที่ตัวแปรย่อย Cons ชี้ไป เรายังได้เพิ่มเมธอด tail เพื่อให้เราสะดวกในการเข้าถึงสมาชิกตัวที่สองหากเรามีตัวแปรย่อย Cons

ในโค้ดตัวอย่างที่ 15-26 เรากำลังเพิ่มฟังก์ชัน main ที่ใช้นิยามในโค้ดตัวอย่างที่ 15-25 โค้ดนี้สร้างลิสต์ใน a และลิสต์ใน b ที่ชี้ไปยังลิสต์ใน a จากนั้น มันแก้ไขลิสต์ใน a ให้ชี้ไปยัง b ทำให้เกิดวงจรการอ้างอิง มีคำสั่ง println! ระหว่างทางเพื่อแสดงให้เห็นว่าจำนวนการอ้างอิงเป็นเท่าใด ณ จุดต่าง ๆ ในกระบวนการนี้

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

// ANCHOR: here
fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}
// ANCHOR_END: here

เราสร้างอินสแตนซ์ Rc<List> ที่ถือครองค่า List ในตัวแปร a โดยมีลิสต์เริ่มต้นเป็น 5, Nil จากนั้นเราสร้างอินสแตนซ์ Rc<List> ที่ถือครองค่า List อีกค่าหนึ่งในตัวแปร b ที่บรรจุค่า 10 และชี้ไปยังลิสต์ใน a

เราแก้ไข a เพื่อให้มันชี้ไปยัง b แทนที่จะเป็น Nil ซึ่งทำให้เกิดวงจร เราทำสิ่งนั้นโดยใช้เมธอด tail เพื่อรับการอ้างอิงไปยัง RefCell<Rc<List>> ใน a ซึ่งเราใส่ไว้ในตัวแปร link จากนั้น เราใช้เมธอด borrow_mut บน RefCell<Rc<List>> เพื่อเปลี่ยนค่าภายในจาก Rc<List> ที่ถือครองค่า Nil ไปเป็น Rc<List> ใน b

เมื่อเรารันโค้ดนี้ โดยใส่คอมเมนต์ปิดคำสั่ง println! บรรทัดสุดท้ายไว้ชั่วคราว เราจะได้ผลลัพธ์นี้:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

จำนวนการอ้างอิงของอินสแตนซ์ Rc<List> ทั้งใน a และ b คือ 2 หลังจากที่เราเปลี่ยนลิสต์ใน a ให้ชี้ไปยัง b ณ จุดสิ้นสุดของ main Rust จะปล่อยตัวแปร b ซึ่งจะลดจำนวนการอ้างอิงของอินสแตนซ์ b Rc<List> จาก 2 เหลือ 1 หน่วยความจำที่ Rc<List> มีบนฮีปจะไม่ถูกล้างทำความสะอาด ณ จุดนี้เนื่องจากจำนวนการอ้างอิงของมันคือ 1 ไม่ใช่ 0 จากนั้น Rust จะปล่อย a ซึ่งจะลดจำนวนการอ้างอิงของอินสแตนซ์ a Rc<List> จาก 2 เหลือ 1 เช่นกัน หน่วยความจำของอินสแตนซ์นี้ไม่สามารถถูกล้างทำความสะอาดได้เช่นกัน เพราะอินสแตนซ์ Rc<List> อีกอันหนึ่งยังคงอ้างอิงถึงมันอยู่ หน่วยความจำที่จัดสรรให้กับลิสต์จะคงอยู่โดยไม่ถูกจัดเก็บคืนตลอดไป ในการมองภาพวงจรการอ้างอิงนี้ เราได้สร้างไดอะแกรมในรูปภาพที่ 15-4

สี่เหลี่ยมผืนผ้าป้ายชื่อ 'a' ที่ชี้ไปยังสี่เหลี่ยมผืนผ้าที่บรรจุจำนวนเต็ม 5 สี่เหลี่ยมผืนผ้าป้ายชื่อ 'b' ที่ชี้ไปยังสี่เหลี่ยมผืนผ้าที่บรรจุจำนวนเต็ม 10 สี่เหลี่ยมผืนผ้าที่บรรจุ 5 ชี้ไปยังสี่เหลี่ยมผืนผ้าที่บรรจุ 10 และสี่เหลี่ยมผืนผ้าที่บรรจุ 10 ชี้กลับไปยังสี่เหลี่ยมผืนผ้าที่บรรจุ 5 ซึ่งทำให้เกิดวงจร

รูปภาพที่ 15-4: วงจรการอ้างอิงของลิสต์ a และ b ที่ชี้หากันและกัน

หากคุณเปิดคอมเมนต์บรรทัด println! สุดท้ายออกแล้วรันโปรแกรม Rust จะพยายามพิมพ์วงจรนี้โดยที่ a ชี้ไปยัง b ชี้ไปยัง a ไปเรื่อย ๆ จนกระทั่งล้นสแต็ก (stack overflow)

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

การสร้างวงจรการอ้างอิงไม่ได้ทำได้ง่าย ๆ แต่ก็ใช่ว่าจะเป็นไปไม่ได้ หากคุณมีค่า RefCell<T> ที่บรรจุค่า Rc<T> หรือการรวมกันแบบซ้อนที่คล้ายกันของชนิดข้อมูลที่มีการแก้ไขภายในและการนับจำนวนการอ้างอิง คุณต้องตรวจสอบให้แน่ใจว่าคุณไม่ได้สร้างวงจร คุณไม่สามารถพึ่งพา Rust ในการตรวจจับพวกมันได้ การสร้างวงจรการอ้างอิงจะเป็นข้อผิดพลาดทางตรรกะ (logic bug) ในโปรแกรมของคุณที่คุณควรใช้การทดสอบอัตโนมัติ การตรวจสอบโค้ด (code review) และแนวปฏิบัติการพัฒนาซอฟต์แวร์อื่น ๆ เพื่อลดโอกาสเกิดให้น้อยที่สุด

อีกทางออกหนึ่งในการหลีกเลี่ยงวงจรการอ้างอิงคือการจัดระเบียบโครงสร้างข้อมูลของคุณใหม่เพื่อให้การอ้างอิงบางอันแสดงความเป็นเจ้าของและการอ้างอิงบางอันไม่แสดง เป็นผลให้คุณสามารถมีวงจรที่ประกอบด้วยความสัมพันธ์ที่เป็นเจ้าของบ้างและไม่เป็นเจ้าของบ้าง และมีเพียงความสัมพันธ์ที่เป็นเจ้าของเท่านั้นที่จะส่งผลต่อการล้างทำความสะอาดค่าได้หรือไม่ ในโค้ดตัวอย่างที่ 15-25 เราต้องการให้ตัวแปรย่อย Cons เป็นเจ้าของลิสต์ของพวกมันเสมอ ดังนั้นการจัดระเบียบโครงสร้างข้อมูลใหม่จึงเป็นไปไม่ได้ มาดูตัวอย่างโดยใช้กราฟที่ประกอบด้วยโหนดแม่ (parent node) และโหนดลูก (child node) เพื่อดูว่าเมื่อใดที่ความสัมพันธ์ที่ไม่เป็นเจ้าของเป็นวิธีที่เหมาะสมในการป้องกันวงจรการอ้างอิง

การป้องกันวงจรการอ้างอิงด้วยการใช้ Weak<T>

จนถึงตอนนี้ เราได้สาธิตว่าการเรียก Rc::clone จะเพิ่ม strong_count ของอินสแตนซ์ Rc<T> และอินสแตนซ์ Rc<T> จะถูกล้างทำความสะอาดเฉพาะเมื่อ strong_count เป็น 0 เท่านั้น คุณยังสามารถสร้างการอ้างอิงแบบอ่อน (weak reference) ไปยังค่าภายในอินสแตนซ์ Rc<T> ได้โดยการเรียก Rc::downgrade และส่งการอ้างอิงไปยัง Rc<T> การอ้างอิงแบบเข้มแข็ง (Strong references) คือวิธีที่คุณสามารถแบ่งปันความเป็นเจ้าของของอินสแตนซ์ Rc<T> การอ้างอิงแบบอ่อน (Weak references) จะไม่แสดงความสัมพันธ์ของการเป็นเจ้าของ และจำนวนของพวกมันไม่ส่งผลกระทบต่อเวลาที่อินสแตนซ์ Rc<T> จะถูกล้างทำความสะอาด พวกมันจะไม่ทำให้เกิดวงจรการอ้างอิง เพราะวงจรใด ๆ ที่เกี่ยวข้องกับการอ้างอิงแบบอ่อนบางอันจะถูกทำลายเมื่อจำนวนการอ้างอิงแบบเข้มแข็งของค่าที่เกี่ยวข้องเป็น 0

เมื่อคุณเรียก Rc::downgrade คุณจะได้พอยน์เตอร์อัจฉริยะชนิด Weak<T> แทนที่จะเพิ่ม strong_count ในอินสแตนซ์ Rc<T> ขึ้น 1 การเรียก Rc::downgrade จะเพิ่ม weak_count ขึ้น 1 ชนิดข้อมูล Rc<T> จะใช้ weak_count เพื่อติดตามว่ามี การอ้างอิง Weak<T> อยู่กี่อัน คล้ายกับ strong_count ความแตกต่างคือ weak_count ไม่จำเป็นต้องเป็น 0 เพื่อให้อินสแตนซ์ Rc<T> ถูกล้างทำความสะอาด

เนื่องจากค่าที่ Weak<T> อ้างอิงถึงอาจจะถูกปล่อย (dropped) ไปแล้ว ในการทำสิ่งใดก็ตามกับค่าที่ Weak<T> ชี้ไป คุณต้องแน่ใจว่าค่านั้นยังคงมีอยู่ ให้ทำสิ่งนี้โดยเรียกใช้เมธอด upgrade บนอินสแตนซ์ Weak<T> ซึ่งจะคืนค่าเป็น Option<Rc<T>> คุณจะได้ผลลัพธ์เป็น Some หากค่า Rc<T> ยังไม่ถูกปล่อย และได้ผลลัพธ์เป็น None หากค่า Rc<T> ถูกปล่อยไปแล้ว เนื่องจาก upgrade คืนค่าเป็น Option<Rc<T>> Rust จะรับประกันว่ากรณี Some และกรณี None จะได้รับการจัดการ และจะไม่มีพอยน์เตอร์ที่ไม่ถูกต้อง

ตัวอย่างเช่น แทนที่จะใช้ลิสต์ที่สมาชิกแต่ละตัวรู้จักเฉพาะสมาชิกถัดไป เราจะสร้างต้นไม้ (tree) ที่สมาชิกแต่ละตัวรู้จักโหนดลูกของมัน และ โหนดแม่ของมัน

การสร้างโครงสร้างข้อมูลแบบต้นไม้

เพื่อเริ่มต้น เราจะสร้างต้นไม้ด้วยโหนดที่รู้จักโหนดลูกของมัน เราจะสร้าง struct ชื่อ Node ที่ถือครองค่า i32 ของตัวมันเอง รวมถึงการอ้างอิงไปยังค่า Node ลูกของมัน:

ชื่อไฟล์: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

เราต้องการให้ Node เป็นเจ้าของโหนดลูกของมัน และเราต้องการแบ่งปันความเป็นเจ้าของนั้นกับตัวแปรเพื่อที่เราจะสามารถเข้าถึงแต่ละ Node ในต้นไม้ได้โดยตรง ในการทำเช่นนี้ เรานิยามสมาชิก Vec<T> ให้เป็นค่าชนิด Rc<Node> เรายังต้องการแก้ไขว่าโหนดใดเป็นโหนดลูกของอีกโหนดหนึ่ง ดังนั้นเราจึงมี RefCell<T> ใน children ล้อมรอบ Vec<Rc<Node>>

ถัดไป เราจะใช้นิยาม struct ของเราและสร้างหนึ่งอินสแตนซ์ Node ชื่อ leaf ที่มีค่า 3 และไม่มีโหนดลูก และอีกหนึ่งอินสแตนซ์ชื่อ branch ที่มีค่า 5 และมี leaf เป็นหนึ่งในโหนดลูกของมัน ดังแสดงในโค้ดตัวอย่างที่ 15-27

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

เราทำสำเนา Rc<Node> ใน leaf และเก็บไว้ใน branch หมายความว่า Node ใน leaf ตอนนี้มีเจ้าของสองคน: leaf และ branch เราสามารถเข้าถึงจาก branch ไปยัง leaf ผ่านทาง branch.children แต่ไม่มีวิธีเข้าถึงจาก leaf ไปยัง branch เหตุผลก็คือ leaf ไม่มี การอ้างอิงไปยัง branch และไม่รู้ว่าพวกมันเกี่ยวข้องกัน เราต้องการให้ leaf รู้ว่า branch เป็นโหนดแม่ของมัน เราจะทำเช่นนั้นในขั้นตอนถัดไป

การเพิ่มการอ้างอิงจากโหนดลูกไปยังโหนดแม่

เพื่อให้โหนดลูกรับรู้ถึงโหนดแม่ของมัน เราจำเป็นต้องเพิ่มฟิลด์ parent ลงในการนิยาม struct Node ของเรา ปัญหาคือการตัดสินใจว่าชนิดของ parent ควรเป็นอะไร เรารู้ว่ามันไม่สามารถบรรจุ Rc<T> ได้ เพราะนั่นจะทำให้เกิดวงจรการอ้างอิงที่ leaf.parent ชี้ไปยัง branch และ branch.children ชี้ไปยัง leaf ซึ่งจะทำให้ค่า strong_count ของพวกมันไม่มีวันเป็น 0

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

ดังนั้น แทนที่จะใช้ Rc<T> เราจะทำให้ชนิดของ parent ใช้ Weak<T> โดยเฉพาะคือ RefCell<Weak<Node>> ตอนนี้ นิยาม struct Node ของเราดูเป็นดังนี้:

ชื่อไฟล์: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

โหนดจะสามารถอ้างอิงถึงโหนดแม่ของมันได้แต่ไม่ได้เป็นเจ้าของโหนดแม่ ในโค้ดตัวอย่างที่ 15-28 เราอัปเดต main ให้ใช้นิยามใหม่นี้เพื่อให้โหนด leaf มีวิธีอ้างอิงถึงโหนดแม่ของมันคือ branch

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

การสร้างโหนด leaf ดูคล้ายกับโค้ดตัวอย่างที่ 15-27 ยกเว้นฟิลด์ parent: leaf เริ่มต้นโดยไม่มีโหนดแม่ ดังนั้นเราจึงสร้างอินสแตนซ์การอ้างอิง Weak<Node> ใหม่ที่ว่างเปล่า

ณ จุดนี้ เมื่อเราลองรับการอ้างอิงไปยังโหนดแม่ของ leaf โดยใช้เมธอด upgrade เราจะได้ค่า None เราเห็นสิ่งนี้ในผลลัพธ์จากคำสั่ง println! บรรทัดแรก:

leaf parent = None

เมื่อเราสร้างโหนด branch มันจะมี การอ้างอิง Weak<Node> ใหม่ในฟิลด์ parent ด้วยเช่นกันเพราะ branch ไม่มีโหนดแม่ เรายังคงมี leaf เป็นหนึ่งในโหนดลูกของ branch เมื่อเรามีอินสแตนซ์ Node ใน branch แล้ว เราสามารถแก้ไข leaf เพื่อให้มันมี การอ้างอิง Weak<Node> ไปยังโหนดแม่ของมันได้ เราใช้เมธอด borrow_mut บน RefCell<Weak<Node>> ในฟิลด์ parent ของ leaf แล้วใช้ฟังก์ชัน Rc::downgrade เพื่อสร้างการอ้างอิง Weak<Node> ไปยัง branch จาก Rc<Node> ใน branch

เมื่อเราพิมพ์โหนดแม่ของ leaf อีกครั้ง ในครั้งนี้เราจะได้ตัวแปรย่อย Some ที่ถือครอง branch: ตอนนี้ leaf สามารถเข้าถึงโหนดแม่ของมันได้แล้ว! เมื่อเราพิมพ์ leaf เรายังหลีกเลี่ยงวงจรที่จบลงด้วยสแต็กล้นเหมือนที่เรามีในโค้ดตัวอย่างที่ 15-26; การอ้างอิง Weak<Node> จะถูกพิมพ์เป็น (Weak):

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

การไม่มีผลลัพธ์ที่เป็นอนันต์แสดงว่าโค้ดนี้ไม่ได้สร้างวงจรการอ้างอิง เรายังสามารถบอกได้โดยดูที่ค่าที่เราได้จากการเรียกใช้ Rc::strong_count และ Rc::weak_count

การมองภาพการเปลี่ยนแปลงของ strong_count และ weak_count

มาดูกันว่าค่า strong_count และ weak_count ของอินสแตนซ์ Rc<Node> เปลี่ยนแปลงไปอย่างไร โดยการสร้างขอบเขตภายในใหม่และย้ายการสร้าง branch เข้าไปในขอบเขตนั้น โดยทำเช่นนั้น เราจะเห็นว่าเกิดอะไรขึ้นเมื่อ branch ถูกสร้างขึ้น แล้วตามด้วยถูกปล่อยเมื่อมันหลุดออกจากขอบเขต การปรับเปลี่ยนแสดงในโค้ดตัวอย่างที่ 15-29

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

หลังจาก leaf ถูกสร้างขึ้น Rc<Node> ของมันจะมีจำนวนแบบเข้มแข็ง (strong count) เป็น 1 และจำนวนแบบอ่อน (weak count) เป็น 0 ในขอบเขตภายใน เราสร้าง branch และเชื่อมโยงมันกับ leaf ซึ่ง ณ จุดนั้นเมื่อเราพิมพ์จำนวน Rc<Node> ใน branch จะมีจำนวนแบบเข้มแข็งเป็น 1 และจำนวนแบบอ่อนเป็น 1 (สำหรับ leaf.parent ที่ชี้ไปยัง branch ด้วย Weak<Node>) เมื่อเราพิมพ์จำนวนใน leaf เราจะเห็นว่ามันจะมีจำนวนแบบเข้มแข็งเป็น 2 เพราะ branch ตอนนี้มีสำเนาของ Rc<Node> ของ leaf เก็บไว้ใน branch.children แต่จะยังคงมีจำนวนแบบอ่อนเป็น 0

เมื่อขอบเขตภายในสิ้นสุดลง branch จะหลุดออกจากขอบเขต และจำนวนแบบเข้มแข็งของ Rc<Node> จะลดลงเหลือ 0 ดังนั้น Node ของมันจึงถูกปล่อย จำนวนแบบอ่อนเป็น 1 จาก leaf.parent ไม่มีผลต่อการที่ Node จะถูกปล่อยหรือไม่ ดังนั้นเราจึงไม่มีหน่วยความจำรั่วไหล!

หากเราลองเข้าถึงโหนดแม่ของ leaf หลังจากสิ้นสุดขอบเขต เราจะได้ None อีกครั้ง ณ จุดสิ้นสุดของโปรแกรม Rc<Node> ใน leaf จะมีจำนวนแบบเข้มแข็งเป็น 1 และจำนวนแบบอ่อนเป็น 0 เพราะตัวแปร leaf ตอนนี้เป็น การอ้างอิงเดียวไปยัง Rc<Node> อีกครั้ง

ตรรกะทั้งหมดที่จัดการตัวนับและการปล่อยค่าถูกสร้างไว้ใน Rc<T> และ Weak<T> และการประยุกต์ใช้เทรต Drop ของพวกมัน โดยการระบุว่าความสัมพันธ์จากโหนดลูกไปยังโหนดแม่ควรเป็นการอ้างอิง Weak<T> ในการนิยาม Node คุณจะสามารถให้โหนดแม่ชี้ไปยังโหนดลูกและในทางกลับกันได้โดยไม่สร้างวงจรการอ้างอิงและหน่วยความจำรั่วไหล

สรุป

บทนี้ครอบคลุมวิธีใช้พอยน์เตอร์อัจฉริยะเพื่อให้ได้การรับประกันและข้อแลกเปลี่ยนที่แตกต่างจากการอ้างอิงทั่วไปที่ Rust ให้เป็นค่าเริ่มต้น ชนิดข้อมูล Box<T> มีขนาดที่แน่นอนและชี้ไปยังข้อมูลที่จัดสรรไว้บนฮีป ชนิดข้อมูล Rc<T> จะคอยติดตามจำนวนการอ้างอิงไปยังข้อมูลบนฮีปเพื่อให้ข้อมูลสามารถมีเจ้าของได้หลายคน ชนิดข้อมูล RefCell<T> พร้อมด้วยรูปแบบการแก้ไขภายใน มอบชนิดข้อมูลที่เราสามารถใช้ได้เมื่อเราต้องการชนิดข้อมูลที่ไม่สามารถแก้ไขได้แต่จำเป็นต้องเปลี่ยนค่าภายในของชนิดข้อมูลนั้น; มันยังบังคับใช้กฎการยืมขณะรันไทม์แทนที่จะเป็นขณะคอมไพล์

นอกจากนี้ยังพูดถึงเทรต Deref และ Drop ซึ่งช่วยเปิดใช้งานฟังก์ชันการทำงานมากมายของพอยน์เตอร์อัจฉริยะ เราได้สำรวจวงจรการอ้างอิงที่สามารถทำให้เกิดหน่วยความจำรั่วไหลและวิธีป้องกันโดยใช้ Weak<T>

หากบทนี้กระตุ้นความสนใจของคุณและคุณต้องการประยุกต์ใช้พอยน์เตอร์อัจฉริยะของคุณเอง ลองไปดูที่ “The Rustonomicon” สำหรับข้อมูลที่มีประโยชน์เพิ่มเติม

ถัดไป เราจะพูดถึงการทำงานพร้อมกัน (concurrency) ใน Rust คุณยังจะได้เรียนรู้เกี่ยวกับพอยน์เตอร์อัจฉริยะใหม่อีกด้วย

การประมวลผลแบบขนานอย่างไร้กังวล (Fearless Concurrency)

การจัดการการโปรแกรมแบบคอนเคอร์เรนต์ (Concurrent programming) อย่างปลอดภัยและมีประสิทธิภาพเป็นอีกหนึ่งเป้าหมายหลักของ Rust โดย การโปรแกรมแบบคอนเคอร์เรนต์ (Concurrent programming) ซึ่งเป็นแบบที่ส่วนต่างๆ ของโปรแกรมทำงานได้อย่างเป็นอิสระต่อกัน และ การโปรแกรมแบบขนาน (Parallel programming) ซึ่งเป็นแบบที่ส่วนต่างๆ ของโปรแกรมทำงานในเวลาเดียวกัน มีความสำคัญเพิ่มขึ้นเรื่อยๆ เนื่องจากคอมพิวเตอร์จำนวนมากเริ่มใช้งานหน่วยประมวลผลแบบหลายคอร์ (multiple processors) ในอดีตการโปรแกรมในบริบทเหล่านี้เป็นเรื่องยากและเกิดข้อผิดพลาดได้ง่าย แต่ Rust หวังว่าจะเปลี่ยนเรื่องนั้น

ในตอนแรก ทีมพัฒนา Rust คิดว่าการรับประกันความปลอดภัยของหน่วยความจำและการป้องกันปัญหาเรื่องคอนเคอร์เรนซี (concurrency) เป็นสองสิ่งที่ท้าทายและแยกออกจากกัน ซึ่งต้องแก้ด้วยวิธีการที่แตกต่างกัน แต่เมื่อเวลาผ่านไป ทีมพบว่าระบบความเป็นเจ้าของ (ownership) และระบบชนิดข้อมูล (type system) เป็นเครื่องมือที่ทรงพลังที่ช่วยจัดการทั้งความปลอดภัยของหน่วยความจำ และ ปัญหาเรื่องคอนเคอร์เรนซี! ด้วยการใช้ประโยชน์จากระบบความเป็นเจ้าของและการตรวจสอบชนิดข้อมูล ข้อผิดพลาดด้านคอนเคอร์เรนซีหลายอย่างใน Rust จะกลายเป็นข้อผิดพลาดในขั้นตอนคอมไพล์ (compile-time errors) แทนที่จะเป็นข้อผิดพลาดขณะโปรแกรมทำงาน (runtime errors) ดังนั้น แทนที่จะต้องเสียเวลามากมายในการพยายามจำลองสถานการณ์เป๊ะๆ ที่ทำให้เกิดบั๊กคอนเคอร์เรนซีในขณะโปรแกรมทำงาน โค้ดที่ไม่ถูกต้องจะถูกปฏิเสธไม่ให้คอมไพล์พร้อมแสดงข้อผิดพลาดที่อธิบายปัญหานั้น ส่งผลให้คุณสามารถแก้ไขโค้ดขณะที่กำลังพัฒนาอยู่ได้ แทนที่จะต้องไปแก้หลังจากปล่อยโปรแกรมไปยังสภาพแวดล้อมจริง (production) เราตั้งชื่อเล่นให้กับแง่มุมนี้ของ Rust ว่า การประมวลผลแบบขนานอย่างไร้กังวล (Fearless concurrency) ซึ่งช่วยให้คุณสามารถเขียนโค้ดที่ปราศจากบั๊กแอบแฝงที่ซับซ้อน และรีแฟกเตอร์ (refactor) ได้ง่ายโดยไม่ก่อให้เกิดบั๊กใหม่ขึ้นมา

หมายเหตุ: เพื่อความเรียบง่าย เราจะอ้างถึงปัญหาหลายๆ อย่างว่า คอนเคอร์เรนต์ (concurrent) แทนที่จะระบุเจาะจงว่า คอนเคอร์เรนต์ และ/หรือ ขนาน (concurrent and/or parallel) สำหรับในบทนี้ โปรดทำความเข้าใจในใจว่าหมายถึง คอนเคอร์เรนต์ และ/หรือ ขนาน ทุกครั้งที่เราใช้คำว่า คอนเคอร์เรนต์ ส่วนในบทถัดไป ซึ่งข้อแตกต่างระหว่างสองคำนี้มีความสำคัญมากกว่า เราจะระบุอย่างละเอียดมากขึ้น

หลายภาษาเขียนโปรแกรมมีแนวทางที่ตายตัวเกี่ยวกับการแก้ไขปัญหาด้านคอนเคอร์เรนซี เช่น ภาษา Erlang มีฟังก์ชันการทำงานที่งดงามสำหรับคอนเคอร์เรนซีแบบส่งข้อความ (message-passing concurrency) แต่มีวิธีที่ไม่ชัดเจนสำหรับการแบ่งปันสถานะ (share state) ระหว่างเธรด การสนับสนุนเพียงแค่ชุดย่อยของการแก้ปัญหาที่เป็นไปได้ถือเป็นกลยุทธ์ที่สมเหตุสมผลสำหรับภาษาระดับสูง (higher-level languages) เนื่องจากภาษาระดับสูงสัญญาว่าได้รับประโยชน์จากการสละการควบคุมบางส่วนเพื่อแลกกับนามธรรม (abstractions) อย่างไรก็ตาม ภาษาระดับล่าง (lower-level languages) คาดหวังว่าจะมอบทางออกที่มีประสิทธิภาพดีที่สุดในทุกสถานการณ์และมีนามธรรมครอบทับฮาร์ดแวร์น้อยกว่า ดังนั้น Rust จึงมอบเครื่องมือที่หลากหลายสำหรับการจำลองปัญหาในรูปแบบใดก็ตามที่เหมาะสมกับสถานการณ์และความต้องการของคุณ

นี่คือหัวข้อที่เราจะครอบคลุมในบทนี้:

  • วิธีการสร้างเธรด (threads) เพื่อรันโค้ดหลายส่วนในเวลาเดียวกัน
  • คอนเคอร์เรนซีแบบ ส่งข้อความ (Message-passing) ซึ่งมีช่องทาง (channels) คอยส่งข้อความระหว่างเธรด
  • คอนเคอร์เรนซีแบบ แบ่งปันสถานะ (Shared-state) ซึ่งเธรดหลายเธรดสามารถเข้าถึงข้อมูลชิ้นเดียวกันได้
  • เทรต Sync และ Send ซึ่งขยายขอบเขตการรับประกันคอนเคอร์เรนซีของ Rust ไปยังชนิดข้อมูลที่ผู้ใช้กำหนดเอง ตลอดจนชนิดข้อมูลที่ไลบรารีมาตรฐานจัดหาไว้ให้

Using Threads to Run Code Simultaneously

การใช้เธรดเพื่อรันโค้ดพร้อมกัน

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

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

  • สภาวะแย่งชิง (Race conditions) ซึ่งเธรดเข้าถึงข้อมูลหรือทรัพยากรในลำดับที่ไม่สอดคล้องกัน
  • เดดล็อก (Deadlocks) ซึ่งเธรดสองเธรดต่างคอยอีกเธรดหนึ่ง ทำให้ทั้งสองเธรดไม่สามารถทำงานต่อไปได้
  • บั๊กที่เกิดขึ้นเฉพาะในบางสถานการณ์เท่านั้น และยากที่จะจำลองซ้ำรวมถึงแก้ไขได้อย่างมั่นใจ

Rust พยายามลดผลกระทบเชิงลบจากการใช้เธรด แต่การเขียนโปรแกรมในบริบทแบบหลายเธรด (multithreaded) ยังคงต้องใช้ความคิดอย่างรอบคอบและต้องการโครงสร้างโค้ดที่แตกต่างจากโปรแกรมที่รันบนเธรดเดียว

ภาษาเขียนโปรแกรมใช้วิธีสร้างเธรดในรูปแบบที่แตกต่างกันเล็กน้อย และระบบปฏิบัติการหลายตัวก็จัดหา API ที่ภาษาเขียนโปรแกรมสามารถเรียกใช้เพื่อสร้างเธรดใหม่ได้ ไลบรารีมาตรฐานของ Rust ใช้โมเดลการทำงานแบบเธรด 1:1 โดยโปรแกรมจะใช้หนึ่งเธรดของระบบปฏิบัติการต่อหนึ่งเธรดของภาษา มีเครต (crates) ที่ใช้โมเดลของเธรดแบบอื่นซึ่งมีข้อดีข้อเสียแลกเปลี่ยนต่างจากโมเดล 1:1 (ระบบ async ของ Rust ซึ่งเราจะได้เห็นในบทถัดไป ก็มอบแนวทางอื่นสำหรับคอนเคอร์เรนซีด้วยเช่นกัน)

การสร้างเธรดใหม่ด้วย spawn

ในการสร้างเธรดใหม่ เราจะเรียกใช้ฟังก์ชัน thread::spawn และส่งโคลเชอร์ (เราพูดถึงโคลเชอร์ไปแล้วในบทที่ 13) ซึ่งบรรจุโค้ดที่เราต้องการรันในเธรดใหม่เข้าไป ตัวอย่างในโค้ดตัวอย่างที่ 16-1 จะพิมพ์ข้อความบางอย่างจากเธรดหลัก (main thread) และข้อความอื่นๆ จากเธรดใหม่

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

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

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

การเรียกใช้ thread::sleep เป็นการบังคับให้เธรดหยุดการทำงานเป็นระยะเวลาสั้นๆ เพื่อเปิดโอกาสให้เธรดอื่นได้รัน เธรดต่างๆ น่าจะสลับกันทำงาน แต่ก็ไม่ได้รับการรับประกัน: ขึ้นอยู่กับว่าระบบปฏิบัติการของคุณจัดคิวการทำงาน (schedule) ของเธรดอย่างไร ในการรันครั้งนี้ เธรดหลักพิมพ์ออกมาก่อน แม้ว่าคำสั่งพิมพ์จากเธรดที่สร้างขึ้นใหม่จะปรากฏขึ้นก่อนในโค้ดก็ตาม และแม้ว่าเราจะบอกให้เธรดใหม่พิมพ์จนกว่า i จะเป็น 9 แต่โปรแกรมกลับไปถึงแค่ 5 เท่านั้นก่อนที่เธรดหลักจะปิดตัวลง

หากคุณรันโค้ดนี้แล้วเห็นเฉพาะผลลัพธ์จากเธรดหลัก หรือไม่เห็นการทำงานสลับกันเลย ลองเพิ่มตัวเลขในขอบเขต (ranges) เพื่อสร้างโอกาสให้ระบบปฏิบัติการสลับไปมาระหว่างเธรดมากขึ้น

การรอให้เธรดทั้งหมดทำงานเสร็จสิ้น

โค้ดในโค้ดตัวอย่างที่ 16-1 ไม่เพียงแต่จะหยุดเธรดที่สร้างขึ้นก่อนเวลาอันควรเนื่องจากการสิ้นสุดของเธรดหลักส่วนใหญ่แล้ว แต่เนื่องจากไม่มีการรับประกันเกี่ยวกับลำดับที่เธรดทำงาน เราจึงไม่สามารถรับประกันได้เลยว่าเธรดที่สร้างขึ้นจะถูกรันเลยหรือไม่!

เราสามารถแก้ไขปัญหาเธรดใหม่ไม่ทำงานหรือจบลงก่อนเวลาอันควรได้โดยการบันทึกค่าที่ส่งกลับมาจาก thread::spawn ไว้ในตัวแปร ชนิดค่าส่งกลับของ thread::spawn คือ JoinHandle<T> โดย JoinHandle<T> เป็นค่าที่มีความเป็นเจ้าของ ซึ่งเมื่อเราเรียกใช้เมธอด join บนนั้น มันจะรอให้เธรดของมันทำงานเสร็จสิ้น โค้ดตัวอย่างที่ 16-2 แสดงวิธีใช้ JoinHandle<T> ของเธรดที่เราสร้างในโค้ดตัวอย่างที่ 16-1 และวิธีเรียกใช้ join เพื่อให้แน่ใจว่าเธรดใหม่ทำงานเสร็จก่อนที่ main จะจบลง

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

การเรียกใช้ join บนแฮนเดิล (handle) จะทำการบล็อก (block) เธรดที่กำลังรันอยู่ในปัจจุบันไว้จนกว่าเธรดที่ถูกแทนด้วยแฮนเดิลนั้นจะสิ้นสุดลง การ บล็อก (Blocking) เธรดหมายความว่าเธรดนั้นถูกขัดขวางไม่ให้ทำงานต่อหรือจบการทำงาน เนื่องจากเราใส่การเรียกใช้ join ไว้หลังลูป for ของเธรดหลัก การรันโค้ดตัวอย่างที่ 16-2 จึงควรให้ผลลัพธ์คล้ายกับสิ่งนี้:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

ทั้งสองเธรดยังคงทำงานสลับกัน แต่เธรดหลักจะรอเนื่องจากการเรียกใช้ handle.join() และจะไม่จบลงจนกว่าเธรดใหม่จะทำงานเสร็จสิ้น

แต่ลองมาดูกันว่าจะเกิดอะไรขึ้นหากเราย้าย handle.join() ไปไว้ก่อนลูป for ใน main แทน ดังนี้:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

เธรดหลักจะรอให้เธรดใหม่ทำงานเสร็จก่อนแล้วจึงค่อยรันลูป for ของตัวเอง ดังนั้นผลลัพธ์จะไม่ทำงานสลับกันอีกต่อไป ดังที่แสดงที่นี่:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

รายละเอียดเล็กๆ น้อยๆ เช่น จุดที่เรียกใช้ join สามารถส่งผลกระทบว่าเธรดของคุณจะทำงานในเวลาเดียวกันหรือไม่

การใช้โคลเชอร์แบบ move กับเธรด

เรามักใช้คีย์เวิร์ด move ร่วมกับโคลเชอร์ที่ส่งไปยัง thread::spawn เพราะโคลเชอร์จะรับเอาความเป็นเจ้าของของค่าที่ใช้งานจากสภาพแวดล้อม (environment) มา จึงเป็นการย้ายความเป็นเจ้าของของค่าเหล่านั้นจากเธรดหนึ่งไปยังอีกเธรดหนึ่ง ในหัวข้อ “การจับการอ้างอิงหรือการย้ายความเป็นเจ้าของ” ในบทที่ 13 เราได้พูดถึง move ในบริบทของโคลเชอร์ไปแล้ว คราวนี้เราจะเน้นไปที่การทำงานร่วมกันระหว่าง move และ thread::spawn มากขึ้น

สังเกตในโค้ดตัวอย่างที่ 16-1 ว่าโคลเชอร์ที่เราส่งให้ thread::spawn ไม่รับอาร์กิวเมนต์ใดๆ: เราไม่ได้ใช้ข้อมูลจากเธรดหลักในโค้ดของเธรดใหม่เลย หากต้องการใช้ข้อมูลจากเธรดหลักในเธรดใหม่ โคลเชอร์ของเธรดใหม่จะต้องจับ (capture) ค่าที่ต้องการใช้ โค้ดตัวอย่างที่ 16-3 แสดงความพยายามสร้างเวกเตอร์ในเธรดหลักและนำไปใช้ในเธรดใหม่ อย่างไรก็ตาม สิ่งนี้จะยังไม่ทำงาน ดังที่คุณจะได้เห็นในอีกสักครู่

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

โคลเชอร์มีการใช้ v ดังนั้นมันจะจับ v เข้าไปเป็นส่วนหนึ่งของสภาพแวดล้อมของโคลเชอร์ เนื่องจาก thread::spawn รันโคลเชอร์นี้ในเธรดใหม่ เราจึงควรจะเข้าถึง v ภายในเธรดใหม่นั้นได้ แต่เมื่อเราคอมไพล์ตัวอย่างนี้ เราจะได้รับข้อผิดพลาดต่อไปนี้:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust อนุมาน (infers) วิธีการจับ v และเนื่องจาก println! ต้องการเพียงแค่การอ้างอิงถึง v โคลเชอร์จึงพยายามยืม v แต่มีปัญหาอยู่ว่า: Rust ไม่สามารถบอกได้ว่าเธรดใหม่จะรันนานแค่ไหน ดังนั้นมันจึงไม่รู้ว่าการอ้างอิงถึง v จะยังคงใช้งานได้ (valid) อยู่เสมอหรือไม่

โค้ดตัวอย่างที่ 16-4 แสดงสถานการณ์ที่มีโอกาสสูงกว่าที่การอ้างอิงถึง v จะกลายเป็นสิ่งที่ไม่สามารถใช้งานได้

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

หาก Rust อนุญาตให้เรารันโค้ดนี้ ก็เป็นไปได้ว่าเธรดใหม่ที่สร้างขึ้นจะถูกส่งไปอยู่เบื้องหลังทันทีโดยไม่ได้รันเลย เธรดใหม่มีการอ้างอิงถึง v อยู่ข้างใน แต่เธรดหลักทิ้ง (drop) v ทันที โดยใช้ฟังก์ชัน drop ที่เราคุยกันในบทที่ 15 จากนั้น เมื่อเธรดใหม่เริ่มทำงาน v ก็ไม่ได้อยู่แล้ว ดังนั้นการอ้างอิงถึงมันจึงไม่ถูกต้องตามไปด้วย!

ในการแก้ไขข้อผิดพลาดคอมไพเลอร์ในโค้ดตัวอย่างที่ 16-3 เราสามารถใช้คำแนะนำจากข้อความแสดงข้อผิดพลาดได้:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

การเติมคีย์เวิร์ด move ไว้หน้าโคลเชอร์ เราจะเป็นการบังคับให้โคลเชอร์รับเอาความเป็นเจ้าของของค่าที่มันใช้งาน แทนที่จะยอมให้ Rust อนุมานเอาเองว่าควรจะยืมค่านั้น การแก้ไขโค้ดตัวอย่างที่ 16-3 ที่แสดงในโค้ดตัวอย่างที่ 16-5 จะคอมไพล์และทำงานได้ตามที่เราตั้งใจไว้

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

เราอาจพยายามทำแบบเดียวกันเพื่อแก้โค้ดในโค้ดตัวอย่างที่ 16-4 ซึ่งเธรดหลักเรียกใช้ drop โดยการใช้โคลเชอร์แบบ move อย่างไรก็ตาม การแก้ไขนี้จะไม่ทำงาน เพราะสิ่งที่โค้ดตัวอย่างที่ 16-4 พยายามทำนั้นไม่อนุญาตเนื่องด้วยเหตุผลอื่น หากเราเพิ่ม move ในโคลเชอร์ เราจะย้าย v เข้าไปในสภาพแวดล้อมของโคลเชอร์ และเราจะไม่สามารถเรียกใช้ drop กับมันในเธรดหลักได้อีกต่อไป เราจะได้รับข้อผิดพลาดคอมไพเลอร์นี้แทน:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
 4 |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |
 6 |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
 7 |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
help: consider cloning the value before moving it into the closure
   |
 6 ~     let value = v.clone();
 7 ~     let handle = thread::spawn(move || {
 8 ~         println!("Here's a vector: {value:?}");
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

กฎความเป็นเจ้าของของ Rust ได้ช่วยเราไว้อีกครั้ง! เราได้รับข้อผิดพลาดจากโค้ดในโค้ดตัวอย่างที่ 16-3 เพราะ Rust ระมัดระวังและเลือกที่จะยืม v สำหรับเธรดเท่านั้น ซึ่งหมายความว่าเธรดหลักอาจทำให้การอ้างอิงของเธรดใหม่ไม่ถูกต้องโดยทฤษฎี การบอก Rust ให้ย้ายความเป็นเจ้าของของ v ไปยังเธรดใหม่ เป็นการรับประกันกับ Rust ว่าเธรดหลักจะไม่ใช้ v อีกต่อไป หากเราเปลี่ยนโค้ดตัวอย่างที่ 16-4 ในลักษณะเดียวกัน เราก็กำลังละเมิดกฎความเป็นเจ้าของเมื่อเราพยายามใช้ v ในเธรดหลัก คีย์เวิร์ด move จะยับยั้งค่าเริ่มต้นที่ระมัดระวังของ Rust ซึ่งก็คือการยืม แต่มันไม่ได้เปิดโอกาสให้เราละเมิดกฎความเป็นเจ้าของได้

เมื่อเราได้ครอบคลุมเกี่ยวกับความหมายของเธรดและเมธอดต่างๆ ที่จัดหาให้โดย thread API แล้ว ลองมาดูสถานการณ์บางอย่างที่เราสามารถใช้เธรดได้

Transfer Data Between Threads with Message Passing

การถ่ายโอนข้อมูลระหว่างเธรดด้วยการส่งข้อความ

หนึ่งในแนวทางที่ได้รับความนิยมเพิ่มขึ้นเรื่อยๆ ในการรับประกันคอนเคอร์เรนซีอย่างปลอดภัยคือการส่งข้อความ (message passing) ซึ่งเธรดหรือแอกเตอร์ (actors) สื่อสารกันโดยการส่งข้อความที่มีข้อมูลบรรจุอยู่หากัน ดังแนวคิดในสโลแกนจาก เอกสารประกอบของภาษา Go: “อย่าสื่อสารด้วยการแบ่งปันหน่วยความจำ แต่จงแบ่งปันหน่วยความจำด้วยการสื่อสาร”

ในการบรรลุคอนเคอร์เรนซีแบบส่งข้อความ ไลบรารีมาตรฐานของ Rust จัดหาการใช้งานแชนเนล (channels) ไว้ให้ แชนเนล (channel) เป็นแนวคิดทั่วไปในการเขียนโปรแกรมซึ่งเป็นช่องทางที่ส่งข้อมูลจากเธรดหนึ่งไปยังอีกเธรดหนึ่ง

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

แชนเนลมีสองส่วน: ผู้ส่ง (transmitter) และผู้รับ (receiver) ฝั่งผู้ส่งคือตำแหน่งต้นน้ำที่คุณใส่เป็ดยางลงในแม่น้ำ และฝั่งผู้รับคือจุดที่เป็ดยางไปถึง ณ ปลายน้ำ โค้ดส่วนหนึ่งของคุณจะเรียกใช้เมธอดบนฝั่งผู้ส่งพร้อมกับข้อมูลที่คุณต้องการส่ง และโค้ดอีกส่วนหนึ่งจะตรวจสอบฝั่งผู้รับเพื่อคอยรับข้อความที่มาถึง แชนเนลจะถูกเรียกว่า ถูกปิด (closed) หากฝั่งผู้ส่งหรือผู้รับถูกคืนค่า (drop) ไป

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

ขั้นแรก ในโค้ดตัวอย่างที่ 16-6 เราจะสร้างแชนเนลขึ้นมาแต่ยังไม่ทำอะไรกับมัน โปรดสังเกตว่าโค้ดนี้จะยังไม่คอมไพล์เพราะ Rust ยังไม่สามารถบอกได้ว่าเราต้องการส่งค่าชนิดใดผ่านแชนเนล

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

เราสร้างแชนเนลใหม่โดยใช้ฟังก์ชัน mpsc::channel โดย mpsc ย่อมาจาก multiple producer, single consumer (ผู้ผลิตหลายราย ผู้บริโภครายเดียว) อธิบายสั้นๆ คือ วิธีที่ไลบรารีมาตรฐานของ Rust ใช้แชนเนลหมายความว่า แชนเนลสามารถมีฝั่ง ส่ง ได้หลายฝั่งซึ่งสร้างค่าขึ้นมา แต่มีฝั่ง รับ เพียงฝั่งเดียวเท่านั้นที่นำค่าเหล่านั้นไปใช้งาน นึกภาพสายธารหลายสายไหลมารวมกันเป็นแม่น้ำสายใหญ่สายเดียว: สิ่งใดก็ตามที่ถูกส่งลงมาจากลำธารสายใดก็ตามจะไปจบลงที่แม่น้ำสายเดียวในตอนท้าย เราจะเริ่มจากผู้ผลิตรายเดียวก่อนในตอนนี้ แต่เราจะเพิ่มผู้ผลิตหลายรายเมื่อเราทำให้ตัวอย่างนี้ทำงานได้แล้ว

ฟังก์ชัน mpsc::channel จะคืนค่าทูเพิล (tuple) ซึ่งสมาชิกแรกคือฝั่งส่ง—ผู้ส่ง (transmitter)—และสมาชิกที่สองคือฝั่งรับ—ผู้รับ (receiver) ตัวย่อ tx และ rx ถูกใช้กันทั่วไปในหลายสาขาเพื่อแทนคำว่า transmitter และ receiver ตามลำดับ ดังนั้นเราจึงตั้งชื่อตัวแปรตามนั้นเพื่อแสดงถึงแต่ละฝั่ง เราใช้คำสั่ง let ร่วมกับรูปแบบที่ทำการกระจายโครงสร้าง (destructures) ทูเพิล เราจะพูดถึงการใช้รูปแบบในคำสั่ง let และการกระจายโครงสร้างในบทที่ 19 สำหรับตอนนี้ ให้ทราบว่าการใช้คำสั่ง let ในลักษณะนี้เป็นวิธีที่สะดวกในการสกัดเอาชิ้นส่วนของทูเพิลที่คืนค่าโดย mpsc::channel ออกมา

ลองย้ายฝั่งส่งเข้าไปในเธรดที่สร้างขึ้นใหม่และสั่งให้มันส่งข้อความสตริงหนึ่งข้อความ เพื่อให้เธรดใหม่สื่อสารกับเธรดหลัก ดังที่แสดงในโค้ดตัวอย่างที่ 16-7 สิ่งนี้เหมือนกับการใส่เป็ดยางลงไปในแม่น้ำทางต้นน้ำ หรือการส่งข้อความแชทจากเธรดหนึ่งไปยังอีกเธรดหนึ่ง

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

อีกครั้ง เราใช้ thread::spawn เพื่อสร้างเธรดใหม่ จากนั้นใช้ move เพื่อย้าย tx เข้าไปในโคลเชอร์เพื่อให้เธรดใหม่เป็นเจ้าของ tx เธรดใหม่จำเป็นต้องเป็นเจ้าของฝั่งส่งเพื่อที่จะสามารถส่งข้อความผ่านแชนเนลได้

ผู้ส่งมีเมธอด send ซึ่งรับเอาค่าที่เราต้องการส่ง เมธอด send คืนค่าชนิด Result<T, E> ดังนั้นหากผู้รับถูกคืนค่า (dropped) ไปแล้วและไม่มีที่ใดที่จะส่งค่าไป การทำงานของ send จะคืนค่าเป็นข้อผิดพลาด ในตัวอย่างนี้ เรากำลังเรียกใช้ unwrap เพื่อให้ตื่นตระหนก (panic) ในกรณีที่เกิดข้อผิดพลาด แต่ในแอปพลิเคชันจริง เราจะจัดการมันอย่างเหมาะสม: ย้อนกลับไปดูบทที่ 9 เพื่อทบทวนกลยุทธ์สำหรับการจัดการข้อผิดพลาดที่เหมาะสม

ในโค้ดตัวอย่างที่ 16-8 เราจะรับค่าจากผู้รับในเธรดหลัก สิ่งนี้เหมือนกับการเก็บเป็ดยางขึ้นมาจากน้ำที่ปลายแม่น้ำ หรือการได้รับข้อความแชท

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

ผู้รับมีเมธอดที่มีประโยชน์สองเมธอด: recv และ try_recv เราใช้ recv ซึ่งย่อมาจาก receive (รับ) ซึ่งจะทำการบล็อกการทำงานของเธรดหลักและรอจนกว่าค่าจะถูกส่งผ่านแชนเนลลงมา เมื่อค่าถูกส่งมาแล้ว recv จะคืนค่านั้นในรูป Result<T, E> เมื่อผู้ส่งปิดลง recv จะคืนค่าเป็นข้อผิดพลาดเพื่อส่งสัญญาณว่าไม่มีค่าอื่นเข้ามาอีกแล้ว

เมธอด try_recv จะไม่บล็อกการทำงาน แต่จะคืนค่า Result<T, E> ทันที: ได้ค่า Ok ที่บรรจุข้อความหากมีข้อความพร้อมใช้งาน และได้ค่า Err หากคราวนี้ไม่มีข้อความ การใช้ try_recv มีประโยชน์หากเธรดนี้มีงานอื่นต้องทำในขณะที่รอข้อความ: เราสามารถเขียนลูปที่เรียกใช้ try_recv เป็นระยะๆ จัดการข้อความหากมี และทำอะไรอย่างอื่นเป็นเวลาสั้นๆ ก่อนที่จะตรวจสอบอีกครั้ง

เราใช้ recv ในตัวอย่างนี้เพื่อความเรียบง่าย เราไม่มีงานอื่นให้เธรดหลักทำนอกเหนือจากการรอข้อความ ดังนั้นการบล็อกเธรดหลักจึงมีความเหมาะสมแล้ว

เมื่อเรารันโค้ดในโค้ดตัวอย่างที่ 16-8 เราจะเห็นค่าที่พิมพ์ออกมาจากเธรดหลัก:

Got: hi

เพอร์เฟกต์!

การถ่ายโอนความเป็นเจ้าของผ่านแชนเนล

กฎความเป็นเจ้าของมีบทบาทสำคัญในการส่งข้อความเพราะมันช่วยให้คุณเขียนโค้ดคอนเคอร์เรนต์ได้อย่างปลอดภัย การป้องกันข้อผิดพลาดในการโปรแกรมแบบคอนเคอร์เรนต์คือข้อดีของการคิดถึงเรื่องความเป็นเจ้าของตลอดการเขียนโปรแกรม Rust ลองมาทำกิจกรรมทดลองเพื่อดูว่าแชนเนลและความเป็นเจ้าของทำงานร่วมกันเพื่อป้องกันปัญหาอย่างไร: เราจะลองใช้ค่า val ในเธรดที่สร้างขึ้นใหม่ หลังจาก เราส่งมันลงไปในแชนเนลแล้ว ลองคอมไพล์โค้ดในโค้ดตัวอย่างที่ 16-9 เพื่อดูว่าทำไมโค้ดนี้ถึงไม่อนุญาต

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

ในที่นี้ เราพยายามพิมพ์ val หลังจากเราส่งมันลงในแชนเนลผ่าน tx.send การอนุญาตให้ทำเช่นนี้เป็นความคิดที่ไม่ดี: เมื่อค่านำส่งไปยังอีกเธรดหนึ่งแล้ว เธรดนั้นอาจแก้ไขหรือทิ้ง (drop) ค่านั้นก่อนที่เราจะพยายามใช้ค่านั้นอีกครั้ง การแก้ไขของอีกเธรดหนึ่งอาจทำให้เกิดข้อผิดพลาดหรือผลลัพธ์ที่ไม่คาดคิดเนื่องจากข้อมูลที่ไม่สอดคล้องกันหรือไม่เหลืออยู่แล้ว อย่างไรก็ตาม Rust แสดงข้อผิดพลาดให้เราเห็นหากเราพยายามคอมไพล์โค้ดในโค้ดตัวอย่างที่ 16-9:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:27
   |
 8 |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
 9 |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                           ^^^ value borrowed here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

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

การส่งหลายค่า

โค้ดในโค้ดตัวอย่างที่ 16-8 คอมไพล์และทำงานได้ แต่ก็ไม่ได้แสดงให้เราเห็นชัดเจนว่าสองเธรดที่แยกกันกำลังสื่อสารกันผ่านแชนเนล

ในโค้ดตัวอย่างที่ 16-10 เราได้ปรับแต่งบางอย่างที่จะพิสูจน์ว่าโค้ดในโค้ดตัวอย่างที่ 16-8 ทำงานแบบคอนเคอร์เรนต์: ตอนนี้เธรดใหม่จะส่งหลายข้อความและหยุดพักเป็นเวลาหนึ่งวินาทีระหว่างแต่ละข้อความ

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}

คราวนี้ เธรดใหม่มีเวกเตอร์ของสตริงที่เราต้องการส่งไปยังเธรดหลัก เราวนลูปผ่านพวกมัน ส่งทีละข้อความ และหยุดพักระหว่างแต่ละข้อความโดยการเรียกใช้ฟังก์ชัน thread::sleep พร้อมกับค่า Duration หนึ่งวินาที

ในเธรดหลัก เราไม่ได้เรียกใช้ฟังก์ชัน recv อย่างชัดเจนอีกต่อไป: แต่เราปฏิบัติกับ rx เหมือนเป็นตัวซ้ำ (iterator) สำหรับแต่ละค่าที่ได้รับ เราจะพิมพ์มันออกมา เมื่อแชนเนลปิดลง การวนลูปซ้ำก็จะสิ้นสุดลง

เมื่อรันโค้ดในโค้ดตัวอย่างที่ 16-10 คุณควรจะเห็นผลลัพธ์ต่อไปนี้โดยมีการหยุดพักหนึ่งวินาทีระหว่างแต่ละบรรทัด:

Got: hi
Got: from
Got: the
Got: thread

เนื่องจากเราไม่มีโค้ดใดๆ ที่หยุดพักหรือหน่วงเวลาในลูป for ในเธรดหลัก เราจึงสามารถบอกได้ว่าเธรดหลักกำลังรอรับค่าจากเธรดใหม่อยู่

การสร้างผู้ผลิตหลายราย

ก่อนหน้านี้เราได้กล่าวว่า mpsc เป็นตัวย่อมาจาก multiple producer, single consumer (ผู้ผลิตหลายราย ผู้บริโภครายเดียว) ลองนำ mpsc มาใช้งานและขยายโค้ดในโค้ดตัวอย่างที่ 16-10 เพื่อสร้างหลายๆ เธรดซึ่งทั้งหมดส่งค่าไปยังผู้รับรายเดียวกัน เราสามารถทำได้โดยการโคลน (clone) ผู้ส่ง ดังแสดงในโค้ดตัวอย่างที่ 16-11

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}

คราวนี้ ก่อนที่เราจะสร้างเธรดใหม่ตัวแรก เราจะเรียกใช้ clone บนผู้ส่ง สิ่งนี้จะมอบผู้ส่งตัวใหม่ให้เราซึ่งเราสามารถส่งไปยังเธรดใหม่ตัวแรกได้ เราส่งผู้ส่งตัวดั้งเดิมไปยังเธรดใหม่ตัวที่สอง สิ่งนี้ทำให้เรามีสองเธรด ซึ่งแต่ละเธรดส่งข้อความต่างกันไปยังผู้รับรายเดียว

เมื่อคุณรันโค้ด ผลลัพธ์ของคุณควรจะมีลักษณะคล้ายกับสิ่งนี้:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

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

ตอนนี้เราได้ดูวิธีการทำงานของแชนเนลไปแล้ว ลองมาดูวิธีอื่นสำหรับคอนเคอร์เรนซีกันบ้าง

Shared-State Concurrency

คอนเคอร์เรนซีแบบแบ่งปันสถานะ (Shared-State Concurrency)

การส่งข้อความ (Message passing) เป็นวิธีที่ดีในการจัดการคอนเคอร์เรนซี แต่มันไม่อยู่ในฐานะวิธีเดียวเท่านั้น อีกวิธีหนึ่งคือการให้หลายๆ เธรดเข้าถึงข้อมูลที่แบ่งปันร่วมกัน (shared data) ลองพิจารณาส่วนหนึ่งของสโลแกนจากเอกสารประกอบของภาษา Go อีกครั้ง: “อย่าสื่อสารด้วยการแบ่งปันหน่วยความจำ”

การสื่อสารด้วยการแบ่งปันหน่วยความจำจะมีหน้าตาเป็นอย่างไร? นอกจากนี้ ทำไมผู้ที่ชื่นชอบการส่งข้อความจึงเตือนว่าไม่ควรใช้การแบ่งปันหน่วยความจำ?

ในแง่หนึ่ง แชนเนลในภาษาเขียนโปรแกรมใดๆ จะคล้ายกับความเป็นเจ้าของแบบเดี่ยว (single ownership) เพราะเมื่อคุณส่งค่าผ่านแชนเนลลงมาแล้ว คุณไม่ควรใช้ค่านั้นอีก คอนเคอร์เรนซีแบบแบ่งปันหน่วยความจำ (Shared-memory concurrency) จะคล้ายกับความเป็นเจ้าของหลายราย (multiple ownership): เธรดหลายเธรดสามารถเข้าถึงตำแหน่งหน่วยความจำเดียวกันในเวลาเดียวกันได้ ดังที่คุณได้เห็นในบทที่ 15 ซึ่งพอยน์เตอร์อัจฉริยะทำให้ความเป็นเจ้าของหลายรายเป็นไปได้ ความเป็นเจ้าของหลายรายสามารถเพิ่มความซับซ้อนขึ้นได้เนื่องจากผู้เป็นเจ้าของต่างชนิดเหล่านี้จำเป็นต้องได้รับการจัดการ ระบบชนิดข้อมูลและกฎความเป็นเจ้าของของ Rust ช่วยได้เป็นอย่างมากในการทำให้การจัดการนี้ถูกต้อง สำหรับตัวอย่าง ลองมาดูมิวเทกซ์ (mutexes) ซึ่งเป็นหนึ่งในพริมิทิฟด้านคอนเคอร์เรนซีที่พบบ่อยสำหรับหน่วยความจำที่แบ่งปันกัน

การควบคุมการเข้าถึงด้วยมิวเทกซ์ (Mutexes)

Mutex (มิวเทกซ์) ย่อมาจาก mutual exclusion (การกันไม่ให้ทำพร้อมกัน) หมายความว่ามิวเทกซ์อนุญาตให้มีเพียงเธรดเดียวเท่านั้นที่สามารถเข้าถึงข้อมูลบางอย่าง ณ เวลาใดเวลาหนึ่งได้ ในการเข้าถึงข้อมูลในมิวเทกซ์ เธรดจะต้องส่งสัญญาณก่อนว่าต้องการเข้าถึงโดยการร้องขอขอรับล็อก (lock) ของมิวเทกซ์ ล็อก (lock) คือโครงสร้างข้อมูลที่เป็นส่วนหนึ่งของมิวเทกซ์ที่คอยติดตามว่าใครมีสิทธิ์เข้าถึงข้อมูลเพียงผู้เดียวในปัจจุบัน ดังนั้น มิวเทกซ์จึงถูกอธิบายว่าทำหน้าที่ คุ้มกัน (guarding) ข้อมูลที่มันถืออยู่ผ่านระบบล็อก

มิวเทกซ์มีชื่อเสียงในเรื่องความยากในการใช้งานเพราะคุณต้องจำกฎสองข้อ:

  1. คุณต้องพยายามขอรับล็อกก่อนที่จะใช้ข้อมูล
  2. เมื่อคุณใช้งานข้อมูลที่มิวเทกซ์คุ้มกันอยู่เสร็จแล้ว คุณต้องปลดล็อก (unlock) ข้อมูล เพื่อให้เธรดอื่นสามารถรับล็อกไปได้

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

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

API ของ Mutex<T>

เพื่อเป็นตัวอย่างวิธีใช้นิวเทกซ์ เรามาเริ่มจากการใช้นิวเทกซ์ในบริบทเธรดเดียวเพื่อความเรียบง่าย ดังแสดงในโค้ดตัวอย่างที่ 16-12

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}

เช่นเดียวกับหลายชนิดข้อมูล เราสร้าง Mutex<T> โดยใช้ฟังก์ชันเกี่ยวเนื่อง (associated function) new ในการเข้าถึงข้อมูลภายในมิวเทกซ์ เราใช้เมธอด lock เพื่อรับล็อก การเรียกใช้นี้จะบล็อกเธรดปัจจุบันทำให้ไม่สามารถทำงานใดๆ ได้จนกว่าจะถึงตาเราที่ได้ครอบครองล็อก

การเรียกใช้ lock จะล้มเหลวหากเธรดอื่นที่ถือล็อกอยู่เกิดตื่นตระหนก (panic) ในกรณีนั้น จะไม่มีใครสามารถรับล็อกได้อีกเลย ดังนั้นเราจึงเลือกที่จะใช้ unwrap เพื่อให้เธรดนี้ตื่นตระหนกหากอยู่ในสถานการณ์นั้น

หลังจากที่เราได้รับล็อกแล้ว เราสามารถปฏิบัติต่อค่าที่ส่งกลับมา ซึ่งในกรณีนี้ตั้งชื่อว่า num เหมือนเป็นอ้างอิงแบบเปลี่ยนแปลงได้ (mutable reference) ไปยังข้อมูลที่อยู่ภายใน ระบบชนิดข้อมูลช่วยรับประกันว่าเราจะได้รับล็อกก่อนที่จะใช้ค่าใน m ชนิดข้อมูลของ m คือ Mutex<i32> ไม่ใช่ i32 ดังนั้นเรา ต้อง เรียกใช้ lock เพื่อที่จะใช้ค่า i32 ได้ เราไม่สามารถลืมได้ เพราะระบบชนิดข้อมูลจะไม่ยอมให้เราเข้าถึง i32 ที่อยู่ภายในเด็ดขาด

การเรียกใช้ lock จะคืนค่าชนิดข้อมูลที่เรียกว่า MutexGuard ซึ่งถูกห่อหุ้มอยู่ใน LockResult ที่เราจัดการด้วยการเรียกใช้ unwrap ชนิดข้อมูล MutexGuard ใช้เทรต Deref ชี้ไปยังข้อมูลภายในของเรา ชนิดข้อมูลนี้ยังมีการใช้เทรต Drop ซึ่งทำการปลดล็อกโดยอัตโนมัติเมื่อ MutexGuard ออกนอกขอบเขต ซึ่งเกิดขึ้นเมื่อสิ้นสุดขอบเขตภายใน (inner scope) ส่งผลให้เราไม่มีความเสี่ยงที่จะลืมปลดล็อกและบล็อกมิวเทกซ์จากการถูกใช้งานโดยเธรดอื่น เพราะการปลดล็อกเกิดขึ้นโดยอัตโนมัติ

หลังจากปลดล็อกแล้ว เราสามารถพิมพ์ค่ามิวเทกซ์และเห็นได้ว่าเราสามารถเปลี่ยนค่า i32 ภายในเป็น 6 ได้

การเข้าถึง Mutex<T> ร่วมกัน

คราวนี้ลองมาแบ่งปันค่าวาลูระหว่างหลายๆ เธรดโดยใช้ Mutex<T> กัน เราจะสร้าง 10 เธรดและสั่งให้แต่ละเธรดเพิ่มค่าตัวนับขึ้นทีละ 1 ดังนั้นตัวนับจะเพิ่มจาก 0 เป็น 10 ตัวอย่างในโค้ดตัวอย่างที่ 16-13 จะเกิดข้อผิดพลาดในการคอมไพล์ และเราจะใช้ข้อผิดพลาดนั้นเพื่อเรียนรู้เพิ่มเติมเกี่ยวกับการใช้ Mutex<T> และวิธีที่ Rust ช่วยให้เราใช้งานมันได้อย่างถูกต้อง

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

เราสร้างตัวแปร counter เพื่อเก็บ i32 ภายใน Mutex<T> เช่นเดียวกับที่เราทำในโค้ดตัวอย่างที่ 16-12 จากนั้น เราสร้าง 10 เธรดโดยการวนลูปตามช่วงตัวเลข เราใช้ thread::spawn และมอบโคลเชอร์เดียวกันให้แก่เธรดทั้งหมด: โคลเชอร์ที่ย้ายตัวนับเข้าไปในเธรด ขอรับล็อกบน Mutex<T> โดยเรียกใช้เมธอด lock จากนั้นบวก 1 เข้ากับค่าในมิวเทกซ์ เมื่อเธรดรันโคลเชอร์เสร็จแล้ว num จะออกนอกขอบเขตและปลดล็อก เพื่อให้เธรดอื่นสามารถรับล็อกต่อไปได้

ในเธรดหลัก เราเก็บรวบรวมจอยแฮนเดิล (join handles) ทั้งหมด จากนั้นเช่นเดียวกับที่ทำในโค้ดตัวอย่างที่ 16-2 เราเรียกใช้ join บนแต่ละแฮนเดิลเพื่อให้แน่ใจว่าเธรดทั้งหมดรันเสร็จสิ้น เมื่อถึงจุดนั้น เธรดหลักจะรับล็อกและพิมพ์ผลลัพธ์ของโปรแกรมนี้

เราได้บอกเป็นนัยไว้แล้วว่าตัวอย่างนี้จะไม่คอมไพล์ คราวนี้ลองมาดูสาเหตุกัน!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
 5 |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
 8 |     for _ in 0..10 {
   |     -------------- inside of this loop
 9 |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

ข้อความแสดงข้อผิดพลาดระบุว่าค่า counter ถูกย้าย (moved) ไปแล้วในการวนลูปครั้งก่อนหน้า Rust กำลังบอกเราว่าเราไม่สามารถย้ายความเป็นเจ้าของของล็อก counter ไปยังหลายๆ เธรดได้ ลองมาแก้ข้อผิดพลาดคอมไพเลอร์ด้วยวิธีความเป็นเจ้าของหลายรายที่เราพูดถึงในบทที่ 15 กัน

ความเป็นเจ้าของหลายรายกับหลายเธรด

ในบทที่ 15 เรามอบค่าให้กับผู้เป็นเจ้าของหลายรายโดยใช้พอยน์เตอร์อัจฉริยะ Rc<T> เพื่อสร้างค่านับจำนวนการอ้างอิง (reference-counted value) ลองมาทำแบบเดียวกันที่นี่และดูว่าจะเกิดอะไรขึ้น เราจะห่อหุ้ม Mutex<T> ไว้ใน Rc<T> ในโค้ดตัวอย่างที่ 16-14 และทำการโคลน Rc<T> ก่อนที่จะย้ายความเป็นเจ้าของไปยังเธรด

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

อีกครั้ง เราคอมไพล์และได้… ข้อผิดพลาดอื่น! คอมไพเลอร์กำลังสอนเรามากมาย:

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/std/src/thread/functions.rs:125:0

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

ว้าว ข้อความแสดงข้อผิดพลาดนี้ยาวมาก! นี่คือส่วนสำคัญที่ควรโฟกัส: `Rc<Mutex<i32>>` cannot be sent between threads safely (ไม่สามารถส่ง Rc<Mutex<i32>> ระหว่างเธรดอย่างปลอดภัยได้) คอมไพเลอร์ยังบอกเหตุผลให้เราทราบด้วย: the trait `Send` is not implemented for `Rc<Mutex<i32>>` (เทรต Send ยังไม่ได้ถูกใช้อยู่บน Rc<Mutex<i32>>) เราจะพูดถึง Send ในหัวข้อถัดไป: มันคือหนึ่งในเทรตที่รับประกันว่าชนิดข้อมูลที่เราใช้กับเธรดมีไว้สำหรับการใช้งานในสถานการณ์แบบคอนเคอร์เรนต์

น่าเสียดายที่ Rc<T> ไม่ปลอดภัยในการแบ่งปันข้ามเธรด เมื่อ Rc<T> จัดการจำนวนการอ้างอิง มันจะเพิ่มจำนวนขึ้นสำหรับการเรียกใช้ clone แต่ละครั้ง และลบออกจากจำนวนเมื่อแต่ละโคลนถูกคืนค่า (dropped) แต่มันไม่ได้ใช้พริมิทิฟด้านคอนเคอร์เรนซีใดๆ เพื่อให้แน่ใจว่าการเปลี่ยนแปลงจำนวนนับจะไม่ถูกขัดจังหวะโดยเธรดอื่น สิ่งนี้อาจนำไปสู่จำนวนนับที่ไม่ถูกต้อง—บั๊กซับซ้อนที่แอบแฝง ซึ่งอาจนำไปสู่ปัญหาหน่วยความจำรั่วซึม (memory leaks) หรือค่าถูกคืนไปก่อนที่เราจะใช้งานเสร็จ สิ่งที่เราต้องการคือชนิดข้อมูลที่เหมือนกับ Rc<T> ทุกประการ แต่เปลี่ยนจำนวนการอ้างอิงในรูปแบบที่ปลอดภัยต่อเธรด (thread-safe)

การนับจำนวนการอ้างอิงแบบอะตอมมิกด้วย Arc<T>

โชคดีที่ Arc<T> เป็น ชนิดข้อมูลเช่นเดียวกับ Rc<T> ที่ปลอดภัยสำหรับใช้ในสถานการณ์คอนเคอร์เรนต์ ตัว a ย่อมาจาก atomic (อะตอมมิก) หมายความว่ามันคือชนิดข้อมูล นับจำนวนการอ้างอิงแบบอะตอมมิก (atomically reference-counted) อะตอมมิกส์ (Atomics) เป็นพริมิทิฟคอนเคอร์เรนซีชนิดเพิ่มเติมที่เราจะไม่ครอบคลุมในรายละเอียดที่นี่: ดูเอกสารประกอบไลบรารีมาตรฐานสำหรับ std::sync::atomic เพื่อดูรายละเอียดเพิ่มเติม ในจุดนี้ คุณเพียงต้องรู้ว่าอะตอมมิกส์ทำงานเหมือนชนิดข้อมูลพริมิทิฟ แต่ปลอดภัยที่จะแบ่งปันข้ามเธรด

คุณอาจสงสัยว่าทำไมชนิดข้อมูลพริมิทิฟทั้งหมดจึงไม่เป็นอะตอมมิก และทำไมชนิดข้อมูลในไลบรารีมาตรฐานจึงไม่ถูกใช้ Arc<T> เป็นค่าเริ่มต้น เหตุผลก็คือความปลอดภัยของเธรด (thread safety) มาพร้อมกับผลกระทบด้านประสิทธิภาพ (performance penalty) ที่คุณต้องการจ่ายเฉพาะเมื่อจำเป็นจริงๆ เท่านั้น หากคุณเพียงแค่การดำเนินการกับค่าต่างๆ ภายในเธรดเดียว โค้ดของคุณจะรันได้เร็วกว่าหากไม่ต้องบังคับใช้การรับประกันที่อะตอมมิกส์จัดหาให้

ลองย้อนกลับมาดูตัวอย่างของเรา: Arc<T> และ Rc<T> มี API เหมือนกัน ดังนั้นเราจึงแก้ไขโปรแกรมของเราโดยการเปลี่ยนบรรทัด use, การเรียกใช้ new, และการเรียกใช้ clone โค้ดในโค้ดตัวอย่างที่ 16-15 ในที่สุดก็จะคอมไพล์และทำงานได้

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

โค้ดนี้จะพิมพ์สิ่งต่อไปนี้:

Result: 10

เราทำสำเร็จแล้ว! เรานับจาก 0 ถึง 10 ซึ่งอาจดูไม่น่าประทับใจเท่าไรนัก แต่มันได้สอนเรามากมายเกี่ยวกับ Mutex<T> และความปลอดภัยของเธรด คุณสามารถใช้โครงสร้างของโปรแกรมนี้ในการทำคำสั่งที่ซับซ้อนกว่าแค่การเพิ่มตัวนับได้ ด้วยกลยุทธ์นี้ คุณสามารถแบ่งการคำนวณออกเป็นส่วนๆ ที่เป็นอิสระต่อกัน กระจายส่วนเหล่านั้นไปยังเธรดต่างๆ จากนั้นใช้ Mutex<T> เพื่อให้แต่ละเธรดอัปเดตผลลัพธ์สุดท้ายด้วยส่วนของมัน

โปรดสังเกตว่าหากคุณกำลังทำการคำนวณตัวเลขอภิปรายอย่างง่าย มีชนิดข้อมูลที่เรียบง่ายกว่าชนิดข้อมูล Mutex<T> ซึ่งจัดหาไว้โดย โมดูล std::sync::atomic ของไลบรารีมาตรฐาน ชนิดข้อมูลเหล่านี้มอบการเข้าถึงชนิดข้อมูลพริมิทิฟแบบอะตอมมิกที่ปลอดภัยและเป็นคอนเคอร์เรนต์ เราเลือกใช้ Mutex<T> ร่วมกับชนิดข้อมูลพริมิทิฟสำหรับตัวอย่างนี้ เพื่อให้เราสามารถโฟกัสไปที่วิธีการทำงานของ Mutex<T> ได้

การเปรียบเทียบ RefCell<T>/Rc<T> และ Mutex<T>/Arc<T>

คุณอาจสังเกตเห็นว่า counter เป็นสิ่งที่ไม่สามารถเปลี่ยนแปลงค่าได้ (immutable) แต่เรายังคงรับการอ้างอิงแบบเปลี่ยนแปลงได้ไปยังค่าภายในมันได้ หมายความว่า Mutex<T> มอบการแก้ไขภายใน (interior mutability) เช่นเดียวกับที่ตระกูล Cell ทำ ในลักษณะเดียวกับที่เราใช้ RefCell<T> ในบทที่ 15 เพื่อยอมให้เราปรับเปลี่ยนเนื้อหาภายใน Rc<T> ได้ เราใช้ Mutex<T> เพื่อปรับเปลี่ยนเนื้อหาภายใน Arc<T>

อีกรายละเอียดหนึ่งที่ควรสังเกตคือ Rust ไม่สามารถปกป้องคุณจากข้อผิดพลาดตรรกะทุกประเภทได้เมื่อคุณใช้ Mutex<T> ย้อนกลับไปในบทที่ 15 การใช้ Rc<T> มาพร้อมกับความเสี่ยงในการสร้างวงรอบการอ้างอิง (reference cycles) ซึ่งค่า Rc<T> สองค่าอ้างอิงถึงกันและกัน ทำให้เกิดหน่วยความจำรั่วซึม ในทำนองเดียวกัน Mutex<T> ก็มาพร้อมกับความเสี่ยงในการเกิด เดดล็อก (deadlocks) สิ่งเหล่านี้เกิดขึ้นเมื่อการทำงานจำเป็นต้องล็อกสองทรัพยากร และสองเธรดต่างถือครองล็อกคนละตัว ทำให้พวกมันรอนายของกันและกันตลอดไป หากคุณสนใจเรื่องเดดล็อก ลองสร้างโปรแกรม Rust ที่มีเดดล็อกดู จากนั้นค้นคว้ากลยุทธ์การลดปัญหาเดดล็อกสำหรับมิวเทกซ์ในภาษาใดก็ได้แล้วลองใช้ใน Rust เอกสารประกอบ API ไลบรารีมาตรฐานสำหรับ Mutex<T> และ MutexGuard มีข้อมูลที่มีประโยชน์ให้อ่าน

เราจะปิดท้ายบทนี้ด้วยการพูดถึงเทรต Send และ Sync และวิธีที่เราสามารถใช้เทรตเหล่านั้นกับชนิดข้อมูลที่กำหนดขึ้นเองได้

Extensible Concurrency with Send and Sync

คอนเคอร์เรนซีที่ขยายความสามารถได้ด้วย Send และ Sync

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

อย่างไรก็ตาม ในบรรดาแนวคิดหลักด้านคอนเคอร์เรนซีที่ฝังอยู่ในภาษามากกว่าไลบรารีมาตรฐานนั้น คือเทรต std::marker ได้แก่ Send และ Sync

การถ่ายโอนความเป็นเจ้าของระหว่างเธรด

เทรตกำกับ (marker trait) Send ระบุว่าความเป็นเจ้าของของค่าของชนิดข้อมูลที่ใช้เทรต Send สามารถถ่ายโอนระหว่างเธรดได้ ชนิดข้อมูลใน Rust เกือบทั้งหมดใช้เทรต Send แต่ก็มีข้อยกเว้นบางประการ รวมถึง Rc<T>: ชนิดข้อมูลนี้ไม่สามารถใช้เทรต Send ได้ เนื่องจากหากคุณโคลนค่า Rc<T> และพยายามถ่ายโอนความเป็นเจ้าของของโคลนนั้นไปยังอีกเธรดหนึ่ง ทั้งสองเธรดอาจอัปเดตจำนวนการอ้างอิงไปพร้อมๆ กัน ด้วยเหตุนี้ Rc<T> จึงถูกสร้างขึ้นมาสำหรับการใช้งานในสถานการณ์เธรดเดียวที่คุณไม่อยากเสียประสิทธิภาพเพิ่มเติมจากความปลอดภัยของเธรด

ดังนั้น ระบบชนิดข้อมูลและขอบเขตเทรต (trait bounds) ของ Rust จึงช่วยให้แน่ใจว่าคุณจะไม่เผลอนำส่งค่า Rc<T> ข้ามเธรดอย่างไม่ปลอดภัยโดยเด็ดขาด เมื่อเราพยายามทำเช่นนั้นในโค้ดตัวอย่างที่ 16-14 เราจึงได้รับข้อผิดพลาด the trait `Send` is not implemented for `Rc<Mutex<i32>>` และเมื่อเราเปลี่ยนไปใช้ Arc<T> ซึ่งใช้เทรต Send โค้ดก็สามารถคอมไพล์ได้

ชนิดข้อมูลใดก็ตามที่ประกอบด้วยชนิดข้อมูลที่เป็น Send ทั้งหมด จะถูกทำเครื่องหมายเป็น Send โดยอัตโนมัติ ชนิดข้อมูลพริมิทิฟเกือบทั้งหมดเป็น Send ยกเว้นพอยน์เตอร์ดิบ (raw pointers) ซึ่งเราจะพูดถึงในบทที่ 20

การเข้าถึงจากหลายเธรด

เทรตกำกับ Sync ระบุว่าชนิดข้อมูลที่ใช้เทรต Sync นั้นปลอดภัยในการถูกอ้างอิงจากหลายเธรด หรือกล่าวอีกนัยหนึ่ง ชนิดข้อมูล T ใดๆ จะใช้เทรต Sync หาก &T (การอ้างอิงแบบเปลี่ยนแปลงไม่ได้ไปยัง T) ใช้เทรต Send ซึ่งหมายความว่าการอ้างอิงนั้นสามารถส่งไปยังอีกเธรดหนึ่งได้อย่างปลอดภัย คล้ายกับ Send ชนิดข้อมูลพริมิทิฟทั้งหมดใช้เทรต Sync และชนิดข้อมูลที่ประกอบด้วยชนิดข้อมูลที่ใช้เทรต Sync ทั้งหมดก็จะใช้เทรต Sync ด้วยเช่นกัน

พอยน์เตอร์อัจฉริยะ Rc<T> ไม่ได้ใช้เทรต Sync ด้วยเหตุผลเดียวกับที่ไม่ใช้เทรต Send ชนิดข้อมูล RefCell<T> (ซึ่งเราพูดถึงในบทที่ 15) และตระกูลชนิดข้อมูล Cell<T> ที่เกี่ยวข้องไม่ได้ใช้เทรต Sync การตรวจสอบการยืมที่ RefCell<T> ทำในขณะโปรแกรมทำงาน (runtime) ไม่ได้ปลอดภัยต่อเธรด ส่วนพอยน์เตอร์อัจฉริยะ Mutex<T> ใช้เทรต Sync และสามารถนำไปใช้แบ่งปันการเข้าถึงกับหลายเธรดได้ ดังที่คุณได้เห็นในหัวข้อ “การเข้าถึง Mutex<T> ร่วมกัน”

การปรับแต่งเทรต Send และ Sync ด้วยตนเองเป็นสิ่งที่ไม่ปลอดภัย (Unsafe)

เนื่องจากชนิดข้อมูลที่ประกอบด้วยชนิดข้อมูลอื่นที่ใช้เทรต Send และ Sync ทั้งหมดจะใช้เทรต Send และ Sync โดยอัตโนมัติ เราจึงไม่ต้องปรับแต่งเทรตเหล่านั้นด้วยตนเอง ในฐานะเทรตกำกับ พวกมันไม่มีแม้กระทั่งเมธอดให้ต้องเขียนการทำงานใดๆ พวกมันมีประโยชน์เพียงเพื่อบังคับใช้อนุรักษ์คุณสมบัติ (invariants) ที่เกี่ยวข้องกับคอนเคอร์เรนซีเท่านั้น

การปรับแต่งการใช้เทรตเหล่านี้ด้วยตนเองจำเป็นต้องเขียนโค้ด Rust ที่ไม่ปลอดภัย (unsafe Rust code) เราจะพูดถึงการใช้โค้ด Rust ที่ไม่ปลอดภัยในบทที่ 20 สำหรับตอนนี้ ข้อมูลสำคัญคือการสร้างชนิดข้อมูลคอนเคอร์เรนต์ใหม่ที่ไม่ได้ประกอบจากส่วนที่เป็น Send และ Sync จำเป็นต้องใช้ความคิดอย่างรอบคอบเพื่อรักษาการรับประกันความปลอดภัยเอาไว้ “The Rustonomicon” มีข้อมูลเพิ่มเติมเกี่ยวกับการรับประกันเหล่านี้และวิธีรักษามันไว้

สรุป

นี่ไม่ใช่ครั้งสุดท้ายที่คุณจะได้เห็นเรื่องคอนเคอร์เรนซีในหนังสือเล่มนี้: บทถัดไปจะเน้นเรื่องการเขียนโปรแกรมแบบอะซิงโครนัส (async programming) และโปรเจกต์ในบทที่ 21 จะนำแนวคิดในบทนี้ไปใช้ในสถานการณ์ที่สมจริงมากกว่าตัวอย่างขนาดเล็กที่คุยกันในที่นี้

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

ไลบรารีมาตรฐานของ Rust จัดหาแชนเนลสำหรับการส่งข้อความและชนิดข้อมูลพอยน์เตอร์อัจฉริยะ เช่น Mutex<T> และ Arc<T> ซึ่งปลอดภัยสำหรับการใช้งานในบริบทแบบคอนเคอร์เรนต์ ระบบชนิดข้อมูลและตัวตรวจสอบการยืมช่วยรับประกันว่าโค้ดที่ใช้โซลูชันเหล่านี้จะไม่จบลงด้วยภาวะแย่งชิงข้อมูล (data races) หรือการอ้างอิงที่ไม่ถูกต้อง เมื่อคุณทำให้โค้ดคอมไพล์ผ่านได้แล้ว คุณก็มั่นใจได้เลยว่าโค้ดจะทำงานบนหลายเธรดได้อย่างราบรื่นโดยไม่มีบั๊กที่ติดตามยากอย่างที่พบบ่อยในภาษาอื่นๆ การโปรแกรมแบบคอนเคอร์เรนซีไม่ใช่แนวคิดที่ต้องกลัวอีกต่อไป: จงก้าวออกไปและทำให้โปรแกรมของคุณทำงานแบบคอนเคอร์เรนต์ได้อย่างไร้กังวล!

พื้นฐานของการเขียนโปรแกรมแบบอะซิงโครนัส: Async, Await, Futures และ Streams

การทำงานหลายอย่างที่เราสั่งให้คอมพิวเตอร์ทำอาจใช้เวลาสักพักจึงจะเสร็จสิ้น จะดีเพียงใดหากเราสามารถทำอย่างอื่นได้ในระหว่างรอให้กระบวนการที่ใช้เวลานานเหล่านั้นทำงานจนเสร็จ คอมพิวเตอร์สมัยใหม่เสนอสองเทคนิคในการทำงานกับคำสั่งมากกว่าหนึ่งคำสั่งในเวลาเดียวกัน นั่นคือ พาราเลลลิซึมหรือการทำงานขนานกัน (parallelism) และคอนเคอร์เรนซี (concurrency) อย่างไรก็ตาม ตรรกะของโปรแกรมเรามักจะถูกเขียนในรูปแบบที่เป็นเส้นตรง (linear fashion) เราอยากจะระบุคำสั่งที่โปรแกรมควรทำงานและจุดที่ฟังก์ชันสามารถหยุดพักชั่วคราวเพื่อให้ส่วนอื่นของโปรแกรมทำงานแทนได้ โดยไม่จำเป็นต้องระบุล่วงหน้าอย่างละเอียดถึงลำดับและรูปแบบที่โค้ดแต่ละส่วนควรจะรัน การเขียนโปรแกรมแบบอะซิงโครนัส (Asynchronous programming) เป็นนามธรรมที่ช่วยให้เราแสดงโค้ดในรูปแบบของจุดที่อาจหยุดพักชั่วคราวและผลลัพธ์ในท้ายที่สุด โดยจะช่วยจัดการรายละเอียดของการประสานงานแทนเรา

บทนี้ต่อยอดจากการใช้เธรดในบทที่ 16 สำหรับพาราเลลลิซึมและคอนเคอร์เรนซี โดยการแนะนำแนวทางทางเลือกในการเขียนโค้ด นั่นคือ futures, streams ของ Rust และไวยากรณ์ async และ await ที่ช่วยให้เราแสดงได้ว่าการทำงานสามารถเป็นแบบอะซิงโครนัสได้อย่างไร ตลอดจนเครตภายนอกที่ใช้สร้างรันไทม์แบบอะซิงโครนัส (asynchronous runtimes): ซึ่งเป็นโค้ดที่จัดการและประสานงานการประมวลผลคำสั่งแบบอะซิงโครนัส

ลองพิจารณาตัวอย่างหนึ่ง สมมติว่าคุณกำลังส่งออก (export) วิดีโอฉลองงานครอบครัวที่คุณสร้างขึ้น การทำงานนี้อาจใช้เวลาตั้งแต่ไม่กี่นาทีไปจนถึงหลายชั่วโมง การส่งออกวิดีโอจะใช้กำลังของ CPU และ GPU มากที่สุดเท่าที่จะทำได้ หากคุณมีคอร์ CPU เพียงคอร์เดียวและระบบปฏิบัติการของคุณไม่ได้หยุดพักการส่งออกนั้นชั่วคราวจนกว่าจะเสร็จสิ้น—กล่าวคือ หากมันประมวลผลการส่งออก แบบซิงโครนัส (synchronously)—คุณจะไม่สามารถทำอย่างอื่นบนคอมพิวเตอร์ได้เลยในระหว่างที่งานนั้นกำลังรันอยู่ นั่นอาจเป็นประสบการณ์ที่น่าอึดอัดใจทีเดียว โชคดีที่ระบบปฏิบัติการของคอมพิวเตอร์คุณสามารถและได้ทำการขัดจังหวะ (interrupt) การส่งออกนั้นโดยที่คุณไม่เห็นบ่อยพอที่จะเปิดโอกาสให้คุณทำงานอื่นไปพร้อมกันได้

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

การส่งออกวิดีโอเป็นตัวอย่างของการทำงานที่มี ขีดจำกัดซีพียู (CPU-bound) หรือ ขีดจำกัดการคำนวณ (compute-bound) ซึ่งถูกจำกัดโดยความเร็วการประมวลผลข้อมูลของคอมพิวเตอร์ภายใน CPU หรือ GPU และความเร็วที่สามารถอุทิศให้กับการทำงานนั้นได้ การดาวน์โหลดวิดีโอเป็นตัวอย่างของการทำงานที่มี ขีดจำกัดไอโอ (I/O-bound) เพราะมันถูกจำกัดโดยความเร็วของ การนำเข้าและส่งออกข้อมูล (input and output) ของคอมพิวเตอร์ มันจะทำงานได้เร็วเท่าที่ข้อมูลสามารถถูกส่งข้ามเครือข่ายมาได้เท่านั้น

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

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

หมายเหตุ: หากลองคิดดู นี่คือวิธีที่การเรียกใช้ฟังก์ชัน ส่วนใหญ่ ทำงาน ทว่าคำว่า บล็อก (blocking) มักจะถูกสงวนไว้สำหรับการเรียกใช้ฟังก์ชันที่โต้ตอบกับไฟล์ เครือข่าย หรือทรัพยากรอื่นๆ บนคอมพิวเตอร์ เนื่องจากนั่นคือกรณีที่โปรแกรมแต่ละโปรแกรมจะได้ประโยชน์จากการทำงานแบบ ไม่บล็อก (non-blocking)

เราสามารถหลีกเลี่ยงการบล็อกเธรดหลักของเราได้โดยการสร้างเธรดเฉพาะขึ้นมาดาวน์โหลดแต่ละไฟล์ อย่างไรก็ตาม โหลดส่วนเกิน (overhead) ของทรัพยากรระบบที่ถูกใช้โดยเธรดเหล่านั้นจะกลายเป็นปัญหาในที่สุด มันจะดีกว่าหากการเรียกใช้งานนั้นไม่บล็อกตั้งแต่แรก และเราสามารถกำหนดจำนวนงานที่เราอยากให้โปรแกรมของเราทำจนเสร็จสิ้น รวมถึงปล่อยให้รันไทม์เลือกลำดับและรูปแบบที่ดีที่สุดในการรันงานเหล่านั้นได้

นั่นคือสิ่งที่นามธรรม async (ย่อมาจาก asynchronous) ของ Rust มอบให้เรา ในบทนี้ คุณจะได้เรียนรู้ทั้งหมดเกี่ยวกับ async โดยเราจะครอบคลุมหัวข้อต่อไปนี้:

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

ก่อนที่เราจะเห็นว่า async ทำงานอย่างไรในทางปฏิบัติ เราต้องอ้อมสั้นๆ เพื่ออภิปรายถึงความแตกต่างระหว่างพาราเลลลิซึม (การทำงานขนานกัน) และคอนเคอร์เรนซี

พาราเลลลิซึมและคอนเคอร์เรนซี

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

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

เมื่อบุคคลหนึ่งทำงานหลายงานที่แตกต่างกันก่อนที่งานใดงานหนึ่งจะเสร็จสิ้น สิ่งนี้คือ คอนเคอร์เรนซี (concurrency) วิธีหนึ่งในการใช้คอนเคอร์เรนซีจะคล้ายกับการที่คุณมีสองโปรเจกต์ที่ต่างกันเปิดอยู่บนคอมพิวเตอร์ และเมื่อคุณเบื่อหรือติดขัดในโปรเจกต์หนึ่ง คุณก็สลับไปยังอีกโปรเจกต์หนึ่ง คุณเป็นเพียงคนๆ เดียว ดังนั้นคุณจึงไม่สามารถสร้างความคืบหน้าในทั้งสองงานได้ในเวลาเดียวกันเป๊ะๆ แต่คุณสามารถทำงานหลายอย่างสลับกัน (multitask) โดยสร้างความคืบหน้าทีละงานได้โดยการสลับไปมาสลับมา (ดูรูปภาพที่ 17-1)

ไดอะแกรมที่มีกล่องซ้อนกันซ้ายขวาชื่อ Task A และ Task B โดยมีรูปเพชรภายในแทนงานย่อย ลูกศรชี้จาก A1 ไปยัง B1, B1 ไปยัง A2, A2 ไปยัง B2, B2 ไปยัง A3, A3 ไปยัง A4 และ A4 ไปยัง B3 ลูกศรระหว่างงานย่อยข้ามกล่องระหว่าง Task A และ Task B
รูปภาพที่ 17-1: เวิร์กโฟลว์แบบคอนเคอร์เรนซี ซึ่งสลับไปมาระหว่าง Task A และ Task B

เมื่อทีมแบ่งกลุ่มงานโดยให้สมาชิกแต่ละคนรับไปหนึ่งงานและทำงานนั้นโดยลำพัง สิ่งนี้คือ พาราเลลลิซึม (parallelism) สมาชิกแต่ละคนในทีมสามารถสร้างความคืบหน้าได้พร้อมกันในเวลาเดียวกันเป๊ะๆ (ดูรูปภาพที่ 17-2)

ไดอะแกรมที่มีกล่องซ้อนกันชื่อ Task A และ Task B โดยมีรูปเพชรภายในแทนงานย่อย ลูกศรชี้จาก A1 ไปยัง A2, A2 ไปยัง A3, A3 ไปยัง A4, B1 ไปยัง B2 และ B2 ไปยัง B3 ไม่มีลูกศรข้ามระหว่างกล่องสำหรับ Task A และ Task B
รูปภาพที่ 17-2: เเวิร์กโฟลว์แบบพาราเลล (ขนานกัน) ซึ่งงานบน Task A และ Task B ดำเนินไปอย่างเป็นอิสระต่อกัน

ในเวิร์กโฟลว์ทั้งสองแบบนี้ คุณอาจต้องประสานงานระหว่างงานต่างๆ ที่ต่างกัน บางทีคุณอาจคิดว่างานที่มอบหมายให้คนๆ หนึ่งเป็นอิสระจากงานของคนอื่นโดยสมบูรณ์ แต่จริงๆ แล้วงานนั้นกลับต้องการให้อีกคนในทีมทำงานของเขาให้เสร็จก่อน งานบางส่วนอาจทำแบบขนาน (parallel) ได้ แต่งานบางส่วนกลับเป็นแบบ อนุกรม (serial): กล่าวคือสามารถเกิดขึ้นเป็นลำดับ ต่อเนื่องกันไป งานหนึ่งหลังอีกงานหนึ่ง ดังในรูปภาพที่ 17-3

ไดอะแกรมที่มีกล่องซ้อนกันชื่อ Task A และ Task B โดยมีรูปเพชรภายในแทนงานย่อย ใน Task A ลูกศรชี้จาก A1 ไปยัง A2 จาก A2 ไปยังเส้นตั้งหนาคู่เหมือนสัญลักษณ์ “หยุดพัก” และจากสัญลักษณ์นั้นไปยัง A3 ใน Task B ลูกศรชี้จาก B1 ไปยัง B2 จาก B2 ไปยัง B3 จาก B3 ไปยัง A3 และจาก B3 ไปยัง B4
รูปภาพที่ 17-3: เวิร์กโฟลว์แบบพาราเลลบางส่วน ซึ่งงานบน Task A และ Task B ดำเนินไปอย่างเป็นอิสระต่อกัน จนกระทั่ง Task A3 ถูกบล็อกเนื่องจากต้องรอผลลัพธ์ของ Task B3

ในทำนองเดียวกัน คุณอาจตระหนักได้ว่างานชิ้นหนึ่งของคุณเองขึ้นอยู่กับอีกงานชิ้นหนึ่งของคุณ ตอนนี้งานแบบคอนเคอร์เรนต์ของคุณก็กลายเป็นงานแบบอนุกรมเช่นกัน

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

กลไกพื้นฐานเดียวกันนี้เกิดขึ้นกับซอฟต์แวร์และฮาร์ดแวร์ บนเครื่องที่มีคอร์ CPU เดียว CPU สามารถประมวลผลคำสั่งได้เพียงหนึ่งคำสั่ง ณ เวลาหนึ่งเท่านั้น แต่มันยังคงสามารถทำงานแบบคอนเคอร์เรนต์ได้ โดยการใช้เครื่องมือ เช่น เธรด โพรเซส และ async คอมพิวเตอร์สามารถหยุดพักกิจกรรมหนึ่งชั่วคราวและสลับไปทำกิจกรรมอื่นก่อนที่จะวนกลับมากิจกรรมแรกอีกครั้ง บนเครื่องที่มีคอร์ CPU หลายคอร์ คอมพิวเตอร์สามารถทำงานแบบขนาน (parallel) ได้ด้วย คอร์หนึ่งสามารถประมวลผลงานหนึ่งในขณะที่อีกคอร์หนึ่งประมวลผลงานที่ไม่เกี่ยวข้องกันโดยสิ้นเชิง และการทำงานเหล่านั้นเกิดขึ้นในเวลาเดียวกันเป๊ะๆ

การรันโค้ด async ใน Rust มักเกิดขึ้นแบบคอนเคอร์เรนต์ ขึ้นอยู่กับฮาร์ดแวร์ ระบบปฏิบัติการ และรันไทม์ async ที่เราใช้ (รายละเอียดเพิ่มเติมเกี่ยวกับรันไทม์ async ในอีกสักครู่) คอนเคอร์เรนซีนั้นอาจใช้พาราเลลลิซึมอยู่เบื้องหลังด้วย

คราวนี้ ลองมาเจาะลึกกันว่าการเขียนโปรแกรมแบบ async ใน Rust ทำงานอย่างไรจริงๆ

Futures and the Async Syntax

Futures และไวยากรณ์ Async

องค์ประกอบสำคัญของการเขียนโปรแกรมแบบอะซิงโครนัสใน Rust คือ ฟิวเจอร์ส (futures) และคีย์เวิร์ด async กับ await ของ Rust

ฟิวเจอร์ (future) คือค่าที่อาจจะยังไม่พร้อมในขณะนี้ แต่จะพร้อมในจุดใดจุดหนึ่งในอนาคต (แนวคิดเดียวกันนี้ปรากฏในหลายภาษา บางครั้งใช้ชื่ออื่น เช่น task หรือ promise) Rust จัดหาเทรต Future ไว้เป็นบล็อกสร้าง เพื่อให้การทำงานแบบ async ที่แตกต่างกันสามารถถูกสร้างขึ้นด้วยโครงสร้างข้อมูลที่ต่างกันแต่มีส่วนต่อประสาน (interface) ร่วมกัน ใน Rust ฟิวเจอร์สคือชนิดข้อมูลที่ใช้เทรต Future ฟิวเจอร์แต่ละตัวจะถือครองข้อมูลความคืบหน้าที่เกิดขึ้น และความหมายของคำว่า “พร้อม” ของตัวมันเอง

คุณสามารถใช้คีย์เวิร์ด async กับบล็อกและฟังก์ชันเพื่อระบุว่าพวกมันสามารถถูกขัดจังหวะและกลับมาทำงานต่อได้ ภายในบล็อก async หรือฟังก์ชัน async คุณสามารถใช้คีย์เวิร์ด await เพื่อ รอฟิวเจอร์ (await a future) (นั่นคือ รอให้มันพร้อม) จุดใดก็ตามที่คุณรอฟิวเจอร์ภายในบล็อกหรือฟังก์ชัน async จะเป็นจุดที่อาจจะเกิดการหยุดพักชั่วคราวและกลับมาทำงานต่อของบล็อกหรือฟังก์ชันนั้น กระบวนการตรวจสอบกับฟิวเจอร์เพื่อดูว่าค่าของมันพร้อมใช้งานแล้วหรือยังเรียกว่า โพลลิง (polling)

ภาษาอื่นๆ บางภาษา เช่น C# และ JavaScript ก็ใช้คีย์เวิร์ด async และ await สำหรับการเขียนโปรแกรมแบบ async เช่นกัน หากคุณคุ้นเคยกับภาษาเหล่านั้น คุณอาจสังเกตเห็นข้อแตกต่างที่สำคัญในวิธีที่ Rust จัดการกับไวยากรณ์ นั่นเป็นเพราะมีเหตุผลที่ดี ดังที่เราจะได้เห็น!

เมื่อเขียน async ใน Rust เราจะใช้คีย์เวิร์ด async และ await ส่วนใหญ่ คอมไพเลอร์ของ Rust จะแปลพวกมันเป็นโค้ดที่เทียบเท่ากันโดยใช้เทรต Future คล้ายกับวิธีที่แปลลูป for เป็นโค้ดเทียบเท่าโดยใช้เทรต Iterator อย่างไรก็ตาม เนื่องจาก Rust จัดหาเทรต Future ไว้ให้ คุณจึงสามารถใช้เทรตนี้กับชนิดข้อมูลของคุณเองได้เมื่อจำเป็น ฟังก์ชันจำนวนมากที่เราจะได้เห็นตลอดทั้งบทนี้จะคืนค่าชนิดข้อมูลที่มีการใช้เทรต Future ของตนเอง เราจะย้อนกลับมาดูคำนิยามของเทรตนี้ตอนท้ายบทและเจาะลึกวิธีการทำงานของมัน แต่นี่เป็นรายละเอียดที่เพียงพอที่จะทำให้เราเดินหน้าต่อไปได้

ทั้งหมดนี้อาจฟังดูเป็นนามธรรมไปสักหน่อย ดังนั้นเรามาเขียนโปรแกรม async ตัวแรกของเรากันดีกว่า: นั่นคือเว็บสแครปเปอร์ (web scraper) เล็กๆ เราจะส่ง URL สองตัวจากบรรทัดคำสั่ง ดึงข้อมูลทั้งสองหน้าแบบคอนเคอร์เรนต์ และคืนค่าผลลัพธ์ของ URL ตัวใดก็ตามที่เสร็จก่อน ตัวอย่างนี้จะมีไวยากรณ์ใหม่ค่อนข้างมาก แต่นึกวางใจได้—เราจะอธิบายทุกอย่างที่คุณจำเป็นต้องรู้ไปเรื่อยๆ

โปรแกรม Async ตัวแรกของเรา

เพื่อรักษาจุดสนใจของบทนี้ไว้ที่การเรียนรู้เรื่อง async มากกว่าการสาละวนกับชิ้นส่วนต่างๆ ในอีโคซิสเต็ม เราได้สร้างเครต trpl ขึ้นมา (trpl ย่อมาจาก “The Rust Programming Language”) ซึ่งทำการ re-export ชนิดข้อมูล เทรต และฟังก์ชันทั้งหมดที่คุณต้องการ โดยส่วนใหญ่มาจากเครต futures และ tokio เครต futures เป็นศูนย์กลางอย่างเป็นทางการในการทดลองโค้ด async ของ Rust และเป็นสถานที่ซึ่งเทรต Future ถูกออกแบบขึ้นในตอนแรก ส่วน Tokio คือรันไทม์ async ที่ถูกใช้งานแพร่หลายที่สุดใน Rust ในปัจจุบัน โดยเฉพาะสำหรับเว็บแอปพลิเคชัน มีรันไทม์ที่ดีอื่นๆ อีกหลายตัวที่นั่น และพวกมันอาจเหมาะกับวัตถุประสงค์ของคุณมากกว่า เราใช้เครต tokio อยู่เบื้องหลังสำหรับ trpl เนื่องจากมันได้รับการทดสอบมาเป็นอย่างดีและถูกใช้งานอย่างกว้างขวาง

ในบางกรณี trpl ยังเปลี่ยนชื่อหรือห่อหุ้ม API ดั้งเดิมเพื่อให้คุณจดจ่อกับรายละเอียดที่เกี่ยวข้องกับบทนี้ หากคุณต้องการเข้าใจว่าเครตนี้ทำอะไร เราขอแนะนำให้ลองเข้าไปดู ซอร์สโค้ดของมัน คุณจะสามารถเห็นได้ว่าการ re-export แต่ละอย่างมาจากเครตใด และเราได้ทิ้งคอมเมนต์อธิบายสิ่งที่เครตทำไว้อย่างละเอียด

สร้างโปรเจกต์แบบไบนารีใหม่ชื่อ hello-async และเพิ่มเครต trpl เป็นพึ่งพา (dependency):

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

คราวนี้เราสามารถใช้ชิ้นส่วนต่างๆ ที่จัดหาโดย trpl เพื่อเขียนโปรแกรม async ตัวแรกของเรา เราจะสร้างเครื่องมือบรรทัดคำสั่งเล็กๆ ที่ดึงเว็บเพจสองหน้า สกัดเอาเอลิเมนต์ <title> จากแต่ละหน้า และพิมพ์ชื่อเรื่องของหน้าใดก็ตามที่ทำกระบวนการทั้งหมดเสร็จเป็นหน้าแรก

การนิยามฟังก์ชัน page_title

ลองเริ่มจากการเขียนฟังก์ชันที่รับ URL หน้าเว็บหนึ่งตัวเป็นพารามิเตอร์ ทำการส่งคำขอไป และคืนค่าข้อความของเอลิเมนต์ <title> ออกมา (ดูโค้ดตัวอย่างที่ 17-1)

extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

ขั้นแรก เรานิยามฟังก์ชันชื่อ page_title และทำเครื่องหมายไว้ด้วยคีย์เวิร์ด async จากนั้นเราใช้ฟังก์ชัน trpl::get เพื่อดึง URL ใดก็ตามที่ส่งเข้ามา แล้วเพิ่มคีย์เวิร์ด await เพื่อรอคำตอบกลับ (response) ในการดึงข้อความของ response เราเรียกใช้เมธอด text ของมันและรอคอยมันอีกครั้งด้วยคีย์เวิร์ด await ขั้นตอนทั้งสองนี้เป็นแบบอะซิงโครนัส สำหรับฟังก์ชัน get เราต้องรอให้เซิร์ฟเวอร์ส่งส่วนแรกของคำตอบกลับมา ซึ่งจะรวมถึงส่วนหัว HTTP (HTTP headers), คุกกี้ และอื่นๆ ซึ่งสามารถส่งแยกต่างหากจากส่วนเนื้อหา (body) ของคำตอบ โดยเฉพาะอย่างยิ่งหากเนื้อหามีขนาดใหญ่มาก ก็อาจใช้เวลาสักพักกว่าที่เนื้อหาทั้งหมดจะมาถึง และเนื่องจากเราต้องรอให้คำตอบมาถึง ทั้งหมด เมธอด text จึงเป็น async ด้วยเช่นกัน

เราต้องทำการรอ (await) ฟิวเจอร์สทั้งสองนี้อย่างชัดเจน เนื่องจากฟิวเจอร์สใน Rust มีลักษณะ ขี้เกียจ (lazy): พวกมันจะไม่ทำอะไรเลยจนกว่าคุณจะขอให้ทำด้วยคีย์เวิร์ด await (ในความเป็นจริง Rust จะแสดงคำเตือนคอมไพเลอร์หากคุณไม่ใช้งานฟิวเจอร์) สิ่งนี้อาจทำให้คุณนึกถึงการอภิปรายเกี่ยวกับตัวซ้ำ (iterators) ในหัวข้อ “การประมวลผลลำดับสิ่งของด้วยตัวซ้ำ” ในบทที่ 13 ตัวซ้ำจะไม่ทำอะไรเลยจนกว่าคุณจะเรียกใช้เมธอด next ของพวกมัน—ไม่ว่าจะโดยตรง หรือโดยการใช้ลูป for หรือเมธอดเช่น map ที่ใช้ next อยู่เบื้องหลัง ในทำนองเดียวกัน ฟิวเจอร์สจะไม่ทำอะไรเลยจนกว่าคุณจะขอร้องพวกมันอย่างชัดเจน ความขี้เกียจนี้ช่วยให้ Rust หลีกเลี่ยงการรันโค้ด async จนกว่าจะจำเป็นต้องใช้งานจริง

หมายเหตุ: สิ่งนี้แตกต่างจากพฤติกรรมที่เราเห็นเมื่อใช้ thread::spawn ในหัวข้อ “การสร้างเธรดใหม่ด้วย spawn” ในบทที่ 16 ซึ่งโคลเชอร์ที่เราส่งไปยังอีกเธรดหนึ่งเริ่มทำงานทันที นอกจากนี้ยังแตกต่างจากวิธีที่หลายภาษาอื่นใช้วิธีจัดการ async แต่มันมีความสำคัญต่อ Rust ในการมอบการรับประกันประสิทธิภาพ เช่นเดียวกับตัวซ้ำ

เมื่อเราได้ response_text มาแล้ว เราสามารถพาร์ส (parse) มันเข้าไปในอินสแตนซ์ของชนิดข้อมูล Html โดยใช้ Html::parse แทนที่จะเป็นสตริงดิบ ตอนนี้เรามีชนิดข้อมูลที่เราสามารถใช้ทำงานกับ HTML ในฐานะโครงสร้างข้อมูลที่สมบูรณ์ยิ่งขึ้น โดยเฉพาะอย่างยิ่ง เราสามารถใช้เมธอด select_first เพื่อค้นหาอินสแตนซ์แรกของ CSS selector ที่กำหนด โดยการส่งสตริง "title" เราจะได้เอลิเมนต์ <title> แรกในเอกสาร หากมีอยู่ เนื่องจากอาจไม่มีเอลิเมนต์ที่ตรงกันเลย select_first จึงคืนค่าเป็น Option<ElementRef> ท้ายที่สุด เราใช้เมธอด Option::map ซึ่งช่วยให้เราทำงานกับไอเทมใน Option หากมันมีอยู่ และไม่ทำอะไรหากไม่มี (เราสามารถใช้พฤติกรรม match ที่นี่ได้เช่นกัน แต่ map มีความเป็นสำนวนสไตล์ Rust มากกว่า) ในส่วนของฟังก์ชันที่เรามอบให้แก่ map เราเรียกใช้ inner_html บน title เพื่อดึงเนื้อหาภายใน ซึ่งก็คือ String เมื่อทำทั้งหมดเสร็จสิ้น เราจะได้ Option<String>

โปรดสังเกตว่าคีย์เวิร์ด await ของ Rust จะอยู่ ตามหลัง นิพจน์ที่คุณกำลังรอ ไม่ใช่ก่อนหน้า กล่าวคือ มันเป็นคีย์เวิร์ดแบบ ต่อท้าย (postfix) สิ่งนี้อาจแตกต่างจากสิ่งที่คุณคุ้นเคยหากคุณเคยใช้ async ในภาษาอื่น แต่ใน Rust มันทำให้การเชื่อมต่อเมธอดเป็นสาย (chains) ทำงานได้งดงามยิ่งขึ้น ผลลัพธ์คือเราสามารถเปลี่ยนเนื้อหาของ page_title เพื่อเชื่อมการเรียกใช้ฟังก์ชัน trpl::get และ text เข้าด้วยกัน โดยมี await คั่นกลาง ดังแสดงในโค้ดตัวอย่างที่ 17-2

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

เพียงเท่านี้ เราก็ได้เขียนฟังก์ชัน async ตัวแรกของเราสำเร็จแล้ว! ก่อนที่เราจะเพิ่มโค้ดใน main เพื่อเรียกใช้มัน ลองมาพูดถึงสิ่งที่เราเขียนและสิ่งที่มันหมายถึงเพิ่มขึ้นอีกเล็กน้อย

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

ดังนั้น การเขียน async fn จึงเทียบเท่ากับการเขียนฟังก์ชันที่คืนค่า ฟิวเจอร์ ของชนิดค่าส่งกลับ สำหรับคอมไพเลอร์ คำนิยามฟังก์ชัน เช่น async fn page_title ในโค้ดตัวอย่างที่ 17-1 จะเทียบเท่าโดยคร่าวๆ กับฟังก์ชันที่ไม่ใช่ async ที่นิยามไว้แบบนี้:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

ลองไล่ดูทีละส่วนของเวอร์ชันที่ถูกแปลง:

  • มันใช้ไวยากรณ์ impl Trait ที่เราได้อภิปรายกันในบทที่ 10 ในหัวข้อ “เทรตในฐานะพารามิเตอร์”
  • ค่าที่คืนกลับมาจะใช้เทรต Future ที่มีชนิดข้อมูลเกี่ยวเนื่อง (associated type) เป็น Output สังเกตว่าชนิดข้อมูล Output คือ Option<String> ซึ่งเหมือนกับชนิดค่าส่งกลับดั้งเดิมจากเวอร์ชัน async fn ของ page_title
  • โค้ดทั้งหมดที่ถูกเรียกใช้ในเนื้อหาของฟังก์ชันดั้งเดิมถูกห่อหุ้มอยู่ในบล็อก async move จำไว้ว่าบล็อกเป็นนิพจน์ (expressions) บล็อกทั้งหมดนี้คือนิพจน์ที่คืนค่าจากฟังก์ชัน
  • บล็อก async นี้สร้างค่าที่มีชนิดข้อมูล Option<String> ดังที่เพิ่งอธิบายไป ค่านั้นตรงกับชนิด Output ในชนิดค่าส่งกลับ สิ่งนี้เหมือนกับบล็อกอื่นๆ ที่คุณเคยเห็น
  • เนื้อหาฟังก์ชันใหม่เป็นบล็อก async move เนื่องจากวิธีที่มันใช้พารามิเตอร์ url (เราจะพูดถึง async กับ async move มากยิ่งขึ้นในภายหลังของบทนี้)

คราวนี้เราสามารถเรียกใช้ page_title ใน main ได้แล้ว

การเรียกใช้ฟังก์ชัน Async ด้วยรันไทม์

ในการเริ่มต้น เราจะดึงชื่อเรื่องของหน้าเว็บเพียงหน้าเดียว แสดงในโค้ดตัวอย่างที่ 17-3 แต่น่าเสียดายที่โค้ดนี้ยังไม่คอมไพล์

extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

เราทำตามรูปแบบเดียวกับที่เราใช้ดึงอาร์กิวเมนต์บรรทัดคำสั่งในหัวข้อ “การรับอาร์กิวเมนต์บรรทัดคำสั่ง” ในบทที่ 12 จากนั้นเราส่งอาร์กิวเมนต์ URL ไปยัง page_title และรอผลลัพธ์ เนื่องจากค่าที่สร้างขึ้นโดยฟิวเจอร์คือ Option<String> เราจึงใช้ นิพจน์ match เพื่อพิมพ์ข้อความที่แตกต่างกันเพื่อรองรับว่าหน้าเว็บมี <title> หรือไม่

สถานที่เดียวที่เราสามารถใช้คีย์เวิร์ด await ได้คือในฟังก์ชันหรือบล็อก async และ Rust จะไม่อนุญาตให้เราทำเครื่องหมายฟังก์ชันพิเศษ main ว่าเป็น async

error[E0752]: `main` function is not allowed to be `async`
  --> src/main.rs:6:1
   |
 6 | async fn main() {
   | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

เหตุผลที่ main ไม่สามารถทำเครื่องหมายว่า async ได้ คือโค้ด async จำเป็นต้องมี รันไทม์ (runtime): ซึ่งเป็นเครต Rust ที่จัดการรายละเอียดการประมวลผลโค้ดแบบอะซิงโครนัส ฟังก์ชัน main ของโปรแกรมสามารถ เริ่มต้นทำงาน (initialize) รันไทม์ได้ แต่มันไม่ใช่รันไทม์ ในตัวเอง (เราจะเห็นเพิ่มเติมว่าทำไมจึงเป็นเช่นนี้ในอีกสักครู่) ทุกโปรแกรม Rust ที่ประมวลผลโค้ด async จะต้องมีอย่างน้อยหนึ่งจุดที่ตั้งค่ารันไทม์เพื่อประมวลผลฟิวเจอร์ส

ภาษาส่วนใหญ่ที่รองรับ async จะแถมรันไทม์มาด้วย แต่ Rust ไม่ได้ทำเช่นนั้น แต่กลับมีรันไทม์ async ที่แตกต่างกันให้เลือกใช้มากมาย ซึ่งแต่ละรันไทม์ก็มีข้อดีข้อเสียแลกเปลี่ยนต่างกันที่เหมาะกับกรณีการใช้งานเป้าหมายของมัน ตัวอย่างเช่น เว็บเซิร์ฟเวอร์ที่มีปริมาณงานสูงพร้อมด้วยคอร์ CPU จำนวนมากและ RAM ขนาดใหญ่ย่อมมีความต้องการที่ต่างกันอย่างมากจากไมโครคอนโทรลเลอร์ที่มีคอร์เดียว RAM ขนาดเล็ก และไม่มีความสามารถในการจองหน่วยความจำฮีป (heap allocation) เครตที่จัดหารันไทม์เหล่านั้นมักจะมอบฟังก์ชันการทำงานเวอร์ชัน async สำหรับงานทั่วไป เช่น File หรือ Network I/O ด้วย

ในบทนี้และบทที่เหลือ เราจะใช้ฟังก์ชัน block_on จากเครต trpl ซึ่งรับเอาฟิวเจอร์เป็นอาร์กิวเมนต์และบล็อกเธรดปัจจุบันไว้จนกว่าฟิวเจอร์นี้จะรันจนเสร็จสมบูรณ์ เบื้องหลังการเรียกใช้ block_on จะเป็นการตั้งค่ายังรันไทม์โดยใช้เครต tokio ซึ่งใช้รันฟิวเจอร์ที่ส่งเข้ามา (พฤติกรรม block_on ของเครต trpl จะคล้ายกับฟังก์ชัน block_on ของเครตรันไทม์อื่นๆ) เมื่อฟิวเจอร์ทำงานเสร็จสมบูรณ์แล้ว block_on จะคืนค่าอะไรก็ตามที่ฟิวเจอร์นั้นสร้างขึ้นมา

เราสามารถส่งฟิวเจอร์ที่คืนค่าโดย page_title ไปยัง block_on โดยตรงได้ และเมื่อมันเสร็จสิ้น เราก็สามารถ match บน Option<String> ที่เป็นผลลัพธ์ตามที่เราพยายามทำในโค้ดตัวอย่างที่ 17-3 อย่างไรก็ตาม สำหรับตัวอย่างส่วนใหญ่ในบทนี้ (และโค้ด async ส่วนใหญ่ในโลกจริง) เราจะทำมากกว่าแค่เรียกใช้ฟังก์ชัน async เพียงฟังก์ชันเดียว ดังนั้นเราจะส่งบล็อก async และรอคอยผลลัพธ์ของการเรียกใช้ page_title อย่างชัดเจนดังในโค้ดตัวอย่างที่ 17-4

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}

เมื่อเรารันโค้ดนี้ เราจะได้พฤติกรรมที่เราคาดไว้ในตอนแรก:

$ cargo run -- "https://www.rust-lang.org"
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

เฮ้อ—ในที่สุดเราก็มีโค้ด async ที่ทำงานได้แล้ว! แต่ก่อนที่เราจะเพิ่มโค้ดเพื่อนำสองเว็บไซต์มาแข่งกัน ลองหันกลับมาให้ความสนใจสั้นๆ ว่าฟิวเจอร์สทำงานอย่างไร

แต่ละ จุดรอ (await point)—นั่นคือทุกจุดที่โค้ดใช้คีย์เวิร์ด await—จะแทนสถานที่ซึ่งการควบคุมถูกส่งกลับไปยังรันไทม์ ในการทำให้สิ่งนี้ทำงานได้ Rust จำเป็นต้องคอยติดตามสถานะ (state) ที่เกี่ยวข้องในบล็อก async เพื่อให้รันไทม์สามารถเริ่มงานอื่นและจากนั้นย้อนกลับมาเมื่อพร้อมจะลองผลักดันงานแรกอีกครั้ง สิ่งนี้คือสเตตแมชชีน (state machine) ที่มองไม่เห็น คล้ายกับว่าคุณเขียน enum แบบนี้ไว้เพื่อบันทึกสถานะปัจจุบัน ณ แต่ละจุดรอ:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

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

ท้ายที่สุด จะต้องมีบางอย่างประมวลผลสเตตแมชชีนนี้ และสิ่งนั้นก็คือรันไทม์ (นี่คือเหตุผลที่คุณอาจพบการเอ่ยถึง executors เมื่อศึกษาเกี่ยวกับรันไทม์: executor คือส่วนหนึ่งของรันไทม์ที่รับผิดชอบการประมวลผลโค้ด async)

ตอนนี้คุณคงเห็นแล้วว่าทำไมคอมไพเลอร์จึงห้ามไม่ให้เราทำฟังก์ชัน main เป็น async ในโค้ดตัวอย่างที่ 17-3 หาก main เป็นฟังก์ชัน async สิ่งอื่นจำเป็นต้องคอยจัดการสเตตแมชชีนสำหรับฟิวเจอร์ใดก็ตามที่ main คืนค่ามา แต่ main คือจุดเริ่มต้นของโปรแกรม! แต่เราเรียกใช้ฟังก์ชัน trpl::block_on ใน main แทนเพื่อตั้งค่ารันไทม์และรันฟิวเจอร์ที่คืนค่าโดยบล็อก async จนกว่าจะเสร็จสิ้น

หมายเหตุ: รันไทม์บางตัวจัดหามาโครไว้เพื่อให้คุณ สามารถ เขียนฟังก์ชัน main แบบ async ได้ มาโครเหล่านั้นจะเขียน async fn main() { ... } ใหม่ให้กลายเป็น fn main ปกติ ซึ่งทำสิ่งเดียวกับที่เราทำด้วยมือในโค้ดตัวอย่างที่ 17-4: นั่นคือเรียกใช้ฟังก์ชันที่รันฟิวเจอร์จนเสร็จสมบูรณ์ในแบบเดียวกับที่ trpl::block_on ทำ

คราวนี้ลองนำชิ้นส่วนเหล่านี้มารวมกันและดูว่าเราจะเขียนโค้ดแบบคอนเคอร์เรนต์ได้อย่างไร

การนำ URL สองตัวมาแข่งกันแบบคอนเคอร์เรนต์

ในโค้ดตัวอย่างที่ 17-5 เราเรียกใช้ page_title ด้วย URL สองตัวที่ส่งมาจากบรรทัดคำสั่ง และนำมันมาแข่งกันโดยเลือกฟิวเจอร์ตัวที่เสร็จก่อน

extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::select(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}

เราเริ่มจากการเรียกใช้ page_title สำหรับแต่ละ URL ที่ผู้ใช้ระบุ เราบันทึกฟิวเจอร์สที่เป็นผลลัพธ์เป็น title_fut_1 และ title_fut_2 จำไว้ว่าพวกมันยังไม่ได้ทำอะไรในตอนนี้ เนื่องจากฟิวเจอร์สมีความขี้เกียจและเรายังไม่ได้ทำการรอ (await) พวกมัน จากนั้นเราส่งฟิวเจอร์สไปยัง trpl::select ซึ่งจะคืนค่าที่ระบุว่าฟิวเจอร์สตัวใดส่งเข้ามาทำงานเสร็จก่อน

หมายเหตุ: เบื้องหลัง trpl::select ถูกสร้างขึ้นบนฟังก์ชัน select ทั่วไปซึ่งถูกนิยามในเครต futures ฟังก์ชัน select ของเครต futures สามารถทำได้หลายอย่างที่ฟังก์ชัน trpl::select ทำไม่ได้ แต่ก็นำมาซึ่งความซับซ้อนเพิ่มเติมที่เราสามารถข้ามไปก่อนได้ในตอนนี้

ฟิวเจอร์สตัวใดตัวหนึ่งสามารถ “ชนะ” ได้อย่างถูกต้องชอบธรรม ดังนั้นจึงไม่มีเหตุผลที่จะคืนค่าเป็น Result แต่ trpl::select จะคืนค่าชนิดข้อมูลที่เรายังไม่เคยเห็นมาก่อน นั่นคือ trpl::Either ชนิดข้อมูล Either คล้ายคลึงกับ Result ในแง่ที่มีสองกรณี ทว่าแตกต่างจาก Result ตรงที่ไม่มี่แนวคิดเรื่องความสำเร็จหรือล้มเหลวฝังอยู่ใน Either แต่มันใช้ Left และ Right เพื่อบ่งบอกว่า “อย่างใดอย่างหนึ่ง”:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

ฟังก์ชัน select จะคืนค่า Left พร้อมกับผลลัพธ์ของฟิวเจอร์นั้นหากอาร์กิวเมนต์ตัวแรกชนะ และคืนค่า Right พร้อมผลลัพธ์ของฟิวเจอร์ตัวที่สองหากตัว นั้น ชนะ สิ่งนี้ตรงตามลำดับที่อาร์กิวเมนต์ปรากฏเมื่อเรียกใช้ฟังก์ชัน: อาร์กิวเมนต์แรกอยู่ทางซ้ายของอาร์กิวเมนต์ที่สอง

เรายังอัปเดต page_title ให้คืนค่า URL เดียวกับที่ส่งเข้ามาด้วย ด้วยวิธีนั้น หากหน้าเว็บที่คืนค่ากลับมาเป็นหน้าแรกไม่มี <title> ให้เราแก้ไขได้ เราก็ยังสามารถพิมพ์ข้อความที่มีความหมายได้ เมื่อมีข้อมูลนั้นพร้อมใช้งาน เราปิดท้ายด้วยการอัปเดตผลลัพธ์ println! เพื่อระบุทั้ง URL ตัวใดที่เสร็จก่อน และข้อความ <title> (หากมี) สำหรับเว็บเพจที่ URL นั้น

ตอนนี้คุณได้สร้างเว็บสแครปเปอร์ขนาดเล็กที่ทำงานได้แล้ว! เลือก URL สองสามตัวแล้วรันเครื่องมือบรรทัดคำสั่ง คุณอาจค้นพบว่าบางไซต์เร็วกว่าไซต์อื่นอย่างต่อเนื่อง ในขณะที่กรณีอื่น ไซต์ที่เร็วกว่าอาจเปลี่ยนไปในแต่ละครั้งที่รัน ที่สำคัญกว่านั้น คุณได้เรียนรู้พื้นฐานของการทำงานกับฟิวเจอร์สแล้ว คราวนี้เราสามารถเจาะลึกลงไปในสิ่งที่เราทำได้กับ async

Applying Concurrency with Async

การประยุกต์ใช้คอนเคอร์เรนซีด้วย Async

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

ในหลายๆ กรณี API สำหรับการทำงานกับคอนเคอร์เรนซีโดยใช้ async จะคล้ายคลึงอย่างมากกับ API สำหรับการใช้เธรด ในกรณีอื่นๆ พวกมันกลับแตกต่างกันค่อนข้างมาก แม้ว่า API จะ ดู คล้ายกันระหว่างเธรดและ async แต่มันมักจะมีพฤติกรรมที่แตกต่างกัน—และเกือบจะเสมอไปที่มีลักษณะด้านประสิทธิภาพที่แตกต่างกัน

การสร้างแทสก์ใหม่ด้วย spawn_task

การทำงานแรกที่เราจัดการในหัวข้อ “การสร้างเธรดใหม่ด้วย spawn” ในบทที่ 16 คือการนับตัวเลขบนสองเธรดที่แยกกัน ลองมาทำสิ่งเดียวกันโดยใช้ async กัน เครต trpl จัดหาฟังก์ชัน spawn_task ซึ่งมีลักษณะคล้ายกับ API thread::spawn อย่างมาก และจัดหาฟังก์ชัน sleep ซึ่งเป็นเวอร์ชัน async ของ API thread::sleep เราสามารถใช้สิ่งเหล่านี้ร่วมกันเพื่อสร้างตัวอย่างการนับเลข ดังแสดงในโค้ดตัวอย่างที่ 17-6

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}

ในฐานะจุดเริ่มต้น เราตั้งค่าฟังก์ชัน main ด้วย trpl::block_on เพื่อให้ฟังก์ชันระดับบนสุดของเราสามารถเป็น async ได้

หมายเหตุ: ตั้งแต่จุดนี้เป็นต้นไปในบทนี้ ทุกๆ ตัวอย่างจะรวมโค้ดห่อหุ้ม trpl::block_on ใน main แบบเดียวกันนี้เป๊ะๆ ดังนั้นเราจะมักจะข้ามมันไปเช่นเดียวกับที่เราข้าม main อย่าลืมใส่ลงในโค้ดของคุณด้วย!

จากนั้นเราเขียนสองลูปภายในบล็อกนั้น โดยแต่ละลูปบรรจุการเรียกใช้ trpl::sleep ซึ่งรอเป็นเวลาครึ่งวินาที (500 มิลลิวินาที) ก่อนที่จะส่งข้อความถัดไป เราใส่ลูปหนึ่งไว้ในเนื้อหาของ trpl::spawn_task และอีกลูปหนึ่งไว้ในลูป for ระดับบนสุด เรายังเพิ่ม await ไว้หลังการเรียกใช้ sleep ด้วย

โค้ดนี้มีพฤติกรรมคล้ายกับเวอร์ชันที่ใช้เธรด—รวมถึงความจริงที่ว่าคุณอาจเห็นข้อความปรากฏในลำดับที่แตกต่างกันในเทอร์มินัลของคุณเมื่อรันมัน:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

เวอร์ชันนี้จะหยุดลงทันทีที่ลูป for ในเนื้อหาของบล็อก async หลักทำงานเสร็จสิ้น เนื่องจากแทสก์ที่สร้างขึ้นโดย spawn_task จะถูกปิดเมื่อฟังก์ชัน main จบลง หากคุณต้องการให้มันรันจนกระทั่งแทสก์เสร็จสมบูรณ์ คุณจำเป็นต้องใช้จอยแฮนเดิล (join handle) เพื่อรอให้แทสก์แรกทำงานเสร็จ สำหรับเธรด เราใช้เมธอด join เพื่อ “บล็อก” จนกว่าเธรดจะทำงานเสร็จ ในโค้ดตัวอย่างที่ 17-7 เราสามารถใช้ await เพื่อทำสิ่งเดียวกันได้ เนื่องจากแทสก์แฮนเดิลเองก็เป็นฟิวเจอร์ โดยชนิด Output ของมันคือ Result ดังนั้นเราจึง unwrap มันหลังจาก await แล้วด้วย

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}

เวอร์ชันอัปเดตนี้จะรันจนกระทั่ง ทั้งสอง ลูปทำงานเสร็จสิ้น:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

จนถึงตอนนี้ ดูเหมือนว่า async และเธรดจะให้ผลลัพธ์ที่คล้ายกัน เพียงแต่ใช้ไวยากรณ์ต่างกัน: นั่นคือใช้ await แทนที่จะเรียกใช้ join บนจอยแฮนเดิล และรอคอยการเรียกใช้ sleep

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

ในหัวข้อ “การรอให้เธรดทั้งหมดทำงานเสร็จสิ้น” ในบทที่ 16 เราแสดงวิธีใช้เมธอด join บนชนิดข้อมูล JoinHandle ที่คืนค่าเมื่อคุณเรียกใช้ std::thread::spawn ฟังก์ชัน trpl::join ก็คล้ายคลึงกัน แต่ใช้สำหรับฟิวเจอร์ส เมื่อคุณมอบฟิวเจอร์สสองตัวให้มัน มันจะสร้างฟิวเจอร์ใหม่ตัวเดียวขึ้นมาซึ่งมีผลลัพธ์เป็นทูเพิลบรรจุผลลัพธ์ของฟิวเจอร์แต่ละตัวที่คุณส่งเข้าไปเมื่อพวกมันทำงานเสร็จ ทั้งคู่ ดังนั้น ในโค้ดตัวอย่างที่ 17-8 เราใช้ trpl::join เพื่อรอให้ทั้ง fut1 และ fut2 ทำงานเสร็จ เรา ไม่ได้ รอคอย fut1 และ fut2 แต่รอคอยฟิวเจอร์ใหม่ที่สร้างขึ้นโดย trpl::join แทน เราละเลยผลลัพธ์ เพราะมันเป็นเพียงทูเพิลที่บรรจุค่ายูนิตสองค่า

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}

เมื่อเรารันสิ่งนี้ เราจะเห็นฟิวเจอร์สทั้งสองรันจนเสร็จสมบูรณ์:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

คราวนี้ คุณจะเห็นลำดับเดียวกันเป๊ะๆ ในทุกๆ ครั้ง ซึ่งแตกต่างอย่างมากจากสิ่งที่เราเห็นกับเธรดและกับ trpl::spawn_task ในโค้ดตัวอย่างที่ 17-7 นั่นเป็นเพราะฟังก์ชัน trpl::join มีความ ยุติธรรม (fair) หมายความว่ามันตรวจสอบฟิวเจอร์แต่ละตัวบ่อยเท่ากัน สลับกันไปมา และไม่ยอมให้ตัวหนึ่งแซงหน้าไปหากอีกตัวหนึ่งพร้อม สำหรับเธรด ระบบปฏิบัติการจะตัดสินใจว่าจะตรวจสอบเธรดใดและปล่อยให้รันนานแค่ไหน ส่วน async ใน Rust รันไทม์จะเป็นผู้ตัดสินใจว่าจะตรวจสอบแทสก์ใด (ในทางปฏิบัติ รายละเอียดจะซับซ้อนขึ้นเนื่องจากรันไทม์ async อาจใช้เธรดระบบปฏิบัติการอยู่เบื้องหลังเป็นส่วนหนึ่งของการจัดการคอนเคอร์เรนซี ดังนั้นการรับประกันความยุติธรรมจึงอาจเป็นงานที่หนักขึ้นสำหรับรันไทม์—แต่มันยังคงเป็นไปได้!) รันไทม์ไม่จำเป็นต้องรับประกันความยุติธรรมสำหรับการทำงานใดๆ และพวกมันมักจัดหา API ที่ต่างกันเพื่อให้คุณเลือกว่าต้องการความยุติธรรมหรือไม่

ลองทดสอบรูปแบบต่างๆ ของการรอคอยฟิวเจอร์สและดูว่าพวกมันทำอะไร:

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

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

การส่งข้อมูลระหว่างสองแทสก์โดยใช้การส่งข้อความ

การแบ่งปันข้อมูลระหว่างฟิวเจอร์สจะเป็นสิ่งที่คุณคุ้นเคยเช่นกัน: เราจะใช้การส่งข้อความ (message passing) อีกครั้ง แต่มันจะเป็นเวอร์ชัน async ของชนิดข้อมูลและฟังก์ชัน เราจะใช้แนวทางที่แตกต่างจากที่เราทำในหัวข้อ “การถ่ายโอนข้อมูลระหว่างเธรดด้วยการส่งข้อความ” ในบทที่ 16 เล็กน้อย เพื่อแสดงข้อแตกต่างหลักบางประการระหว่างคอนเคอร์เรนซีแบบอิงเธรดและแบบอิงฟิวเจอร์ส ในโค้ดตัวอย่างที่ 17-9 เราจะเริ่มด้วยบล็อก async เพียงบล็อกเดียว—โดย ไม่ สร้างแทสก์แยกต่างหากเหมือนกับที่เราเคยสร้างเธรดแยกต่างหาก

extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("received '{received}'");
    });
}

ในที่นี้ เราใช้ trpl::channel ซึ่งเป็นเวอร์ชัน async ของ API แชนเนลแบบผู้ผลิตหลายราย ผู้บริโภครายเดียวที่เราใช้กับเธรดในบทที่ 16 เวอร์ชัน async ของ API แตกต่างจากเวอร์ชันอิงเธรดเพียงเล็กน้อย: มันใช้ผู้รับ rx แบบเปลี่ยนแปลงได้ (mutable) แทนที่จะเป็นแบบเปลี่ยนแปลงไม่ได้ และเมธอด recv ของมันสร้างฟิวเจอร์ที่เราจำเป็นต้องรอคอย (await) แทนที่จะสร้างค่าโดยตรง คราวนี้เราสามารถส่งข้อความจากผู้ส่งไปยังผู้รับได้ สังเกตว่าเราไม่จำเป็นต้องสร้างเธรดแยกต่างหาก หรือแม้กระทั่งแทสก์แยกต่างหาก เราเพียงต้องรอคอยการเรียกใช้ rx.recv

เมธอดแบบซิงโครนัส Receiver::recv ใน std::mpsc::channel จะบล็อกจนกว่าจะได้รับข้อความ แต่เมธอด trpl::Receiver::recv ไม่ทำเช่นนั้น เพราะมันเป็น async แทนที่จะบล็อก มันจะส่งการควบคุมกลับไปยังรันไทม์จนกว่าจะได้รับข้อความ หรือฝั่งส่งของแชนเนลถูกปิดลง ในทางตรงกันข้าม เราไม่ได้รอคอยการเรียกใช้ send เพราะมันไม่ได้บล็อก มันไม่จำเป็นต้องบล็อก เพราะแชนเนลที่เราส่งลงไปเป็นแบบไม่จำกัดขอบเขต (unbounded)

หมายเหตุ: เนื่องจากโค้ด async ทั้งหมดนี้รันอยู่ในบล็อก async ในการเรียกใช้ trpl::block_on ทุกอย่างภายในนั้นจึงสามารถหลีกเลี่ยงการบล็อกได้ อย่างไรก็ตาม โค้ดที่อยู่ ภายนอก มันจะบล็อกตรงการคืนค่าฟังก์ชัน block_on นั่นคือวัตถุประสงค์ทั้งหมดของฟังก์ชัน trpl::block_on: มันช่วยให้คุณ เลือก ได้ว่าจะบล็อกตรงจุดใดในชุดโค้ด async และช่วยสร้างจุดเชื่อมต่อระหว่างโค้ด sync และ async

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

ลองมาจัดการส่วนแรกโดยการส่งชุดข้อความและหยุดพักระหว่างข้อความ ดังแสดงในโค้ดตัวอย่างที่ 17-10

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}

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

ในโค้ดตัวอย่างที่ 16-10 เราใช้ลูป for เพื่อประมวลผลไอเทมทั้งหมดที่ได้รับจากแชนเนลแบบซิงโครนัส อย่างไรก็ตาม Rust ยังไม่มีวิธีใช้ลูป for กับชุดไอเทมที่ สร้างขึ้นแบบอะซิงโครนัส ดังนั้นเราจึงจำเป็นต้องใช้ลูปที่เรายังไม่เคยเห็นมาก่อน: นั่นคือลูปแบบมีเงื่อนไข while let สิ่งนี้คือเวอร์ชันลูปของโครงสร้าง if let ที่เราเคยเห็นในหัวข้อ “การควบคุมการทำงานอย่างกระชับด้วย if let และ let...else ในบทที่ 6 ลูปจะทำงานต่อไปเรื่อยๆ ตราบใดที่รูปแบบที่มันระบุยังคงตรงกับค่า

การเรียกใช้ rx.recv จะสร้างฟิวเจอร์ขึ้นมา ซึ่งเรารอคอย รันไทม์จะหยุดพักฟิวเจอร์จนกว่ามันจะพร้อม เมื่อข้อความมาถึง ฟิวเจอร์จะคลี่คลายค่าออกมาเป็น Some(message) บ่อยเท่าที่มีข้อความมาถึง เมื่อแชนเนลปิดลง ไม่ว่าจะมีข้อความส่งมาถึง เลยหรือไม่ ฟิวเจอร์จะคลี่คลายค่าออกมาเป็น None เพื่อระบุว่าไม่มีค่าใดๆ อีกแล้ว ดังนั้นเราจึงควรหยุดโพลลิง—กล่าวคือ หยุดรอคอย

ลูป while let จะรวบรวมทั้งหมดนี้เข้าด้วยกัน หากผลลัพธ์ของการเรียกใช้ rx.recv().await คือ Some(message) เราจะสามารถเข้าถึงข้อความนั้นและใช้งานมันในเนื้อหาของลูปได้ เช่นเดียวกับที่ทำได้ด้วย if let หากผลลัพธ์เป็น None ลูปจะจบลง ทุกครั้งที่ลูปทำงานครบรอบ มันจะชนกับจุดรออีกครั้ง ดังนั้นรันไทม์จึงหยุดพักมันอีกครั้งจนกว่าข้อความอื่นจะมาถึง

ตอนนี้โค้ดส่งและรับข้อความทั้งหมดได้สำเร็จแล้ว แต่น่าเสียดายที่ยังคงมีปัญหาอยู่อีกสองสามประการ ประการหนึ่งคือ ข้อความไม่ได้มาถึงในช่วงเวลาเว้นห่างกันครึ่งวินาที พวกมันมาถึงพร้อมกันทั้งหมด 2 วินาที (2,000 มิลลิวินาที) หลังจากที่เราเริ่มโปรแกรม อีกประการหนึ่งคือ โปรแกรมนี้ไม่มีวันจบการทำงาน! แต่กลับรอข้อความใหม่ตลอดไป คุณจะต้องปิดมันโดยใช้ ctrl-C

โค้ดภายในบล็อก Async บล็อกเดียวจะทำงานเป็นเส้นตรง

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

มีบล็อก async เพียงบล็อกเดียวในโค้ดตัวอย่างที่ 17-10 ดังนั้นทุกอย่างในนั้นจึงรันเป็นเส้นตรง และยังไม่มีคอนเคอร์เรนซี การเรียกใช้ tx.send ทั้งหมดเกิดขึ้น สลับกับการเรียกใช้ trpl::sleep ทั้งหมดและจุดรอที่เกี่ยวข้อง จากนั้นลูป while let จึงจะได้ผ่านจุด await บนการเรียกใช้ recv

ในการได้พฤติกรรมที่เราต้องการ ซึ่งการหน่วงเวลาการนอนหลับเกิดขึ้นระหว่างแต่ละข้อความ เราจำเป็นต้องแยกการทำงานของ tx และ rx ไว้ในบล็อก async ของตนเอง ดังแสดงในโค้ดตัวอย่างที่ 17-11 จากนั้นรันไทม์สามารถประมวลผลแต่ละอันแยกกันได้โดยใช้ trpl::join เช่นเดียวกับในโค้ดตัวอย่างที่ 17-8 อีกครั้ง เรารอคอยผลลัพธ์ของการเรียกใช้ trpl::join ไม่ใช่ฟิวเจอร์แต่ละตัว หากเรารอคอยฟิวเจอร์แต่ละตัวตามลำดับ เราก็จะกลับไปสู่การทำงานตามลำดับเดิมอีกครั้ง—ซึ่งเป็นสิ่งที่เรารพยายาม ไม่ ทำ

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

ด้วยโค้ดอัปเดตในโค้ดตัวอย่างที่ 17-11 ข้อความจะถูกพิมพ์ที่ช่วงเวลาเว้นห่างกัน 500 มิลลิวินาที แทนที่จะรีบมาพร้อมกันทั้งหมดหลังจาก 2 วินาที

การย้ายความเป็นเจ้าของเข้าไปในบล็อก Async

อย่างไรก็ตาม โปรแกรมนี้ยังคงไม่มีวันจบการทำงาน เนื่องจากวิธีที่ลูป while let โต้ตอบกับ trpl::join:

  • ฟิวเจอร์ที่คืนค่าจาก trpl::join จะทำงานเสร็จสิ้นก็ต่อเมื่อฟิวเจอร์ส ทั้งสอง ที่ส่งเข้าไปทำงานเสร็จสิ้นแล้ว
  • ฟิวเจอร์ tx_fut จะทำงานเสร็จสิ้นเมื่อมันทำงานนอนหลับเสร็จหลังจากส่งข้อความสุดท้ายใน vals
  • ฟิวเจอร์ rx_fut จะไม่เสร็จสิ้นจนกว่าลูป while let จะจบลง
  • ลูป while let จะไม่จบลงจนกว่าการรอคอย rx.recv จะได้ผลลัพธ์เป็น None
  • การรอคอย rx.recv จะคืนค่า None ก็ต่อเมื่ออีกฝั่งหนึ่งของแชนเนลถูกปิดลง
  • แชนเนลจะปิดลงก็ต่อเมื่อเราเรียกใช้ rx.close หรือเมื่อฝั่งส่ง tx ถูกคืนค่า (drop)
  • เราไม่ได้เรียกใช้ rx.close ที่ใดเลย และ tx จะไม่ถูกคืนค่าจนกว่าบล็อก async นอกสุดที่ส่งไปยัง trpl::block_on จะจบลง
  • บล็อกไม่สามารถจบลงได้เพราะมันถูกบล็อกตรงการรัน trpl::join จนเสร็จสิ้น ซึ่งทำให้เราวนกลับไปที่ส่วนบนสุดของรายการนี้อีกครั้ง

ในตอนนี้ บล็อก async ที่เราส่งข้อความเพียงแค่ ยืม tx เพราะการส่งข้อความไม่ได้ต้องการความเป็นเจ้าของ แต่ถ้าเราสามารถ ย้าย (move) tx เข้าไปในบล็อก async นั้นได้ มันจะถูกคืนค่าทันทีเมื่อบล็อกนั้นจบลง ในหัวข้อ “การจับการอ้างอิงหรือการย้ายความเป็นเจ้าของ” ในบทที่ 13 คุณได้เรียนรู้วิธีใช้คีย์เวิร์ด move กับโคลเชอร์ และดังที่อภิปรายในหัวข้อ “การใช้โคลเชอร์แบบ move กับเธรด” ในบทที่ 16 เรามักต้องย้ายข้อมูลเข้าไปในโคลเชอร์เมื่อทำงานกับเธรด กลไกพื้นฐานเดียวกันนี้ก็ประยุกต์ใช้กับบล็อก async ด้วย ดังนั้นคีย์เวิร์ด move จึงทำงานกับบล็อก async เช่นเดียวกับที่ทำกับโคลเชอร์

ในโค้ดตัวอย่างที่ 17-12 เราเปลี่ยนบล็อกที่ใช้ส่งข้อความจาก async เป็น async move

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

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

การร่วมฟิวเจอร์สจำนวนมากด้วยมาโคร join!

แชนเนล async นี้ยังเป็นแชนเนลแบบผู้ผลิตหลายรายเช่นกัน ดังนั้นเราจึงสามารถเรียกใช้ clone บน tx ได้หากเราต้องการส่งข้อความจากหลายๆ ฟิวเจอร์ส ดังแสดงในโค้ดตัวอย่างที่ 17-13

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}

ขั้นแรก เราโคลน tx สร้างเป็น tx1 ภายนอกบล็อก async แรก เราย้าย tx1 เข้าไปในบล็อกนั้นเหมือนกับที่เราทำก่อนหน้านี้กับ tx จากนั้นในภายหลัง เราย้าย tx ดั้งเดิมเข้าไปในบล็อก async ใหม่ ซึ่งเราส่งข้อความเพิ่มเติมโดยมีการหยุดพักที่ช้าลงเล็กน้อย เราใส่บล็อก async ใหม่นี้ไว้หลังบล็อก async สำหรับรับข้อความ แต่มันสามารถอยู่ก่อนหน้าได้เช่นกัน คีย์สำคัญคือลำดับที่ฟิวเจอร์สถูกรอคอย (await) ไม่ใช่ลำดับที่พวกมันถูกสร้างขึ้น

บล็อก async ทั้งสองสำหรับการส่งข้อความจำเป็นต้องเป็นบล็อก async move เพื่อให้ทั้ง tx และ tx1 ถูกคืนค่าเมื่อบล็อกเหล่านั้นจบลง มิฉะนั้น เราจะจบลงด้วยลูปไม่รู้จบแบบเดิมที่เราเริ่มตั้งต้น

ท้ายที่สุด เราเปลี่ยนจาก trpl::join เป็น trpl::join! เพื่อจัดการกับฟิวเจอร์เพิ่มเติม: มาโคร join! จะรอคอยฟิวเจอร์สจำนวนกี่ตัวก็ได้โดยที่เราทราบจำนวนของฟิวเจอร์สในขั้นตอนคอมไพล์ เราจะอภิปรายเรื่องการรอคอยคอลเลกชันของฟิวเจอร์สที่ไม่ทราบจำนวนในภายหลังของบทนี้

คราวนี้เราจะเห็นข้อความทั้งหมดจากฟิวเจอร์สผู้ส่งทั้งสองตัว และเนื่องจากฟิวเจอร์สผู้ส่งใช้การหยุดพักที่แตกต่างกันเล็กน้อยหลังจากส่ง ข้อความจึงถูกรับในช่วงเวลาเว้นห่างที่แตกต่างกันเหล่านั้นด้วย:

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

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

Working With Any Number of Futures

การคืนการควบคุมให้แก่รันไทม์

ย้อนกลับไปในหัวข้อ “โปรแกรม Async ตัวแรกของเรา” ณ แต่ละจุดรอ (await point) Rust จะเปิดโอกาสให้รันไทม์หยุดพักแทสก์และสลับไปยังแทสก์อื่นได้หากฟิวเจอร์ที่กำลังรอคอยนั้นยังไม่พร้อม ในทางกลับกันก็เป็นจริงเช่นกัน: Rust จะหยุดพักบล็อก async และส่งมอบการควบคุมกลับคืนสู่รันไทม์ เฉพาะ ณ จุดรอเท่านั้น ทุกอย่างที่อยู่ระหว่างจุดรอจะเป็นแบบซิงโครนัส

นั่นหมายความว่าหากคุณทำงานกองโตในบล็อก async โดยไม่มีจุดรอ ฟิวเจอร์นั้นจะบล็อกฟิวเจอร์อื่นๆ ไม่ให้มีความคืบหน้า บางครั้งคุณอาจได้ยินคำเรียกสิ่งนี้ว่า ฟิวเจอร์หนึ่งกำลัง ทำให้อดอาหาร (starving) ฟิวเจอร์อื่นๆ ในบางกรณี สิ่งนี้อาจไม่ใช่เรื่องใหญ่นัก อย่างไรก็ตาม หากคุณกำลังทำการตั้งค่าที่มีราคาแพงหรือการทำงานที่ใช้เวลานาน หรือหากคุณมีฟิวเจอร์ที่จะทำแทสก์เฉพาะบางอย่างไปเรื่อยๆ อย่างไม่มีที่สิ้นสุด คุณจะต้องคิดถึงเวลาและสถานที่ที่จะคืนการควบคุมกลับไปยังรันไทม์

ลองจำลองการทำงานที่ใช้เวลานานเพื่ออธิบายปัญหาการอดอาหาร (starvation) จากนั้นสำรวจวิธีแก้ไข โค้ดตัวอย่างที่ 17-14 แนะนำฟังก์ชัน slow

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

โค้ดนี้ใช้ std::thread::sleep แทน trpl::sleep เพื่อให้การเรียกใช้ slow บล็อกเธรดปัจจุบันเป็นเวลาหลายมิลลิวินาที เราสามารถใช้ slow แทนการทำงานในโลกจริงที่มีทั้งความยาวนานและเป็นแบบบล็อก

ในโค้ดตัวอย่างที่ 17-15 เราใช้ slow เพื่อจำลองการทำงานแบบมีขีดจำกัดซีพียู (CPU-bound) ในฟิวเจอร์สคู่หนึ่ง

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

ฟิวเจอร์แต่ละตัวจะส่งมอบการควบคุมกลับคืนสู่รันไทม์ หลังจาก ดำเนินการทำงานที่เชื่องช้าจำนวนหนึ่งเสร็จสิ้นแล้วเท่านั้น หากคุณรันโค้ดนี้ คุณจะเห็นผลลัพธ์นี้:

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

เช่นเดียวกับในโค้ดตัวอย่างที่ 17-5 ที่เราใช้ trpl::select เพื่อแข่งฟิวเจอร์สในการดึง URL สองตัว select ยังคงทำงานเสร็จสิ้นทันทีที่ a เสร็จสิ้น ทว่า ไม่มีการสลับกันทำงานระหว่างการเรียกใช้ slow ในฟิวเจอร์สทั้งสองตัว ฟิวเจอร์ a ทำงานทั้งหมดของมันจนกระทั่งการเรียกใช้ trpl::sleep ถูกรอคอย จากนั้นฟิวเจอร์ b จึงทำงานทั้งหมดของมันจนกระทั่งการเรียกใช้ trpl::sleep ของตัวมันเองถูกรอคอย และในที่สุดฟิวเจอร์ a ก็เสร็จสมบูรณ์ ในการอนุญาตให้ฟิวเจอร์สทั้งสองตัวสร้างความคืบหน้าระหว่างแทสก์ที่เชื่องช้าของพวกมัน เราจำเป็นต้องมีจุดรอเพื่อส่งมอบการควบคุมกลับไปยังรันไทม์ นั่นหมายความว่าเราต้องการบางอย่างที่เราสามารถรอคอย (await) ได้!

เราสามารถเห็นการส่งมอบประเภทนี้เกิดขึ้นแล้วในโค้ดตัวอย่างที่ 17-15: หากเราลบ trpl::sleep ที่ส่วนท้ายของฟิวเจอร์ a ออก มันก็จะทำงานเสร็จสิ้นโดยที่ฟิวเจอร์ b ไม่ได้รัน เลยแม้แต่น้อย ลองใช้ฟังก์ชัน trpl::sleep เป็นจุดเริ่มต้นในการปล่อยให้การทำงานต่างๆ สลับกันสร้างความคืบหน้า ดังแสดงในโค้ดตัวอย่างที่ 17-16

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

เราได้เพิ่มการเรียกใช้ trpl::sleep พร้อมกับจุดรอไว้ระหว่างการเรียกใช้ slow แต่ละครั้ง ตอนนี้งานของฟิวเจอร์สทั้งสองสลับกันทำแล้ว:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

ฟิวเจอร์ a ยังคงรันอยู่ครู่หนึ่งก่อนจะส่งมอบการควบคุมให้ b เนื่องจากมันเรียกใช้ slow ก่อนที่จะเรียกใช้ trpl::sleep แต่หลังจากนั้น ฟิวเจอร์สจะสลับกันไปมาในแต่ละครั้งที่ตัวหนึ่งชนเข้ากับจุดรอ ในกรณีนี้ เราทำเช่นนั้นหลังจากการเรียกใช้ slow ทุกๆ ครั้ง แต่เราสามารถแบ่งแยกงานในรูปแบบใดก็ตามที่สมเหตุสมผลที่สุดสำหรับเราได้

อย่างไรก็ตาม เราไม่ได้ต้องการจะ นอนหลับ (sleep) ในที่นี้จริงๆ: เราต้องการก้าวหน้าให้เร็วที่สุดเท่าที่จะทำได้ เราเพียงต้องการส่งมอบการควบคุมกลับไปยังรันไทม์ เราสามารถทำได้โดยตรง โดยใช้ฟังก์ชัน trpl::yield_now ในโค้ดตัวอย่างที่ 17-17 เราแทนที่การเรียกใช้ trpl::sleep ทั้งหมดเหล่านั้นด้วย trpl::yield_now

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

โค้ดนี้มีความชัดเจนมากกว่าเกี่ยวกับเจตนาที่แท้จริง และสามารถทำงานได้เร็วกว่าการใช้ sleep อย่างมีนัยสำคัญ เนื่องจากตัวจับเวลา เช่น ตัวที่ใช้โดย sleep มักมีขีดจำกัดว่าสามารถละเอียดได้เพียงใด เวอร์ชันของ sleep ที่เราใช้อยู่ ตัวอย่างเช่น จะนอนหลับอย่างน้อยหนึ่งมิลลิวินาทีเสมอ แม้ว่าเราจะส่งค่า Duration หนึ่งนาโนวินาทีให้มันก็ตาม อีกครั้ง คอมพิวเตอร์สมัยใหม่นั้น เร็ว มาก: พวกมันสามารถทำสิ่งต่างๆ ได้มากมายในหนึ่งมิลลิวินาที!

สิ่งนี้หมายความว่า async สามารถมีประโยชน์แม้กระทั่งสำหรับงานที่มีขีดจำกัดการคำนวณ (compute-bound) ขึ้นอยู่กับว่าโปรแกรมของคุณกำลังทำอะไรอย่างอื่นอยู่ เนื่องจากมันจัดหาเครื่องมือที่มีประโยชน์สำหรับการวางโครงสร้างความสัมพันธ์ระหว่างส่วนต่างๆ ของโปรแกรม (แต่นำมาซึ่งค่าใช้จ่ายของโหลดส่วนเกินของสเตตแมชชีน async) สิ่งนี้คือรูปแบบหนึ่งของ การทำงานหลายงานร่วมกันอย่างสอดประสาน (cooperative multitasking) ซึ่งฟิวเจอร์แต่ละตัวมีอำนาจในการตัดสินใจว่าจะส่งมอบการควบคุมเมื่อใดผ่านจุดรอ ฟิวเจอร์แต่ละตัวจึงมีความรับผิดชอบในการหลีกเลี่ยงการบล็อกไว้นานเกินไป ในระบบปฏิบัติการแบบฝังตัว (embedded OS) ที่ใช้ Rust บางระบบ สิ่งนี้คือรูปแบบเดียวของการทำงานหลายงาน!

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

การสร้างนามธรรม Async ของเราเอง

เรายังสามารถประกอบฟิวเจอร์สเข้าด้วยกันเพื่อสร้างรูปแบบใหม่ๆ ตัวอย่างเช่น เราสามารถสร้างฟังก์ชัน timeout ด้วยบล็อกสร้าง async ที่เรามีอยู่แล้ว เมื่อเราทำเสร็จ ผลลัพธ์จะเป็นบล็อกสร้างอีกชิ้นหนึ่งที่เราสามารถใช้สร้างนามธรรม async เพิ่มเติมได้อีก

โค้ดตัวอย่างที่ 17-18 แสดงวิธที่เราคาดหวังให้ timeout นี้ทำงานกับฟิวเจอร์ที่เชื่องช้า

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

ลองมาสร้างสิ่งนี้กัน! ในการเริ่มต้น ลองคิดถึง API สำหรับ timeout:

  • มันจำเป็นต้องเป็นฟังก์ชัน async ในตัวเองเพื่อที่เราจะสามารถรอคอยมันได้
  • พารามิเตอร์แรกของมันควรเป็นฟิวเจอร์ที่จะรัน เราสามารถทำให้มันเป็นเจเนอริก (generic) เพื่ออนุญาตให้มันทำงานร่วมกับฟิวเจอร์ใดๆ ก็ได้
  • พารามิเตอร์ที่สองของมันจะเป็นเวลาสูงสุดที่จะรอ หากเราใช้ Duration สิ่งนั้นจะทำให้ง่ายต่อการส่งต่อไปยัง trpl::sleep
  • มันควรจะคืนค่าเป็น Result หากฟิวเจอร์ทำงานเสร็จสมบูรณ์ ค่า Result จะเป็น Ok พร้อมกับค่าที่สร้างโดยฟิวเจอร์ หากเวลาของ timeout หมดลงก่อน ค่า Result จะเป็น Err พร้อมกับระยะเวลาที่ timeout ได้รอไป

โค้ดตัวอย่างที่ 17-19 แสดงการประกาศนี้

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}

สิ่งนั้นตอบสนองเป้าหมายของเราสำหรับชนิดข้อมูลแล้ว คราวนี้ลองมาคิดถึง พฤติกรรม ที่เราต้องการ: เราต้องการแข่งฟิวเจอร์ที่ส่งเข้ามาเทียบกับระยะเวลา เราสามารถใช้ trpl::sleep เพื่อสร้างฟิวเจอร์ตัวจับเวลาจากระยะเวลา และใช้ trpl::select เพื่อรันตัวจับเวลานั้นร่วมกับฟิวเจอร์ที่ผู้เรียกส่งเข้ามา

ในโค้ดตัวอย่างที่ 17-20 เราสร้าง timeout โดยใช้การ match บนผลลัพธ์ของการรอคอย trpl::select

extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::select(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}

การใช้ trpl::select ไม่ได้มีความยุติธรรม (not fair): มันจะโพล (poll) อาร์กิวเมนต์ตามลำดับที่ถูกส่งเข้าไปเสมอ (การใช้อื่นๆ ของ select จะสุ่มเลือกอาร์กิวเมนต์ที่จะโพลก่อน) ดังนั้น เราจึงส่ง future_to_try ไปยัง select เป็นลำดับแรก เพื่อให้มันมีโอกาสทำงานเสร็จสมบูรณ์แม้ว่า max_time จะเป็นระยะเวลาที่สั้นมากก็ตาม หาก future_to_try ทำงานเสร็จก่อน select จะคืนค่าเป็น Left พร้อมกับผลลัพธ์จาก future_to_try หาก timer ทำงานเสร็จก่อน select จะคืนค่าเป็น Right พร้อมผลลัพธ์ของตัวจับเวลาซึ่งก็คือ ()

หาก future_to_try สำเร็จและเราได้ Left(output) เราจะคืนค่า Ok(output) หากตัวจับเวลาของ sleep หมดเวลาแทนและเราได้ Right(()) เราจะละเลย () ด้วย _ และคืนค่า Err(max_time) แทน

ด้วยเหตุนี้ เราจึงมี timeout ที่ทำงานได้ ซึ่งถูกสร้างขึ้นมาจากตัวช่วย async อีกสองตัว เนื่องจากฟิวเจอร์สสามารถประกอบเข้ากับฟิวเจอร์สอื่นได้ คุณจึงสามารถสร้างเครื่องมือที่ทรงพลังอย่างยิ่งได้โดยใช้บล็อกสร้าง async ที่มีขนาดเล็กกว่า ตัวอย่างเช่น คุณสามารถใช้แนวทางเดียวกันนี้ในการรวม timeout เข้ากับการลองใหม่ (retries) และนำพวกมันไปใช้กับการทำงาน เช่น การเรียกใช้เครือข่าย (ดังเช่นในโค้ดตัวอย่างที่ 17-5)

ในทางปฏิบัติ โดยทั่วไปคุณจะทำงานโดยตรงกับ async และ await และใช้ฟังก์ชันรอง เช่น select และมาโคร เช่น มาโคร join! เพื่อควบคุมวิธีที่ฟิวเจอร์สนอกสุดจะถูกประมวลผล

ตอนนี้เราได้เห็นหลายๆ วิธีในการทำงานกับฟิวเจอร์สหลายตัวพร้อมกันแล้ว ถัดไป เราจะดูว่าเราสามารถทำงานกับฟิวเจอร์สหลายตัวในลักษณะที่เป็นลำดับตามเวลาได้อย่างไรด้วย สตรีม (streams)

Streams: Futures in Sequence

Streams: Futures ในรูปแบบลำดับ

ลองนึกถึงตอนที่เราใช้ตัวรับ (receiver) สำหรับช่องทาง async channel ของเราก่อนหน้านี้ในบทนี้ ในส่วน “Message Passing” เมธอด async recv จะสร้างลำดับของข้อมูลออกมาตามช่วงเวลา สิ่งนี้เป็นตัวอย่างของรูปแบบที่ทั่วไปยิ่งกว่าที่เรียกว่า stream แนวคิดหลายอย่างสามารถแทนด้วย stream ได้อย่างเป็นธรรมชาติ เช่น ข้อมูลที่ทยอยเข้ามาในคิว (queue), ชิ้นส่วนของข้อมูลที่ถูกดึงทีละนิดจากระบบไฟล์เมื่อชุดข้อมูลทั้งหมดมีขนาดใหญ่เกินกว่าหน่วยความจำของคอมพิวเตอร์ หรือข้อมูลที่ทยอยมาถึงผ่านเครือข่ายตามเวลา เนื่องจาก stream คือ future รูปแบบหนึ่ง เราจึงสามารถใช้ stream ร่วมกับ future รูปแบบอื่นๆ และรวมเข้าด้วยกันในวิธีที่น่าสนใจได้ ตัวอย่างเช่น เราสามารถจัดกลุ่มเหตุการณ์ (batch events) เพื่อหลีกเลี่ยงการส่งคำขอทางเครือข่ายถี่เกินไป ตั้งเวลาหมดเวลา (timeout) ให้กับลำดับการทำงานที่ใช้เวลานาน หรือหน่วงการทำงานของเหตุการณ์บนส่วนประสานงานผู้ใช้ (user interface events) เพื่อหลีกเลี่ยงการทำงานที่ไม่จำเป็น

เราเคยเห็นลำดับของข้อมูลมาแล้วในบทที่ 13 ตอนที่เราดูเกี่ยวกับ Iterator trait ในส่วน “The Iterator Trait and the next Method” แต่มีความแตกต่างสองประการระหว่าง iterator กับตัวรับ async channel ประการแรกคือเรื่องของเวลา: iterator เป็นแบบซิงโครนัส (synchronous) ในขณะที่ตัวรับ channel เป็นแบบอะซิงโครนัส (asynchronous) ประการที่สองคือ API เมื่อทำงานกับ Iterator โดยตรง เราจะเรียกเมธอด next ที่เป็นซิงโครนัส แต่สำหรับ trpl::Receiver stream โดยเฉพาะ เราจะเรียกเมธอด recv ที่เป็นอะซิงโครนัสแทน นอกเหนือจากนี้แล้ว API เหล่านี้ให้ความรู้สึกคล้ายกันมาก และความคล้ายคลึงกันนั้นก็ไม่ใช่เรื่องบังเอิญ stream เปรียบเสมือนรูปแบบอะซิงโครนัสของการวนซ้ำ (iteration) อย่างไรก็ตาม ในขณะที่ trpl::Receiver จะรอรับข้อความโดยเฉพาะ API ของ stream ทั่วไปนั้นกว้างกว่ามาก: มันจะมอบคุณค่าถัดไปในลักษณะเดียวกับที่ Iterator ทำ แต่ทำในแบบอะซิงโครนัส

ความคล้ายคลึงกันระหว่าง iterator กับ stream ใน Rust หมายความว่าเราสามารถสร้าง stream จาก iterator ใดๆ ก็ได้ เช่นเดียวกับ iterator เราสามารถทำงานกับ stream ได้โดยการเรียกเมธอด next แล้วใช้ await รับผลลัพธ์ ดังแสดงในโค้ดตัวอย่างที่ 17-21 ซึ่งจะยังไม่สามารถคอมไพล์ได้ในตอนนี้

extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

เราเริ่มต้นด้วยอาเรย์ของตัวเลข ซึ่งเราแปลงเป็น iterator แล้วเรียกใช้ map เพื่อเพิ่มค่าทั้งหมดเป็นสองเท่า จากนั้นเราแปลง iterator ให้กลายเป็น stream โดยใช้ฟังก์ชัน trpl::stream_from_iter ถัดมาเราวนลูปผ่านรายการข้อมูลใน stream เมื่อข้อมูลมาถึงด้วยลูป while let

น่าเสียดายที่เมื่อเราพยายามรันโค้ด มันกลับคอมไพล์ไม่ผ่าน แต่รายงานว่าไม่มีเมธอด next ให้ใช้งานแทน:

error[E0599]: no method named `next` found for struct `tokio_stream::iter::Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

ตามที่ผลลัพธ์นี้อธิบายไว้ เหตุผลที่เกิดข้อผิดพลาดในการคอมไพล์คือ เราต้องนำ trait ที่ถูกต้องเข้ามาอยู่ในขอบเขต (scope) เพื่อให้สามารถใช้เมธอด next ได้ จากสิ่งที่เราพูดคุยกันมา คุณอาจคาดหวังว่า trait นั้นควรจะเป็น Stream แต่จริงๆ แล้วมันคือ StreamExt ย่อมาจาก extension ซึ่ง Ext เป็นรูปแบบ (pattern) ที่ใช้กันทั่วไปในชุมชน Rust สำหรับการขยายความสามารถของ trait หนึ่งด้วยอีก trait หนึ่ง

Stream trait นิยามอินเทอร์เฟซระดับต่ำ (low-level interface) ซึ่งรวมเอาความสามารถของ Iterator และ Future trait เข้าด้วยกันอย่างมีประสิทธิภาพ ส่วน StreamExt จะมอบคุณสมบัติ API ระดับสูงกว่าอยู่บน Stream รวมถึงเมธอด next ตลอดจนเมธอดอรรถประโยชน์ (utility methods) อื่นๆ ที่คล้ายกับเมธอดที่มีใน Iterator trait ปัจจุบัน Stream และ StreamExt ยังไม่ได้เป็นส่วนหนึ่งของไลบรารีมาตรฐาน (standard library) ของ Rust แต่ crate ส่วนใหญ่ในระบบนิเวศจะใช้นิยามที่คล้ายกันนี้

การแก้ไขข้อผิดพลาดในการคอมไพล์ทำได้โดยการเพิ่มคำสั่ง use สำหรับ trpl::StreamExt ดังแสดงในโค้ดตัวอย่างที่ 17-22

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::block_on(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        // --snip--
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

เมื่อนำชิ้นส่วนทั้งหมดมารวมกัน โค้ดนี้ก็ทำงานได้ตามที่เราต้องการ! ยิ่งไปกว่านั้น เมื่อตอนนี้เรามี StreamExt อยู่ในขอบเขตแล้ว เรายังสามารถใช้เมธอดอรรถประโยชน์ทั้งหมดของมันได้ เช่นเดียวกับ iterator

A Closer Look at the Traits for Async

เจาะลึก Trait สำหรับ Async

ตลอดทั้งบทนี้ เราได้ใช้ trait Future, Stream, และ StreamExt ในหลากหลายรูปแบบ อย่างไรก็ตาม จนถึงตอนนี้เรายังคงเลี่ยงที่จะลงลึกในรายละเอียดว่าพวกมันทำงานอย่างไรหรือเกี่ยวข้องกันอย่างไร ซึ่งก็เพียงพอสำหรับการทำงานในชีวิตประจำวันด้วย Rust โดยทั่วไป แต่ในบางครั้ง คุณอาจต้องเผชิญกับสถานการณ์ที่จำเป็นต้องเข้าใจรายละเอียดของ trait เหล่านี้มากขึ้น รวมถึงชนิดข้อมูล Pin และ trait Unpin ด้วย ในหัวข้อนี้ เราจะเจาะลึกเพียงพอที่จะช่วยในสถานการณ์เหล่านั้น โดยปล่อยให้การลงลึก จริงๆ เป็นหน้าที่ของเอกสารประกอบอื่นๆ

Trait Future

ลองเริ่มต้นด้วยการดูการทำงานของ trait Future อย่างใกล้ชิด นี่คือวิธีที่ Rust นิยามมันไว้:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

คำนิยามของ trait นี้ประกอบด้วยชนิดข้อมูลใหม่จำนวนหนึ่งและไวยากรณ์ที่เรายังไม่เคยเห็นมาก่อน ดังนั้นเรามาไล่ดูนิยามนี้ไปทีละส่วนกัน

ประการแรก associated type ที่ชื่อ Output ของ Future จะบอกว่า future นั้นจะคืนค่าผลลัพธ์เป็นอะไร ซึ่งคล้ายคลึงกับ associated type Item สำหรับ trait Iterator ประการที่สอง Future มีเมธอด poll ซึ่งรับพารามิเตอร์ self เป็นพารามิเตอร์อ้างอิงแบบพิเศษ Pin และรับการอ้างอิงแบบเปลี่ยนค่าได้ (mutable reference) ไปยังชนิดข้อมูล Context และคืนค่าเป็น Poll<Self::Output> เราจะพูดถึง Pin และ Context เพิ่มเติมในอีกสักครู่ สำหรับตอนนี้ ให้เรามุ่งเน้นไปที่สิ่งที่เมธอดนี้คืนค่าออกมา นั่นคือชนิดข้อมูล Poll:

#![allow(unused)]
fn main() {
pub enum Poll<T> {
    Ready(T),
    Pending,
}
}

ชนิดข้อมูล Poll นี้คล้ายกับ Option โดยมีแวเรียนต์หนึ่งที่มีค่าคือ Ready(T) และแวเรียนต์หนึ่งที่ไม่มีค่าคือ Pending อย่างไรก็ตาม Poll มีความหมายที่แตกต่างจาก Option อยู่พอสมควร! แวเรียนต์ Pending บ่งบอกว่า future ยังมีงานที่ต้องทำอยู่ ดังนั้นผู้เรียกจะต้องกลับมาตรวจสอบใหม่อีกครั้งในภายหลัง ส่วนแวเรียนต์ Ready บ่งบอกว่า Future ทำงานเสร็จเรียบร้อยแล้วและมีค่า T พร้อมใช้งาน

หมายเหตุ: เป็นเรื่องยากที่เราจะต้องเรียกใช้ poll โดยตรง แต่หากคุณจำเป็นต้องเรียกใช้ โปรดจำไว้ว่าสำหรับ future ส่วนใหญ่ ผู้เรียกไม่ควรเรียก poll ซ้ำอีกหลังจากที่ future คืนค่า Ready แล้ว future จำนวนมากจะเกิด panic หากถูก poll อีกครั้งหลังจากพร้อมใช้งานแล้ว ส่วน future ที่ปลอดภัยในการ poll ซ้ำจะระบุไว้โดยชัดแจ้งในเอกสารประกอบ ซึ่งคล้ายกับพฤติกรรมของ Iterator::next

เมื่อคุณเห็นโค้ดที่ใช้ await Rust จะคอมไพล์โค้ดนั้นเบื้องหลังให้เป็นโค้ดที่เรียกใช้ poll หากคุณย้อนกลับไปดูในโค้ดตัวอย่างที่ 17-4 ที่เราพิมพ์ชื่อหน้าเว็บสำหรับ URL เดี่ยวเมื่อประมวลผลเสร็จ Rust จะคอมไพล์โค้ดนั้นให้ออกมาในลักษณะประมาณนี้ (แม้ว่าจะไม่ได้เหมือนเป๊ะก็ตาม):

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // But what goes here?
    }
}

เราควรทำอย่างไรเมื่อ future ยังคงเป็น Pending? เราต้องการวิธีบางอย่างในการพยายามใหม่ซ้ำแล้วซ้ำเล่าจนกว่า future จะพร้อมใช้งานในที่สุด กล่าวอีกนัยหนึ่งคือเราต้องการลูป:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // continue
        }
    }
}

ทว่าหาก Rust คอมไพล์เป็นโค้ดแบบนั้นทุกประการ ทุกๆ await ก็จะกลายเป็นการบล็อก (blocking) ซึ่งตรงกันข้ามกับสิ่งที่เราต้องการอย่างสิ้นเชิง! แทนที่จะทำเช่นนั้น Rust จะทำให้มั่นใจว่าลูปสามารถส่งต่อการควบคุมไปยังสิ่งที่สามารถหยุดการทำงานของ future นี้ชั่วคราว เพื่อไปทำงานกับ future อื่นๆ แล้วค่อยกลับมาตรวจสอบ future นี้อีกครั้งในภายหลัง ดังที่เราได้เห็นแล้ว สิ่งนั้นก็คือ async runtime และงานจัดคิวและประสานงานนี้ก็เป็นหนึ่งในหน้าที่หลักของมัน

ในส่วน “Sending Data Between Two Tasks Using Message Passing” เราได้อธิบายการรอคอย rx.recv การเรียก recv จะคืนค่าเป็น future และการใช้ await กับ future นั้นจะเป็นการ poll มัน เราได้ระบุว่า runtime จะหยุดพัก future ไว้ชั่วคราว จนกว่ามันจะพร้อมด้วยค่า Some(message) หรือ None เมื่อ channel ปิดตัวลง ด้วยความเข้าใจที่ลึกซึ้งยิ่งขึ้นเกี่ยวกับ trait Future และโดยเฉพาะอย่างยิ่ง Future::poll เราจะเห็นได้ว่ากระบวนการนั้นทำงานอย่างไร runtime รู้ว่า future ยังไม่พร้อมเมื่อมันคืนค่า Poll::Pending ในทางกลับกัน runtime รู้ว่า future พร้อมแล้ว และดำเนินการต่อเมื่อ poll คืนค่าเป็น Poll::Ready(Some(message)) หรือ Poll::Ready(None)

รายละเอียดที่แม่นยำว่า runtime ทำสิ่งนั้นอย่างไรนั้นเกินขอบเขตของหนังสือเล่มนี้ แต่หัวใจสำคัญคือการมองเห็นกลไกพื้นฐานของ future: runtime จะ poll แต่ละ future ที่มันรับผิดชอบ และพา future นั้นกลับไปหลับพักผ่อนเมื่อมันยังไม่พร้อม

ชนิดข้อมูล Pin และ Trait Unpin

ย้อนกลับไปในโค้ดตัวอย่างที่ 17-13 เราใช้แมโคร trpl::join! เพื่อรอคอย future สามตัว อย่างไรก็ตาม เป็นเรื่องปกติที่เราจะมีคอลเลกชัน เช่น vector ที่บรรจุ future จำนวนหนึ่งซึ่งจะไม่ทราบจำนวนจนกว่าจะถึงเวลาทำงาน (runtime) เรามาเปลี่ยนโค้ดตัวอย่างที่ 17-13 ให้เป็นโค้ดในตัวอย่างที่ 17-23 ที่นำ future ทั้งสามตัวใส่ลงใน vector แล้วเรียกใช้ฟังก์ชัน trpl::join_all แทน ซึ่งโค้ดนี้จะยังไม่สามารถคอมไพล์ได้ในตอนนี้

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

เราใส่แต่ละ future ไว้ภายใน Box เพื่อแปลงให้เป็น trait object เหมือนที่เราเคยทำในส่วน “การคืนค่าข้อผิดพลาดจาก run” ในบทที่ 12 (เราจะครอบคลุมเรื่อง trait object โดยละเอียดในบทที่ 18) การใช้ trait object ทำให้เราสามารถปฏิบัติต่อ future นิรนาม (anonymous future) แต่ละตัวที่สร้างขึ้นจากประเภทเหล่านี้เสร็จเป็นประเภทเดียวกันได้ เพราะทุกตัวต่างก็นำ trait Future ไปใช้งาน (implement)

สิ่งนี้อาจสร้างความประหลาดใจ เพราะท้ายที่สุดแล้ว ไม่มีบล็อก async ไหนคืนค่าอะไรเลย ดังนั้นแต่ละบล็อกจึงสร้าง Future<Output = ()> ออกมา แต่โปรดจำไว้ว่า Future เป็น trait และคอมไพเลอร์จะสร้าง enum ที่มีเอกลักษณ์เฉพาะสำหรับแต่ละบล็อก async แม้ว่าจะให้ประเภทของผลลัพธ์ที่เหมือนกันก็ตาม เช่นเดียวกับที่คุณไม่สามารถใส่ struct สองตัวที่เขียนขึ้นต่างกันลงใน Vec เดียวกันได้ คุณก็ไม่สามารถผสม enum ที่คอมไพเลอร์สร้างขึ้นต่างกันได้เช่นกัน

จากนั้นเราผ่านคอลเลกชันของ future ไปยังฟังก์ชัน trpl::join_all แล้วรอคอยผลลัพธ์ อย่างไรก็ตาม โค้ดนี้คอมไพล์ไม่ผ่าน นี่คือส่วนที่เกี่ยวข้องของข้อความผิดพลาด:

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

หมายเหตุในข้อความผิดพลาดนี้บอกเราว่า เราควรใช้แมโคร pin! เพื่อ pin (ตรึง) ค่าเหล่านั้น ซึ่งหมายถึงการนำค่าเหล่านั้นไปไว้ภายในชนิดข้อมูล Pin ที่รับประกันว่าค่าจะไม่ถูกย้ายตำแหน่งในหน่วยความจำ ข้อความผิดพลาดระบุว่าจำเป็นต้องมีการตรึง (pinning) เนื่องจาก dyn Future<Output = ()> จำเป็นต้องนำ trait Unpin ไปใช้งาน แต่ในปัจจุบันยังไม่ได้ทำ

ฟังก์ชัน trpl::join_all คืนค่า struct ที่เรียกว่า JoinAll ซึ่ง struct นั้นเป็นเจเนอริกกับชนิดข้อมูล F ที่ถูกจำกัดไว้ว่าต้องนำ trait Future ไปใช้งาน การรอคอย future โดยตรงด้วย await จะทำการ pin future โดยนัย (implicitly) นั่นคือเหตุผลที่เราไม่จำเป็นต้องใช้ pin! ในทุกๆ ที่ที่เราต้องการ await future

อย่างไรก็ตาม ในที่นี้เราไม่ได้รอคอย future โดยตรง แต่เรากำลังสร้าง future ตัวใหม่ขึ้นมาคือ JoinAll โดยการผ่านคอลเลกชันของ future ไปยังฟังก์ชัน join_all รายละเอียดของฟังก์ชัน join_all กำหนดให้ประเภทของรายการในคอลเลกชันทั้งหมดต้องนำ trait Future ไปใช้งาน และ Box<T> จะนำ Future ไปใช้งานก็ต่อเมื่อ T ที่มันห่อหุ้มอยู่นั้นเป็น future ที่นำ trait Unpin ไปใช้งานด้วย

มีเรื่องให้ทำความเข้าใจเยอะมาก! เพื่อให้เข้าใจอย่างแท้จริง เรามาเจาะลึกลงไปอีกเล็กน้อยว่า trait Future ทำงานอย่างไร โดยเฉพาะอย่างยิ่งในเรื่องเกี่ยวกับการ pin ให้ลองดูนิยามของ trait Future อีกครั้ง:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

พารามิเตอร์ cx และชนิดข้อมูล Context ของมันเป็นกุญแจสำคัญว่า runtime รู้ได้อย่างไรว่าเมื่อใดควรตรวจสอบ future ใดๆ โดยที่ยังคงทำงานแบบประหยัดพลังงาน (lazy) อีกครั้ง รายละเอียดการทำงานของสิ่งนี้เกินขอบเขตของบทนี้ และโดยทั่วไปคุณต้องคิดถึงเรื่องนี้เมื่อเขียนการนำ Future ไปใช้งานเองเท่านั้น เราจะเน้นไปที่ชนิดข้อมูลของ self แทน เนื่องจากนี่เป็นครั้งแรกที่เราได้เห็นเมธอดที่ self มีการระบุชนิดข้อมูล (type annotation) การระบุชนิดข้อมูลสำหรับ self ทำงานเหมือนกับการระบุชนิดข้อมูลสำหรับพารามิเตอร์ของฟังก์ชันอื่นๆ แต่มีข้อแตกต่างหลักสองประการ:

  • มันบอก Rust ว่า self ต้องเป็นชนิดข้อมูลใดเพื่อให้เมธอดนี้ถูกเรียกใช้งานได้
  • มันจะเป็นชนิดข้อมูลใดก็ได้ไม่ได้ มันถูกจำกัดไว้เฉพาะชนิดข้อมูลที่นำเมธอดนั้นไปใช้งาน, การอ้างอิง (reference) หรือสมาร์ตพอยน์เตอร์ (smart pointer) ไปยังชนิดข้อมูลนั้น หรือ Pin ที่ห่อหุ้มการอ้างอิงไปยังชนิดข้อมูลนั้น

เราจะได้เห็นไวยากรณ์นี้เพิ่มเติมใน บทที่ 18 สำหรับตอนนี้ พอที่จะทราบว่าหากเราต้องการ poll future เพื่อตรวจสอบว่าเป็น Pending หรือ Ready(Output) เราจำเป็นต้องมีการอ้างอิงแบบเปลี่ยนค่าได้ที่ถูกห่อหุ้มด้วย Pin ไปยังชนิดข้อมูลนั้น

Pin เป็นตัวห่อหุ้ม (wrapper) สำหรับชนิดข้อมูลที่มีลักษณะเหมือนพอยน์เตอร์ เช่น &, &mut, Box, และ Rc (ในทางเทคนิค Pin ทำงานร่วมกับชนิดข้อมูลที่นำ trait Deref หรือ DerefMut ไปใช้งาน แต่นั่นก็เทียบเท่ากับการทำงานเฉพาะกับการอ้างอิงและสมาร์ตพอยน์เตอร์) ตัว Pin เองไม่ใช่พอยน์เตอร์และไม่มีพฤติกรรมใดๆ ของตัวเองเหมือนที่ Rc และ Arc มีกับการนับการอ้างอิง (reference counting) มันเป็นเพียงเครื่องมือที่คอมไพเลอร์สามารถใช้เพื่อบังคับใช้ข้อจำกัดเกี่ยวกับการใช้พอยน์เตอร์

การระลึกได้ว่า await ถูกสร้างขึ้นโดยการเรียกใช้ poll เริ่มจะอธิบายข้อความผิดพลาดที่เราได้เห็นก่อนหน้านี้ได้แล้ว แต่นั่นเป็นเรื่องของ Unpin ไม่ใช่ Pin แล้ว Pin เกี่ยวข้องอย่างไรกับ Unpin และทำไม Future จึงต้องการให้ self อยู่ในชนิดข้อมูล Pin เพื่อเรียกใช้ poll?

โปรดจำไว้ว่าก่อนหน้านี้ในบทนี้ ลำดับจุด await ใน future จะถูกคอมไพล์ให้กลายเป็นสเตตแมชชีน (state machine) และคอมไพเลอร์จะทำให้แน่ใจว่าสเตตแมชชีนนั้นปฏิบัติตามกฎปกติทั้งหมดของ Rust เกี่ยวกับความปลอดภัย รวมถึงการยืม (borrowing) และความเป็นเจ้าของ (ownership) เพื่อให้สิ่งนั้นทำงานได้ Rust จะดูว่าต้องใช้ข้อมูลใดระหว่างจุด await จุดหนึ่งไปยังจุด await ถัดไป หรือจุดสิ้นสุดของบล็อก async จากนั้นมันจะสร้างแวเรียนต์ที่สอดคล้องกันในสเตตแมชชีนที่ถูกคอมไพล์ขึ้น แต่ละแวเรียนต์จะได้รับสิทธิ์การเข้าถึงข้อมูลที่จำเป็นในการใช้งานในส่วนนั้นของโค้ดต้นฉบับ ไม่ว่าจะโดยการรับความเป็นเจ้าของข้อมูลนั้น หรือโดยการรับการอ้างอิงแบบเปลี่ยนค่าได้หรือเปลี่ยนค่าไม่ได้

จนถึงตอนนี้ ทุกอย่างยังดูดี: หากเราทำอะไรผิดพลาดเกี่ยวกับความเป็นเจ้าของหรือการอ้างอิงในบล็อก async ตัวตรวจสอบการยืม (borrow checker) จะแจ้งเตือนเรา แต่เมื่อเราต้องการเคลื่อนย้าย (move) future ที่สอดคล้องกับบล็อกนั้น—เช่น การย้ายมันลงใน Vec เพื่อส่งต่อไปยัง join_all—สิ่งต่างๆ จะซับซ้อนยิ่งขึ้น

เมื่อเราเคลื่อนย้าย future—ไม่ว่าจะเป็นการดันมันลงในโครงสร้างข้อมูลเพื่อใช้เป็น iterator ร่วมกับ join_all หรือการคืนค่ามันจากฟังก์ชัน—นั่นหมายถึงการเคลื่อนย้ายสเตตแมชชีนที่ Rust สร้างขึ้นเพื่อเรา และไม่เหมือนกับชนิดข้อมูลอื่นๆ ส่วนใหญ่ใน Rust ตัว future ที่ Rust สร้างขึ้นสำหรับบล็อก async อาจจบลงด้วยการมีการอ้างอิงถึงตัวเอง (self-reference) ในฟิลด์ของแวเรียนต์ใดๆ ดังแสดงในภาพประกอบอย่างง่ายในรูปที่ 17-4

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
รูปที่ 17-4: ชนิดข้อมูลที่อ้างอิงถึงตัวเอง (Self-referential data type)

โดยค่านิยมเริ่มแรก (by default) ออบเจกต์ใดๆ ที่มีการอ้างอิงถึงตัวเองจะไม่ปลอดภัยในการเคลื่อนย้าย เนื่องจากพอยน์เตอร์อ้างอิงมักจะชี้ไปยังที่อยู่จริงในหน่วยความจำของสิ่งมันอ้างอิงถึงเสมอ (ดูรูปที่ 17-5) หากคุณเคลื่อนย้ายตัวโครงสร้างข้อมูลนั้นเอง การอ้างอิงภายในเหล่านั้นจะถูกทิ้งไว้ให้ชี้ไปยังตำแหน่งเดิมในหน่วยความจำ แต่ตำแหน่งหน่วยความจำนั้นตอนนี้ไม่ถูกต้องแล้ว ประการหนึ่ง ค่าของมันจะไม่ถูกอัปเดตเมื่อคุณเปลี่ยนแปลงโครงสร้างข้อมูล อีกประการหนึ่ง—ซึ่งสำคัญกว่า—คอมพิวเตอร์สามารถนำหน่วยความจำนั้นไปใช้ประโยชน์อื่นได้แล้ว! คุณอาจจบลงด้วยการอ่านข้อมูลที่ไม่เกี่ยวข้องเลยในภายหลัง

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
รูปที่ 17-5: ผลลัพธ์ที่ไม่ปลอดภัยจากการเคลื่อนย้ายชนิดข้อมูลที่อ้างอิงถึงตัวเอง

ในทางทฤษฎี คอมไพเลอร์ของ Rust สามารถพยายามอัปเดตทุกการอ้างอิงถึงออบเจกต์เมื่อใดก็ตามที่มันถูกเคลื่อนย้าย แต่นั่นอาจเพิ่มภาระทางประสิทธิภาพ (performance overhead) อย่างมาก โดยเฉพาะอย่างยิ่งหากจำเป็นต้องอัปเดตเครือข่ายการอ้างอิงทั้งหมด หากเราสามารถทำให้แน่ใจได้แทนว่า โครงสร้างข้อมูลที่เกี่ยวข้อง จะไม่ถูกเคลื่อนย้ายในหน่วยความจำ เราก็ไม่ต้องอัปเดตการอ้างอิงใดๆ นี่คือสิ่งที่ตัวตรวจสอบการยืม (borrow checker) ของ Rust มีไว้เพื่อสิ่งนี้: ในโค้ดที่ปลอดภัย (safe code) มันจะป้องกันไม่ให้คุณเคลื่อนย้ายรายการใดๆ ที่มีผู้ยืมใช้งานอยู่

Pin ต่อยอดจากสิ่งนั้นเพื่อให้การรับประกันที่ตรงตามที่เราต้องการ เมื่อเรา pin ค่าโดยการห่อหุ้มพอยน์เตอร์ไปยังค่านั้นใน Pin ค่านั้นจะไม่สามารถเคลื่อนย้ายได้อีกต่อไป ดังนั้น หากคุณมี Pin<Box<SomeType>> คุณจะทำการ pin ค่า SomeType จริงๆ ไม่ใช่ ตัวพอยน์เตอร์ Box รูปที่ 17-6 แสดงกระบวนการนี้

Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and terminates inside the “pinned” box at the “fut” table.
รูปที่ 17-6: การ Pin `Box` ที่ชี้ไปยังชนิดข้อมูล future ที่อ้างอิงถึงตัวเอง

ในความเป็นจริง พอยน์เตอร์ Box ยังสามารถย้ายไปมาได้อย่างอิสระ โปรดจำไว้ว่า: สิ่งที่เราใส่ใจคือการทำให้แน่ใจว่า ข้อมูลที่ถูกอ้างอิงถึงในท้ายที่สุดยังคงอยู่ที่เดิม หากพอยน์เตอร์ย้ายไปมา แต่ข้อมูลที่มันชี้ไป อยู่ที่เดิม เหมือนในรูปที่ 17-7 ก็จะไม่มีปัญหาที่อาจเกิดขึ้นได้ (ในฐานะแบบฝึกหัดอิสระ ให้ลองดูเอกสารประกอบสำหรับชนิดข้อมูลต่างๆ ตลอดจนโมดูล std::pin และลองหาคำตอบว่าคุณจะทำเช่นนี้ด้วย Pin ที่ห่อหุ้ม Box ได้อย่างไร) หัวใจสำคัญคือตัวชนิดข้อมูลที่อ้างอิงถึงตัวเองจะไม่สามารถย้ายตำแหน่งได้ เนื่องจากมันยังคงถูก pin อยู่

Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.
รูปที่ 17-7: การเคลื่อนย้าย `Box` ซึ่งชี้ไปยังชนิดข้อมูล future ที่อ้างอิงถึงตัวเอง

อย่างไรก็ตาม ชนิดข้อมูลส่วนใหญ่มีความปลอดภัยอย่างสมบูรณ์ในการเคลื่อนย้ายไปมา แม้ว่าพวกมันจะอยู่หลังพอยน์เตอร์ Pin ก็ตาม เราจำเป็นต้องคิดถึงเรื่องการ pinning เฉพาะเมื่อรายการนั้นมีข้อมูลอ้างอิงภายในเท่านั้น ค่าดั้งเดิม (primitive values) เช่น ตัวเลขและบูลีน มีความปลอดภัยเนื่องจากเห็นได้ชัดว่าไม่มีการอ้างอิงภายในใดๆ ชนิดข้อมูลส่วนใหญ่ที่คุณทำงานด้วยใน Rust ก็ไม่มีเช่นกัน คุณสามารถเคลื่อนย้าย Vec ไปมาได้โดยไม่ต้องกังวล จากสิ่งที่เราได้เห็นมาจนถึงตอนนี้ หากคุณมี Pin<Vec<String>> คุณจะต้องทำทุกอย่างผ่าน API ที่ปลอดภัยแต่จำกัดซึ่งจัดไว้โดย Pin แม้ว่า Vec<String> จะปลอดภัยเสมอในการเคลื่อนย้ายหากไม่มีการอ้างอิงอื่นๆ ถึงมันก็ตาม เราต้องการวิธีบอกคอมไพเลอร์ว่า ปลอดภัยที่จะย้ายรายการไปมาในกรณีเช่นนี้—และนั่นคือจุดที่ Unpin เข้ามามีบทบาท

Unpin เป็น marker trait ซึ่งคล้ายกับ trait Send และ Sync ที่เราได้เห็นในบทที่ 16 จึงไม่มีฟังก์ชันการทำงานของตัวเอง marker trait มีอยู่เพียงเพื่อบอกคอมไพเลอร์ว่า ปลอดภัยที่จะใช้ชนิดข้อมูลที่นำ trait นั้นไปใช้งานในบริบทที่กำหนด Unpin แจ้งคอมไพเลอร์ว่าชนิดข้อมูลนั้น ไม่จำเป็น ต้องปฏิบัติตามหลักประกันใดๆ เกี่ยวกับว่าค่านั้นสามารถย้ายตำแหน่งได้อย่างปลอดภัยหรือไม่

เช่นเดียวกับ Send และ Sync คอมไพเลอร์จะนำ Unpin ไปใช้งานโดยอัตโนมัติสำหรับทุกชนิดข้อมูลที่สามารถพิสูจน์ได้ว่าปลอดภัย กรณีพิเศษ ซึ่งคล้ายกับ Send และ Sync อีกเช่นกัน คือกรณีที่ Unpin ไม่ได้ ถูกนำไปใช้งานสำหรับชนิดข้อมูลนั้น สัญลักษณ์สำหรับกรณีนี้คือ impl !Unpin for SomeType โดยที่ SomeType คือชื่อของชนิดข้อมูลที่ จำเป็น ต้องปฏิบัติตามหลักประกันเหล่านั้นเพื่อให้ปลอดภัยเมื่อใดก็ตามที่พอยน์เตอร์ไปยังชนิดข้อมูลนั้นถูกใช้ใน Pin

กล่าวอีกนัยหนึ่ง มีสองสิ่งที่คุณต้องจำไว้เกี่ยวกับความสัมพันธ์ระหว่าง Pin และ Unpin ประการแรก Unpin คือกรณี “ปกติ” และ !Unpin คือกรณีพิเศษ ประการที่สอง การที่ชนิดข้อมูลจะนำ Unpin หรือ !Unpin ไปใช้งานนั้น มีความสำคัญ เฉพาะ เมื่อคุณใช้พอยน์เตอร์ที่ถูก pin ไว้ไปยังชนิดข้อมูลนั้น เช่น Pin<&mut SomeType>

เพื่อให้เห็นภาพชัดเจน ให้คิดถึง String: มันมี ความยาว และ ตัวอักษร Unicode ที่ประกอบเป็นข้อความขึ้นมา เราสามารถห่อหุ้ม String ไว้ใน Pin ได้ ดังที่เห็นในรูปที่ 17-8 อย่างไรก็ตาม String จะนำ Unpin ไปใช้งานโดยอัตโนมัติ เช่นเดียวกับชนิดข้อมูลอื่นๆ ส่วนใหญ่ใน Rust

A box labeled “Pin” on the left with an arrow going from it to a box labeled “String” on the right. The “String” box contains the data 5usize, representing the length of the string, and the letters “h”, “e”, “l”, “l”, and “o” representing the characters of the string “hello” stored in this String instance. A dotted rectangle surrounds the “String” box and its label, but not the “Pin” box.
รูปที่ 17-8: การ Pin `String`; เส้นประระบุว่า `String` นำ trait `Unpin` ไปใช้งาน จึงไม่ได้ถูกตรึงตำแหน่งไว้

ด้วยเหตุนี้ เราจึงสามารถทำสิ่งที่จะผิดกฎหมายได้หาก String นำ !Unpin ไปใช้งานแทน เช่น การแทนที่สตริงหนึ่งด้วยอีกสตริงหนึ่งที่ตำแหน่งเดียวกันในหน่วยความจำดังรูปที่ 17-9 สิ่งนี้ไม่ได้ละเมิดข้อตกลงของ Pin เนื่องจาก String ไม่มีข้อมูลอ้างอิงภายในที่ทำให้ไม่ปลอดภัยในการย้ายตำแหน่ง นั่นคือเหตุผลที่มันนำ Unpin ไปใช้งานแทนที่จะเป็น !Unpin

The same “hello” string data from the previous example, now labeled “s1” and grayed out. The “Pin” box from the previous example now points to a different String instance, one that is labeled “s2”, is valid, has a length of 7usize, and contains the characters of the string “goodbye”. s2 is surrounded by a dotted rectangle because it, too, implements the Unpin trait.
รูปที่ 17-9: การแทนที่ `String` ด้วย `String` อื่นอย่างสมบูรณ์ในหน่วยความจำ

ตอนนี้เรารู้เพียงพอแล้วที่จะเข้าใจข้อผิดพลาดที่รายงานสำหรับการเรียกใช้ join_all ในโค้ดตัวอย่างที่ 17-23 เดิมทีเราพยายามย้าย future ที่สร้างขึ้นจากบล็อก async ลงใน Vec<Box<dyn Future<Output = ()>>> แต่ดังที่เราได้เห็นแล้ว future เหล่านั้นอาจมีการอ้างอิงภายใน ดังนั้นพวกมันจึงไม่ได้นำ Unpin ไปใช้งานโดยอัตโนมัติ เมื่อเรา pin พวกมันแล้ว เราจึงสามารถส่งประเภท Pin ที่ได้ลงใน Vec ได้ โดยมั่นใจว่าข้อมูลเบื้องหลังใน future จะ ไม่ ถูกย้ายตำแหน่ง โค้ดตัวอย่างที่ 17-24 แสดงวิธีแก้ไขโค้ดโดยการเรียกใช้แมโคร pin! ตรงจุดที่กำหนด future แต่ละตัวทั้งสามตัว และปรับแต่งชนิดข้อมูลของ trait object

extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// --snip--

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

ตอนนี้โค้ดตัวอย่างนี้สามารถคอมไพล์และรันได้แล้ว และเราสามารถเพิ่มหรือลบ future ออกจาก vector ในขณะรันและ join ทั้งหมดเข้าด้วยกันได้

Pin และ Unpin ส่วนใหญ่มีความสำคัญสำหรับการสร้างไลบรารีระดับล่าง หรือเมื่อคุณกำลังสร้าง runtime เอง มากกว่าสำหรับโค้ด Rust ในชีวิตประจำวัน อย่างไรก็ตาม เมื่อคุณเห็น trait เหล่านี้ในข้อความผิดพลาด ตอนนี้คุณจะมีไอเดียที่ดีขึ้นเกี่ยวกับวิธีแก้ไขโค้ดของคุณ!

หมายเหตุ: การผสมผสานกันระหว่าง Pin และ Unpin ทำให้เป็นไปได้ที่จะนำชนิดข้อมูลที่ซับซ้อนทั้งประเภทไปใช้งานได้อย่างปลอดภัยใน Rust ซึ่งมิฉะนั้นจะพิสูจน์แล้วว่าเป็นเรื่องท้าทายเพราะเป็นแบบอ้างอิงถึงตัวเอง ชนิดข้อมูลที่ต้องการ Pin มักจะปรากฏใน async Rust ในปัจจุบันเป็นส่วนใหญ่ แต่ในบางครั้ง คุณอาจเห็นพวกมันในบริบทอื่นๆ ได้ด้วยเช่นกัน

รายละเอียดเฉพาะเกี่ยวกับการทำงานของ Pin และ Unpin รวมถึงกฎระเบียบที่พวกมันต้องปฏิบัติตาม ได้รับการอธิบายไว้อย่างครอบคลุมในเอกสารประกอบ API สำหรับ std::pin ดังนั้นหากคุณสนใจที่จะเรียนรู้เพิ่มเติม นั่นเป็นจุดเริ่มต้นที่ดีเยี่ยม

หากคุณต้องการเข้าใจวิธีการทำงานเบื้องหลังโดยละเอียดมากยิ่งขึ้น ให้ดูที่ บทที่ 2 และ 4 ของหนังสือ Asynchronous Programming in Rust

Trait Stream

ตอนนี้ที่คุณมีความเข้าใจอย่างลึกซึ้งยิ่งขึ้นเกี่ยวกับ trait Future, Pin, และ Unpin แล้ว เราสามารถหันมาให้ความสนใจกับ trait Stream ได้ ดังที่คุณได้เรียนรู้ก่อนหน้านี้ในบทนี้ stream คล้ายกับ iterator แบบอะซิงโครนัส อย่างไรก็ตาม แตกต่างจาก Iterator และ Future ตรงที่ ณ ขณะที่เขียนนี้ Stream ยังไม่มีนิยามในไลบรารีมาตรฐาน แต่ มี นิยามที่ใช้กันทั่วไปจาก crate futures ที่ถูกใช้ตลอดทั่วทั้งระบบนิเวศ

เรามารบทวนนิยามของ trait Iterator และ Future ก่อนที่จะดูว่า trait Stream อาจรวมพวกมันเข้าด้วยกันได้อย่างไร จาก Iterator เรามีแนวคิดเรื่องลำดับ: เมธอด next ของมันจะส่งมอบ Option<Self::Item> จาก Future เรามีแนวคิดเรื่องความพร้อมใช้งานตามช่วงเวลา: เมธอด poll ของมันจะส่งมอบ Poll<Self::Output> ในการแทนลำดับของรายการข้อมูลที่พร้อมใช้งานตามช่วงเวลา เราจึงนิยาม trait Stream ที่รวมคุณสมบัติเหล่านั้นเข้าด้วยกัน:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

trait Stream นิยาม associated type ที่เรียกว่า Item สำหรับประเภทของรายการข้อมูลที่ถูกสร้างขึ้นโดย stream สิ่งนี้คล้ายกับ Iterator ซึ่งอาจมีรายการข้อมูลตั้งแต่ศูนย์ถึงหลายรายการ และต่างจาก Future ตรงที่ Future จะมี Output เดี่ยวเสมอ แม้ว่าจะเป็น unit type () ก็ตาม

Stream ยังนิยามเมธอดเพื่อรับรายการข้อมูลเหล่านั้นด้วย เราเรียกมันว่า poll_next เพื่อให้ชัดเจนว่ามันทำการ poll ในลักษณะเดียวกับที่ Future::poll ทำ และสร้างลำดับของรายการข้อมูลในลักษณะเดียวกับที่ Iterator::next ทำ ชนิดข้อมูลคืนค่าของมันรวม Poll เข้ากับ Option ชนิดข้อมูลชั้นนอกคือ Poll เนื่องจากต้องได้รับการตรวจสอบความพร้อมเหมือนกับ future ชนิดข้อมูลชั้นในคือ Option เนื่องจากจำเป็นต้องส่งสัญญาณว่ายังมีข้อความเพิ่มเติมหรือไม่เหมือนกับ iterator

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

ในตัวอย่างที่เราเห็นในส่วน “Streams: Futures in Sequence” เราไม่ได้ใช้ poll_next หรือ Stream แต่ใช้ next และ StreamExt แทน แน่นอนว่าเรา สามารถ ทำงานกับ API poll_next โดยตรงได้โดยการเขียนสเตตแมชชีน Stream ของเราเอง เช่นเดียวกับที่เรา สามารถ ทำงานกับ future โดยตรงผ่านเมธอด poll ของพวกมัน ทว่าการใช้ await นั้นดีกว่ามาก และ trait StreamExt ก็นำเสนอเมธอด next เพื่อให้เราทำเช่นนั้นได้:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

หมายเหตุ: นิยามจริงที่เราใช้ก่อนหน้านี้ในบทนี้ดูแตกต่างจากนี้เล็กน้อย เนื่องจากรองรับเวอร์ชันของ Rust ที่ยังไม่รองรับการใช้ฟังก์ชัน async ใน trait ผลลัพธ์ที่ได้จึงมีลักษณะดังนี้:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

ชนิดข้อมูล Next นั้นเป็น struct ที่นำ Future ไปใช้งาน และช่วยให้เราสามารถระบุชื่ออายุการใช้งาน (lifetime) ของการอ้างอิงไปยัง self ด้วย Next<'_, Self> เพื่อให้ await สามารถทำงานกับเมธอดนี้ได้

trait StreamExt ยังเป็นศูนย์รวมของเมธอดที่น่าสนใจทั้งหมดที่มีให้ใช้ร่วมกับ stream StreamExt จะถูกนำไปใช้งานโดยอัตโนมัติสำหรับทุกชนิดข้อมูลที่นำ Stream ไปใช้งาน แต่ trait เหล่านี้ถูกนิยามแยกกัน เพื่อให้ชุมชนสามารถพัฒนา API ความสะดวกสบายได้โดยไม่ส่งผลกระทบต่อ trait พื้นฐาน

ในเวอร์ชันของ StreamExt ที่ใช้ใน crate trpl ตัว trait ไม่เพียงแต่นิยามเมธอด next เท่านั้น แต่ยังจัดเตรียมการนำไปใช้งานเริ่มต้น (default implementation) ของ next ที่จัดการรายละเอียดของการเรียกใช้ Stream::poll_next อย่างถูกต้องด้วย ซึ่งหมายความว่าแม้ว่าคุณจะจำเป็นต้องเขียนชนิดข้อมูลสตรีมมิ่งของคุณเอง คุณก็ต้องนำไปใช้งาน เฉพาะ Stream เท่านั้น และจากนั้นใครก็ตามที่ใช้ชนิดข้อมูลของคุณจะสามารถใช้ StreamExt และเมธอดของมันได้โดยอัตโนมัติ

นั่นคือทั้งหมดที่เราจะครอบคลุมสำหรับรายละเอียดระดับล่างของ trait เหล่านี้ สรุปแล้ว ให้เราพิจารณาว่า futures (รวมถึง streams), tasks, และ threads สอดประสานเข้าด้วยกันได้อย่างไร!

Futures, Tasks, and Threads

สรุปองค์รวม: Futures, Tasks และ Threads

ดังที่เราได้เห็นใน บทที่ 16 threads ให้แนวทางหนึ่งในการทำงานแบบพร้อมกัน (concurrency) และเราก็ได้เห็นอีกแนวทางหนึ่งในบทนี้ นั่นคือการใช้ async ร่วมกับ futures และ streams หากคุณสงสัยว่าจะเลือกใช้วิธีใดวิธีหนึ่งเมื่อใด คำตอบคือ: ขึ้นอยู่กับสถานการณ์! และในหลายๆ กรณี ทางเลือกไม่ได้เป็นเรื่องของ threads หรือ async แต่เป็น threads และ async ร่วมกัน

ระบบปฏิบัติการจำนวนมากได้จัดหาโมเดลการทำงานแบบพร้อมกันตามพื้นฐานของ thread มาเป็นเวลานานหลายทศวรรษแล้ว และด้วยเหตุนี้ภาษาโปรแกรมจำนวนมากจึงรองรับโมเดลเหล่านี้ อย่างไรก็ตาม โมเดลเหล่านี้ก็มีข้อแลกเปลี่ยน (tradeoffs) ในระบบปฏิบัติการหลายๆ ระบบ พวกมันใช้หน่วยความจำค่อนข้างมากสำหรับแต่ละ thread นอกจากนี้ Threads ยังเป็นตัวเลือกเฉพาะเมื่อระบบปฏิบัติการและฮาร์ดแวร์ของคุณรองรับเท่านั้น ไม่เหมือนกับคอมพิวเตอร์เดสก์ท็อปและมือถือกระแสหลัก ระบบฝังตัว (embedded systems) บางระบบไม่มี OS เลย ดังนั้นจึงไม่มี threads เช่นกัน

โมเดล async มอบชุดข้อแลกเปลี่ยนที่แตกต่างกัน—และเติมเต็มซึ่งกันและกันอย่างสมบูรณ์แบบ ในโมเดล async การทำงานแบบพร้อมกันไม่จำเป็นต้องมี thread ของตัวเองเสมอไป แต่สามารถรันบน task แทนได้ เช่น เมื่อเราใช้ trpl::spawn_task เพื่อเริ่มทำงานจากฟังก์ชันแบบซิงโครนัสในส่วนของ streams ตัว task คล้ายกับ thread แต่แทนที่จะได้รับการจัดการโดยระบบปฏิบัติการ มันจะได้รับการจัดการโดยโค้ดระดับไลบรารี: นั่นคือ runtime

มีเหตุผลที่ทำให้ API สำหรับการสร้าง thread และการสร้าง task มีความคล้ายคลึงกันมาก Thread ทำหน้าที่เป็นขอบเขตสำหรับชุดการทำงานแบบซิงโครนัส การทำงานแบบพร้อมกันสามารถเกิดขึ้นได้ ระหว่าง threads ส่วน Task ทำหน้าที่เป็นขอบเขตสำหรับชุดการทำงานแบบ อะซิงโครนัส การทำงานแบบพร้อมกันสามารถเกิดขึ้นได้ทั้ง ระหว่าง และ ภายใน tasks เพราะว่า task สามารถสลับการทำงานระหว่าง futures ต่างๆ ภายในตัวมันได้ ท้ายที่สุด Future คือหน่วยของการทำงานแบบพร้อมกันที่รายละเอียดที่สุดของ Rust และแต่ละ future อาจแทนโครงสร้างต้นไม้ของ futures อื่นๆ โดยที่ runtime—โดยเฉพาะอย่างยิ่ง executor ของมัน—จะจัดการ tasks และ tasks จะจัดการ futures ในแง่นั้น tasks จึงเปรียบเสมือน thread น้ำหนักเบาที่ถูกจัดการโดย runtime พร้อมกับความสามารถเพิ่มเติมที่มาจากการถูกจัดการโดย runtime แทนที่จะเป็นระบบปฏิบัติการ

นี่ไม่ได้หมายความว่า async tasks จะดีกว่า threads (หรือในทางกลับกัน) เสมอไป การทำงานแบบพร้อมกันด้วย threads มีรูปแบบการเขียนโปรแกรมที่เรียบง่ายกว่าในบางแง่มุมเมื่อเทียบกับการทำงานแบบพร้อมกันด้วย async ซึ่งนั่นอาจเป็นจุดแข็งหรือจุดอ่อนก็ได้ Threads จะมีลักษณะแบบ “ส่งแล้วลืม (fire and forget)” อยู่บ้าง พวกมันไม่มีสิ่งที่เทียบเท่ากับ future โดยธรรมชาติ ดังนั้นพวกมันจึงรันจนเสร็จโดยไม่ถูกขัดจังหวะ ยกเว้นโดยตัวระบบปฏิบัติการเอง

และปรากฏว่า threads และ tasks มักจะทำงานร่วมกันได้ดีมาก เพราะว่า tasks สามารถ (อย่างน้อยในบาง runtime) ถูกย้ายไปมาระหว่าง threads ได้ ในความเป็นจริง เบื้องหลัง runtime ที่เราใช้อยู่—รวมถึงฟังก์ชัน spawn_blocking และ spawn_task—เป็นแบบหลาย thread (multithreaded) โดยเริ่มต้น! Runtime จำนวนมากใช้วิธีการที่เรียกว่า work stealing เพื่อย้าย tasks ไปมาระหว่าง threads ได้อย่างโปร่งใส ตามการใช้งานของ threads ในขณะนั้น เพื่อปรับปรุงประสิทธิภาพโดยรวมของระบบ วิธีการดังกล่าวนั้นต้องอาศัย threads และ tasks และทำให้ต้องใช้ futures ร่วมด้วยในที่สุด

เมื่อคิดว่าจะใช้วิธีใดเมื่อใด ให้พิจารณาหลักการกว้างๆ เหล่านี้:

  • หากงานนั้น ประมวลผลแบบขนานได้ดีมาก (นั่นคือ เน้นภาระ CPU หรือ CPU-bound) เช่น การประมวลผลชุดข้อมูลขนาดใหญ่ที่แต่ละส่วนสามารถประมวลผลแยกกันได้ threads จะเป็นตัวเลือกที่ดีกว่า
  • หากงานนั้น เน้นการทำงานแบบพร้อมกันอย่างมาก (นั่นคือ เน้นภาระ I/O หรือ I/O-bound) เช่น การจัดการข้อความจากหลายแหล่งที่อาจเข้ามาในช่วงเวลาหรืออัตราความเร็วที่แตกต่างกัน async จะเป็นตัวเลือกที่ดีกว่า

และหากคุณต้องการทั้งการทำงานแบบขนาน (parallelism) และการทำงานแบบพร้อมกัน (concurrency) คุณไม่จำเป็นต้องเลือกระหว่าง threads หรือ async คุณสามารถใช้พวกมันร่วมกันได้อย่างอิสระ โดยปล่อยให้แต่ละส่วนทำหน้าที่ในสิ่งที่ตนเองถนัดที่สุด ตัวอย่างเช่น โค้ดตัวอย่างที่ 17-25 แสดงตัวอย่างการผสมผสานชนิดนี้ที่พบเห็นได้ทั่วไปในโค้ด Rust ของโลกแห่งความเป็นจริง

extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

เราเริ่มต้นด้วยการสร้าง async channel จากนั้นสร้าง thread ที่รับความเป็นเจ้าของของฝั่งส่งข้อความ (sender side) ของ channel โดยใช้คีย์เวิร์ด move ภายใน thread เราส่งตัวเลข 1 ถึง 10 โดยหยุดพัก (sleep) 1 วินาทีในแต่ละช่วง ท้ายที่สุด เราจะรัน future ที่สร้างด้วยบล็อก async ซึ่งส่งต่อไปยัง trpl::block_on เช่นเดียวกับที่เราทำมาตลอดทั้งบท ใน future นั้นเรารอคอย (await) ข้อความเหล่านั้น เหมือนกับตัวอย่างการส่งข้อความอื่นๆ ที่เราเคยเห็นมา

หากย้อนกลับไปยังสถานการณ์ที่เราเปิดบทเรียนไว้ ให้จินตนาการถึงการรันชุดงานเข้ารหัสวิดีโอ (video encoding) โดยใช้ thread ที่แยกต่างหากโดยเฉพาะ (เพราะการเข้ารหัสวิดีโอเน้นการคำนวณหรือ compute-bound) แต่แจ้งเตือนส่วนประสานงานผู้ใช้ (UI) ว่าการทำงานเหล่านั้นเสร็จสิ้นแล้วด้วย async channel มีตัวอย่างชุดการผสมผสานประเภทนี้มากมายนับไม่ถ้วนในกรณีการใช้งานจริง

สรุป

นี่ไม่ใช่ครั้งสุดท้ายที่คุณจะได้เห็นเรื่องการทำงานแบบพร้อมกันในหนังสือเล่มนี้ โปรเจกต์ใน บทที่ 21 จะประยุกต์ใช้แนวคิดเหล่านี้ในสถานการณ์ที่สมจริงยิ่งกว่าตัวอย่างง่ายๆ ที่อภิปรายไว้ที่นี่ และเปรียบเทียบการแก้ปัญหาระหว่างการใช้ threading กับ tasks และ futures โดยตรงยิ่งขึ้น

ไม่ว่าคุณจะเลือกแนวทางใดก็ตาม Rust จะมอบเครื่องมือที่คุณต้องการเพื่อเขียนโค้ดที่ปลอดภัย รวดเร็ว และทำงานแบบพร้อมกันได้—ไม่ว่าจะสำหรับเว็บเซิร์ฟเวอร์ที่รองรับทราฟฟิกสูง (high-throughput) หรือระบบปฏิบัติการฝังตัวก็ตาม

ในบทถัดไป เราจะพูดถึงวิธีที่เป็นแบบฉบับ (idiomatic ways) ในการสร้างโมเดลปัญหาและจัดโครงสร้างแนวทางแก้ไขเมื่อโปรแกรม Rust ของคุณมีขนาดใหญ่ขึ้น นอกจากนี้ เราจะอภิปรายว่าสำนวน (idioms) ของ Rust เกี่ยวข้องกับสิ่งที่คุณอาจคุ้นเคยจากการเขียนโปรแกรมเชิงวัตถุ (object-oriented programming) อย่างไร

คุณลักษณะการเขียนโปรแกรมเชิงวัตถุ (Object-Oriented Programming)

การเขียนโปรแกรมเชิงวัตถุ (Object-Oriented Programming หรือ OOP) เป็นรูปแบบหนึ่งของการจำลองโครงสร้างโปรแกรม ออบเจกต์ (Objects) ในฐานะแนวคิดทางการเขียนโปรแกรมถูกนำเสนอครั้งแรกในภาษาโปรแกรม Simula ในช่วงทศวรรษ 1960 ออบเจกต์เหล่านั้นได้ส่งอิทธิพลต่อสถาปัตยกรรมการเขียนโปรแกรมของ Alan Kay ซึ่งเป็นสถาปัตยกรรมที่ออบเจกต์ส่งข้อความ (messages) ถึงกันและกัน เพื่อที่จะอธิบายสถาปัตยกรรมนี้ เขาได้คิดคำว่า object-oriented programming ขึ้นมาในปี ค.ศ. 1967 มีนิยามมากมายที่แตกต่างกันเพื่ออธิบายว่า OOP คืออะไร และตามบางนิยามภาษา Rust ถือว่าเป็นเชิงวัตถุ แต่ตามนิยามอื่นอาจถือว่าไม่ได้เป็น ในบทนี้ เราจะสำรวจคุณลักษณะบางประการที่มักถูกพิจารณาว่าเป็นเชิงวัตถุ และคุณลักษณะเหล่านั้นแปลงมาสู่รูปแบบที่เป็นแบบฉบับ (idiomatic) ของ Rust อย่างไร จากนั้นเราจะแสดงวิธีนำรูปแบบการออกแบบเชิงวัตถุ (design pattern) มาใช้งานใน Rust และอภิปรายข้อดีข้อเสียของการทำเช่นนั้น เปรียบเทียบกับการนำแนวทางแก้ไขที่ใช้จุดแข็งเฉพาะของ Rust มาใช้งานแทน

Characteristics of Object-Oriented Languages

คุณลักษณะของภาษาเชิงวัตถุ

ไม่มีฉันทามติในชุมชนการเขียนโปรแกรมเกี่ยวกับคุณสมบัติที่ภาษาหนึ่งต้องมีเพื่อจะถือว่าเป็นภาษาเชิงวัตถุ Rust ได้รับอิทธิพลจากพาราดิสม์ (paradigms) การเขียนโปรแกรมหลายแบบ รวมถึง OOP ตัวอย่างเช่น เราได้สำรวจคุณลักษณะที่มาจากฟังก์ชันนัลโปรแกรมมิ่งในบทที่ 13 ไปแล้ว อาจกล่าวได้ว่าภาษา OOP มีคุณลักษณะร่วมบางประการ—นั่นคือ ออบเจกต์ (objects), การซ่อนแคปซูล (encapsulation), และการสืบทอด (inheritance) มาดูกันว่าแต่ละคุณลักษณะหมายถึงอะไร และ Rust รองรับคุณลักษณะเหล่านั้นหรือไม่

ออบเจกต์บรรจุข้อมูลและพฤติกรรม

หนังสือ Design Patterns: Elements of Reusable Object-Oriented Software โดย Erich Gamma, Richard Helm, Ralph Johnson, และ John Vlissides (Addison-Wesley, 1994) ซึ่งมักถูกเรียกขานกันในภาษาพูดว่าหนังสือ The Gang of Four (หรือ GoF) เป็นคลังรวบรวมรูปแบบการออกแบบเชิงวัตถุ หนังสือเล่มนี้นิยาม OOP ไว้ดังนี้:

โปรแกรมเชิงวัตถุประกอบขึ้นจากออบเจกต์ ออบเจกต์ ทำหน้าที่รวบรวมทั้งข้อมูลและขั้นตอนการทำงาน (procedures) ที่กระทำต่อข้อมูลนั้นเข้าไว้ด้วยกัน ขั้นตอนการทำงานเหล่านั้นมักถูกเรียกว่า เมธอด (methods) หรือ ออเพอเรชัน (operations)

หากใช้นิยามนี้ ภาษา Rust ถือว่าเป็นเชิงวัตถุ: Structs และ enums มีข้อมูล และบล็อก impl มอบเมธอดให้กับ structs และ enums แม้ว่า structs และ enums ที่มีเมธอดจะไม่ถูก เรียกว่า ออบเจกต์ แต่พวกมันก็นำเสนอฟังก์ชันการทำงานแบบเดียวกัน ตามนิยามออบเจกต์ของ Gang of Four

การซ่อนแคปซูลเพื่อปกปิดรายละเอียดการทำงานเบื้องหลัง

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

เราได้พูดถึงวิธีควบคุมการซ่อนแคปซูลไปแล้วในบทที่ 7: เราสามารถใช้คีย์เวิร์ด pub เพื่อตัดสินใจว่าโมดูล ชนิดข้อมูล ฟังก์ชัน และเมธอดใดในโค้ดของเราควรเป็นสาธารณะ (public) และโดยค่านิยมเริ่มแรก สิ่งอื่นๆ ทั้งหมดจะเป็นส่วนตัว (private) ตัวอย่างเช่น เราสามารถนิยาม struct AveragedCollection ที่มีฟิลด์เก็บ vector ของค่า i32 นอกจากนี้ struct ยังสามารถมีฟิลด์ที่เก็บค่าเฉลี่ยของข้อมูลใน vector ซึ่งหมายความว่าค่าเฉลี่ยไม่จำเป็นต้องถูกคำนวณใหม่ตามคำขอทุกครั้งที่มีคนต้องการ กล่าวอีกนัยหนึ่ง AveragedCollection จะแคช (cache) ค่าเฉลี่ยที่คำนวณไว้แล้วให้เรา โค้ดตัวอย่างที่ 18-1 มีคำนิยามของ struct AveragedCollection

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

struct ถูกทำเครื่องหมายเป็น pub เพื่อให้โค้ดอื่นสามารถนำไปใช้งานได้ แต่ฟิลด์ภายใน struct ยังคงเป็นส่วนตัว (private) สิ่งนี้สำคัญในกรณีนี้เพราะเราต้องการเน้นย้ำให้แน่ใจว่า เมื่อใดก็ตามที่มีการเพิ่มหรือลบค่าออกจากรายการ ค่าเฉลี่ยจะถูกอัปเดตตามไปด้วย เราทำสิ่งนี้โดยการนำเมธอด add, remove, และ average ไปใช้งานบน struct ดังแสดงในโค้ดตัวอย่างที่ 18-2

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

เมธอดสาธารณะ add, remove, และ average เป็นช่องทางเดียวในการเข้าถึงหรือแก้ไขข้อมูลในอินสแตนซ์ของ AveragedCollection เมื่อมีการเพิ่มรายการลงใน list โดยใช้เมธอด add หรือลบออกโดยใช้เมธอด remove การทำงานภายในของแต่ละเมธอดจะเรียกเมธอดส่วนตัว update_average ซึ่งรับหน้าที่จัดการอัปเดตฟิลด์ average ด้วยเช่นกัน

เราละฟิลด์ list และ average ให้เป็นส่วนตัว เพื่อไม่ให้โค้ดภายนอกสามารถเพิ่มหรือลบรายการออกจากฟิลด์ list โดยตรงได้ มิฉะนั้น ฟิลด์ average อาจทำงานไม่สอดคล้องกับข้อมูลที่เปลี่ยนไปใน list เมธอด average จะคืนค่าที่อยู่ในฟิลด์ average อนุญาตให้โค้ดภายนอกอ่านค่า average ได้แต่ไม่สามารถแก้ไขได้

เนื่องจากเราได้ซ่อนแคปซูลรายละเอียดการทำงานภายในของ struct AveragedCollection ไว้ เราจึงสามารถเปลี่ยนแปลงองค์ประกอบบางอย่าง เช่น โครงสร้างข้อมูล ได้อย่างง่ายดายในอนาคต ตัวอย่างเช่น เราสามารถใช้ HashSet<i32> แทน Vec<i32> สำหรับฟิลด์ list ได้ ตราบใดที่ลายเซ็น (signatures) ของเมธอดสาธารณะ add, remove, และ average ยังคงเดิม โค้ดที่ใช้ AveragedCollection ก็ไม่จำเป็นต้องแก้ไข แต่หากเราเปิดฟิลด์ list ให้เป็นสาธารณะแทน สถานการณ์อาจไม่เป็นเช่นนั้น: HashSet<i32> และ Vec<i32> มีเมธอดที่แตกต่างกันสำหรับการเพิ่มและลบรายการ ดังนั้นโค้ดภายนอกจึงน่าจะต้องแก้ไขหากมีการปรับเปลี่ยน list โดยตรง

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

การสืบทอดในฐานะระบบชนิดข้อมูลและการแบ่งปันโค้ด

การสืบทอด (Inheritance) เป็นกลไกที่ออบเจกต์หนึ่งสามารถสืบทอดองค์ประกอบจากนิยามของอีกออบเจกต์หนึ่งได้ ทำให้ได้รับข้อมูลและพฤติกรรมของออบเจกต์แม่ (parent object) โดยที่คุณไม่ต้องกำหนดขึ้นใหม่ซ้ำอีก

หากภาษาโปรแกรมต้องมีระบบการสืบทอดจึงจะถือว่าเป็นภาษาเชิงวัตถุ Rust ย่อมไม่ได้เป็นภาษานั้น ไม่มีวิธีในการนิยาม struct ให้สืบทอดฟิลด์และการนำเมธอดไปใช้งานของ struct แม่โดยไม่ใช้แมโครได้

อย่างไรก็ตาม หากคุณคุ้นเคยกับการใช้การสืบทอดในชุดเครื่องมือการเขียนโปรแกรม คุณสามารถใช้แนวทางแก้ไขอื่นใน Rust ได้ ขึ้นอยู่กับเหตุผลของคุณในการเลือกใช้การสืบทอดตั้งแต่แรก

คุณจะเลือกใช้การสืบทอดด้วยเหตุผลหลักสองประการ ประการแรกคือเพื่อการใช้โค้ดซ้ำ (reuse of code): คุณสามารถนำพฤติกรรมเฉพาะไปใช้งานกับชนิดข้อมูลหนึ่ง และการสืบทอดช่วยให้คุณนำการทำงานนั้นไปใช้ซ้ำกับชนิดข้อมูลอื่นได้ คุณสามารถทำสิ่งนี้ในรูปแบบจำกัดได้ในโค้ด Rust โดยใช้การนำเมธอดเริ่มต้นของ trait ไปใช้งาน (default trait method implementations) ซึ่งคุณได้เห็นในโค้ดตัวอย่างที่ 10-14 เมื่อเราเพิ่มการนำเมธอด summarize แบบเริ่มต้นไปใช้งานบน trait Summary ชนิดข้อมูลใดๆ ที่นำ trait Summary ไปใช้งานจะมีเมธอด summarize พร้อมให้ใช้งานโดยไม่ต้องเขียนโค้ดเพิ่ม สิ่งนี้คล้ายกับคลาสแม่ที่มีการนำเมธอดไปใช้งาน และคลาสลูกที่สืบทอดมาก็มีการนำเมธอดนั้นไปใช้งานด้วย นอกจากนี้ เรายังสามารถแทนที่ (override) การนำเมธอด summarize แบบเริ่มต้นไปใช้งานเมื่อเรานำ trait Summary ไปใช้งาน ซึ่งคล้ายกับคลาสลูกที่ override การนำเมธอดไปใช้งานที่สืบทอดมาจากคลาสแม่

เหตุผลอีกประการในการใช้การสืบทอดเกี่ยวข้องกับระบบชนิดข้อมูล: เพื่อเปิดโอกาสให้ชนิดข้อมูลลูก (child type) ถูกใช้ในตำแหน่งเดียวกับชนิดข้อมูลแม่ได้ สิ่งนี้ถูกเรียกว่า พหุสัณฐาน (polymorphism) ซึ่งหมายความว่าคุณสามารถใช้ออบเจกต์หลายรูปแบบทดแทนกันได้ในขณะรัน หากพวกมันมีคุณลักษณะบางประการร่วมกัน

พหุสัณฐาน (Polymorphism)

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

ในทางกลับกัน Rust ใช้เจเนอริก (generics) ในการสร้างนามธรรมครอบคลุมชนิดข้อมูลที่เป็นไปได้ต่างๆ และใช้ trait bounds ในการกำหนดข้อจำกัดว่าชนิดข้อมูลเหล่านั้นต้องจัดเตรียมอะไรบ้าง บางครั้งสิ่งนี้ถูกเรียกว่า bounded parametric polymorphism

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

ด้วยเหตุผลเหล่านี้ Rust จึงใช้แนวทางที่แตกต่างออกไปโดยใช้ trait objects แทนการสืบทอดเพื่อให้บรรลุผลพหุสัณฐาน (polymorphism) ในขณะรัน เรามาดูกันว่า trait objects ทำงานอย่างไร

Using Trait Objects to Abstract over Shared Behavior

การใช้ Trait Objects เพื่อสร้างนามธรรมเหนือพฤติกรรมที่ใช้ร่วมกัน

ในบทที่ 8 เราได้กล่าวไว้ว่าข้อจำกัดประการหนึ่งของ vector คือมันสามารถเก็บองค์ประกอบได้เพียงชนิดข้อมูลเดียว เราได้สร้างแนวทางแก้ไขในโค้ดตัวอย่างที่ 8-9 โดยการนิยาม enum SpreadsheetCell ซึ่งมีแวเรียนต์สำหรับเก็บจำนวนเต็ม ทศนิยม และข้อความ สิ่งนี้หมายความว่าเราสามารถเก็บชนิดข้อมูลที่แตกต่างกันในแต่ละเซลล์ และยังคงมี vector ที่แทนแถวของเซลล์ได้ ซึ่งนี่เป็นทางออกที่ดีเยี่ยมเมื่อรายการสับเปลี่ยนของเราเป็นชุดชนิดข้อมูลที่คงที่และเรารู้ล่วงหน้าขณะคอมไพล์โค้ด

อย่างไรก็ตาม ในบางครั้งเราต้องการให้ผู้ใช้ไลบรารีของเราสามารถขยายชุดของชนิดข้อมูลที่ใช้งานได้ในสถานการณ์เฉพาะ เพื่อแสดงว่าเราบรรลุสิ่งนี้ได้อย่างไร เราจะสร้างตัวอย่างเครื่องมืออินเทอร์เฟซผู้ใช้แบบกราฟิก (GUI) ที่วนลูปผ่านรายการข้อมูล โดยเรียกเมธอด draw บนแต่ละรายการเพื่อวาดมันลงบนหน้าจอ—ซึ่งเป็นเทคนิคทั่วไปสำหรับเครื่องมือ GUI เราจะสร้าง crate ไลบรารีชื่อ gui ที่บรรจุโครงสร้างของไลบรารี GUI ซึ่ง crate นี้อาจรวมชนิดข้อมูลบางอย่างเพื่อให้ผู้คนนำไปใช้ เช่น Button หรือ TextField นอกจากนี้ ผู้ใช้ gui จะต้องการสร้างชนิดข้อมูลของตนเองที่สามารถวาดได้ เช่น โปรแกรมเมอร์คนหนึ่งอาจเพิ่ม Image และอีกคนหนึ่งอาจเพิ่ม SelectBox

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

หากจะทำสิ่งนี้ในภาษาที่มีการสืบทอด (inheritance) เราอาจนิยามคลาสชื่อ Component ที่มีเมธอดชื่อ draw อยู่ภายใน คลาสอื่นๆ เช่น Button, Image, และ SelectBox จะสืบทอดมาจาก Component และสืบทอดเมธอด draw มาด้วย พวกมันแต่ละคลาสสามารถแทนที่ (override) เมธอด draw เพื่อนิยามพฤติกรรมที่กำหนดเอง แต่ตัวเฟรมเวิร์กสามารถปฏิบัติต่อชนิดข้อมูลทั้งหมดเหมือนกับว่าเป็นอินสแตนซ์ของ Component และเรียกใช้ draw บนพวกมันได้ แต่เนื่องจาก Rust ไม่มีระบบการสืบทอด เราจึงต้องการวิธีอื่นในการจัดโครงสร้างไลบรารี gui เพื่ออนุญาตให้ผู้ใช้สร้างชนิดข้อมูลใหม่ที่เข้ากันได้กับไลบรารี

การนิยาม Trait สำหรับพฤติกรรมร่วม

เพื่อที่จะนำพฤติกรรมที่เราต้องการให้ gui มีไปใช้งาน เราจะนิยาม trait ชื่อ Draw ซึ่งจะมีเมธอดเดียวชื่อ draw จากนั้นเราสามารถนิยาม vector ที่รับ trait object ได้ โดย trait object จะชี้ไปยังทั้งอินสแตนซ์ของชนิดข้อมูลที่นำ trait ที่ระบุไปใช้งาน และตารางที่ใช้สำหรับค้นหาเมธอดของ trait บนชนิดข้อมูลนั้นในขณะรัน เราสร้าง trait object โดยการระบุพอยน์เตอร์บางประเภท เช่น การอ้างอิง (reference) หรือสมาร์ตพอยน์เตอร์ Box<T> ตามด้วยคีย์เวิร์ด dyn แล้วระบุ trait ที่เกี่ยวข้อง (เราจะพูดถึงเหตุผลที่ trait object ต้องใช้พอยน์เตอร์ในส่วน “Dynamically Sized Types and the Sized Trait” ในบทที่ 20) เราสามารถใช้ trait object แทนชนิดข้อมูลเจเนอริกหรือชนิดข้อมูลรูปธรรมได้ ไม่ว่าเราจะใช้ trait object ที่ใด ระบบชนิดข้อมูลของ Rust จะทำให้แน่ใจในขณะคอมไพล์ว่าค่าใดๆ ที่ใช้ในบริบทนั้นได้นำ trait ของ trait object นั้นไปใช้งานแล้ว ผลก็คือ เราไม่จำเป็นต้องรู้ชนิดข้อมูลที่เป็นไปได้ทั้งหมดในขณะคอมไพล์

เราได้กล่าวไปแล้วว่าใน Rust เราหลีกเลี่ยงที่จะเรียก structs และ enums ว่า “ออบเจกต์” เพื่อแยกแยะออกจากออบเจกต์ในภาษาอื่นๆ ใน struct หรือ enum ข้อมูลในฟิลด์ของ struct และพฤติกรรมในบล็อก impl จะถูกแยกออกจากกัน ในขณะที่ในภาษาอื่น ข้อมูลและพฤติกรรมที่รวมกันเข้าเป็นแนวคิดเดียวมักถูกเรียกว่าออบเจกต์ Trait objects มีความแตกต่างจากออบเจกต์ในภาษาอื่นตรงที่เราไม่สามารถเพิ่มข้อมูลลงใน trait object ได้ Trait objects ไม่ได้มีประโยชน์ทั่วไปเหมือนกับออบเจกต์ในภาษาอื่น: วัตถุประสงค์เฉพาะของพวกมันคือเปิดโอกาสให้สร้างนามธรรมครอบคลุมพฤติกรรมร่วมกัน

โค้ดตัวอย่างที่ 18-3 แสดงวิธีนิยาม trait ชื่อ Draw ที่มีเมธอดเดียวชื่อ draw

pub trait Draw {
    fn draw(&self);
}

ไวยากรณ์นี้น่าจะดูคุ้นเคยจากการอภิปรายของเราเกี่ยวกับการนิยาม trait ในบทที่ 10 ถัดมาคือไวยากรณ์ใหม่: โค้ดตัวอย่างที่ 18-4 นิยาม struct ชื่อ Screen ที่เก็บ vector ชื่อ components โดย vector นี้มีชนิดข้อมูลเป็น Box<dyn Draw> ซึ่งเป็น trait object มันคือตัวแทนสำหรับชนิดข้อมูลใดๆ ภายใน Box ที่นำ trait Draw ไปใช้งาน

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

บน struct Screen เราจะนิยามเมธอดชื่อ run ซึ่งจะเรียกเมธอด draw บนแต่ละรายการใน components ของมัน ดังแสดงในโค้ดตัวอย่างที่ 18-5

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

สิ่งนี้ทำงานแตกต่างจากการนิยาม struct ที่ใช้พารามิเตอร์ชนิดข้อมูลเจเนอริก (generic type parameter) ร่วมกับ trait bounds พารามิเตอร์ชนิดข้อมูลเจเนอริกสามารถแทนที่ด้วยชนิดข้อมูลรูปธรรมได้เพียงชนิดเดียวในแต่ละครั้ง ในขณะที่ trait objects อนุญาตให้ชนิดข้อมูลรูปธรรมหลายประเภทเข้ามาแทนที่ trait object ในขณะรันได้ ตัวอย่างเช่น เราอาจนิยาม struct Screen โดยใช้ชนิดข้อมูลเจเนอริกและ trait bound ดังแสดงในโค้ดตัวอย่างที่ 18-6

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

วิธีนี้จะจำกัดให้เรามีอินสแตนซ์ของ Screen ที่มีรายการของคอมโพเนนต์เป็นชนิดข้อมูล Button ทั้งหมด หรือชนิดข้อมูล TextField ทั้งหมด หากคุณจะมีเฉพาะคอลเลกชันที่เป็นชนิดข้อมูลเดียวกันเสมอ (homogeneous collections) การใช้เจเนอริกและ trait bounds จะเหมาะสมกว่า เนื่องจากนิยามจะถูกเปลี่ยนรูปแปลงร่าง (monomorphized) ในขณะคอมไพล์เพื่อใช้ชนิดข้อมูลรูปธรรม

ในทางกลับกัน วิธีการที่ใช้ trait objects อนุญาตให้หนึ่งอินสแตนซ์ของ Screen เก็บ Vec<T> ที่บรรจุทั้ง Box<Button> และ Box<TextField> ร่วมกันได้ เรามาดูกันว่าสิ่งนี้ทำงานอย่างไร จากนั้นเราจะพูดถึงผลกระทบต่อประสิทธิภาพขณะรัน

การนำ Trait ไปใช้งาน

ตอนนี้เราจะเพิ่มชนิดข้อมูลบางอย่างที่นำ trait Draw ไปใช้งาน เราจะจัดเตรียมชนิดข้อมูล Button อีกครั้ง การสร้างไลบรารี GUI จริงๆ นั้นเกินขอบเขตของหนังสือเล่มนี้ ดังนั้นเมธอด draw จึงยังไม่มีการทำงานที่มีประโยชน์จริงภายในบอร์ดี้ ในการจินตนาการว่าการทำงานเป็นอย่างไร struct Button อาจมีฟิลด์สำหรับ width, height, และ label ดังแสดงในโค้ดตัวอย่างที่ 18-7

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

ฟิลด์ width, height, และ label บน Button จะแตกต่างจากฟิลด์ในคอมโพเนนต์อื่น ตัวอย่างเช่น ชนิดข้อมูล TextField อาจมีฟิลด์เหล่านั้นเหมือนกัน พร้อมด้วยฟิลด์ placeholder เพิ่มเติม แต่ละชนิดข้อมูลที่เราต้องการวาดบนหน้าจอจะนำ trait Draw ไปใช้งาน แต่จะใช้โค้ดที่แตกต่างกันในเมธอด draw เพื่อกำหนดวิธีวาดชนิดข้อมูลเฉพาะนั้น ดังที่ Button ทำในที่นี้ (โดยไม่มีโค้ด GUI จริงตามที่ระบุ) ยกตัวอย่างเช่น ชนิดข้อมูล Button อาจมีบล็อก impl เพิ่มเติมที่บรรจุเมธอดเกี่ยวกับสิ่งที่เกิดขึ้นเมื่อผู้ใช้คลิกปุ่ม ซึ่งเมธอดประเภทนี้จะไม่ประยุกต์ใช้กับชนิดข้อมูลอย่าง TextField

หากผู้ที่ใช้ไลบรารีของเราตัดสินใจสร้าง struct SelectBox ที่มีฟิลด์ width, height, และ options พวกเขาก็จะนำ trait Draw ไปใช้งานกับชนิดข้อมูล SelectBox ด้วยเช่นกัน ดังแสดงในโค้ดตัวอย่างที่ 18-8

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}

ผู้ใช้ไลบรารีของเราสามารถเขียนฟังก์ชัน main เพื่อสร้างอินสแตนซ์ของ Screen ได้แล้ว พวกเขาสามารถเพิ่ม SelectBox และ Button ลงในอินสแตนซ์ของ Screen โดยการนำแต่ละตัวใส่ไว้ใน Box<T> เพื่อกลายเป็น trait object จากนั้นพวกเขาสามารถเรียกใช้เมธอด run บนอินสแตนซ์ของ Screen ซึ่งจะเรียกเมธอด draw บนแต่ละคอมโพเนนต์ โค้ดตัวอย่างที่ 18-9 แสดงการทำงานส่วนนี้

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

เมื่อตอนที่เราเขียนไลบรารี เราไม่รู้เลยว่าจะมีคนเพิ่มชนิดข้อมูล SelectBox แต่การสร้าง Screen ของเราสามารถทำงานกับชนิดข้อมูลใหม่และวาดมันขึ้นมาได้ เพราะว่า SelectBox ได้นำ trait Draw ไปใช้งาน ซึ่งหมายความว่ามันมีเมธอด draw ให้เรียกใช้งาน

แนวคิดนี้—ซึ่งใส่ใจเฉพาะข้อความ (messages) ที่ค่าหนึ่งตอบสนอง มากกว่าการสนใจชนิดข้อมูลรูปธรรมของค่านั้น—คล้ายคลึงกับแนวคิดเรื่อง duck typing ในภาษาแบบไดนามิก: หากมันเดินเหมือนเป็ดและร้องก๊ากๆ เหมือนเป็ด มันก็คงเป็นเป็ด! ในการนำเมธอด run บน Screen ไปใช้งานในโค้ดตัวอย่างที่ 18-5 ตัวเมธอด run ไม่จำเป็นต้องรู้ว่าชนิดข้อมูลรูปธรรมของแต่ละคอมโพเนนต์คืออะไร มันไม่ได้ตรวจสอบว่าคอมโพเนนต์นั้นเป็นอินสแตนซ์ของ Button หรือ SelectBox มันเพียงแค่เรียกใช้เมธอด draw บนคอมโพเนนต์นั้น โดยการระบุ Box<dyn Draw> เป็นชนิดข้อมูลของค่าใน vector components เราได้กำหนดให้ Screen ต้องการค่าที่เราสามารถเรียกใช้เมธอด draw บนพวกมันได้

ข้อดีของการใช้ trait objects และระบบชนิดข้อมูลของ Rust เพื่อเขียนโค้ดที่คล้ายกับการใช้ duck typing คือ เราไม่จำเป็นต้องตรวจสอบในขณะรันว่าค่านั้นนำเมธอดเฉพาะไปใช้งานหรือไม่ หรือต้องกังวลว่าจะมีข้อผิดพลาดเกิดขึ้นหากค่านั้นไม่ได้นำเมธอดไปใช้งานแต่เราเรียกมัน Rust จะคอมไพล์โค้ดไม่ผ่านหากค่านั้นไม่ได้นำ trait ที่ trait objects ต้องการไปใช้งาน

ตัวอย่างเช่น โค้ดตัวอย่างที่ 18-10 แสดงสิ่งที่เกิดขึ้นหากเราพยายามสร้าง Screen โดยมี String เป็นคอมโพเนนต์

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

เราจะได้รับข้อผิดพลาดนี้เนื่องจาก Stringไม่ได้นำ trait Draw ไปใช้งาน:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
  --> src/main.rs:5:26
   |
 5 |         components: vec![Box::new(String::from("Hi"))],
   |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
   |
help: the trait `Draw` is implemented for `Button`
  --> src/lib.rs:23:1
   |
23 | impl Draw for Button {
   | ^^^^^^^^^^^^^^^^^^^^
   = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

ข้อผิดพลาดนี้ทำให้เรารู้ว่า เราอาจกำลังส่งบางอย่างไปยัง Screen โดยไม่ได้ตั้งใจ และควรส่งชนิดข้อมูลอื่นแทน หรือเราควรนำ trait Draw ไปใช้งานบน String เพื่อให้ Screen สามารถเรียกใช้ draw บนมันได้

การดำเนินการ Dynamic Dispatch

ย้อนกลับไปในส่วน “Performance of Code Using Generics” ในบทที่ 10 ที่พูดถึงกระบวนการเปลี่ยนรูปแปลงร่าง (monomorphization) ที่กระทำกับเจเนอริกโดยคอมไพเลอร์: คอมไพเลอร์จะสร้างโค้ดการทำงานแบบนอนเจเนอริก (nongeneric) ของฟังก์ชันและเมธอดสำหรับแต่ละชนิดข้อมูลรูปธรรมที่เราใช้แทนที่พารามิเตอร์ชนิดข้อมูลเจเนอริก โค้ดที่เป็นผลลัพธ์จากการเปลี่ยนรูปแปลงร่างกำลังทำ static dispatch ซึ่งเกิดขึ้นเมื่อคอมไพเลอร์รู้ว่าคุณกำลังเรียกใช้เมธอดใดในขณะคอมไพล์ สิ่งนี้ตรงกันข้ามกับ dynamic dispatch ซึ่งเป็นกรณีที่คอมไพเลอร์ไม่สามารถบอกในขณะคอมไพล์ได้ว่าคุณกำลังเรียกใช้เมธอดใด ในกรณีของ dynamic dispatch คอมไพเลอร์จะปล่อยโค้ดที่ในขณะรันจะรู้ว่าต้องเรียกใช้เมธอดใด

เมื่อเราใช้ trait objects ภาษา Rust จะต้องใช้ dynamic dispatch คอมไพเลอร์ไม่ทราบทุกชนิดข้อมูลที่อาจถูกนำมาใช้กับโค้ดที่ใช้ trait objects ดังนั้นมันจึงไม่ทราบว่าต้องเรียกใช้เมธอดที่ถูกนำไปใช้งานบนชนิดข้อมูลใด แต่ในขณะรัน Rust จะใช้พอยน์เตอร์ภายใน trait object เพื่อดูว่าต้องเรียกใช้เมธอดใด การค้นหาตำแหน่งนี้ทำให้เกิดต้นทุนทางประสิทธิภาพในขณะรัน (runtime cost) ซึ่งจะไม่เกิดขึ้นกับ static dispatch นอกจากนี้ dynamic dispatch ยังขัดขวางไม่ให้คอมไพเลอร์เลือกรวมโค้ดของเมธอดเข้าด้วยกัน (inline) ซึ่งจะส่งผลให้ไม่สามารถทำ optimization บางอย่างได้ และ Rust มีกฎเกณฑ์บางอย่างเกี่ยวกับสถานที่ที่คุณสามารถและไม่สามารถใช้ dynamic dispatch ได้ ซึ่งเรียกว่า dyn compatibility กฎเหล่านั้นเกินขอบเขตของการอภิปรายนี้ แต่คุณสามารถอ่านเพิ่มเติมได้ ในเอกสารอ้างอิง อย่างไรก็ตาม เราได้รับความยืดหยุ่นเพิ่มเติมในโค้ดที่เราเขียนในโค้ดตัวอย่างที่ 18-5 และได้รับการรองรับในโค้ดตัวอย่างที่ 18-9 ดังนั้นนี่จึงเป็นข้อแลกเปลี่ยนที่ต้องพิจารณา

Implementing an Object-Oriented Design Pattern

การนำ Object-Oriented Design Pattern ไปใช้งาน

state pattern เป็นรูปแบบการออกแบบเชิงวัตถุ (object-oriented design pattern) หัวใจสำคัญของรูปแบบนี้คือเรานิยามชุดของสถานะ (states) ที่ค่าหนึ่งๆ สามารถมีได้ภายใน ตัวสถานะต่างๆ ถูกแทนด้วยชุดของ state objects และพฤติกรรมของค่านั้นจะเปลี่ยนแปลงไปตามสถานะของมัน เรากำลังจะทำงานผ่านตัวอย่างของ struct โพสต์บล็อก (blog post) ที่มีฟิลด์สำหรับเก็บสถานะของมัน ซึ่งจะเป็น state object จากชุดสถานะ “ร่าง” (draft), “รอตรวจทาน” (review), หรือ “เผยแพร่แล้ว” (published)

State objects เหล่านี้แชร์ฟังก์ชันการทำงานร่วมกัน: ใน Rust แน่นอนว่าเราใช้ structs และ traits แทนที่จะเป็น objects และ inheritance ตัว state object แต่ละตัวรับผิดชอบพฤติกรรมของตัวเองและกำกับการสลับเข้าสู่สถานะอื่น ค่าที่เก็บ state object ไม่จำเป็นต้องรู้พฤติกรรมที่แตกต่างกันของสถานะต่างๆ หรือรู้ว่าเมื่อใดควรสลับเปลี่ยนระหว่างสถานะ

ข้อดีของการใช้ state pattern คือ เมื่อความต้องการทางธุรกิจของโปรแกรมเปลี่ยนแปลงไป เราไม่จำเป็นต้องแก้ไขโค้ดของค่าที่เก็บสถานะหรือโค้ดที่ใช้นั้น เราเพียงแค่อัปเดตโค้ดภายใน state object ตัวใดตัวหนึ่งเพื่อเปลี่ยนแปลงกฎระเบียบของมัน หรืออาจเพิ่ม state objects เข้าไปอีก

ก่อนอื่น เราจะนำ state pattern ไปใช้งานในสไตล์เชิงวัตถุดั้งเดิมก่อน จากนั้นเราจะใช้วิธีการที่เป็นธรรมชาติมากกว่าใน Rust เรามาเจาะลึกเพื่อสร้างเวิร์กโฟลว์ของโพสต์บล็อกทีละขั้นตอนโดยใช้ state pattern กัน

ฟังก์ชันการทำงานสุดท้ายจะมีลักษณะดังนี้:

  1. โพสต์บล็อกเริ่มต้นเป็นร่าง (draft) ที่ว่างเปล่า
  2. เมื่อร่างเสร็จสิ้น มีการส่งคำขอตรวจทาน (review) โพสต์
  3. เมื่อโพสต์ได้รับการอนุมัติ (approved) โพสต์จะถูกเผยแพร่ (published)
  4. เฉพาะโพสต์บล็อกที่เผยแพร่แล้วเท่านั้นที่จะคืนค่าเนื้อหาออกมาพิมพ์ได้ เพื่อไม่ให้โพสต์ที่ยังไม่อนุมัติถูกเผยแพร่โดยบังเอิญ

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

การทดลองใช้สไตล์เชิงวัตถุแบบดั้งเดิม

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

โค้ดตัวอย่างที่ 18-11 แสดงเวิร์กโฟลว์นี้ในรูปแบบโค้ด: นี่คือตัวอย่างการใช้งาน API ที่เราจะนำไปใช้งานใน crate ไลบรารีชื่อ blog โค้ดนี้จะยังคอมไพล์ไม่ผ่านเนื่องจากเรายังไม่ได้นำ crate blog ไปใช้งาน

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

เราต้องการให้ผู้ใช้สามารถสร้างโพสต์บล็อกร่างใหม่ด้วย Post::new เราต้องการอนุญาตให้เพิ่มข้อความลงในโพสต์บล็อก หากเราพยายามเอาเนื้อหาของโพสต์ทันที ก่อนการอนุมัติ เราไม่ควรได้ข้อความใดๆ ออกมาเนื่องจากโพสต์ยังคงเป็นร่าง เราได้เพิ่ม assert_eq! ในโค้ดเพื่อวัตถุประสงค์ในการสาธิต ยูนิตเทสต์ที่ดีเยี่ยมสำหรับสิ่งนี้คือการยืนยันว่าโพสต์บล็อกร่างจะคืนค่าสตริงว่างจากเมธอด content แต่เราจะไม่เขียนเทสต์สำหรับตัวอย่างนี้

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

โปรดสังเกตว่าชนิดข้อมูลเดียวที่เราปฏิสัมพันธ์ด้วยจาก crate คือชนิดข้อมูล Post ชนิดข้อมูลนี้จะใช้ state pattern และจะเก็บค่าที่จะเป็นหนึ่งในสาม state objects ซึ่งแทนสถานะต่างๆ ที่โพสต์สามารถเป็นได้—draft, review, หรือ published การเปลี่ยนจากสถานะหนึ่งไปยังอีกสถานะหนึ่งจะถูกจัดการภายในชนิดข้อมูล Post สถานะต่างๆ จะเปลี่ยนแปลงเพื่อตอบสนองต่อเมธอดที่เรียกใช้โดยผู้ใช้ไลบรารีของเราบนอินสแตนซ์ของ Post โดยที่พวกเขาไม่ต้องจัดการการเปลี่ยนแปลงสถานะโดยตรง นอกจากนี้ ผู้ใช้ยังไม่สามารถทำข้อผิดพลาดเกี่ยวกับสถานะได้ เช่น การเผยแพร่โพสต์ก่อนที่จะได้รับการตรวจทาน

การนิยาม Post และการสร้างอินสแตนซ์ใหม่

เรามาเริ่มต้นการนำไลบรารีไปใช้งานกัน! เรารู้ว่าเราต้องการ struct Post สาธารณะที่เก็บเนื้อหาบางอย่าง ดังนั้นเราจะเริ่มด้วยคำนิยามของ struct และฟังก์ชันสาธารณะที่เกี่ยวเนื่อง new เพื่อสร้างอินสแตนซ์ของ Post ดังแสดงในโค้ดตัวอย่างที่ 18-12 เรายังจะสร้าง trait ส่วนตัว State ซึ่งจะนิยามพฤติกรรมที่ state objects ทั้งหมดสำหรับ Post ต้องมี

จากนั้น Post จะเก็บ trait object ของ Box<dyn State> ไว้ภายใน Option<T> ในฟิลด์ส่วนตัวชื่อ state เพื่อเก็บ state object คุณจะได้เห็นว่าเหตุใด Option<T> จึงจำเป็นในอีกสักครู่

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

trait State นิยามพฤติกรรมที่แชร์ร่วมกันโดยสถานะต่างๆ ของโพสต์ โดย state objects คือ Draft, PendingReview, และ Published ซึ่งทุกตัวจะนำ trait State ไปใช้งาน สำหรับตอนนี้ trait ยังไม่มีเมธอดใดๆ และเราจะเริ่มด้วยการนิยามเฉพาะสถานะ Draft เพราะนั่นคือสถานะที่เราต้องการให้โพสต์เริ่มต้น

เมื่อเราสร้าง Post ใหม่ เราตั้งค่าฟิลด์ state ของมันเป็นค่า Some ที่เก็บ Box โดย Box นี้ชี้ไปยังอินสแตนซ์ใหม่ของ struct Draft สิ่งนี้เน้นย้ำให้แน่ใจว่าเมื่อใดก็ตามที่เราสร้างอินสแตนซ์ใหม่ของ Post มันจะเริ่มต้นด้วยการเป็นร่าง เนื่องจากฟิลด์ state ของ Post เป็นส่วนตัว จึงไม่มีวิธีสร้าง Post ในสถานะอื่นได้เลย! ในฟังก์ชัน Post::new เราตั้งค่าฟิลด์ content เป็น String ใหม่ที่ว่างเปล่า

การจัดเก็บข้อความเนื้อหาของโพสต์

เราเห็นในโค้ดตัวอย่างที่ 18-11 ว่าเราต้องการเรียกเมธอดชื่อ add_text และส่ง &str ให้มัน ซึ่งจะถูกเพิ่มเข้าไปเป็นเนื้อหาข้อความของโพสต์บล็อก เรานำสิ่งนี้ไปใช้งานในรูปแบบเมธอด แทนที่จะเปิดฟิลด์ content เป็น pub เพื่อที่ในภายหลังเราจะได้สามารถนำเมธอดมาใช้วิธีควบคุมการอ่านข้อมูลของฟิลด์ content เมธอด add_text ค่อนข้างตรงไปตรงมา ดังนั้นเรามาเพิ่มการทำงานในโค้ดตัวอย่างที่ 18-13 ลงในบล็อก impl Post กัน

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

เมธอด add_text รับการอ้างอิงแบบเปลี่ยนค่าได้ (mutable reference) ไปยัง self เพราะเรากำลังเปลี่ยนแปลงอินสแตนซ์ของ Post ที่เราเรียกใช้ add_text จากนั้นเราเรียก push_str บน String ใน content และส่งอาร์กิวเมนต์ text เพื่อเพิ่มเข้าไปใน content ที่บันทึกไว้ พฤติกรรมนี้ไม่ได้ขึ้นอยู่กับสถานะที่โพสต์อยู่ ดังนั้นจึงไม่ได้เป็นส่วนหนึ่งของ state pattern เมธอด add_text ไม่ได้ปฏิสัมพันธ์กับฟิลด์ state เลย แต่มันเป็นส่วนหนึ่งของพฤติกรรมที่เราต้องการรองรับ

การทำให้แน่ใจว่าเนื้อหาของโพสต์ร่างว่างเปล่า

แม้ว่าหลังจากที่เราเรียก add_text และเพิ่มเนื้อหาบางอย่างลงในโพสต์ของเราแล้ว เรายังคงต้องการให้เมธอด content คืนค่าสไลซ์สตริงว่างเปล่า (empty string slice) ออกมาอยู่ดี เนื่องจากโพสต์ยังคงอยู่ในสถานะร่าง ดังแสดงโดย assert_eq! ตัวแรกในโค้ดตัวอย่างที่ 18-11 สำหรับตอนนี้ เรามานำเมธอด content ไปใช้งานด้วยสิ่งที่เรียบง่ายที่สุดที่จะตอบสนองความต้องการนี้ นั่นคือการคืนค่าสไลซ์สตริงว่างเปล่าเสมอ เราจะเปลี่ยนแปลงสิ่งนี้ในภายหลังเมื่อเรานำความสามารถในการเปลี่ยนสถานะของโพสต์ไปใช้งานเพื่อให้มันถูกเผยแพร่ได้ จนถึงตอนนี้ โพสต์สามารถอยู่ในสถานะร่างได้เท่านั้น ดังนั้นเนื้อหาของโพสต์จึงควรว่างเปล่าเสมอ โค้ดตัวอย่างที่ 18-14 แสดงการนำไปใช้งานชั่วคราว (placeholder) นี้

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

เมื่อเพิ่มเมธอด content นี้เข้าไป ทุกอย่างในโค้ดตัวอย่างที่ 18-11 ไปจนถึง assert_eq! ตัวแรกก็ทำงานตามที่ตั้งใจไว้

การส่งคำขอตรวจทาน ซึ่งเปลี่ยนสถานะของโพสต์

ถัดไป เราต้องเพิ่มฟังก์ชันการทำงานเพื่อส่งคำขอตรวจทานโพสต์ ซึ่งควรเปลี่ยนสถานะจาก Draft เป็น PendingReview โค้ดตัวอย่างที่ 18-15 แสดงโค้ดนี้

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

เราให้เมธอดสาธารณะชื่อ request_review แก่ Post ซึ่งจะรับการอ้างอิงแบบเปลี่ยนค่าได้ไปที่ self จากนั้น เราเรียกเมธอด request_review ภายในบนสถานะปัจจุบันของ Post และเมธอด request_review ตัวที่สองนี้จะใช้สถานะปัจจุบันไปและคืนค่าสถานะใหม่กลับมา

เราเพิ่มเมธอด request_review ลงใน trait State ชนิดข้อมูลทั้งหมดที่นำ trait นี้ไปใช้งานจำเป็นต้องนำเมธอด request_review ไปใช้งานด้วย โปรดสังเกตว่าแทนที่จะมี self, &self, หรือ &mut self เป็นพารามิเตอร์แรกของเมธอด เรากลับมี self: Box<Self> ไวยากรณ์นี้หมายความว่าเมธอดจะใช้งานได้ก็ต่อเมื่อถูกเรียกบน Box ที่เก็บชนิดข้อมูลนั้นไว้ ไวยากรณ์นี้รับความเป็นเจ้าของของ Box<Self> ทำให้สถานะเดิมใช้การไม่ได้ เพื่อให้ค่าสถานะของ Post แปลงสภาพเป็นสถานะใหม่ได้

ในการนำสถานะเดิมไปใช้ เมธอด request_review จำเป็นต้องรับความเป็นเจ้าของของค่าสถานะ นั่นคือเหตุผลที่ Option ในฟิลด์ state ของ Post เข้ามามีบทบาท: เราเรียกใช้เมธอด take เพื่อดึงค่า Some ออกมาจากฟิลด์ state และทิ้ง None ไว้แทนที่ เพราะ Rust ไม่ยินยอมให้เรามีฟิลด์ที่ไม่มีค่าอยู่ใน struct สิ่งนี้ช่วยให้เราเคลื่อนย้ายค่า state ออกจาก Post แทนที่จะเป็นการยืมมัน จากนั้น เราจะตั้งค่า state ของโพสต์เป็นผลลัพธ์ของการดำเนินการนี้

เราจำเป็นต้องตั้งค่า state เป็น None ชั่วคราว แทนที่จะตั้งค่ามันโดยตรงด้วยโค้ดอย่าง self.state = self.state.request_review(); เพื่อรับความเป็นเจ้าของของค่า state สิ่งนี้เน้นย้ำให้แน่ใจว่า Post ไม่สามารถใช้ค่า state เก่าหลังจากที่เราแปลงสภาพมันเป็นสถานะใหม่ไปแล้ว

เมธอด request_review บน Draft จะคืนค่าอินสแตนซ์ใหม่ที่อยู่ใน box ของ struct PendingReview ตัวใหม่ ซึ่งแทนสถานะเมื่อโพสต์กำลังรอการตรวจทาน Struct PendingReview ก็นำเมธอด request_review ไปใช้งานเช่นกันแต่ไม่ได้ทำการแปลงสภาพใดๆ แต่จะคืนค่าตัวมันเองกลับมา เพราะเมื่อเราส่งคำขอตรวจทานบนโพสต์ที่อยู่ในสถานะ PendingReview อยู่แล้ว มันควรจะยังคงอยู่ในสถานะ PendingReview ต่อไป

ตอนนี้เราเริ่มเห็นข้อดีของ state pattern แล้ว: เมธอด request_review บน Post จะเหมือนเดิมเสมอไม่ว่าค่า state ของมันจะเป็นอะไรก็ตาม แต่ละสถานะจะรับผิดชอบกฎระเบียบของตนเอง

เราจะละเมิดเมธอด content บน Post ไว้ตามเดิม ให้คืนค่าสไลซ์สตริงว่างเปล่า ตอนนี้เราสามารถมี Post ในสถานะ PendingReview เช่นเดียวกับในสถานะ Draft แต่เราต้องการพฤติกรรมเดียวกันในสถานะ PendingReview โค้ดตัวอย่างที่ 18-11 สามารถทำงานได้ถึงการเรียก assert_eq! ตัวที่สองแล้ว!

การเพิ่ม approve เพื่อเปลี่ยนพฤติกรรมของ content

เมธอด approve จะคล้ายคลึงกับเมธอด request_review: มันจะตั้งค่า state เป็นค่าที่สถานะปัจจุบันบอกว่าควรเป็นอย่างไรเมื่อสถานะนั้นได้รับการอนุมัติ ดังแสดงในโค้ดตัวอย่างที่ 18-16

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

เราเพิ่มเมธอด approve ลงใน trait State และเพิ่ม struct ใหม่ที่นำ State ไปใช้งาน นั่นคือสถานะ Published

คล้ายกับวิธีที่ request_review บน PendingReview ทำงาน หากเราเรียกเมธอด approve บน Draft มันจะไม่มีผลใดๆ เพราะ approve จะคืนค่า self เมื่อเราเรียก approve บน PendingReview มันจะคืนค่าอินสแตนซ์ใหม่ใน box ของ struct Published ตัว struct Published นำ trait State ไปใช้งาน และทั้งเมธอด request_review และเมธอด approve จะคืนค่าตัวมันเองกลับมา เนื่องจากโพสต์ควรคงอยู่ในสถานะ Published ในกรณีเหล่านั้น

ตอนนี้เราต้องอัปเดตเมธอด content บน Post เราต้องการให้ค่าที่คืนจาก content ขึ้นอยู่กับสถานะปัจจุบันของ Post ดังนั้นเราจึงจะให้ Post มอบหมายงานไปยังเมธอด content ที่นิยามไว้บน state ของมัน ดังแสดงในโค้ดตัวอย่างที่ 18-17

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

เพราะเป้าหมายคือการเก็บกฎระเบียบทั้งหมดนี้ไว้ภายใน structs ที่นำ State ไปใช้งาน เราจึงเรียกเมธอด content บนค่าใน state และส่งอินสแตนซ์ของโพสต์ (นั่นคือ self) เป็นอาร์กิวเมนต์ จากนั้น เราคืนค่าที่ได้กลับมาจากการใช้เมธอด content บนค่า state

เราเรียกเมธอด as_ref บน Option เพราะเราต้องการการอ้างอิงไปยังค่าภายใน Option แทนที่จะเป็นความเป็นเจ้าของค่านั้น เนื่องจาก state เป็น Option<Box<dyn State>> เมื่อเราเรียก as_ref จะได้ Option<&Box<dyn State>> คืนกลับมา หากเราไม่เรียก as_ref เราจะพบกับข้อผิดพลาดเพราะเราไม่สามารถเคลื่อนย้าย state ออกจากการอ้างอิง &self ของพารามิเตอร์ฟังก์ชันได้

จากนั้นเราเรียกเมธอด unwrap ซึ่งเรารู้ว่าจะไม่มีทางเกิด panic เนื่องจากเรารู้ว่าเมธอดต่างๆ บน Post รับประกันว่า state จะบรรจุค่า Some เสมอเมื่อเมธอดเหล่านั้นทำงานเสร็จ นี่เป็นหนึ่งในกรณีที่เราอภิปรายในส่วน “When You Have More Information Than the Compiler” ในบทที่ 9 เมื่อเรารู้ว่าค่า None ไม่มีทางเกิดขึ้นได้ แม้ว่าคอมไพเลอร์จะไม่สามารถเข้าใจสิ่งนั้นได้ก็ตาม

ณ จุดนี้ เมื่อเราเรียก content บน &Box<dyn State> กระบวนการ deref coercion จะส่งผลกับ & และ Box เพื่อให้เมธอด content ถูกเรียกใช้งานลงไปถึงชนิดข้อมูลที่นำ trait State ไปใช้งานในท้ายที่สุด นั่นหมายความว่าเราจำเป็นต้องเพิ่ม content ลงในการนิยาม trait State และนั่นคือจุดที่เราจะใส่ตรรกะว่าเนื้อหาใดควรถูกคืนค่าออกมาตามสถานะที่เรามี ดังแสดงในโค้ดตัวอย่างที่ 18-18

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

เราเพิ่มการนำไปใช้งานแบบเริ่มต้นสำหรับเมธอด content ที่จะคืนค่าสไลซ์สตริงว่างเปล่า นั่นหมายความว่าเราไม่จำเป็นต้องนำ content ไปใช้งานบน structs Draft และ PendingReview ตัว struct Published จะทำการ override เมธอด content และคืนค่าที่อยู่ใน post.content แม้จะสะดวกสบาย แต่การให้เมธอด content บน State เป็นตัวกำหนดเนื้อหาของ Post ก็ทำให้เส้นแบ่งระหว่างหน้าที่ความรับผิดชอบของ State กับของ Post เริ่มเลือนรางไป

โปรดสังเกตว่าเราจำเป็นต้องระบุชื่ออายุการใช้งาน (lifetime annotations) บนเมธอดนี้ ดังที่เราอภิปรายในบทที่ 10 เรากำลังรับการอ้างอิงไปยัง post เป็นอาร์กิวเมนต์และคืนการอ้างอิงไปยังส่วนหนึ่งของ post นั้น ดังนั้นอายุการใช้งานของการอ้างอิงที่คืนกลับมาจึงสัมพันธ์กับอายุการใช้งานของอาร์กิวเมนต์ post

และเราก็ทำเสร็จแล้ว—โค้ดทั้งหมดในตัวอย่างที่ 18-11 ทำงานได้แล้วตอนนี้! เราได้นำ state pattern ไปใช้งานตามกฎของเวิร์กโฟลว์โพสต์บล็อก ตรรกะที่เกี่ยวข้องกับกฎระเบียบต่างๆ อาศัยอยู่ใน state objects แทนที่จะกระจัดกระจายอยู่ทั่ว Post

ทำไมไม่ใช้ Enum?

คุณอาจจะสงสัยว่าทำไมเราจึงไม่ใช้ enum ที่มีแวเรียนต์เป็นสถานะต่างๆ ของโพสต์ที่เป็นไปได้ นั่นเป็นแนวทางแก้ไขที่เป็นไปได้แน่นอน! ลองทำและเปรียบเทียบผลลัพธ์สุดท้ายดูว่าคุณชอบแบบไหนมากกว่า! ข้อเสียอย่างหนึ่งของการใช้ enum คือทุกแห่งที่ตรวจสอบค่าของ enum จะต้องใช้พจน์ match หรือที่คล้ายกันเพื่อจัดการกับทุกแวเรียนต์ที่เป็นไปได้ สิ่งนี้อาจกลายเป็นการเขียนซ้ำซ้ำซากมากกว่าแนวทางแก้ไขด้วย trait object นี้

การประเมินผล State Pattern

เราได้แสดงให้เห็นว่า Rust มีความสามารถในการนำ state pattern เชิงวัตถุไปใช้งานเพื่อซ่อนแคปซูลพฤติกรรมประเภทต่างๆ ที่โพสต์ควรมีในแต่ละสถานะ เมธอดบน Post ไม่จำเป็นต้องรู้เกี่ยวกับพฤติกรรมเหล่านั้นเลย เนื่องจากวิธีที่เราจัดระเบียบโค้ด เราจึงต้องดูเพียงแห่งเดียวเพื่อรับรู้พฤติกรรมในแง่มุมต่างๆ ที่โพสต์ที่เผยแพร่แล้วสามารถทำได้: นั่นคือการนำ trait State ไปใช้งานบน struct Published

หากเราสร้างทางเลือกการนำไปใช้งานที่ไม่ได้ใช้ state pattern เราอาจต้องใช้พจน์ match ในเมธอดต่างๆ บน Post หรือแม้แต่ในโค้ด main ที่ตรวจสอบสถานะของโพสต์และเปลี่ยนพฤติกรรม ณ จุดเหล่านั้น นั่นจะหมายความว่าเราต้องดูในหลายแห่งเพื่อทำความเข้าใจผลกระทบทั้งหมดของการที่โพสต์อยู่ในสถานะเผยแพร่แล้ว

ด้วย state pattern เมธอดของ Post และตำแหน่งที่เราใช้ Post ไม่จำเป็นต้องมีพจน์ match และในการเพิ่มสถานะใหม่ เราจะต้องเพิ่ม struct ใหม่และนำเมธอดของ trait ไปใช้งานบน struct นั้นเพียงจุดเดียวเท่านั้น

การนำไปใช้งานโดยใช้ state pattern ขยายเพิ่มเติมเพื่อเพิ่มฟังก์ชันการทำงานได้อย่างง่ายดาย ในการสัมผัสความเรียบง่ายของการดูแลรักษาโค้ดที่ใช้ state pattern ให้ลองทำตามคำแนะนำเหล่านี้:

  • เพิ่มเมธอด reject ที่เปลี่ยนสถานะของโพสต์จาก PendingReview กลับเป็น Draft
  • กำหนดให้ต้องเรียกใช้ approve สองครั้งก่อนที่สถานะจะถูกเปลี่ยนเป็น Published ได้
  • อนุญาตให้ผู้ใช้เพิ่มเนื้อหาข้อความได้เฉพาะเมื่อโพสต์อยู่ในสถานะ Draft เท่านั้น คำแนะนำ: ให้ state object รับผิดชอบสิ่งที่อาจเปลี่ยนแปลงเกี่ยวกับเนื้อหา แต่ไม่ต้องรับผิดชอบการแก้ไข Post

ข้อเสียอย่างหนึ่งของ state pattern คือ เนื่องจากสถานะต่างๆ เป็นผู้นำการสลับเปลี่ยนระหว่างสถานะไปใช้งาน สถานะบางตัวจึงถูกผูกมัด (coupled) เข้าด้วยกัน หากเราเพิ่มอีกสถานะหนึ่งระหว่าง PendingReview กับ Published เช่น Scheduled เราจะต้องเปลี่ยนโค้ดใน PendingReview เพื่อให้เปลี่ยนไปสู่ Scheduled แทน งานจะน้อยลงหาก PendingReview ไม่จำเป็นต้องเปลี่ยนแปลงเมื่อมีการเพิ่มสถานะใหม่ แต่นั่นจะหมายถึงการสลับไปใช้ดีไซน์แพตเทิร์นอื่น

ข้อเสียอีกประการหนึ่งคือเราได้เขียนตรรกะซ้ำซากบางส่วน ในการขจัดความซ้ำซากบางส่วน เราอาจพยายามสร้างการนำไปใช้งานแบบเริ่มต้นสำหรับเมธอด request_review และ approve บน trait State ที่จะคืนค่า self ทว่าสิ่งนี้จะไม่ทำงาน: เมื่อใช้ State เป็น trait object ตัว trait ไม่ทราบว่า self รูปธรรมจะเป็นอะไรอย่างแน่ชัด ดังนั้นชนิดข้อมูลของค่าคืนกลับจึงไม่ทราบในขณะคอมไพล์ (นี่เป็นหนึ่งในกฎ dyn compatibility ที่กล่าวถึงก่อนหน้านี้)

ส่วนซ้ำซากอื่นๆ รวมไปถึงการนำเมธอด request_review และ approve ไปใช้งานที่คล้ายกันบน Post ทั้งสองเมธอดใช้ Option::take กับฟิลด์ state ของ Post และหาก state เป็น Some พวกมันจะมอบหมายไปยังการนำเมธอดเดียวกันไปใช้งานของค่าที่ห่อหุ้มอยู่ และตั้งค่าใหม่ของฟิลด์ state เป็นผลลัพธ์ หากเรามีเมธอดจำนวนมากบน Post ที่ทำตามรูปแบบนี้ เราอาจพิจารณานิยามแมโครเพื่อขจัดความซ้ำซาก (ดูส่วน “Macros” ในบทที่ 20)

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

การเข้ารหัสสถานะและพฤติกรรมให้เป็นชนิดข้อมูล

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

ให้พิจารณาส่วนแรกของ main ในโค้ดตัวอย่างที่ 18-11:

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

เรายังคงเปิดใช้งานการสร้างโพสต์ใหม่ในสถานะร่างโดยใช้ Post::new และความสามารถในการเพิ่มข้อความลงในเนื้อหาของโพสต์ แต่แทนที่จะมีเมธอด content บนโพสต์ร่างที่คืนค่าสตริงว่าง เราจะทำให้โพสต์ร่างไม่มีเมธอด content เลย ด้วยวิธีนี้ หากเราพยายามเอาเนื้อหาของโพสต์ร่าง เราจะได้ข้อผิดพลาดจากคอมไพเลอร์ที่บอกว่าไม่มีเมธอดนี้อยู่ ส่งผลให้เป็นไปไม่ได้เลยที่เราจะแสดงเนื้อหาของโพสต์ร่างในโปรดักชันโดยบังเอิญ เพราะโค้ดนั้นจะไม่สามารถคอมไพล์ผ่านตั้งแต่แรก โค้ดตัวอย่างที่ 18-19 แสดงนิยามของ struct Post และ struct DraftPost รวมถึงเมธอดบนแต่ละตัว

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

ทั้ง struct Post และ DraftPost ต่างมีฟิลด์ส่วนตัว content ที่เก็บข้อความโพสต์บล็อก ตัว structs ไม่มีฟิลด์ state อีกต่อไป เพราะเราได้ย้ายการเข้ารหัสของสถานะไปไว้ที่ชนิดข้อมูลของ structs โดย struct Post จะแทนโพสต์ที่เผยแพร่แล้ว และมีเมธอด content ที่คืนค่า content

เรายังมีฟังก์ชัน Post::new อยู่ แต่แทนที่จะคืนค่าอินสแตนซ์ของ Post มันจะคืนค่าอินสแตนซ์ของ DraftPost เนื่องจาก content เป็นส่วนตัวและไม่มีฟังก์ชันใดที่คืนค่า Post จึงเป็นไปไม่ได้ที่จะสร้างอินสแตนซ์ของ Post ได้ในตอนนี้

struct DraftPost มีเมธอด add_text เราจึงสามารถเพิ่มข้อความลงใน content ได้เหมือนเดิม แต่สังเกตว่า DraftPost ไม่ได้นิยามเมธอด content ไว้! ดังนั้นตอนนี้โปรแกรมจะรับประกันว่าโพสต์ทั้งหมดเริ่มต้นจากการเป็นโพสต์ร่าง และโพสต์ร่างไม่มีเนื้อหาพร้อมให้แสดง การพยายามหลีกเลี่ยงข้อจำกัดเหล่านี้จะส่งผลให้เกิดข้อผิดพลาดจากคอมไพเลอร์

แล้วเราจะรับโพสต์ที่เผยแพร่แล้วได้อย่างไร? เราต้องการบังคับใช้กฎว่าโพสต์ร่างจะต้องได้รับการตรวจทานและอนุมัติก่อนจึงจะเผยแพร่ได้ โพสต์ในสถานะรอการตรวจทานไม่ควรแสดงเนื้อหาใดๆ เช่นกัน เรามานำข้อจำกัดเหล่านี้ไปใช้งานโดยการเพิ่มอีก struct หนึ่งคือ PendingReviewPost โดยนิยามเมธอด request_review บน DraftPost ให้คืนค่า PendingReviewPost และนิยามเมธอด approve บน PendingReviewPost ให้คืนค่า Post ดังแสดงในโค้ดตัวอย่างที่ 18-20

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

เมธอด request_review และ approve รับความเป็นเจ้าของของ self จึงเป็นการกินอินสแตนซ์ DraftPost และ PendingReviewPost ไป และแปลงสภาพพวกมันเป็น PendingReviewPost และ Post ที่เผยแพร่แล้วตามลำดับ ด้วยวิธีนี้ เราจะไม่มีอินสแตนซ์ DraftPost หลงเหลืออยู่หลังจากที่เราเรียกใช้ request_review บนพวกมัน และอื่นๆ ตัว struct PendingReviewPost ไม่ได้นิยามเมธอด content ไว้ ดังนั้นการพยายามอ่านเนื้อหาจะส่งผลให้เกิดข้อผิดพลาดจากคอมไพเลอร์เช่นเดียวกับ DraftPost เนื่องจากวิธีเดียวที่จะได้อินสแตนซ์ Post ที่เผยแพร่แล้วซึ่งมีเมธอด content นิยามไว้ คือการเรียกใช้เมธอด approve บน PendingReviewPost และวิธีเดียวที่จะได้ PendingReviewPost คือการเรียกเมธอด request_review บน DraftPost เราจึงได้เข้ารหัสเวิร์กโฟลว์ของโพสต์บล็อกลงในระบบชนิดข้อมูลเรียบร้อยแล้ว

แต่เราจำเป็นต้องปรับเปลี่ยนเล็กน้อยใน main ด้วย เมธอด request_review และ approve คืนค่าอินสแตนซ์ใหม่แทนที่จะแก้ไข struct ที่พวกมันถูกเรียกใช้ ดังนั้นเราจึงต้องเพิ่มการกำหนดค่า shadowing ด้วย let post = เพื่อบันทึกอินสแตนซ์ที่คืนกลับมา นอกจากนี้ เราไม่สามารถมีการยืนยันเกี่ยวกับเนื้อหาของโพสต์ร่างและโพสต์ที่รอการตรวจทานว่าเป็นสตริงว่างเปล่าได้อีก และเราก็ไม่จำเป็นต้องมีพวกมันอีกต่อไป: เราไม่สามารถคอมไพล์โค้ดที่พยายามใช้เนื้อหาของโพสต์ในสถานะเหล่านั้นได้อีกแล้ว โค้ดที่อัปเดตใน main แสดงไว้ในโค้ดตัวอย่างที่ 18-21

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

การเปลี่ยนแปลงที่เราต้องทำกับ main เพื่อกำหนดค่า post ใหม่ หมายความว่าการนำไปใช้งานนี้ไม่ได้ดำเนินตาม state pattern เชิงวัตถุอย่างแท้จริงอีกต่อไป: การแปลงสภาพระหว่างสถานะไม่ได้ถูกซ่อนแคปซูลอยู่ภายในการสร้าง Post ทั้งหมดอีกแล้ว อย่างไรก็ตาม สิ่งที่เราได้รับกลับมาคือสถานะที่ไม่ถูกต้องกลายเป็นเรื่องที่เป็นไปไม่ได้ เนื่องจากมาจากระบบชนิดข้อมูลและการตรวจสอบชนิดข้อมูลที่เกิดขึ้นในขณะคอมไพล์! สิ่งนี้รับประกันว่าบั๊กบางประเภท เช่น การแสดงเนื้อหาของโพสต์ที่ยังไม่ได้เผยแพร่ จะถูกค้นพบก่อนที่จะหลุดไปถึงโปรดักชัน

ลองทำโจทย์งานที่แนะนำในตอนต้นของหัวข้อนี้กับ crate blog ในสภาพหลังโค้ดตัวอย่างที่ 18-21 เพื่อดูว่าคุณคิดอย่างไรเกี่ยวกับการออกแบบของโค้ดเวอร์ชันนี้ สังเกตว่าบางงานอาจเสร็จสิ้นสมบูรณ์อยู่แล้วในดีไซน์นี้

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

สรุป

ไม่ว่าคุณจะคิดว่า Rust เป็นภาษาเชิงวัตถุหรือไม่หลังจากอ่านบทนี้ ตอนนี้คุณรู้อยู่แล้วว่าคุณสามารถใช้ trait objects เพื่อรับคุณลักษณะเชิงวัตถุบางอย่างใน Rust ได้ การ dispatch แบบไดนามิก (dynamic dispatch) สามารถมอบความยืดหยุ่นให้กับโค้ดของคุณเพื่อแลกกับประสิทธิภาพขณะรันเล็กน้อย คุณสามารถใช้ความยืดหยุ่นนี้เพื่อนับเอาดีไซน์แพตเทิร์นเชิงวัตถุมาใช้อนุเคราะห์การดูแลรักษาโค้ดของคุณ Rust ยังมีคุณลักษณะอื่นๆ เช่น ความเป็นเจ้าของ ที่ภาษาเชิงวัตถุไม่มี รูปแบบเชิงวัตถุจะไม่ใช่ทางที่ดีที่สุดในการดึงเอาจุดแข็งของ Rust มาใช้เสมอไป แต่ก็เป็นตัวเลือกที่มีพร้อมให้ใช้งาน

ถัดไป เราจะดูเรื่องรูปแบบ (patterns) ซึ่งเป็นอีกหนึ่งคุณลักษณะของ Rust ที่ช่วยให้เกิดความยืดหยุ่นอย่างมาก เราได้ดูพวกมันแบบคร่าวๆ มาแล้วตลอดทั้งหนังสือเล่มนี้ แต่ยังไม่ได้เห็นความสามารถอย่างเต็มที่ของพวกมัน มาลุยกันเลย!

รูปแบบและการจับคู่ (Patterns and Matching)

รูปแบบ (Patterns) เป็นไวยากรณ์พิเศษในภาษา Rust สำหรับการจับคู่กับโครงสร้างของชนิดข้อมูล ทั้งชนิดข้อมูลที่ซับซ้อนและเรียบง่าย การใช้รูปแบบร่วมกับพจน์ match และโครงสร้างอื่นๆ ช่วยให้คุณควบคุมลำดับการทำงาน (control flow) ของโปรแกรมได้ดียิ่งขึ้น รูปแบบประกอบด้วยส่วนผสมของสิ่งต่อไปนี้:

  • ค่าตรงตัว (Literals)
  • อาเรย์, enums, structs, หรือ tuples ที่ถูกแยกโครงสร้าง (Destructured)
  • ตัวแปร (Variables)
  • ไวลด์การ์ด (Wildcards)
  • ตัวแทนตำแหน่ง (Placeholders)

ตัวอย่างรูปแบบบางส่วน เช่น x, (a, 3), และ Some(Color::Red) ในบริบทที่รูปแบบสามารถใช้งานได้ องค์ประกอบเหล่านี้จะอธิบายรูปร่าง (shape) ของข้อมูล จากนั้นโปรแกรมของเราจะจับคู่ค่าต่างๆ กับรูปแบบเพื่อพิจารณาว่าข้อมูลนั้นมีรูปร่างที่ถูกต้องสำหรับการทำงานกับส่วนของโค้ดนั้นๆ ต่อไปหรือไม่

ในการใช้รูปแบบ เราจะเปรียบเทียบรูปแบบนั้นกับบางค่า หากรูปแบบจับคู่ตรงกับค่า เราจะนำส่วนต่างๆ ของค่านั้นมาใช้ในโค้ดของเรา ลองนึกถึงพจน์ match ในบทที่ 6 ที่ใช้รูปแบบ เช่น ตัวอย่างเครื่องคัดแยกเหรียญ หากค่าตรงกับรูปร่างของรูปแบบ เราจะสามารถใช้ชิ้นส่วนที่มีชื่อได้ หากไม่ตรง โค้ดที่เกี่ยวข้องกับรูปแบบนั้นจะไม่ถูกทำงาน

บทนี้เป็นคู่มืออ้างอิงเกี่ยวกับทุกสิ่งทุกอย่างที่เกี่ยวข้องกับรูปแบบ เราจะครอบคลุมตำแหน่งที่สามารถใช้รูปแบบได้อย่างถูกต้อง ความแตกต่างระหว่างรูปแบบแบบ refutable (หักล้างได้) และ irrefutable (หักล้างไม่ได้) รวมถึงไวยากรณ์รูปแบบประเภทต่างๆ ที่คุณอาจได้พบเห็น เมื่ออ่านจนจบบทนี้ คุณจะรู้วิธีใช้รูปแบบเพื่อสื่อสารและแสดงแนวคิดต่างๆ ได้อย่างชัดเจน

All the Places Patterns Can Be Used

ตำแหน่งทั้งหมดที่สามารถใช้รูปแบบได้

รูปแบบ (Patterns) ปรากฏขึ้นในหลายๆ ตำแหน่งในภาษา Rust และคุณได้ใช้งานพวกมันมามากมายโดยไม่รู้ตัว! ในหัวข้อนี้จะอธิบายถึงตำแหน่งทั้งหมดที่รูปแบบสามารถใช้งานได้อย่างถูกต้อง

แขนของ match (Arms)

ดังที่ได้อภิปรายไว้ในบทที่ 6 เราใช้รูปแบบในแขน (arms) ของพจน์ match โดยหลักการอย่างเป็นทางการ พจน์ match นิยามด้วยคีย์เวิร์ด match ตามด้วยค่าที่จะนำมาจับคู่ และแขนจับคู่ตั้งแต่หนึ่งแขนขึ้นไป ซึ่งประกอบด้วยรูปแบบและพจน์ที่จะทำงานหากค่านั้นจับคู่ตรงกับรูปแบบของแขนนั้น เช่นนี้:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

ตัวอย่างเช่น นี่คือพจน์ match จากโค้ดตัวอย่างที่ 6-5 ที่จับคู่กับค่า Option<i32> ในตัวแปร x:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

รูปแบบในพจน์ match นี้คือ None และ Some(i) ทางด้านซ้ายของแต่ละเครื่องหมายลูกศร

ข้อกำหนดประการหนึ่งสำหรับพจน์ match คือพวกมันจำเป็นต้องมีความครอบคลุมทุกกรณี (exhaustive) ในแง่ที่ว่าความเป็นไปได้ทั้งหมดสำหรับค่าในพจน์ match จะต้องถูกนับรวมไว้ วิธีหนึ่งในการทำให้แน่ใจว่าคุณได้ครอบคลุมทุกความเป็นไปได้คือการมีรูปแบบรวบตึง (catch-all pattern) สำหรับแขนสุดท้าย: ตัวอย่างเช่น ชื่อตัวแปรที่จับคู่กับค่าใดก็ได้จะไม่มีวันล้มเหลว จึงครอบคลุมทุกกรณีที่เหลืออยู่ได้

รูปแบบเฉพาะอย่าง _ จะจับคู่กับอะไรก็ได้ แต่มันไม่มีวันผูก (bind) เข้ากับตัวแปร ดังนั้นมันจึงมักถูกใช้ในแขนจับคู่สุดท้าย รูปแบบ _ มีประโยชน์เมื่อคุณต้องการละเว้นค่าใดๆ ที่ไม่ได้ระบุไว้ ตัวอย่างเช่น เราจะครอบคลุมรูปแบบ _ โดยละเอียดเพิ่มเติมในส่วน “Ignoring Values in a Pattern” ในบทนี้ในภายหลัง

คำสั่ง let

ก่อนหน้าบทนี้ เราได้อภิปรายอย่างชัดแจ้งเฉพาะการใช้รูปแบบกับ match และ if let เท่านั้น แต่ในความเป็นจริง เราได้ใช้รูปแบบในตำแหน่งอื่นด้วย รวมถึงในคำสั่ง let ตัวอย่างเช่น พิจารณาการกำหนดค่าตัวแปรอย่างตรงไปตรงมาด้วย let:

#![allow(unused)]
fn main() {
let x = 5;
}

ทุกครั้งที่คุณใช้คำสั่ง let แบบนี้ คุณได้ใช้รูปแบบมาโดยตลอด แม้ว่าคุณอาจจะไม่เคยตระหนักถึงมันก็ตาม! โดยหลักการอย่างเป็นทางการ คำสั่ง let มีลักษณะดังนี้:

let PATTERN = EXPRESSION;

ในคำสั่งเช่น let x = 5; ที่มีชื่อตัวแปรอยู่ในช่อง PATTERN ตัวชื่อตัวแปรนั้นเป็นเพียงรูปแบบในรูปอย่างง่ายแบบหนึ่งเท่านั้น Rust จะเปรียบเทียบพจน์กับรูปแบบและกำหนดชื่อใดก็ตามที่มันพบ ดังนั้น ในตัวอย่าง let x = 5; ตัว x คือรูปแบบที่มีความหมายว่า “ผูกสิ่งที่จับคู่ตรงนี้เข้ากับตัวแปร x” เนื่องจากชื่อ x เป็นรูปแบบทั้งหมด รูปแบบนี้จึงหมายถึง “ผูกทุกอย่างเข้ากับตัวแปร x ไม่ว่าค่านั้นจะเป็นอะไรก็ตาม”

เพื่อให้เห็นแง่มุมของการจับคู่รูปแบบของ let ชัดเจนยิ่งขึ้น พิจารณาโค้ดตัวอย่างที่ 19-1 ซึ่งใช้รูปแบบกับ let เพื่อแยกโครงสร้าง (destructure) tuple

fn main() {
    let (x, y, z) = (1, 2, 3);
}

ในที่นี้ เราจับคู่ tuple กับรูปแบบ Rust จะเปรียบเทียบค่า (1, 2, 3) กับรูปแบบ (x, y, z) และเห็นว่าค่านั้นจับคู่ตรงกับรูปแบบ—นั่นคือ มันเห็นว่าจำนวนองค์ประกอบเท่ากันทั้งสองฝั่ง—ดังนั้น Rust จึงผูก 1 เข้ากับ x, 2 เข้ากับ y, และ 3 เข้ากับ z คุณสามารถคิดว่ารูปแบบ tuple นี้เป็นการซ้อนรูปแบบตัวแปรเดี่ยวสามตัวไว้ภายในมัน

หากจำนวนองค์ประกอบในรูปแบบไม่ตรงกับจำนวนองค์ประกอบใน tuple ชนิดข้อมูลโดยรวมจะไม่ตรงกันและเราจะได้รับข้อผิดพลาดจากคอมไพเลอร์ ตัวอย่างเช่น โค้ดตัวอย่างที่ 19-2 แสดงความพยายามแยกโครงสร้าง tuple ที่มีสามองค์ประกอบออกเป็นตัวแปรสองตัว ซึ่งจะไม่สามารถทำงานได้

fn main() {
    let (x, y) = (1, 2, 3);
}

การพยายามคอมไพล์โค้ดนี้จะส่งผลให้เกิดข้อผิดพลาดทางชนิดข้อมูลดังนี้:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

ในการแก้ไขข้อผิดพลาด เราสามารถละเว้นค่าหนึ่งค่าหรือหลายค่าใน tuple ได้โดยใช้ _ หรือ .. ดังที่คุณจะได้เห็นในส่วน “Ignoring Values in a Pattern” หากปัญหาคือเรามีตัวแปรในรูปแบบมากเกินไป ทางแก้คือการทำให้ชนิดข้อมูลตรงกันโดยการลบตัวแปรออก เพื่อให้จำนวนตัวแปรเท่ากับจำนวนองค์ประกอบใน tuple

พจน์เงื่อนไข if let

ในบทที่ 6 เราได้อภิปรายวิธีใช้พจน์ if let เป็นหลักในฐานะวิธีสั้นกว่าในการเขียนสิ่งที่เทียบเท่ากับ match ที่จับคู่เฉพาะกรณีเดียว ซึ่งตัวเลือกเสริมคือ if let สามารถมี else ที่สอดคล้องกันซึ่งบรรจุโค้ดที่จะทำงานหากรูปแบบใน if let ไม่จับคู่ตรงกัน

โค้ดตัวอย่างที่ 19-3 แสดงให้เห็นว่าเป็นไปได้เช่นกันที่จะผสมผสานพจน์ if let, else if, และ else if let การทำเช่นนั้นให้ความยืดหยุ่นแก่เรามากกว่าพจน์ match ซึ่งเราสามารถแสดงค่าได้เพียงค่าเดียวเพื่อเปรียบเทียบกับรูปแบบ นอกจากนี้ Rust ไม่ได้กำหนดให้เงื่อนไขในลำดับของแขน if let, else if, และ else if let ต้องเกี่ยวข้องกัน

โค้ดในโค้ดตัวอย่างที่ 19-3 ทำการกำหนดว่าควรใช้สีใดทำเป็นพื้นหลังตามลำดับการตรวจสอบเงื่อนไขหลายประการ สำหรับตัวอย่างนี้ เราได้สร้างตัวแปรที่มีค่าที่กำหนดไว้ตายตัว (hardcoded values) ซึ่งในโปรแกรมจริงอาจได้รับมาจากอินพุตของผู้ใช้

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}

หากผู้ใช้ระบุสีโปรด สีก็นั้นจะถูกใช้เป็นพื้นหลัง หากไม่ได้ระบุสีโปรดและวันนี้เป็นวันอังคาร สีพื้นหลังจะเป็นสีเขียว มิฉะนั้น หากผู้ใช้ระบุอายุเป็นสตริงและเราสามารถแปลง (parse) เป็นตัวเลขได้สำเร็จ สีกจะเป็นสีม่วงหรือสีส้มขึ้นอยู่กับค่าของตัวเลข หากไม่มีเงื่อนไขใดในเหล่านี้ตรง สีพื้นหลังจะเป็นสีน้ำเงิน

โครงสร้างเงื่อนไขนี้ช่วยให้เรารองรับความต้องการที่ซับซ้อนได้ ด้วยค่าที่กำหนดไว้อย่างตายตัวที่เรามีตรงนี้ ตัวอย่างนี้จะพิมพ์ Using purple as the background color

คุณจะเห็นว่า if let สามารถแนะนำตัวแปรใหม่ที่มาบังตัวแปรที่มีอยู่เดิม (shadowing) ในลักษณะเดียวกับที่แขนของ match ทำได้: บรรทัด if let Ok(age) = age แนะนำตัวแปร age ตัวใหม่ที่บรรจุค่าภายในแวเรียนต์ Ok ซึ่งจะบดบังตัวแปร age ที่มีอยู่เดิม นั่นหมายความว่าเราจำเป็นต้องวางเงื่อนไข if age > 30 ไว้ภายในบล็อกนั้น: เราไม่สามารถรวมสองเงื่อนไขนี้เข้าด้วยกันเป็น if let Ok(age) = age && age > 30 ได้ ตัวแปร age ใหม่ที่เราต้องการนำมาเปรียบเทียบกับ 30 จะยังไม่มีผลจนกว่าขอบเขต (scope) ใหม่จะเริ่มต้นขึ้นด้วยวงเล็บปีกกา

ข้อเสียของการใช้พจน์ if let คือคอมไพเลอร์จะไม่ตรวจสอบความครอบคลุมทุกกรณี (exhaustiveness) ในขณะที่พจน์ match จะตรวจสอบ หากเราละเว้นบล็อก else สุดท้ายไป และทำให้พลาดการจัดการบางกรณี คอมไพเลอร์จะไม่แจ้งเตือนเราถึงบั๊กทางตรรกะที่อาจเกิดขึ้นได้

ลูปเงื่อนไข while let

มีโครงสร้างคล้ายกับ if let ลูปเงื่อนไข while let อนุญาตให้ลูป while ทำงานตราบใดที่รูปแบบยังคงจับคู่ตรงอย่างต่อเนื่อง ในโค้ดตัวอย่างที่ 19-4 เราแสดงลูป while let ที่รอคอยข้อความที่ส่งระหว่าง threads แต่ในกรณีนี้เป็นการตรวจสอบ Result แทนที่จะเป็น Option

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}

ตัวอย่างนี้จะพิมพ์ 1, 2, และ 3 เมธอด recv ดึงข้อความแรกออกจากฝั่งรับข้อความของ channel และคืนค่า Ok(value) เมื่อตอนที่เราเห็น recv ครั้งแรกในบทที่ 16 เราทำการ unwrap ข้อผิดพลาดโดยตรง หรือมีปฏิสัมพันธ์กับมันในฐานะ iterator โดยใช้ลูป for อย่างไรก็ตาม ดังที่โค้ดตัวอย่างที่ 19-4 แสดง เรายังสามารถใช้ while let ได้ด้วย เพราะเมธอด recv คืนค่า Ok ทุกครั้งที่มีข้อความมาถึง ตราบใดที่ฝั่งส่งยังมีตัวตนอยู่ และจะสร้าง Err ออกมาเมื่อฝั่งส่งตัดการเชื่อมต่อ

ลูป for

ในลูป for ค่าที่ตามหลังคีย์เวิร์ด for โดยตรงคือรูปแบบ ตัวอย่างเช่น ใน for x in y ตัว x คือรูปแบบ โค้ดตัวอย่างที่ 19-5 สาธิตวิธีใช้รูปแบบในลูป for เพื่อแยกโครงสร้าง (destructure) หรือแยกชิ้นส่วน tuple ซึ่งเป็นส่วนหนึ่งของลูป for

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}

โค้ดในโค้ดตัวอย่างที่ 19-5 จะพิมพ์สิ่งต่อไปนี้:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

เราดัดแปลง iterator โดยใช้เมธอด enumerate เพื่อให้มันสร้างค่าและดัชนี (index) สำหรับค่านั้น โดยจัดวางไว้ใน tuple ค่าแรกที่ถูกสร้างขึ้นคือ tuple (0, 'a') เมื่อค่านี้นำมาจับคู่กับรูปแบบ (index, value) ตัว index จะเป็น 0 และ value จะเป็น 'a' ซึ่งจะพิมพ์บรรทัดแรกของผลลัพธ์ออกมา

พารามิเตอร์ของฟังก์ชัน

พารามิเตอร์ของฟังก์ชันสามารถเป็นรูปแบบได้เช่นกัน โค้ดในโค้ดตัวอย่างที่ 19-6 ซึ่งประกาศฟังก์ชันชื่อ foo ที่รับพารามิเตอร์หนึ่งตัวชื่อ x ชนิดข้อมูล i32 ควรจะดูคุ้นเคยในตอนนี้

fn foo(x: i32) {
    // code goes here
}

fn main() {}

ส่วน x คือรูปแบบ! เช่นเดียวกับที่เราทำกับ let เราสามารถจับคู่ tuple ในอาร์กิวเมนต์ของฟังก์ชันกับรูปแบบได้ โค้ดตัวอย่างที่ 19-7 แยกค่าใน tuple เมื่อเราส่งมันไปยังฟังก์ชัน

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}

โค้ดนี้จะพิมพ์ Current location: (3, 5) ค่า &(3, 5) จับคู่ตรงกับรูปแบบ &(x, y) ดังนั้น x จึงมีค่าเป็น 3 และ y จึงมีค่าเป็น 5

เรายังสามารถใช้รูปแบบในรายการพารามิเตอร์ของโคลเชอร์ (closure) ในลักษณะเดียวกันกับในรายการพารามิเตอร์ของฟังก์ชันได้ เนื่องจากโคลเชอร์คล้ายคลึงกับฟังก์ชัน ดังที่อภิปรายในบทที่ 13

ณ จุดนี้ คุณได้เห็นวิธีใช้รูปแบบหลากหลายวิธีแล้ว แต่รูปแบบไม่ได้ทำงานเหมือนกันในทุกตำแหน่งที่เราสามารถใช้มันได้ ในบางตำแหน่ง รูปแบบต้องเป็นแบบ irrefutable (หักล้างไม่ได้) ในสถานการณ์อื่น สามารถเป็นแบบ refutable (หักล้างได้) เราจะอภิปรายสองแนวคิดนี้ต่อไป

Refutability: Whether a Pattern Might Fail to Match

Refutability: การที่รูปแบบอาจล้มเหลวในการจับคู่หรือไม่

รูปแบบ (Patterns) มีสองรูปแบบ: refutable (หักล้างได้/ล้มเหลวได้) และ irrefutable (หักล้างไม่ได้/สำเร็จเสมอ) รูปแบบที่จะจับคู่ตรงกับทุกค่าที่เป็นไปได้ซึ่งส่งเข้ามาเรียกว่า irrefutable ตัวอย่างเช่น x ในคำสั่ง let x = 5; เพราะ x จับคู่ตรงกับอะไรก็ได้ ดังนั้นจึงไม่มีวันจับคู่ล้มเหลว ส่วนรูปแบบที่สามารถจับคู่ล้มเหลวสำหรับบางค่าที่เป็นไปได้เรียกว่า refutable ตัวอย่างเช่น Some(x) ในพจน์ if let Some(x) = a_value เพราะหากค่าในตัวแปร a_value เป็น None แทนที่จะเป็น Some รูปแบบ Some(x) ก็จะไม่จับคู่ตรงกัน

พารามิเตอร์ของฟังก์ชัน คำสั่ง let และลูป for จะรับได้เฉพาะรูปแบบที่เป็น irrefutable เท่านั้น เนื่องจากโปรแกรมไม่สามารถทำสิ่งที่มีความหมายใดๆ ได้เมื่อค่าไม่จับคู่ตรงกัน ส่วนพจน์ if let, while let และคำสั่ง let...else จะรับได้ทั้งรูปแบบ refutable และ irrefutable แต่คอมไพเลอร์จะเตือนหากใช้รูปแบบ irrefutable เพราะตามนิยามแล้ว พวกมันมีไว้เพื่อจัดการกับความล้มเหลวที่อาจเกิดขึ้นได้: ฟังก์ชันการทำงานของเงื่อนไขอยู่ที่ความสามารถในการดำเนินการที่แตกต่างกันขึ้นอยู่กับความสำเร็จหรือความล้มเหลว

โดยทั่วไป คุณไม่จำเป็นต้องกังวลเกี่ยวกับความแตกต่างระหว่างรูปแบบ refutable และ irrefutable มากนัก อย่างไรก็ตาม คุณจำเป็นต้องคุ้นเคยกับแนวคิดเรื่อง refutability เพื่อที่คุณจะได้สามารถรับมือและแก้ไขเมื่อเห็นมันในข้อความผิดพลาด ในกรณีเหล่านั้น คุณจะต้องเปลี่ยนรูปแบบหรือเปลี่ยนโครงสร้างที่คุณใช้ร่วมกับรูปแบบนั้น ขึ้นอยู่กับพฤติกรรมที่ตั้งใจไว้ของโค้ด

เรามาดูตัวอย่างสิ่งที่เกิดขึ้นเมื่อเราพยายามใช้รูปแบบ refutable ในตำแหน่งที่ Rust ต้องการรูปแบบ irrefutable และในทางกลับกัน โค้ดตัวอย่างที่ 19-8 แสดงคำสั่ง let แต่สำหรับรูปแบบ เราได้ระบุ Some(x) ซึ่งเป็นรูปแบบ refutable อย่างที่คุณอาจคาดคิดไว้ โค้ดนี้จะไม่สามารถคอมไพล์ได้

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}

หาก some_option_value เป็นค่า None มันจะจับคู่กับรูปแบบ Some(x) ล้มเหลว ซึ่งหมายความว่ารูปแบบนี้เป็นแบบ refutable อย่างไรก็ตาม คำสั่ง let สามารถรับได้เฉพาะรูปแบบที่เป็น irrefutable เท่านั้น เนื่องจากไม่มีสิ่งที่ถูกต้องใดๆ ที่โค้ดจะสามารถทำได้กับค่า None ในขณะคอมไพล์ Rust จะร้องเรียนว่าเราได้พยายามใช้รูปแบบ refutable ในตำแหน่งที่ต้องการรูปแบบ irrefutable:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let...else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

เนื่องจากเราไม่ได้ครอบคลุม (และไม่สามารถครอบคลุมได้!) ทุกค่าที่ถูกต้องด้วยรูปแบบ Some(x) Rust จึงสร้างข้อผิดพลาดจากคอมไพเลอร์ออกมาอย่างถูกต้อง

หากเรามีรูปแบบ refutable ในตำแหน่งที่ต้องการรูปแบบ irrefutable เราสามารถแก้ไขได้โดยการเปลี่ยนโค้ดที่ใช้รูปแบบนั้น: แทนที่จะใช้ let เราสามารถใช้ let...else ได้ จากนั้น หากรูปแบบไม่จับคู่ตรงกัน โค้ดในวงเล็บปีกกาจะทำหน้าที่จัดการค่านั้น โค้ดตัวอย่างที่ 19-9 แสดงวิธีแก้ไขโค้ดในโค้ดตัวอย่างที่ 19-8

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}

เราได้มอบทางออกให้กับโค้ดเรียบร้อยแล้ว! โค้ดนี้ถูกต้องอย่างสมบูรณ์ แม้ว่าจะหมายความว่าเราไม่สามารถใช้รูปแบบ irrefutable โดยไม่ได้รับคำเตือนก็ตาม หากเรามอบรูปแบบที่จะจับคู่ตรงเสมอให้กับ let...else เช่น x ดังแสดงในโค้ดตัวอย่างที่ 19-10 คอมไพเลอร์จะแจ้งเตือน

fn main() {
    let x = 5 else {
        return;
    };
}

Rust จะร้องเรียนว่าไม่มีเหตุผลที่จะใช้ let...else ร่วมกับรูปแบบ irrefutable:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern always matches, so the else clause is unreachable
help: remove this `else` block
 --> src/main.rs:2:20
  |
2 |       let x = 5 else {
  |  ____________________^
3 | |         return;
4 | |     };
  | |_____^
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

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

เมื่อตอนนี้คุณทราบแล้วว่าควรใช้รูปแบบที่ใดบ้าง รวมถึงความแตกต่างระหว่างรูปแบบ refutable และ irrefutable เรามาดูไวยากรณ์ทั้งหมดที่เราสามารถใช้ในการสร้างรูปแบบกัน

Pattern Syntax

ไวยากรณ์รูปแบบ (Pattern Syntax)

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

การจับคู่กับค่าตรงตัว (Matching Literals)

ดังที่คุณได้เห็นในบทที่ 6 คุณสามารถจับคู่รูปแบบกับค่าตรงตัว (literals) โดยตรงได้ โค้ดต่อไปนี้แสดงตัวอย่างบางส่วน:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

โค้ดนี้จะพิมพ์ one เพราะค่าใน x คือ 1 ไวยากรณ์นี้มีประโยชน์เมื่อคุณต้องการให้โค้ดของคุณดำเนินการบางอย่างหากมันได้รับค่ารูปธรรมเฉพาะเจาะจง

การจับคู่กับตัวแปรที่มีชื่อ (Matching Named Variables)

ตัวแปรที่มีชื่อ (Named variables) เป็นรูปแบบประเภท irrefutable ที่จับคู่ตรงกับค่าใดๆ ก็ได้ และเราได้ใช้พวกมันมาหลายครั้งในหนังสือเล่มนี้ อย่างไรก็ตาม มีความซับซ้อนบางประการเมื่อคุณใช้ตัวแปรที่มีชื่อในพจน์ match, if let, หรือ while let เนื่องจากพจน์ประเภทเหล่านี้แต่ละชนิดจะเริ่มต้นขอบเขต (scope) ใหม่ ตัวแปรที่ถูกประกาศเป็นส่วนหนึ่งของรูปแบบภายในพจน์เหล่านี้จึงจะบดบัง (shadow) ตัวแปรที่มีชื่อเดียวกันนอกโครงสร้าง เช่นเดียวกับกรณีของตัวแปรทั้งหมด ในโค้ดตัวอย่างที่ 19-11 เราประกาศตัวแปรชื่อ x มีค่า Some(5) และตัวแปร y มีค่า 10 จากนั้นเราสร้างพจน์ match บนค่า x ให้ดูรูปแบบในแขนของ match และ println! ตอนท้าย แล้วลองคิดว่าโค้ดจะพิมพ์อะไรออกมาก่อนที่จะรันโค้ดหรืออ่านต่อ

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

เรามาไล่ดูสิ่งที่เกิดขึ้นเมื่อพจน์ match ทำงาน รูปแบบในแขนแรกไม่ตรงกับค่าของ x ที่ถูกกำหนดไว้ ดังนั้นโค้ดจึงทำงานต่อ

รูปแบบในแขนที่สองแนะนำตัวแปรใหม่ชื่อ y ที่จะจับคู่กับค่าใดๆ ก็ได้ภายในค่า Some เนื่องจากเราอยู่ในขอบเขตใหม่ภายในพจน์ match ตัวแปรนี้จึงเป็นตัวแปร y ตัวใหม่ ไม่ใช่ y ที่เราประกาศไว้ตอนแรกที่มีค่า 10 การผูก y ใหม่นี้จะจับคู่กับค่าใดก็ได้ภายใน Some ซึ่งก็คือสิ่งที่เรามีใน x ดังนั้น y ใหม่นี้จึงผูกเข้ากับค่าภายในของ Some ใน x ค่านั้นคือ 5 ดังนั้นพจน์สำหรับแขนนั้นจึงทำงานและพิมพ์ Matched, y = 5

หาก x เป็นค่า None แทนที่จะเป็น Some(5) รูปแบบในสองแขนแรกก็จะไม่จับคู่ตรงกัน ดังนั้นค่าจะไปจับคู่ตรงกับเครื่องหมายขีดล่าง (underscore) เราไม่ได้แนะนำตัวแปร x ในรูปแบบของแขนขีดล่าง ดังนั้น x ในพจน์นั้นจึงยังคงเป็น x ตัวนอกที่ไม่ถูกบดบัง ในกรณีสมมตินี้ match จะพิมพ์ Default case, x = None

เมื่อพจน์ match ทำงานเสร็จสิ้น ขอบเขตของมันก็จบลง และขอบเขตของ y ภายในก็จบลงเช่นกัน println! ตัวสุดท้ายจึงพิมพ์ at the end: x = Some(5), y = 10

การสร้างพจน์ match ที่เปรียบเทียบค่าของ x และ y ตัวนอก แทนที่จะเป็นการแนะนำตัวแปรใหม่ที่บดบังตัวแปร y ที่มีอยู่เดิม เราจะต้องใช้เงื่อนไข match guard แทน เราจะพูดถึง match guards ในภายหลังในส่วน “Adding Conditionals with Match Guards”

การจับคู่กับหลายรูปแบบ (Matching Multiple Patterns)

ในพจน์ match คุณสามารถจับคู่กับหลายรูปแบบได้โดยใช้ไวยากรณ์ | ซึ่งเป็นตัวดำเนินการ or ของรูปแบบ ตัวอย่างเช่น ในโค้ดต่อไปนี้ เราจับคู่ค่าของ x กับแขนของ match ซึ่งแขนแรกมีตัวเลือก or หมายความว่าหากค่าของ x จับคู่ตรงกับค่าใดค่าหนึ่งในแขนนั้น โค้ดของแขนนั้นจะทำงาน:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

โค้ดนี้จะพิมพ์ one or two

การจับคู่กับช่วงของค่าด้วย ..=

ไวยากรณ์ ..= อนุญาตให้เราจับคู่กับช่วงของค่าแบบนับรวม (inclusive range) ในโค้ดต่อไปนี้ เมื่อรูปแบบจับคู่ตรงกับค่าใดๆ ภายในช่วงที่กำหนด แขนนั้นจะทำงาน:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

หาก x เป็น 1, 2, 3, 4, หรือ 5 แขนแรกจะจับคู่ตรงกัน ไวยากรณ์นี้สะดวกกว่าสำหรับการจับคู่หลายค่าเมื่อเทียบกับการใช้ตัวดำเนินการ | เพื่อแสดงแนวคิดเดียวกัน หากเราใช้ | เราจะต้องระบุ 1 | 2 | 3 | 4 | 5 การระบุช่วงนั้นสั้นกว่ามาก โดยเฉพาะอย่างยิ่งหากเราต้องการจับคู่ตัวเลขใดๆ ระหว่าง 1 ถึง 1,000!

คอมไพเลอร์จะตรวจสอบว่าช่วงนั้นไม่ได้ว่างเปล่าในขณะคอมไพล์ และเนื่องจากชนิดข้อมูลเดียวที่ Rust สามารถบอกได้ว่าช่วงว่างเปล่าหรือไม่คือ char และค่าตัวเลข ช่วงจึงอนุญาตให้ใช้เฉพาะกับค่าตัวเลขหรือ char เท่านั้น

นี่คือตัวอย่างการใช้ช่วงของค่า char:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust สามารถบอกได้ว่า 'c' อยู่ภายในช่วงของรูปแบบแรก จึงพิมพ์ early ASCII letter

การแยกโครงสร้างเพื่อถอดส่วนประกอบของค่า

เราสามารถใช้รูปแบบเพื่อแยกโครงสร้าง (destructure) structs, enums, และ tuples เพื่อใช้ส่วนประกอบต่างๆ ของค่าเหล่านี้ได้ เรามาไล่ดูทีละชนิดข้อมูลกัน

Structs

โค้ดตัวอย่างที่ 19-12 แสดง struct Point ที่มีสองฟิลด์คือ x และ y ซึ่งเราสามารถแยกชิ้นส่วนได้โดยใช้รูปแบบร่วมกับคำสั่ง let

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}

โค้ดนี้สร้างตัวแปร a และ b ที่จับคู่กับค่าของฟิลด์ x และ y ของ struct p ตัวอย่างนี้แสดงว่าชื่อของตัวแปรในรูปแบบไม่จำเป็นต้องตรงกับชื่อฟิลด์ของ struct อย่างไรก็ตาม เป็นเรื่องปกติที่จะกำหนดชื่อตัวแปรให้ตรงกับชื่อฟิลด์เพื่อให้ง่ายต่อการจดจำว่าตัวแปรใดมาจากฟิลด์ใด เนื่องจากรูปแบบการใช้งานทั่วไปนี้ และเนื่องจากการเขียน let Point { x: x, y: y } = p; มีความซ้ำซาก Rust จึงมีทางลัดสำหรับรูปแบบที่จับคู่กับฟิลด์ของ struct: คุณเพียงระบุชื่อของฟิลด์ใน struct และตัวแปรที่สร้างขึ้นจากรูปแบบจะมีชื่อเดียวกัน โค้ดตัวอย่างที่ 19-13 มีพฤติกรรมในลักษณะเดียวกับโค้ดตัวอย่างที่ 19-12 แต่ตัวแปรที่สร้างขึ้นในรูปแบบ let คือ x และ y แทนที่จะเป็น a และ b

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}

โค้ดนี้สร้างตัวแปร x และ y ที่จับคู่กับฟิลด์ x และ y ของตัวแปร p ผลลัพธ์คือตัวแปร x และ y จะบรรจุค่าจาก struct p

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

ในโค้ดตัวอย่างที่ 19-14 เรามีพจน์ match ที่แยกค่า Point ออกเป็นสามกรณี: จุดที่อยู่บนแกน x โดยตรง (ซึ่งเป็นจริงเมื่อ y = 0), บนแกน y (x = 0), หรือไม่อยู่บนแกนใดเลย

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}

แขนแรกจะจับคู่กับจุดใดๆ ที่อยู่บนแกน x โดยระบุว่าฟิลด์ y จับคู่หากค่าของมันตรงกับค่าตรงตัว 0 รูปแบบยังคงสร้างตัวแปร x ที่เราสามารถใช้ในโค้ดสำหรับแขนนี้ได้

ในทำนองเดียวกัน แขนที่สองจะจับคู่กับจุดใดๆ บนแกน y โดยระบุว่าฟิลด์ x จับคู่หากค่าของมันเป็น 0 และสร้างตัวแปร y สำหรับค่าของฟิลด์ y แขนที่สามไม่ได้ระบุค่าตรงตัวใดๆ จึงจับคู่กับ Point อื่นๆ ทั้งหมด และสร้างตัวแปรสำหรับทั้งฟิลด์ x และ y

ในตัวอย่างนี้ ค่า p จับคู่กับแขนที่สองเนื่องจาก x บรรจุค่า 0 ดังนั้นโค้ดนี้จะพิมพ์ On the y axis at 7

โปรดจำไว้ว่าพจน์ match จะหยุดตรวจสอบแขนทันทีที่พบรูปแบบแรกที่จับคู่ตรงกัน ดังนั้นแม้ว่า Point { x: 0, y: 0 } จะอยู่ทั้งบนแกน x และแกน y โค้ดนี้จะพิมพ์เฉพาะ On the x axis at 0 เท่านั้น

Enums

เราได้แยกโครงสร้าง enums ในหนังสือเล่มนี้มาแล้ว (เช่น โค้ดตัวอย่างที่ 6-5 ในบทที่ 6) แต่เรายังไม่ได้อภิปรายอย่างชัดแจ้งว่ารูปแบบในการแยกโครงสร้าง enum นั้นสอดคล้องกับวิธีที่ข้อมูลภายใน enum ถูกนิยามไว้ ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 19-15 เราใช้ enum Message จากโค้ดตัวอย่างที่ 6-2 และเขียน match ด้วยรูปแบบที่จะแยกโครงสร้างค่าภายในแต่ละค่า

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}

โค้ดนี้จะพิมพ์ Change color to red 0, green 160, and blue 255 ลองเปลี่ยนค่าของ msg เพื่อดูโค้ดจากแขนอื่นๆ ทำงาน

สำหรับแวเรียนต์ของ enum ที่ไม่มีข้อมูล เช่น Message::Quit เราไม่สามารถแยกโครงสร้างค่านั้นเพิ่มเติมได้อีก เราสามารถจับคู่ได้เฉพาะกับค่าตรงตัว Message::Quit เท่านั้น และไม่มีตัวแปรใดๆ ในรูปแบบนั้น

สำหรับแวเรียนต์ของ enum ที่คล้าย struct เช่น Message::Move เราสามารถใช้รูปแบบที่คล้ายกับรูปแบบที่เราระบุเพื่อจับคู่ struct ได้ หลังชื่อแวเรียนต์ เราใส่วงเล็บปีกกาแล้วระบุฟิลด์พร้อมตัวแปร เพื่อให้เราแยกส่วนประกอบออกเป็นชิ้นๆ สำหรับใช้ในโค้ดของแขนนั้น ในที่นี้เราใช้รูปแบบทางลัดตามที่ทำในโค้ดตัวอย่างที่ 19-13

สำหรับแวเรียนต์ของ enum ที่คล้าย tuple เช่น Message::Write ที่เก็บ tuple มีหนึ่งองค์ประกอบ และ Message::ChangeColor ที่เก็บ tuple มีสามองค์ประกอบ รูปแบบจะคล้ายกับรูปแบบที่เราระบุเพื่อจับคู่ tuple จำนวนตัวแปรในรูปแบบต้องตรงกับจำนวนองค์ประกอบในแวเรียนต์ที่เรากำลังจับคู่

Structs และ Enums ซ้อนกัน

จนถึงตอนนี้ ตัวอย่างทั้งหมดของเราเป็นการจับคู่ structs หรือ enums ความลึกระดับเดียว แต่การจับคู่สามารถทำงานกับรายการที่ซ้อนกันได้ด้วย! ตัวอย่างเช่น เราสามารถปรับปรุงโครงสร้าง (refactor) โค้ดในโค้ดตัวอย่างที่ 19-15 เพื่อรองรับสี RGB และ HSV ในข้อความ ChangeColor ดังแสดงในโค้ดตัวอย่างที่ 19-16

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}

รูปแบบของแขนแรกในพจน์ match จับคู่กับแวเรียนต์ enum Message::ChangeColor ที่บรรจุแวเรียนต์ Color::Rgb จากนั้นรูปแบบจะผูกเข้ากับค่า i32 ภายในทั้งสามค่า รูปแบบของแขนที่สองก็นำไปจับคู่กับแวเรียนต์ enum Message::ChangeColor เช่นกัน แต่อนุภาค inner enum จะจับคู่กับ Color::Hsv แทน เราสามารถระบุเงื่อนไขที่ซับซ้อนเหล่านี้ในพจน์ match เดียว แม้ว่าจะมี enums สองตัวเข้ามาเกี่ยวข้องก็ตาม

Structs และ Tuples

เราสามารถผสมผสาน จับคู่ และซ้อนรูปแบบการแยกโครงสร้างในวิธีที่ซับซ้อนยิ่งขึ้นไปอีก ตัวอย่างต่อไปนี้แสดงการแยกโครงสร้างที่ซับซ้อนซึ่งเราซ้อน structs และ tuples ไว้ภายใน tuple และแยกโครงสร้างค่าดั้งเดิม (primitive values) ทั้งหมดออกมา:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

โค้ดนี้ช่วยให้เราแยกชนิดข้อมูลที่ซับซ้อนออกเป็นส่วนประกอบย่อยๆ เพื่อให้เราสามารถใช้ค่าที่เราสนใจแยกจากกันได้

การแยกโครงสร้างด้วยรูปแบบเป็นวิธีที่สะดวกในการใช้ส่วนต่างๆ ของค่า เช่น ค่าจากแต่ละฟิลด์ใน struct โดยแยกออกจากกัน

การละเว้นค่าในรูปแบบ

คุณได้เห็นแล้วว่าบางครั้งการละเว้นค่าในรูปแบบมีประโยชน์ เช่น ในแขนสุดท้ายของ match เพื่อรับแบบรวบตึง (catch-all) ที่ไม่ได้ดำเนินการใดๆ แต่ก็นับรวมความเป็นไปได้ที่เหลืออยู่ทั้งหมด มีหลายวิธีในการละเว้นทั้งค่าหรือบางส่วนของค่าในรูปแบบ: การใช้รูปแบบ _ (ซึ่งคุณได้เห็นแล้ว), การใช้รูปแบบ _ ภายในอีกรูปแบบหนึ่ง, การใช้ชื่อที่เริ่มต้นด้วยเครื่องหมายขีดล่าง, หรือการใช้ .. เพื่อละเว้นส่วนที่เหลือของค่า เรามาสำรวจวิธีและเหตุผลในการใช้แต่ละรูปแบบกัน

ละเว้นทั้งค่าด้วย _

เราได้ใช้เครื่องหมายขีดล่างเป็นรูปแบบไวลด์การ์ดที่จะจับคู่กับค่าใดๆ แต่ไม่ผูกเข้ากับค่านั้น สิ่งนี้มีประโยชน์อย่างยิ่งในฐานะแขนสุดท้ายในพจน์ match แต่เรายังสามารถใช้มันในรูปแบบใดก็ได้ รวมถึงพารามิเตอร์ของฟังก์ชัน ดังแสดงในโค้ดตัวอย่างที่ 19-17

fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}

โค้ดนี้จะละเว้นค่า 3 ที่ส่งมาเป็นอาร์กิวเมนต์แรกอย่างสมบูรณ์ และจะพิมพ์ This code only uses the y parameter: 4

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

ละเว้นบางส่วนของค่าด้วย _ ที่ซ้อนอยู่

เรายังสามารถใช้ _ ภายในอีกรูปแบบหนึ่งเพื่อละเว้นเพียงบางส่วนของค่าได้ ตัวอย่างเช่น เมื่อเราต้องการทดสอบเพียงบางส่วนของค่า แต่ไม่มีประโยชน์สำหรับส่วนอื่นในโค้ดที่สอดคล้องกันที่เราต้องการรัน โค้ดตัวอย่างที่ 19-18 แสดงโค้ดที่รับผิดชอบในการจัดการค่าของตั้งค่า ข้อกำหนดทางธุรกิจคือผู้ใช้ไม่ควรได้รับอนุญาตให้ทับการกำหนดค่าเดิมที่มีอยู่ของตั้งค่า แต่สามารถยกเลิกการตั้งค่าและมอบค่าให้มันได้หากปัจจุบันยังไม่ได้ถูกตั้งค่า

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}

โค้ดนี้จะพิมพ์ Can't overwrite an existing customized value แล้วตามด้วย setting is Some(5) ในแขน match แรก เราไม่จำเป็นต้องจับคู่หรือใช้ค่าภายในแวเรียนต์ Some ตัวใดตัวหนึ่ง แต่เราจำเป็นต้องทดสอบสำหรับกรณีที่ setting_value และ new_setting_value เป็นแวเรียนต์ Some ในกรณีนั้น เราจะพิมพ์เหตุผลในการไม่เปลี่ยน setting_value และมันจะไม่ถูกเปลี่ยน

ในกรณีอื่นๆ ทั้งหมด (หาก setting_value หรือ new_setting_value เป็น None) ซึ่งถูกแสดงโดยรูปแบบ _ ในแขนที่สอง เราต้องการอนุญาตให้ new_setting_value กลายเป็น setting_value

เรายังสามารถใช้ขีดล่างในหลายตำแหน่งภายในรูปแบบเดียวเพื่อละเว้นค่าเฉพาะเจาะจงได้ โค้ดตัวอย่างที่ 19-19 แสดงตัวอย่างการละเว้นค่าที่สองและสี่ใน tuple ที่มีห้าองค์ประกอบ

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}

โค้ดนี้จะพิมพ์ Some numbers: 2, 8, 32 และค่า 4 กับ 16 จะถูกละเว้น

ตัวแปรที่ไม่ถูกใช้งานโดยการเริ่มต้นชื่อด้วย _

หากคุณสร้างตัวแปรแต่ไม่ได้ใช้มันที่ไหนเลย โดยปกติ Rust จะออกคำเตือนเพราะตัวแปรที่ไม่ถูกใช้งานอาจเป็นบั๊กได้ อย่างไรก็ตาม ในบางครั้งมีประโยชน์ที่จะสามารถสร้างตัวแปรที่คุณจะยังไม่ใช้ เช่น เมื่อคุณกำลังทำต้นแบบ (prototyping) หรือเพิ่งเริ่มโปรเจกต์ ในสถานการณ์นี้ คุณสามารถบอก Rust ไม่ให้เตือนคุณเกี่ยวกับตัวแปรที่ไม่ถูกใช้งานโดยการเริ่มต้นชื่อของตัวแปรด้วยขีดล่าง ในโค้ดตัวอย่างที่ 19-20 เราสร้างตัวแปรที่ไม่ถูกใช้งานสองตัว แต่เมื่อเราคอมไพล์โค้ดนี้ เราควรร่วมได้รับคำเตือนเฉพาะเกี่ยวกับตัวแปรตัวเดียวเท่านั้น

fn main() {
    let _x = 5;
    let y = 10;
}

ในที่นี้ เราได้รับคำเตือนเกี่ยวกับการไม่ใช้ตัวแปร y แต่เราไม่ได้รับคำเตือนเกี่ยวกับการไม่ใช้ _x

โปรดสังเกตว่ามีความแตกต่างอย่างแยบยลระหว่างการใช้เพียง _ กับการใช้ชื่อที่เริ่มต้นด้วยขีดล่าง ไวยากรณ์ _x ยังคงผูกค่าเข้ากับตัวแปร ในขณะที่ _ ไม่ผูกเลย ในการแสดงกรณีที่ความแตกต่างนี้มีความสำคัญ โค้ดตัวอย่างที่ 19-21 จะให้ข้อผิดพลาดแก่เรา

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

เราจะได้รับข้อผิดพลาดเนื่องจากค่า s จะยังคงถูกย้ายไปยัง _s ซึ่งป้องกันไม่ให้เราใช้ s อีกครั้ง อย่างไรก็ตาม การใช้ขีดล่างโดยลำพังจะไม่มีวันผูกเข้ากับค่า โค้ดตัวอย่างที่ 19-22 จะคอมไพล์โดยไม่มีข้อผิดพลาดใดๆ เพราะ s ไม่ได้ถูกย้ายลงใน _

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

โค้ดนี้ทำงานได้เป็นอย่างดีเพราะเราไม่มีวันผูก s เข้ากับอะไรเลย มันจึงไม่ถูกย้าย

ส่วนที่เหลือของค่าด้วย ..

กับค่าที่มีหลายส่วน เราสามารถใช้ไวยากรณ์ .. เพื่อใช้เฉพาะส่วนที่สนใจและละเว้นส่วนที่เหลือ ช่วยหลีกเลี่ยงความจำเป็นในการระบุขีดล่างสำหรับแต่ละค่าที่ถูกละเว้น รูปแบบ .. จะละเว้นส่วนใดๆ ของค่าที่เราไม่ได้จับคู่อย่างชัดแจ้งในส่วนที่เหลือของรูปแบบ ในโค้ดตัวอย่างที่ 19-23 เรามี struct Point ที่เก็บพิกัดในพื้นที่สามมิติ ในพจน์ match เราต้องการดำเนินการเฉพาะกับพิกัด x และละเว้นค่าในฟิลด์ y และ z

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}

เราระบุค่า x แล้วเพียงแค่ใส่รูปแบบ .. เข้าไป สิ่งนี้เร็วกว่าการต้องระบุ y: _ และ z: _ โดยเฉพาะเมื่อเราทำงานกับ structs ที่มีฟิลด์จำนวนมากในสถานการณ์ที่มีเพียงหนึ่งหรือสองฟิลด์เท่านั้นที่เกี่ยวข้อง

ไวยากรณ์ .. จะขยายครอบคลุมไปยังค่าจำนวนเท่าใดก็ได้ตามที่ต้องการ โค้ดตัวอย่างที่ 19-24 แสดงวิธีใช้ .. กับ tuple

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}

ในโค้ดนี้ ค่าแรกและค่าสุดท้ายจะถูกจับคู่กับ first และ last ตัว .. จะจับคู่และละเว้นทุกอย่างในตรงกลาง

อย่างไรก็ตาม การใช้ .. ต้องไม่มีความกำกวม (unambiguous) หากไม่ชัดเจนว่าค่าใดตั้งใจไว้สำหรับการจับคู่และค่าใดควรละเว้น Rust จะแจ้งข้อผิดพลาดแก่เรา โค้ดตัวอย่างที่ 19-25 แสดงตัวอย่างการใช้ .. ในวิธีที่กำกวม ดังนั้นจึงไม่สามารถคอมไพล์ได้

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}

เมื่อเราคอมไพล์ตัวอย่างนี้ เราจะได้รับข้อผิดพลาดนี้:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

เป็นไปไม่ได้ที่ Rust จะกำหนดว่าต้องละเว้นกี่ค่าใน tuple ก่อนที่จะจับคู่ค่ากับ second และจากนั้นต้องละเว้นค่าอีกกี่ค่าต่อไป โค้ดนี้อาจหมายความว่าเราต้องการละเว้น 2, ผูก second เข้ากับ 4, และจากนั้นละเว้น 8, 16, และ 32; หรือเราต้องการละเว้น 2 และ 4, ผูก second เข้ากับ 8, และจากนั้นละเว้น 16 และ 32; และอื่น ๆ ชื่อตัวแปร second ไม่ได้มีความหมายพิเศษใดๆ สำหรับ Rust ดังนั้นเราจึงได้รับข้อผิดพลาดจากคอมไพเลอร์ เนื่องจากการใช้ .. ในสองตำแหน่งเช่นนี้มีความกำกวม

การเพิ่มเงื่อนไขด้วย Match Guards

match guard คือเงื่อนไข if เพิ่มเติม ที่ระบุหลังจากรูปแบบในแขนของ match ซึ่งจะต้องจับคู่ตรงกันด้วยเพื่อให้แขนนั้นถูกเลือก Match guards มีประโยชน์สำหรับแสดงแนวคิดที่ซับซ้อนเกินกว่าที่รูปแบบเพียงอย่างเดียวจะอนุญาต อย่างไรก็ตาม โปรดสังเกตว่าพวกมันมีให้ใช้เฉพาะในพจน์ match เท่านั้น ไม่ใช่ในพจน์ if let หรือ while let

เงื่อนไขสามารถใช้ตัวแปรที่สร้างขึ้นในรูปแบบได้ โค้ดตัวอย่างที่ 19-26 แสดง match ที่แขนแรกมีรูปแบบ Some(x) และยังมี match guard เป็น if x % 2 == 0 (ซึ่งจะเป็น true หากตัวเลขเป็นเลขคู่)

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}

ตัวอย่างนี้จะพิมพ์ The number 4 is even เมื่อ num นำมาเปรียบเทียบกับรูปแบบในแขนแรก มันจะจับคู่ตรงเพราะ Some(4) ตรงกับ Some(x) จากนั้น match guard จะตรวจสอบว่าเศษจากการหาร x ด้วย 2 เท่ากับ 0 หรือไม่ และเนื่องจากมันเท่ากับ 0 แขนแรกจึงถูกเลือก

หาก num เป็น Some(5) แทน match guard ในแขนแรกจะเป็น false เพราะเศษจากการหาร 5 ด้วย 2 คือ 1 ซึ่งไม่เท่ากับ 0 จากนั้น Rust จะไปยังแขนที่สอง ซึ่งจะจับคู่ตรงเพราะแขนที่สองไม่มี match guard จึงจับคู่กับแวเรียนต์ Some ใดๆ

ไม่มีวิธีแสดงเงื่อนไข if x % 2 == 0 ภายในรูปแบบได้ ดังนั้น match guard จึงมอบความสามารถในการแสดงตรรกะนี้แก่เรา ข้อเสียของการแสดงออกที่เพิ่มขึ้นนี้คือ คอมไพเลอร์จะไม่พยายามตรวจสอบความครอบคลุมทุกกรณี (exhaustiveness) เมื่อมีพจน์ match guard เข้ามาเกี่ยวข้อง

เมื่ออภิปรายโค้ดตัวอย่างที่ 19-11 เรากล่าวว่าเราสามารถใช้ match guards เพื่อแก้ปัญหา pattern-shadowing ของเราได้ ลองนึกดูว่าเราสร้างตัวแปรใหม่ภายในรูปแบบในพจน์ match แทนที่จะใช้ตัวแปนอก match ตัวแปรใหม่นั้นทำให้เราไม่สามารถทดสอบเปรียบเทียบกับค่าของตัวแปรตัวนอกได้ โค้ดตัวอย่างที่ 19-27 แสดงวิธีที่เราสามารถใช้ match guard เพื่อแก้ไขปัญหานี้

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

ตอนนี้โค้ดนี้จะพิมพ์ Default case, x = Some(5) รูปแบบในแขน match ที่สองไม่ได้แนะนำตัวแปร y ใหม่ที่จะมาบดบัง y ตัวนอก หมายความว่าเราสามารถใช้ y ตัวนอกใน match guard ได้ แทนที่จะระบุรูปแบบเป็น Some(y) ซึ่งจะบดบัง y ตัวนอก เราจะระบุ Some(n) แทน สิ่งนี้สร้างตัวแปรใหม่ n ที่ไม่ได้บดบังอะไรเลย เพราะไม่มีตัวแปร n นอก match

match guard if n == y ไม่ใช่รูปแบบ ดังนั้นจึงไม่ได้แนะนำตัวแปรใหม่ ตัว y นี้ คือ y ตัวนอก แทนที่จะเป็น y ใหม่ที่มาบดบังมัน และเราสามารถมองหาค่าที่มีค่าเดียวกับ y ตัวนอกได้โดยการเปรียบเทียบ n กับ y

คุณยังสามารถใช้ตัวดำเนินการ or | ใน match guard เพื่อระบุหลายรูปแบบ; เงื่อนไข match guard จะส่งผลประยุกต์ใช้กับรูปแบบทั้งหมด โค้ดตัวอย่างที่ 19-28 แสดงลำดับความสำคัญเมื่อรวมรูปแบบที่ใช้ | เข้ากับ match guard ส่วนสำคัญของตัวอย่างนี้คือ match guard if y ส่งผลประยุกต์ใช้กับ 4, 5, และ 6 แม้ว่าจะอาจดูเหมือน if y ส่งผลเฉพาะกับ 6 ก็ตาม

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}

เงื่อนไข match ระบุว่าแขนจะจับคู่ตรงก็ต่อเมื่อค่าของ x เท่ากับ 4, 5, หรือ 6 และ หาก y เป็น true เมื่อโค้ดนี้ทำงาน รูปแบบของแขนแรกจับคู่ตรงเพราะ x คือ 4 แต่ match guard if y เป็น false แขนแรกจึงไม่ถูกเลือก โค้ดจะย้ายไปยังแขนที่สอง ซึ่งจับคู่ตรง และโปรแกรมนี้จะพิมพ์ no เหตุผลคือเงื่อนไข if ส่งผลต่อรูปแบบทั้งหมด 4 | 5 | 6 ไม่ใช่แค่ค่าสุดท้าย 6 กล่าวอีกนัยหนึ่ง ลำดับความสำคัญของ match guard ในความสัมพันธ์กับรูปแบบมีพฤติกรรมเช่นนี้:

(4 | 5 | 6) if y => ...

แทนที่จะเป็นเช่นนี้:

4 | 5 | (6 if y) => ...

หลังจากรันโค้ด พฤติกรรมลำดับความสำคัญก็เห็นได้ชัด: หาก match guard ถูกประยุกต์ใช้เฉพาะกับค่าสุดท้ายในรายการค่าที่ระบุโดยใช้ตัวดำเนินการ | แขนนั้นก็จะจับคู่ตรง และโปรแกรมก็จะพิมพ์ yes

การใช้ @ Bindings

ตัวดำเนินการ at @ ช่วยให้เราสามารถสร้างตัวแปรที่เก็บค่าไปพร้อมๆ กับที่เรากำลังทดสอบค่านั้นสำหรับการจับคู่รูปแบบ ในโค้ดตัวอย่างที่ 19-29 เราต้องการทดสอบว่าฟิลด์ id ของ Message::Hello อยู่ภายในช่วง 3..=7 หรือไม่ เรายังต้องการผูกค่านั้นเข้ากับตัวแปร id เพื่อให้เราสามารถใช้มันในโค้ดที่เกี่ยวข้องกับแขนนั้นได้

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello { id: id @ 3..=7 } => {
            println!("Found an id in range: {id}")
        }
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}

ตัวอย่างนี้จะพิมพ์ Found an id in range: 5 โดยการระบุ id @ ก่อนช่วง 3..=7 เรากำลังจับค่าใดก็ตามที่จับคู่ตรงกับช่วงไว้ในตัวแปรชื่อ id ไปพร้อมกับทดสอบด้วยว่าค่านั้นจับคู่ตรงกับรูปแบบช่วงด้วย

ในแขนที่สอง ซึ่งเรามีเฉพาะช่วงที่ระบุในรูปแบบ โค้ดที่เกี่ยวข้องกับแขนไม่ได้มีตัวแปรที่บรรจุค่าจริงของฟิลด์ id ค่าของฟิลด์ id อาจเป็น 10, 11, หรือ 12 แต่โค้ดที่ไปกับรูปแบบนั้นไม่รู้ว่าเป็นตัวไหน โค้ดรูปแบบไม่สามารถใช้ค่าจากฟิลด์ id ได้เพราะเราไม่ได้บันทึกค่า id ไว้ในตัวแปร

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

การใช้ @ ช่วยให้เราทดสอบค่าและบันทึกไว้ในตัวแปรภายในรูปแบบเดียวได้

สรุป

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

ถัดไป สำหรับบทรองสุดท้ายของหนังสือเล่มนี้ เราจะดูแง่มุมระดับสูงของคุณลักษณะต่างๆ ของ Rust

คุณลักษณะระดับสูง (Advanced Features)

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

ในบทนี้ เราจะครอบคลุม:

  • Unsafe Rust: วิธีการขอถอนตัวจากการรับประกันบางประการของ Rust และรับผิดชอบในการรักษาหลักประกันเหล่านั้นด้วยตนเอง
  • Traits ระดับสูง (Advanced traits): Associated types, default type parameters, fully qualified syntax, supertraits, และ newtype pattern ที่เกี่ยวข้องกับ traits
  • ชนิดข้อมูลระดับสูง (Advanced types): ข้อมูลเพิ่มเติมเกี่ยวกับ newtype pattern, type aliases, never type, และ dynamically sized types
  • ฟังก์ชันและโคลเชอร์ระดับสูง (Advanced functions and closures): Function pointers และการคืนค่า closures
  • แมโคร (Macros): วิธีการนิยามโค้ดที่สร้างโค้ดเพิ่มเติมในขณะคอมไพล์

นี่คือคลังรวมคุณลักษณะของ Rust ที่มีบางสิ่งบางอย่างสำหรับทุกคน! มาเจาะลึกกันเลย!

Unsafe Rust

Unsafe Rust

โค้ดทั้งหมดที่เราได้พูดถึงจนถึงตอนนี้ได้รับการบังคับใช้การรับประกันความปลอดภัยของหน่วยความจำ (memory safety guarantees) ของ Rust ในขณะคอมไพล์ อย่างไรก็ตาม Rust มีภาษาที่สองซ่อนอยู่ภายใน ซึ่งไม่ได้บังคับใช้การรับประกันความปลอดภัยของหน่วยความจำเหล่านี้: สิ่งนี้เรียกว่า unsafe Rust และทำงานเหมือนกับ Rust ปกติ แต่ให้ซูเปอร์เพาเวอร์ (superpowers) เพิ่มเติมแก่เรา

Unsafe Rust มีอยู่เนื่องจากโดยธรรมชาติแล้ว การวิเคราะห์แบบสแตติก (static analysis) มีความระมัดระวังเป็นพิเศษ (conservative) เมื่อคอมไพเลอร์พยายามพิจารณาว่าโค้ดปฏิบัติตามการรับประกันหรือไม่ การปฏิเสธโปรแกรมที่ถูกต้องบางโปรแกรม ย่อมดีกว่าการยอมรับโปรแกรมที่ไม่ถูกต้อง แม้ว่าโค้ด อาจจะ ปกติ แต่หากคอมไพเลอร์ Rust มีข้อมูลไม่เพียงพอที่จะมั่นใจ มันจะปฏิเสธโค้ดนั้น ในกรณีเหล่านี้ คุณสามารถใช้โค้ด unsafe เพื่อบอกคอมไพเลอร์ว่า “เชื่อฉันเถอะ ฉันรู้ว่ากำลังทำอะไรอยู่” อย่างไรก็ตาม ขอเตือนว่าคุณใช้ unsafe Rust ด้วยความเสี่ยงของคุณเอง: หากคุณใช้โค้ด unsafe ไม่ถูกต้อง ปัญหาอาจเกิดขึ้นเนื่องจากความไม่ปลอดภัยของหน่วยความจำ เช่น การเรียกดูค่าพอยน์เตอร์ที่เป็น null (null pointer dereferencing)

อีกเหตุผลหนึ่งที่ Rust มีคู่หูที่เป็น unsafe คือการที่ฮาร์ดแวร์คอมพิวเตอร์เบื้องหลังมีความไม่ปลอดภัยโดยธรรมชาติ หาก Rust ไม่ยอมให้คุณทำปฏิบัติการที่ไม่ปลอดภัย คุณก็จะไม่สามารถทำภารกิจบางอย่างได้ Rust จำเป็นต้องอนุญาตให้คุณทำการเขียนโปรแกรมระบบระดับล่าง (low-level systems programming) เช่น การปฏิสัมพันธ์กับระบบปฏิบัติการโดยตรง หรือแม้กระทั่งการเขียนระบบปฏิบัติการของคุณเอง การทำงานกับการเขียนโปรแกรมระบบระดับล่างเป็นหนึ่งในเป้าหมายของภาษานี้ เรามาสำรวจสิ่งที่เราทำได้ด้วย unsafe Rust และวิธีทำสิ่งเหล่านั้นกัน

การใช้ซูเปอร์เพาเวอร์ Unsafe

ในการสลับไปใช้ unsafe Rust ให้ใช้คีย์เวิร์ด unsafe แล้วเริ่มต้นบล็อกใหม่ที่บรรจุโค้ด unsafe คุณสามารถดำเนินการ 5 อย่างใน unsafe Rust ที่คุณไม่สามารถทำได้ใน safe Rust ซึ่งเราเรียกว่า ซูเปอร์เพาเวอร์ unsafe (unsafe superpowers) ซูเปอร์เพาเวอร์เหล่านั้นรวมถึงความสามารถในการ:

  1. การเรียกดูค่าพอยน์เตอร์ดิบ (Dereference a raw pointer)
  2. การเรียกใช้ฟังก์ชันหรือเมธอดที่เป็น unsafe
  3. การเข้าถึงหรือแก้ไขตัวแปรสแตติกที่เปลี่ยนค่าได้ (mutable static variable)
  4. การนำ trait ที่เป็น unsafe ไปใช้งาน (Implement an unsafe trait)
  5. การเข้าถึงฟิลด์ของ unions

สิ่งสำคัญคือต้องเข้าใจว่า unsafe ไม่ได้ปิดตัวตรวจสอบการยืม (borrow checker) หรือยกเลิกการตรวจสอบความปลอดภัยอื่นๆ ของ Rust: หากคุณใช้การอ้างอิง (reference) ในโค้ด unsafe มันยังคงถูกตรวจสอบอยู่ คีย์เวิร์ด unsafe เพียงให้คุณเข้าถึงฟีเจอร์ 5 อย่างนี้ซึ่งจะไม่ถูกตรวจสอบความปลอดภัยของหน่วยความจำโดยคอมไพเลอร์ คุณยังคงได้รับความปลอดภัยในระดับหนึ่งภายในบล็อก unsafe

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

มนุษย์ย่อมมีความผิดพลาดและข้อผิดพลาดจะเกิดขึ้น แต่โดยการกำหนดให้ปฏิบัติการ unsafe ทั้ง 5 นี้ต้องอยู่ภายในบล็อกที่ใส่คำอธิบายประกอบด้วย unsafe คุณจะรู้ว่าข้อผิดพลาดใดๆ ที่เกี่ยวข้องกับความปลอดภัยของหน่วยความจำต้องอยู่ภายในบล็อก unsafe ควบคุมบล็อก unsafe ให้มีขนาดเล็กไว้; คุณจะขอบคุณตัวเองในภายหลังเมื่อคุณสืบสวนหาบั๊กทางหน่วยความจำ

เพื่อแยกโค้ด unsafe ให้ได้มากที่สุด เป็นการดีที่สุดที่จะห่อหุ้มโค้ดดังกล่าวไว้ภายในโครงสร้างนามธรรมที่ปลอดภัย (safe abstraction) และมอบ API ที่ปลอดภัย ซึ่งเราจะพูดถึงในภายหลังในบทนี้เมื่อเราตรวจสอบฟังก์ชันและเมธอดที่เป็น unsafe บางส่วนของไลบรารีมาตรฐานถูกนำมาใช้งานในฐานะโครงสร้างนามธรรมที่ปลอดภัยเหนือโค้ด unsafe ที่ผ่านการตรวจสอบแล้ว การห่อหุ้มโค้ด unsafe ไว้ในโครงสร้างนามธรรมที่ปลอดภัยช่วยป้องกันไม่ให้การใช้ unsafe รั่วไหลออกไปในทุกที่ที่คุณหรือผู้ใช้ของคุณอาจต้องการใช้ฟังก์ชันการทำงานที่นำไปใช้งานด้วยโค้ด unsafe เพราะการใช้โครงสร้างนามธรรมที่ปลอดภัยนั้นปลอดภัย

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

การเรียกดูค่าพอยน์เตอร์ดิบ

ในบทที่ 4 ในส่วน “Dangling References” เราได้กล่าวไว้ว่าคอมไพเลอร์ทำให้แน่ใจว่าการอ้างอิงมีความถูกต้องเสมอ Unsafe Rust มีชนิดข้อมูลใหม่สองชนิดเรียกว่า พอยน์เตอร์ดิบ (raw pointers) ซึ่งคล้ายกับการอ้างอิง เช่นเดียวกับการอ้างอิง พอยน์เตอร์ดิบสามารถเปลี่ยนค่าไม่ได้หรือเปลี่ยนค่าได้ และเขียนเป็น *const T และ *mut T ตามลำดับ เครื่องหมายดอกจันไม่ใช่ตัวดำเนินการเรียกดูค่า (dereference operator); มันเป็นส่วนหนึ่งของชื่อชนิดข้อมูล ในบริบทของพอยน์เตอร์ดิบ คำว่า เปลี่ยนค่าไม่ได้ หมายความว่าพอยน์เตอร์ไม่สามารถถูกกำหนดค่าโดยตรงได้หลังจากถูก dereferenced แล้ว

ข้อแตกต่างจากการอ้างอิงและสมาร์ตพอยน์เตอร์ พอยน์เตอร์ดิบ:

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

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

โค้ดตัวอย่างที่ 20-1 แสดงวิธีสร้างพอยน์เตอร์ดิบแบบเปลี่ยนค่าไม่ได้และเปลี่ยนค่าได้

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}

โปรดสังเกตว่าเราไม่ได้รวมคีย์เวิร์ด unsafe ไว้ในโค้ดนี้ เราสามารถสร้างพอยน์เตอร์ดิบในโค้ด safe ได้ เพียงแต่เราไม่สามารถ dereference พอยน์เตอร์ดิบนอกบล็อก unsafe ได้ ดังที่คุณจะได้เห็นในอีกสักครู่

เราได้สร้างพอยน์เตอร์ดิบขึ้นโดยใช้ตัวดำเนินการยืมดิบ (raw borrow operators): &raw const num สร้างพอยน์เตอร์ดิบที่เปลี่ยนค่าไม่ได้ *const i32 และ &raw mut num สร้างพอยน์เตอร์ดิบที่เปลี่ยนค่าได้ *mut i32 เนื่องจากเราสร้างพวกมันขึ้นโดยตรงจากตัวแปรท้องถิ่น เราจึงรู้ว่าพอยน์เตอร์ดิบเฉพาะเหล่านี้มีความถูกต้อง แต่เราไม่สามารถตั้งข้อสันนิษฐานแบบนั้นกับพอยน์เตอร์ดิบใดๆ ได้

เพื่อสาธิตสิ่งนี้ ถัดมาเราจะสร้างพอยน์เตอร์ดิบที่เราไม่สามารถแน่ใจในความถูกต้องของมันได้ โดยใช้คีย์เวิร์ด as เพื่อแปลงค่าแทนที่จะใช้ตัวดำเนินการยืมดิบ โค้ดตัวอย่างที่ 20-2 แสดงวิธีสร้างพอยน์เตอร์ดิบไปยังตำแหน่งตามใจชอบในหน่วยความจำ การพยายามใช้หน่วยความจำตามใจชอบคือพฤติกรรมที่ไม่ถูกกำหนดไว้ (undefined behavior): อาจมีข้อมูล ณ ที่อยู่นั้น หรืออาจจะไม่มีก็ได้ คอมไพเลอร์อาจปรับเปลี่ยนโค้ดเพื่อไม่ให้มีการเข้าถึงหน่วยความจำ หรือโปรแกรมอาจยุติการทำงานด้วยข้อผิดพลาด segmentation fault โดยปกติแล้ว ไม่มีเหตุผลที่ดีที่จะเขียนโค้ดเช่นนี้ โดยเฉพาะในกรณีที่คุณสามารถใช้ตัวดำเนินการยืมดิบแทนได้ แต่มันเป็นไปได้

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

จำไว้ว่าเราสามารถสร้างพอยน์เตอร์ดิบในโค้ด safe ได้ แต่เราไม่สามารถ dereference พอยน์เตอร์ดิบและอ่านข้อมูลที่ถูกชี้ไปได้ ในโค้ดตัวอย่างที่ 20-3 เราใช้ตัวดำเนินการ dereference * บนพอยน์เตอร์ดิบซึ่งจำเป็นต้องใช้บล็อก unsafe

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

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

โปรดสังเกตในโค้ดตัวอย่างที่ 20-1 และ 20-3 ว่าเราได้สร้างพอยน์เตอร์ดิบ *const i32 และ *mut i32 ซึ่งทั้งคู่ชี้ไปยังตำแหน่งหน่วยความจำเดียวกันที่ num ถูกจัดเก็บอยู่ หากเราพยายามสร้างการอ้างอิงแบบเปลี่ยนค่าไม่ได้และเปลี่ยนค่าได้ไปยัง num แทน โค้ดจะคอมไพล์ไม่ผ่านเนื่องจากกฎความเป็นเจ้าของของ Rust ไม่อนุญาตให้มีการอ้างอิงแบบเปลี่ยนค่าได้ในเวลาเดียวกับการอ้างอิงแบบเปลี่ยนค่าไม่ได้ สำหรับพอยน์เตอร์ดิบ เราสามารถสร้างพอยน์เตอร์เปลี่ยนค่าได้และพอยน์เตอร์เปลี่ยนค่าไม่ได้ชี้ไปยังตำแหน่งเดียวกัน และเปลี่ยนแปลงข้อมูลผ่านพอยน์เตอร์เปลี่ยนค่าได้ ซึ่งอาจก่อให้เกิดภาวะแย่งชิงข้อมูล (data race) โปรดระมัดระวัง!

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

การเรียกใช้ฟังก์ชันหรือเมธอด Unsafe

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

นี่คือฟังก์ชัน unsafe ชื่อ dangerous ซึ่งไม่ได้ทำอะไรในบอร์ดี้ของมัน:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

เราต้องเรียกใช้ฟังก์ชัน dangerous ภายในบล็อก unsafe ที่แยกต่างหาก หากเราพยายามเรียกใช้ dangerous โดยไม่มีบล็อก unsafe เราจะได้รับข้อผิดพลาด:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

ด้วยบล็อก unsafe เรากำลังยืนยันกับ Rust ว่าเราได้อ่านเอกสารประกอบของฟังก์ชันแล้ว เข้าใจวิธีใช้งานอย่างถูกต้อง และได้ตรวจสอบแล้วว่าเราได้ปฏิบัติตามข้อตกลงของฟังก์ชันเรียบร้อยแล้ว

ในการดำเนินการปฏิบัติการ unsafe ในบอร์ดี้ของฟังก์ชัน unsafe คุณยังคงต้องใช้บล็อก unsafe เช่นเดียวกับภายในฟังก์ชันปกติ และคอมไพเลอร์จะเตือนหากคุณลืม สิ่งนี้ช่วยให้เราควบคุมบล็อก unsafe ให้มีขนาดเล็กที่สุดเท่าที่จะเป็นไปได้ เนื่องจากปฏิบัติการ unsafe อาจไม่จำเป็นต้องใช้ตลอดทั้งบอร์ดี้ฟังก์ชัน

การสร้างโครงสร้างนามธรรมที่ปลอดภัยเหนือโค้ด Unsafe

เพียงเพราะฟังก์ชันบรรจุโค้ด unsafe ไม่ได้หมายความว่าเราจำเป็นต้องทำเครื่องหมายทั้งฟังก์ชันเป็น unsafe ในความเป็นจริง การห่อหุ้มโค้ด unsafe ไว้ในฟังก์ชันที่ปลอดภัยเป็นโครงสร้างนามธรรมทั่วไป ตัวอย่างเช่น เรามาศึกษากระบวนการของฟังก์ชัน split_at_mut จากไลบรารีมาตรฐาน ซึ่งจำเป็นต้องใช้โค้ด unsafe เราจะสำรวจวิธีที่เราอาจนำมันไปใช้งาน เมธอดที่ปลอดภัยนี้ถูกนิยามไว้บน mutable slices: มันรับสไลซ์หนึ่งอันและทำให้มันกลายเป็นสองอันโดยการแบ่งสไลซ์ ณ ดัชนีที่ส่งมาเป็นอาร์กิวเมนต์ โค้ดตัวอย่างที่ 20-4 แสดงวิธีใช้ split_at_mut

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

เราไม่สามารถนำฟังก์ชันนี้ไปใช้งานโดยใช้เฉพาะ safe Rust ได้ ความพยายามอาจมีลักษณะเหมือนโค้ดตัวอย่างที่ 20-5 ซึ่งจะไม่คอมไพล์ เพื่อความเรียบง่าย เราจะนำ split_at_mut ไปใช้งานในฐานะฟังก์ชันแทนที่จะเป็นเมธอด และใช้เฉพาะสำหรับสไลซ์ของค่า i32 แทนที่จะเป็นชนิดข้อมูลเจเนอริก T

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

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

จากนั้น เราคืนค่าสไลซ์แบบเปลี่ยนค่าได้สองอันใน tuple: อันหนึ่งจากจุดเริ่มต้นของสไลซ์ดั้งเดิมจนถึงดัชนี mid และอีกอันจาก mid จนถึงจุดสิ้นสุดของสไลซ์

เมื่อเราพยายามคอมไพล์โค้ดในโค้ดตัวอย่างที่ 20-5 เราจะได้รับข้อผิดพลาด:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

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

โค้ดตัวอย่างที่ 20-6 แสดงวิธีใช้บล็อก unsafe, พอยน์เตอร์ดิบ, และการเรียกใช้ฟังก์ชัน unsafe บางส่วนเพื่อทำให้การนำ split_at_mut ไปใช้งานทำงานได้

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

ย้อนกลับไปจากส่วน “The Slice Type” ในบทที่ 4 สไลซ์คือพอยน์เตอร์ไปยังข้อมูลบางอย่างและความยาวของสไลซ์ เราใช้เมธอด len เพื่อรับความยาวของสไลซ์และเมธอด as_mut_ptr เพื่อเข้าถึงพอยน์เตอร์ดิบของสไลซ์ ในกรณีนี้ เนื่องจากเรามีสไลซ์เปลี่ยนค่าได้ไปยังค่า i32 เมธอด as_mut_ptr จะคืนค่าพอยน์เตอร์ดิบที่มีชนิดข้อมูล *mut i32 ซึ่งเราได้จัดเก็บไว้ในตัวแปร ptr

เราคงการยืนยันว่าดัชนี mid อยู่ภายในสไลซ์ไว้ จากนั้น เราไปถึงโค้ด unsafe: ฟังก์ชัน slice::from_raw_parts_mut รับพอยน์เตอร์ดิบและความยาว และสร้างสไลซ์ขึ้นมา เราใช้ฟังก์ชันนี้เพื่อสร้างสไลซ์ที่เริ่มต้นจาก ptr และมีความยาวเท่ากับ mid รายการ จากนั้น เราเรียกเมธอด add บน ptr ด้วย mid เป็นอาร์กิวเมนต์เพื่อรับพอยน์เตอร์ดิบที่เริ่มต้นที่ mid และเราสร้างสไลซ์โดยใช้พอยน์เตอร์นั้นและจำนวนรายการที่เหลือหลังจาก mid เป็นความยาว

ฟังก์ชัน slice::from_raw_parts_mut เป็น unsafe เนื่องจากรับพอยน์เตอร์ดิบและต้องวางใจว่าพอยน์เตอร์นี้มีความถูกต้อง เมธอด add บนพอยน์เตอร์ดิบก็นำไปใช้เป็น unsafe เช่นกันเนื่องจากต้องวางใจว่าตำแหน่งออฟเซ็ตเป็นพอยน์เตอร์ที่ถูกต้องด้วย ดังนั้น เราจึงต้องวางบล็อก unsafe ล้อมรอบการเรียกใช้ slice::from_raw_parts_mut และ add เพื่อให้เราเรียกใช้พวกมันได้ โดยการดูที่โค้ดและการเพิ่มการยืนยันว่า mid ต้องน้อยกว่าหรือเท่ากับ len เราสามารถบอกได้ว่าพอยน์เตอร์ดิบทั้งหมดที่ใช้ภายในบล็อก unsafe จะเป็นพอยน์เตอร์ที่ถูกต้องไปยังข้อมูลภายในสไลซ์ นี่เป็นการใช้ unsafe ที่ยอมรับได้และเหมาะสม

สังเกตว่าเราไม่จำเป็นต้องทำเครื่องหมายฟังก์ชัน split_at_mut ที่ได้เป็น unsafe และเราสามารถเรียกใช้ฟังก์ชันนี้จาก safe Rust ได้ เราได้สร้างโครงสร้างนามธรรมที่ปลอดภัยขึ้นปกคลุมโค้ด unsafe ด้วยการนำฟังก์ชันไปใช้งานที่ใช้โค้ด unsafe ในทางที่ปลอดภัย เนื่องจากมันสร้างเฉพาะพอยน์เตอร์ที่ถูกต้องจากข้อมูลที่ฟังก์ชันนี้มีสิทธิ์เข้าถึง

ในทางกลับกัน การใช้ slice::from_raw_parts_mut ในโค้ดตัวอย่างที่ 20-7 น่าจะทำให้โปรแกรมค้าง (crash) เมื่อใช้สไลซ์ โค้ดนี้รับตำแหน่งหน่วยความจำตามใจชอบและสร้างสไลซ์ที่มีความยาว 10,000 รายการ

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

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

การใช้ฟังก์ชัน extern เพื่อเรียกใช้โค้ดภายนอก

บางครั้งโค้ด Rust ของคุณอาจจำเป็นต้องมีปฏิสัมพันธ์กับโค้ดที่เขียนด้วยภาษาอื่น สำหรับสิ่งนี้ Rust มีคีย์เวิร์ด extern ซึ่งช่วยความสะดวกในการสร้างและใช้งาน Foreign Function Interface (FFI) ซึ่งเป็นวิธีสำหรับภาษาโปรแกรมในการนิยามฟังก์ชันและอนุญาตให้ภาษาโปรแกรมอื่น (ต่างประเทศ) สามารถเรียกใช้ฟังก์ชันเหล่านั้นได้

โค้ดตัวอย่างที่ 20-8 สาธิตวิธีตั้งค่าการบูรณาการกับฟังก์ชัน abs จากไลบรารีมาตรฐาน C ฟังก์ชันที่ประกาศไว้ภายในบล็อก extern โดยทั่วไปไม่ปลอดภัยที่จะเรียกใช้จากโค้ด Rust ดังนั้นบล็อก extern จึงต้องถูกทำเครื่องหมายเป็น unsafe ด้วย เหตุผลคือภาษาอื่นไม่ได้บังคับใช้กฎและการรับประกันของ Rust และ Rust ไม่สามารถตรวจสอบพวกมันได้ ดังนั้นความรับผิดชอบจึงตกอยู่กับโปรแกรมเมอร์ในการรับประกันความปลอดภัย

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

ภายในบล็อก unsafe extern "C" เราจะรายการชื่อและลายเซ็นของฟังก์ชันภายนอกจากภาษาอื่นที่เราต้องการเรียกใช้ ส่วน "C" จะนิยามว่าฟังก์ชันภายนอกใช้ application binary interface (ABI) ใด: โดย ABI จะนิยามวิธีเรียกใช้ฟังก์ชันในระดับภาษาแอสเซมบลี "C" ABI เป็นภาษาที่พบบ่อยที่สุดและปฏิบัติตาม ABI ของภาษาโปรแกรม C ข้อมูลเกี่ยวกับ ABI ทั้งหมดที่ Rust รองรับมีอยู่ใน the Rust Reference

ทุกรายการที่ประกาศภายในบล็อก unsafe extern เป็น unsafe โดยนัย อย่างไรก็ตาม ฟังก์ชัน FFI บางอย่าง ก็ปลอดภัย ที่จะเรียกใช้ ตัวอย่างเช่น ฟังก์ชัน abs จากไลบรารีมาตรฐานของ C ไม่มีการพิจารณาเรื่องความปลอดภัยของหน่วยความจำใดๆ และเรารู้ว่าสามารถเรียกใช้ด้วย i32 ใดก็ได้ ในกรณีแบบนี้ เราสามารถใช้คีย์เวิร์ด safe เพื่อบอกว่าฟังก์ชันเฉพาะนี้ปลอดภัยที่จะเรียกใช้ แม้ว่าจะอยู่ในบล็อก unsafe extern ก็ตาม เมื่อเราทำการเปลี่ยนแปลงนั้น การเรียกใช้ก็ไม่จำเป็นต้องใช้บล็อก unsafe อีกต่อไป ดังแสดงในโค้ดตัวอย่างที่ 20-9

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}

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

การเรียกใช้ฟังก์ชัน Rust จากภาษาอื่น

เรายังสามารถใช้ extern เพื่อสร้างอินเทอร์เฟซที่อนุญาตให้ภาษาอื่นเรียกใช้ฟังก์ชัน Rust ได้ด้วย แทนที่จะสร้างบล็อก extern ทั้งบล็อก เราเพิ่มคีย์เวิร์ด extern และระบุ ABI ที่จะใช้ล่วงหน้าก่อนคีย์เวิร์ด fn สำหรับฟังก์ชันที่เกี่ยวข้อง นอกจากนี้ เราจำเป็นต้องเพิ่มแอตทริบิวต์ #[unsafe(no_mangle)] เพื่อบอกคอมไพเลอร์ Rust ไม่ให้แต่งเติมชื่อ (mangle) ของฟังก์ชันนี้ การแต่งเติมชื่อ (Mangling) คือการที่คอมไพเลอร์เปลี่ยนชื่อที่เรามอบให้กับฟังก์ชันไปเป็นชื่ออื่นที่บรรจุข้อมูลเพิ่มเติมสำหรับส่วนอื่นของกระบวนการคอมไพล์เพื่อนำไปใช้ แต่มีความสามารถในการอ่านโดยมนุษย์น้อยลง คอมไพเลอร์ภาษาโปรแกรมทุกภาษาจะแต่งเติมชื่อแตกต่างกันเล็กน้อย ดังนั้นเพื่อให้ฟังก์ชัน Rust สามารถตั้งชื่อโดยภาษาอื่นได้ เราจึงต้องปิดการแต่งเติมชื่อของคอมไพเลอร์ Rust สิ่งนี้เป็น unsafe เพราะอาจมีการชนกันของชื่อ (name collisions) ข้ามไลบรารีโดยไม่มีการแต่งเติมชื่อในตัว ดังนั้นจึงเป็นความรับผิดชอบของเราในการทำให้แน่ใจว่าชื่อที่เราเลือกนั้นปลอดภัยที่จะส่งออกโดยไม่แต่งเติมชื่อ

ในตัวอย่างต่อไปนี้ เราทำให้ฟังก์ชัน call_from_c เข้าถึงได้จากโค้ด C หลังจากที่มันถูกคอมไพล์เป็นไลบรารีที่แชร์และลิงก์จาก C:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

การใช้ extern นี้ต้องการ unsafe เฉพาะในแอตทริบิวต์เท่านั้น ไม่ใช่บนบล็อก extern

การเข้าถึงหรือแก้ไขตัวแปรสแตติกที่เปลี่ยนค่าได้

ในหนังสือเล่มนี้ เรายังไม่ได้พูดถึงตัวแปรโกลบอล (global variables) ซึ่ง Rust รองรับแต่ว่าอาจมีปัญหาเกี่ยวกับกฎความเป็นเจ้าของของ Rust หากสอง threads เข้าถึงตัวแปรโกลบอลเปลี่ยนค่าได้ตัวเดียวกัน มันอาจก่อให้เกิดภาวะแย่งชิงข้อมูล (data race) ได้

ใน Rust ตัวแปรโกลบอลเรียกว่าตัวแปร static โค้ดตัวอย่างที่ 20-10 แสดงตัวอย่างการประกาศและการใช้ตัวแปร static โดยมีสไลซ์สตริงเป็นค่า

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("value is: {HELLO_WORLD}");
}

ตัวแปร static มีความคล้ายคลึงกับค่าคงที่ (constants) ซึ่งเราอภิปรายในส่วน “Declaring Constants” ในบทที่ 3 ชื่อของตัวแปร static จะเป็นตัวพิมพ์ใหญ่แบบ snake case (SCREAMING_SNAKE_CASE) ตามธรรมเนียม ตัวแปร static สามารถจัดเก็บเฉพาะการอ้างอิงที่มีอายุการใช้งาน 'static เท่านั้น ซึ่งหมายความว่าคอมไพเลอร์ Rust สามารถคำนวณอายุการใช้งานได้และเราไม่จำเป็นต้องระบุชื่ออย่างชัดแจ้ง การเข้าถึงตัวแปร static ที่เปลี่ยนค่าไม่ได้นั้นปลอดภัย

ข้อแตกต่างเล็กน้อยระหว่างค่าคงที่และตัวแปร static ที่เปลี่ยนค่าไม่ได้คือ ค่าในตัวแปร static จะมีที่อยู่ที่แน่นอนในหน่วยความจำ การใช้ค่าจะเข้าถึงข้อมูลเดิมเสมอ ในทางกลับกัน ค่าคงที่ได้รับอนุญาตให้คัดลอกซ้ำข้อมูลของมันเมื่อใดก็ตามที่ถูกใช้งาน อีกข้อแตกต่างหนึ่งคือตัวแปร static สามารถเปลี่ยนค่าได้ (mutable) การเข้าถึงและการแก้ไขตัวแปร static ที่เปลี่ยนค่าได้นั้นเป็น unsafe โค้ดตัวอย่างที่ 20-11 แสดงวิธีประกาศ เข้าถึง และแก้ไขตัวแปร static ที่เปลี่ยนค่าได้ชื่อ COUNTER

static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}

เช่นเดียวกับตัวแปรปกติ เราระบุความสามารถในการเปลี่ยนค่าโดยใช้คีย์เวิร์ด mut โค้ดใดๆ ที่อ่านหรือเขียนจาก COUNTER ต้องอยู่ภายในบล็อก unsafe โค้ดในโค้ดตัวอย่างที่ 20-11 คอมไพล์และพิมพ์ COUNTER: 3 ตามที่เราคาดหวังเพราะเป็นแบบ single-threaded การให้หลาย thread เข้าถึง COUNTER น่าจะส่งผลให้เกิดภาวะแย่งชิงข้อมูล ดังนั้นจึงเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้ (undefined behavior) ดังนั้น เราจึงจำเป็นต้องทำเครื่องหมายทั้งฟังก์ชันเป็น unsafe และบันทึกข้อจำกัดด้านความปลอดภัยไว้ในเอกสาร เพื่อให้ใครก็ตามที่เรียกใช้ฟังก์ชันรู้ว่าสิ่งใดอนุญาตและไม่อนุญาตให้ทำได้อย่างปลอดภัย

เมื่อใดก็ตามที่เราเขียนฟังก์ชัน unsafe ถือเป็นแบบฉบับ (idiomatic) ที่จะเขียนคอมเมนต์เริ่มต้นด้วย SAFETY และอธิบายสิ่งที่ผู้เรียกจำเป็นต้องทำเพื่อเรียกใช้ฟังก์ชันได้อย่างปลอดภัย ในทำนองเดียวกัน เมื่อใดก็ตามที่เราปฏิบัติการ unsafe ถือเป็นแบบฉบับที่จะเขียนคอมเมนต์เริ่มต้นด้วย SAFETY เพื่ออธิบายว่ากฎระเบียบความปลอดภัยได้รับการปฏิบัติตามอย่างไร

นอกจากนี้ คอมไพเลอร์จะปฏิเสธโดยค่านิยมเริ่มต้น ความพยายามใดๆ ในการสร้างการอ้างอิงไปยังตัวแปร static ที่เปลี่ยนค่าได้ผ่านทาง compiler lint คุณต้องถอนตัวออกจากการปกป้องของ lint นั้นอย่างชัดแจ้งโดยการเพิ่มแอตทริบิวต์ #[allow(static_mut_refs)] หรือเข้าถึงตัวแปร static ที่เปลี่ยนค่าได้ผ่านพอยน์เตอร์ดิบที่สร้างด้วยตัวดำเนินการยืมดิบตัวใดตัวหนึ่ง ซึ่งรวมถึงกรณีที่การอ้างอิงถูกสร้างขึ้นอย่างมองไม่เห็น เช่น เมื่อมันถูกใช้ใน println! ในรายการโค้ดนี้ การกำหนดให้การอ้างอิงไปยังตัวแปร static ที่เปลี่ยนค่าได้สร้างขึ้นผ่านพอยน์เตอร์ดิบช่วยให้ข้อกำหนดด้านความปลอดภัยสำหรับการใช้งานชัดเจนยิ่งขึ้น

ด้วยข้อมูลที่เปลี่ยนค่าได้ซึ่งเข้าถึงได้ทั่วโลก (globally accessible) เป็นเรื่องยากที่จะรับประกันว่าไม่มีภาวะแย่งชิงข้อมูลเกิดขึ้น นั่นคือเหตุผลที่ Rust ถือว่าตัวแปร static ที่เปลี่ยนค่าได้เป็น unsafe ในตำแหน่งที่เป็นไปได้ ควรเลือกใช้เทคนิคการทำงานแบบพร้อมกันและสมาร์ตพอยน์เตอร์ที่ปลอดภัยสำหรับ thread ที่เราอภิปรายในบทที่ 16 เพื่อให้คอมไพเลอร์ตรวจสอบว่าการเข้าถึงข้อมูลจาก thread ต่างๆ ถูกต้องอย่างปลอดภัย

การนำ Unsafe Trait ไปใช้งาน

เราสามารถใช้ unsafe เพื่อนำ unsafe trait ไปใช้งานได้ trait จะเป็น unsafe เมื่ออย่างน้อยหนึ่งในเมธอดของมันมีคุณสมบัติคงตัวบางประการ (invariant) ที่คอมไพเลอร์ไม่สามารถตรวจสอบได้ เราประกาศว่า trait เป็น unsafe โดยการเพิ่มคีย์เวิร์ด unsafe ก่อน trait และทำเครื่องหมายการนำ trait ไปใช้งานเป็น unsafe ด้วยเช่นกัน ดังแสดงในโค้ดตัวอย่างที่ 20-12

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}

โดยการใช้ unsafe impl เรากำลังสัญญาว่าเราจะรักษาคุณสมบัติคงตัวที่คอมไพเลอร์ไม่สามารถตรวจสอบได้ไว้

ตัวอย่างเช่น ลองนึกถึง marker traits อย่าง Send และ Sync ที่เราอภิปรายในส่วน “Extensible Concurrency with Send and Sync ในบทที่ 16: คอมไพเลอร์จะนำ trait เหล่านี้ไปใช้งานโดยอัตโนมัติหากชนิดข้อมูลของเราประกอบขึ้นจากชนิดข้อมูลอื่นที่นำ Send และ Sync ไปใช้งานทั้งหมด หากเรานำชนิดข้อมูลที่บรรจุชนิดข้อมูลที่ไม่นำ Send หรือ Sync ไปใช้งานมาใช้งาน เช่น พอยน์เตอร์ดิบ และเราต้องการทำเครื่องหมายชนิดข้อมูลนั้นเป็น Send หรือ Sync เราต้องใช้ unsafe Rust ไม่สามารถตรวจสอบได้ว่าชนิดข้อมูลของเราปฏิบัติตามการรับประกันว่าสามารถส่งข้าม threads หรือเข้าถึงจากหลาย threads ได้อย่างปลอดภัยหรือไม่ ดังนั้นเราจึงจำเป็นต้องทำความสะอาดการตรวจสอบเหล่านั้นด้วยตนเองและระบุดังกล่าวด้วย unsafe

การเข้าถึงฟิลด์ของ Union

ปฏิบัติการสุดท้ายที่ทำงานได้เฉพาะกับ unsafe คือการเข้าถึงฟิลด์ของ union ตัว union จะคล้ายกับ struct แต่ฟิลด์ที่ถูกประกาศไว้เพียงฟิลด์เดียวเท่านั้นที่จะถูกใช้ในอินสแตนซ์เฉพาะ ณ เวลาหนึ่ง Unions ถูกนำมาใช้เป็นหลักในการปฏิสัมพันธ์กับ unions ในโค้ดภาษา C การเข้าถึงฟิลด์ของ union นั้นเป็น unsafe เนื่องจาก Rust ไม่สามารถรับประกันชนิดข้อมูลของข้อมูลที่ถูกจัดเก็บอยู่ในอินสแตนซ์ของ union ณ ปัจจุบันได้ คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับ unions ได้ใน the Rust Reference

การใช้ Miri ตรวจสอบโค้ด Unsafe

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

การใช้ Miri จำเป็นต้องใช้การบิลด์ nightly ของ Rust (ซึ่งเราพูดถึงเพิ่มเติมใน Appendix G: How Rust is Made and “Nightly Rust”) คุณสามารถติดตั้งทั้งเวอร์ชัน nightly ของ Rust และเครื่องมือ Miri ได้โดยการพิมพ์ rustup +nightly component add miri สิ่งนี้ไม่ได้เปลี่ยนเวอร์ชันของ Rust ที่โปรเจกต์ของคุณใช้; มันเพียงเพิ่มเครื่องมือลงในระบบของคุณเพื่อให้คุณสามารถใช้งานได้เมื่อต้องการ คุณสามารถรัน Miri บนโปรเจกต์ได้โดยการพิมพ์ cargo +nightly miri run หรือ cargo +nightly miri test

สำหรับตัวอย่างของประโยชน์จากเครื่องมือนี้ ให้พิจารณาสิ่งที่เกิดขึ้นเมื่อเรารันมันกับโค้ดตัวอย่างที่ 20-7:

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
warning: integer-to-pointer cast
 --> src/main.rs:5:13
  |
5 |     let r = address as *mut i32;
  |             ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
  |
  = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
  = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
  = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
  = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
  = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning

error: Undefined Behavior: constructing invalid value of type &mut [i32]: encountered a dangling reference (0x1234[noalloc] has no provenance)
 --> src/main.rs:7:35
  |
7 |     let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
  |                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
  |
  = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
  = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error; 1 warning emitted

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

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

กล่าวอีกนัยหนึ่ง: หาก Miri ตรวจพบ ปัญหา คุณรู้ว่ามีบั๊กอยู่ แต่เพียงเพราะ Miri ไม่ตรวจพบ บั๊ก ไม่ได้หมายความว่าไม่มีปัญหา อย่างไรก็ตาม มันสามารถจับปัญหาได้จำนวนมาก ลองรันมันกับตัวอย่างอื่นๆ ของโค้ด unsafe ในบทนี้และดูว่ามันรายงานอะไร!

คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับ Miri ได้ที่ GitHub repository ของมัน

การใช้โค้ด Unsafe อย่างถูกต้อง

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

สำหรับการสำรวจเชิงลึกเพิ่มเติมเกี่ยวกับการทำงานกับ unsafe Rust อย่างมีประสิทธิภาพ อ่านคู่มืออย่างเป็นทางการของ Rust สำหรับ unsafe, The Rustonomicon

Advanced Traits

Advanced Traits

เราได้พูดถึงเทรต (traits) ครั้งแรกในหัวข้อ “การนิยามพฤติกรรมร่วมด้วย Traits” ในบทที่ 10 แต่เรายังไม่ได้พูดถึงรายละเอียดระดับสูงขึ้น ตอนนี้ที่คุณรู้เกี่ยวกับ Rust มากขึ้นแล้ว เรามาเจาะลึกรายละเอียดเชิงลึกกันครับ

Defining Traits with Associated Types

ประเภทแบบเชื่อมโยง (Associated types) ทำหน้าที่เชื่อมต่อตัวแทนประเภท (type placeholder) เข้ากับเทรต เพื่อให้การนิยามเมธอดของเทรตสามารถใช้ประเภทตัวแทนเหล่านี้ในลายเซ็น (signatures) ของมันได้ ผู้ที่นำเทรตไปใช้งาน (implementor) จะเป็นผู้ระบุประเภทคอนกรีต (concrete type) ที่จะนำมาใช้แทนตัวแทนประเภทสำหรับการอิมพลีเมนต์นั้นๆ ด้วยวิธีนี้ เราสามารถนิยามเทรตที่ใช้ประเภทบางอย่างได้ โดยไม่จำเป็นต้องรู้ว่าประเภทเหล่านั้นคืออะไรแน่ชัดจนกว่าจะมีการอิมพลีเมนต์เทรต

เราได้อธิบายคุณสมบัติขั้นสูงส่วนใหญ่ในบทนี้ว่าเป็นสิ่งที่ไม่ค่อยได้ใช้บ่อยนัก สำหรับ Associated types นั้นจัดอยู่ในระดับปานกลาง: พวกมันถูกใช้งานน้อยกว่าคุณสมบัติอื่นๆ ที่อธิบายไว้ในส่วนที่เหลือของหนังสือเล่มนี้ แต่ก็ใช้งานบ่อยกว่าคุณสมบัติขั้นสูงหลายๆ อย่างที่พูดถึงในบทนี้

ตัวอย่างหนึ่งของเทรตที่มีประเภทแบบเชื่อมโยงคือ Iterator trait ที่ไลบรารีมาตรฐาน (standard library) มีให้ ประเภทแบบเชื่อมโยงมีชื่อว่า Item ซึ่งทำหน้าที่เป็นตัวแทนสำหรับประเภทของค่าที่ประเภทซึ่งอิมพลีเมนต์ Iterator trait กำลังวนซ้ำอยู่ นิยามของ Iterator trait แสดงในโค้ดตัวอย่างที่ 20-13

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

ประเภท Item คือตัวแทนประเภท และการนิยามเมธอด next แสดงให้เห็นว่าจะคืนค่าประเภท Option<Self::Item> ผู้ที่อิมพลีเมนต์ Iterator trait จะระบุประเภทคอนกรีตสำหรับ Item และเมธอด next จะคืนค่า Option ที่บรรจุค่าของประเภทคอนกรีตนั้น

ประเภทแบบเชื่อมโยงอาจดูคล้ายกับแนวคิดของเจเนอริก (generics) ในแง่ที่ว่าเจเนอริกอนุญาตให้เรานิยามฟังก์ชันโดยไม่ต้องระบุประเภทที่สามารถรับได้ เพื่อศึกษาความแตกต่างระหว่างสองแนวคิดนี้ เราจะดูการอิมพลีเมนต์ Iterator trait บนประเภทชื่อ Counter ซึ่งระบุว่าประเภท Item คือ u32:

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

ไวยากรณ์นี้ดูเทียบเคียงได้กับไวยากรณ์ของเจเนอริก แล้วทำไมเราไม่เพียงแค่นิยาม Iterator trait ด้วยเจเนอริก เหมือนที่แสดงในโค้ดตัวอย่างที่ 20-14 ล่ะ?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

ความแตกต่างคือเมื่อใช้เจเนอริก เหมือนในโค้ดตัวอย่างที่ 20-14 เราต้องระบุประเภท (type annotation) ในการอิมพลีเมนต์แต่ละครั้ง เนื่องจากเราสามารถอิมพลีเมนต์ Iterator<String> for Counter หรือประเภทอื่นใดก็ได้ เราจึงสามารถมีการอิมพลีเมนต์ Iterator สำหรับ Counter ได้หลายรายการ กล่าวอีกนัยหนึ่ง เมื่อเทรตมีพารามิเตอร์เจเนอริก มันสามารถถูกอิมพลีเมนต์ให้กับประเภทหนึ่งๆ ได้หลายครั้ง โดยเปลี่ยนประเภทคอนกรีตของพารามิเตอร์ประเภทเจเนอริกในแต่ละครั้ง เมื่อเราใช้เมธอด next บน Counter เราจะต้องระบุการคำอธิบายประเภทเพื่อระบุว่าการอิมพลีเมนต์ Iterator ใดที่เราต้องการใช้

เมื่อใช้ประเภทแบบเชื่อมโยง เราไม่จำเป็นต้องระบุประเภท เพราะเราไม่สามารถอิมพลีเมนต์เทรตให้กับประเภทหนึ่งๆ ได้หลายครั้ง ในโค้ดตัวอย่างที่ 20-13 ที่ใช้นิยามประเภทแบบเชื่อมโยง เราสามารถเลือกประเภทที่จะเป็น Item ได้เพียงครั้งเดียวเท่านั้น เนื่องจากจะมี impl Iterator for Counter ได้เพียงอันเดียว เราจึงไม่ต้องระบุว่าต้องการตัววนซ้ำของค่า u32 ทุกครั้งที่เราเรียกใช้ next บน Counter

ประเภทแบบเชื่อมโยงยังกลายเป็นส่วนหนึ่งของสัญญา (contract) ของเทรต: ผู้ที่อิมพลีเมนต์เทรตจะต้องระบุประเภทมาแทนที่ตัวแทนประเภทแบบเชื่อมโยงนั้น โดยประเภทแบบเชื่อมโยงมักจะมีชื่อที่อธิบายถึงวิธีการที่จะนำประเภทนั้นไปใช้งาน และการเขียนเอกสารกำกับประเภทแบบเชื่อมโยงไว้ในเอกสารประกอบ API ถือเป็นแนวทางปฏิบัติที่ดี

Using Default Generic Parameters and Operator Overloading

เมื่อเราใช้พารามิเตอร์ประเภทเจเนอริก เราสามารถระบุประเภทคอนกรีตเริ่มต้น (default concrete type) สำหรับประเภทเจเนอริกได้ สิ่งนี้ทำให้ขจัดความจำเป็นที่ผู้พัฒนาซึ่งนำเทรตไปใช้ต้องระบุประเภทคอนกรีตหากประเภทเริ่มต้นนั้นทำงานได้ตามต้องการ คุณจะระบุประเภทเริ่มต้นเมื่อประกาศประเภทเจเนอริกด้วยไวยากรณ์ <PlaceholderType=ConcreteType>

ตัวอย่างที่ยอดเยี่ยมของสถานการณ์ที่เทคนิคนี้มีประโยชน์คือเรื่อง การโอเวอร์โหลดตัวดำเนินการ (operator overloading) ซึ่งคุณกำหนดพฤติกรรมของตัวดำเนินการ (เช่น +) สำหรับสถานการณ์เฉพาะเองได้

Rust ไม่อนุญาตให้คุณสร้างตัวดำเนินการของคุณเองหรือโอเวอร์โหลดตัวดำเนินการใดๆ ได้ตามใจชอบ แต่คุณสามารถโอเวอร์โหลดปฏิบัติการและเทรตที่เกี่ยวข้องซึ่งรายการอยู่ใน std::ops ได้ โดยการอิมพลีเมนต์เทรตที่เชื่อมโยงกับตัวดำเนินการนั้นๆ ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 20-15 เราโอเวอร์โหลดตัวดำเนินการ + เพื่อบวกอินสแตนซ์ Point สองอันเข้าด้วยกัน โดยเราทำสิ่งนี้ด้วยการอิมพลีเมนต์ Add trait บน Point struct

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

เมธอด add จะบวกค่า x ของอินสแตนซ์ Point สองอัน และบวกค่า y ของอินสแตนซ์ Point สองอันเพื่อสร้าง Point ใหม่ขึ้นมา Add trait มีประเภทแบบเชื่อมโยงชื่อ Output ซึ่งกำหนดประเภทที่ส่งคืนจากเมธอด add

ประเภทเจเนอริกเริ่มต้นในโค้ดนี้อยู่ภายใน Add trait นี่คือนิยามของมัน:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

โค้ดนี้น่าจะดูคุ้นเคยโดยทั่วไป: เทรตที่มีหนึ่งเมธอดและประเภทแบบเชื่อมโยง ส่วนใหม่คือ Rhs=Self: ไวยากรณ์นี้เรียกว่า พารามิเตอร์ประเภทเริ่มต้น (default type parameters) พารามิเตอร์ประเภทเจเนอริก Rhs (ย่อมาจาก “right-hand side” หรือฝั่งขวา) จะกำหนดประเภทของพารามิเตอร์ rhs ในเมธอด add หากเราไม่ได้ระบุประเภทคอนกรีตสำหรับ Rhs ตอนเราอิมพลีเมนต์ Add trait ประเภทของ Rhs จะถูกตั้งเป็นค่าเริ่มต้นคือ Self ซึ่งก็คือประเภทที่เรากำลังอิมพลีเมนต์ Add ให้ในขณะนั้น

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

เรามีสตรักต์สองอันคือ Millimeters และ Meters ซึ่งถือครองค่าในหน่วยที่ต่างกัน การหุ้มประเภทที่มีอยู่บางๆ ไว้ในอีกสตรักต์หนึ่งเรียกว่า รูปแบบ newtype (newtype pattern) ซึ่งเราจะอธิบายรายละเอียดเพิ่มเติมในหัวข้อ “การอิมพลีเมนต์ External Traits ด้วยรูปแบบ Newtype” เราต้องการบวกค่าในหน่วยมิลลิเมตรเข้ากับค่าในหน่วยเมตร และให้การอิมพลีเมนต์ Add ทำการแปลงหน่วยอย่างถูกต้อง เราสามารถอิมพลีเมนต์ Add สำหรับ Millimeters โดยให้ Meters เป็น Rhs ได้ ดังแสดงในโค้ดตัวอย่างที่ 20-16

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

ในการบวก Millimeters และ Meters เราจะระบุ impl Add<Meters> เพื่อตั้งค่าของพารามิเตอร์ประเภท Rhs แทนที่จะใช้ค่าเริ่มต้นซึ่งก็คือ Self

คุณจะใช้พารามิเตอร์ประเภทเริ่มต้นในสองกรณีหลักๆ:

  1. เพื่อขยายความสามารถของประเภทโดยไม่ทำให้โค้ดเดิมเสียหาย (breaking existing code)
  2. เพื่อเปิดให้สามารถปรับแต่งได้ในกรณีเฉพาะเจาะจงที่ผู้ใช้ส่วนใหญ่ไม่จำเป็นต้องใช้

Add trait ในไลบรารีมาตรฐานเป็นตัวอย่างของวัตถุประสงค์ประการที่สอง: โดยปกติ คุณมักจะบวกสองประเภทที่เหมือนกันเข้าด้วยกัน แต่ Add trait ให้ความสามารถในการปรับแต่งเพิ่มเติมมากกว่านั้น การใช้พารามิเตอร์ประเภทเริ่มต้นในนิยามของ Add trait หมายความว่าคุณไม่ต้องระบุพารามิเตอร์พิเศษเกือบตลอดเวลา กล่าวอีกนัยหนึ่ง ช่วยลดโค้ดส่วนเกินในการอิมพลีเมนต์ (boilerplate) ทำให้ใช้งานเทรตได้ง่ายขึ้น

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

Disambiguating Between Identically Named Methods

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

เมื่อเรียกใช้เมธอดที่มีชื่อเหมือนกัน คุณต้องบอก Rust ว่าต้องการใช้เมธอดใด พิจารณาโค้ดในโค้ดตัวอย่างที่ 20-17 ซึ่งเรานิยามเทรตสองอันคือ Pilot และ Wizard โดยทั้งคู่มีเมธอดชื่อ fly จากนั้นเราอิมพลีเมนต์ทั้งสองเทรตบนประเภท Human ซึ่งมีเมธอดชื่อ fly อิมพลีเมนต์อยู่บนตัวมันเองอยู่แล้ว โดยเมธอด fly แต่ละอันทำหน้าที่แตกต่างกัน

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}

เมื่อเราเรียกใช้ fly บนอินสแตนซ์ของ Human คอมไพเลอร์จะเรียกใช้เมธอดที่อิมพลีเมนต์อยู่บนประเภทโดยตรงเป็นค่าเริ่มต้น ดังแสดงในโค้ดตัวอย่างที่ 20-18

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

การรันโค้ดนี้จะพิมพ์ *waving arms furiously* ซึ่งแสดงว่า Rust เรียกใช้เมธอด fly ที่อิมพลีเมนต์บน Human โดยตรง

ในการเรียกใช้เมธอด fly จาก Pilot trait หรือ Wizard trait เราต้องใช้ไวยากรณ์ที่ชัดเจนยิ่งขึ้นเพื่อระบุว่าเราหมายถึงเมธอด fly อันไหน โค้ดตัวอย่างที่ 20-19 สาธิตไวยากรณ์นี้

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

การระบุชื่อเทรตไว้ข้างหน้าชื่อเมธอดจะช่วยให้ Rust เข้าใจชัดเจนว่าเราต้องการเรียกการอิมพลีเมนต์ fly ใด นอกจากนี้เรายังสามารถเขียน Human::fly(&person) ซึ่งมีผลเทียบเท่ากับ person.fly() ที่เราใช้ในโค้ดตัวอย่างที่ 20-19 แต่การเขียนแบบนี้จะยาวกว่าเล็กน้อยหากเราไม่จำเป็นต้องแยกความกำกวม

การรันโค้ดนี้จะพิมพ์ผลลัพธ์ต่อไปนี้:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

เนื่องจากเมธอด fly รับพารามิเตอร์ self หากเรามีสอง_ประเภท_ที่อิมพลีเมนต์หนึ่ง_เทรต_เหมือนกัน Rust จะสามารถพิจารณาได้ว่าควรใช้การอิมพลีเมนต์เทรตอันไหนโดยอิงตามประเภทของ self

อย่างไรก็ตาม ฟังก์ชันที่เชื่อมโยง (associated functions) ที่ไม่ได้เป็นเมธอดจะไม่มีพารามิเตอร์ self เมื่อมีหลายประเภทหรือหลายเทรตที่นิยามฟังก์ชันที่ไม่ใช่เมธอดด้วยชื่อฟังก์ชันเดียวกัน Rust จะไม่ทราบเสมอไปว่าคุณหมายถึงประเภทใด เว้นแต่คุณจะใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ (fully qualified syntax) ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 20-20 เราสร้างเทรตสำหรับสถานรับเลี้ยงสัตว์ที่ต้องการตั้งชื่อลูกสุนัขทุกตัวว่า Spot เราสร้าง Animal trait พร้อมฟังก์ชันที่เชื่อมโยงที่ไม่ใช่เมธอดชื่อ baby_name โดย Animal trait ถูกอิมพลีเมนต์ให้กับสตรักต์ Dog ซึ่งบน Dog เองก็มีฟังก์ชันที่เชื่อมโยงที่ไม่ใช่เมธอดชื่อ baby_name โดยตรงเช่นกัน

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

เราอิมพลีเมนต์โค้ดสำหรับการตั้งชื่อลูกสุนัขทั้งหมดว่า Spot ในฟังก์ชันที่เชื่อมโยง baby_name ซึ่งนิยามบน Dog ประเภท Dog ยังอิมพลีเมนต์ Animal trait ซึ่งอธิบายลักษณะเด่นที่สัตว์ทุกตัวมี ลูกของสุนัขเรียกว่าลูกสุนัข (puppy) และสิ่งนั้นถูกแสดงออกในการอิมพลีเมนต์ Animal trait บน Dog ในฟังก์ชัน baby_name ที่เชื่อมโยงกับ Animal trait

ใน main เราเรียกฟังก์ชัน Dog::baby_name ซึ่งจะเรียกใช้ฟังก์ชันที่เชื่อมโยงที่นิยามไว้บน Dog โดยตรง โค้ดนี้จะพิมพ์ผลลัพธ์ดังนี้:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

ผลลัพธ์นี้ไม่ใช่สิ่งที่เราต้องการ เราต้องการเรียกใช้ฟังก์ชัน baby_name ที่เป็นส่วนหนึ่งของ Animal trait ซึ่งเราอิมพลีเมนต์ให้กับ Dog เพื่อให้โค้ดพิมพ์ A baby dog is called a puppy เทคนิคการระบุชื่อเทรตที่เราใช้ในโค้ดตัวอย่างที่ 20-19 ไม่สามารถช่วยได้ที่นี่ หากเราเปลี่ยน main เป็นโค้ดในโค้ดตัวอย่างที่ 20-21 เราจะเจอข้อผิดพลาดในการคอมไพล์

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

เนื่องจาก Animal::baby_name ไม่มีพารามิเตอร์ self และอาจมีประเภทอื่นที่อิมพลีเมนต์ Animal trait อยู่ด้วย Rust จึงไม่สามารถคาดเดาได้ว่าเราต้องการการอิมพลีเมนต์ Animal::baby_name ของอันไหน เราจะได้รับข้อผิดพลาดคอมไพเลอร์ดังนี้:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
 2 |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

เพื่อขจัดความกำกวมและบอก Rust ว่าเราต้องการใช้การอิมพลีเมนต์ Animal สำหรับ Dog ไม่ใช่การอิมพลีเมนต์ Animal สำหรับประเภทอื่น เราจำเป็นต้องใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ (fully qualified syntax) โค้ดตัวอย่างที่ 20-22 แสดงวิธีใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

เราได้ระบุการอธิบายประเภทให้แก่ Rust ไว้ภายในวงเล็บสามเหลี่ยม (angle brackets) ซึ่งบ่งบอกว่าเราต้องการเรียกเมธอด baby_name จาก Animal trait ตามที่อิมพลีเมนต์ไว้บน Dog โดยการบอกว่าเราต้องการปฏิบัติกับประเภท Dog เสมือนเป็น Animal สำหรับการเรียกฟังก์ชันนี้ บัดนี้โค้ดนี้จะพิมพ์ผลลัพธ์ตามที่เราต้องการแล้ว:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

โดยทั่วไป ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์จะถูกนิยามไว้ดังนี้:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

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

Using Supertraits

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

ตัวอย่างเช่น สมมติว่าเราต้องการสร้าง OutlinePrint trait ที่มีเมธอด outline_print ซึ่งจะพิมพ์ค่าที่กำหนดโดยจัดรูปแบบให้ล้อมรอบด้วยเครื่องหมายดอกจัน นั่นคือ หากมีสตรักต์ Point ที่อิมพลีเมนต์เทรตในไลบรารีมาตรฐาน Display ซึ่งแสดงผลเป็น (x, y) เมื่อเราเรียกใช้ outline_print บนอินสแตนซ์ของ Point ที่มี 1 เป็น x และ 3 เป็น y มันควรจะพิมพ์ผลลัพธ์ดังนี้:

**********
*        *
* (1, 3) *
*        *
**********

ในการอิมพลีเมนต์เมธอด outline_print เราต้องการใช้ฟังก์ชันการทำงานของ Display trait ดังนั้น เราจึงต้องระบุว่า OutlinePrint trait จะทำงานเฉพาะกับประเภทที่อิมพลีเมนต์ Display ด้วย และมอบฟังก์ชันการทำงานที่ OutlinePrint ต้องการเท่านั้น เราสามารถทำได้ในนิยามเทรตโดยระบุ OutlinePrint: Display เทคนิคนี้คล้ายกับการเพิ่มข้อจำกัดเทรต (trait bound) ให้กับเทรต โค้ดตัวอย่างที่ 20-23 แสดงการอิมพลีเมนต์ OutlinePrint trait

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}

เนื่องจากเราระบุว่า OutlinePrint ต้องการ Display trait เราจึงสามารถใช้ฟังก์ชัน to_string ซึ่งถูกอิมพลีเมนต์ให้อัตโนมัติสำหรับทุกประเภทที่อิมพลีเมนต์ Display หากเราพยายามใช้ to_string โดยไม่ได้เพิ่มเครื่องหมายทวิภาค (:) และไม่ระบุ Display trait ต่อท้ายชื่อเทรต เราจะพบข้อผิดพลาดที่ระบุว่าไม่พบเมธอดชื่อ to_string สำหรับประเภท &Self ในขอบเขตปัจจุบัน

เรามาดูสิ่งที่จะเกิดขึ้นเมื่อเราพยายามอิมพลีเมนต์ OutlinePrint บนประเภทที่ไม่ได้อิมพลีเมนต์ Display เช่น สตรักต์ Point:

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

เราจะได้รับข้อผิดพลาดระบุว่าต้องการ Display แต่อังไม่ได้ถูกอิมพลีเมนต์:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
 4 |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

เพื่อแก้ไขปัญหานี้ เราจึงอิมพลีเมนต์ Display บน Point เพื่อให้เป็นไปตามเงื่อนไขข้อจำกัดที่ OutlinePrint ต้องการ ดังนี้:

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

จากนั้น การอิมพลีเมนต์ OutlinePrint trait บน Point จะคอมไพล์ผ่านได้สำเร็จ และเราสามารถเรียกใช้ outline_print บนอินสแตนซ์ของ Point เพื่อแสดงผลโดยมีกรอบดอกจันล้อมรอบได้

Implementing External Traits with the Newtype Pattern

ในหัวข้อ “การอิมพลีเมนต์ Trait บนประเภท” ในบทที่ 10 เราได้กล่าวถึงกฎกำพร้า (orphan rule) ซึ่งระบุว่าเราได้รับอนุญาตให้อิมพลีเมนต์เทรตบนประเภทหนึ่งๆ ได้ก็ต่อเมื่อเทรตหรือประเภท หรือทั้งคู่ เป็นสิ่งท้องถิ่น (local) ในเครตของเราเท่านั้น เป็นไปได้ที่จะก้าวข้ามข้อจำกัดนี้โดยใช้รูปแบบ newtype (newtype pattern) ซึ่งเกี่ยวข้องกับการสร้างประเภทใหม่ในทูเพิลสตรักต์ (tuple struct) (เราได้ครอบคลุมเรื่องทูเพิลสตรักต์ในหัวข้อ “การสร้างประเภทที่แตกต่างด้วย Tuple Structs” ในบทที่ 5) โดยทูเพิลสตรักต์จะมีหนึ่งฟิลด์และทำหน้าที่เป็นตัวหุ้มอย่างบาง (thin wrapper) รอบประเภทที่เราต้องการอิมพลีเมนต์เทรตให้ จากนั้น ประเภทตัวหุ้มดังกล่าวจะกลายเป็นสิ่งท้องถิ่นในเครตของเรา และเราสามารถอิมพลีเมนต์เทรตบนตัวหุ้มนั้นได้คำว่า Newtype เป็นคำที่มีจุดเริ่มต้นมาจากภาษาโปรแกรม Haskell การใช้รูปแบบนี้ไม่มีบทลงโทษทางประสิทธิภาพในการทำงานขณะรันไทม์ (runtime performance penalty) และประเภทตัวหุ้มจะถูกตัดออกไปตอนคอมไพล์ (elided at compile time)

เพื่อเป็นตัวอย่าง สมมติว่าเราต้องการอิมพลีเมนต์ Display บน Vec<T> ซึ่งกฎกำพร้าป้องกันไม่ให้เราทำโดยตรงเนื่องจาก Display trait และประเภท Vec<T> ถูกนิยามไว้นอกเครตของเรา เราสามารถสร้างสตรักต์ Wrapper ที่บรรจุอินสแตนซ์ของ Vec<T> เอาไว้ จากนั้นเราสามารถอิมพลีเมนต์ Display บน Wrapper และใช้ค่า Vec<T> ได้ ดังแสดงในโค้ดตัวอย่างที่ 20-24

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}

การอิมพลีเมนต์ Display ใช้ self.0 เพื่อเข้าถึง Vec<T> ภายใน เนื่องจาก Wrapper เป็นทูเพิลสตรักต์ และ Vec<T> เป็นไอเทมที่ดัชนี 0 ในทูเพิล จากนั้นเราจะสามารถใช้ฟังก์ชันการทำงานของ Display trait บน Wrapper ได้

ข้อเสียของการใช้เทคนิคนี้คือ Wrapper เป็นประเภทใหม่ ดังนั้นจึงไม่มีเมธอดของค่าที่มันถือครองอยู่ เราจะต้องอิมพลีเมนต์เมธอดทั้งหมดของ Vec<T> บน Wrapper โดยตรงเพื่อให้เมธอดเหล่านั้นมอบหมายงาน (delegate) ต่อไปยัง self.0 ซึ่งจะทำให้เราสามารถปฏิบัติต่อ Wrapper ได้เหมือนกับ Vec<T> ทุกประการ หากเราต้องการให้ประเภทใหม่นี้มีทุกเมธอดที่ประเภทภายในมี การอิมพลีเมนต์ Deref trait บน Wrapper เพื่อส่งคืนประเภทภายในก็จะเป็นแนวทางแก้ไขปัญหา (เราได้พูดคุยเกี่ยวกับการอิมพลีเมนต์ Deref trait ในหัวข้อ “การปฏิบัติต่อ Smart Pointers เสมือนการอ้างอิงปกติ” ในบทที่ 15) หากเราไม่ต้องการให้ประเภท Wrapper มีเมธอดทั้งหมดของประเภทภายใน—เช่น เพื่อจำกัดพฤติกรรมของประเภท Wrapper—เราจะต้องอิมพลีเมนต์เฉพาะเมธอดที่เราต้องการด้วยตนเอง

รูปแบบ newtype นี้ยังมีประโยชน์แม้ว่าจะไม่ได้เกี่ยวข้องกับเทรตก็ตาม ตอนนี้เรามาเปลี่ยนจุดสนใจไปดูวิธีขั้นสูงบางวิธีในการโต้ตอบกับระบบประเภท (type system) ของ Rust กันครับ

Advanced Types

Advanced Types

ระบบประเภท (type system) ของ Rust มีคุณสมบัติบางอย่างที่เราเคยเอ่ยถึงแต่ยังไม่ได้ลงรายละเอียด เราจะเริ่มต้นด้วยการพูดถึง newtypes โดยทั่วไปเมื่อพิจารณาว่าทำไมพวกมันจึงมีประโยชน์ในฐานะประเภทข้อมูล จากนั้นเราจะขยับไปพูดถึงนามแฝงประเภท (type aliases) ซึ่งเป็นคุณสมบัติที่คล้ายกับ newtypes แต่มีความหมายเชิงระบบ (semantics) ที่ต่างกันเล็กน้อย นอกจากนี้เราจะพูดถึงประเภท ! และประเภทที่มีขนาดแปรผันขณะรันไทม์ (dynamically sized types)

Type Safety and Abstraction with the Newtype Pattern

หัวข้อนี้สมมติว่าคุณได้อ่านหัวข้อก่อนหน้านี้เรื่อง “การอิมพลีเมนต์ External Traits ด้วยรูปแบบ Newtype” มาแล้ว รูปแบบ newtype (newtype pattern) ยังมีประโยชน์สำหรับงานอื่นๆ นอกเหนือจากที่เราได้พูดถึงไปแล้ว ซึ่งรวมถึงการบังคับใช้แบบสแตติก (statically enforcing) เพื่อไม่ให้ค่าสับสนกัน และใช้ในการระบุหน่วยของค่า คุณได้เห็นตัวอย่างของการใช้ newtype เพื่อระบุหน่วยในโค้ดตัวอย่างที่ 20-16 มาแล้ว: จำได้ไหมว่าสตรักต์ Millimeters และ Meters หุ้มค่า u32 ไว้ใน newtype หากเราเขียนฟังก์ชันที่มีพารามิเตอร์ประเภท Millimeters เราจะไม่สามารถคอมไพล์โปรแกรมที่พยายามเรียกใช้ฟังก์ชันนั้นด้วยค่าประเภท Meters หรือ u32 ธรรมดาโดยบังเอิญได้

เรายังสามารถใช้รูปแบบ newtype เพื่อการซ่อนและนามธรรม (abstract) รายละเอียดการอิมพลีเมนต์บางอย่างของประเภทข้อมูลได้: ประเภทใหม่สามารถเปิดเผย public API ที่แตกต่างจาก API ของประเภทภายในที่เป็น private

Newtype ยังสามารถซ่อนการอิมพลีเมนต์ภายในได้อีกด้วย ตัวอย่างเช่น เราสามารถให้ประเภท People เพื่อหุ้ม HashMap<i32, String> ที่เก็บ ID ของบุคคลคู่กับชื่อของพวกเขา โค้ดที่ใช้ People จะโต้ตอบเฉพาะกับ public API ที่เราจัดเตรียมไว้ เช่น เมธอดสำหรับเพิ่มสตริงชื่อลงในคอลเลกชัน People โค้ดนั้นไม่จำเป็นต้องรู้ว่าเรากำหนด ID แบบ i32 ให้กับชื่อเป็นการภายใน รูปแบบ newtype เป็นวิธีน้ำหนักเบาในการบรรลุการแคปซูล (encapsulation) เพื่อซ่อนรายละเอียดการอิมพลีเมนต์ ซึ่งเราได้พูดถึงไปในหัวข้อ “การแคปซูลที่ซ่อนรายละเอียดการอิมพลีเมนต์” ในบทที่ 18

Type Synonyms and Type Aliases

Rust มีความสามารถในการประกาศ นามแฝงประเภท (type alias) เพื่อตั้งชื่ออื่นให้กับประเภทที่มีอยู่แล้ว สำหรับสิ่งนี้ เราใช้คีย์เวิร์ด type ตัวอย่างเช่น เราสามารถสร้างนามแฝง Kilometers สำหรับ i32 ได้ดังนี้:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

ตอนนี้นามแฝง Kilometers เป็น คำพ้อง (synonym) ของ i32; ไม่เหมือนกับประเภท Millimeters และ Meters ที่เราสร้างในโค้ดตัวอย่างที่ 20-16 Kilometers ไม่ใช่ประเภทใหม่แยกต่างหาก ค่าที่มีประเภท Kilometers จะถูกปฏิบัติเสมือนเป็นค่าประเภท i32 ทุกประการ:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

เนื่องจาก Kilometers และ i32 เป็นประเภทเดียวกัน เราจึงสามารถบวกค่าของทั้งสองประเภทเข้าด้วยกัน และสามารถส่งค่า Kilometers ไปยังฟังก์ชันที่รับพารามิเตอร์ i32 ได้ อย่างไรก็ตาม การใช้วิธีนี้จะทำให้เราไม่ได้ประโยชน์จากการตรวจประเภท (type-checking) ที่เราได้รับจากรูปแบบ newtype ที่กล่าวถึงก่อนหน้านี้ กล่าวอีกนัยหนึ่ง หากเราสับสนผสมค่า Kilometers และ i32 เข้าด้วยกันที่ไหนสักแห่ง คอมไพเลอร์จะไม่แจ้งข้อผิดพลาดให้เราทราบ

กรณีการใช้งานหลักสำหรับคำพ้องประเภทคือการลดการเขียนซ้ำ ตัวอย่างเช่น เราอาจมีประเภทที่ยืดยาวเช่นนี้:

Box<dyn Fn() + Send + 'static>

การเขียนประเภทที่ยาวนี้ในลายเซ็นฟังก์ชัน (function signatures) และเป็นการระบุประเภททั่วทั้งโค้ดอาจเป็นเรื่องน่าเบื่อและเกิดข้อผิดพลาดได้ง่าย ลองจินตนาการถึงโปรเจกต์ที่เต็มไปด้วยโค้ดลักษณะดังโค้ดตัวอย่างที่ 20-25

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}

นามแฝงประเภททำให้โค้ดนี้จัดการได้ง่ายขึ้นโดยการลดการเขียนซ้ำ ในโค้ดตัวอย่างที่ 20-26 เราได้แนะนำนามแฝงชื่อ Thunk สำหรับประเภทที่ยืดยาวนั้น และสามารถแทนที่การใช้งานประเภทนั้นทั้งหมดด้วยนามแฝงสั้นๆ ว่า Thunk

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}

โค้ดนี้อ่านและเขียนง่ายขึ้นมาก! การเลือกชื่อที่มีความหมายสำหรับนามแฝงประเภทสามารถช่วยสื่อเจตนาของคุณได้เช่นกัน (thunk เป็นคำที่ใช้เรียกโค้ดที่จะถูกประมวลผลในภายหลัง ดังนั้นจึงเป็นชื่อที่เหมาะสมสำหรับโคลเชอร์ที่ถูกจัดเก็บไว้)

นามแฝงประเภทยังถูกใช้บ่อยกับประเภท Result<T, E> เพื่อลดการเขียนซ้ำอีกด้วย ลองพิจารณามอดูล std::io ในไลบรารีมาตรฐาน ปฏิบัติการ I/O มักจะคืนค่าเป็น Result<T, E> เพื่อจัดการกับสถานการณ์ที่ปฏิบัติการทำงานล้มเหลว ไลบรารีนี้มีสตรักต์ std::io::Error ที่เป็นตัวแทนของข้อผิดพลาด I/O ทั้งหมดที่เป็นไปได้ หลายฟังก์ชันใน std::io จะคืนค่า Result<T, E> โดยที่ E คือ std::io::Error เช่น ฟังก์ชันเหล่านี้ใน Write trait:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> ถูกเขียนซ้ำเยอะมาก ดังนั้น std::io จึงมีการประกาศนามแฝงประเภทนี้:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

เนื่องจากการประกาศนี้อยู่ในมอดูล std::io เราจึงสามารถใช้นามแฝงแบบระบุชื่อเต็มสมบูรณ์ std::io::Result<T> ได้ นั่นคือ Result<T, E> ที่มี E เติมด้วย std::io::Error ไว้แล้ว ลายเซ็นฟังก์ชันของ Write trait จึงออกมาดูดังนี้:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

นามแฝงประเภทช่วยสองทาง: มันทำให้โค้ดเขียนง่ายขึ้น และ มอบอินเทอร์เฟซที่สอดคล้องกันทั่วทั้ง std::io เนื่องจากมันเป็นเพียงนามแฝง มันก็คือ Result<T, E> อีกอันหนึ่ง ซึ่งหมายความว่าเราสามารถใช้เมธอดใดๆ ที่ทำงานบน Result<T, E> กับมันได้ รวมถึงไวยากรณ์พิเศษอย่างตัวดำเนินการ ?

The Never Type That Never Returns

Rust มีประเภทพิเศษชื่อ ! ซึ่งเป็นที่รู้จักในศัพท์ทฤษฎีประเภท (type theory) ว่าเป็น ประเภทว่างเปล่า (empty type) เนื่องจากมันไม่มีค่าใดๆ เลย เราชอบที่จะเรียกว่า never type เพราะมันอยู่แทนตำแหน่งประเภทที่ส่งคืนเมื่อฟังก์ชันจะไม่ส่งคืนค่ากลับมาเลย นี่คือตัวอย่าง:

fn bar() -> ! {
    // --snip--
    panic!();
}

โค้ดนี้อ่านว่า “ฟังก์ชัน bar ส่งคืนค่าเป็น never” ฟังก์ชันที่ส่งคืนค่าเป็น never เรียกว่า diverging functions (ฟังก์ชันลู่ออก) เราไม่สามารถสร้างค่าของประเภท ! ได้ ดังนั้น bar จึงไม่มีทางส่งคืนค่ากลับมาได้เลย

แต่ประเภทที่คุณไม่สามารถสร้างค่าให้ได้จะมีประโยชน์อะไร? จำโค้ดในโค้ดตัวอย่างที่ 2-5 ที่เป็นส่วนหนึ่งของเกมทายตัวเลขได้ไหม? เราได้จำลองส่วนหนึ่งของมันมาไว้ที่นี่ในโค้ดตัวอย่างที่ 20-27

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

ในตอนนั้น เราข้ามรายละเอียดบางอย่างในโค้ดนี้ไป ในหัวข้อ “โครงสร้างควบคุมทิศทาง match ในบทที่ 6 เราได้พูดคุยกันว่าแขนของ match ทั้งหมดจะต้องส่งคืนประเภทเดียวกัน ดังนั้น ตัวอย่างเช่น โค้ดต่อไปนี้จะไม่ทำงาน:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

ประเภทของ guess ในโค้ดนี้จะต้องเป็นทั้งจำนวนเต็ม และ สตริง ซึ่ง Rust กำหนดว่า guess ต้องมีประเภทเดียวเท่านั้น แล้ว continue คืนค่าอะไร? ทำไมเราถึงได้รับอนุญาตให้ส่งคืน u32 จากแขนข้างหนึ่ง และมีแขนอีกข้างหนึ่งที่ลงท้ายด้วย continue ในโค้ดตัวอย่างที่ 20-27 ได้?

ตามที่คุณอาจเดาได้ continue มีค่าเป็น ! นั่นคือเมื่อ Rust คำนวณประเภทของ guess มันจะดูแขนของ match ทั้งสองข้าง ข้างแรกมีค่าเป็น u32 และข้างหลังมีค่าเป็น ! เนื่องจาก ! ไม่มีทางมีค่าได้ Rust จึงตัดสินใจว่าประเภทของ guess คือ u32

วิธีพูดอย่างเป็นทางการในการอธิบายพฤติกรรมนี้คือ นิพจน์ของประเภท ! สามารถถูกบีบบังคับแปลงประเภท (coerced) ไปเป็นประเภทอื่นใดก็ได้ เราได้รับอนุญาตให้จบแขน match นี้ด้วย continue เพราะ continue ไม่ได้ส่งคืนค่า แต่ย้ายการควบคุมกลับไปที่จุดเริ่มต้นของลูป ดังนั้นในกรณีที่เป็น Err เราจะไม่กำหนดค่าใดๆ ให้กับ guess

never type มีประโยชน์กับแมโคร panic! เช่นกัน จำฟังก์ชัน unwrap ที่เราเรียกใช้บนค่า Option<T> เพื่อสร้างค่าหรือเกิด panic ตามนิยามนี้ได้ไหม:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

ในโค้ดนี้ สิ่งเดียวกันนี้เกิดขึ้นเหมือนกับใน match ในโค้ดตัวอย่างที่ 20-27: Rust เห็นว่า val มีประเภท T และ panic! มีประเภท ! ดังนั้นผลลัพธ์ของนิพจน์ match โดยรวมจึงเป็น T โค้ดนี้ทำงานได้เพราะ panic! ไม่ได้สร้างค่า มันยุติการทำงานของโปรแกรม ในกรณี None เราจะไม่ส่งคืนค่าจาก unwrap โค้ดนี้จึงถูกต้องและใช้ได้

นิพจน์สุดท้ายที่มีประเภท ! คือลูป:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

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

Dynamically Sized Types and the Sized Trait

Rust จำเป็นต้องรู้รายละเอียดบางอย่างเกี่ยวกับประเภทข้อมูลของมัน เช่น ขนาดพื้นที่ที่จะจองให้กับค่าของประเภทใดประเภทหนึ่งโดยเฉพาะ สิ่งนี้ทิ้งมุมหนึ่งของระบบประเภทให้ชวนสับสนเล็กน้อยในตอนแรก นั่นคือแนวคิดของ ประเภทที่มีขนาดแปรผันขณะรันไทม์ (dynamically sized types) หรือที่บางครั้งเรียกว่า DSTs หรือ unsized types ประเภทเหล่านี้ช่วยให้เราเขียนโค้ดโดยใช้ค่าที่เราสามารถทราบขนาดของมันได้ในขณะรันไทม์เท่านั้น

เรามาเจาะลึกรายละเอียดของประเภทที่มีขนาดแปรผันขณะรันไทม์ที่เรียกว่า str ซึ่งเราได้ใช้มาตลอดทั้งเล่ม ถูกต้องแล้ว ไม่ใช่ &str แต่เป็น str ลอยๆ ตัวมันเองนี่แหละคือ DST ในหลายๆ กรณี เช่น เมื่อจัดเก็บข้อความที่ป้อนโดยผู้ใช้ เราไม่สามารถรู้ได้ว่าสตริงมีความยาวเท่าใดจนกว่าจะถึงเวลาที่โปรแกรมทำงาน (runtime) นั่นหมายความว่าเราไม่สามารถสร้างตัวแปรประเภท str ได้ และไม่สามารถรับอาร์กิวเมนต์ประเภท str ได้เช่นกัน พิจารณาโค้ดต่อไปนี้ซึ่งทำงานไม่ได้:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust จำเป็นต้องรู้ว่าจะต้องจองหน่วยความจำเท่าใดสำหรับค่าใดๆ ของประเภทหนึ่งๆ และค่าทั้งหมดของประเภทนั้นจะต้องใช้ขนาดหน่วยความจำเท่ากัน หาก Rust อนุญาตให้เราเขียนโค้ดนี้ ค่า str ทั้งสองนี้จะต้องใช้ขนาดพื้นที่เท่ากัน แต่พวกมันมีความยาวต่างกัน: s1 ต้องการพื้นที่จัดเก็บ 12 ไบต์ และ s2 ต้องการ 15 ไบต์ นี่คือเหตุผลว่าทำไมจึงไม่สามารถสร้างตัวแปรที่ถือครองประเภทที่มีขนาดแปรผันขณะรันไทม์ได้

แล้วเราจะทำอย่างไร? ในกรณีนี้ คุณรู้อยู่แล้ว: เราทำให้ประเภทของ s1 และ s2 เป็น string slice (&str) แทนที่จะเป็น str ย้อนกลับไปดูหัวข้อ “String Slices” ในบทที่ 4 โครงสร้างข้อมูล slice จะจัดเก็บเฉพาะตำแหน่งเริ่มต้นและความยาวของ slice เท่านั้น ดังนั้น แม้ว่า &T จะเป็นค่าเดียวที่จัดเก็บตำแหน่งที่อยู่หน่วยความจำ (memory address) ที่ T ตั้งอยู่ แต่ string slice กลับเป็นค่า สอง ค่า: ตำแหน่งที่อยู่ของ str และความยาวของมัน ดังนั้น เราจึงสามารถทราบขนาดของค่า string slice ได้ตั้งแต่ตอนคอมไพล์: มันคือสองเท่าของความยาวของ usize นั่นคือ เราทราบขนาดของ string slice เสมอ ไม่ว่าสตริงที่มันอ้างอิงถึงจะยาวแค่ไหนก็ตาม โดยทั่วไป นี่คือวิธีที่ประเภทที่มีขนาดแปรผันขณะรันไทม์ถูกใช้ใน Rust: พวกมันมีข้อมูลกำกับ (metadata) พิเศษที่จัดเก็บขนาดของข้อมูลไดนามิกนั้น กฎเหล็กของประเภทที่มีขนาดแปรผันขณะรันไทม์คือ เราต้องวางค่าของประเภทที่มีขนาดแปรผันขณะรันไทม์ไว้หลังพอยน์เตอร์ (pointer) บางชนิดเสมอ

เราสามารถรวม str เข้ากับพอยน์เตอร์ได้ทุกประเภท: ตัวอย่างเช่น Box<str> หรือ Rc<str> ในความเป็นจริง คุณเคยเห็นสิ่งนี้มาก่อนแต่กับประเภทที่มีขนาดแปรผันขณะรันไทม์อีกชนิดหนึ่ง: traits ทุกๆ trait คือประเภทที่มีขนาดแปรผันขณะรันไทม์ที่เราสามารถอ้างถึงได้โดยใช้ชื่อของเทรต ในหัวข้อ “การใช้ Trait Objects เพื่อเป็นตัวแทนนามธรรมสำหรับพฤติกรรมร่วม” ในบทที่ 18 เราได้กล่าวไว้ว่าหากต้องการใช้ traits เป็น trait objects เราต้องวางพวกมันไว้หลังพอยน์เตอร์ เช่น &dyn Trait หรือ Box<dyn Trait> (Rc<dyn Trait> ก็ใช้ได้เช่นกัน)

ในการทำงานกับ DSTs Rust มอบ Sized trait เพื่อพิจารณาว่าขนาดของประเภทนั้นๆ เป็นที่ทราบกันตั้งแต่ตอนคอมไพล์หรือไม่ เทรตนี้ถูกอิมพลีเมนต์ให้อัตโนมัติสำหรับทุกสิ่งที่ทราบขนาดแล้วตั้งแต่ตอนคอมไพล์ นอกจากนี้ Rust ยังเพิ่มข้อจำกัดบน Sized ให้กับทุกๆ ฟังก์ชันเจเนอริกโดยปริยาย (implicitly) นั่นคือ นิยามฟังก์ชันเจเนอริกแบบนี้:

fn generic<T>(t: T) {
    // --snip--
}

ในความเป็นจริงจะถูกปฏิบัติเสมือนว่าเราเขียนแบบนี้:

fn generic<T: Sized>(t: T) {
    // --snip--
}

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

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

ข้อจำกัดเทรตบน ?Sized หมายถึง “T อาจจะเป็น Sized หรือไม่เป็น Sized ก็ได้” และสัญลักษณ์นี้จะยกเลิกค่าเริ่มต้นที่ประเภทเจเนอริกต้องมีขนาดทราบแน่ชัดตอนคอมไพล์ ไวยากรณ์ ?Trait ที่มีความหมายนี้ใช้ได้เฉพาะกับ Sized เท่านั้น ไม่สามารถใช้กับเทรตอื่นได้

นอกจากนี้ให้สังเกตว่าเราได้เปลี่ยนประเภทของพารามิเตอร์ t จาก T เป็น &T เนื่องจากประเภทนั้นอาจจะไม่เป็น Sized เราจึงต้องใช้มันไว้หลังพอยน์เตอร์บางชนิด ในกรณีนี้ เราได้เลือกใช้การอ้างอิง (reference)

ถัดไป เราจะพูดถึงฟังก์ชันและโคลเชอร์กันครับ!

Advanced Functions and Closures

Advanced Functions and Closures

หัวข้อนี้จะสำรวจคุณสมบัติขั้นสูงบางประการที่เกี่ยวข้องกับฟังก์ชันและโคลเชอร์ (closures) ซึ่งรวมถึงพอยน์เตอร์ฟังก์ชัน (function pointers) และการส่งคืนค่าเป็นโคลเชอร์

Function Pointers

เราได้พูดถึงวิธีส่งโคลเชอร์ไปยังฟังก์ชันมาแล้ว คุณยังสามารถส่งฟังก์ชันธรรมดาไปยังฟังก์ชันได้อีกด้วย! เทคนิคนี้มีประโยชน์เมื่อคุณต้องการส่งฟังก์ชันที่คุณได้นิยามไว้แล้ว แทนที่จะนิยามโคลเชอร์ขึ้นมาใหม่ ฟังก์ชันจะถูกบีบบังคับแปลงประเภท (coerce) ไปเป็นประเภท fn (ตัว f พิมพ์เล็ก) ซึ่งไม่ควรสับสนกับ Fn closure trait ประเภท fn ถูกเรียกว่า พอยน์เตอร์ฟังก์ชัน (function pointer) การส่งฟังก์ชันด้วยพอยน์เตอร์ฟังก์ชันจะทำให้คุณสามารถใช้ฟังก์ชันเป็นอาร์กิวเมนต์ให้กับฟังก์ชันอื่นได้

ไวยากรณ์สำหรับระบุว่าพารามิเตอร์เป็นพอยน์เตอร์ฟังก์ชันนั้นคล้ายกับของโคลเชอร์ ดังแสดงในโค้ดตัวอย่างที่ 20-28 ซึ่งเรานิยามฟังก์ชัน add_one ที่บวก 1 เข้ากับพารามิเตอร์ของมัน ฟังก์ชัน do_twice รับพารามิเตอร์สองตัว: พอยน์เตอร์ฟังก์ชันที่ไปยังฟังก์ชันใดๆ ที่รับพารามิเตอร์ i32 และคืนค่าเป็น i32 และค่า i32 อีกหนึ่งค่า ฟังก์ชัน do_twice จะเรียกฟังก์ชัน f สองครั้ง โดยส่งค่า arg ไปให้ จากนั้นบวกผลลัพธ์ของการเรียกฟังก์ชันทั้งสองเข้าด้วยกัน ฟังก์ชัน main จะเรียก do_twice ด้วยอาร์กิวเมนต์ add_one และ 5

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}

โค้ดนี้จะพิมพ์ The answer is: 12 ระบุว่าพารามิเตอร์ f ใน do_twice เป็น fn ที่รับพารามิเตอร์หนึ่งตัวประเภท i32 และคืนค่าเป็น i32 จากนั้นเราสามารถเรียก f ภายในบอดี้ของ do_twice ได้ ใน main เราสามารถส่งชื่อฟังก์ชัน add_one เป็นอาร์กิวเมนต์แรกให้กับ do_twice ได้

แตกต่างจากโคลเชอร์ fn เป็นประเภท (type) ไม่ใช่เทรต (trait) ดังนั้นเราจึงระบุ fn เป็นประเภทพารามิเตอร์โดยตรง แทนที่จะประกาศพารามิเตอร์ประเภทเจเนอริกที่มีเทรต Fn ตัวใดตัวหนึ่งเป็นข้อจำกัดเทรต (trait bound)

พอยน์เตอร์ฟังก์ชันอิมพลีเมนต์ทั้งสาม closure traits (Fn, FnMut, และ FnOnce) ซึ่งหมายความว่าคุณสามารถส่งพอยน์เตอร์ฟังก์ชันเป็นอาร์กิวเมนต์ให้กับฟังก์ชันที่คาดหวังโคลเชอร์ได้เสมอ ทางที่ดีที่สุดคือควรเขียนฟังก์ชันโดยใช้ประเภทเจเนอริกและ closure traits ตัวใดตัวหนึ่ง เพื่อให้ฟังก์ชันของคุณสามารถรับได้ทั้งฟังก์ชันและโคลเชอร์

อย่างไรก็ตาม ตัวอย่างหนึ่งที่คุณต้องการรับเฉพาะ fn และไม่รับโคลเชอร์คือ เมื่อต้องเชื่อมต่อกับโค้ดภายนอกที่ไม่มีโคลเชอร์: ฟังก์ชันในภาษา C สามารถรับฟังก์ชันเป็นอาร์กิวเมนต์ได้ แต่ภาษา C ไม่มีโคลเชอร์

เพื่อเป็นตัวอย่างที่คุณสามารถใช้ได้ทั้งโคลเชอร์ที่นิยามแบบอินไลน์ (inline) หรือฟังก์ชันที่มีชื่อ ให้ดูการใช้งานเมธอด map ที่มีให้โดย Iterator trait ในไลบรารีมาตรฐาน หากต้องการใช้เมธอด map เปลี่ยนเวกเตอร์ของตัวเลขให้เป็นเวกเตอร์ของสตริง เราสามารถใช้โคลเชอร์ได้ ดังแสดงในโค้ดตัวอย่างที่ 20-29

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}

หรือเราสามารถระบุชื่อฟังก์ชันเป็นอาร์กิวเมนต์ให้กับ map แทนโคลเชอร์ได้ โค้ดตัวอย่างที่ 20-30 แสดงให้เห็นว่ารูปแบบนี้จะออกมาเป็นอย่างไร

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}

โปรดสังเกตว่าเราต้องใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ (fully qualified syntax) ที่เราพูดถึงในหัวข้อ “Advanced Traits” เนื่องจากมีหลายฟังก์ชันที่มีชื่อว่า to_string

ในที่นี้ เราใช้ฟังก์ชัน to_string ที่นิยามไว้ใน ToString trait ซึ่งไลบรารีมาตรฐานอิมพลีเมนต์ไว้ให้สำหรับประเภทใดๆ ที่อิมพลีเมนต์ Display

จำได้ไหมจากหัวข้อ “ค่านิวเมอเรชัน (Enum Values)” ในบทที่ 6 ว่าชื่อของแต่ละตัวแปรผันของอีนัม (enum variant) ที่เรานิยามจะกลายเป็นฟังก์ชันสร้างค่าเริ่มต้น (initializer function) ด้วยเช่นกัน เราสามารถใช้ฟังก์ชันสร้างค่าเริ่มต้นเหล่านี้เป็นพอยน์เตอร์ฟังก์ชันที่อิมพลีเมนต์ closure traits ได้ ซึ่งหมายความว่าเราสามารถระบุฟังก์ชันสร้างค่าเริ่มต้นเป็นอาร์กิวเมนต์สำหรับเมธอดที่รับโคลเชอร์ได้ ดังที่เห็นในโค้ดตัวอย่างที่ 20-31

fn main() {
    // ANCHOR: here
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
    // ANCHOR_END: here
}

ในที่นี้ เราสร้างอินสแตนซ์ Status::Value โดยใช้ค่า u32 แต่ละตัวในช่วงที่ map ถูกเรียกใช้ โดยผ่านฟังก์ชันสร้างค่าเริ่มต้นของ Status::Value บางคนชอบสไตล์นี้และบางคนชอบใช้โคลเชอร์ ทั้งสองแบบจะถูกคอมไพล์เป็นโค้ดเดียวกัน ดังนั้นเลือกใช้สไตล์ใดก็ได้ที่คุณรู้สึกว่าชัดเจนกว่าสำหรับคุณ

Returning Closures

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

แต่โดยปกติคุณจะใช้ไวยากรณ์ impl Trait ที่เราเรียนรู้กันในบทที่ 10 คุณสามารถส่งคืนประเภทฟังก์ชันใดก็ได้ โดยใช้ Fn, FnOnce, และ FnMut ตัวอย่างเช่น โค้ดในโค้ดตัวอย่างที่ 20-32 จะคอมไพล์ผ่านได้ด้วยดี

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}

อย่างไรก็ตาม ดังที่เราได้ตั้งข้อสังเกตไว้ในหัวข้อ “การอนุมานและการระบุประเภทโคลเชอร์” ในบทที่ 13 โคลเชอร์แต่ละอันก็เป็นประเภทเฉพาะที่แตกต่างกันในตัวเองเช่นกัน หากคุณต้องการทำงานกับหลายฟังก์ชันที่มีลายเซ็นเดียวกันแต่มีการอิมพลีเมนต์ต่างกัน คุณจะต้องใช้ trait object สำหรับพวกมัน พิจารณาสิ่งที่จะเกิดขึ้นหากคุณเขียนโค้ดลักษณะดังแสดงในโค้ดตัวอย่างที่ 20-33

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}

ในที่นี้ เรามีฟังก์ชันสองอันคือ returns_closure และ returns_initialized_closure ซึ่งทั้งคู่ส่งคืน impl Fn(i32) -> i32 ข้อสังเกตคือโคลเชอร์ที่พวกมันส่งคืนนั้นแตกต่างกัน แม้ว่าจะอิมพลีเมนต์ประเภทเดียวกันก็ตาม หากเราพยายามคอมไพล์สิ่งนี้ Rust จะแจ้งให้เราทราบว่าไม่สามารถทำงานได้:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
 2 |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
 9 | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32`
              found opaque type `impl Fn(i32) -> i32`
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

ข้อความแสดงข้อผิดพลาดบอกเราว่าเมื่อใดก็ตามที่เราส่งคืน impl Trait Rust จะสร้าง opaque type (ประเภททึบแสง) ที่ไม่ซ้ำกันขึ้นมา ซึ่งเป็นประเภทที่เราไม่สามารถมองเห็นรายละเอียดของสิ่งที่ Rust สร้างขึ้นมาให้เราได้ และไม่สามารถเดาประเภทที่ Rust จะสร้างขึ้นเพื่อเขียนเองได้ ดังนั้น แม้ว่าฟังก์ชันเหล่านี้จะส่งคืนโคลเชอร์ที่อิมพลีเมนต์เทรตเดียวกันคือ Fn(i32) -> i32 แต่ opaque types ที่ Rust สร้างขึ้นสำหรับแต่ละอันก็ยังคงแตกต่างกัน (สิ่งนี้คล้ายกับวิธีที่ Rust สร้างประเภทคอนกรีตที่แตกต่างกันสำหรับบล็อก async ที่แยกจากกัน แม้ว่าจะมียิวด์ประเภทเดียวกัน ดังที่เราเห็นในหัวข้อ “ประเภท Pin และ Unpin Trait” ในบทที่ 17) เราได้เห็นแนวทางแก้ไขปัญหานี้มาแล้วสองสามครั้ง: เราสามารถใช้ trait object ดังแสดงในโค้ดตัวอย่างที่ 20-34

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}

โค้ดนี้จะคอมไพล์ได้สำเร็จ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ trait objects โปรดอ้างอิงหัวข้อ “การใช้ Trait Objects เพื่อเป็นตัวแทนนามธรรมสำหรับพฤติกรรมร่วม” ในบทที่ 18

ถัดไป เราจะมาดูเรื่องแมโคร (macros) กันครับ!

Macros

Macros

เราได้ใช้แมโคร (macros) อย่าง println! มาตลอดทั้งหนังสือเล่มนี้ แต่เรายังไม่ได้สำรวจอย่างเต็มที่ว่าแมโครคืออะไรและทำงานอย่างไร คำว่า แมโคร (macro) หมายถึงตระกูลของคุณสมบัติใน Rust ซึ่งประกอบด้วยแมโครแบบแจ้งประกาศ (declarative macros) ด้วย macro_rules! และแมโครเชิงขั้นตอน (procedural macros) อีกสามชนิด:

  • แมโคร #[derive] กำหนดเอง (custom #[derive] macros) ที่ระบุโค้ดที่ถูกเพิ่มเข้ามาด้วยแอททริบิวต์ derive ที่ใช้บนสตรักต์และอีนัม
  • แมโครคล้ายแอททริบิวต์ (attribute-like macros) ที่นิยามแอททริบิวต์กำหนดเองเพื่อใช้งานบนไอเทมใดๆ ก็ได้
  • แมโครคล้ายฟังก์ชัน (function-like macros) ที่ดูเหมือนการเรียกใช้ฟังก์ชัน แต่ปฏิบัติการบนโทเค็น (tokens) ที่ถูกระบุเป็นอาร์กิวเมนต์ของมัน

เราจะพูดถึงแต่ละชนิดตามลำดับ แต่ก่อนอื่น เรามาดูกันว่าทำไมเราจึงจำเป็นต้องมีแมโคร ทั้งๆ ที่เรามีฟังก์ชันอยู่แล้ว

The Difference Between Macros and Functions

โดยพื้นฐานแล้ว แมโครคือวิธีเขียนโค้ดเพื่อสร้างโค้ดอื่น ซึ่งเป็นที่รู้จักกันในชื่อ การเขียนโปรแกรมเชิงอภิพรรณนา (metaprogramming) ในภาคผนวก C เราพูดถึงแอททริบิวต์ derive ซึ่งสร้างการอิมพลีเมนต์เทรตต่างๆ ให้คุณโดยอัตโนมัติ เรายังได้ใช้แมโคร println! และ vec! มาตลอดทั้งเล่ม แมโครเหล่านี้ทั้งหมดจะ ขยายผล (expand) เพื่อสร้างโค้ดมากกว่าโค้ดที่คุณเขียนขึ้นเองด้วยตนเอง

การเขียนโปรแกรมเชิงอภิพรรณนามีประโยชน์ในการลดปริมาณโค้ดที่คุณต้องเขียนและดูแลรักษา ซึ่งก็เป็นหนึ่งในบทบาทของฟังก์ชันเช่นกัน อย่างไรก็ตาม แมโครมีพลังเพิ่มเติมบางอย่างที่ฟังก์ชันไม่มี

ลายเซ็นฟังก์ชัน (function signature) ต้องประกาศจำนวนและประเภทของพารามิเตอร์ที่ฟังก์ชันมี ในทางกลับกัน แมโครสามารถรับจำนวนพารามิเตอร์ที่ไม่คงที่ได้: เราสามารถเรียก println!("hello") ด้วยอาร์กิวเมนต์เดียว หรือ println!("hello {}", name) ด้วยสองอาร์กิวเมนต์ได้ นอกจากนี้ แมโครยังถูกขยายผลก่อนที่คอมไพเลอร์จะตีความความหมายของโค้ด ดังนั้นแมโครจึงสามารถทำสิ่งต่างๆ เช่น อิมพลีเมนต์เทรตให้กับประเภทที่กำหนดได้ แต่ฟังก์ชันทำไม่ได้ เนื่องจากฟังก์ชันถูกเรียกใช้ในขณะรันไทม์ และเทรตจำเป็นต้องถูกอิมพลีเมนต์ในขณะคอมไพล์

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

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

Declarative Macros for General Metaprogramming

รูปแบบของแมโครที่ใช้กันแพร่หลายที่สุดใน Rust คือ แมโครแบบแจ้งประกาศ (declarative macro) ซึ่งบางครั้งก็ถูกเรียกว่า “macros by example”, “macro_rules! macros” หรือเรียกสั้นๆ ว่า “แมโคร” โดยหัวใจหลักแล้ว แมโครแบบแจ้งประกาศช่วยให้คุณเขียนบางสิ่งที่คล้ายกับนิพจน์ match ใน Rust ได้ ดังที่ได้หารือกันในบทที่ 6 นิพจน์ match คือโครงสร้างควบคุมที่รับนิพจน์มา เปรียบเทียบค่าผลลัพธ์ของนิพจน์กับรูปแบบ (patterns) แล้วรันโค้ดที่สัมพันธ์กับรูปแบบที่ตรงกัน แมโครก็ทำการเปรียบเทียบค่ากับรูปแบบที่สัมพันธ์กับโค้ดเฉพาะเช่นกัน: ในสถานการณ์นี้ ค่าคือโค้ดต้นฉบับ Rust ตัวอักษรที่ถูกส่งไปยังแมโคร รูปแบบจะถูกเปรียบเทียบกับโครงสร้างของโค้ดต้นฉบับนั้น และโค้ดที่สัมพันธ์กับแต่ละรูปแบบเมื่อตรงกัน จะเข้าแทนที่โค้ดที่ส่งไปยังแมโคร ทั้งหมดนี้เกิดขึ้นในระหว่างการคอมไพล์

ในการนิยามแมโคร คุณจะใช้โครงสร้าง macro_rules! เรามาสำรวจวิธีใช้ macro_rules! โดยดูวิธีที่แมโคร vec! ถูกนิยามไว้ บทที่ 8 ได้ครอบคลุมวิธีที่เราสามารถใช้แมโคร vec! เพื่อสร้างเวกเตอร์ใหม่ที่มีค่าเฉพาะได้ ตัวอย่างเช่น แมโครต่อไปนี้สร้างเวกเตอร์ใหม่ที่บรรจุจำนวนเต็มสามตัว:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

เรายังสามารถใช้แมโคร vec! เพื่อสร้างเวกเตอร์ของจำนวนเต็มสองตัว หรือเวกเตอร์ของ string slices ห้าตัวได้ด้วย เราไม่สามารถใช้ฟังก์ชันทำสิ่งเดียวกันนี้ได้ เพราะเราไม่ทราบจำนวนหรือประเภทของค่าล่วงหน้า

โค้ดตัวอย่างที่ 20-35 แสดงนิยามแบบย่อของแมโคร vec!

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

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

คำอธิบาย #[macro_export] บ่งบอกว่าแมโครนี้ควรจะเปิดให้ใช้งานได้เมื่อใดก็ตามที่เครตที่นิยามแมโครนี้ถูกนำเข้าสู่ขอบเขต หากไม่มีคำอธิบายนี้ แมโครจะไม่สามารถถูกนำเข้าสู่ขอบเขตได้

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

โครงสร้างในบอดี้ของ vec! คล้ายกับโครงสร้างของนิพจน์ match ในที่นี้เรามีแขนข้างหนึ่งที่มีรูปแบบ ( $( $x:expr ),* ) ตามด้วย => และบล็อกโค้ดที่สัมพันธ์กับรูปแบบนี้ หากรูปแบบตรงกัน บล็อกโค้ดที่สัมพันธ์กันจะถูกปล่อยออกมา (emitted) เนื่องจากนี่เป็นรูปแบบเดียวในแมโครนี้ จึงมีวิธีจับคู่ที่ถูกต้องเพียงวิธีเดียวเท่านั้น รูปแบบอื่นใดจะส่งผลให้เกิดข้อผิดพลาด แมโครที่ซับซ้อนกว่าจะมีมากกว่าหนึ่งแขน

ไวยากรณ์รูปแบบที่ถูกต้องในนิยามแมโครจะแตกต่างจากไวยากรณ์รูปแบบที่ครอบคลุมในบทที่ 19 เนื่องจากรูปแบบแมโครจะถูกเปรียบเทียบกับโครงสร้างโค้ด Rust ไม่ใช่เปรียบเทียบกับค่า เรามาไล่ดูว่าชิ้นส่วนรูปแบบในโค้ดตัวอย่างที่ 20-29 หมายถึงอะไร; สำหรับไวยากรณ์รูปแบบแมโครฉบับเต็ม โปรดดู Rust Reference

ประการแรก เราใช้วงเล็บคู่หนึ่งเพื่อครอบคลุมรูปแบบทั้งหมด เราใช้เครื่องหมายดอลลาร์ ($) เพื่อประกาศตัวแปรในระบบแมโครที่จะบรรจุโค้ด Rust ที่ตรงกับรูปแบบ เครื่องหมายดอลลาร์ช่วยให้ชัดเจนว่านี่คือตัวแปรแมโคร ตรงข้ามกับตัวแปร Rust ปกติ ถัดมาคือวงเล็บคู่หนึ่งที่จับค่าที่ตรงกับรูปแบบภายในวงเล็บเพื่อนำไปใช้ในโค้ดทดแทน ภายใน $() คือ $x:expr ซึ่งตรงกับนิพจน์ Rust ใดๆ และตั้งชื่อนิพจน์นั้นว่า $x

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

เมื่อเราเรียกใช้แมโครนี้ด้วย vec![1, 2, 3]; รูปแบบ $x จะตรงกันสามครั้งกับสามนิพจน์คือ 1, 2, และ 3

ตอนนี้มาดูรูปแบบในบอดี้ของโค้ดที่สัมพันธ์กับแขนนี้: temp_vec.push() ภายใน $()* จะถูกสร้างขึ้นสำหรับแต่ละส่วนที่ตรงกับ $() ในรูปแบบศูนย์ครั้งหรือมากกว่านั้นขึ้นอยู่กับจำนวนครั้งที่รูปแบบตรงกัน $x ถูกแทนที่ด้วยแต่ละนิพจน์ที่ตรงกัน เมื่อเราเรียกแมโครนี้ด้วย vec![1, 2, 3]; โค้ดที่สร้างขึ้นเพื่อแทนที่การเรียกแมโครนี้จะเป็นดังนี้:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

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

หากต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีเขียนแมโคร โปรดศึกษาจากเอกสารออนไลน์หรือทรัพยากรอื่นๆ เช่น “The Little Book of Rust Macros” ซึ่งเริ่มโดย Daniel Keep และสานต่อโดย Lukas Wirth

Procedural Macros for Generating Code from Attributes

รูปแบบที่สองของแมโครคือแมโครเชิงขั้นตอน (procedural macro) ซึ่งทำหน้าที่เหมือนฟังก์ชันมากกว่า (และเป็นชนิดหนึ่งของโพรซีเจอร์) แมโครเชิงขั้นตอน จะรับโค้ดบางอย่างเข้ามาเป็นอินพุต ดำเนินการกับโค้ดนั้น และสร้างโค้ดบางอย่างออกไปเป็นเอาต์พุต แทนที่จะเปรียบเทียบกับรูปแบบและทดแทนโค้ดด้วยโค้ดอื่นอย่างที่แมโครแบบแจ้งประกาศทำ แมโครเชิงขั้นตอนมีสามชนิดคือ derive กำหนดเอง, คล้ายแอททริบิวต์, และคล้ายฟังก์ชัน ซึ่งทั้งหมดทำงานในลักษณะที่คล้ายคลึงกัน

เมื่อสร้างแมโครเชิงขั้นตอน นิยามของมันจะต้องอาศัยอยู่ในเครตของตัวเองด้วยประเภทเครตพิเศษ สิ่งนี้มีเหตุผลทางเทคนิคที่ซับซ้อนที่เราหวังว่าจะสามารถขจัดออกไปได้ในอนาคต ในโค้ดตัวอย่างที่ 20-36 เราแสดงวิธีนิยามแมโครเชิงขั้นตอน โดยที่ some_attribute เป็นตัวแทนการใช้แมโครแต่ละชนิด

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

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

เรามาดูแมโครเชิงขั้นตอนชนิดต่างๆ กัน เราจะเริ่มด้วยแมโคร derive กำหนดเอง แล้วอธิบายความแตกต่างเล็กๆ น้อยๆ ที่ทำให้รูปแบบอื่นๆ แตกต่างออกไป

Custom derive Macros

เรามาสร้างเครตชื่อ hello_macro ที่นิยามเทรตชื่อ HelloMacro พร้อมฟังก์ชันที่เชื่อมโยงอยู่หนึ่งฟังก์ชันชื่อ hello_macro แทนที่จะให้ผู้ใช้ของเราอิมพลีเมนต์ HelloMacro trait สำหรับแต่ละประเภทของพวกเขา เราจะจัดเตรียมแมโครเชิงขั้นตอนไว้เพื่อให้ผู้ใช้สามารถเขียนคำอธิบายประเภทของตนเองด้วย #[derive(HelloMacro)] เพื่อรับการอิมพลีเมนต์เริ่มต้นของฟังก์ชัน hello_macro การอิมพลีเมนต์เริ่มต้นจะพิมพ์ Hello, Macro! My name is TypeName! โดยที่ TypeName คือชื่อของประเภทที่เทรตนี้ถูกนิยามไว้ กล่าวอีกนัยหนึ่ง เราจะเขียนเครตที่ช่วยให้โปรแกรมเมอร์คนอื่นสามารถเขียนโค้ดดังเช่นโค้ดตัวอย่างที่ 20-37 โดยใช้เครตของเราได้

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

โค้ดนี้จะพิมพ์ Hello, Macro! My name is Pancakes! เมื่อเราทำเสร็จสิ้น ขั้นตอนแรกคือสร้างไลบรารีเครตใหม่ ดังนี้:

$ cargo new hello_macro --lib

ถัดไป ในโค้ดตัวอย่างที่ 20-38 เราจะนิยาม HelloMacro trait และฟังก์ชันที่เชื่อมโยงของมัน

pub trait HelloMacro {
    fn hello_macro();
}

เรามีเทรตและฟังก์ชันของมันแล้ว ถึงจุดนี้ ผู้ใช้เครตของเราสามารถอิมพลีเมนต์เทรตเพื่อบรรลุฟังก์ชันการทำงานที่ต้องการได้ ดังแสดงในโค้ดตัวอย่างที่ 20-39

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

อย่างไรก็ตาม พวกเขาจำเป็นต้องเขียนบล็อกการอิมพลีเมนต์สำหรับแต่ละประเภทที่ต้องการใช้กับ hello_macro; เราต้องการช่วยให้พวกเขาไม่ต้องทำภาระงานนี้

นอกจากนี้ เรายังไม่สามารถจัดเตรียมฟังก์ชัน hello_macro พร้อมการอิมพลีเมนต์เริ่มต้นที่จะพิมพ์ชื่อของประเภทที่เทรตอิมพลีเมนต์อยู่ได้: เนื่องจาก Rust ไม่มีตระหนักรู้โครงสร้างตนเอง (reflection) ดังนั้นมันจึงไม่สามารถค้นหาชื่อประเภทในขณะรันไทม์ได้ เราจึงต้องใช้แมโครเพื่อสร้างโค้ดในขณะคอมไพล์

ขั้นตอนถัดไปคือการนิยามแมโครเชิงขั้นตอน ณ ขณะที่เขียนนี้ แมโครเชิงขั้นตอนจำเป็นต้องอยู่ในเครตของมันเอง ในที่สุดข้อจำกัดนี้อาจถูกยกเลิกไป ข้อตกลงในการจัดโครงสร้างเครตและแมโครเครตมีดังนี้: สำหรับเครตชื่อ foo เครตแมโครเชิงขั้นตอน derive กำหนดเองจะเรียกว่า foo_derive เรามาริมเริ่มเครตใหม่ชื่อ hello_macro_derive ภายในโปรเจกต์ hello_macro ของเรากัน:

$ cargo new hello_macro_derive --lib

สองเครตของเรามีความเกี่ยวข้องกันอย่างใกล้ชิด ดังนั้นเราจึงสร้างเครตแมโครเชิงขั้นตอนไว้ภายในไดเรกทอรีของเครต hello_macro ของเรา หากเราเปลี่ยนนิยามเทรตใน hello_macro เราก็จะต้องเปลี่ยนการอิมพลีเมนต์ของแมโครเชิงขั้นตอนใน hello_macro_derive ด้วย ทั้งสองเครตจำเป็นต้องถูกเผยแพร่แยกกัน และโปรแกรมเมอร์ที่ใช้เครตเหล่านี้จะต้องเพิ่มทั้งสองเป็น dependencies และนำทั้งคู่เข้าสู่ขอบเขต เราสามารถให้เครต hello_macro ใช้ hello_macro_derive เป็น dependency และส่งออกโค้ดแมโครเชิงขั้นตอนซ้ำ (re-export) แทนได้ อย่างไรก็ตาม วิธีที่เราจัดโครงสร้างโปรเจกต์ช่วยให้โปรแกรมเมอร์สามารถใช้ hello_macro ได้ แม้ว่าพวกเขาจะไม่ต้องการฟังก์ชันการทำงานของ derive ก็ตาม

เราจำเป็นต้องประกาศเครต hello_macro_derive เป็นเครตแมโครเชิงขั้นตอน เรายังต้องการฟังก์ชันการทำงานจากเครต syn และ quote ดังที่คุณจะได้เห็นในอีกสักครู่ ดังนั้นเราจึงต้องเพิ่มพวกมันเป็น dependencies เพิ่มเติมดังต่อไปนี้ลงในไฟล์ Cargo.toml ของ hello_macro_derive:

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

ในการเริ่มนิยามแมโครเชิงขั้นตอน ให้ใส่โค้ดในโค้ดตัวอย่างที่ 20-40 ลงในไฟล์ src/lib.rs ของคุณสำหรับเครต hello_macro_derive โปรดทราบว่าโค้ดนี้จะยังไม่คอมไพล์จนกว่าเราจะเพิ่มนิยามสำหรับฟังก์ชัน impl_hello_macro

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}

สังเกตว่าเราได้แยกโค้ดออกเป็นฟังก์ชัน hello_macro_derive ซึ่งมีหน้าที่ในการพาร์ส (parse) TokenStream และฟังก์ชัน impl_hello_macro ซึ่งมีหน้าที่แปลงโครงสร้างต้นไม้ไวยากรณ์ (syntax tree): สิ่งนี้ทำให้การเขียนแมโครเชิงขั้นตอนสะดวกยิ่งขึ้น โค้ดในฟังก์ชันภายนอก (hello_macro_derive ในกรณีนี้) จะเหมือนกันสำหรับเครตแมโครเชิงขั้นตอนเกือบทุกอันที่คุณเห็นหรือสร้างขึ้น โค้ดที่คุณระบุในบอดี้ของฟังก์ชันภายใน (impl_hello_macro ในกรณีนี้) จะแตกต่างกันไปตามวัตถุประสงค์ของแมโครเชิงขั้นตอนของคุณ

เราได้แนะนำเครตใหม่สามอัน: proc_macro, syn, และ quote เครต proc_macro ติดมากับ Rust อยู่แล้ว ดังนั้นเราจึงไม่ต้องเพิ่มลงใน dependencies ใน Cargo.toml เครต proc_macro คือ API ของคอมไพเลอร์ที่ช่วยให้เราอ่านและจัดการโค้ด Rust จากโค้ดของเราได้

เครต syn พาร์สโค้ด Rust จากสตริงให้เป็นโครงสร้างข้อมูลที่เราสามารถดำเนินการสิ่งต่างๆ บนนั้นได้ เครต quote แปลงโครงสร้างข้อมูลของ syn กลับเป็นโค้ด Rust เครตเหล่านี้ช่วยให้พาร์สโค้ด Rust ชนิดใดๆ ที่เราต้องการจัดการได้ง่ายขึ้นมาก: การเขียนตัวพาร์สฉบับเต็มสำหรับโค้ด Rust นั้นไม่ใช่งานง่ายๆ เลย

ฟังก์ชัน hello_macro_derive จะถูกเรียกเมื่อผู้ใช้ไลบรารีของเราระบุ #[derive(HelloMacro)] บนประเภทข้อมูล สิ่งนี้เป็นไปได้เพราะเราได้กำกับฟังก์ชัน hello_macro_derive ที่นี่ด้วย proc_macro_derive และระบุชื่อ HelloMacro ซึ่งตรงกับชื่อเทรตของเรา; นี่คือข้อตกลงที่แมโครเชิงขั้นตอนส่วนใหญ่ปฏิบัติตาม

ฟังก์ชัน hello_macro_derive จะแปลง input จาก TokenStream ไปเป็นโครงสร้างข้อมูลที่เราสามารถตีความและดำเนินการสิ่งต่างๆ บนนั้นได้ก่อน นี่คือจุดที่ syn เข้ามามีบทบาท ฟังก์ชัน parse ใน syn จะรับ TokenStream และคืนค่าเป็นสตรักต์ DeriveInput ที่เป็นตัวแทนของโค้ด Rust ที่พาร์สแล้ว โค้ดตัวอย่างที่ 20-41 แสดงส่วนที่เกี่ยวข้องของสตรักต์ DeriveInput ที่เราได้จากการพาร์สสตริง struct Pancakes;

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

ฟิลด์ของสตรักต์นี้แสดงให้เห็นว่าโค้ด Rust ที่เราพาร์สแล้วเป็นยูหนิตสตรักต์ (unit struct) ที่มี ident (identifier หมายถึงชื่อ) เป็น Pancakes มีฟิลด์เพิ่มเติมบนสตรักต์นี้สำหรับอธิบายโค้ด Rust ชนิดต่างๆ; ตรวจสอบ syn documentation สำหรับ DeriveInput สำหรับข้อมูลเพิ่มเติม

อีกไม่นานเราจะนิยามฟังก์ชัน impl_hello_macro ซึ่งเป็นจุดที่เราจะสร้างโค้ด Rust ใหม่ที่เราต้องการรวมไว้ด้วย แต่ก่อนที่เราจะทำเช่นนั้น โปรดทราบว่าเอาต์พุตสำหรับแมโคร derive ของเราก็เป็น TokenStream เช่นกัน TokenStream ที่ส่งคืนจะถูกเพิ่มเข้าไปในโค้ดที่ผู้ใช้เครตของเราเขียน ดังนั้นเมื่อพวกเขาคอมไพล์เครตของพวกเขา พวกเขาก็จะได้ฟังก์ชันการทำงานเพิ่มเติมที่เราให้ไว้ใน TokenStream ที่ปรับแต่งแล้ว

คุณอาจสังเกตเห็นว่าเราเรียกใช้ unwrap เพื่อทำให้ฟังก์ชัน hello_macro_derive เกิด panic หากการเรียกใช้ฟังก์ชัน syn::parse ล้มเหลวที่นี่ มันจำเป็นอย่างยิ่งที่แมโครเชิงขั้นตอนของเราจะต้อง panic เมื่อเกิดข้อผิดพลาด เนื่องจากฟังก์ชัน proc_macro_derive ต้องส่งคืน TokenStream แทนที่จะเป็น Result เพื่อให้เป็นไปตาม API ของแมโครเชิงขั้นตอน เราได้ทำให้ตัวอย่างนี้ง่ายขึ้นโดยใช้ unwrap; ในโค้ดสำหรับการผลิตจริง คุณควรให้ข้อความแสดงข้อผิดพลาดเฉพาะเจาะจงมากขึ้นเกี่ยวกับสิ่งที่ผิดพลาดโดยใช้ panic! หรือ expect

ตอนนี้เรามีโค้ดที่จะแปลงโค้ด Rust ที่มีคำอธิบายกำกับจาก TokenStream ให้เป็นอินสแตนซ์ DeriveInput แล้ว เรามาสร้างโค้ดที่อิมพลีเมนต์ HelloMacro trait บนประเภทที่มีคำอธิบายกำกับกัน ดังแสดงในโค้ดตัวอย่างที่ 20-42

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

เราได้อินสแตนซ์สตรักต์ Ident ที่บรรจุชื่อ (identifier) ของประเภทที่มีคำอธิบายกำกับโดยใช้ ast.ident สตรักต์ในโค้ดตัวอย่างที่ 20-41 แสดงว่าเมื่อเรารันฟังก์ชัน impl_hello_macro บนโค้ดในโค้ดตัวอย่างที่ 20-37 ident ที่เราได้จะมีฟิลด์ ident ที่มีค่าเป็น "Pancakes" ดังนี้ ตัวแปร name ในโค้ดตัวอย่างที่ 20-42 จะบรรจุอินสแตนซ์สตรักต์ Ident ซึ่งเมื่อพิมพ์ออกมาแล้ว จะเป็นสตริง "Pancakes" ซึ่งเป็นชื่อของสตรักต์ในโค้ดตัวอย่างที่ 20-37

แมโคร quote! ช่วยให้เรานิยามโค้ด Rust ที่เราต้องการส่งคืนได้ คอมไพเลอร์คาดหวังบางอย่างที่แตกต่างจากผลลัพธ์โดยตรงของการรันแมโคร quote! ดังนั้นเราจึงจำเป็นต้องแปลงมันให้เป็น TokenStream เราทำสิ่งนี้โดยการเรียกใช้เมธอด into ซึ่งจะบริโภคโครงสร้างตัวแทนระดับกลางนี้และคืนค่าเป็นประเภท TokenStream ตามที่ต้องการ

แมโคร quote! ยังมอบกลไกการสร้างแม่แบบ (templating mechanics) ที่เจ๋งมากอีกด้วย: เราสามารถใส่ #name และ quote! จะแทนที่ด้วยค่าในตัวแปร name คุณยังสามารถทำการซ้ำคล้ายกับวิธีที่แมโครธรรมดาทำงานได้ ลองดู เอกสารของเครต quote สำหรับบทนำฉบับสมบูรณ์

เราต้องการให้แมโครเชิงขั้นตอนของเราสร้างการอิมพลีเมนต์ของ HelloMacro trait สำหรับประเภทที่ผู้ใช้ใส่คำอธิบายกำกับไว้ ซึ่งเราสามารถรับมาได้โดยใช้ #name การอิมพลีเมนต์เทรตจะมีหนึ่งฟังก์ชันคือ hello_macro ซึ่งบอดี้ของมันบรรจุฟังก์ชันการทำงานที่เราต้องการจัดเตรียมไว้: นั่นคือพิมพ์ Hello, Macro! My name is ตามด้วยชื่อของประเภทที่มีคำอธิบายกำกับ

แมโคร stringify! ที่ใช้ในที่นี้ถูกสร้างไว้ในตัว Rust มันรับนิพจน์ Rust เช่น 1 + 2 และแปลงนิพจน์นั้นในขณะคอมไพล์ให้เป็นตัวอักษรสตริง (string literal) เช่น "1 + 2" สิ่งนี้แตกต่างจาก format! หรือ println! ซึ่งเป็นแมโครที่ประเมินผลลัพธ์ของนิพจน์แล้วค่อยแปลงผลลัพธ์นั้นเป็น String มีความเป็นไปได้ว่าอินพุต #name อาจเป็นนิพจน์ที่ต้องพิมพ์ตามตัวอักษร เราจึงใช้ stringify! การใช้ stringify! ยังช่วยประหยัดการจัดสรรหน่วยความจำโดยแปลง #name ให้เป็นตัวอักษรสตริงในขณะคอมไพล์

ถึงจุดนี้ cargo build ควรจะคอมไพล์ผ่านสำเร็จทั้งใน hello_macro และ hello_macro_derive เรามาเชื่อมต่อเครตเหล่านี้เข้ากับโค้ดในโค้ดตัวอย่างที่ 20-37 เพื่อดูแมโครเชิงขั้นตอนทำงานกัน! สร้างโปรเจกต์ไบนารีใหม่ในไดเรกทอรี projects ของคุณโดยใช้ cargo new pancakes เราจำเป็นต้องเพิ่ม hello_macro และ hello_macro_derive เป็น dependencies ในไฟล์ Cargo.toml ของเครต pancakes หากคุณกำลังเผยแพร่เวอร์ชันของ hello_macro และ hello_macro_derive ลงใน crates.io พวกมันก็จะเป็น dependencies ปกติ; หากไม่เป็นเช่นนั้น คุณสามารถระบุพวกมันเป็น path dependencies ได้ดังต่อไปนี้:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

ใส่โค้ดในโค้ดตัวอย่างที่ 20-37 ลงใน src/main.rs และรัน cargo run: มันควรจะพิมพ์ Hello, Macro! My name is Pancakes! การอิมพลีเมนต์ HelloMacro trait จากแมโครเชิงขั้นตอนถูกรวมไว้โดยที่เครต pancakes ไม่จำเป็นต้องอิมพลีเมนต์เอง; #[derive(HelloMacro)] ได้เพิ่มการอิมพลีเมนต์เทรตให้แล้ว

ถัดไป เรามาสำรวจว่าแมโครเชิงขั้นตอนชนิดอื่นๆ แตกต่างจากแมโคร derive กำหนดเองอย่างไร

Attribute-Like Macros

แมโครคล้ายแอททริบิวต์ (attribute-like macros) จะคล้ายกับแมโคร derive กำหนดเอง แต่แทนที่จะสร้างโค้ดสำหรับแอททริบิวต์ derive พวกมันอนุญาตให้คุณสร้างแอททริบิวต์ใหม่ขึ้นมาได้ พวกมันยังมีความยืดหยุ่นมากกว่าอีกด้วย: derive ทำงานได้เฉพาะกับสตรักต์และอีนัมเท่านั้น; แต่แอททริบิวต์สามารถนำไปใช้กับไอเทมอื่นได้ด้วย เช่น ฟังก์ชัน นี่คือตัวอย่างการใช้แมโครคล้ายแอททริบิวต์ สมมติว่าคุณมีแอททริบิวต์ชื่อ route ที่กำกับฟังก์ชันเมื่อใช้เว็บแอปพลิเคชันเฟรมเวิร์ก:

#[route(GET, "/")]
fn index() {

แอททริบิวต์ #[route] นี้จะถูกนิยามโดยเฟรมเวิร์กในฐานะแมโครเชิงขั้นตอน ลายเซ็นของฟังก์ชันนิยามแมโครจะมีหน้าตาเป็นดังนี้:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

ในที่นี้ เรามีพารามิเตอร์สองตัวประเภท TokenStream ตัวแรกสำหรับเนื้อหาของแอททริบิวต์: ส่วน GET, "/" ตัวที่สองคือบอดี้ของไอเทมที่แอททริบิวต์แนบอยู่: ในกรณีนี้คือ fn index() {} และส่วนที่เหลือของบอดี้ฟังก์ชัน

นอกเหนือจากนั้น แมโครคล้ายแอททริบิวต์ก็ทำงานในลักษณะเดียวกับแมโคร derive กำหนดเอง: คุณสร้างเครตที่มีประเภทเครต proc-macro และอิมพลีเมนต์ฟังก์ชันที่สร้างโค้ดที่คุณต้องการ!

Function-Like Macros

แมโครคล้ายฟังก์ชัน (function-like macros) นิยามแมโครที่ดูเหมือนการเรียกใช้ฟังก์ชัน คล้ายกับแมโคร macro_rules! พวกมันมีความยืดหยุ่นมากกว่าฟังก์ชัน; ตัวอย่างเช่น พวกมันสามารถรับจำนวนอาร์กิวเมนต์ที่ไม่แน่นอนได้ อย่างไรก็ตาม แมโคร macro_rules! สามารถนิยามได้โดยใช้ไวยากรณ์คล้าย match ที่เราพูดถึงในหัวข้อ “Declarative Macros for General Metaprogramming” ก่อนหน้านี้เท่านั้น แมโครคล้ายฟังก์ชันรับพารามิเตอร์ TokenStream และนิยามของพวกมันจะจัดการกับ TokenStream นั้นโดยใช้โค้ด Rust เช่นเดียวกับแมโครเชิงขั้นตอนอีกสองชนิด ตัวอย่างของแมโครคล้ายฟังก์ชันคือแมโคร sql! ที่อาจถูกเรียกใช้ดังนี้:

let sql = sql!(SELECT * FROM posts WHERE id=1);

แมโครนี้จะพาร์สคำสั่ง SQL ภายในและตรวจสอบว่าถูกต้องตามไวยากรณ์หรือไม่ ซึ่งเป็นการประมวลผลที่ซับซ้อนกว่าแมโคร macro_rules! มาก แมโคร sql! จะถูกนิยามดังนี้:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

นิยามนี้คล้ายกับลายเซ็นของแมโคร derive กำหนดเอง: เราได้รับโทเค็นที่อยู่ภายในวงเล็บและส่งคืนโค้ดที่เราต้องการสร้าง

Summary

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

ถัดไป เราจะนำทุกสิ่งที่เราหารือกันมาตลอดทั้งเล่มไปปฏิบัติจริง และทำอีกหนึ่งโปรเจกต์กันครับ!

Final Project: Building a Multithreaded Web Server

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

สำหรับโปรเจกต์สุดท้ายของเรา เราจะสร้างเว็บเซิร์ฟเวอร์ที่ทักทายว่า “Hello!” และมีหน้าตาเหมือนในรูปที่ 21-1 ในเว็บเบราว์เซอร์

นี่คือแผนของเราในการสร้างเว็บเซิร์ฟเวอร์:

  1. เรียนรู้เล็กน้อยเกี่ยวกับ TCP และ HTTP
  2. รอรับการเชื่อมต่อ TCP บนซ็อกเก็ต (socket)
  3. พาร์ส (parse) คำร้องขอ HTTP (HTTP requests) จำนวนเล็กน้อย
  4. สร้างการตอบกลับ HTTP (HTTP response) ที่ถูกต้อง
  5. ปรับปรุงปริมาณการประมวลผล (throughput) ของเซิร์ฟเวอร์เราด้วยพูลของเธรด (thread pool)
Screenshot of a web browser visiting the address 127.0.0.1:8080 displaying a webpage with the text content “Hello! Hi from Rust”

Figure 21-1: โปรเจกต์สุดท้ายร่วมกันของเรา

ก่อนที่เราจะเริ่ม เราควรกล่าวถึงรายละเอียดสองประการ ประการแรก วิธีการที่เราจะใช้นั้นจะไม่ใช่วิธีที่ดีที่สุดในการสร้างเว็บเซิร์ฟเวอร์ด้วย Rust สมาชิกในชุมชนได้เผยแพร่เครตที่พร้อมสำหรับการใช้งานจริงจำนวนมากไว้ที่ crates.io ซึ่งมีการอิมพลีเมนต์เว็บเซิร์ฟเวอร์และพูลของเธรดที่สมบูรณ์กว่าสิ่งที่เราจะสร้างขึ้น อย่างไรก็ตาม เจตนาของเราในบทนี้คือเพื่อช่วยให้คุณได้เรียนรู้ ไม่ใช่การเลือกเส้นทางที่ง่าย เนื่องจาก Rust เป็นภาษาโปรแกรมระบบ (systems programming language) เราจึงสามารถเลือกระดับของนามธรรม (level of abstraction) ที่เราต้องการทำงานด้วยได้ และสามารถลงไปในระดับที่ต่ำกว่าสิ่งที่เป็นไปได้หรือทางปฏิบัติในภาษาอื่น

ประการที่สอง เราจะไม่ใช้ async และ await ที่นี่ การสร้างพูลของเธรดเป็นความท้าทายที่ใหญ่พอสมควรในตัวมันเองอยู่แล้ว โดยไม่จำเป็นต้องบวกเพิ่มเรื่องการสร้าง async runtime! อย่างไรก็ตาม เราจะตั้งข้อสังเกตว่า async และ await อาจนำมาประยุกต์ใช้กับปัญหาบางอย่างแบบเดียวกับที่เราจะได้เห็นในบทนี้ได้อย่างไร สรุปแล้ว ดังที่เราเคยตั้งข้อสังเกตไว้ในบทที่ 17 async runtimes จำนวนมากใช้พูลของเธรดเพื่อจัดการงานของพวกมัน

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

Building a Single-Threaded Web Server

Building a Single-Threaded Web Server

เราจะเริ่มต้นด้วยการทำให้เว็บเซิร์ฟเวอร์แบบเธรดเดียว (single-threaded web server) ทำงานได้ ก่อนที่เราจะเริ่ม เรามาดูภาพรวมสั้นๆ ของโปรโตคอลที่เกี่ยวข้องกับการสร้างเว็บเซิร์ฟเวอร์กัน รายละเอียดของโปรโตคอลเหล่านี้อยู่เกินขอบเขตของหนังสือเล่มนี้ แต่ภาพรวมสั้นๆ จะมอบข้อมูลที่คุณจำเป็นต้องรู้

สองโปรโตคอลหลักที่เกี่ยวข้องกับเว็บเซิร์ฟเวอร์คือ Hypertext Transfer Protocol (HTTP) และ Transmission Control Protocol (TCP) ทั้งสองโปรโตคอลเป็นโปรโตคอลแบบ คำร้องขอ-การตอบกลับ (request-response) ซึ่งหมายความว่า ไคลเอนต์ (client) จะเป็นผู้เริ่มต้นส่งคำร้องขอ และ เซิร์ฟเวอร์ (server) จะรอฟังคำร้องขอและให้คำตอบกลับแก่ไคลเอนต์ เนื้อหาของคำร้องขอและการตอบกลับเหล่านั้นถูกกำหนดไว้ตามโปรโตคอล

TCP เป็นโปรโตคอลระดับต่ำกว่า (lower-level protocol) ที่อธิบายรายละเอียดของวิธีการที่ข้อมูลส่งจากเซิร์ฟเวอร์หนึ่งไปอีกเซิร์ฟเวอร์หนึ่ง แต่ไม่ได้ระบุว่าข้อมูลนั้นคืออะไร HTTP สร้างต่อยอดอยู่บน TCP โดยการกำหนดเนื้อหาของคำร้องขอและการตอบกลับ ในทางเทคนิคแล้วสามารถใช้ HTTP ร่วมกับโปรโตคอลอื่นได้ แต่ในกรณีส่วนใหญ่ HTTP จะส่งข้อมูลผ่าน TCP เราจะทำงานกับไบต์ดิบ (raw bytes) ของคำร้องขอและการตอบกลับใน TCP และ HTTP

Listening to the TCP Connection

เว็บเซิร์ฟเวอร์ของเราจำเป็นต้องรอฟังการเชื่อมต่อ TCP ดังนั้น นั่นคือส่วนแรกที่เราจะทำ ไลบรารีมาตรฐานเสนอมอดูล std::net ที่ช่วยให้เราทำสิ่งนี้ได้ มาสร้างโปรเจกต์ใหม่ตามปกติกันครับ:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

ตอนนี้ให้ใส่โค้ดในโค้ดตัวอย่างที่ 21-1 ลงใน src/main.rs เพื่อเริ่มต้น โค้ดนี้จะรอฟังที่แอดเดรสภายในเครื่อง 127.0.0.1:7878 สำหรับสตรีม TCP (TCP streams) ที่เข้ามา เมื่อได้รับสตรีมที่เข้ามา มันจะพิมพ์ Connection established!

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}

การใช้ TcpListener ช่วยให้เรารอฟังการเชื่อมต่อ TCP ที่แอดเดรส 127.0.0.1:7878 ได้ ในแอดเดรสนั้น ส่วนก่อนเครื่องหมายทวิภาคคือ IP address ที่เป็นตัวแทนของคอมพิวเตอร์คุณ (ซึ่งเหมือนกันบนคอมพิวเตอร์ทุกเครื่องและไม่ได้เป็นตัวแทนคอมพิวเตอร์ของผู้เขียนโดยเฉพาะ) และ 7878 คือพอร์ต (port) เราเลือกพอร์ตนี้ด้วยสองเหตุผล: ปกติแล้ว HTTP จะไม่ถูกรับบนพอร์ตนี้ ดังนั้นเซิร์ฟเวอร์ของเราจึงไม่น่าจะขัดแย้งกับเว็บเซิร์ฟเวอร์อื่นใดที่คุณอาจกำลังรันอยู่บนเครื่องของคุณ และ 7878 คือคำว่า rust ที่พิมพ์บนแป้นพิมพ์โทรศัพท์

ฟังก์ชัน bind ในสถานการณ์นี้ทำงานเหมือนฟังก์ชัน new ตรงที่จะคืนค่าอินสแตนซ์ TcpListener ใหม่ ฟังก์ชันนี้ถูกเรียกว่า bind เพราะว่าในระบบเครือข่าย การเชื่อมต่อกับพอร์ตเพื่อรอฟังจะถูกเรียกว่า “การผูกเข้ากับพอร์ต (binding to a port)”

ฟังก์ชัน bind จะคืนค่า Result<T, E> ซึ่งระบุว่าการผูกพอร์ตอาจล้มเหลวได้ ตัวอย่างเช่น หากเรารันโปรแกรมของเราสองอินสแตนซ์พร้อมกัน และทำให้มีสองโปรแกรมรอฟังพอร์ตเดียวกัน เนื่องจากเรากำลังเขียนเซิร์ฟเวอร์พื้นฐานเพื่อการเรียนรู้เท่านั้น เราจะไม่กังวลเกี่ยวกับการจัดการข้อผิดพลาดประเภทเหล่านี้; แต่เราจะใช้ unwrap เพื่อหยุดโปรแกรมหากเกิดข้อผิดพลาดขึ้น

เมธอด incoming บน TcpListener จะคืนค่าตัววนซ้ำ (iterator) ที่มอบลำดับของสตรีมให้กับเรา (เจาะจงกว่านั้นคือ สตรีมประเภท TcpStream) สตรีม (stream) เดียวเป็นตัวแทนของการเชื่อมต่อที่เปิดอยู่ระหว่างไคลเอนต์และเซิร์ฟเวอร์ การเชื่อมต่อ (Connection) คือชื่อของกระบวนการคำร้องขอและการตอบกลับทั้งหมดที่ไคลเอนต์เชื่อมต่อกับเซิร์ฟเวอร์ เซิร์ฟเวอร์สร้างคำตอบกลับ และเซิร์ฟเวอร์ปิดการเชื่อมต่อ ดังนั้น เราจะอ่านจาก TcpStream เพื่อดูว่าไคลเอนต์ส่งอะไรมา แล้วเขียนคำตอบกลับของเราลงในสตรีมเพื่อส่งข้อมูลกลับไปยังไคลเอนต์ โดยรวมแล้ว ลูป for นี้จะประมวลผลการเชื่อมต่อแต่ละอันตามลำดับ และสร้างชุดสตรีมเพื่อให้เราจัดการ

ในตอนนี้ การจัดการสตรีมของเราประกอบด้วยการเรียก unwrap เพื่อยุติโปรแกรมหากสตรีมมีข้อผิดพลาดใดๆ; หากไม่มีข้อผิดพลาด โปรแกรมจะพิมพ์ข้อความ เราจะเพิ่มฟังก์ชันการทำงานเพิ่มเติมสำหรับกรณีที่สำเร็จในโค้ดตัวอย่างถัดไป เหตุผลที่เราอาจได้รับข้อผิดพลาดจากเมธอด incoming เมื่อไคลเอนต์เชื่อมต่อกับเซิร์ฟเวอร์ คือเราไม่ได้กำลังวนซ้ำเหนือการเชื่อมต่อจริงๆ แต่เรากำลังวนซ้ำเหนือ ความพยายามในการเชื่อมต่อ (connection attempts) การเชื่อมต่ออาจไม่สำเร็จเนื่องจากเหตุผลหลายประการ ซึ่งหลายประการขึ้นอยู่กับระบบปฏิบัติการโดยเฉพาะ ตัวอย่างเช่น ระบบปฏิบัติการหลายระบบมีขีดจำกัดจำนวนการเชื่อมต่อที่เปิดพร้อมกันซึ่งสามารถรองรับได้; ความพยายามเชื่อมต่อใหม่ที่เกินจำนวนนั้นจะเกิดข้อผิดพลาดจนกว่าการเชื่อมต่อที่เปิดอยู่บางอันจะถูกปิดลง

เรามาลองรันโค้ดนี้กัน! เรียกใช้ cargo run ในเทอร์มินัลแล้วโหลด 127.0.0.1:7878 ในเว็บเบราว์เซอร์ เบราว์เซอร์ควรแสดงข้อความแสดงข้อผิดพลาดเช่น “Connection reset” เนื่องจากเซิร์ฟเวอร์ไม่ได้ส่งข้อมูลใดๆ กลับไปในขณะนี้ แต่เมื่อคุณดูที่เทอร์มินัล คุณควรเห็นข้อความหลายข้อความที่ถูกพิมพ์ขึ้นเมื่อเบราว์เซอร์เชื่อมต่อกับเซิร์ฟเวอร์!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

บางครั้งคุณจะเห็นข้อความหลายข้อความพิมพ์ขึ้นสำหรับการร้องขอครั้งเดียวจากเบราว์เซอร์; เหตุผลอาจเป็นเพราะเบราว์เซอร์กำลังทำการร้องขอสำหรับหน้าเว็บ รวมถึงการร้องขอทรัพยากรอื่นๆ เช่น ไอคอน favicon.ico ที่ปรากฏในแท็บเบราว์เซอร์

อาจเป็นไปได้ว่าเบราว์เซอร์พยายามเชื่อมต่อกับเซิร์ฟเวอร์หลายครั้งเนื่องจากเซิร์ฟเวอร์ไม่ตอบกลับด้วยข้อมูลใดๆ เมื่อ stream หลุดออกจากขอบเขตและถูกดรอป (dropped) ที่จุดสิ้นสุดของลูป การเชื่อมต่อจะถูกปิดลงอันเป็นส่วนหนึ่งของการอิมพลีเมนต์ drop เบราว์เซอร์บางครั้งจะจัดการกับการเชื่อมต่อที่ปิดลงด้วยการลองใหม่ เนื่องจากปัญหาอาจเกิดขึ้นเพียงชั่วคราว

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

ปัจจัยสำคัญคือเราสามารถรับมือ (get a handle) การเชื่อมต่อ TCP ได้สำเร็จแล้ว!

อย่าลืมหยุดโปรแกรมโดยการกด ctrl-C เมื่อคุณรันโค้ดเวอร์ชันเฉพาะนั้นเสร็จแล้ว จากนั้น รีสตาร์ตโปรแกรมโดยการเรียกใช้คำสั่ง cargo run หลังจากที่คุณได้ทำการเปลี่ยนแปลงโค้ดแต่ละชุด เพื่อให้แน่ใจว่าคุณกำลังรันโค้ดล่าสุด

Reading the Request

เรามาอิมพลีเมนต์ฟังก์ชันการทำงานเพื่ออ่านคำร้องขอจากเบราว์เซอร์กัน! เพื่อแยกความรับผิดชอบในการรับการเชื่อมต่อกับการดำเนินการกับการเชื่อมนั้น เราจะเริ่มต้นฟังก์ชันใหม่สำหรับการประมวลผลการเชื่อมต่อ ในฟังก์ชัน handle_connection ใหม่นี้ เราจะอ่านข้อมูลจากสตรีม TCP และพิมพ์ออกมา เพื่อให้เราเห็นข้อมูลที่ถูกส่งจากเบราว์เซอร์ เปลี่ยนโค้ดให้เหมือนกับโค้ดตัวอย่างที่ 21-2

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}

เรานำ std::io::BufReader และ std::io::prelude เข้าสู่ขอบเขตเพื่อรับสิทธิ์เข้าถึงเทรตและประเภทที่ช่วยให้เราอ่านและเขียนไปยังสตรีมได้ ในลูป for ในฟังก์ชัน main แทนที่จะพิมพ์ข้อความว่าเราได้เชื่อมต่อแล้ว ตอนนี้เราเรียกฟังก์ชัน handle_connection ใหม่และส่ง stream ไปให้มัน

ในฟังก์ชัน handle_connection เราสร้างอินสแตนซ์ BufReader ใหม่ที่หุ้มการอ้างอิงไปยัง stream ไว้ BufReader จะเพิ่มการบัฟเฟอร์ (buffering) โดยการจัดการการเรียกใช้เมธอดเทรต std::io::Read ให้เรา

เราสร้างตัวแปรชื่อ http_request เพื่อรวบรวมบรรทัดคำร้องขอที่เบราว์เซอร์ส่งไปยังเซิร์ฟเวอร์ของเรา ระบุว่าเราต้องการรวบรวมบรรทัดเหล่านี้ไว้ในเวกเตอร์โดยเพิ่มคำอธิบายประเภท Vec<_>

BufReader อิมพลีเมนต์ std::io::BufRead trait ซึ่งมอบเมธอด lines มาให้ เมธอด lines จะคืนค่าตัววนซ้ำของ Result<String, std::io::Error> โดยการแบ่งสตรีมข้อมูลเมื่อใดก็ตามที่เห็นไบต์ขึ้นบรรทัดใหม่ เพื่อรับแต่ละ String เราใช้ map และ unwrap แต่ละ Result โดย Result อาจเป็นข้อผิดพลาดหากข้อมูลไม่ได้เป็น UTF-8 ที่ถูกต้อง หรือหากมีปัญหาในการอ่านจากสตรีม อีกครั้ง โปรแกรมจริงในการทำงานควรจัดการข้อผิดพลาดเหล่านี้อย่างเหมาะสม แต่เราเลือกที่จะหยุดโปรแกรมในกรณีข้อผิดพลาดเพื่อความเรียบง่าย

เบราว์เซอร์จะส่งสัญญาณสิ้นสุดคำร้องขอ HTTP โดยการส่งตัวอักษรขึ้นบรรทัดใหม่สองตัวติดต่อกัน ดังนั้นเพื่อให้ได้หนึ่งคำร้องขอจากสตรีม เราจึงรับบรรทัดไปเรื่อยๆ จนกว่าจะได้บรรทัดที่เป็นสตริงว่าง เมื่อเรารวบรวมบรรทัดลงในเวกเตอร์แล้ว เราจะพิมพ์พวกมันออกมาโดยใช้รูปแบบดีบักแบบสวยงาม (pretty debug formatting) เพื่อให้เราสามารถดูคำสั่งที่เว็บเบราว์เซอร์ส่งไปยังเซิร์ฟเวอร์ของเราได้

เรามาลองรันโค้ดนี้กัน! เริ่มโปรแกรมและทำการร้องขอในเว็บเบราว์เซอร์อีกครั้ง โปรดทราบว่าเราจะยังคงได้รับหน้าข้อผิดพลาดในเบราว์เซอร์ แต่เอาต์พุตของโปรแกรมในเทอร์มินัลจะออกมาคล้ายดังนี้:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
      Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

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

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

Looking More Closely at an HTTP Request

HTTP เป็นโปรโตคอลแบบข้อความ (text-based protocol) และคำร้องขอจะมีรูปแบบดังนี้:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

บรรทัดแรกคือ บรรทัดคำร้องขอ (request line) ที่ถือครองข้อมูลเกี่ยวกับสิ่งที่ไคลเอนต์กำลังร้องขอ ส่วนแรกของบรรทัดคำร้องขอระบุถึงวิธีการ (method) ที่กำลังถูกใช้ เช่น GET หรือ POST ซึ่งอธิบายว่าไคลเอนต์สร้างคำร้องขอนี้อย่างไร ไคลเอนต์ของเราใช้คำร้องขอแบบ GET ซึ่งหมายความว่ามันกำลังขอข้อมูล

ส่วนถัดไปของบรรทัดคำร้องขอคือ / ซึ่งระบุถึง uniform resource identifier (URI) ที่ไคลเอนต์กำลังร้องขอ: URI เกือบจะเหมือนกับ แต่ไม่เหมือนเสียทีเดียวกับ uniform resource locator (URL) ความแตกต่างระหว่าง URIs และ URLs ไม่ได้สำคัญสำหรับวัตถุประสงค์ของเราในบทนี้ แต่ข้อกำหนดของ HTTP ใช้คำว่า URI ดังนั้นเราจึงสามารถแทนที่คำว่า URL แทน URI ในใจได้ที่นี่

ส่วนสุดท้ายคือเวอร์ชัน HTTP ที่ไคลเอนต์ใช้ จากนั้นบรรทัดคำร้องขอจะจบลงด้วยลำดับ CRLF (CRLF ย่อมาจาก carriage return และ line feed ซึ่งเป็นคำตั้งแต่สมัยพิมพ์ดีด!) ลำดับ CRLF ยังสามารถเขียนเป็น \r\n โดยที่ \r คือ carriage return และ \n คือ line feed ลำดับ CRLF จะคั่นบรรทัดคำร้องขอออกจากส่วนที่เหลือของข้อมูลคำร้องขอ สังเกตว่าเมื่อพิมพ์ CRLF ออกมา เราจะเห็นบรรทัดใหม่เริ่มต้นขึ้นแทนที่จะเป็น \r\n

เมื่อดูที่ข้อมูลบรรทัดคำร้องขอที่เราได้รับจากการรันโปรแกรมของเราจนถึงตอนนี้ เราจะเห็นว่า GET คือวิธีการ, / คือ URI คำร้องขอ, และ HTTP/1.1 คือเวอร์ชัน

หลังจากบรรทัดคำร้องขอ บรรทัดที่เหลือที่เริ่มต้นตั้งแต่ Host: เป็นต้นไปคือส่วนหัว (headers) คำร้องขอแบบ GET ไม่มีบอดี้ (body)

ลองทำการร้องขอจากเบราว์เซอร์อื่น หรือลองร้องขอแอดเดรสอื่น เช่น 127.0.0.1:7878/test เพื่อดูว่าข้อมูลคำร้องขอเปลี่ยนแปลงไปอย่างไร

ตอนนี้เมื่อเรารู้แล้วว่าเบราว์เซอร์กำลังร้องขออะไร เรามาส่งข้อมูลกลับไปกันครับ!

Writing a Response

เรากำลังจะอิมพลีเมนต์การส่งข้อมูลเพื่อตอบกลับคำร้องขอของไคลเอนต์ การตอบกลับมีรูปแบบดังต่อไปนี้:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

บรรทัดแรกคือ บรรทัดสถานะ (status line) ที่บรรจุเวอร์ชัน HTTP ที่ใช้ในการตอบกลับ, รหัสสถานะเชิงตัวเลข (numeric status code) ที่สรุปผลลัพธ์ของคำร้องขอ, และวลีเหตุผล (reason phrase) ที่มอบคำอธิบายเป็นข้อความของรหัสสถานะ หลังจากลำดับ CRLF จะเป็นส่วนหัว (headers) ใดๆ, ลำดับ CRLF อีกชุดหนึ่ง, และบอดี้ของการตอบกลับ

นี่คือตัวอย่างการตอบกลับที่ใช้เวอร์ชัน HTTP 1.1 และมีรหัสสถานะ 200, วลีเหตุผลเป็น OK, ไม่มีส่วนหัว, และไม่มีบอดี้:

HTTP/1.1 200 OK\r\n\r\n

รหัสสถานะ 200 คือการตอบกลับสำเร็จมาตรฐาน ข้อความนี้คือการตอบกลับ HTTP ที่สำเร็จที่มีขนาดเล็กมาก เรามารายงานสิ่งนี้ลงในสตรีมเพื่อเป็นการตอบกลับคำร้องขอที่สำเร็จกันครับ! จากฟังก์ชัน handle_connection ให้ลบ println! ที่กำลังพิมพ์ข้อมูลคำร้องขอออก แล้วแทนที่ด้วยโค้ดในโค้ดตัวอย่างที่ 21-3

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}

บรรทัดใหม่บรรทัดแรกนิยามตัวแปร response ที่ถือครองข้อมูลของข้อความสำเร็จ จากนั้น เราเรียกใช้ as_bytes บน response ของเราเพื่อแปลงข้อมูลสตริงเป็นไบต์ เมธอด write_all บน stream จะรับ &[u8] และส่งไบต์เหล่านั้นโดยตรงผ่านการเชื่อมต่อ เนื่องจากปฏิบัติการ write_all อาจล้มเหลวได้ เราจึงใช้ unwrap กับผลลัพธ์ข้อผิดพลาดใดๆ เหมือนก่อนหน้านี้ อีกครั้ง ในแอปพลิเคชันจริง คุณควรเพิ่มการจัดการข้อผิดพลาดที่นี่

ด้วยการเปลี่ยนแปลงเหล่านี้ เรามารันโค้ดและทำการร้องขอกันครับ เราไม่ได้พิมพ์ข้อมูลใดๆ ออกทางเทอร์มินัลอีกต่อไป ดังนั้นเราจะไม่เห็นเอาต์พุตใดๆ นอกเหนือจากเอาต์พุตจาก Cargo เมื่อคุณโหลด 127.0.0.1:7878 ในเว็บเบราว์เซอร์ คุณควรได้หน้าเปล่าแทนที่จะเป็นข้อผิดพลาด คุณเพิ่งจะเขียนโค้ดด้วยตนเองในการรับคำร้องขอ HTTP และการส่งการตอบกลับสำเร็จแล้ว!

Returning Real HTML

เรามาอิมพลีเมนต์ฟังก์ชันการทำงานสำหรับการส่งคืนหน้าเว็บจริงแทนที่จะเป็นหน้าเปล่ากันครับ สร้างไฟล์ใหม่ชื่อ hello.html ในโฟลเดอร์หลัก (root) ของไดเรกทอรีโปรเจกต์ของคุณ ไม่ใช่ในไดเรกทอรี src คุณสามารถใส่ HTML ใดๆ ที่คุณต้องการได้; โค้ดตัวอย่างที่ 21-4 แสดงความเป็นไปได้แบบหนึ่ง

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

นี่คือเอกสาร HTML5 ขนาดเล็กที่มีหัวข้อและข้อความบางอย่าง ในการส่งคืนสิ่งนี้จากเซิร์ฟเวอร์เมื่อได้รับคำร้องขอ เราจะปรับเปลี่ยน handle_connection ดังแสดงในโค้ดตัวอย่างที่ 21-5 เพื่ออ่านไฟล์ HTML เพิ่มมันลงในการตอบกลับในฐานะบอดี้ และส่งมันไป

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

เราได้เพิ่ม fs ลงในคำสั่ง use เพื่อนำมอดูลระบบไฟล์ (filesystem module) ของไลบรารีมาตรฐานเข้าสู่ขอบเขต โค้ดสำหรับการอ่านเนื้อหาของไฟล์ลงในสตริงควรจะดูคุ้นเคย; เราเคยใช้มันเมื่อเราอ่านเนื้อหาของไฟล์สำหรับโปรเจกต์ I/O ของเราในโค้ดตัวอย่างที่ 12-4

ถัดไป เราใช้ format! เพื่อเพิ่มเนื้อหาของไฟล์เป็นบอดี้ของการตอบกลับที่สำเร็จ เพื่อรับประกันว่าจะได้การตอบกลับ HTTP ที่ถูกต้อง เราเพิ่มส่วนหัว Content-Length ซึ่งตั้งค่าเป็นขนาดของบอดี้การตอบกลับของเรา—ในกรณีนี้คือขนาดของ hello.html

รันโค้ดนี้ด้วย cargo run และโหลด 127.0.0.1:7878 ในเบราว์เซอร์ของคุณ; คุณควรเห็น HTML ของคุณถูกเรนเดอร์!

ในปัจจุบัน เรากำลังเพิกเฉยต่อข้อมูลคำร้องขอใน http_request และเพียงส่งเนื้อหาของไฟล์ HTML กลับไปโดยไม่มีเงื่อนไข นั่นหมายความว่าหากคุณลองร้องขอ 127.0.0.1:7878/something-else ในเบราว์เซอร์ของคุณ คุณก็จะยังได้รับคำตอบกลับเป็น HTML เดียวกันนี้ ในขณะนี้ เซิร์ฟเวอร์ของเรามีข้อจำกัดอย่างมากและไม่ได้ทำในสิ่งที่เว็บเซิร์ฟเวอร์ส่วนใหญ่ทำ เราต้องการปรับแต่งการตอบกลับตามคำร้องขอ และส่งไฟล์ HTML กลับไปเฉพาะคำร้องขอที่ถูกต้องไปยัง / เท่านั้น

Validating the Request and Selectively Responding

ในขณะนี้ เว็บเซิร์ฟเวอร์ของเราจะคืนค่า HTML ในไฟล์เสมอ ไม่ว่าไคลเอนต์จะร้องขออะไรก็ตาม เรามาเพิ่มฟังก์ชันการทำงานเพื่อตรวจสอบว่าเบราว์เซอร์กำลังร้องขอ / ก่อนส่งไฟล์ HTML กลับไป และคืนค่าข้อผิดพลาดหากเบราว์เซอร์ร้องขอสิ่งอื่น สำหรับสิ่งนี้เราจำเป็นต้องปรับเปลี่ยน handle_connection ดังแสดงในโค้ดตัวอย่างที่ 21-6 โค้ดใหม่นี้จะตรวจสอบเนื้อหาของคำร้องขอที่ได้รับเปรียบเทียบกับสิ่งที่เรารู้ว่าคำร้องขอสำหรับ / มีหน้าตาเป็นอย่างไร และเพิ่มบล็อก if และ else เพื่อจัดการคำร้องขอที่แตกต่างกัน

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}

เราจะดูเฉพาะบรรทัดแรกของคำร้องขอ HTTP ดังนั้นแทนที่จะอ่านคำร้องขอทั้งหมดลงในเวกเตอร์ เราจึงเรียกใช้ next เพื่อรับไอเทมแรกจากตัววนซ้ำ unwrap อันแรกจะจัดการ Option และหยุดโปรแกรมหากตัววนซ้ำไม่มีไอเทม unwrap อันที่สองจะจัดการ Result และมีผลเช่นเดียวกับ unwrap ที่อยู่ใน map ซึ่งเพิ่มเข้ามาในโค้ดตัวอย่างที่ 21-2

ถัดไป เราตรวจสอบ request_line เพื่อดูว่ามันเท่ากับบรรทัดคำร้องขอของคำร้องขอ GET ไปยังเส้นทาง / หรือไม่ หากใช่ บล็อก if จะคืนค่าเนื้อหาของไฟล์ HTML ของเรา

หาก request_line ไม่ เท่ากับคำร้องขอ GET ไปยังเส้นทาง / นั่นหมายความว่าเราได้รับคำร้องขออื่น เราจะเพิ่มโค้ดในบล็อก else ในอีกสักครู่เพื่อตอบกลับคำร้องขออื่นทั้งหมด

รันโค้ดนี้ตอนนี้และร้องขอ 127.0.0.1:7878; คุณควรได้ HTML ใน hello.html หากคุณทำการร้องขออื่นใด เช่น 127.0.0.1:7878/something-else คุณจะได้รับข้อผิดพลาดการเชื่อมต่อเช่นเดียวกับที่คุณเห็นเมื่อรันโค้ดในโค้ดตัวอย่างที่ 21-1 และโค้ดตัวอย่างที่ 21-2

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

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}

ในที่นี้ การตอบกลับของเรามีบรรทัดสถานะพร้อมรหัสสถานะ 404 และวลีเหตุผล NOT FOUND บอดี้ของการตอบกลับจะเป็น HTML ในไฟล์ 404.html คุณจะต้องสร้างไฟล์ 404.html อยู่ข้างๆ hello.html สำหรับหน้าข้อผิดพลาด; อีกครั้ง สามารถใช้ HTML ใดก็ได้ที่คุณต้องการ หรือใช้ตัวอย่าง HTML ในโค้ดตัวอย่างที่ 21-8

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

ด้วยการเปลี่ยนแปลงเหล่านี้ ให้รันเซิร์ฟเวอร์ของคุณอีกครั้ง การร้องขอ 127.0.0.1:7878 ควรคืนค่าเนื้อหาของ hello.html และการร้องขออื่นใด เช่น 127.0.0.1:7878/foo ควรคืนค่าข้อผิดพลาด HTML จาก 404.html

Refactoring

ในขณะนี้ บล็อก if และ else มีการเขียนซ้ำหลายแห่ง: ทั้งคู่ต่างอ่านไฟล์และเขียนเนื้อหาของไฟล์ลงในสตรีม ความแตกต่างเพียงอย่างเดียวคือบรรทัดสถานะและชื่อไฟล์ เรามาทำให้โค้ดกระชับขึ้นโดยการดึงความแตกต่างเหล่านั้นออกมาใส่ในบรรทัด if และ else แยกต่างหากที่จะกำหนดค่าของบรรทัดสถานะและชื่อไฟล์ให้กับตัวแปร; จากนั้นเราจะสามารถใช้ตัวแปรเหล่านั้นโดยไม่มีเงื่อนไขในโค้ดเพื่ออ่านไฟล์และเขียนการตอบกลับ โค้ดตัวอย่างที่ 21-9 แสดงโค้ดผลลัพธ์หลังจากแทนที่บล็อก if และ else ขนาดใหญ่

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

ตอนนี้บล็อก if และ else จะ คืนค่าเฉพาะค่าที่เหมาะสมสำหรับบรรทัดสถานะและชื่อไฟล์ในทูเพิล (tuple) เท่านั้น; จากนั้นเราใช้การกระจายโครงสร้าง (destructuring) เพื่อกำหนดสองค่านีให้กับ status_line และ filename โดยใช้รูปแบบในคำสั่ง let ดังที่ได้หารือกันในบทที่ 19

โค้ดที่เคยซ้ำซ้อนกันตอนนี้จะอยู่ภายนอกบล็อก if และ else และใช้ตัวแปร status_line และ filename สิ่งนี้ทำให้ง่ายต่อการมองเห็นความแตกต่างระหว่างสองกรณี และหมายความว่าเรามีเพียงจุดเดียวในการอัปเดตโค้ดหากเราต้องการเปลี่ยนแปลงวิธีที่การอ่านไฟล์และการเขียนการตอบกลับทำงาน พฤติกรรมของโค้ดในโค้ดตัวอย่างที่ 21-9 จะเหมือนกับในโค้ดตัวอย่างที่ 21-7

ยอดเยี่ยม! ตอนนี้เรามีเว็บเซิร์ฟเวอร์อย่างง่ายในโค้ด Rust ประมาณ 40 บรรทัด ซึ่งตอบกลับคำร้องขอหนึ่งด้วยหน้าเนื้อหา และตอบกลับคำร้องขออื่นทั้งหมดด้วยการตอบกลับ 404

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

From Single-Threaded to Multithreaded Server

From a Single-Threaded to a Multithreaded Server

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

Simulating a Slow Request

เราจะดูว่าคำร้องขอที่ประมวลผลช้าสามารถส่งผลกระทบต่อคำร้องขออื่นที่ถูกสร้างไปยังการอิมพลีเมนต์เซิร์ฟเวอร์ปัจจุบันของเราได้อย่างไร โค้ดตัวอย่างที่ 21-10 อิมพลีเมนต์การจัดการคำร้องขอไปยัง /sleep พร้อมการจำลองการตอบกลับที่ช้า ซึ่งจะทำให้เซิร์ฟเวอร์หลับ (sleep) เป็นเวลาห้าวินาทีก่อนที่จะตอบกลับ

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

ตอนนี้เราสลับจาก if มาเป็น match เนื่องจากเรามีสามกรณี เราจำเป็นต้องจับคู่บนสไลซ์ (slice) ของ request_line อย่างชัดเจนเพื่อจับคู่รูปแบบเทียบกับค่าตัวอักษรสตริง (string literal); match ไม่ได้ทำอ้างอิงและยกเลิกอ้างอิง (referencing and dereferencing) อัตโนมัติเหมือนเมธอดความเท่ากัน

แขนแรกเหมือนกับบล็อก if จากโค้ดตัวอย่างที่ 21-9 แขนที่สองจับคู่คำร้องขอไปยัง /sleep เมื่อได้รับคำร้องขอนั้น เซิร์ฟเวอร์จะหลับเป็นเวลาห้าวินาทีก่อนที่จะเรนเดอร์หน้า HTML ที่สำเร็จ แขนที่สามเหมือนกับบล็อก else จากโค้ดตัวอย่างที่ 21-9

คุณจะเห็นได้ว่าเซิร์ฟเวอร์ของเรามีพื้นฐานเพียงใด: ไลบรารีจริงจะจัดการการรับรู้คำร้องขอหลายรายการในลักษณะที่ยืดยาวน้อยกว่ามาก!

เริ่มเซิร์ฟเวอร์โดยใช้ cargo run จากนั้นเปิดหน้าต่างเบราว์เซอร์สองหน้าต่าง: หน้าต่างหนึ่งสำหรับ http://127.0.0.1:7878 และอีกหน้าต่างสำหรับ http://127.0.0.1:7878/sleep หากคุณป้อน URI / สองสามครั้งเหมือนก่อนหน้านี้ คุณจะเห็นว่ามันตอบกลับอย่างรวดเร็ว แต่หากคุณป้อน /sleep แล้วโหลด / คุณจะเห็นว่า / รอจนกว่า sleep จะหลับครบห้าวินาทีก่อนที่จะโหลด

มีเทคนิคหลายประการที่เราสามารถใช้เพื่อหลีกเลี่ยงไม่ให้คำร้องขอสะสมอยู่เบื้องหลังคำร้องขอที่ช้า ซึ่งรวมถึงการใช้ async ดังที่เราทำในบทที่ 17; สิ่งที่เราจะอิมพลีเมนต์คือพูลของเธรด (thread pool)

Improving Throughput with a Thread Pool

พูลของเธรด (thread pool) คือกลุ่มของเธรดที่ถูกสร้างขึ้น (spawned threads) ซึ่งพร้อมและรอที่จะจัดการงาน เมื่อโปรแกรมได้รับงานใหม่ มันจะมอบหมายงานให้เธรดตัวใดตัวหนึ่งในพูล และเธรดนั้นจะประมวลผลงานนั้น เธรดที่เหลือในพูลพร้อมที่จะจัดการกับงานอื่นที่เข้ามาในขณะที่เธรดแรกกำลังประมวลผล เมื่อเธรดแรกประมวลผลงานเสร็จสิ้น มันจะถูกส่งกลับไปยังพูลของเธรดที่ว่าง เพื่อพร้อมรับงานใหม่ พูลของเธรดช่วยให้คุณสามารถประมวลผลการเชื่อมต่อแบบทำงานพร้อมกัน (concurrently) เพิ่มปริมาณการประมวลผล (throughput) ของเซิร์ฟเวอร์คุณ

เราจะจำกัดจำนวนเธรดในพูลให้มีจำนวนน้อยเพื่อปกป้องเราจากการโจมตีแบบ DoS; หากเราให้โปรแกรมสร้างเธรดใหม่สำหรับแต่ละคำร้องขอที่เข้ามา หากมีคนสร้างคำร้องขอ 10 ล้านรายการไปยังเซิร์ฟเวอร์ของเรา สิ่งนั้นอาจสร้างความเสียหายโดยการใช้ทรัพยากรทั้งหมดของเซิร์ฟเวอร์เราจนหมด และทำให้การประมวลผลคำร้องขอนั้นหยุดชะงัก

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

เทคนิคนี้เป็นเพียงหนึ่งในหลายวิธีในการปรับปรุงปริมาณการประมวลผลของเว็บเซิร์ฟเวอร์ ตัวเลือกอื่นๆ ที่คุณอาจสำรวจคือแบบจำลอง fork/join, แบบจำลอง single-threaded async I/O, และแบบจำลอง multithreaded async I/O หากคุณสนใจหัวข้อนี้ คุณสามารถอ่านเพิ่มเติมเกี่ยวกับวิธีแก้อื่นๆ และลองอิมพลีเมนต์พวกมันได้; ด้วยภาษาในระดับต่ำอย่าง Rust ตัวเลือกทั้งหมดเหล่านี้เป็นไปได้

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

คล้ายกับวิธีที่เราใช้การพัฒนาที่ขับเคลื่อนด้วยการทดสอบ (test-driven development) ในโปรเจกต์ในบทที่ 12 เราจะใช้การพัฒนาที่ขับเคลื่อนด้วยคอมไพเลอร์ (compiler-driven development) ที่นี่ เราจะเขียนโค้ดที่เรียกฟังก์ชันที่เราต้องการ จากนั้นเราจะดูข้อผิดพลาดจากคอมไพเลอร์เพื่อพิจารณาว่าเราควรเปลี่ยนแปลงอะไรต่อไปเพื่อให้โค้ดทำงานได้ อย่างไรก็ตาม ก่อนที่เราจะทำเช่นนั้น เราจะสำรวจเทคนิคที่เราจะไม่นำมาใช้เป็นจุดเริ่มต้นก่อน

Spawning a Thread for Each Request

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

โค้ดตัวอย่างที่ 21-11 แสดงการเปลี่ยนแปลงที่จะทำกับ main เพื่อสร้างเธรดใหม่เพื่อจัดการแต่ละสตรีมภายในลูป for

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

ดังที่คุณได้เรียนรู้ในบทที่ 16 thread::spawn จะสร้างเธรดใหม่แล้วรันโค้ดในโคลเชอร์ในเธรดใหม่ หากคุณรันโค้ดนี้และโหลด /sleep ในเบราว์เซอร์ของคุณ แล้วโหลด / ในแท็บเบราว์เซอร์อีกสองแท็บ คุณจะเห็นว่าคำร้องขอไปยัง / ไม่ต้องรอให้ /sleep ทำงานเสร็จ อย่างไรก็ตาม ดังที่เราได้กล่าวไว้ สิ่งนี้จะทำให้ระบบท่วมท้นในที่สุดเพราะคุณกำลังสร้างเธรดใหม่โดยไม่มีขีดจำกัดใดๆ

คุณอาจจำได้จากบทที่ 17 ว่านี่คือสถานการณ์ประเภทที่ async และ await โดดเด่นอย่างยิ่ง! โปรดระลึกไว้เสมอขณะที่เราสร้างพูลของเธรด และคิดว่าสิ่งต่างๆ จะดูแตกต่างออกไปหรือเหมือนเดิมด้วย async อย่างไร

Creating a Finite Number of Threads

เราต้องการให้พูลของเธรดของเราทำงานในลักษณะที่คล้ายกันและคุ้นเคย เพื่อให้การสลับจากเธรดไปเป็นพูลของเธรดไม่ต้องมีการเปลี่ยนแปลงครั้งใหญ่ในโค้ดที่ใช้ API ของเรา โค้ดตัวอย่างที่ 21-12 แสดงอินเทอร์เฟซสมมติสำหรับสตรักต์ ThreadPoolที่เราต้องการใช้แทน thread::spawn

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

เราใช้ ThreadPool::new เพื่อสร้างพูลของเธรดใหม่ที่มีจำนวนเธรดที่สามารถตั้งค่าได้ ในกรณีนี้คือสี่ จากนั้น ในลูป for pool.execute จะมีอินเทอร์เฟซคล้ายกับ thread::spawn ตรงที่รับโคลเชอร์ที่พูลควรรันสำหรับแต่ละสตรีม เราจำเป็นต้องอิมพลีเมนต์ pool.execute เพื่อให้รับโคลเชอร์และมอบให้กับเธรดในพูลเพื่อรัน โค้ดนี้จะยังไม่คอมไพล์ แต่เราจะพยายามเพื่อให้คอมไพเลอร์สามารถนำทางเราในการแก้ไขได้

Building ThreadPool Using Compiler-Driven Development

ทำการเปลี่ยนแปลงในโค้ดตัวอย่างที่ 21-12 ลงใน src/main.rs จากนั้นให้เราใช้ข้อผิดพลาดจากคอมไพเลอร์จาก cargo check เพื่อนำทางการพัฒนาของเรา นี่คือข้อผิดพลาดแรกที่เราได้รับ:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: cannot find type `ThreadPool` in this scope
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

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

สร้างไฟล์ src/lib.rs ที่บรรจุเนื้อหาต่อไปนี้ ซึ่งเป็นนิยามอย่างง่ายที่สุดของสตรักต์ ThreadPool ที่เราสามารถมีได้ในขณะนี้:

pub struct ThreadPool;

จากนั้น แก้ไขไฟล์ main.rs นำ ThreadPool เข้าสู่ขอบเขตจากไลบรารีเครตโดยการเพิ่มโค้ดต่อไปนี้ไว้ที่ด้านบนสุดของ src/main.rs:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

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

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no associated function or constant named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ associated function or constant not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

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

pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

เราเลือก usize เป็นประเภทของพารามิเตอร์ size เนื่องจากเรารู้ว่าจำนวนเธรดที่เป็นลบนั้นไม่มีความหมาย เรายังรู้ด้วยว่าเราจะใช้ 4 นี้เป็นจำนวนองค์ประกอบในคอลเลกชันของเธรด ซึ่งเป็นสิ่งที่ประเภท usize ถูกสร้างมาเพื่อใช้งาน ดังที่ได้หารือกันในหัวข้อ “Integer Types” ในบทที่ 3

ให้เราตรวจสอบโค้ดอีกครั้ง:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

ตอนนี้ข้อผิดพลาดเกิดขึ้นเนื่องจากเราไม่มีเมธอด execute บน ThreadPool จำได้ไหมจากหัวข้อ “การสร้างจำนวนเธรดที่จำกัด” ว่าเราตัดสินใจให้พูลของเธรดของเรามีอินเทอร์เฟซคล้ายกับ thread::spawn นอกจากนี้ เราจะอิมพลีเมนต์ฟังก์ชัน execute เพื่อให้รับโคลเชอร์ที่ให้มาและมอบให้แก่เธรดที่ว่างในพูลเพื่อรัน

เราจะนิยามเมธอด execute บน ThreadPool เพื่อรับโคลเชอร์เป็นพารามิเตอร์ จำได้ไหมจากหัวข้อ “การย้ายค่าที่จับได้ออกจาก Closures” ในบทที่ 13 ว่าเราสามารถรับโคลเชอร์เป็นพารามิเตอร์ด้วยเทรตที่แตกต่างกันสามแบบ: Fn, FnMut, และ FnOnce เราจำเป็นต้องตัดสินใจว่าจะใช้โคลเชอร์ชนิดใดในที่นี้ เรารู้ว่าเราจะลงเอยด้วยการทำบางสิ่งที่คล้ายกับการอิมพลีเมนต์ thread::spawn ของไลบรารีมาตรฐาน ดังนั้นเราจึงสามารถดูว่าลายเซ็นของ thread::spawn มีข้อจำกัดอะไรบ้างบนพารามิเตอร์ของมัน เอกสารประกอบแสดงให้เราเห็นดังนี้:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

พารามิเตอร์ประเภท F คือสิ่งที่เรากำลังพิจารณาในที่นี้; พารามิเตอร์ประเภท T เกี่ยวข้องกับค่าที่ส่งคืน และเราไม่ได้พิจารณาในส่วนนั้น เราจะเห็นว่า spawn ใช้ FnOnce เป็นข้อจำกัดเทรตบน F สิ่งนี้น่าจะเป็นสิ่งที่เราต้องการเช่นกัน เนื่องจากสุดท้ายแล้วเราจะส่งอาร์กิวเมนต์ที่เราได้ใน execute ไปยัง spawn เรามีความมั่นใจยิ่งขึ้นว่า FnOnce คือเทรตที่เราต้องการใช้ เนื่องจากเธรดสำหรับการรันคำร้องขอจะดำเนินการโคลเชอร์ของคำร้องขอนั้นเพียงครั้งเดียว ซึ่งตรงกับ Once ใน FnOnce

พารามิเตอร์ประเภท F ยังมีข้อจำกัดเทรต Send และข้อจำกัดอายุขัย 'static ซึ่งมีประโยชน์ในสถานการณ์ของเรา: เราจำเป็นต้องใช้ Send เพื่อถ่ายโอนโคลเชอร์จากเธรดหนึ่งไปยังอีกเธรดหนึ่ง และ 'static เพราะเราไม่รู้ว่าเธรดจะใช้เวลานานเท่าใดในการทำงาน ให้เราสร้างเมธอด execute บน ThreadPool ที่จะรับพารามิเตอร์เจเนอริกประเภท F พร้อมข้อจำกัดเหล่านี้:

pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

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

อีกครั้ง นี่คือการอิมพลีเมนต์อย่างง่ายที่สุดของเมธอด execute: มันไม่ทำอะไรเลย แต่เราเพียงพยายามทำให้โค้ดของเราคอมไพล์ได้ ให้เราตรวจสอบอีกครั้ง:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

มันคอมไพล์ผ่านแล้ว! แต่โปรดทราบว่าหากคุณลอง cargo run และทำการร้องขอในเบราว์เซอร์ คุณจะเห็นข้อผิดพลาดในเบราว์เซอร์ที่เราเห็นในตอนเริ่มต้นของบท ไลบรารีของเรายังไม่ได้เรียกใช้โคลเชอร์ที่ส่งไปยัง execute จริงๆ!

หมายเหตุ: คำกล่าวที่คุณอาจได้ยินเกี่ยวกับภาษาที่มีคอมไพเลอร์ที่เข้มงวด เช่น Haskell และ Rust คือ “หากโค้ดคอมไพล์ผ่าน มันก็ทำงานได้” แต่คำกล่าวนี้ไม่ได้เป็นจริงเสมอไป โปรเจกต์ของเราคอมไพล์ผ่าน แต่มันไม่ได้ทำอะไรเลยจริงๆ! หากเรากำลังสร้างโปรเจกต์จริงที่สมบูรณ์ นี่จะเป็นเวลาที่ดีที่จะเริ่มเขียนยูนิตเทสต์เพื่อตรวจสอบว่าโค้ดคอมไพล์ผ่าน และ มีพฤติกรรมที่เราต้องการ

พิจารณา: จะมีอะไรแตกต่างกันบ้างในที่นี้ หากเรากำลังจะรัน future แทนที่จะเป็น closure?

Validating the Number of Threads in new

เรายังไม่ได้ทำอะไรกับพารามิเตอร์ของ new และ execute ให้เราอิมพลีเมนต์บอดี้ของฟังก์ชันเหล่านี้ด้วยพฤติกรรมที่เราต้องการ ในการเริ่มต้น ให้เราคิดถึง new ก่อนหน้านี้เราเลือกประเภท Unsigned สำหรับพารามิเตอร์ size เนื่องจากพูลที่มีจำนวนเธรดเป็นลบนั้นไม่มีความหมาย อย่างไรก็ตาม พูลที่มีศูนย์เธรดก็ไม่มีความหมายเช่นกัน ทั้งๆ ที่ศูนย์เป็น usize ที่ถูกต้องโดยสมบูรณ์ เราจะเพิ่มโค้ดเพื่อตรวจสอบว่า size มากกว่าศูนย์ก่อนที่เราจะคืนค่าอินสแตนซ์ ThreadPool และเราจะให้โปรแกรมเกิด panic หากได้รับศูนย์ โดยใช้แมโคร assert! ดังแสดงในโค้ดตัวอย่างที่ 21-13

pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

เราได้เพิ่มเอกสารประกอบสำหรับ ThreadPool ของเราด้วยความคิดเห็นเอกสาร (doc comments) สังเกตว่าเราได้ปฏิบัติตามแนวทางปฏิบัติในการเขียนเอกสารที่ดีโดยการเพิ่มส่วนที่ระบุถึงสถานการณ์ที่ฟังก์ชันของเราสามารถเกิด panic ได้ ดังที่ได้หารือกันในบทที่ 14 ลองรัน cargo doc --open แล้วคลิกที่สตรักต์ ThreadPool เพื่อดูว่าเอกสารที่สร้างขึ้นสำหรับ new มีหน้าตาเป็นอย่างไร!

แทนที่จะเพิ่มแมโคร assert! อย่างที่เราได้ทำที่นี่ เราสามารถเปลี่ยน new เป็น build และคืนค่า Result เหมือนที่เราทำกับ Config::build ในโปรเจกต์ I/O ในโค้ดตัวอย่างที่ 12-9 ได้ แต่เราได้ตัดสินใจในกรณีนี้ว่าความพยายามสร้างพูลของเธรดโดยไม่มีเธรดใดๆ ควรเป็นข้อผิดพลาดที่ไม่สามารถฟื้นฟูได้ (unrecoverable error) หากคุณมีความทะเยอทะยาน ลองเขียนฟังก์ชันชื่อ build ที่มีลายเซ็นต่อไปนี้เพื่อเปรียบเทียบกับฟังก์ชัน new:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

Creating Space to Store the Threads

ตอนนี้เมื่อเรามีวิธีทำให้รู้ว่าเรามีจำนวนเธรดที่ถูกต้องเพื่อเก็บในพูลแล้ว เราสามารถสร้างเธรดเหล่านั้นและเก็บไว้ในสตรักต์ ThreadPool ก่อนส่งคืนสตรักต์ได้ แต่เราจะ “เก็บ” เธรดได้อย่างไร? ให้เราดูที่ลายเซ็นของ thread::spawn อีกครั้ง:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

ฟังก์ชัน spawn จะคืนค่า JoinHandle<T> โดยที่ T คือประเภทที่โคลเชอร์ส่งคืน ให้เราลองใช้ JoinHandle ด้วยและดูว่าจะเกิดอะไรขึ้น ในกรณีของเรา โคลเชอร์ที่เราส่งไปยังพูลของเธรดจะจัดการการเชื่อมต่อและไม่ส่งคืนสิ่งใด ดังนั้น T จะเป็นประเภทนิต ()

โค้ดในโค้ดตัวอย่างที่ 21-14 จะคอมไพล์ผ่าน แต่มันยังไม่ได้สร้างเธรดใดๆ เราได้เปลี่ยนนิยามของ ThreadPool ให้ถือครองเวกเตอร์ของอินสแตนซ์ thread::JoinHandle<()> กำหนดค่าเริ่มต้นให้กับเวกเตอร์ด้วยความจุ size ตั้งค่าลูป for ที่จะรันโค้ดบางอย่างเพื่อสร้างเธรด และคืนค่าอินสแตนซ์ ThreadPool ที่บรรจุพวกมันอยู่

use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

เรานำ std::thread เข้าสู่ขอบเขตในไลบรารีเครตเนื่องจากเรากำลังใช้ thread::JoinHandle เป็นประเภทของไอเทมในเวกเตอร์ใน ThreadPool

เมื่อได้รับขนาดที่ถูกต้องแล้ว ThreadPool ของเราจะสร้างเวกเตอร์ใหม่ที่สามารถเก็บไอเทมขนาด size ได้ ฟังก์ชัน with_capacity ทำงานแบบเดียวกับ Vec::new แต่มีความแตกต่างที่สำคัญ: มันจัดสรรพื้นที่ในเวกเตอร์ไว้ล่วงหน้า เนื่องจากเรารู้ว่าเราจำเป็นต้องเก็บองค์ประกอบขนาด size ในเวกเตอร์ การจัดสรรนี้ล่วงหน้าจึงมีประสิทธิภาพมากกว่าการใช้ Vec::new เล็กน้อย ซึ่งจะปรับขนาดตัวเองเมื่อมีการแทรกองค์ประกอบ

เมื่อคุณรัน cargo check อีกครั้ง มันควรจะสำเร็จ

Sending Code from the ThreadPool to a Thread

เราได้ทิ้งความคิดเห็นไว้ในลูป for ในโค้ดตัวอย่างที่ 21-14 เกี่ยวกับการสร้างเธรด ในที่นี้ เราจะดูวิธีที่เราสร้างเธรดจริงๆ ไลบรารีมาตรฐานมี thread::spawn เป็นวิธีสร้างเธรด และ thread::spawn คาดหวังว่าจะได้รับโค้ดบางอย่างที่เธรดควรรันทันทีที่เธรดถูกสร้างขึ้น อย่างไรก็ตาม ในกรณีของเรา เราต้องการสร้างเธรดและให้พวกมัน รอ โค้ดที่เราจะส่งในภายหลัง การอิมพลีเมนต์เธรดของไลบรารีมาตรฐานไม่ได้รวมวิธีทำสิ่งนั้นไว้; เราต้องอิมพลีเมนต์มันเองด้วยตนเอง

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

ลองนึกถึงคนที่ทำงานในห้องครัวในร้านอาหาร: คนงาน (workers) จะรอจนกว่าคำสั่งซื้อจะมาจากลูกค้า จากนั้นพวกเขาก็มีหน้าที่รับคำสั่งซื้อเหล่านั้นและทำตามคำสั่งซื้อ

แทนที่จะเก็บเวกเตอร์ของอินสแตนซ์ JoinHandle<()> ในพูลของเธรด เราจะเก็บอินสแตนซ์ของสตรักต์ Worker แต่ละ Worker จะเก็บอินสแตนซ์ JoinHandle<()> เดี่ยวไว้ จากนั้น เราจะอิมพลีเมนต์เมธอดบน Worker ที่จะรับโคลเชอร์ของโค้ดที่จะรันและส่งมันไปให้เธรดที่กำลังรันอยู่แล้วเพื่อประมวลผล เรายังจะให้ id แก่แต่ละ Worker เพื่อให้เราสามารถแยกแยะระหว่างอินสแตนซ์ต่างๆ ของ Worker ในพูลเมื่อมีการบันทึกบันทึก (logging) หรือการดีบัก

นี่คือกระบวนการใหม่ที่จะเกิดขึ้นเมื่อเราสร้าง ThreadPool เราจะอิมพลีเมนต์โค้ดที่ส่งโคลเชอร์ไปยังเธรดหลังจากที่เราได้ตั้งค่า Worker ในลักษณะนี้:

  1. นิยามสตรักต์ Worker ที่ถือครอง id และ JoinHandle<()>
  2. เปลี่ยน ThreadPool ให้ถือครองเวกเตอร์ของอินสแตนซ์ Worker
  3. นิยามฟังก์ชัน Worker::new ที่รับหมายเลข id และคืนค่าอินสแตนซ์ Worker ที่ถือครอง id และเธรดที่ถูกสร้างขึ้นด้วยโคลเชอร์ว่างเปล่า
  4. ใน ThreadPool::new ให้ใช้ตัวนับลูป for เพื่อสร้าง id สร้าง Worker ใหม่ด้วย id นั้น และเก็บ Worker นั้นในเวกเตอร์

หากคุณพร้อมสำหรับความท้าทาย ลองอิมพลีเมนต์การเปลี่ยนแปลงเหล่านี้ด้วยตนเองก่อนที่จะดูโค้ดในโค้ดตัวอย่างที่ 21-15

พร้อมหรือยัง? นี่คือโค้ดตัวอย่างที่ 21-15 พร้อมวิธีหนึ่งในการทำการปรับเปลี่ยนดังกล่าว

use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

เราได้เปลี่ยนชื่อของฟิลด์บน ThreadPool จาก threads เป็น workers เนื่องจากตอนนี้มันถือครองอินสแตนซ์ Worker แทนที่จะเป็นอินสแตนซ์ JoinHandle<()> เราใช้ตัวนับในลูป for เป็นอาร์กิวเมนต์ให้กับ Worker::new และเราเก็บ Worker ใหม่แต่ละอันในเวกเตอร์ชื่อ workers

โค้ดภายนอก (เช่น เซิร์ฟเวอร์ของเราใน src/main.rs) ไม่จำเป็นต้องรู้รายละเอียดการอิมพลีเมนต์เกี่ยวกับการใช้สตรักต์ Worker ภายใน ThreadPool ดังนั้นเราจึงทำให้สตรักต์ Worker และฟังก์ชัน new ของมันเป็นแบบ private ฟังก์ชัน Worker::new ใช้ idที่เรามอบให้ และเก็บอินสแตนซ์ JoinHandle<()> ที่ถูกสร้างขึ้นโดยการสร้างเธรดใหม่โดยใช้โคลเชอร์ว่างเปล่า

หมายเหตุ: หากระบบปฏิบัติการไม่สามารถสร้างเธรดได้เนื่องจากมีทรัพยากรระบบไม่เพียงพอ thread::spawn จะเกิด panic สิ่งนั้นจะทำให้เซิร์ฟเวอร์ทั้งหมดของเราเกิด panic แม้ว่าการสร้างเธรดบางอันอาจสำเร็จก็ตาม เพื่อความเรียบง่าย พฤติกรรมนี้ถือว่าใช้ได้ แต่ในการอิมพลีเมนต์พูลของเธรดสำหรับการทำงานจริง คุณน่าจะต้องการใช้ std::thread::Builder และเมธอด spawn ของมันที่คืนค่า Result แทน

โค้ดนี้จะคอมไพล์ผ่านและจะเก็บจำนวนอินสแตนซ์ Worker ตามที่เราจับคู่ไว้เป็นอาร์กิวเมนต์ให้กับ ThreadPool::new แต่เรา ยังคง ไม่ได้ประมวลผลโคลเชอร์ที่เราได้ใน execute ให้เราดูวิธีทำสิ่งนั้นต่อไป

Sending Requests to Threads via Channels

ปัญหาต่อไปที่เราจะจัดการคือโคลเชอร์ที่มอบให้กับ thread::spawn ไม่ได้ทำอะไรเลย ในปัจจุบัน เราได้รับโคลเชอร์ที่เราต้องการรันในเมธอด execute แต่เราจำเป็นต้องมอบโคลเชอร์ให้กับ thread::spawn เพื่อรันเมื่อเราสร้างแต่ละ Worker ในระหว่างการสร้าง ThreadPool

เราต้องการให้สตรักต์ Worker ที่เราเพิ่งสร้างขึ้นดึงโค้ดที่จะรันจากคิวที่ถือครองอยู่ใน ThreadPool และส่งโค้ดนั้นไปยังเธรดของมันเพื่อรัน

ช่องทางสื่อสาร (channels) ที่เราเรียนรู้กันในบทที่ 16—วิธีง่ายๆ ในการสื่อสารระหว่างสองเธรด—จะสมบูรณ์แบบสำหรับกรณีการใช้งานนี้ เราจะใช้ช่องทางสื่อสารทำหน้าที่เป็นคิวของงาน และ execute จะส่งงานจาก ThreadPool ไปยังอินสแตนซ์ Worker ซึ่งจะส่งงานไปยังเธรดของมัน นี่คือแผน:

  1. ThreadPool จะสร้างช่องทางสื่อสารและถือครองผู้ส่ง (sender) ไว้
  2. แต่ละ Worker จะถือครองผู้รับ (receiver) ไว้
  3. เราจะสร้างสตรักต์ Job ใหม่ที่จะถือครองโคลเชอร์ที่เราต้องการส่งผ่านช่องทางสื่อสาร
  4. เมธอด execute จะส่งงานที่ต้องการรันผ่านผู้ส่ง
  5. ในเธรดของมัน Worker จะวนลูปเหนือผู้รับและรันโคลเชอร์ของงานใดๆ ที่มันได้รับ

ให้เราเริ่มต้นด้วยการสร้างช่องทางสื่อสารใน ThreadPool::new และถือครองผู้ส่งไว้ในอินสแตนซ์ ThreadPool ดังแสดงในโค้ดตัวอย่างที่ 21-16 สตรักต์ Job ไม่ได้ถือครองสิ่งใดไว้ในตอนนี้ แต่จะเป็นประเภทของไอเทมที่เราส่งผ่านช่องทางสื่อสาร

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

ใน ThreadPool::new เราสร้างช่องทางสื่อสารใหม่ของเราและให้พูลถือครองผู้ส่ง สิ่งนี้จะคอมไพล์ผ่านได้สำเร็จ

ให้เราลองส่งผู้รับของช่องทางสื่อสารไปยังแต่ละ Worker ในขณะที่พูลของเธรดสร้างช่องทางสื่อสาร เรารู้ว่าเราต้องการใช้ผู้รับในเธรดที่อินสแตนซ์ Worker สร้างขึ้น ดังนั้นเราจะอ้างอิงพารามิเตอร์ receiver ในโคลเชอร์ โค้ดในโค้ดตัวอย่างที่ 21-17 จะยังไม่คอมไพล์ผ่าน

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

เราได้ทำการเปลี่ยนแปลงเล็กๆ และตรงไปตรงมา: เราส่งผู้รับเข้าสู่ Worker::new จากนั้นเราใช้มันภายในโคลเชอร์

เมื่อเราลองตรวจสอบโค้ดนี้ เราได้รับข้อผิดพลาดนี้:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

โค้ดกำลังพยายามส่ง receiver ไปยังหลายๆ อินสแตนซ์ Worker สิ่งนี้จะไม่ทำงาน ดังที่คุณจะจำได้จากบทที่ 16: การอิมพลีเมนต์ช่องทางสื่อสารที่ Rust มีให้นั้นเป็นแบบหลายผู้ผลิต ผู้บริโภคเดียว (multiple producer, single consumer) นี่หมายความว่าเราไม่สามารถแค่โคลนฝั่งผู้บริโภคของช่องทางสื่อสารเพื่อแก้ไขโค้ดนี้ได้ เรายังไม่ต้องการส่งข้อความหลายครั้งไปยังผู้บริโภคหลายราย; เราต้องการรายการข้อความหนึ่งรายการกับอินสแตนซ์ Worker หลายอันเพื่อให้แต่ละข้อความถูกประมวลผลเพียงครั้งเดียว

นอกจากนี้ การดึงงานออกจากคิวช่องทางสื่อสารเกี่ยวข้องกับการเปลี่ยนแปลง receiver ดังนั้นเธรดจึงต้องการวิธีที่ปลอดภัยในการแชร์และแก้ไข receiver; มิฉะนั้น เราอาจเจอภาวะแย่งชิงทรัพยากร (race conditions) (ดังที่ครอบคลุมในบทที่ 16)

สืบค้นสมาร์ตพอยน์เตอร์ที่ปลอดภัยสำหรับเธรด (thread-safe smart pointers) ที่หารือกันในบทที่ 16: ในการแชร์ความเป็นเจ้าของข้ามหลายเธรดและอนุญาตให้เธรดเปลี่ยนแปลงค่าได้ เราจำเป็นต้องใช้ Arc<Mutex<T>> ประเภท Arc จะอนุญาตให้อินสแตนซ์ Worker หลายอันเป็นเจ้าของผู้รับ และ Mutex จะรับประกันว่าจะมี Worker เพียงอันเดียวเท่านั้นที่ได้รับงานจากผู้รับในแต่ละครั้ง โค้ดตัวอย่างที่ 21-18 แสดงการเปลี่ยนแปลงที่เราจำเป็นต้องทำ

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

ใน ThreadPool::new เราวางผู้รับไว้ใน Arc และ Mutex สำหรับ Worker ใหม่แต่ละอัน เราโคลน Arc เพื่อเพิ่มตัวนับการอ้างอิงเพื่อให้ อินสแตนซ์ Worker สามารถแชร์ความเป็นเจ้าของของผู้รับได้

ด้วยการเปลี่ยนแปลงเหล่านี้ โค้ดจะคอมไพล์ผ่านได้! เราใกล้จะถึงจุดนั้นแล้ว!

Implementing the execute Method

ในที่สุด ให้เราอิมพลีเมนต์เมธอด execute บน ThreadPool เรายังจะเปลี่ยน Job จากสตรักต์มาเป็นนามแฝงประเภท (type alias) สำหรับ trait object ที่ถือครองประเภทของโคลเชอร์ที่ executeได้รับ ดังที่ได้หารือกันในหัวข้อ “Type Synonyms and Type Aliases” ในบทที่ 20 นามแฝงประเภทช่วยให้เราทำให้ประเภทยาวๆ สั้นลงเพื่อความสะดวกในการใช้งาน ดูที่โค้ดตัวอย่างที่ 21-19

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

หลังจากสร้างอินสแตนซ์ Job ใหม่โดยใช้โคลเชอร์ที่เราได้ใน execute เราส่งงานนั้นผ่านฝั่งส่งของช่องทางสื่อสาร เรากำลังเรียก unwrap บน send สำหรับกรณีที่การส่งล้มเหลว สิ่งนี้อาจเกิดขึ้นได้หาก ตัวอย่างเช่น เราหยุดเธรดทั้งหมดจากการประมวลผล ซึ่งหมายความว่าฝั่งรับหยุดการรับข้อความใหม่แล้ว ในปัจจุบัน เราไม่สามารถหยุดเธรดของเราจากการประมวลผลได้: เธรดของเรายังคงประมวลผลต่อไปตราบเท่าที่พูลยังมีอยู่ เหตุผลที่เราใช้ unwrap คือเรารู้ว่ากรณีการล้มเหลวจะไม่เกิดขึ้น แต่คอมไพเลอร์ไม่รู้สิ่งนั้น

แต่เรายังทำไม่เสร็จสิ้น! ใน Worker โคลเชอร์ของเราที่ถูกส่งไปยัง thread::spawn ยังคงเพียง อ้างอิง ถึงฝั่งรับของช่องทางสื่อสารเท่านั้น แต่เราต้องการให้โคลเชอร์วนลูปตลอดไป ถามฝั่งรับของช่องทางสื่อสารสำหรับงาน และรันงานเมื่อได้รับมา ให้เราทำการเปลี่ยนแปลงที่แสดงในโค้ดตัวอย่างที่ 21-20 กับ Worker::new

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

ในที่นี้ ก่อนอื่นเราเรียก lock บน receiver เพื่อครอบครองมิวเทกซ์ (mutex) จากนั้นเราเรียก unwrap เพื่อให้เกิด panic สำหรับข้อผิดพลาดใดๆ การครอบครองล็อกอาจล้มเหลวหากมิวเทกซ์อยู่ในสภาวะ ถูกวางยา (poisoned state) ซึ่งเกิดขึ้นได้หากเธรดอื่นเกิด panic ขณะที่ถือครองล็อกไว้แทนที่จะปล่อยล็อก ในสถานการณ์นี้ การเรียก unwrap เพื่อให้เธรดนี้เกิด panic จึงเป็นการดำเนินการที่ถูกต้อง รู้สึกอิสระที่จะเปลี่ยน unwrap นี้เป็น expect พร้อมข้อความแสดงข้อผิดพลาดที่มีความหมายสำหรับคุณ

หากเราได้รับล็อกบนมิวเทกซ์ เราจะเรียก recv เพื่อรับ Job จากช่องทางสื่อสาร unwrap สุดท้ายจะก้าวข้ามข้อผิดพลาดใดๆ ที่นี่เช่นกัน ซึ่งอาจเกิดขึ้นได้หากเธรดที่ถือครองผู้ส่งปิดตัวลง คล้ายกับวิธีที่เมธอด send คืนค่า Err หากผู้รับปิดตัวลง

การเรียก recv จะเป็นแบบบล็อก (blocks) ดังนั้นหากยังไม่มีงาน เธรดปัจจุบันจะรอจนกว่างานจะพร้อมใช้งาน Mutex<T> รับประกันว่าจะ มีเธรด Worker เพียงอันเดียวเท่านั้นในแต่ละครั้งที่พยายามร้องของาน

พูลของเธรดของเราอยู่ในสภาวะที่ทำงานได้แล้ว! ลองให้ cargo run และทำการร้องขอบางอย่าง:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
  --> src/lib.rs:7:5
   |
6  | pub struct ThreadPool {
   |            ---------- field in this struct
7  |     workers: Vec<Worker>,
   |     ^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
      Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

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

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

นี่เป็นเวลาที่ดีที่จะหยุดพักและพิจารณาว่าโค้ดในโค้ดตัวอย่างที่ 21-18, 21-19, และ 21-20 จะแตกต่างกันอย่างไร หากเราใช้ futures แทน closure สำหรับงานที่จะทำ ประเภทใดบ้างจะเปลี่ยนแปลง? ลายเซ็นเมธอดจะแตกต่างกันอย่างไร (หากมี)? ส่วนใดของโค้ดจะยังคงเหมือนเดิม?

หลังจากเรียนรู้เกี่ยวกับลูป while let ในบทที่ 17 และบทที่ 19 คุณอาจสงสัยว่าทำไมเราจึงไม่เขียนโค้ดเธรด Worker ดังแสดงในโค้ดตัวอย่างที่ 21-21

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

โค้ดนี้คอมไพล์ผ่านและรันได้ แต่ไม่ได้ส่งผลให้เกิดพฤติกรรมเธรดที่ต้องการ: คำร้องขอที่ช้าจะยังคงทำให้คำร้องขออื่นต้องรอรับการประมวลผล เหตุผลมีความค่อนข้างละเอียดอ่อน: สตรักต์ Mutex ไม่มีเมธอด unlock สาธารณะเนื่องจากความเป็นเจ้าของล็อกอิงตามอายุขัยของ MutexGuard<T> ภายใน LockResult<MutexGuard<T>> ที่เมธอด lock คืนค่า ในขณะคอมไพล์ ตัวตรวจสอบการยืม (borrow checker) สามารถบังคับใช้กฎที่ว่าทรัพยากรที่ถูกป้องกันโดย Mutex ไม่สามารถเข้าถึงได้เว้นแต่เราจะถือครองล็อก อย่างไรก็ตาม การอิมพลีเมนต์นี้ยังสามารถส่งผลให้ล็อกถูกถือครองนานกว่าที่ตั้งใจไว้หากเราไม่ระมัดระวังเกี่ยวกับอายุขัยของ MutexGuard<T>

โค้ดในโค้ดตัวอย่างที่ 21-20 ที่ใช้ let job = receiver.lock().unwrap().recv().unwrap(); ทำงานได้เพราะว่าด้วย let ค่าชั่วคราวใดๆ ที่ใช้ในนิพจน์ทางฝั่งขวาของเครื่องหมายเท่ากับจะถูกดรอปทันทีเมื่อคำสั่ง let สิ้นสุดลง อย่างไรก็ตาม while let (รวมถึง if let และ match) ไม่ได้ดรอปค่าชั่วคราวจนกว่าจะสิ้นสุดบล็อกที่เกี่ยวข้อง ในโค้ดตัวอย่างที่ 21-21 ล็อกยังคงถูกถือครองตลอดระยะเวลาของการเรียกใช้ job() ซึ่งหมายความว่าอินสแตนซ์ Worker อื่นๆ ไม่สามารถรับงานได้

Graceful Shutdown and Cleanup

Graceful Shutdown and Cleanup

โค้ดในโค้ดตัวอย่างที่ 21-20 กำลังตอบกลับคำร้องขอแบบไม่พร้อมกัน (asynchronously) ผ่านการใช้พูลของเธรดตามที่เราตั้งใจ เราได้รับการแจ้งเตือนบางอย่างเกี่ยวกับฟิลด์ workers, id, และ thread ที่เราไม่ได้ใช้โดยตรง ซึ่งเตือนเราว่าเราไม่ได้ล้างข้อมูล (clean up) อะไรเลย เมื่อเราใช้วิธี ctrl-C ที่สวยงามน้อยกว่าในการหยุดเธรดหลัก เธรดอื่นทั้งหมดก็ถูกหยุดลงทันทีเช่นกัน แม้ว่าพวกมันกำลังอยู่ในระหว่างการให้บริการคำร้องขออยู่ก็ตาม

ถัดไป เราจะอิมพลีเมนต์ Drop trait เพื่อเรียก join บนแต่ละเธรดในพูล เพื่อให้พวกมันสามารถทำคำร้องขอที่กำลังทำอยู่ให้เสร็จสิ้นก่อนที่จะปิดลง จากนั้น เราจะอิมพลีเมนต์วิธีที่จะบอกเธรดว่าพวกมันควรหยุดรับคำร้องขอใหม่และปิดตัวลง เพื่อให้เห็นโค้ดนี้ทำงานจริง เราจะปรับเปลี่ยนเซิร์ฟเวอร์ของเราให้รับคำร้องขอเพียงสองรายการก่อนที่จะปิดตัวพูลของเธรดอย่างนุ่มนวล (graceful shutdown)

สิ่งหนึ่งที่ต้องสังเกตขณะเราดำเนินการ: ไม่มีสิ่งใดในนี้ส่งผลกระทบต่อส่วนของโค้ดที่จัดการการรันโคลเชอร์ ดังนั้นทุกอย่างในที่นี้จะเหมือนเดิมแม้ว่าเราจะใช้พูลของเธรดสำหรับ async runtime ก็ตาม

Implementing the Drop Trait on ThreadPool

เรามาเริ่มต้นด้วยการอิมพลีเมนต์ Drop บนพูลของเธรดของเรา เมื่อพูลถูกดรอป เธรดของเราทั้งหมดควรเรียก join เพื่อให้แน่ใจว่าพวกมันทำงานเสร็จสิ้น โค้ดตัวอย่างที่ 21-22 แสดงความพยายามครั้งแรกในการอิมพลีเมนต์ Drop; โค้ดนี้จะยังไม่คอมไพล์ผ่าน

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

แรกเริ่ม เราวนลูปผ่านแต่ละ workers ของพูลของเธรด เราใช้ &mut สำหรับสิ่งนี้เนื่องจาก self เป็นการอ้างอิงที่เปลี่ยนแปลงค่าได้ (mutable reference) และเรายังจำเป็นต้องสามารถเปลี่ยนแปลงค่า worker ได้ด้วย สำหรับแต่ละ worker เราพิมพ์ข้อความระบุว่าอินสแตนซ์ Worker เฉพาะนี้กำลังปิดตัวลง จากนั้นเราเรียก join บนเธรดของอินสแตนซ์ Worker นั้น หากการเรียก join ล้มเหลว เราใช้ unwrap เพื่อทำให้ Rust เกิด panic และขยับไปสู่การปิดตัวที่ไม่นุ่มนวล

นี่คือข้อผิดพลาดที่เราได้รับเมื่อคอมไพล์โค้ดนี้:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/std/src/thread/join_handle.rs:149:16

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

ข้อผิดพลาดบอกเราว่าเราไม่สามารถเรียก join ได้เนื่องจากเรามีเพียงยืมแบบเปลี่ยนแปลงค่าได้ (mutable borrow) ของแต่ละ worker และ join รับความเป็นเจ้าของอาร์กิวเมนต์ของมัน เพื่อแก้ปัญหานี้ เราจำเป็นต้องย้ายเธรดออกจากอินสแตนซ์ Worker ที่เป็นเจ้าของ thread เพื่อให้ join สามารถบริโภคเธรดได้ วิธีหนึ่งในการทำเช่นนี้คือการใช้แนวทางเดียวกับที่เราใช้ในโค้ดตัวอย่างที่ 18-15 หาก Worker ถือครอง Option<thread::JoinHandle<()>> เราจะสามารถเรียกเมธอด take บน Option เพื่อย้ายค่าออกจากตัวแปรผัน Some และเหลือตัวแปรผัน None ไว้แทนที่ กล่าวอีกนัยหนึ่ง Worker ที่กำลังรันจะมีตัวแปรผัน Some ใน thread และเมื่อเราต้องการล้างข้อมูล Worker เราก็จะแทนที่ Some ด้วย None เพื่อให้ Worker ไม่มีเธรดให้รัน

อย่างไรก็ตาม เวลา_เดียว_ที่จะเกิดสิ่งนี้ขึ้นคือเมื่อทำการดรอป Worker ในทางกลับกัน เราจะต้องจัดการกับ Option<thread::JoinHandle<()>> ทุกๆ ที่ที่เราเข้าถึง worker.thread Rust แบบสำนวนนิยม (Idiomatic Rust) ใช้ Option ค่อนข้างมาก แต่เมื่อคุณพบว่าตัวเองหุ้มสิ่งที่คุณรู้ว่าจะมีอยู่เสมอไว้ใน Option เพื่อเป็นทางแก้ปัญหาชั่วคราวเช่นนี้ จึงเป็นความคิดที่ดีที่จะมองหาแนวทางอื่นเพื่อให้โค้ดของคุณสะอาดขึ้นและเกิดข้อผิดพลาดได้น้อยลง

ในกรณีนี้ มีทางเลือกที่ดีกว่า: เมธอด Vec::drain มันรับพารามิเตอร์ช่วง (range parameter) เพื่อระบุไอเทมที่จะลบออกจากเวกเตอร์ และคืนค่าตัววนซ้ำของไอเทมเหล่านั้น การส่งไวยากรณ์ช่วง .. จะลบ ทุก ค่าออกจากเวกเตอร์

ดังนั้น เราจำเป็นต้องอัปเดตการอิมพลีเมนต์ drop ของ ThreadPool ดังนี้:

#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

สิ่งนี้จะแก้ข้อผิดพลาดคอมไพเลอร์และไม่ต้องการการเปลี่ยนแปลงอื่นใดในโค้ดของเรา โปรดทราบว่า เนื่องจาก drop สามารถถูกเรียกใช้เมื่อเกิด panic ได้ unwrap จึงอาจเกิด panic และทำให้เกิด double panic ซึ่งจะทำให้โปรแกรมค้าง/แครชทันทีและยุติการล้างข้อมูลที่กำลังดำเนินการอยู่ สิ่งนี้ใช้ได้สำหรับโปรแกรมตัวอย่าง แต่ไม่แนะนำสำหรับโค้ดสำหรับการทำงานจริง

Signaling to the Threads to Stop Listening for Jobs

ด้วยการเปลี่ยนแปลงทั้งหมดที่เราได้ทำ โค้ดของเราคอมไพล์ผ่านโดยไม่มีคำเตือนใดๆ อย่างไรก็ตาม ข่าวร้ายคือโค้ดนี้ยังไม่ได้ทำงานในแบบที่เราต้องการ สิ่งสำคัญคือตรรกะในโคลเชอร์ที่รันโดยเธรดของอินสแตนซ์ Worker: ในขณะนี้ เราเรียก join แต่นั่นจะไม่ปิดเธรดลง เนื่องจากพวกมันวน loop ตลอดไปเพื่อมองหางาน หากเราลองดรอป ThreadPool ด้วยการอิมพลีเมนต์ drop ปัจจุบันของเรา เธรดหลักจะบล็อกตลอดไป โดยรอให้เธรดแรกทำงานเสร็จ

เพื่อแก้ไขปัญหานี้ เราจำเป็นต้องมีการเปลี่ยนแปลงในการอิมพลีเมนต์ drop ของ ThreadPool จากนั้นจึงเปลี่ยนลูปใน Worker

ประการแรก เราจะเปลี่ยนการอิมพลีเมนต์ drop ของ ThreadPool ให้ดรอป sender อย่างชัดเจนก่อนที่จะรอให้เธรดทำงานเสร็จ โค้ดตัวอย่างที่ 21-23 แสดงการเปลี่ยนแปลงกับ ThreadPool เพื่อดรอป sender อย่างชัดเจน ไม่เหมือนกับเธรด ในที่นี้เรา จำเป็นต้อง ใช้ Option เพื่อให้สามารถย้าย sender ออกจาก ThreadPool ด้วย Option::take ได้

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

การดรอป sender จะปิดช่องทางสื่อสาร ซึ่งบ่งบอกว่าจะไม่มีการส่งข้อความเพิ่มเติมอีก เมื่อสิ่งนั้นเกิดขึ้น การเรียก recv ทั้งหมดที่อินสแตนซ์ Worker ทำในลูปอนันต์จะคืนค่าเป็นข้อผิดพลาด ในโค้ดตัวอย่างที่ 21-24 เราเปลี่ยนลูปใน Worker ให้ออกจากลูปอย่างนุ่มนวลในกรณีนั้น ซึ่งหมายความว่าเธรดจะเสร็จสิ้นเมื่อการอิมพลีเมนต์ drop ของ ThreadPool เรียก join บนพวกมัน

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}

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

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

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

เมธอด take ถูกนิยามไว้ใน Iterator trait และจำกัดการวนซ้ำไว้ที่สองไอเทมแรกเป็นอย่างมาก ThreadPool จะหลุดออกจากขอบเขตเมื่อสิ้นสุด main และการอิมพลีเมนต์ drop จะรัน

เริ่มเซิร์ฟเวอร์ด้วย cargo run แล้วทำการร้องขอสามรายการ คำร้องขอที่สามควรเกิดข้อผิดพลาด และในเทอร์มินัลของคุณ คุณควรเห็นเอาต์พุตคล้ายดังนี้:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
      Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

คุณอาจเห็นลำดับของ IDs ของ Worker และข้อความที่พิมพ์ออกมาแตกต่างกัน เราสามารถดูวิธีที่โค้ดนี้ทำงานได้จากข้อความ: อินสแตนซ์ Worker 0 และ 3 ได้รับคำร้องขอสองรายการแรก เซิร์ฟเวอร์หยุดรับการเชื่อมต่อหลังจากเรารับการเชื่อมต่อที่สอง และการอิมพลีเมนต์ Drop บน ThreadPool เริ่มทำงานก่อนที่ Worker 3 จะเริ่มงานของมันเสียอีก การดรอป sender จะตัดการเชื่อมต่ออินสแตนซ์ Worker ทั้งหมดและบอกให้พวกมันปิดตัวลง อินสแตนซ์ Worker แต่ละอันจะพิมพ์ข้อความเมื่อพวกมันตัดการเชื่อมต่อ จากนั้นพูลของเธรดจะเรียก join เพื่อรอให้เธรด Worker แต่ละอันทำงานเสร็จสิ้น

สังเกตแง่มุมที่น่าสนใจประการหนึ่งของการทำงานเฉพาะนี้: ThreadPool ดรอป sender และก่อนที่ Worker ใดๆ จะได้รับข้อผิดพลาด เราลองเรียก join บน Worker 0 โดย Worker 0 ยังไม่ได้รับข้อผิดพลาดจาก recv ดังนั้นเธรดหลักจึงบล็อก โดยรอให้ Worker 0 ทำงานเสร็จสิ้น ในระหว่างนั้น Worker 3 ได้รับงาน จากนั้นเธรดทั้งหมดก็ได้รับข้อผิดพลาด เมื่อ Worker 0 ทำงานเสร็จ เธรดหลักจึงรอให้อินสแตนซ์ Worker ที่เหลือทำงานเสร็จสิ้น ณ จุดนั้น พวกมันทั้งหมดได้ออกจากลูปและหยุดทำงานลงแล้ว

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

นี่คือโค้ดฉบับเต็มสำหรับการอ้างอิง:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

เราสามารถทำอะไรเพิ่มเติมที่นี่ได้อีก! หากคุณต้องการเพิ่มประสิทธิภาพโปรเจกต์นี้ต่อไป นี่คือไอเดียบางประการ:

  • เพิ่มเอกสารประกอบให้กับ ThreadPool และเมธอดสาธารณะของมัน
  • เพิ่มการทดสอบ (tests) สำหรับฟังก์ชันการทำงานของไลบรารี
  • เปลี่ยนการเรียกใช้ unwrap ให้เป็นการจัดการข้อผิดพลาดที่แข็งแกร่งยิ่งขึ้น
  • ใช้ ThreadPool เพื่อทำภารกิจอื่นนอกเหนือจากการให้บริการคำร้องขอของเว็บ
  • ค้นหาเครตพูลของเธรดบน crates.io แล้วอิมพลีเมนต์เว็บเซิร์ฟเวอร์ที่คล้ายกันโดยใช้เครตนั้นแทน จากนั้นเปรียบเทียบ API และความแข็งแกร่งของมันกับพูลของเธรดที่เราอิมพลีเมนต์

Summary

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

ภาคผนวก

ส่วนต่างๆ ต่อไปนี้ประกอบด้วยเนื้อหาอ้างอิงที่คุณอาจพบว่ามีประโยชน์ในการเดินทางร่วมกับภาษา Rust

A - Keywords

ภาคผนวก A: คำสงวน (Keywords)

รายการต่อไปนี้ประกอบด้วยคำสงวน (keywords) ที่ถูกจองไว้สำหรับการใช้งานในปัจจุบันหรือในอนาคตโดยภาษา Rust ด้วยเหตุนี้ คำเหล่านี้จึงไม่สามารถนำมาใช้เป็นตัวระบุ (identifiers) ได้ (ยกเว้นกรณีการใช้งานในรูปแบบตัวระบุดิบ (raw identifiers) ตามที่เราจะอธิบายในส่วน “ตัวระบุดิบ (Raw Identifiers)”) ตัวระบุ (Identifiers) คือชื่อของฟังก์ชัน, ตัวแปร, พารามิเตอร์, ฟิลด์ของโครงสร้างข้อมูล (struct fields), โมดูล (modules), เครต (crates), ค่าคงที่ (constants), มาโคร (macros), ค่าสแตติก (static values), แอตทริบิวต์ (attributes), ชนิดข้อมูล (types), เทรต (traits) หรืออายุขัยของข้อมูล (lifetimes)

คำสงวนที่ใช้งานอยู่ในปัจจุบัน

ด้านล่างนี้คือรายการของคำสงวนที่กำลังใช้งานอยู่ในปัจจุบันพร้อมทั้งคำอธิบายหน้าที่การทำงาน

  • as: ใช้ทำการแปลงชนิดข้อมูลพื้นฐาน (primitive casting), ช่วยชี้เฉพาะเจาะจงว่าเทรตใดมีไอเทมดังกล่าวเพื่อหลีกเลี่ยงความกำกวม หรือใช้เปลี่ยนชื่อไอเทมในคำสั่ง use
  • async: คืนค่ากลับมาเป็น Future แทนที่จะทำการบล็อกการทำงานของเธรดปัจจุบัน
  • await: หยุดการทำงานชั่วคราวไว้จนกว่าผลลัพธ์ของ Future จะพร้อมใช้งาน
  • break: ออกจากลูปในทันที
  • const: นิยามไอเทมที่เป็นค่าคงที่ หรือตัวชี้ดิบที่เป็นค่าคงที่ (constant raw pointers)
  • continue: ข้ามไปทำงานในรอบถัดไปของลูปทันที
  • crate: ในเส้นทางของโมดูล (module path) จะหมายถึงรากของเครต (crate root)
  • dyn: ทำการเรียกใช้งานเมธอดแบบไดนามิก (dynamic dispatch) ไปยังอ็อบเจกต์เทรต (trait object)
  • else: ทางเลือกกรณีอื่นๆ สำหรับโครงสร้างควบคุมทิศทางแบบ if และ if let
  • enum: นิยามชนิดข้อมูลแบบแจกแจงรายละเอียด (enumeration)
  • extern: เชื่อมโยงกับฟังก์ชันหรือตัวแปรภายนอก
  • false: ค่าบูลีนที่เป็นเท็จ (false literal)
  • fn: นิยามฟังก์ชัน หรือชนิดข้อมูลตัวชี้ฟังก์ชัน (function pointer type)
  • for: วนลูปผ่านไอเทมต่างๆ จากตัววนซ้ำ (iterator), การอิมพลิเมนต์เทรต (implement a trait) หรือการกำหนดอายุขัยในระดับที่สูงกว่า (higher ranked lifetime)
  • if: การแยกสาขาการทำงานตามผลลัพธ์ของนิพจน์เงื่อนไข
  • impl: อิมพลิเมนต์ความสามารถเฉพาะตัว (inherent) หรือความสามารถของเทรต (trait functionality)
  • in: ส่วนหนึ่งของไวยากรณ์การวนลูป for
  • let: ผูกตัวแปร (bind a variable)
  • loop: วนลูปแบบไม่มีเงื่อนไข
  • match: จับคู่ค่ากับรูปแบบ (patterns) ต่างๆ
  • mod: นิยามโมดูล
  • move: ทำให้โคลเชอร์ (closure) ยึดความเป็นเจ้าของ (take ownership) ของค่าทั้งหมดที่มันดึงเข้ามาใช้งาน
  • mut: ระบุความสามารถในการแก้ไขค่าได้ (mutability) ในตัวอ้างอิง (references), ตัวชี้ดิบ (raw pointers) หรือการผูกรูปแบบ (pattern bindings)
  • pub: ระบุความสามารถในการเข้าถึงเป็นแบบสาธารณะ (public visibility) ในฟิลด์ของโครงสร้างข้อมูล, บล็อก impl หรือโมดูล
  • ref: ผูกค่าด้วยการอ้างอิง (bind by reference)
  • return: คืนค่ากลับจากฟังก์ชัน
  • Self: นามแฝงของชนิดข้อมูล (type alias) สำหรับชนิดข้อมูลที่เรากำลังนิยามหรือกำลังอิมพลิเมนต์อยู่
  • self: ตัวตนที่เป็นผู้เรียกใช้เมธอด (method subject) หรือหมายถึงโมดูลปัจจุบัน
  • static: ตัวแปรส่วนกลาง (global variable) หรืออายุขัยที่คงอยู่ตลอดระยะเวลาที่โปรแกรมทำงาน
  • struct: นิยามโครงสร้างข้อมูล (structure)
  • super: โมดูลแม่ (parent module) ของโมดูลปัจจุบัน
  • trait: นิยามเทรต (trait)
  • true: ค่าบูลีนที่เป็นจริง (true literal)
  • type: นิยามนามแฝงของชนิดข้อมูล (type alias) หรือชนิดข้อมูลที่เชื่อมโยงกัน (associated type)
  • union: นิยามยูเนียน (union); จะเป็นคำสงวนเฉพาะเมื่อถูกใช้ในการประกาศยูเนียนเท่านั้น

คำสงวนที่จองไว้สำหรับการใช้งานในอนาคต

คำสงวนดังต่อไปนี้ยังไม่มีหน้าที่การทำงานใดๆ ในปัจจุบัน แต่ทาง Rust ได้จองไว้สำหรับการใช้งานที่อาจเกิดขึ้นในอนาคต:

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

ตัวระบุดิบ (Raw Identifiers)

ตัวระบุดิบ (Raw identifiers) คือไวยากรณ์ที่ช่วยให้คุณสามารถใช้งานคำสงวนในตำแหน่งที่โดยปกติแล้วไม่ได้รับอนุญาตให้ใช้ได้ คุณสามารถใช้งานตัวระบุดิบได้โดยการเติมคำนำหน้าคำสงวนด้วย r#

ยกตัวอย่างเช่น match เป็นคำสงวน หากคุณพยายามคอมไพล์ฟังก์ชันต่อไปนี้ที่ใช้คำว่า match เป็นชื่อฟังก์ชัน:

ชื่อไฟล์: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

คุณจะพบกับข้อผิดพลาดนี้:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

ข้อผิดพลาดนี้แสดงให้เห็นว่าคุณไม่สามารถใช้คำสงวน match เป็นตัวระบุชื่อฟังก์ชันได้ หากต้องการใช้ match เป็นชื่อฟังก์ชัน คุณจำเป็นต้องใช้ไวยากรณ์ตัวระบุดิบ ดังนี้:

ชื่อไฟล์: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

โค้ดนี้จะสามารถคอมไพล์ได้โดยไม่มีข้อผิดพลาดใดๆ สังเกตการเติมคำนำหน้าด้วย r# ตรงชื่อฟังก์ชันในการนิยาม รวมถึงตำแหน่งที่เรียกใช้งานฟังก์ชันใน main

ตัวระบุดิบช่วยให้คุณสามารถเลือกใช้คำใดก็ได้เป็นตัวระบุ แม้ว่าคำนั้นจะตรงกับคำสงวนก็ตาม วิธีนี้ช่วยให้เรามีอิสระมากขึ้นในการเลือกชื่อตัวระบุ และยังช่วยให้เราสามารถเชื่อมโยงการทำงานร่วมกับโปรแกรมที่เขียนขึ้นด้วยภาษาที่คำเหล่านี้ไม่ได้เป็นคำสงวน นอกจากนี้ ตัวระบุดิบยังช่วยให้คุณใช้ไลบรารีที่เขียนขึ้นโดยใช้ Rust รุ่น (edition) อื่นที่ต่างจากรุ่นที่เครตของคุณใช้อยู่ด้วย ตัวอย่างเช่น คำว่า try ไม่ใช่คำสงวนในรุ่นปี 2015 แต่เป็นคำสงวนในรุ่นปี 2018, 2021 และ 2024 หากคุณต้องพึ่งพาไลบรารีที่เขียนขึ้นโดยใช้รุ่นปี 2015 และมีฟังก์ชันที่ชื่อว่า try คุณจำเป็นต้องใช้ไวยากรณ์ตัวระบุดิบ ซึ่งในกรณีนี้คือ r#try เพื่อเรียกใช้งานฟังก์ชันดังกล่าวจากโค้ดของคุณในรุ่นที่ใหม่กว่า ดูรายละเอียดเพิ่มเติมเกี่ยวกับรุ่น (editions) ได้ที่ ภาคผนวก E

B - Operators and Symbols

ภาคผนวก B: ตัวดำเนินการและสัญลักษณ์

ภาคผนวกนี้ประกอบด้วยอภิธานศัพท์เกี่ยวกับไวยากรณ์ของ Rust ซึ่งรวมถึงตัวดำเนินการ (operators) และสัญลักษณ์อื่นๆ ที่ปรากฏแยกกัน หรือปรากฏในบริบทของเส้นทาง (paths), เจเนอริก (generics), ขอบเขตของเทรต (trait bounds), มาโคร (macros), แอตทริบิวต์ (attributes), ความคิดเห็น (comments), ทูเพิล (tuples) และวงเล็บแบบต่างๆ (brackets)

ตัวดำเนินการ (Operators)

ตาราง B-1 ประกอบด้วยตัวดำเนินการในภาษา Rust, ตัวอย่างการแสดงตัวดำเนินการในบริบทต่างๆ, คำอธิบายสั้นๆ และข้อมูลว่าตัวดำเนินการนั้นสามารถทำโอเวอร์โหลด (overloadable) ได้หรือไม่ หากตัวดำเนินการสามารถโอเวอร์โหลดได้ จะมีการระบุเทรต (trait) ที่เกี่ยวข้องที่ต้องนำมาใช้ในการทำโอเวอร์โหลดนั้นไว้ด้วย

ตาราง B-1: ตัวดำเนินการ

ตัวดำเนินการตัวอย่างคำอธิบายโอเวอร์โหลดได้หรือไม่?
!ident!(...), ident!{...}, ident![...]การขยายมาโคร (Macro expansion)
!!exprตัวเติมเต็มทางบิตหรือทางตรรกะ (Bitwise or logical complement)Not
!=expr != exprการเปรียบเทียบความไม่เท่ากันPartialEq
%expr % exprเศษเหลือจากการหารทางคณิตศาสตร์ (Arithmetic remainder)Rem
%=var %= exprเศษเหลือจากการหารและการกำหนดค่าทางคณิตศาสตร์RemAssign
&&expr, &mut exprการยืมค่า (Borrow)
&&type, &mut type, &'a type, &'a mut typeชนิดข้อมูลตัวชี้ที่ถูกยืม (Borrowed pointer type)
&expr & exprตัวดำเนินการทางบิต ANDBitAnd
&=var &= exprตัวดำเนินการทางบิต AND และการกำหนดค่าBitAndAssign
&&expr && exprตัวดำเนินการทางตรรกะ AND แบบประเมินค่าสั้น (Short-circuiting)
*expr * exprการคูณทางคณิตศาสตร์Mul
*=var *= exprการคูณและการกำหนดค่าทางคณิตศาสตร์MulAssign
**exprการอ้างอิงกลับ (Dereference)Deref
**const type, *mut typeตัวชี้ดิบ (Raw pointer)
+trait + trait, 'a + traitข้อจำกัดของชนิดข้อมูลแบบผสม (Compound type constraint)
+expr + exprการบวกทางคณิตศาสตร์Add
+=var += exprการบวกและการกำหนดค่าทางคณิตศาสตร์AddAssign
,expr, exprตัวคั่นอาร์กิวเมนต์และองค์ประกอบ (Argument and element separator)
-- exprค่าปฏิเสธทางคณิตศาสตร์ (Arithmetic negation)Neg
-expr - exprการลบทางคณิตศาสตร์Sub
-=var -= exprการลบและการกำหนดค่าทางคณิตศาสตร์SubAssign
->fn(...) -> type, |…| -> typeชนิดข้อมูลที่ส่งกลับของฟังก์ชันและโคลเชอร์
.expr.identการเข้าถึงฟิลด์ (Field access)
.expr.ident(expr, ...)การเรียกใช้งานเมธอด (Method call)
.expr.0, expr.1, and so onการระบุตรรกชี้วัดทูเพิล (Tuple indexing)
...., expr.., ..expr, expr..exprค่าช่วงที่ไม่รวมขอบขวา (Right-exclusive range literal)PartialOrd
..=..=expr, expr..=exprค่าช่วงที่รวมขอบขวา (Right-inclusive range literal)PartialOrd
....exprไวยากรณ์อัปเดตโครงสร้างข้อมูลตามค่าเดิม (Struct literal update syntax)
..variant(x, ..), struct_type { x, .. }การผูกรูปแบบ “และข้อมูลส่วนที่เหลือ” (“And the rest” pattern binding)
...expr...expr(เลิกใช้งานแล้ว ให้ใช้ ..= แทน) ในรูปแบบ: รูปแบบช่วงที่รวมค่าขอบขวา
/expr / exprการหารทางคณิตศาสตร์Div
/=var /= exprการหารและการกำหนดค่าทางคณิตศาสตร์DivAssign
:pat: type, ident: typeข้อจำกัด (Constraints)
:ident: exprตัวกำหนดค่าเริ่มต้นฟิลด์โครงสร้างข้อมูล (Struct field initializer)
:'a: loop {...}ป้ายชื่อลูป (Loop label)
;expr;ตัวปิดประโยคคำสั่งและไอเทม (Statement and item terminator)
;[...; len]ส่วนหนึ่งของไวยากรณ์อาร์เรย์ขนาดคงที่
<<expr << exprการเลื่อนบิตไปทางซ้าย (Left-shift)Shl
<<=var <<= exprการเลื่อนบิตไปทางซ้ายและการกำหนดค่าShlAssign
<expr < exprการเปรียบเทียบน้อยกว่าPartialOrd
<=expr <= exprการเปรียบเทียบน้อยกว่าหรือเท่ากับPartialOrd
=var = expr, ident = typeการกำหนดค่า/ความเท่าเทียม (Assignment/equivalence)
==expr == exprการเปรียบเทียบความเท่ากันPartialEq
=>pat => exprส่วนหนึ่งของไวยากรณ์แขนงการจับคู่ (match arm syntax)
>expr > exprการเปรียบเทียบมากกว่าPartialOrd
>=expr >= exprการเปรียบเทียบมากกว่าหรือเท่ากับPartialOrd
>>expr >> exprการเลื่อนบิตไปทางขวา (Right-shift)Shr
>>=var >>= exprการเลื่อนบิตไปทางขวาและการกำหนดค่าShrAssign
@ident @ patการผูกรูปแบบ (Pattern binding)
^expr ^ exprตัวดำเนินการทางบิต XORBitXor
^=var ^= exprตัวดำเนินการทางบิต XOR และการกำหนดค่าBitXorAssign
|pat | patทางเลือกในรูปแบบ (Pattern alternatives)
|expr | exprตัวดำเนินการทางบิต ORBitOr
|=var |= exprตัวดำเนินการทางบิต OR และการกำหนดค่าBitOrAssign
||expr || exprตัวดำเนินการทางตรรกะ OR แบบประเมินค่าสั้น (Short-circuiting)
?expr?การเผยแพร่ข้อผิดพลาด (Error propagation)

สัญลักษณ์ที่ไม่ใช่ตัวดำเนินการ (Non-operator Symbols)

ตารางต่อไปนี้ประกอบด้วยสัญลักษณ์ทั้งหมดที่ไม่ได้ทำหน้าที่เป็นตัวดำเนินการ กล่าวคือ สัญลักษณ์เหล่านี้ไม่มีพฤติกรรมเสมือนการเรียกฟังก์ชันหรือเมธอด

ตาราง B-2 แสดงสัญลักษณ์ที่ปรากฏเดี่ยวๆ และสามารถใช้งานได้ในหลากหลายตำแหน่ง

ตาราง B-2: ไวยากรณ์แบบแยกเดี่ยว (Stand-alone Syntax)

สัญลักษณ์คำอธิบาย
'identอายุขัยที่ระบุชื่อ (Named lifetime) หรือป้ายชื่อลูป (loop label)
ตัวเลขตามด้วย u8, i32, f64, usize และอื่นๆ ในทันทีค่าตรงตัวเชิงตัวเลขตามชนิดข้อมูลเฉพาะ (Numeric literal of specific type)
"..."ค่าตรงตัวประเภทข้อความ (String literal)
r"...", r#"..."#, r##"..."## และค่าต่อๆ ไปค่าตรงตัวประเภทข้อความดิบ (Raw string literal); โดยจะไม่มีการประมวลผลอักขระหลีก (escape characters)
b"..."ค่าตรงตัวข้อความไบต์ (Byte string literal); สร้างเป็นอาร์เรย์ของไบต์แทนที่จะเป็นข้อความธรรมดา
br"...", br#"..."#, br##"..."## และค่าต่อๆ ไปค่าตรงตัวข้อความไบต์ดิบ (Raw byte string literal); เป็นการรวมกันของรูปแบบข้อความดิบและข้อความไบต์
'...'ค่าตรงตัวประเภทอักขระ (Character literal)
b'...'ค่าตรงตัวไบต์ ASCII (ASCII byte literal)
|…| exprโคลเชอร์ (Closure)
!ชนิดข้อมูลล่างสุดที่ว่างเปล่าเสมอ (Always-empty bottom type) สำหรับฟังก์ชันที่ไม่มีวันสิ้นสุด (diverging functions)
_การผูกรูปแบบ “ที่ถูกละเว้น” (“Ignored” pattern binding); และใช้สำหรับแบ่งกลุ่มเพื่อทำให้อ่านค่าตรงตัวของตัวเลขจำนวนเต็มได้ง่ายขึ้น

ตาราง B-3 แสดงสัญลักษณ์ที่ปรากฏในบริบทของเส้นทาง (path) ผ่านลำดับชั้นของโมดูลเพื่อเข้าถึงไอเทม

ตาราง B-3: ไวยากรณ์ที่เกี่ยวข้องกับเส้นทาง (Path-Related Syntax)

สัญลักษณ์คำอธิบาย
ident::identเส้นทางขอบเขตชื่อ (Namespace path)
::pathเส้นทางที่อ้างอิงกับรากของเครต (หมายถึงเส้นทางแบบสัมบูรณ์อย่างชัดเจน)
self::pathเส้นทางที่อ้างอิงกับโมดูลปัจจุบัน (หมายถึงเส้นทางแบบสัมพัทธ์อย่างชัดเจน)
super::pathเส้นทางที่อ้างอิงกับโมดูลแม่ของโมดูลปัจจุบัน
type::ident, <type as trait>::identค่าคงที่, ฟังก์ชัน และชนิดข้อมูลที่เชื่อมโยงกัน (Associated constants, functions, and types)
<type>::...ไอเทมที่เชื่อมโยงสำหรับชนิดข้อมูลที่ไม่สามารถระบุชื่อโดยตรงได้ (เช่น <&T>::..., <[T]>::... และค่าต่อๆ ไป)
trait::method(...)หลีกเลี่ยงความกำกวมในการเรียกใช้เมธอดโดยการระบุชื่อเทรตที่นิยามเมธอดนั้น
type::method(...)หลีกเลี่ยงความกำกวมในการเรียกใช้เมธอดโดยการระบุชื่อชนิดข้อมูลที่เมธอดนั้นนิยามไว้
<type as trait>::method(...)หลีกเลี่ยงความกำกวมในการเรียกใช้เมธอดโดยการระบุทั้งชื่อเทรตและชนิดข้อมูล

ตาราง B-4 แสดงสัญลักษณ์ที่ปรากฏในบริบทของการใช้พารามิเตอร์ชนิดเจเนอริก (generic type parameters)

ตาราง B-4: เจเนอริก (Generics)

สัญลักษณ์คำอธิบาย
path<...>ระบุพารามิเตอร์สำหรับชนิดเจเนอริกในชนิดข้อมูล (เช่น Vec<u8>)
path::<...>, method::<...>ระบุพารามิเตอร์สำหรับชนิดเจเนอริก ฟังก์ชัน หรือเมธอดในรูปของนิพจน์ มักจะเรียกกันว่า turbofish (เช่น "42".parse::<i32>())
fn ident<...> ...นิยามฟังก์ชันแบบเจเนอริก
struct ident<...> ...นิยามโครงสร้างข้อมูลแบบเจเนอริก
enum ident<...> ...นิยามประเภทแจกแจงรายละเอียดแบบเจเนอริก
impl<...> ...นิยามการอิมพลิเมนต์แบบเจเนอริก
for<...> typeขอบเขตอายุขัยในระดับที่สูงกว่า (Higher ranked lifetime bounds)
type<ident=type>ชนิดเจเนอริกที่ชนิดข้อมูลที่เชื่อมโยงกันตั้งแต่หนึ่งชนิดขึ้นไปมีการกำหนดค่าเฉพาะ (เช่น Iterator<Item=T>)

ตาราง B-5 แสดงสัญลักษณ์ที่ปรากฏในบริบทของการจำกัดขอบเขตพารามิเตอร์ชนิดเจเนอริกด้วยขอบเขตของเทรต (trait bounds)

ตาราง B-5: ข้อจำกัดขอบเขตของเทรต (Trait Bound Constraints)

สัญลักษณ์คำอธิบาย
T: Uพารามิเตอร์เจเนอริก T ถูกจำกัดให้เฉพาะชนิดข้อมูลที่อิมพลิเมนต์ U เท่านั้น
T: 'aชนิดเจเนอริก T จะต้องมีชีวิตอยู่ยืนยาวกว่าอายุขัย 'a (หมายความว่าชนิดข้อมูลดังกล่าวไม่สามารถมีตัวอ้างอิงที่มีอายุขัยสั้นกว่า 'a ปะปนอยู่ได้ไม่ว่าจะในทางอ้อมก็ตาม)
T: 'staticชนิดเจเนอริก T จะต้องไม่มีการอ้างอิงที่ถูกยืม ยกเว้นที่เป็นการอ้างอิงแบบ 'static
'b: 'aอายุขัยเจเนอริก 'b จะต้องมีชีวิตอยู่ยืนยาวกว่าอายุขัย 'a
T: ?Sizedอนุญาตให้พารามิเตอร์ชนิดเจเนอริกเป็นชนิดข้อมูลที่มีขนาดเปลี่ยนไปมาได้แบบไดนามิก (dynamically sized type)
'a + trait, trait + traitข้อจำกัดของชนิดข้อมูลแบบผสม

ตาราง B-6 แสดงสัญลักษณ์ที่ปรากฏในบริบทของการเรียกใช้หรือนิยามมาโคร และการระบุแอตทริบิวต์บนไอเทม

ตาราง B-6: มาโครและแอตทริบิวต์ (Macros and Attributes)

สัญลักษณ์คำอธิบาย
#[meta]แอตทริบิวต์ด้านนอก (Outer attribute)
#![meta]แอตทริบิวต์ด้านใน (Inner attribute)
$identการแทนที่ด้วยมาโคร (Macro substitution)
$ident:kindตัวแปรอภิมานของมาโคร (Macro metavariable)
$(...)...การทำซ้ำของมาโคร (Macro repetition)
ident!(...), ident!{...}, ident![...]การเรียกใช้งานมาโคร (Macro invocation)

ตาราง B-7 แสดงสัญลักษณ์ที่ใช้สำหรับสร้างความคิดเห็น (comments)

ตาราง B-7: ความคิดเห็น (Comments)

สัญลักษณ์คำอธิบาย
//ความคิดเห็นแบบบรรทัดเดียว (Line comment)
//!คำอธิบายเอกสารแบบบรรทัดเดียวด้านใน (Inner line doc comment)
///คำอธิบายเอกสารแบบบรรทัดเดียวด้านนอก (Outer line doc comment)
/*...*/ความคิดเห็นแบบบล็อก (Block comment)
/*!...*/คำอธิบายเอกสารแบบบล็อกด้านใน (Inner block doc comment)
/**...*/คำอธิบายเอกสารแบบบล็อกด้านนอก (Outer block doc comment)

ตาราง B-8 แสดงบริบทที่มีการใช้งานวงเล็บกลม (parentheses)

ตาราง B-8: วงเล็บกลม (Parentheses)

สัญลักษณ์คำอธิบาย
()ทูเพิลว่างเปล่า (หรือเรียกว่ายูนิต - unit) ซึ่งเป็นได้ทั้งค่าตรงตัวและชนิดข้อมูล
(expr)นิพจน์ภายในวงเล็บกลม
(expr,)นิพจน์ประเภททูเพิลที่มีองค์ประกอบเดียว
(type,)ชนิดข้อมูลประเภททูเพิลที่มีองค์ประกอบเดียว
(expr, ...)นิพจน์ประเภททูเพิล
(type, ...)ชนิดข้อมูลประเภททูเพิล
expr(expr, ...)นิพจน์การเรียกใช้ฟังก์ชัน; และยังใช้เพื่อกำหนดค่าเริ่มต้นให้ทูเพิลโครงสร้างข้อมูล (struct) และตัวแปรชนิดแจกแจงแบบทูเพิล (enum variants)

ตาราง B-9 แสดงบริบทที่มีการใช้งานวงเล็บปีกกา (curly brackets)

ตาราง B-9: วงเล็บปีกกา (Curly Brackets)

บริบทคำอธิบาย
{...}นิพจน์รูปแบบบล็อก (Block expression)
Type {...}ค่าตรงตัวประเภทโครงสร้างข้อมูล (Struct literal)

ตาราง B-10 แสดงบริบทที่มีการใช้งานวงเล็บเหลี่ยม (square brackets)

ตาราง B-10: วงเล็บเหลี่ยม (Square Brackets)

บริบทคำอธิบาย
[...]ค่าตรงตัวประเภทอาร์เรย์ (Array literal)
[expr; len]ค่าตรงตัวประเภทอาร์เรย์ที่ประกอบด้วยสำเนาของ expr จำนวน len ชุด
[type; len]ชนิดข้อมูลประเภทอาร์เรย์ที่ประกอบด้วยอ็อบเจกต์ประเภท type จำนวน len ตัว
expr[expr]การระบุตรรกชี้วัดในคอลเลกชัน (Collection indexing); สามารถทำโอเวอร์โหลดได้ (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]การระบุตรรกชี้วัดในคอลเลกชันโดยเลียนแบบการทำสไลซ์ (slicing) ในคอลเลกชัน โดยใช้ Range, RangeFrom, RangeTo หรือ RangeFull เป็น “ดัชนีชี้วัด”

C - Derivable Traits

ภาคผนวก C: เทรตที่สามารถจัดทำอนุพันธ์ได้ (Derivable Traits)

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

ในภาคผนวกนี้ เราจะให้ข้อมูลอ้างอิงของเทรตทั้งหมดในไลบรารีมาตรฐานที่คุณสามารถนำมาใช้งานร่วมกับ derive ได้ โดยในแต่ละส่วนจะครอบคลุมถึง:

  • ตัวดำเนินการและเมธอดใดที่จะพร้อมใช้งานเมื่อทำการทำอนุพันธ์เทรต (deriving this trait) นี้
  • พฤติกรรมของการอิมพลิเมนต์เทรตที่ได้รับจาก derive ว่ามีหน้าที่ทำอะไรบ้าง
  • การอิมพลิเมนต์เทรตนี้บ่งบอกความหมายอย่างไรเกี่ยวกับชนิดข้อมูลดังกล่าว
  • เงื่อนไขที่คุณได้รับอนุญาตหรือไม่อนุญาตให้อิมพลิเมนต์เทรตนี้
  • ตัวอย่างของการดำเนินการที่จำเป็นต้องใช้เทรตนี้

หากคุณต้องการให้มีพฤติกรรมที่แตกต่างไปจากที่เตรียมไว้ให้โดยแอตทริบิวต์ derive คุณสามารถศึกษาเพิ่มเติมได้จาก เอกสารประกอบของไลบรารีมาตรฐาน สำหรับแต่ละเทรตเพื่อศึกษารายละเอียดวิธีอิมพลิเมนต์มันด้วยตนเอง (manually implement)

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

ตัวอย่างของเทรตที่ไม่สามารถทำการดิไรฟ์ (derive) ได้คือ Display ซึ่งใช้จัดการการจัดรูปแบบข้อความสำหรับแสดงผลให้แก่ผู้ใช้ทั่วไป คุณควรพิจารณาวิธีการที่เหมาะสมในการแสดงผลชนิดข้อมูลให้กับผู้ใช้ทั่วไปอยู่เสมอ เช่น ส่วนใดของชนิดข้อมูลที่ผู้ใช้ควรจะมองเห็นได้บ้าง? ส่วนใดที่พวกเขาจะมองว่าเกี่ยวข้อง? และรูปแบบข้อมูลแบบใดที่จะตรงความต้องการของพวกเขามากที่สุด? คอมไพเลอร์ของ Rust ไม่มีข้อมูลเชิงลึกเหล่านี้ จึงไม่สามารถมอบพฤติกรรมเริ่มต้นที่เหมาะสมให้แก่คุณได้

รายการเทรตที่สามารถดิไรฟ์ได้ซึ่งจัดเตรียมไว้ในภาคผนวกนี้ไม่ใช่รายการที่ครอบคลุมทั้งหมด: ไลบรารีภายนอกต่างๆ สามารถอิมพลิเมนต์ derive สำหรับเทรตของตนเองได้ ทำให้รายการของเทรตที่คุณสามารถใช้ derive ร่วมด้วยนั้นเปิดกว้างอย่างไม่มีที่สิ้นสุด การอิมพลิเมนต์ derive นั้นเกี่ยวข้องกับการใช้โปรซีจูรัลมาโคร (procedural macro) ซึ่งครอบคลุมอยู่ในหัวข้อ “มาโคร derive แบบกำหนดเอง (Custom derive Macros)” ในบทที่ 20

Debug สำหรับผลลัพธ์ของนักพัฒนาโปรแกรม

เทรต Debug ช่วยเปิดใช้งานการจัดรูปแบบสำหรับการดีบักในข้อความที่ถูกจัดรูปแบบ (format strings) ซึ่งคุณสามารถระบุได้โดยการเพิ่ม :? ภายในตัวแทนวงเล็บปีกกา {}

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

ตัวอย่างการใช้งานที่จำเป็นต้องมีเทรต Debug เช่น การใช้มาโคร assert_eq! มาโครนี้จะพิมพ์ค่าของอินสแตนซ์ต่างๆ ที่ส่งไปเป็นอาร์กิวเมนต์หากการตรวจสอบความเท่ากันล้มเหลว เพื่อให้นักพัฒนาสามารถมองเห็นได้ว่าทำไมอินสแตนซ์ทั้งสองจึงไม่เท่ากัน

PartialEq และ Eq สำหรับการเปรียบเทียบความเท่ากัน

เทรต PartialEq ช่วยให้คุณสามารถเปรียบเทียบอินสแตนซ์ของชนิดข้อมูลเพื่อตรวจสอบความเท่ากัน และช่วยเปิดใช้งานการใช้ตัวดำเนินการ == และ !=

การดิไรฟ์ PartialEq จะอิมพลิเมนต์เมธอด eq ขึ้นมา เมื่อดิไรฟ์ PartialEq บนโครงสร้างข้อมูล (structs) อินสแตนซ์สองตัวจะเท่ากันก็ต่อเมื่อฟิลด์ ทั้งหมด มีค่าเท่ากัน และจะไม่เท่ากันหากมีฟิลด์ ใดฟิลด์หนึ่ง ไม่เท่ากัน เมื่อดิไรฟ์บนเอ็นัม (enums) ตัวแปรย่อย (variants) แต่ละตัวจะมีค่าเท่ากับตัวมันเอง และจะไม่เท่ากับตัวแปรย่อยอื่นๆ

ตัวอย่างการใช้งานที่จำเป็นต้องมีเทรต PartialEq เช่น การใช้มาโคร assert_eq! ซึ่งต้องสามารถเปรียบเทียบความเท่ากันระหว่างอินสแตนซ์สองตัวของชนิดข้อมูลได้

เทรต Eq ไม่มีเมธอดภายในตัวมันเอง วัตถุประสงค์ของมันคือการส่งสัญญาณบ่งชี้ว่าสำหรับทุกๆ ค่าของชนิดข้อมูลที่ถูกระบุ ค่าของมันย่อมมีค่าเท่ากับตัวเองเสมอ เทรต Eq สามารถใช้ได้กับชนิดข้อมูลที่อิมพลิเมนต์ PartialEq อยู่แล้วเท่านั้น อย่างไรก็ดี ไม่ใช่ว่าทุกชนิดข้อมูลที่อิมพลิเมนต์ PartialEq จะสามารถอิมพลิเมนต์ Eq ได้ ตัวอย่างหนึ่งคือชนิดข้อมูลตัวเลขทศนิยม (floating-point number types): การอิมพลิเมนต์ตัวเลขทศนิยมระบุว่า ค่าที่ไม่ได้เป็นตัวเลข (NaN หรือ not-a-number) สองค่าจะไม่มีค่าเท่ากัน

ตัวอย่างการใช้งานที่จำเป็นต้องมี Eq คือ การใช้คีย์ (keys) ใน HashMap<K, V> เพื่อให้ HashMap<K, V> สามารถแยกแยะได้ว่าคีย์ทั้งสองคีย์นั้นเป็นคีย์เดียวกันหรือไม่

PartialOrd และ Ord สำหรับการเปรียบเทียบการจัดเรียงลำดับ

เทรต PartialOrd ช่วยให้คุณสามารถเปรียบเทียบอินสแตนซ์ของชนิดข้อมูลเพื่อวัตถุประสงค์ในการจัดเรียงลำดับ ชนิดข้อมูลที่อิมพลิเมนต์ PartialOrd จะสามารถใช้ร่วมกับตัวดำเนินการ <, >, <=, และ >= ได้ คุณสามารถใช้เทรต PartialOrd กับชนิดข้อมูลที่ได้อิมพลิเมนต์ PartialEq อยู่แล้วเท่านั้น

การดิไรฟ์ PartialOrd จะเป็นการอิมพลิเมนต์เมธอด partial_cmp ซึ่งจะคืนค่ากลับมาเป็น Option<Ordering> ที่จะมีค่าเป็น None เมื่อค่าที่ระบุไม่สามารถนำมาเปรียบเทียบเพื่อจัดลำดับได้ ตัวอย่างของค่าที่ไม่นำไปสู่การจัดเรียงลำดับ (แม้ว่าค่าส่วนใหญ่ของชนิดข้อมูลนั้นจะเปรียบเทียบกันได้ก็ตาม) คือค่าทศนิยมแบบ NaN โดยการเรียกใช้ partial_cmp ระหว่างตัวเลขทศนิยมใดๆ กับค่าทศนิยมแบบ NaN จะส่งกลับค่าเป็น None

เมื่อดิไรฟ์กับโครงสร้างข้อมูล PartialOrd จะเปรียบเทียบอินสแตนซ์สองตัวโดยเปรียบเทียบค่าในแต่ละฟิลด์ตามลำดับการปรากฏของฟิลด์นั้นๆ ในคำนิยามโครงสร้างข้อมูล เมื่อดิไรฟ์กับเอ็นัม ตัวแปรย่อยของเอ็นัมที่ประกาศไว้ก่อนในคำนิยามเอ็นัมจะถือว่ามีค่าน้อยกว่าตัวแปรย่อยที่ระบุไว้ทีหลัง

ตัวอย่างการใช้งานที่จำเป็นต้องมีเทรต PartialOrd เช่น เมธอด gen_range จากเครต rand ที่ทำหน้าที่สร้างค่าสุ่มภายในช่วงที่กำหนดไว้โดยนิพจน์ช่วง (range expression)

เทรต Ord ช่วยให้คุณมั่นใจได้ว่าสำหรับค่าใดๆ สองค่าของชนิดข้อมูลดังกล่าว จะมีลำดับเปรียบเทียบที่ถูกต้องเกิดขึ้นเสมอ เทรต Ord จะอิมพลิเมนต์เมธอด cmp ซึ่งจะคืนค่ากลับมาเป็น Ordering แทนที่จะเป็น Option<Ordering> เนื่องจากจะสามารถจัดหาลำดับที่ถูกต้องได้เสมอ คุณสามารถนำเทรต Ord ไปใช้กับชนิดข้อมูลที่ได้อิมพลิเมนต์ PartialOrd และ Eq อยู่ก่อนแล้วเท่านั้น (และ Eq ก็ต้องการ PartialEq เช่นกัน) เมื่อดิไรฟ์บนโครงสร้างข้อมูลและเอ็นัม เมธอด cmp จะทำงานในลักษณะเดียวกับผลลัพธ์การดิไรฟ์ของ partial_cmp ร่วมกับ PartialOrd

ตัวอย่างการใช้งานที่จำเป็นต้องมี Ord เช่น เมื่อต้องการเก็บค่าต่างๆ ไว้ใน BTreeSet<T> ซึ่งเป็นโครงสร้างข้อมูลที่จัดเก็บข้อมูลตามลำดับการจัดเรียงของค่านั้นๆ

Clone และ Copy สำหรับการทำสำเนาค่าข้อมูล

เทรต Clone ช่วยให้คุณสามารถสร้างการคัดลอกแบบลึก (deep copy) ของค่าข้อมูลอย่างชัดเจนได้ และขั้นตอนการทำซ้ำนี้อาจรวมถึงการทำงานของโค้ดใดๆ และการคัดลอกข้อมูลในฮีป (heap data) ดูรายละเอียดเพิ่มเติมเกี่ยวกับ Clone ได้ในหัวข้อ “ตัวแปรและข้อมูลที่มีปฏิสัมพันธ์กับ Clone” ในบทที่ 4

การดิไรฟ์ Clone จะอิมพลิเมนต์เมธอด clone ซึ่งเมื่อมีผลครอบคลุมทั้งชนิดข้อมูลแล้ว จะเข้าไปเรียกใช้งาน clone กับส่วนประกอบย่อยแต่ละส่วนของชนิดข้อมูลนั้น ซึ่งหมายความว่าฟิลด์หรือค่าข้อมูลทั้งหมดในชนิดข้อมูลนั้นจะต้องอิมพลิเมนต์ Clone ด้วยเช่นกัน จึงจะสามารถดิไรฟ์ Clone ได้

ตัวอย่างการใช้งานที่จำเป็นต้องมี Clone เช่น การเรียกเมธอด to_vec บนชิ้นข้อมูล (slice) ชิ้นข้อมูลนั้นไม่ได้ถือครองความเป็นเจ้าของของอินสแตนซ์ที่มันประกอบอยู่ แต่เวกเตอร์ (vector) ที่ได้รับกลับมาจาก to_vec จำเป็นต้องเป็นเจ้าของอินสแตนซ์ของมันเอง ดังนั้น to_vec จึงต้องเรียกใช้งาน clone ในแต่ละไอเทม ด้วยเหตุนี้ ชนิดข้อมูลที่จัดเก็บในชิ้นข้อมูลจึงต้องอิมพลิเมนต์ Clone

เทรต Copy ช่วยให้คุณสามารถทำซ้ำค่าข้อมูลได้โดยการคัดลอกเฉพาะบิตที่จัดเก็บอยู่บนสแตก (stack) เท่านั้น โดยไม่จำเป็นต้องรันโค้ดใดๆ เพิ่มเติม ดูรายละเอียดเพิ่มเติมเกี่ยวกับ Copy ได้ในหัวข้อ “ข้อมูลที่อยู่บนสแตกเท่านั้น: Copy” ในบทที่ 4

เทรต Copy ไม่ได้นิยามเมธอดใดๆ ไว้ เพื่อป้องกันไม่ให้นักพัฒนาทำการทำโอเวอร์โหลดเมธอดเหล่านั้นและละเมิดข้อตกลงที่ว่าไม่มีโค้ดใดๆ ถูกสั่งให้ทำงานเพิ่มเติม ด้วยวิธีนี้ นักพัฒนาทุกคนจึงสามารถสันนิษฐานได้ว่าการคัดลอกค่าข้อมูลดังกล่าวจะทำงานได้อย่างรวดเร็วมาก

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

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

ทุกสิ่งที่สามารถทำได้ด้วย Copy คุณก็สามารถทำได้ด้วย Clone เช่นกัน แต่โค้ดอาจทำงานได้ช้ากว่า หรืออาจต้องระบุการเรียกใช้งาน clone ในบางจุดของโปรแกรม

Hash สำหรับการจับคู่ค่าข้อมูลไปยังค่าที่มีขนาดคงที่

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

ตัวอย่างการใช้งานที่จำเป็นต้องมี Hash คือการจัดเก็บคีย์ใน HashMap<K, V> เพื่อบันทึกข้อมูลได้อย่างมีประสิทธิภาพ

Default สำหรับค่าเริ่มต้นของข้อมูล

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

ฟังก์ชัน Default::default มักถูกนำมาใช้ร่วมกับไวยากรณ์การอัปเดตโครงสร้างข้อมูลที่กล่าวถึงในหัวข้อ “การสร้างอินสแตนซ์จากอินสแตนซ์อื่นด้วยไวยากรณ์อัปเดตโครงสร้างข้อมูล” ในบทที่ 5 คุณสามารถปรับแต่งฟิลด์บางส่วนของโครงสร้างข้อมูลด้วยตนเอง แล้วเลือกกำหนดและใช้ค่าเริ่มต้นสำหรับฟิลด์ส่วนที่เหลือได้โดยการระบุ ..Default::default()

ตัวอย่างการใช้งานที่จำเป็นต้องมีเทรต Default เช่น เมื่อคุณใช้เมธอด unwrap_or_default บนอินสแตนซ์ Option<T> หากค่า Option<T> เป็น None เมธอด unwrap_or_default จะส่งกลับผลลัพธ์ของ Default::default สำหรับชนิดข้อมูล T ที่ถูกเก็บอยู่ใน Option<T>

D - Useful Development Tools

ภาคผนวก D: เครื่องมือพัฒนาที่เป็นประโยชน์ (Useful Development Tools)

ในภาคผนวกนี้ เราจะพูดถึงเครื่องมือการพัฒนาที่เป็นประโยชน์ซึ่งโครงการ Rust มีเตรียมไว้ให้ เราจะมาทำความรู้จักกับการจัดรูปแบบโค้ดโดยอัตโนมัติ, วิธีจัดการแก้ไขตามคำเตือนอย่างรวดเร็ว, เครื่องมือตรวจสอบรูปแบบโค้ด (linter) และการเชื่อมต่อทำงานร่วมกับ IDE ต่างๆ

การจัดรูปแบบโค้ดโดยอัตโนมัติด้วย rustfmt

เครื่องมือ rustfmt ทำหน้าที่จัดรูปแบบโครงสร้างโค้ดของคุณใหม่ตามรูปแบบการเขียนโค้ดมาตรฐานของชุมชน โครงการที่ร่วมกันพัฒนาหลายโครงการเลือกใช้งาน rustfmt เพื่อขจัดข้อโต้แย้งเกี่ยวกับการเลือกใช้รูปแบบโค้ดเมื่อเขียนภาษา Rust: โดยกำหนดให้ทุกคนจัดรูปแบบโค้ดของตนด้วยเครื่องมือตัวนี้

การติดตั้ง Rust จะรวม rustfmt มาให้เป็นค่าเริ่มต้น ดังนั้นคุณจึงควรมีโปรแกรม rustfmt และ cargo-fmt ติดตั้งอยู่บนระบบของคุณแล้ว คำสั่งทั้งสองนี้เปรียบได้กับ rustc และ cargo โดยที่ rustfmt ช่วยให้ควบคุมรายละเอียดได้เจาะลึกขึ้น ขณะที่ cargo-fmt จะเข้าใจข้อกำหนดเฉพาะของโครงการที่ใช้งาน Cargo หากต้องการจัดรูปแบบโครงการ Cargo ใดๆ ให้ป้อนคำสั่งดังต่อไปนี้:

$ cargo fmt

การรันคำสั่งนี้จะจัดรูปแบบโค้ด Rust ทั้งหมดในเครตปัจจุบันใหม่ ซึ่งจะมีผลต่อการเปลี่ยนรูปแบบการเขียนโค้ดเท่านั้น โดยไม่มีการแก้ไขการทำงาน (semantics) ของโค้ดแต่อย่างใด ศึกษาข้อมูลเพิ่มเติมเกี่ยวกับ rustfmt ได้ที่ เอกสารประกอบการใช้งาน

แก้ไขโค้ดของคุณด้วย rustfix

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

ชื่อไฟล์: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

ในจุดนี้ เรากำลังนิยามตัวแปร x เป็นแบบที่สามารถแก้ไขค่าได้ (mut) แต่เราไม่เคยทำการเปลี่ยนแปลงค่าของมันเลย Rust จึงแจ้งเตือนเราเกี่ยวกับเรื่องนี้:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

คำเตือนแนะนำให้เรานำคีย์เวิร์ด mut ออกไป เราสามารถนำคำแนะนำนั้นมาใช้แก้ไขโค้ดได้โดยอัตโนมัติผ่านการใช้เครื่องมือ rustfix ด้วยการรันคำสั่ง cargo fix:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

เมื่อเรากลับไปดูที่ไฟล์ src/main.rs อีกครั้ง เราจะพบว่า cargo fix ได้เข้ามาแก้ไขโค้ดให้เรียบร้อยแล้ว:

ชื่อไฟล์: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

ตอนนี้ตัวแปร x ไม่สามารถแก้ไขค่าได้แล้ว (immutable) และคำเตือนก็จะไม่แสดงขึ้นมาอีก

นอกจากนี้ คุณยังสามารถใช้คำสั่ง cargo fix เพื่อเปลี่ยนผ่านโค้ดของคุณไปมาระหว่าง Rust รุ่น (editions) ต่างๆ ได้อีกด้วย โดยข้อมูลของรุ่นต่างๆ จะครอบคลุมอยู่ใน ภาคผนวก E

ตรวจสอบรูปแบบโค้ดเพิ่มเติมด้วย Clippy

เครื่องมือ Clippy คือชุดรวบรวมการวิเคราะห์สไตล์การเขียนโค้ด (lints) เพื่อช่วยวิเคราะห์โปรแกรมของคุณ เพื่อป้องกันและตรวจหาข้อผิดพลาดที่มักเกิดขึ้นได้บ่อย ตลอดจนช่วยปรับปรุงพัฒนาโค้ดภาษา Rust ของคุณ Clippy จะมีมาให้พร้อมกับการติดตั้ง Rust ตามมาตรฐานทั่วไปอยู่แล้ว

หากต้องการรันการตรวจโค้ดของ Clippy ในโครงการ Cargo ใดๆ ให้ป้อนคำสั่งดังต่อไปนี้:

$ cargo clippy

ตัวอย่างเช่น สมมติว่าคุณเขียนโปรแกรมที่ใช้ค่าประมาณของค่าคงที่ทางคณิตศาสตร์ เช่น ค่าพาย (pi) ดังเช่นโปรแกรมนี้:

fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

การรัน cargo clippy ในโครงการนี้จะแสดงผลลัพธ์ข้อผิดพลาดนี้ออกมา:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

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

โค้ดต่อไปนี้จะไม่ทำให้เกิดข้อผิดพลาดหรือคำเตือนใดๆ จาก Clippy:

fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

ศึกษาข้อมูลเพิ่มเติมเกี่ยวกับ Clippy ได้ที่ เอกสารประกอบการใช้งาน

การเชื่อมต่อร่วมกับ IDE โดยใช้ rust-analyzer

เพื่อความสะดวกในการเชื่อมต่อนำไปทำงานร่วมกับ IDE ชุมชน Rust แนะนำให้ใช้ rust-analyzer เครื่องมือนี้คือชุดโปรแกรมยูทิลิตีที่มีคอมไพเลอร์เป็นศูนย์กลางซึ่งสื่อสารกันผ่าน Language Server Protocol (LSP) ซึ่งเป็นข้อกำหนดมาตรฐานสำหรับให้ IDE และภาษานักพัฒนาโปรแกรมสามารถสื่อสารติดต่อระหว่างกันได้ ไคลเอนต์ชนิดต่างๆ สามารถใช้งาน rust-analyzer ร่วมด้วยได้ เช่น ปลั๊กอิน Rust analyzer สำหรับ Visual Studio Code

คุณสามารถเข้าไปเยี่ยมชม โฮมเพจของโครงการ rust-analyzer สำหรับคู่มือคำแนะนำในการติดตั้ง จากนั้นจึงทำการติดตั้งชุดเสริมการทำงานสำหรับ IDE เฉพาะที่คุณกำลังใช้งานอยู่ IDE ของคุณก็จะได้รับความสามารถที่สะดวกสบายเพิ่มขึ้น เช่น การเติมคำอัตโนมัติ (autocompletion), การข้ามไปยังจุดนิยาม (jump to definition) และการแสดงข้อผิดพลาดภายในการพิมพ์โค้ด (inline errors)

E - Editions

ภาคผนวก E: รุ่นของภาษา Rust (Editions)

ในบทที่ 1 คุณจะเห็นว่าคำสั่ง cargo new ได้เพิ่มข้อมูลเมทาดาตาบางส่วนลงในไฟล์ Cargo.toml ของคุณเกี่ยวกับรุ่น (edition) ของภาษา ภาคผนวกนี้จะอธิบายถึงความหมายของมัน!

ภาษาและคอมไพเลอร์ Rust มีรอบการเผยแพร่ทุกๆ 6 สัปดาห์ ซึ่งหมายความว่าผู้ใช้จะได้รับคุณลักษณะใหม่ๆ (features) อย่างต่อเนื่อง ภาษาโปรแกรมอื่นๆ มักจะปล่อยการเปลี่ยนแปลงขนาดใหญ่ในระยะเวลาที่นานกว่า แต่สำหรับ Rust จะใช้วิธีปล่อยการอัปเดตย่อยๆ ถี่ขึ้น หลังจากเวลาผ่านไป การเปลี่ยนแปลงเล็กๆ น้อยๆ เหล่านี้จะรวมกันเป็นสิ่งใหญ่ๆ อย่างไรก็ดี หากดูจากการเผยแพร่ในแต่ละรอบแบบปกติ อาจเป็นเรื่องยากที่เราจะมองย้อนกลับไปแล้วพูดว่า “ว้าว ระหว่าง Rust 1.10 กับ Rust 1.31 ภาษา Rust เปลี่ยนแปลงไปมากเลย!”

ดังนั้น ทุกๆ 3 ปีโดยประมาณ ทีมงาน Rust จะกำหนดและจัดทำ Rust รุ่น (edition) ใหม่ออกมา ซึ่งแต่ละรุ่นจะรวบรวมฟีเจอร์ต่างๆ ที่เผยแพร่ไปแล้วมารวมเข้าด้วยกันเป็นแพ็กเกจที่ชัดเจน พร้อมทั้งมีเอกสารและชุดเครื่องมือต่างๆ ที่อัปเดตใหม่อย่างครบถ้วน โดยรุ่นใหม่ๆ นี้จะถูกส่งมอบเป็นส่วนหนึ่งของกระบวนการเผยแพร่ปกติทุกๆ 6 สัปดาห์นั่นเอง

การแบ่งรุ่น (Editions) ทำหน้าที่ตอบโจทย์วัตถุประสงค์ที่แตกต่างกันตามกลุ่มบุคคล:

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

ในขณะที่เขียนหนังสือเล่มนี้ มีรุ่นของภาษา Rust ออกมาแล้ว 4 รุ่นด้วยกัน ได้แก่ Rust 2015, Rust 2018, Rust 2021 และ Rust 2024 หนังสือเล่มนี้เขียนขึ้นโดยใช้รูปแบบและสำนวนภาษา (idioms) ของรุ่น Rust 2024

คีย์ edition ในไฟล์ Cargo.toml บ่งบอกว่าคอมไพเลอร์ควรใช้รุ่นใดสำหรับโค้ดของคุณ หากไม่มีการระบุคีย์ดังกล่าว Rust จะใช้รุ่น 2015 เป็นค่าเริ่มต้นด้วยเหตุผลด้านความเข้ากันได้ย้อนหลัง (backward compatibility)

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

คอมไพเลอร์ Rust ทุกเวอร์ชันจะสนับสนุนทุกรุ่นของภาษาที่เปิดตัวมาก่อนการเผยแพร่คอมไพเลอร์เวอร์ชันนั้นๆ และสามารถเชื่อมโยงเครต (crates) ต่างรุ่นที่รองรับเข้าด้วยกันได้ การเปลี่ยนแปลงของรุ่นภาษาจะส่งผลเฉพาะต่อวิธีที่คอมไพเลอร์ทำการวิเคราะห์ไวยากรณ์ (parse) โค้ดในขั้นแรกเริ่มเท่านั้น ดังนั้น หากโครงการของคุณใช้รุ่น Rust 2015 แต่หนึ่งในโมดูลที่โครงการของคุณต้องพึ่งพา (dependencies) ใช้รุ่น Rust 2018 โครงการของคุณก็จะสามารถคอมไพล์และเรียกใช้งานโมดูลนั้นได้อย่างไม่มีปัญหา เช่นกันในสถานการณ์สลับกันที่โครงการของคุณใช้ Rust 2018 และตัวพึ่งพาใช้ Rust 2015 ก็สามารถทำงานร่วมกันได้ตามปกติ

ขอชี้แจงให้ชัดเจน: คุณลักษณะ (features) ส่วนใหญ่จะพร้อมใช้งานในทุกรุ่น นักพัฒนาที่ใช้ภาษา Rust รุ่นใดก็ตามจะยังคงได้รับการปรับปรุงประสิทธิผลอย่างต่อเนื่องเมื่อมีการอัปเดตเวอร์ชันเสถียร (stable releases) ใหม่ๆ ออกมา ทว่าในบางกรณี โดยเฉพาะเมื่อมีการเพิ่มคำสงวนใหม่ๆ คุณลักษณะใหม่บางอย่างอาจมีให้ใช้งานได้เฉพาะในรุ่นที่ใหม่กว่าเท่านั้น คุณจำเป็นต้องสลับรุ่นของโครงการหากต้องการใช้ประโยชน์จากคุณลักษณะเหล่านั้น

ศึกษาข้อมูลเพิ่มเติมได้ที่ คู่มือรุ่นของภาษา Rust (The Rust Edition Guide) ซึ่งเป็นหนังสืออย่างเป็นทางการฉบับสมบูรณ์ที่แสดงรายละเอียดความแตกต่างของแต่ละรุ่น ตลอดจนแนวทางสำหรับอัปเกรดโค้ดของคุณไปยังรุ่นใหม่โดยอัตโนมัติผ่านคำสั่ง cargo fix

F - Translations of the Book

ภาคผนวก F: ฉบับแปลของหนังสือเล่มนี้

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

G - How Rust is Made and “Nightly Rust”

ภาคผนวก G - กระบวนการสร้างภาษา Rust และ “Nightly Rust”

ภาคผนวกนี้เกี่ยวกับวิธีการสร้างและพัฒนาภาษา Rust และการที่กระบวนการดังกล่าวส่งผลต่อคุณในฐานะนักพัฒนา Rust อย่างไร

เสถียรภาพโดยไม่หยุดนิ่ง (Stability Without Stagnation)

ในฐานะภาษาโปรแกรม Rust ใส่ใจเกี่ยวกับเสถียรภาพ (stability) ของโค้ดของคุณเป็นอย่าง มาก เราต้องการให้ Rust เป็นรากฐานที่มั่นคงดั่งหินผาที่คุณสามารถต่อยอดพัฒนาสิ่งต่างๆ ได้ และหากมีการเปลี่ยนแปลงเกิดขึ้นตลอดเวลา เรื่องดังกล่าวก็คงเป็นไปไม่ได้ ในขณะเดียวกัน หากเราไม่มีการทดลองกับคุณลักษณะ (features) ใหม่ๆ เลย เราก็อาจไม่พบข้อบกพร่องที่สำคัญจนกระทั่งมีการเผยแพร่ฟีเจอร์เหล่านั้นออกไปแล้ว ซึ่งถึงตอนนั้นเราก็ไม่สามารถกลับไปแก้ไขเปลี่ยนรูปแบบการทำงานได้อีก

แนวทางแก้ไขปัญหานี้ของเราคือสิ่งที่เราเรียกว่า “เสถียรภาพโดยไม่หยุดนิ่ง (stability without stagnation)” และหลักการชี้นำของเราคือ: คุณไม่ควรต้องหวาดกลัวกับการอัปเกรด Rust เป็นเวอร์ชันเสถียร (stable) เวอร์ชันใหม่ การอัปเกรดแต่ละครั้งควรปราศจากความยุ่งยาก แต่ในขณะเดียวกันก็ควรนำพาคุณลักษณะใหม่ๆ มาให้คุณ มีจุดบกพร่องที่น้อยลง และช่วยให้ประมวลผลคอมไพล์ได้เร็วขึ้น

ปู๊นๆ! ช่องทางการเผยแพร่และขบวนรถไฟซอฟต์แวร์ (Release Channels and Riding the Trains)

การพัฒนาภาษา Rust ทำงานตามระบบ ตารางเดินรถไฟ นั่นคือการพัฒนาทั้งหมดจะทำบนสาขาหลัก (main) ในคลังเก็บโค้ด (repository) ของ Rust การเผยแพร่รุ่นใหม่ๆ จะดำเนินตามรูปแบบขบวนรถไฟซอฟต์แวร์ (release train model) ซึ่งเป็นระบบที่เคยนำไปใช้ในโครงการพัฒนา Cisco IOS และซอฟต์แวร์อื่นๆ มาแล้ว โดยช่องทางการเผยแพร่ (release channels) สำหรับ Rust จะมีอยู่ 3 ช่องทางหลัก ได้แก่:

  • Nightly
  • Beta
  • Stable

นักพัฒนา Rust ส่วนใหญ่จะเลือกใช้ช่องทาง stable เป็นหลัก แต่สำหรับผู้ที่ต้องการทดลองใช้คุณลักษณะใหม่ๆ ที่อยู่ในขั้นทดลอง ก็สามารถสลับไปใช้งาน nightly หรือ beta ได้

นี่คือตัวอย่างการทำงานของกระบวนการพัฒนาและการเผยแพร่: สมมติว่าทีมงาน Rust กำลังจัดทำ Rust เวอร์ชัน 1.5 (เวอร์ชันนี้ออกเผยแพร่จริงเมื่อเดือนธันวาคม ปี 2015 แต่เรานำมาเป็นข้อมูลเพื่อแสดงเลขเวอร์ชันประกอบให้เห็นภาพชัดเจน) เมื่อมีฟีเจอร์ใหม่เพิ่มเข้ามาใน Rust: คอมมิตใหม่จะถูกบันทึกส่งไปยังสาขาหลัก (main) ทุกๆ คืน คอมไพเลอร์ Rust เวอร์ชัน nightly จะถูกสร้างขึ้น ทุกๆ วันจึงเปรียบเหมือนวันเผยแพร่ และรุ่นย่อยเหล่านี้จะถูกสร้างโดยโครงสร้างพื้นฐานการเผยแพร่ของเราโดยอัตโนมัติ ดังนั้น เมื่อเวลาผ่านไป รุ่นที่เผยแพร่ของเราจะมีลักษณะดังนี้ในทุกๆ คืน:

nightly: * - - * - - *

ทุกๆ 6 สัปดาห์ จะถึงเวลาเตรียมเผยแพร่รุ่นใหม่! สาขา beta ในคลังเก็บโค้ดของ Rust จะแตกแขนงแยกออกจากสาขาหลัก (main) ที่ nightly ใช้งานอยู่ ตอนนี้เราจะมีระบบเผยแพร่อยู่ 2 ส่วน:

nightly: * - - * - - *
                     |
beta:                *

ผู้ใช้ Rust ส่วนใหญ่จะไม่ได้ใช้งานรุ่นทดสอบ beta ในระหว่างทำงานทั่วไป แต่จะระบุให้ทำการทดสอบโปรแกรมเทียบกับเวอร์ชัน beta ในระบบการรวมโค้ดต่อเนื่อง (CI system) ของตนเอง เพื่อช่วยโครงการ Rust ตรวจหาข้อบกพร่องที่อาจเกิดความถดถอยในการทำงาน (regressions) ในขณะเดียวกัน ทุกๆ คืนก็ยังคงมีการปล่อยรุ่น nightly ออกมาอย่างต่อเนื่อง:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

สมมติว่ามีข้อบกพร่องของการถดถอยถูกตรวจพบ ถือเป็นเรื่องดีที่เรามีเวลาทดสอบเวอร์ชัน beta ก่อนที่ข้อบกพร่องนั้นจะหลุดรอดไปถึงรุ่นเสถียร (stable release)! วิธีแก้ไขจะถูกบันทึกส่งเข้าไปยังสาขาหลักเพื่อแก้ไขปัญหาใน nightly จากนั้นการแก้ไขนี้จะถูกพอร์ตย้อนกลับ (backported) มายังสาขา beta และจะสร้างรุ่น beta รุ่นอัปเดตใหม่ออกมา:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

หลังจากสร้าง beta แรกเป็นเวลา 6 สัปดาห์ ก็ได้เวลาเผยแพร่รุ่นเสถียร! สาขา stable จะถูกสร้างขึ้นมาจากสาขา beta:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

ไชโย! Rust 1.5 เสร็จสมบูรณ์แล้ว! อย่างไรก็ดี เราลืมไปอย่างหนึ่ง: เนื่องจากเวลาล่วงเลยไปแล้ว 6 สัปดาห์ เราจึงต้องมีรุ่น beta ใหม่ของ Rust เวอร์ชัน ถัดไป นั่นคือเวอร์ชัน 1.6 ด้วยเช่นกัน ดังนั้น หลังจากสาขา stable แตกออกจาก beta แล้ว สาขา beta เวอร์ชันถัดไปก็จะแตกแยกออกมาจาก nightly อีกครั้ง:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

วิธีนี้เรียกว่า “รูปแบบขบวนรถไฟ (train model)” เนื่องจากในทุกๆ 6 สัปดาห์ จะมีรุ่นปล่อยใหม่ออกจาก “สถานีรถไฟ” แต่ยังต้องเดินทางผ่านช่องทาง beta เพื่อทดสอบตรวจสอบระบบก่อนที่จะเทียบชานชาลากลายเป็นรุ่นเสถียร (stable release)

การออกเวอร์ชันใหม่ของ Rust จะเกิดขึนทุกๆ 6 สัปดาห์ตรงเวลาดั่งเครื่องจักรกล หากคุณทราบวันที่การเผยแพร่รุ่นใดรุ่นหนึ่งของ Rust คุณย่อมรู้ว่ารุ่นถัดไปจะออกเมื่อใด นั่นคือ 6 สัปดาห์ถัดไปนั่นเอง ข้อดีของการมีรอบการเผยแพร่ทุกๆ 6 สัปดาห์คือ รถไฟขบวนถัดไปจะมาถึงในไม่ช้า หากมีฟีเจอร์ใดพลาดการเผยแพร่ในรอบนี้ ก็ไม่จำเป็นต้องกังวล: ขบวนถัดไปจะมาถึงในอีกไม่นาน! วิธีนี้ช่วยลดความกดดันที่จะต้องเร่งรีบใส่ฟีเจอร์ที่อาจยังไม่เสร็จสมบูรณ์ร้อยเปอร์เซ็นต์เข้าสู่ช่วงเวลาก่อนเส้นตายการเผยแพร่

ด้วยกระบวนการนี้ คุณจึงสามารถทดสอบตรวจสอบ build ถัดไปของ Rust ได้อยู่เสมอ เพื่อยืนยันด้วยตนเองว่ากระบวนการอัปเกรดเป็นเรื่องง่าย: หากรุ่น beta ทำงานได้ไม่เป็นไปตามที่คาดหวัง คุณสามารถรายงานปัญหาให้ทีมพัฒนาทราบเพื่อแก้ไขปัญหาให้เรียบร้อยก่อนที่รุ่นเสถียรจริงจะออกเผยแพร่! ความเสียหายในรุ่น beta ถือว่าเกิดขึ้นค่อนข้างน้อย แต่ rustc ก็ยังคงเป็นซอฟต์แวร์ชิ้นหนึ่ง ข้อผิดพลาด (bugs) จึงย่อมมีโอกาสเกิดขึ้นได้เป็นธรรมดา

ระยะเวลาการดูแลรักษาความเสถียร (Maintenance time)

โครงการ Rust จะให้การสนับสนุนดูแลเฉพาะเวอร์ชันเสถียรเวอร์ชันล่าสุดเท่านั้น เมื่อมีการออกเวอร์ชันเสถียรเวอร์ชันใหม่ เวอร์ชันเก่าจะถึงจุดสิ้นสุดการสนับสนุน (end of life - EOL) ทันที ซึ่งหมายความว่าในแต่ละเวอร์ชันจะมีระยะเวลาได้รับการสนับสนุนดูแลเป็นเวลา 6 สัปดาห์

คุณลักษณะที่ยังไม่เสถียร (Unstable Features)

ยังมีเงื่อนไขเพิ่มเติมอีกอย่างหนึ่งสำหรับโมเดลการเผยแพร่นี้: คุณลักษณะที่ยังไม่เสถียร (unstable features) ภาษา Rust ใช้เทคนิคที่เรียกว่า “แฟล็กคุณลักษณะ (feature flags)” เพื่อระบุว่าฟีเจอร์ใดที่จะเปิดใช้ในรุ่นที่เผยแพร่นั้นๆ หากคุณลักษณะใหม่กำลังอยู่ระหว่างการพัฒนา โค้ดจะถูกบันทึกส่งไปยังสาขาหลัก และส่งผลให้ปรากฏใน nightly แต่จะถูกปิดซ่อนไว้ภายใต้ แฟล็กคุณลักษณะ หากคุณในฐานะผู้ใช้ต้องการทดลองใช้งานฟีเจอร์ที่ยังพัฒนาไม่เสร็จสิ้นนี้ คุณสามารถทำได้ แต่จำเป็นต้องใช้งานคอมไพเลอร์ Rust รุ่น nightly และเพิ่มคำกำกับ (annotate) ลงในโค้ดต้นฉบับของคุณด้วยแฟล็กที่เหมาะสมเพื่อตกลงยอมรับการทดลองใช้งาน

หากคุณใช้งาน Rust รุ่น beta หรือ stable คุณจะไม่สามารถเปิดใช้งานแฟล็กคุณลักษณะใดๆ ได้ นี่คือกุญแจสำคัญที่ช่วยให้พวกเราสามารถเก็บข้อมูลการใช้งานจริงของฟีเจอร์ใหม่ๆ ได้ ก่อนที่จะประกาศรับรองความเสถียรของฟีเจอร์เหล่านั้นตลอดไป ผู้ที่ชื่นชอบทดลองสิ่งใหม่ๆ ในระดับขอบเทคโนโลยี (bleeding edge) ก็สามารถทำได้ ส่วนผู้ที่ต้องการใช้งานระบบที่เสถียรอย่างสมบูรณ์แบบก็สามารถยึดมั่นกับรุ่น stable และมั่นใจได้ว่าโค้ดของพวกเขาจะไม่พังเสียหาย เสถียรภาพโดยไม่หยุดนิ่ง

หนังสือเล่มนี้มีเฉพาะข้อมูลเกี่ยวกับคุณลักษณะที่เสถียรแล้วเท่านั้น เนื่องจากคุณลักษณะที่อยู่ระหว่างการพัฒนายังคงมีการเปลี่ยนแปลงอยู่เสมอ และย่อมมีความแตกต่างกันระหว่างช่วงเวลาที่เขียนหนังสือเล่มนี้กับช่วงเวลาที่ฟีเจอร์เหล่านั้นได้รับการเปิดใช้งานใน build เวอร์ชันเสถียร คุณสามารถศึกษาเอกสารอธิบายฟีเจอร์เฉพาะ nightly เพิ่มเติมได้ทางออนไลน์

Rustup และบทบาทของ Rust Nightly

เครื่องมือ Rustup ช่วยอำนวยความสะดวกให้การสลับเปลี่ยนไปมาระหว่างช่องทางการเผยแพร่ต่างๆ ของ Rust เป็นเรื่องง่าย ทั้งในแบบครอบคลุมทั้งระบบ (global) หรือแยกเฉพาะในแต่ละโครงการ โดยค่าเริ่มต้น คุณจะมี Rust รุ่น stable ติดตั้งอยู่แล้ว ตัวอย่างการติดตั้ง nightly เช่น:

$ rustup toolchain install nightly

คุณยังสามารถตรวจสอบ ชุดเครื่องมือ (toolchains) (เวอร์ชันปล่อยของ Rust และส่วนประกอบที่เกี่ยวข้อง) ทั้งหมดที่คุณติดตั้งผ่าน rustup ได้เช่นกัน นี่คือตัวอย่างที่เกิดขึ้นบนเครื่องคอมพิวเตอร์ระบบ Windows ของหนึ่งในผู้เขียนหนังสือเล่มนี้:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

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

$ cd ~/projects/needs-nightly
$ rustup override set nightly

ตอนนี้ ทุกๆ ครั้งที่คุณพิมพ์เรียกใช้งาน rustc หรือ cargo ภายในโฟลเดอร์ ~/projects/needs-nightly ตัว rustup จะคอยควบคุมดูแลให้คุณใช้ Rust เวอร์ชัน nightly แทนการเรียกใช้เวอร์ชันเสถียรที่เป็นค่าเริ่มต้นตามปกติ สิ่งนี้จะมีประโยชน์อย่างมากเมื่อคุณต้องดูแลโครงการ Rust จำนวนหลายโครงการ!

กระบวนการ RFC และทีมงานพัฒนา (The RFC Process and Teams)

แล้วคุณจะเรียนรู้เกี่ยวกับคุณลักษณะใหม่ๆ เหล่านี้ได้อย่างไร? รูปแบบการพัฒนาของ Rust จะดำเนินตาม กระบวนการยื่นขอความคิดเห็น (Request For Comments - RFC process) หากคุณต้องการแนะนำให้มีการปรับปรุงสิ่งใดใน Rust คุณสามารถร่างเอกสารข้อเสนอขึ้นมา ซึ่งเรียกว่า RFC

ทุกคนสามารถเขียนเอกสาร RFC เพื่อแนะนำการปรับปรุงพัฒนาภาษา Rust ได้ โดยข้อเสนอจะได้รับการตรวจสอบและหารือร่วมกันโดยทีมงาน Rust ซึ่งประกอบไปด้วยทีมย่อยที่ดูแลในหัวข้อต่างๆ คุณสามารถดูรายการทีมทั้งหมดได้ที่ เว็บไซต์ทางการของ Rust ซึ่งมีทีมที่รับผิดชอบในแต่ละด้านของโครงการ เช่น ทีมออกแบบภาษา (language design), ทีมพัฒนาคอมไพเลอร์ (compiler implementation), ทีมโครงสร้างพื้นฐาน (infrastructure), ทีมเอกสารคู่มือ (documentation) และอื่นๆ ทีมงานที่เกี่ยวข้องจะทำการอ่านร่างข้อเสนอและความคิดเห็นต่างๆ เพิ่มข้อเสนอแนะส่วนตัวของทีม และในขั้นตอนสุดท้าย จะมีข้อสรุปร่วมกันในการเลือกที่จะยอมรับหรือปฏิเสธฟีเจอร์นั้นๆ

หากฟีเจอร์ดังกล่าวได้รับการตอบรับยอมรับ จะมีการสร้างประเด็นปัญหา (issue) ขึ้นบนคลังเก็บโค้ดของ Rust เพื่อให้นักพัฒนาสามารถเข้ามาอิมพลิเมนต์สร้างระบบขึ้นมาได้ โดยผู้ที่จะมาเขียนโค้ดอิมพลิเมนต์อาจไม่ใช่คนเดียวกันกับที่ยื่นข้อเสนอ RFC ในตอนแรกก็ได้! เมื่อโค้ดพร้อมใช้งานแล้ว มันจะถูกส่งเข้าไปรวมบนสาขาหลักภายใต้การควบคุมสิทธิ์การเปิดใช้ฟีเจอร์ (feature gate) ตามที่เราอธิบายไปในหัวข้อ “คุณลักษณะที่ยังไม่เสถียร”

เมื่อเวลาผ่านไประยะหนึ่ง หลังจากนักพัฒนา Rust ที่ใช้เวอร์ชัน nightly ได้มีโอกาสทดลองใช้งานฟีเจอร์ใหม่นี้แล้ว สมาชิกในทีมจะหารือเกี่ยวกับฟีเจอร์นั้นๆ รวมถึงผลลัพธ์ที่ได้จากการทำงานบน nightly และตัดสินใจร่วมกันว่าควรผลักดันฟีเจอร์นี้เข้าสู่ Rust เวอร์ชันเสถียร (stable) หรือไม่ หากผลตัดสินคือการนำไปใช้งานต่อ ตัวปิดกั้นฟีเจอร์ (feature gate) จะถูกเอาออก และนับแต่นั้นจะถือว่าคุณลักษณะนี้มีสถานะเสถียร (stable)! และจะเดินทางไปตามสายรถไฟซอฟต์แวร์มุ่งสู่รุ่นเสถียรรุ่นใหม่ของ Rust ต่อไป