Refactor เพื่อ Modularity และจัดการ Error ให้ดีขึ้น
เพื่อปรับปรุงโปรแกรมของเรา เราจะแก้สี่ปัญหาที่เกี่ยวกับโครงสร้างของ
โปรแกรมและวิธีจัดการ error ที่อาจเกิดขึ้น ก่อนอื่น ฟังก์ชัน main
ของเราตอนนี้ทำสองงาน — มัน parse อาร์กิวเมนต์และอ่านไฟล์ เมื่อโปรแกรม
ของเราเติบโต จำนวนงานแยกที่ฟังก์ชัน main จัดการจะเพิ่มขึ้น เมื่อ
ฟังก์ชันได้รับความรับผิดชอบ มันยากขึ้นที่จะคิดเกี่ยวกับ ยากขึ้นที่จะ
ทดสอบ และยากขึ้นที่จะเปลี่ยนโดยไม่ทำลายส่วนใดส่วนหนึ่ง ดีที่สุดที่จะ
แยก functionality เพื่อให้แต่ละฟังก์ชันรับผิดชอบหนึ่งงาน
ประเด็นนี้ยังเชื่อมโยงกับปัญหาที่สอง — แม้ query และ file_path เป็น
ตัวแปร configuration ของโปรแกรมของเรา ตัวแปรอย่าง contents ถูกใช้
ทำ logic ของโปรแกรม ยิ่ง main ยาว ยิ่งต้องนำตัวแปรมากเข้า scope —
ยิ่งมีตัวแปรใน scope มาก ยิ่งยากที่จะตามจุดประสงค์ของแต่ละตัวแปร ดี
ที่สุดที่จะจัดกลุ่มตัวแปร configuration เข้าโครงสร้างเดียวเพื่อให้
จุดประสงค์ของพวกมันชัดเจน
ปัญหาที่สามคือเราใช้ expect เพื่อ print ข้อความ error เมื่ออ่านไฟล์
fail แต่ข้อความ error แค่ print Should have been able to read the file
การอ่านไฟล์ fail ได้หลายวิธี — ตัวอย่างเช่น ไฟล์อาจหาย หรือเราอาจไม่
มี permission ที่จะเปิดมัน ตอนนี้ ไม่ว่าสถานการณ์เป็นยังไง เราจะ print
ข้อความ error แบบเดียวกันสำหรับทุกอย่าง ซึ่งไม่ให้ข้อมูลใด ๆ แก่ user!
ที่สี่ เราใช้ expect ในการจัดการ error และถ้า user รันโปรแกรมของเรา
โดยไม่ระบุอาร์กิวเมนต์เพียงพอ พวกเขาจะได้ error index out of bounds
จาก Rust ที่ไม่อธิบายปัญหาชัดเจน ดีที่สุดถ้าโค้ดจัดการ error ทั้งหมด
อยู่ที่เดียว เพื่อให้ผู้ดูแลในอนาคตมีเพียงที่เดียวที่จะปรึกษาโค้ดถ้า
logic จัดการ error ต้องเปลี่ยน การมีโค้ดจัดการ error ทั้งหมดที่เดียว
จะรับประกันด้วยว่าเรา print ข้อความที่มีความหมายต่อ end user ของเรา
มาแก้สี่ปัญหานี้โดย refactor โปรเจกต์ของเรา
แยก Concern ในโปรเจกต์ Binary
ปัญหาเชิงการจัดระเบียบของการแบ่งความรับผิดชอบสำหรับหลายงานให้ฟังก์ชัน
main ทั่วไปในโปรเจกต์ binary หลายโปรเจกต์ ผลคือ programmer Rust
หลายคนพบว่ามีประโยชน์ที่จะแยก concern แยกของโปรแกรม binary เมื่อ
ฟังก์ชัน main เริ่มใหญ่ขึ้น กระบวนการนี้มีขั้นตอนต่อไปนี้:
- แยกโปรแกรมของคุณเป็นไฟล์ main.rs และไฟล์ lib.rs และย้าย logic ของโปรแกรมของคุณไปยัง lib.rs
- ตราบใดที่ logic parse command line ของคุณเล็ก มันอยู่ในฟังก์ชัน
mainได้ - เมื่อ logic parse command line เริ่มซับซ้อน ดึงมันจากฟังก์ชัน
mainไปยังฟังก์ชันหรือ type อื่น
ความรับผิดชอบที่ยังอยู่ในฟังก์ชัน main หลังกระบวนการนี้ควรจำกัดอยู่
ที่ต่อไปนี้:
- เรียก logic parse command line ด้วยค่าอาร์กิวเมนต์
- ตั้งค่า configuration อื่นใด
- เรียกฟังก์ชัน
runใน lib.rs - จัดการ error ถ้า
runreturn error
pattern นี้เกี่ยวกับการแยก concern — main.rs จัดการการรันโปรแกรม
และ lib.rs จัดการ logic ทั้งหมดของงานในมือ เพราะคุณทดสอบฟังก์ชัน
main โดยตรงไม่ได้ โครงสร้างนี้ให้คุณทดสอบ logic ของโปรแกรมทั้งหมด
โดยย้ายมันออกจากฟังก์ชัน main โค้ดที่ยังอยู่ในฟังก์ชัน main จะ
เล็กพอที่จะตรวจสอบความถูกต้องโดยอ่าน มาทำใหม่โปรแกรมของเราโดยทำตาม
กระบวนการนี้
ดึง Argument Parser
เราจะดึง functionality สำหรับ parse อาร์กิวเมนต์เข้าฟังก์ชันที่
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)
}
parse_config จาก mainเรายัง collect อาร์กิวเมนต์ command line เข้า vector แต่แทนที่จะ
assign ค่าอาร์กิวเมนต์ที่ index 1 ให้ตัวแปร query และค่าอาร์กิวเมนต์
ที่ index 2 ให้ตัวแปร file_path ภายในฟังก์ชัน main เราส่ง vector
ทั้งหมดให้ฟังก์ชัน parse_config ฟังก์ชัน parse_config จากนั้นเก็บ
logic ที่กำหนดว่าอาร์กิวเมนต์ไหนไปในตัวแปรไหน และส่งค่ากลับไปยัง
main เรายังสร้างตัวแปร query และ file_path ใน main แต่ main
ไม่มีความรับผิดชอบในการกำหนดว่าอาร์กิวเมนต์ command line และตัวแปร
สอดคล้องกันอย่างไรอีกต่อไป
การทำใหม่นี้อาจดูเกินจำเป็นสำหรับโปรแกรมเล็กของเรา แต่เรากำลัง refactor ในขั้นตอนเล็ก ๆ ทีละนิด หลังจากทำการเปลี่ยนแปลงนี้ รันโปรแกรม อีกครั้งเพื่อตรวจสอบว่า parse อาร์กิวเมนต์ยังทำงาน ดีที่จะตรวจสอบ ความก้าวหน้าของคุณบ่อย ๆ เพื่อช่วยระบุสาเหตุของปัญหาเมื่อพวกมันเกิดขึ้น
จัดกลุ่มค่า Configuration
เราทำอีกขั้นตอนเล็กเพื่อปรับปรุงฟังก์ชัน parse_config เพิ่มเติมได้
ตอนนี้ เรากำลัง return tuple แต่เราแยก tuple นั้นเป็นชิ้นแต่ละชิ้น
ทันที นี่เป็นสัญญาณว่าบางที เรายังไม่มี abstraction ที่ถูกต้อง
ตัวบ่งชี้อีกตัวที่แสดงว่ามีพื้นที่สำหรับการปรับปรุงคือส่วน config ของ
parse_config ซึ่งบ่งบอกว่าสองค่าที่เรา return เกี่ยวข้องกัน และทั้ง
สองเป็นส่วนหนึ่งของค่า configuration เดียว เราไม่ได้สื่อความหมายนี้ใน
โครงสร้างของข้อมูลในตอนนี้ นอกจากการจัดกลุ่มสองค่าเข้า tuple — เรา
จะใส่สองค่าเข้า struct เดียวแทน และให้ field ของ struct แต่ละตัวมีชื่อ
ที่มีความหมาย การทำเช่นนั้นจะทำให้ง่ายขึ้นสำหรับผู้ดูแลในอนาคตของโค้ด
นี้ที่จะเข้าใจว่าค่าต่าง ๆ เกี่ยวข้องกันยังไงและจุดประสงค์ของพวกมันคือ
อะไร
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 }
}
parse_config เพื่อ return instance ของ struct Configเราเพิ่ม struct ชื่อ Config ที่นิยามให้มี field ชื่อ query และ
file_path signature ของ parse_config ตอนนี้ระบุว่ามัน return ค่า
Config ใน body ของ parse_config ที่เราเคย return string slice ที่
อ้างถึงค่า String ใน args เราตอนนี้นิยาม Config ให้บรรจุค่า
String ที่ own ตัวแปร args ใน main เป็น owner ของค่าอาร์กิวเมนต์
และให้ฟังก์ชัน parse_config borrow พวกมันเท่านั้น แปลว่าเราจะละเมิด
กฎ borrowing ของ Rust ถ้า Config พยายามรับ ownership ของค่าใน args
มีวิธีหลายอย่างที่เราจัดการข้อมูล String ได้ — วิธีที่ง่ายที่สุด
แม้จะไม่มีประสิทธิภาพอยู่บ้าง คือเรียกเมธอด clone บนค่า สิ่งนี้จะทำ
copy เต็มของข้อมูลให้ instance Config own ซึ่งใช้เวลาและ memory
มากกว่าการเก็บ reference ของข้อมูล string อย่างไรก็ตาม การ clone
ข้อมูลยังทำให้โค้ดของเราตรงไปตรงมามาก เพราะเราไม่ต้องจัดการ lifetime
ของ reference — ในสถานการณ์นี้ ยอมเสีย performance เล็กน้อยเพื่อได้
ความง่ายเป็น trade-off ที่คุ้มค่า
Trade-off ของการใช้ clone
มีแนวโน้มในหมู่ Rustacean หลายคนที่จะหลีกเลี่ยงการใช้ clone เพื่อ
แก้ปัญหา ownership เพราะค่า runtime ของมัน ใน
บทที่ 13 คุณจะเรียนวิธีใช้เมธอดที่มี
ประสิทธิภาพมากกว่าในสถานการณ์ประเภทนี้ แต่ตอนนี้ ก็โอเคที่จะ copy
string สักสองสามตัวเพื่อทำความก้าวหน้าต่อ เพราะคุณจะทำ copy เหล่านี้
เพียงครั้งเดียวและ file path และ query string ของคุณเล็กมาก ดีกว่า
ที่จะมีโปรแกรมที่ทำงานได้แต่ไม่มีประสิทธิภาพหน่อย กว่าที่จะพยายาม
hyperoptimize โค้ดในรอบแรก เมื่อคุณมีประสบการณ์กับ Rust มากขึ้น มัน
จะง่ายขึ้นที่จะเริ่มด้วยคำตอบที่มีประสิทธิภาพที่สุด แต่ตอนนี้
ยอมรับได้สมบูรณ์แบบที่จะเรียก clone
เราอัพเดท main เพื่อให้มันวาง instance ของ Config ที่ return โดย
parse_config ในตัวแปรชื่อ config และเราอัพเดทโค้ดที่ก่อนหน้าใช้
ตัวแปร query และ file_path แยก ให้ใช้ field บน struct Config
แทน
ตอนนี้โค้ดของเราสื่อชัดเจนขึ้นว่า query และ file_path เกี่ยวข้องกัน
และจุดประสงค์ของพวกมันคือ configure ว่าโปรแกรมจะทำงานยังไง โค้ดใดที่
ใช้ค่าเหล่านี้รู้ที่จะหาพวกมันใน instance config ใน field ที่ตั้ง
ชื่อตามจุดประสงค์ของพวกมัน
สร้าง Constructor สำหรับ Config
จนถึงตอนนี้ เราดึง logic ที่รับผิดชอบในการ parse อาร์กิวเมนต์ command
line จาก main และวางในฟังก์ชัน parse_config การทำเช่นนั้นช่วยให้
เราเห็นว่าค่า query และ file_path เกี่ยวข้องกัน และความสัมพันธ์
นั้นควรถูกสื่อในโค้ดของเรา จากนั้นเราเพิ่ม struct Config เพื่อตั้ง
ชื่อจุดประสงค์ที่เกี่ยวข้องของ query และ file_path และเพื่อสามารถ
return ชื่อค่าเป็นชื่อ field ของ struct จากฟังก์ชัน parse_config
ดังนั้น ตอนนี้จุดประสงค์ของฟังก์ชัน parse_config คือสร้าง instance
Config เราเปลี่ยน parse_config จากฟังก์ชันธรรมดาเป็นฟังก์ชันชื่อ
new ที่ associate กับ struct Config ได้ การเปลี่ยนแปลงนี้จะทำให้
โค้ด idiomatic มากขึ้น เราสร้าง instance ของ type ใน standard
library เช่น String ได้โดยเรียก String::new ในทำนองเดียวกัน โดย
เปลี่ยน parse_config เป็นฟังก์ชัน new ที่ associate กับ Config
เราจะสร้าง instance ของ 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 }
}
}
parse_config เป็น Config::newเราอัพเดท main ที่เรากำลังเรียก parse_config ให้เรียก Config::new
แทน เราเปลี่ยนชื่อ parse_config เป็น new และย้ายมันภายใน block
impl ซึ่ง associate ฟังก์ชัน new กับ Config ลองคอมไพล์โค้ดนี้
อีกครั้งเพื่อให้แน่ใจว่ามันทำงาน
แก้การจัดการ Error
ตอนนี้เราจะทำงานแก้การจัดการ error ของเรา จำได้ว่าการพยายามเข้าถึงค่า
ใน vector args ที่ index 1 หรือ index 2 จะทำให้โปรแกรม panic ถ้า
vector มี item น้อยกว่าสาม ลองรันโปรแกรมโดยไม่มีอาร์กิวเมนต์ — มันจะ
ดูแบบนี้:
$ 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' 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 เป็น
ข้อความ error ที่มีไว้สำหรับ programmer มันจะไม่ช่วย end user ของเรา
เข้าใจว่าพวกเขาควรทำอะไรแทน มาแก้ตอนนี้
ปรับปรุงข้อความ Error
ใน Listing 12-8 เราเพิ่มการตรวจสอบในฟังก์ชัน new ที่จะตรวจสอบว่า
slice ยาวพอก่อนเข้าถึง index 1 และ index 2 ถ้า slice ไม่ยาวพอ
โปรแกรม panic และแสดงข้อความ error ที่ดีขึ้น
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 อยู่นอกช่วงค่าที่ valid
แทนที่จะตรวจสอบสำหรับช่วงของค่าที่นี่ เรากำลังตรวจสอบว่าความยาวของ
args อย่างน้อย 3 และส่วนที่เหลือของฟังก์ชันทำงานภายใต้สมมุติฐาน
ว่าเงื่อนไขนี้เป็นจริง ถ้า args มี item น้อยกว่าสาม เงื่อนไขนี้จะ
เป็น true และเราเรียกมาโคร panic! เพื่อจบโปรแกรมทันที
ด้วยโค้ดเพิ่มสองสามบรรทัดใน new มารันโปรแกรมโดยไม่มีอาร์กิวเมนต์
อีกครั้ง เพื่อดูว่า error ดูเป็นยังไงตอนนี้:
$ 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' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
output นี้ดีขึ้น — ตอนนี้เรามีข้อความ error ที่สมเหตุสมผล อย่างไรก็
ตาม เรามีข้อมูลส่วนเกินที่เราไม่ต้องการให้ user ของเรา บางทีเทคนิคที่
เราใช้ใน Listing 9-13 ไม่ใช่ที่ดีที่สุดที่จะใช้ที่นี่ — การเรียก
panic! เหมาะกับปัญหา programming มากกว่าปัญหา usage
ดังที่พูดถึงในบทที่ 9 แทน เรา
จะใช้เทคนิคอีกอย่างที่คุณเรียนในบทที่ 9 —
return Result ที่ระบุความสำเร็จหรือ
error
Return Result แทนการเรียก panic!
แทน เรา return ค่า Result ที่จะบรรจุ instance Config ในกรณี
สำเร็จและจะอธิบายปัญหาในกรณี error ได้ เรายังจะเปลี่ยนชื่อฟังก์ชัน
จาก new เป็น build เพราะ programmer หลายคนคาดหวังให้ฟังก์ชัน
new ไม่ fail เมื่อ Config::build กำลังสื่อสารกับ main เราใช้
type Result เพื่อส่งสัญญาณว่ามีปัญหาได้ จากนั้น เราเปลี่ยน main
เพื่อแปลง variant Err เป็น error ที่ practical มากขึ้นสำหรับ user
ของเราโดยไม่มี text ล้อมรอบเกี่ยวกับ thread 'main' และ
RUST_BACKTRACE ที่การเรียก panic! ก่อให้เกิด
Listing 12-9 แสดงการเปลี่ยนแปลงที่เราต้องทำกับค่า return ของฟังก์ชัน
ที่ตอนนี้เรียก Config::build และ body ของฟังก์ชันที่จำเป็นเพื่อ
return 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 })
}
}
Result จาก Config::buildฟังก์ชัน build ของเรา return Result พร้อม instance Config ใน
กรณีสำเร็จและ string literal ในกรณี error ค่า error ของเราจะเป็น
string literal ที่มี lifetime 'static เสมอ
เราทำสองการเปลี่ยนแปลงใน body ของฟังก์ชัน — แทนที่จะเรียก panic!
เมื่อ user ไม่ส่งอาร์กิวเมนต์เพียงพอ ตอนนี้เรา return ค่า Err และ
เรา wrap ค่า return Config ใน Ok การเปลี่ยนแปลงเหล่านี้ทำให้
ฟังก์ชันสอดคล้องกับ type signature ใหม่ของมัน
การ return ค่า Err จาก Config::build ให้ฟังก์ชัน main จัดการค่า
Result ที่ return จากฟังก์ชัน build และออกจาก process สะอาดขึ้น
ในกรณี error
เรียก Config::build และจัดการ Error
ในการจัดการกรณี error และ print ข้อความที่เป็นมิตรกับ user เราต้อง
อัพเดท main เพื่อจัดการ Result ที่ return โดย Config::build
ดังที่แสดงใน Listing 12-10 เรายังจะรับความรับผิดชอบของการออกจาก
เครื่องมือ command line ด้วย error code ที่ไม่เป็นศูนย์จาก panic!
และ implement มันด้วยมือแทน exit status ที่ไม่เป็นศูนย์เป็นธรรมเนียม
ที่จะส่งสัญญาณไปยัง process ที่เรียกโปรแกรมของเราว่าโปรแกรมออกด้วย
สถานะ error
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 })
}
}
Config failใน listing นี้ เราใช้เมธอดที่เรายังไม่ครอบคลุมในรายละเอียด —
unwrap_or_else ซึ่งนิยามบน Result<T, E> โดย standard library การ
ใช้ unwrap_or_else ให้เรานิยามการจัดการ error แบบกำหนดเองที่ไม่ใช่
panic! ถ้า Result เป็นค่า Ok พฤติกรรมของเมธอดนี้คล้ายกับ
unwrap — มัน return ค่าภายในที่ Ok กำลัง wrap อย่างไรก็ตาม ถ้าค่า
เป็นค่า Err เมธอดนี้เรียกโค้ดใน closure ซึ่งเป็นฟังก์ชัน anonymous
ที่เรานิยามและส่งเป็นอาร์กิวเมนต์ให้ unwrap_or_else เราจะครอบคลุม
closure ในรายละเอียดมากขึ้นใน บทที่ 13 ตอนนี้
คุณแค่ต้องรู้ว่า unwrap_or_else จะส่งค่าภายในของ Err ซึ่งในกรณีนี้
คือ string static "not enough arguments" ที่เราเพิ่มใน Listing
12-9 ไปยัง closure ของเราในอาร์กิวเมนต์ err ที่ปรากฏระหว่าง
vertical pipe โค้ดใน closure จากนั้นใช้ค่า err ได้เมื่อมันรัน
เราเพิ่มบรรทัด use ใหม่เพื่อนำ process จาก standard library เข้า
scope โค้ดใน closure ที่จะถูกรันในกรณี error มีเพียงสองบรรทัด — เรา
print ค่า err แล้วเรียก process::exit ฟังก์ชัน process::exit
จะหยุดโปรแกรมทันทีและ return ตัวเลขที่ถูกส่งเป็น exit status code
นี่คล้ายกับการจัดการแบบ panic! ที่เราใช้ใน Listing 12-8 แต่เราไม่
ได้รับ output ส่วนเกินทั้งหมดอีก ลอง:
$ 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
ดี! output นี้เป็นมิตรกับ user ของเรามากกว่ามาก
ดึง Logic จาก main
ตอนนี้เราเสร็จ refactor การ parse configuration แล้ว มาหันไปยัง logic
ของโปรแกรม ดังที่เราระบุใน
“แยก Concern ในโปรเจกต์ Binary”
เราจะดึงฟังก์ชันชื่อ run ที่จะเก็บ logic ทั้งหมดที่ปัจจุบันอยู่ใน
ฟังก์ชัน main ที่ไม่เกี่ยวข้องกับการตั้งค่า configuration หรือ
จัดการ error เมื่อเราเสร็จ ฟังก์ชัน main จะกระชับและตรวจสอบความ
ถูกต้องโดยการตรวจดูง่าย และเราจะเขียนเทสสำหรับ logic อื่นทั้งหมดได้
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 ที่บรรจุ logic ที่เหลือของโปรแกรมฟังก์ชัน run ตอนนี้บรรจุ logic ที่เหลือทั้งหมดจาก main เริ่มจาก
การอ่านไฟล์ ฟังก์ชัน run รับ instance Config เป็นอาร์กิวเมนต์
Return Error จาก run
เมื่อ logic โปรแกรมที่เหลือถูกแยกเข้าฟังก์ชัน run เราปรับปรุงการ
จัดการ error ได้ เหมือนที่เราทำกับ Config::build ใน Listing 12-9
แทนที่จะให้โปรแกรม panic โดยเรียก expect ฟังก์ชัน run จะ return
Result<T, E> เมื่อสิ่งใดผิดพลาด นี่จะให้เรารวม logic ในการจัดการ
error เข้า main ในแบบที่เป็นมิตรกับ user เพิ่ม Listing 12-12 แสดง
การเปลี่ยนแปลงที่เราต้องทำกับ signature และ body ของ 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 ให้ return Resultเราทำการเปลี่ยนแปลงสำคัญสามอย่างที่นี่ ก่อนอื่น เราเปลี่ยน return type
ของฟังก์ชัน run เป็น Result<(), Box<dyn Error>> ฟังก์ชันนี้
ก่อนหน้านี้ return unit type () และเราเก็บอันนั้นเป็นค่าที่ return
ในกรณี Ok
สำหรับ error type เราใช้ trait object Box<dyn Error> (และเรานำ
std::error::Error เข้า scope ด้วย statement use ที่ด้านบน) เราจะ
ครอบคลุม trait object ใน บทที่ 18 ตอนนี้ แค่
รู้ว่า Box<dyn Error> หมายความว่าฟังก์ชันจะ return type ที่
implement trait Error แต่เราไม่ต้องระบุว่าค่า return จะเป็น type
เฉพาะเจาะจงอะไร นี่ให้เรามีความยืดหยุ่นที่จะ return ค่า error ที่อาจ
เป็น type ต่างกันในกรณี error ต่างกัน keyword dyn ย่อมาจาก
dynamic
ที่สอง เราลบการเรียก expect ในความนิยมของ operator ? ดังที่เรา
พูดถึงใน บทที่ 9 แทนที่จะ
panic! บน error ? จะ return ค่า error จากฟังก์ชันปัจจุบันให้
caller จัดการ
ที่สาม ฟังก์ชัน run ตอนนี้ return ค่า Ok ในกรณีสำเร็จ เราประกาศ
success type ของฟังก์ชัน run เป็น () ใน signature แปลว่าเราต้อง
wrap ค่า unit type ในค่า Ok syntax Ok(()) นี้อาจดูแปลก ๆ ในตอน
แรก แต่การใช้ () แบบนี้เป็นวิธี idiomatic ที่จะระบุว่าเรากำลัง
เรียก run เพื่อ side effect เท่านั้น — มันไม่ return ค่าที่เรา
ต้องการ
เมื่อคุณรันโค้ดนี้ มันจะคอมไพล์แต่จะแสดง warning:
$ 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)]` 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 บอกเราว่าโค้ดของเรา ignore ค่า Result และค่า Result อาจระบุ
ว่ามี error เกิดขึ้น แต่เราไม่ได้ตรวจสอบดูว่ามี error หรือไม่ และ
compiler เตือนเราว่าเราน่าจะตั้งใจให้มีโค้ดจัดการ error ที่นี่! มา
แก้ปัญหานั้นตอนนี้
จัดการ Error ที่ Return จาก run ใน main
เราจะตรวจสอบ error และจัดการพวกมันโดยใช้เทคนิคคล้ายกับที่เราใช้กับ
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 return ค่า
Err ไหม และเรียก process::exit(1) ถ้าใช่ ฟังก์ชัน run ไม่ return
ค่าที่เราต้องการ unwrap ในแบบเดียวกับที่ Config::build return
instance Config เพราะ run return () ในกรณีสำเร็จ เราสนใจเฉพาะ
การตรวจจับ error ดังนั้นเราไม่ต้องการ unwrap_or_else ในการ return
ค่าที่ unwrap แล้ว ซึ่งจะเป็นเพียง ()
body ของ if let และฟังก์ชัน unwrap_or_else เหมือนกันในทั้งสอง
กรณี — เรา print error และออก
แยกโค้ดเข้า Library Crate
โปรเจกต์ minigrep ของเราดูดีจนถึงตอนนี้! ตอนนี้เราจะแยกไฟล์
src/main.rs และใส่โค้ดบางส่วนใน src/lib.rs ด้วยวิธีนั้น เราทดสอบ
โค้ดได้ และมีไฟล์ src/main.rs ที่มีความรับผิดชอบน้อยลง
มานิยามโค้ดที่รับผิดชอบในการค้นหา text ใน src/lib.rs แทนใน
src/main.rs ซึ่งจะให้เรา (หรือใครก็ตามอื่นที่ใช้ library minigrep
ของเรา) เรียกฟังก์ชันการค้นหาจากหลาย context มากกว่า binary
minigrep ของเรา
ก่อนอื่น มานิยาม signature ของฟังก์ชัน search ใน src/lib.rs ดังที่
แสดงใน Listing 12-13 พร้อม body ที่เรียกมาโคร unimplemented! เรา
จะอธิบาย signature ในรายละเอียดมากขึ้นเมื่อเราเติม implementation
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search ใน src/lib.rsเราใช้ keyword pub บนนิยามฟังก์ชันเพื่อกำหนด search เป็นส่วนหนึ่ง
ของ public API ของ library crate ของเรา ตอนนี้เรามี library crate
ที่เราใช้จาก binary crate ของเราได้และทดสอบได้!
ตอนนี้เราต้องนำโค้ดที่นิยามใน src/lib.rs เข้า scope ของ 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(())
}
search ของ library crate minigrep ใน src/main.rsเราเพิ่มบรรทัด use minigrep::search เพื่อนำฟังก์ชัน search จาก
library crate เข้า scope ของ binary crate จากนั้น ในฟังก์ชัน run
แทนที่จะ print เนื้อหาของไฟล์ เราเรียกฟังก์ชัน search และส่งค่า
config.query และ contents เป็นอาร์กิวเมนต์ จากนั้น run จะใช้
loop for เพื่อ print แต่ละบรรทัดที่ return จาก search ที่ตรงกับ
query นี่ยังเป็นเวลาดีที่จะลบการเรียก println! ในฟังก์ชัน main ที่
แสดง query และ file path เพื่อให้โปรแกรมของเรา print เฉพาะผลค้นหา
(ถ้าไม่มี error เกิดขึ้น)
สังเกตว่าฟังก์ชัน search จะ collect ผลทั้งหมดเข้า vector ที่มัน return ก่อนที่ print ใด ๆ จะเกิดขึ้น implementation นี้อาจช้าในการแสดงผลเมื่อ ค้นหาไฟล์ใหญ่ เพราะผลไม่ถูก print เมื่อพวกมันถูกพบ — เราจะพูดถึงวิธี ที่เป็นไปได้ในการแก้สิ่งนี้โดยใช้ iterator ในบทที่ 13
โอ้ว! นั่นเป็นงานเยอะ แต่เราตั้งตัวเองเพื่อความสำเร็จในอนาคตแล้ว ตอน นี้ง่ายขึ้นมากที่จะจัดการ error และเราทำให้โค้ด modular มากขึ้น เกือบ ทั้งหมดของงานเราจะทำใน src/lib.rs ตั้งแต่ตอนนี้ไป
มาใช้ประโยชน์จาก modularity ที่เพิ่งค้นพบนี้โดยทำสิ่งที่จะยากด้วยโค้ด เก่า แต่ง่ายด้วยโค้ดใหม่ — เราจะเขียนเทสบางตัว!