بهبود پروژه I/O

با این دانش جدید درباره iteratorها، می‌توانیم پروژه I/O در فصل ۱۲ را با استفاده از iteratorها بهبود بخشیم تا بخش‌هایی از کد واضح‌تر و مختصرتر شوند. بیایید ببینیم چگونه iteratorها می‌توانند پیاده‌سازی تابع Config::build و تابع search را بهبود دهند.

حذف یک clone با استفاده از یک Iterator

در لیست 12-6، کدی اضافه کردیم که یک برش از مقادیر String را گرفته و یک نمونه از ساختار Config ایجاد می‌کرد. این کار با شاخص‌گذاری در برش و کلون کردن مقادیر انجام شد تا ساختار Config مالک آن مقادیر شود. در لیست 13-17، پیاده‌سازی تابع Config::build را همانطور که در لیست 12-23 بود بازتولید کرده‌ایم:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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)
        );
    }
}
Listing 13-17: بازتولید تابع 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::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

ابتدا شروع تابع main که در لیست 12-24 داشتیم را به کدی که در لیست 13-18 است تغییر می‌دهیم، که این بار از یک iterator استفاده می‌کند. این کد تا زمانی که Config::build را نیز به‌روزرسانی کنیم، کامپایل نخواهد شد.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 13-18: ارسال مقدار بازگردانده‌شده توسط 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 نشان داده شده تغییر دهید. این کد هنوز کامپایل نخواهد شد زیرا باید بدنه تابع را نیز به‌روزرسانی کنیم.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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)
        );
    }
}
Listing 13-19: به‌روزرسانی امضای 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 به‌روزرسانی می‌کند:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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)
        );
    }
}
Listing 13-20: تغییر بدنه 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 بود بازتولید شده است:

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub 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 })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

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));
    }
}
Listing 13-21: پیاده‌سازی تابع search از لیست 12-19

ما می‌توانیم این کد را با استفاده از متدهای تطبیق‌دهنده iterator به شکلی مختصرتر بنویسیم. این کار همچنین به ما اجازه می‌دهد که از داشتن یک وکتور میانی قابل تغییر به نام results اجتناب کنیم. سبک برنامه‌نویسی تابعی ترجیح می‌دهد مقدار حالت‌های قابل تغییر را به حداقل برساند تا کد واضح‌تر شود. حذف حالت قابل تغییر ممکن است امکان ارتقاء آینده را فراهم کند تا جستجو به صورت موازی انجام شود، زیرا نیازی به مدیریت دسترسی همزمان به وکتور results نخواهیم داشت. لیست 13-22 این تغییر را نشان می‌دهد:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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)
        );
    }
}
Listing 13-22: استفاده از متدهای تطبیق‌دهنده iterator در پیاده‌سازی تابع search

به یاد داشته باشید که هدف تابع search بازگرداندن تمام خطوط موجود در contents است که شامل query باشند. مشابه مثال filter در لیست 13-16، این کد از تطبیق‌دهنده filter برای نگه‌داشتن فقط خطوطی که برای آن‌ها line.contains(query) مقدار true بازمی‌گرداند، استفاده می‌کند. سپس خطوط مطابق را با collect در یک وکتور دیگر جمع‌آوری می‌کنیم. بسیار ساده‌تر! اگر تمایل دارید، می‌توانید همین تغییر را برای استفاده از متدهای iterator در تابع search_case_insensitive نیز انجام دهید.

انتخاب بین حلقه‌ها یا Iteratorها

سؤال منطقی بعدی این است که کدام سبک را باید در کد خود انتخاب کنید و چرا: پیاده‌سازی اصلی در لیست 13-21 یا نسخه‌ای که از iteratorها در لیست 13-22 استفاده می‌کند. اکثر برنامه‌نویسان Rust ترجیح می‌دهند از سبک iterator استفاده کنند. یادگیری آن در ابتدا کمی سخت‌تر است، اما وقتی با تطبیق‌دهنده‌های مختلف iterator و کارهایی که انجام می‌دهند آشنا شوید، استفاده از iteratorها می‌تواند آسان‌تر شود. به جای دست‌و‌پنجه نرم کردن با بخش‌های مختلف حلقه‌ها و ساخت وکتورهای جدید، کد بر هدف سطح بالا حلقه تمرکز می‌کند. این کار برخی از کدهای عمومی را پنهان می‌کند، بنابراین مفاهیمی که مختص این کد هستند، مانند شرط فیلتر کردن که هر عنصر در iterator باید پاس کند، واضح‌تر دیده می‌شوند.

اما آیا این دو پیاده‌سازی واقعاً معادل هستند؟ فرضیه شهودی این است که حلقه سطح پایین‌تر سریع‌تر خواهد بود. بیایید درباره عملکرد صحبت کنیم.