بهبود پروژه I/O
با این دانش جدید درباره iteratorها، میتوانیم پروژه I/O در فصل ۱۲ را با استفاده از iteratorها بهبود بخشیم تا بخشهایی از کد واضحتر و مختصرتر شوند. بیایید ببینیم چگونه iteratorها میتوانند پیادهسازی تابع Config::build و تابع search را بهبود دهند.
حذف یک clone با استفاده از یک Iterator
در لیست 12-6، کدی اضافه کردیم که یک برش از مقادیر String را گرفته و یک نمونه از ساختار Config ایجاد میکرد. این کار با شاخصگذاری در برش و کلون کردن مقادیر انجام شد تا ساختار 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(())
}
Config::build از لیست 12-23در آن زمان گفتیم که نگران تماسهای ناکارآمد clone نباشید زیرا در آینده آنها را حذف خواهیم کرد. خب، اکنون زمان آن فرا رسیده است!
ما در اینجا به clone نیاز داشتیم زیرا در پارامتر args یک برش با عناصر String داریم، اما تابع build مالک args نیست. برای بازگرداندن مالکیت یک نمونه Config، مجبور بودیم مقادیر فیلدهای query و file_path را از Config کلون کنیم تا نمونه Config بتواند مالک مقادیرش باشد.
با دانش جدیدمان درباره iteratorها، میتوانیم تابع build را تغییر دهیم تا مالکیت یک iterator را به عنوان آرگومان خود بگیرد، به جای اینکه یک برش را قرض بگیرد. ما از قابلیتهای iterator به جای کدی که طول برش را بررسی میکند و به مکانهای خاص شاخص میزند، استفاده خواهیم کرد. این کار مشخص میکند که تابع Config::build چه کاری انجام میدهد زیرا iterator به مقادیر دسترسی پیدا خواهد کرد.
زمانی که Config::build مالکیت iterator را به دست آورد و استفاده از عملیات شاخصگذاری که قرض میگیرند را متوقف کرد، میتوانیم مقادیر String را از iterator به Config منتقل کنیم به جای اینکه clone را فراخوانی کنیم و تخصیص جدیدی ایجاد کنیم.
استفاده مستقیم از Iterator بازگرداندهشده
فایل src/main.rs پروژه I/O خود را باز کنید، که باید به این شکل باشد:
نام فایل: 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 است تغییر میدهیم، که این بار از یک iterator استفاده میکند. این کد تا زمانی که 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 به Config::buildتابع env::args یک iterator بازمیگرداند! به جای جمعآوری مقادیر iterator در یک وکتور و سپس ارسال یک برش به Config::build، اکنون ما مالکیت iterator بازگرداندهشده از env::args را مستقیماً به Config::build ارسال میکنیم.
سپس باید تعریف تابع Config::build را بهروزرسانی کنیم. در فایل src/lib.rs پروژه I/O خود، امضای تابع 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(())
}
Config::build برای انتظار یک iteratorمستندات کتابخانه استاندارد برای تابع env::args نشان میدهد که نوع iterator بازگرداندهشده std::env::Args است، و این نوع صفت Iterator را پیادهسازی کرده و مقادیر String بازمیگرداند.
ما امضای تابع Config::build را بهروزرسانی کردهایم تا پارامتر args یک نوع جنریک با محدودیتهای صفت impl Iterator<Item = String> باشد به جای &[String]. این استفاده از نحو impl Trait که در بخش “Traits به عنوان پارامترها” فصل 10 بحث شد، به این معناست که args میتواند هر نوعی باشد که صفت Iterator را پیادهسازی کرده و آیتمهای String بازمیگرداند.
از آنجا که مالکیت args را به دست میآوریم و با پیمایش در آن، args را تغییر خواهیم داد، میتوانیم کلمه کلیدی mut را به مشخصات پارامتر args اضافه کنیم تا آن را قابل تغییر کنیم.
استفاده از متدهای صفت 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(())
}
Config::build برای استفاده از متدهای iteratorبه یاد داشته باشید که اولین مقدار در مقدار بازگرداندهشده از env::args نام برنامه است. ما میخواهیم آن را نادیده بگیریم و به مقدار بعدی برسیم، بنابراین ابتدا next را فراخوانی میکنیم و هیچ کاری با مقدار بازگشتی انجام نمیدهیم. سپس، next را فراخوانی میکنیم تا مقداری که میخواهیم در فیلد query از Config قرار دهیم را دریافت کنیم. اگر next یک Some بازگرداند، از یک match برای استخراج مقدار استفاده میکنیم. اگر None بازگرداند، به این معنی است که آرگومانهای کافی ارائه نشدهاند و با مقدار Err زودتر بازمیگردیم. همین کار را برای مقدار file_path انجام میدهیم.
واضحتر کردن کد با تطبیقدهندههای Iterator
ما همچنین میتوانیم از iteratorها در تابع 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));
}
}
search از لیست 12-19ما میتوانیم این کد را با استفاده از متدهای تطبیقدهنده iterator به شکلی مختصرتر بنویسیم. این کار همچنین به ما اجازه میدهد که از داشتن یک وکتور میانی قابل تغییر به نام results اجتناب کنیم. سبک برنامهنویسی تابعی ترجیح میدهد مقدار حالتهای قابل تغییر را به حداقل برساند تا کد واضحتر شود. حذف حالت قابل تغییر ممکن است امکان ارتقاء آینده را فراهم کند تا جستجو به صورت موازی انجام شود، زیرا نیازی به مدیریت دسترسی همزمان به وکتور 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به یاد دارید که هدف تابع 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 میتواند از ویژگی تنبلی پیمایشگر استفاده کند.
انتخاب بین حلقهها و پیمایشگرها
سؤال منطقی بعدی این است که کدام سبک را در کد خود انتخاب کنیم و چرا: پیادهسازی اولیه در لیستینگ 13-21 یا نسخهای که از پیمایشگرها استفاده میکند در لیستینگ 13-22 (با فرض اینکه تمام نتایج را پیش از بازگرداندن جمعآوری میکنیم و نه اینکه خود پیمایشگر را بازگردانیم). بیشتر برنامهنویسان Rust ترجیح میدهند از سبک پیمایشگر استفاده کنند. در ابتدا ممکن است درک آن کمی دشوارتر باشد، اما زمانی که با آداپتورهای مختلف پیمایشگر و عملکرد آنها آشنا شدید، کار با آنها آسانتر خواهد بود. به جای کلنجار رفتن با بخشهای مختلف حلقه و ساخت وکتورهای جدید، کد روی هدف سطح بالای حلقه تمرکز میکند. این امر باعث پنهان شدن بخشی از کدهای تکراری شده و فهم مفاهیم خاص این کد (مانند شرط فیلتر شدن هر عنصر پیمایشگر) را آسانتر میکند.
اما آیا این دو پیادهسازی واقعاً معادل هم هستند؟ فرض شهودی ممکن است این باشد که حلقه سطح پایینتر سریعتر است. بیایید درباره عملکرد صحبت کنیم.