การเขียนโปรแกรมเกมทายตัวเลข
มาเริ่มก้าวเข้าสู่ภาษา 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