การปรับปรุงโปรเจกต์ 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 ส่วนใหญ่นิยมใช้รูปแบบตัวซ้ำ มันอาจจะเข้าใจยากกว่าเล็กน้อยในตอนแรก แต่เมื่อคุณคุ้นเคยกับตัวปรับแต่งตัวซ้ำแบบต่าง ๆ และสิ่งที่พวกมันทำแล้ว ตัวซ้ำอาจจะเข้าใจได้ง่ายกว่า แทนที่จะต้องยุ่งกับรายละเอียดของการลูปและการสร้างเวกเตอร์ใหม่ โค้ดจะมุ่งเน้นไปที่เป้าหมายระดับสูงของลูป สิ่งนี้จะช่วยซ่อนโค้ดพื้นฐานทั่วไปเพื่อให้อ่านแนวคิดที่เป็นเอกลักษณ์ของโค้ดนี้ได้ง่ายขึ้น เช่น เงื่อนไขการกรองที่สมาชิกแต่ละตัวในตัวซ้ำต้องผ่าน
แต่การประยุกต์ใช้ทั้งสองแบบนั้นเท่าเทียมกันจริงหรือไม่? ข้อสมมติฐานตามสัญชาตญาณอาจคิดว่าลูประดับต่ำกว่าน่าจะทำงานได้เร็วกว่า มาพูดคุยกันเกี่ยวกับประสิทธิภาพ