توسعه قابلیتهای کتابخانه با توسعه آزمونمحور (TDD) یا همان (Test-Driven Development)
اکنون که منطق جستوجو را در فایل src/lib.rs و جدا از تابع main
داریم، نوشتن تست برای عملکرد اصلی کد بسیار آسانتر شده است.
میتوانیم توابع را مستقیماً با آرگومانهای مختلف فراخوانی کنیم و مقادیر بازگشتی را بررسی کنیم، بدون آنکه نیاز باشد باینری خود را از طریق خط فرمان اجرا کنیم.
در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمونمحور (TDD) به برنامه minigrep
اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:
- نوشتن یک تست که شکست میخورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست میخورد.
- نوشتن یا تغییر کد به اندازهای که تست جدید پاس شود.
- بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تستها همچنان پاس میشوند.
- تکرار از مرحله ۱!
TDD تنها یکی از روشهای نوشتن نرمافزار است، اما میتواند به طراحی بهتر کد کمک کند. نوشتن تست قبل از نوشتن کدی که تست را پاس میکند، کمک میکند تا پوشش تست بالا در طول فرآیند حفظ شود.
ما با استفاده از TDD پیادهسازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search
اضافه خواهیم کرد.
نوشتن یک تست که شکست میخورد
در فایل src/lib.rs، یک ماژول tests
با یک تابع تست اضافه میکنیم، همانطور که در [فصل ۱۱][ch11-anatomy] انجام دادیم.
تابع تست، رفتاری را که از تابع search
انتظار داریم مشخص میکند: این تابع یک query
و متنی برای جستوجو دریافت میکند، و تنها خطوطی از متن را که شامل query
هستند بازمیگرداند.
لیست ۱۲-۱۵ این تست را نشان میدهد.
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));
}
}
search
به منظور پیادهسازی عملکرد مورد انتظاراین تست به دنبال رشته "duct"
میگردد. متنی که در آن جستجو میکنیم شامل سه خط است که تنها یکی از آنها شامل "duct"
است (توجه داشته باشید که بکاسلش بعد از علامت نقل قول بازکننده به Rust میگوید که کاراکتر newline در ابتدای محتویات این literal رشته قرار ندهد). ما تأیید میکنیم که مقدار بازگردانده شده از تابع search
تنها شامل خطی است که انتظار داریم.
هنوز قادر به اجرای این تست و مشاهده شکست آن نیستیم زیرا تست حتی کامپایل نمیشود: تابع search
هنوز وجود ندارد! بر اساس اصول TDD، ما تنها به اندازهای کد اضافه میکنیم که تست کامپایل و اجرا شود، با اضافه کردن یک تعریف از تابع search
که همیشه یک بردار خالی بازمیگرداند، همانطور که در لیست ۱۲-۱۶ نشان داده شده است. سپس تست باید کامپایل و شکست بخورد زیرا یک بردار خالی با یک بردار شامل خط "safe, fast, productive."
مطابقت ندارد.
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));
}
}
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
، برنامه ما باید این مراحل را دنبال کند:
- تکرار از طریق هر خط از محتوای فایل.
- بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
- اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
- اگر نبود، کاری انجام ندهیم.
- لیست نتایجی که مطابقت دارند را بازگردانیم.
بیایید هر مرحله را یکییکی اجرا کنیم، با تکرار از طریق خطوط شروع میکنیم.
تکرار از طریق خطوط با متد lines
Rust یک متد مفید برای مدیریت تکرار خط به خط در رشتهها ارائه میدهد که به طور مناسبی lines
نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار میکند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.
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));
}
}
contents
متد lines
یک iterator برمیگرداند. ما در فصل ۱۳ عمیقاً در مورد iteratorها صحبت خواهیم کرد، اما به یاد داشته باشید که قبلاً این روش استفاده از یک iterator را در لیست ۳-۵ دیدید، جایی که از یک حلقه for
با یک iterator برای اجرای کدی روی هر آیتم در یک مجموعه استفاده کردیم.
جستجو در هر خط برای کوئری
اکنون، بررسی خواهیم کرد که آیا خط فعلی شامل رشته کوئری ما هست یا نه. خوشبختانه، رشتهها یک متد مفید به نام contains
دارند که این کار را برای ما انجام میدهد! یک فراخوانی به متد contains
را در تابع search
اضافه کنید، همانطور که در لیست ۱۲-۱۸ نشان داده شده است. توجه داشته باشید که این کد همچنان کامپایل نخواهد شد.
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));
}
}
query
هست یا نهدر حال حاضر، ما در حال ایجاد قابلیتهای بیشتر هستیم. برای اینکه کد کامپایل شود، نیاز داریم مقداری را از بدنه تابع بازگردانیم همانطور که در امضای تابع اشاره کردیم.
ذخیره خطوط مطابق
برای تکمیل این تابع، نیاز داریم روشی برای ذخیره خطوط مطابق که میخواهیم بازگردانیم داشته باشیم. برای این کار، میتوانیم یک بردار mutable قبل از حلقه for
ایجاد کنیم و با استفاده از متد push
یک خط را در بردار ذخیره کنیم. بعد از حلقه for
، بردار را بازمیگردانیم، همانطور که در لیست ۱۲-۱۹ نشان داده شده است.
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
باید فقط خطوطی را که شامل 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) چاپ کنیم، که هر دو در هنگام نوشتن برنامههای خط فرمان مفید هستند.