توسعه قابلیتهای کتابخانه با توسعه آزمونمحور (TDD) یا همان (Test-Driven Development)
اکنون که منطق را به src/lib.rs استخراج کردهایم و جمعآوری آرگومانها و مدیریت خطاها را در src/main.rs باقی گذاشتهایم، نوشتن تست برای قابلیتهای اصلی کد ما بسیار آسانتر شده است. میتوانیم مستقیماً توابع را با آرگومانهای مختلف فراخوانی کرده و مقادیر بازگشتی را بررسی کنیم، بدون اینکه نیاز باشد از باینری ما از خط فرمان استفاده کنیم.
در این بخش، منطق جستجو را با استفاده از فرآیند توسعه آزمونمحور (TDD) به برنامه minigrep
اضافه خواهیم کرد. مراحل این فرآیند به شرح زیر است:
- نوشتن یک تست که شکست میخورد و اجرای آن برای اطمینان از اینکه به دلیلی که انتظار داشتید شکست میخورد.
- نوشتن یا تغییر کد به اندازهای که تست جدید پاس شود.
- بازسازی کدی که به تازگی اضافه یا تغییر داده شده و اطمینان از اینکه تستها همچنان پاس میشوند.
- تکرار از مرحله ۱!
TDD تنها یکی از روشهای نوشتن نرمافزار است، اما میتواند به طراحی بهتر کد کمک کند. نوشتن تست قبل از نوشتن کدی که تست را پاس میکند، کمک میکند تا پوشش تست بالا در طول فرآیند حفظ شود.
ما با استفاده از TDD پیادهسازی قابلیت جستجوی رشته کوئری در محتوای فایل و تولید لیستی از خطوط مطابق با کوئری را توسعه خواهیم داد. این قابلیت را در تابعی به نام search
اضافه خواهیم کرد.
نوشتن یک تست که شکست میخورد
از آنجا که دیگر به آنها نیاز نداریم، بیایید عبارتهای println!
را از src/lib.rs و src/main.rs که برای بررسی رفتار برنامه استفاده میکردیم حذف کنیم. سپس، در src/lib.rs، یک ماژول tests
با یک تابع تست اضافه خواهیم کرد، همانطور که در فصل ۱۱ انجام دادیم. تابع تست، رفتاری که میخواهیم تابع search
داشته باشد را مشخص میکند: این تابع یک کوئری و متن برای جستجو دریافت میکند و تنها خطوطی از متن که شامل کوئری هستند را بازمیگرداند. لیست ۱۲-۱۵ این تست را نشان میدهد که هنوز کامپایل نخواهد شد.
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(())
}
#[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."
مطابقت ندارد.
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> {
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
تا تست ما کامپایل شودمتوجه میشوید که ما نیاز داریم یک طول عمر صریح '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:28:51
|
28 | 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
|
28 | 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 به هیچ وجه نمیتواند بداند کدام یک از دو آرگومان مورد نیاز است، بنابراین ما باید به صورت صریح به آن بگوییم. از آنجایی که contents
آرگومانی است که شامل تمام متن ما است و ما میخواهیم قسمتهایی از آن متن که مطابقت دارند را بازگردانیم، میدانیم که contents
آرگومانی است که باید با استفاده از نحو طول عمر به مقدار بازگشتی متصل شود.
دیگر زبانهای برنامهنویسی نیازی ندارند آرگومانها را به مقادیر بازگشتی در امضا متصل کنید، اما این تمرین با گذشت زمان آسانتر میشود. ممکن است بخواهید این مثال را با مثالهای موجود در بخش “اعتبارسنجی مراجع با طول عمر” از فصل ۱۰ مقایسه کنید.
اکنون بیایید تست را اجرا کنیم:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
left: ["safe, fast, productive."]
right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::one_result
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
عالی است، تست دقیقا همانطور که انتظار داشتیم شکست میخورد. بیایید تست را پاس کنیم!
نوشتن کدی برای پاس کردن تست
در حال حاضر، تست ما به دلیل اینکه همیشه یک بردار خالی بازمیگرداند، شکست میخورد. برای رفع این مشکل و پیادهسازی search
، برنامه ما باید این مراحل را دنبال کند:
- تکرار از طریق هر خط از محتوای فایل.
- بررسی اینکه آیا خط شامل رشته کوئری ما هست یا نه.
- اگر خط شامل کوئری بود، آن را به لیست مقادیر بازگشتی اضافه کنیم.
- اگر نبود، کاری انجام ندهیم.
- لیست نتایجی که مطابقت دارند را بازگردانیم.
بیایید هر مرحله را یکییکی اجرا کنیم، با تکرار از طریق خطوط شروع میکنیم.
تکرار از طریق خطوط با متد lines
Rust یک متد مفید برای مدیریت تکرار خط به خط در رشتهها ارائه میدهد که به طور مناسبی lines
نامیده شده است و همانطور که در لیست ۱۲-۱۷ نشان داده شده کار میکند. توجه داشته باشید که این کد هنوز کامپایل نخواهد شد.
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> {
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
اضافه کنید، همانطور که در لیست ۱۲-۱۸ نشان داده شده است. توجه داشته باشید که این کد همچنان کامپایل نخواهد شد.
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> {
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
، بردار را بازمیگردانیم، همانطور که در لیست ۱۲-۱۹ نشان داده شده است.
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));
}
}
اکنون تابع 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) چاپ کنیم، که هر دو در هنگام نوشتن برنامههای خط فرمان مفید هستند.