Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

توسعه قابلیت‌های کتابخانه با توسعه آزمون‌محور (TDD) یا همان (Test-Driven Development)

اکنون که منطق جست‌وجو را در فایل src/lib.rs و جدا از تابع main داریم، نوشتن تست برای عملکرد اصلی کد بسیار آسان‌تر شده است. می‌توانیم توابع را مستقیماً با آرگومان‌های مختلف فراخوانی کنیم و مقادیر بازگشتی را بررسی کنیم، بدون آن‌که نیاز باشد باینری خود را از طریق خط فرمان اجرا کنیم.

در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمون‌محور (TDD) به برنامه minigrep اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:

  1. نوشتن یک تست که شکست می‌خورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست می‌خورد.
  2. نوشتن یا تغییر کد به اندازه‌ای که تست جدید پاس شود.
  3. بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تست‌ها همچنان پاس می‌شوند.
  4. تکرار از مرحله ۱!

TDD تنها یکی از روش‌های نوشتن نرم‌افزار است، اما می‌تواند به طراحی بهتر کد کمک کند. نوشتن تست قبل از نوشتن کدی که تست را پاس می‌کند، کمک می‌کند تا پوشش تست بالا در طول فرآیند حفظ شود.

ما با استفاده از TDD پیاده‌سازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search اضافه خواهیم کرد.

نوشتن یک تست که شکست می‌خورد

در فایل src/lib.rs، یک ماژول tests با یک تابع تست اضافه می‌کنیم، همان‌طور که در [فصل ۱۱][ch11-anatomy] انجام دادیم. تابع تست، رفتاری را که از تابع search انتظار داریم مشخص می‌کند: این تابع یک query و متنی برای جست‌وجو دریافت می‌کند، و تنها خطوطی از متن را که شامل query هستند بازمی‌گرداند. لیست ۱۲-۱۵ این تست را نشان می‌دهد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[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 12-15: ایجاد یک تست شکست‌خورده برای تابع search به منظور پیاده‌سازی عملکرد مورد انتظار

این تست به دنبال رشته "duct" می‌گردد. متنی که در آن جستجو می‌کنیم شامل سه خط است که تنها یکی از آن‌ها شامل "duct" است (توجه داشته باشید که بک‌اسلش بعد از علامت نقل قول بازکننده به Rust می‌گوید که کاراکتر newline در ابتدای محتویات این literal رشته قرار ندهد). ما تأیید می‌کنیم که مقدار بازگردانده شده از تابع search تنها شامل خطی است که انتظار داریم.

هنوز قادر به اجرای این تست و مشاهده شکست آن نیستیم زیرا تست حتی کامپایل نمی‌شود: تابع search هنوز وجود ندارد! بر اساس اصول TDD، ما تنها به اندازه‌ای کد اضافه می‌کنیم که تست کامپایل و اجرا شود، با اضافه کردن یک تعریف از تابع search که همیشه یک بردار خالی بازمی‌گرداند، همانطور که در لیست ۱۲-۱۶ نشان داده شده است. سپس تست باید کامپایل و شکست بخورد زیرا یک بردار خالی با یک بردار شامل خط "safe, fast, productive." مطابقت ندارد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[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 12-16: تعریف حداقل کد برای تابع search تا تست ما کامپایل شود

حال بیایید بررسی کنیم که چرا نیاز داریم یک lifetime صریح با نام 'a در امضای تابع search تعریف کنیم و این lifetime را با آرگومان contents و مقدار بازگشتی استفاده کنیم. به یاد بیاورید که در فصل ۱۰، پارامترهای lifetime مشخص می‌کردند که lifetime کدام آرگومان با lifetime مقدار بازگشتی مرتبط است. در این‌جا، مشخص می‌کنیم که بردار بازگشتی باید شامل برش‌هایی از رشته باشد که به بخش‌هایی از آرگومان contents رفرنس می‌دهند (نه آرگومان query).

متوجه می‌شوید که ما نیاز داریم یک طول عمر صریح 'a در امضای تابع search تعریف کنیم و از آن طول عمر با آرگومان contents و مقدار بازگشتی استفاده کنیم. به یاد داشته باشید که در فصل ۱۰ توضیح دادیم که پارامترهای طول عمر مشخص می‌کنند کدام طول عمر آرگومان به طول عمر مقدار بازگشتی متصل است. در این مورد، ما مشخص می‌کنیم که بردار بازگشتی باید شامل برش‌های رشته‌ای باشد که به برش‌های آرگومان contents اشاره دارند (نه آرگومان query).

به عبارت دیگر، به Rust می‌گوییم داده‌ای که توسط تابع search بازگردانده می‌شود به اندازه داده‌ای که به تابع search در آرگومان contents منتقل می‌شود زنده خواهد بود. این مهم است! داده‌ای که توسط یک برش مرجع داده می‌شود باید معتبر باشد تا مرجع نیز معتبر باشد؛ اگر کامپایلر فرض کند که ما در حال ساختن برش‌های رشته‌ای از query هستیم به جای contents، بررسی‌های ایمنی را به اشتباه انجام خواهد داد.

اگر طول عمرها را فراموش کنیم و سعی کنیم این تابع را کامپایل کنیم، این خطا را دریافت خواهیم کرد:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

کامپایلر Rust نمی‌تواند به‌صورت خودکار تشخیص دهد که کدام‌یک از دو پارامتر باید به مقدار بازگشتی مرتبط باشد، بنابراین باید این موضوع را به‌صراحت به آن اعلام کنیم.
توجه داشته باشید که متن راهنمای خطا پیشنهاد می‌دهد که برای همه پارامترها و نوع بازگشتی، از یک پارامتر lifetime مشترک استفاده شود، که این پیشنهاد نادرست است!
از آن‌جا که contents پارامتری است که تمام متن ما را در بر دارد و ما قصد داریم بخش‌هایی از آن متن را که با جستجو مطابقت دارند بازگردانیم، می‌دانیم که فقط contents باید با مقدار بازگشتی از طریق نگارش lifetime مرتبط شود.

دیگر زبان‌های برنامه‌نویسی نیازی ندارند آرگومان‌ها را به مقادیر بازگشتی در امضا متصل کنید، اما این تمرین با گذشت زمان آسان‌تر می‌شود. ممکن است بخواهید این مثال را با مثال‌های موجود در بخش “اعتبارسنجی مراجع با طول عمر” از فصل ۱۰ مقایسه کنید.

نوشتن کدی برای پاس کردن تست

در حال حاضر، تست ما به دلیل اینکه همیشه یک بردار خالی بازمی‌گرداند، شکست می‌خورد. برای رفع این مشکل و پیاده‌سازی search، برنامه ما باید این مراحل را دنبال کند:

  1. تکرار از طریق هر خط از محتوای فایل.
  2. بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
  3. اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
  4. اگر نبود، کاری انجام ندهیم.
  5. لیست نتایجی که مطابقت دارند را بازگردانیم.

بیایید هر مرحله را یکی‌یکی اجرا کنیم، با تکرار از طریق خطوط شروع می‌کنیم.

تکرار از طریق خطوط با متد lines

Rust یک متد مفید برای مدیریت تکرار خط به خط در رشته‌ها ارائه می‌دهد که به طور مناسبی lines نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار می‌کند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[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 12-17: تکرار از طریق هر خط در contents

متد lines یک iterator برمی‌گرداند. ما در فصل ۱۳ عمیقاً در مورد iteratorها صحبت خواهیم کرد، اما به یاد داشته باشید که قبلاً این روش استفاده از یک iterator را در لیست ۳-۵ دیدید، جایی که از یک حلقه for با یک iterator برای اجرای کدی روی هر آیتم در یک مجموعه استفاده کردیم.

جستجو در هر خط برای کوئری

اکنون، بررسی خواهیم کرد که آیا خط فعلی شامل رشته کوئری ما هست یا نه. خوشبختانه، رشته‌ها یک متد مفید به نام contains دارند که این کار را برای ما انجام می‌دهد! یک فراخوانی به متد contains را در تابع search اضافه کنید، همانطور که در لیست ۱۲-۱۸ نشان داده شده است. توجه داشته باشید که این کد همچنان کامپایل نخواهد شد.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[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 12-18: اضافه کردن قابلیت بررسی اینکه آیا خط شامل رشته موجود در query هست یا نه

در حال حاضر، ما در حال ایجاد قابلیت‌های بیشتر هستیم. برای اینکه کد کامپایل شود، نیاز داریم مقداری را از بدنه تابع بازگردانیم همانطور که در امضای تابع اشاره کردیم.

ذخیره خطوط مطابق

برای تکمیل این تابع، نیاز داریم روشی برای ذخیره خطوط مطابق که می‌خواهیم بازگردانیم داشته باشیم. برای این کار، می‌توانیم یک بردار mutable قبل از حلقه for ایجاد کنیم و با استفاده از متد push یک خط را در بردار ذخیره کنیم. بعد از حلقه for، بردار را بازمی‌گردانیم، همانطور که در لیست ۱۲-۱۹ نشان داده شده است.

Filename: src/lib.rs
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 12-19: ذخیره خطوط مطابق برای بازگرداندن آن‌ها

اکنون تابع search باید فقط خطوطی را که شامل query هستند بازگرداند، و تست ما باید پاس شود. بیایید تست را اجرا کنیم:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

تست ما پاس شد، بنابراین می‌دانیم که کار می‌کند!

در این مرحله، می‌توانیم فرصت‌هایی برای بازسازی پیاده‌سازی تابع جستجو در نظر بگیریم و در عین حال تست‌ها را پاس نگه داریم تا همان قابلیت را حفظ کنیم. کد در تابع جستجو چندان بد نیست، اما از برخی ویژگی‌های مفید iteratorها استفاده نمی‌کند. ما در فصل ۱۳ به این مثال بازخواهیم گشت، جایی که iteratorها را با جزئیات بررسی می‌کنیم و به نحوه بهبود آن می‌پردازیم.

استفاده از تابع search در تابع run

اکنون که تابع search کار می‌کند و تست شده است، باید تابع search را از تابع run فراخوانی کنیم. ما باید مقدار config.query و contents که run از فایل می‌خواند را به تابع search بدهیم. سپس run هر خطی که از search برگردانده شده را چاپ خواهد کرد:

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)?;

    for line in search(&config.query, &contents) {
        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
}

#[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));
    }
}

ما هنوز از یک حلقه for برای بازگرداندن هر خط از search و چاپ آن استفاده می‌کنیم.

اکنون باید کل برنامه به‌درستی کار کند! بیایید آن را امتحان کنیم، ابتدا با واژه‌ای مانند frog که باید دقیقاً یک خط از شعر امیلی دیکینسون را بازگرداند.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

عالی! حالا بیایید کلمه‌ای را امتحان کنیم که چندین خط را مطابقت دهد، مثل body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

و در نهایت، مطمئن شویم که وقتی کلمه‌ای را جستجو می‌کنیم که در هیچ جای شعر وجود ندارد، مثل monomorphization، هیچ خطی دریافت نخواهیم کرد:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

عالی! ما نسخه کوچکی از یک ابزار کلاسیک ساختیم و چیزهای زیادی درباره نحوه ساختاردهی برنامه‌ها آموختیم. همچنین کمی درباره ورودی و خروجی فایل، طول عمر‌ها، تست کردن و تجزیه دستورات خط فرمان یاد گرفتیم.

برای تکمیل این پروژه، به طور مختصر نشان خواهیم داد که چگونه با متغیرهای محیطی کار کنیم و چگونه به خطای استاندارد (standard error) چاپ کنیم، که هر دو در هنگام نوشتن برنامه‌های خط فرمان مفید هستند.