تحسين مشروع الإدخال والإخراج الخاص بنا (Improving Our I/O Project)
باستخدام هذه المعرفة الجديدة حول المكررات (Iterators)، يمكننا تحسين مشروع الإدخال والإخراج (I/O) في الفصل 12 باستخدام Iterators لجعل أجزاء الكود (Code) أكثر وضوحاً وإيجازاً. دعنا نلقي نظرة على كيفية قيام Iterators بتحسين تنفيذنا لدالة (Function) Config::build ودالة search.
إزالة clone باستخدام مكرر (Iterator)
في القائمة (Listing) 12-6، أضفنا Code يأخذ شريحة (Slice) من قيم String وأنشأ مثيلاً (Instance) من هيكل (Struct) Config عن طريق الفهرسة (Indexing) في Slice واستنساخ (Cloning) القيم، مما يسمح لـ Struct Config بامتلاك تلك القيم. في Listing 13-17، أعدنا إنتاج تنفيذ Function Config::build كما كان في Listing 12-23.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
في ذلك الوقت، قلنا ألا تقلق بشأن استدعاءات clone غير الفعالة لأننا سنزيلها في المستقبل. حسناً، لقد حان ذلك الوقت الآن!
احتجنا إلى clone هنا لأن لدينا Slice بعناصر String في الوسيط (Parameter) args ، لكن Function build لا تمتلك args. لإعادة ملكية (Ownership) Instance Config ، كان علينا استنساخ القيم من حقول (Fields) query و file_path الخاصة بـ Config بحيث يمكن لـ Instance Config امتلاك قيمه الخاصة.
مع معرفتنا الجديدة حول Iterators، يمكننا تغيير Function build لتأخذ ملكية Iterator كـ Argument بدلاً من استعارة (Borrowing) Slice. سنستخدم وظائف Iterator بدلاً من Code الذي يتحقق من طول Slice ويقوم بـ Indexing في مواقع محددة. سيؤدي هذا إلى توضيح ما تفعله Function Config::build لأن Iterator سيصل إلى القيم.
بمجرد أن تأخذ Config::build ملكية Iterator وتتوقف عن استخدام عمليات Indexing التي تقوم بـ Borrow، يمكننا نقل (Move) قيم String من Iterator إلى Config بدلاً من استدعاء clone وإجراء تخصيص (Allocation) جديد.
استخدام المكرر المُعاد مباشرة
افتح ملف 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(())
}
سنقوم أولاً بتغيير بداية Function main التي كانت لدينا في Listing 12-24 إلى Code الموجود في Listing 13-18، والذي يستخدم هذه المرة Iterator. لن يتم تحويل هذا برمجياً (Compile) حتى نقوم بتحديث 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 مكرراً (Iterator)! وبدلاً من تجميع قيم Iterator في متجه (Vector) ثم تمرير Slice إلى Config::build ، فإننا الآن نمرر ملكية Iterator المُعاد من env::args إلى Config::build مباشرة.
بعد ذلك، نحتاج إلى تحديث تعريف Config::build. دعنا نغير توقيع (Signature) Config::build ليبدو مثل Listing 13-19. هذا لا يزال لن يتم Compile ، لأننا بحاجة إلى تحديث جسم الدالة (Function Body).
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(())
}
توضح وثائق المكتبة القياسية (Standard Library Documentation) لدالة env::args أن نوع Iterator الذي تعيده هو std::env::Args ، وهذا النوع ينفذ سمة (Trait) الـ Iterator ويعيد قيم String.
لقد قمنا بتحديث Signature لـ Function Config::build بحيث يكون لـ Parameter args نوع عام (Generic Type) مع قيود السمة (Trait Bounds) impl Iterator<Item = String> بدلاً من &[String]. هذا الاستخدام لصيغة impl Trait التي ناقشناها في قسم “استخدام السمات كـ Parameters” من الفصل 10 يعني أن args يمكن أن يكون أي نوع ينفذ Trait Iterator ويعيد عناصر String.
لأننا نأخذ ملكية args وسنقوم بتعديل (Mutating) args عن طريق التكرار عليه، يمكننا إضافة الكلمة المفتاحية mut في مواصفات Parameter args لجعله قابلاً للتغير (Mutable).
استخدام طرق سمة Iterator
بعد ذلك، سنقوم بإصلاح Function Body لـ Config::build. نظراً لأن args ينفذ Trait Iterator ، فإننا نعلم أنه يمكننا استدعاء طريقة (Method) next عليه! يقوم Listing 13-20 بتحديث Code من Listing 12-23 لاستخدام Method 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 للحصول على القيمة التي نريد وضعها في Field query الخاص بـ Config. إذا أعاد next القيمة Some ، فإننا نستخدم match لاستخراج القيمة. وإذا أعاد None ، فهذا يعني أنه لم يتم توفير وسائط كافية، ونقوم بالإرجاع مبكراً مع قيمة Err. نفعل الشيء نفسه لقيمة file_path.
توضيح الكود باستخدام محولات المكرر (Iterator Adapters)
يمكننا أيضاً الاستفادة من Iterators في Function search في مشروع I/O الخاص بنا، والتي أُعيد إنتاجها هنا في Listing 13-21 كما كانت في Listing 12-19.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
يمكننا كتابة هذا Code بطريقة أكثر إيجازاً باستخدام طرق محولات المكرر (Iterator Adapter Methods). القيام بذلك يجنبنا أيضاً وجود Vector وسيط قابل للتغير لـ results. يفضل أسلوب البرمجة الوظيفية (Functional Programming) تقليل كمية الحالة القابلة للتغير (Mutable State) لجعل Code أكثر وضوحاً. قد تتيح إزالة Mutable State تحسيناً مستقبلياً لجعل البحث يحدث بالتوازي لأننا لن نضطر إلى إدارة الوصول المتزامن (Concurrent Access) إلى Vector الـ results. يوضح Listing 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)
);
}
}
تذكر أن الغرض من Function search هو إرجاع جميع الأسطر في contents التي تحتوي على query. على غرار مثال الـ filter في Listing 13-16، يستخدم هذا Code محول filter للاحتفاظ فقط بالأسطر التي تعيد لها line.contains(query) القيمة true. ثم نقوم بتجميع (Collect) الأسطر المتطابقة في Vector آخر باستخدام collect. أبسط بكثير! لا تتردد في إجراء نفس التغيير لاستخدام طرق Iterator في Function search_case_insensitive أيضاً.
لمزيد من التحسين، قم بإرجاع Iterator من Function search عن طريق إزالة استدعاء collect وتغيير نوع الإرجاع إلى impl Iterator<Item = &'a str> بحيث تصبح Function عبارة عن Iterator Adapter. لاحظ أنك ستحتاج أيضاً إلى تحديث الاختبارات! ابحث في ملف كبير باستخدام أداة minigrep الخاصة بك قبل وبعد إجراء هذا التغيير لملاحظة الفرق في السلوك. قبل هذا التغيير، لن يطبع البرنامج أي نتائج حتى يجمع كل النتائج، ولكن بعد التغيير، ستُطبع النتائج فور العثور على كل سطر مطابق لأن حلقة (Loop) الـ for في Function run قادرة على الاستفادة من الكسل (Laziness) الخاص بـ Iterator.
الاختيار بين الحلقات أو المكررات (Loops or Iterators)
السؤال المنطقي التالي هو أي أسلوب يجب أن تختاره في Code الخاص بك ولماذا: التنفيذ الأصلي في Listing 13-21 أو الإصدار الذي يستخدم Iterators في Listing 13-22 (بافتراض أننا نجمع كل النتائج قبل إرجاعها بدلاً من إرجاع Iterator). يفضل معظم مبرمجي Rust استخدام أسلوب Iterator. من الصعب قليلاً التعود عليه في البداية، ولكن بمجرد أن تعتاد على مختلف Iterator Adapters وما تفعله، يمكن أن تكون Iterators أسهل في الفهم. بدلاً من العبث بأجزاء Loops المختلفة وبناء Vectors جديدة، يركز Code على الهدف عالي المستوى لـ Loop. هذا يجرد بعض Code الشائع بحيث يسهل رؤية المفاهيم الفريدة لهذا Code، مثل شرط التصفية (Filtering Condition) الذي يجب أن يمر به كل عنصر في Iterator.
ولكن هل التنفيذان متكافئان حقاً؟ قد يكون الافتراض البديهي هو أن Loop منخفض المستوى سيكون أسرع. دعنا نتحدث عن الأداء (Performance).