بازسازی برای بهبود ماژولار بودن و مدیریت خطاها
برای بهبود برنامه خود، چهار مشکلی که به ساختار برنامه و نحوه مدیریت خطاهای بالقوه مربوط میشوند را رفع خواهیم کرد.
-
تکمسئولیتی کردن تابع
main
: در حال حاضر، تابعmain
دو وظیفه را انجام میدهد: تجزیه آرگومانها و خواندن فایلها. با رشد برنامه، تعداد وظایف جداگانهای که تابعmain
باید مدیریت کند افزایش خواهد یافت. هرچه یک تابع مسئولیتهای بیشتری داشته باشد، درک آن سختتر میشود، تست کردن آن پیچیدهتر خواهد شد و تغییر آن بدون آسیب به بخشهای دیگر دشوارتر میشود. بهتر است قابلیتها را جدا کنیم تا هر تابع فقط مسئول یک وظیفه باشد. -
گروهبندی متغیرهای پیکربندی:
متغیرهایی مانند
query
وfile_path
متغیرهای پیکربندی برای برنامه ما هستند، در حالی که متغیرهایی مانندcontents
برای اجرای منطق برنامه استفاده میشوند. هرچه تابعmain
طولانیتر شود، به متغیرهای بیشتری نیاز خواهد داشت که وارد دامنه شوند؛ و هرچه تعداد متغیرها بیشتر شود، پیگیری هدف هر متغیر دشوارتر خواهد شد. بهتر است متغیرهای پیکربندی را در یک ساختار گروهبندی کنیم تا هدف آنها واضحتر باشد. -
بهبود پیامهای خطا:
هنگام شکست در خواندن فایل، از
expect
برای چاپ پیام خطا استفاده کردهایم، اما پیام خطا فقطShould have been able to read the file
را چاپ میکند. خواندن یک فایل میتواند به دلایل مختلفی شکست بخورد: مثلاً ممکن است فایل وجود نداشته باشد یا ممکن است اجازه دسترسی به آن را نداشته باشیم. در حال حاضر، بدون توجه به شرایط، همان پیام خطا برای همه چیز چاپ میشود که اطلاعاتی به کاربر نمیدهد. -
یکپارچهسازی مدیریت خطاها:
اگر کاربر برنامه ما را بدون مشخص کردن تعداد کافی آرگومان اجرا کند، یک خطای
index out of bounds
از Rust دریافت میکنند که به وضوح مشکل را توضیح نمیدهد. بهتر است تمام کد مدیریت خطاها در یک مکان قرار گیرد تا نگهداریکنندگان آینده تنها یک مکان را برای بررسی تغییرات در منطق مدیریت خطا داشته باشند. این کار همچنین اطمینان حاصل میکند که پیامهایی که چاپ میشوند برای کاربران نهایی معنادار هستند.
جداسازی وظایف برای پروژههای دودویی
مشکل تخصیص مسئولیتهای چندگانه به تابع main
در بسیاری از پروژههای دودویی رایج است. به همین دلیل، جامعه Rust دستورالعملهایی برای تقسیم دغدغههای جداگانه یک برنامه دودویی ارائه داده است. این فرایند شامل مراحل زیر است:
- برنامه خود را به فایلهای _main.rs_ و _lib.rs_ تقسیم کرده و منطق برنامه را به _lib.rs_ منتقل کنید.
- تا زمانی که منطق تجزیه آرگومانهای خط فرمان کوچک است، میتواند در _main.rs_ باقی بماند.
- وقتی منطق تجزیه آرگومانها پیچیده شد، آن را از _main.rs_ جدا کرده و به _lib.rs_ منتقل کنید.
وظایفی که پس از این فرایند در تابع main
باقی میمانند باید محدود به موارد زیر باشند:
- فراخوانی منطق تجزیه آرگومانهای خط فرمان با مقادیر آرگومانها
- تنظیم هرگونه پیکربندی دیگر
- فراخوانی یک تابع `run` در _lib.rs_
- مدیریت خطاها در صورت بازگرداندن خطا توسط `run`
این الگو درباره جداسازی وظایف است: main.rs اجرای برنامه را مدیریت میکند و lib.rs تمام منطق مربوط به کار مورد نظر را مدیریت میکند. از آنجا که نمیتوان تابع main
را مستقیماً تست کرد، این ساختار به شما اجازه میدهد تمام منطق برنامه خود را با انتقال آن به توابع در lib.rs تست کنید. کدی که در main.rs باقی میماند به اندازه کافی کوچک خواهد بود که با خواندن آن از صحت آن اطمینان حاصل کنید. بیایید برنامه خود را با پیروی از این فرایند بازسازی کنیم.
استخراج تجزیهکننده آرگومانها
ما قابلیت تجزیه آرگومانها را به یک تابع جداگانه استخراج میکنیم که تابع main
آن را فراخوانی خواهد کرد تا برای انتقال منطق تجزیه آرگومان خط فرمان به فایل src/lib.rs آماده شویم. لیست ۱۲-۵ شروع جدید تابع main
را نشان میدهد که یک تابع جدید به نام parse_config
را فراخوانی میکند، که در حال حاضر در src/main.rs تعریف خواهیم کرد.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --snip--
println!("Searching for {query}");
println!("In file {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
parse_config
از main
ما همچنان آرگومانهای خط فرمان را به یک بردار جمعآوری میکنیم، اما به جای اینکه مقدار آرگومان در اندیس (index)۱ را به متغیر query
و مقدار آرگومان در اندیس (index)۲ را به متغیر file_path
در تابع main
اختصاص دهیم، کل بردار را به تابع parse_config
ارسال میکنیم. تابع parse_config
سپس منطق مشخص میکند که کدام آرگومان در کدام متغیر قرار میگیرد و مقادیر را به تابع main
بازمیگرداند. ما همچنان متغیرهای query
و file_path
را در main
ایجاد میکنیم، اما main
دیگر مسئول تعیین ارتباط آرگومانهای خط فرمان و متغیرها نیست.
این تغییر ممکن است برای برنامه کوچک ما زیادهروی به نظر برسد، اما ما در حال بازسازی کد به صورت گامهای کوچک و تدریجی هستیم. پس از اعمال این تغییر، دوباره برنامه را اجرا کنید تا اطمینان حاصل کنید که تجزیه آرگومان همچنان کار میکند. بررسی مداوم پیشرفت کد کمک میکند تا در صورت بروز مشکلات، علت آنها را سریعتر شناسایی کنید.
گروهبندی مقادیر پیکربندی
میتوانیم یک گام کوچک دیگر برای بهبود بیشتر تابع parse_config
برداریم. در حال حاضر، ما یک tuple بازمیگردانیم، اما بلافاصله آن tuple را به قسمتهای جداگانه تقسیم میکنیم. این نشانهای است که شاید هنوز انتزاع درستی نداریم.
نشانه دیگری که نشان میدهد جا برای بهبود وجود دارد، قسمت config
در parse_config
است، که نشان میدهد دو مقداری که بازمیگردانیم به هم مرتبط هستند و هر دو بخشی از یک مقدار پیکربندی هستند. ما در حال حاضر این معنا را در ساختار دادهها به جز با گروهبندی دو مقدار در یک tuple منتقل نمیکنیم؛ در عوض، این دو مقدار را در یک struct قرار میدهیم و به هر یک از فیلدهای struct نامی معنادار میدهیم. انجام این کار درک نحوه ارتباط مقادیر مختلف و هدف آنها را برای نگهداریکنندگان آینده این کد آسانتر میکند.
لیست ۱۲-۶ بهبودهای تابع parse_config
را نشان میدهد.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
// --snip--
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
parse_config
برای بازگرداندن یک نمونه از struct Config
ما یک ساختار جدید به نام Config
تعریف کردهایم که دارای فیلدهایی با نامهای query
و file_path
است. امضای تابع parse_config
اکنون نشان میدهد که این تابع یک مقدار Config
را بازمیگرداند. در بدنه تابع parse_config
، جایی که قبلاً اسلایسهای رشتهای را که به مقادیر String
در args
اشاره میکردند بازمیگرداندیم، اکنون Config
را طوری تعریف میکنیم که دارای مقادیر String
متعلق به خود باشد.
متغیر args
در تابع main
مالک مقادیر آرگومان است و فقط به تابع parse_config
اجازه قرض گرفتن آنها را میدهد، به این معنی که اگر Config
بخواهد مالک مقادیر در args
شود، قوانین قرضگیری Rust را نقض میکنیم.
چندین روش برای مدیریت دادههای String
وجود دارد؛ سادهترین و شاید ناکارآمدترین روش، فراخوانی متد clone
روی مقادیر است. این کار یک کپی کامل از دادهها برای نمونه Config
ایجاد میکند که مالک آن است. این روش زمان و حافظه بیشتری نسبت به ذخیره یک مرجع به دادهها نیاز دارد. با این حال، کپی کردن دادهها باعث میشود که کد ما بسیار ساده شود زیرا نیازی به مدیریت طول عمر مراجع نداریم؛ در این شرایط، از دست دادن کمی کارایی برای دستیابی به سادگی ارزشمند است.
هزینهها و مزایای استفاده از clone
در بین بسیاری از برنامهنویسان Rust، تمایلی به استفاده از clone
برای رفع مشکلات مالکیت به دلیل هزینه اجرای آن وجود دارد. در فصل ۱۳، یاد خواهید گرفت که چگونه در این نوع موقعیتها از روشهای کارآمدتر استفاده کنید. اما در حال حاضر، کپی کردن چند رشته برای ادامه پیشرفت اشکالی ندارد زیرا این کپیها فقط یکبار انجام میشوند و مسیر فایل و رشته جستجوی شما بسیار کوچک هستند. بهتر است یک برنامه کارا که کمی ناکارآمد است داشته باشید تا اینکه در اولین تلاش خود برای نوشتن کد، بهینهسازی بیش از حد انجام دهید. با تجربه بیشتر در Rust، شروع با راهحل کارآمدتر آسانتر خواهد بود، اما در حال حاضر استفاده از clone
کاملاً قابل قبول است.
ما تابع main
را بهروزرسانی کردیم تا نمونهای از Config
که توسط parse_config
بازگردانده میشود را در یک متغیر به نام config
قرار دهد، و کدی که قبلاً از متغیرهای جداگانه query
و file_path
استفاده میکرد، اکنون از فیلدهای موجود در struct Config
استفاده میکند.
اکنون کد ما بهوضوح نشان میدهد که query
و file_path
به هم مرتبط هستند و هدف آنها تنظیم نحوه کار برنامه است. هر کدی که از این مقادیر استفاده میکند میداند که باید آنها را در نمونه config
در فیلدهایی که نام آنها برای هدفشان انتخاب شده است، پیدا کند.
ایجاد سازنده برای Config
تا اینجا، منطق مسئول تجزیه آرگومانهای خط فرمان را از main
استخراج کرده و در تابع parse_config
قرار دادهایم. این کار به ما کمک کرد ببینیم که مقادیر query
و file_path
به هم مرتبط هستند و این رابطه باید در کد ما منتقل شود. سپس یک struct به نام Config
اضافه کردیم تا هدف مشترک query
و file_path
را نامگذاری کنیم و بتوانیم نام مقادیر را بهعنوان فیلدهای struct از تابع parse_config
بازگردانیم.
حالا که هدف تابع parse_config
ایجاد یک نمونه از Config
است، میتوانیم parse_config
را از یک تابع معمولی به یک تابع با نام new
تغییر دهیم که به struct Config
مرتبط است. این تغییر کد را بهصورت idiomaticتر میکند. ما میتوانیم نمونههایی از انواع موجود در کتابخانه استاندارد، مانند String
، را با فراخوانی String::new
ایجاد کنیم. به همین ترتیب، با تغییر parse_config
به تابع new
مرتبط با Config
، میتوانیم نمونههایی از Config
را با فراخوانی Config::new
ایجاد کنیم. لیست ۱۲-۷ تغییرات لازم را نشان میدهد.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
// --snip--
}
// --snip--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
parse_config
به Config::new
ما تابع main
را که در آن parse_config
را فراخوانی میکردیم بهروزرسانی کردهایم تا بهجای آن Config::new
را فراخوانی کند. نام parse_config
را به new
تغییر داده و آن را در یک بلوک impl
قرار دادهایم که تابع new
را به Config
مرتبط میکند. کد را دوباره کامپایل کنید تا مطمئن شوید که کار میکند.
رفع مشکلات مدیریت خطا
حالا روی رفع مشکلات مدیریت خطا کار میکنیم. به خاطر بیاورید که تلاش برای دسترسی به مقادیر موجود در بردار args
در اندیس (index)۱ یا ۲ باعث میشود برنامه در صورت داشتن کمتر از سه آیتم، دچار وحشت شود. برنامه را بدون هیچ آرگومانی اجرا کنید؛ این حالت به شکل زیر خواهد بود:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
خط index out of bounds: the len is 1 but the index is 1
یک پیام خطا است که برای برنامهنویسان در نظر گرفته شده است. این پیام به کاربران نهایی کمکی نمیکند تا بفهمند باید چه کار کنند. حالا این مشکل را رفع میکنیم.
بهبود پیام خطا
در لیست ۱۲-۸، یک بررسی در تابع new
اضافه میکنیم که بررسی میکند آیا آرایه بهاندازه کافی طولانی است تا بتوان به اندیسهای ۱ و ۲ دسترسی داشت. اگر طول آرایه کافی نباشد، برنامه دچار وحشت میشود و یک پیام خطای بهتر نمایش میدهد.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
این کد شبیه به تابع Guess::new
که در لیست ۹-۱۳ نوشتیم است، جایی که وقتی آرگومان value
خارج از محدوده مقادیر معتبر بود، panic!
فراخوانی کردیم. به جای بررسی محدوده مقادیر، در اینجا بررسی میکنیم که طول args
حداقل برابر با 3
باشد و بقیه تابع میتواند با فرض اینکه این شرط برقرار شده است، عمل کند. اگر args
کمتر از سه آیتم داشته باشد، این شرط true
خواهد بود و ما ماکرو panic!
را برای خاتمه برنامه بلافاصله فراخوانی میکنیم.
با این چند خط اضافی در new
، بیایید دوباره برنامه را بدون هیچ آرگومانی اجرا کنیم تا ببینیم اکنون پیام خطا چگونه است:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
این خروجی بهتر است: اکنون یک پیام خطای منطقی داریم. با این حال، هنوز اطلاعات اضافی داریم که نمیخواهیم به کاربران خود ارائه دهیم. شاید تکنیکی که در لیست ۹-۱۳ استفاده کردیم بهترین گزینه برای اینجا نباشد: یک فراخوانی به panic!
برای مشکل برنامهنویسی مناسبتر است تا یک مشکل استفاده، همانطور که در فصل ۹ بحث شد. در عوض، از تکنیک دیگری که در فصل ۹ یاد گرفتید استفاده میکنیم—بازگرداندن یک Result
که نشاندهنده موفقیت یا خطا است.
بازگرداندن یک Result
به جای فراخوانی panic!
ما میتوانیم به جای آن، یک مقدار Result
بازگردانیم که در صورت موفقیت شامل یک نمونه از Config
باشد و در صورت خطا مشکل را توصیف کند. همچنین قصد داریم نام تابع را از new
به build
تغییر دهیم زیرا بسیاری از برنامهنویسان انتظار دارند که توابع new
هرگز شکست نخورند. وقتی Config::build
با main
ارتباط برقرار میکند، میتوانیم از نوع Result
برای اعلام مشکل استفاده کنیم. سپس میتوانیم main
را تغییر دهیم تا یک واریانت Err
را به یک پیام خطای عملیتر برای کاربران خود تبدیل کنیم، بدون متنهای اضافی مربوط به thread 'main'
و RUST_BACKTRACE
که یک فراخوانی به panic!
ایجاد میکند.
لیست ۱۲-۹ تغییراتی را که باید در مقدار بازگشتی تابع که اکنون آن را Config::build
مینامیم و بدنه تابع برای بازگرداندن یک Result
ایجاد کنیم، نشان میدهد. توجه داشته باشید که این کد تا زمانی که main
را نیز بهروزرسانی نکنیم کامپایل نمیشود، که این کار را در لیست بعدی انجام خواهیم داد.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
Result
از Config::build
تابع build
و بازگشت مقدار Result
تابع build
ما اکنون یک مقدار Result
را بازمیگرداند که در صورت موفقیت شامل یک نمونه از Config
و در صورت خطا یک مقدار رشتهای ثابت (string literal
) است. مقادیر خطای ما همیشه رشتههای ثابت با طول عمر 'static
خواهند بود.
ما دو تغییر در بدنه تابع ایجاد کردهایم: به جای فراخوانی panic!
زمانی که کاربر آرگومانهای کافی ارائه نمیدهد، اکنون یک مقدار Err
بازمیگردانیم و مقدار بازگشتی Config
را در یک Ok
قرار دادهایم. این تغییرات باعث میشوند تابع با امضای نوع جدید خود سازگار باشد.
بازگرداندن مقدار Err
از Config::build
به تابع main
اجازه میدهد که مقدار Result
بازگشتی از تابع build
را مدیریت کرده و در صورت بروز خطا، فرآیند را به شکلی تمیزتر خاتمه دهد.
فراخوانی Config::build
و مدیریت خطاها
برای مدیریت حالت خطا و چاپ یک پیام دوستانه برای کاربر، باید تابع main
را بهروزرسانی کنیم تا مقدار Result
بازگرداندهشده توسط Config::build
را مدیریت کند. این کار در لیست ۱۲-۱۰ نشان داده شده است. همچنین مسئولیت خاتمه دادن ابزار خط فرمان با کد خطای غیر صفر را از panic!
گرفته و به صورت دستی پیادهسازی خواهیم کرد. کد خروجی غیر صفر به عنوان یک قرارداد برای اعلام وضعیت خطا به فرآیندی که برنامه ما را فراخوانده است، استفاده میشود.
use std::env;
use std::fs;
use std::process;
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);
});
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
Config
در این لیستینگ، ما از متدی استفاده کردهایم که هنوز جزئیات آن را بهطور کامل پوشش ندادهایم: unwrap_or_else
. این متد که در استاندارد کتابخانه Rust برای Result<T, E>
تعریف شده است، به ما امکان میدهد مدیریت خطاهای سفارشی و بدون استفاده از panic!
را تعریف کنیم. اگر مقدار Result
از نوع Ok
باشد، رفتار این متد مشابه unwrap
است: مقدار داخلی که Ok
در خود قرار داده را بازمیگرداند. با این حال، اگر مقدار از نوع Err
باشد، این متد کدی را که در closure تعریف کردهایم اجرا میکند. Closure یک تابع ناشناس است که آن را تعریف کرده و بهعنوان آرگومان به unwrap_or_else
ارسال میکنیم.
ما closures را به تفصیل در فصل ۱۳ توضیح خواهیم داد. فعلاً کافی است بدانید که unwrap_or_else
مقدار داخلی Err
را به closure میدهد. در اینجا، مقدار استاتیک "not enough arguments"
که در لیستینگ 12-9 اضافه کردیم، به closure ارسال شده و به آرگومان err
تخصیص داده میشود، که بین خط عمودیها قرار دارد. کد درون closure سپس میتواند از مقدار err
استفاده کند.
ما همچنین یک خط جدید use
اضافه کردهایم تا process
را از کتابخانه استاندارد به محدوده بیاوریم. کدی که در حالت خطا اجرا میشود تنها شامل دو خط است: ابتدا مقدار err
را چاپ میکنیم و سپس process::exit
را فراخوانی میکنیم. تابع process::exit
بلافاصله برنامه را متوقف کرده و عددی که بهعنوان کد وضعیت خروج ارسال شده است را بازمیگرداند. این روش شبیه مدیریت مبتنی بر panic!
است که در لیستینگ 12-8 استفاده کردیم، اما دیگر خروجی اضافی تولید نمیشود. حالا آن را آزمایش کنیم:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
عالی! این خروجی برای کاربران ما بسیار دوستانهتر است.
جداسازی منطق از main
اکنون که بازآرایی برای تجزیه تنظیمات را به پایان رساندهایم، بیایید به منطق برنامه بپردازیم. همانطور که در «تفکیک نگرانیها برای پروژههای باینری» بیان کردیم، تابعی به نام run
استخراج خواهیم کرد که تمام منطقی که در حال حاضر در تابع main
وجود دارد و مربوط به تنظیمات یا مدیریت خطا نیست را نگه میدارد. هنگامی که کار ما تمام شود، main
مختصر و آسان برای بررسی خواهد بود و میتوانیم تستهایی برای سایر منطقها بنویسیم.
لیست ۱۲-۱۱ تابع استخراجشده run
را نشان میدهد. فعلاً فقط بهبود کوچکی انجام میدهیم که تابع را استخراج کنیم. همچنان تابع را در فایل src/main.rs تعریف میکنیم.
use std::env;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
// --snip--
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
run
از main
با این تغییرات، main
اکنون تابع run
را فراخوانی میکند و مسئولیت اجرای منطق اصلی برنامه را به آن واگذار میکند. این جداسازی باعث میشود تابع main
سادهتر شود و ما بتوانیم تستهای دقیقی برای بخشهای مختلف کد بنویسیم. این روش به بهبود قابلیت نگهداری و خوانایی کد کمک شایانی میکند.
بازگرداندن خطاها از تابع run
اکنون که منطق باقیمانده برنامه را در تابع run
جدا کردهایم، میتوانیم مانند Config::build
در لیستینگ 12-9، مدیریت خطا را بهبود بخشیم. به جای اجازه دادن به برنامه برای اجرای panic
با فراخوانی expect
، تابع run
در صورت بروز مشکل یک Result<T, E>
بازمیگرداند. این رویکرد به ما امکان میدهد منطق مرتبط با مدیریت خطا را به صورت کاربرپسندانهای در تابع main
متمرکز کنیم. تغییرات لازم برای امضا و بدنه تابع run
در لیستینگ 12-12 نشان داده شده است:
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --snip--
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);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
run
برای بازگرداندن Result
تغییرات مهم
-
تغییر نوع بازگشتی:
نوع بازگشتی تابع
run
بهResult<(), Box<dyn Error>>
تغییر داده شده است. این تابع قبلاً نوع واحد (()
) را بازمیگرداند، که همچنان برای حالت موفقیت حفظ شده است. برای نوع خطا از یک شیء صفات به نامBox<dyn Error>
استفاده کردهایم (و با استفاده ازuse
،std::error::Error
را به محدوده آوردهایم). در فصل 18 بیشتر درباره شیء صفات صحبت خواهیم کرد. فعلاً کافی است بدانید کهBox<dyn Error>
به این معنا است که تابع میتواند نوعی از مقدار را که صفتError
را پیادهسازی کرده بازگرداند، بدون اینکه نوع خاصی را مشخص کند. کلمه کلیدیdyn
به معنای دینامیک است. -
حذف
expect
و استفاده از عملگر?
: به جای استفاده ازpanic!
در صورت بروز خطا، عملگر?
مقدار خطا را از تابع جاری بازمیگرداند تا فراخوانیکننده بتواند آن را مدیریت کند. -
بازگرداندن مقدار
Ok
در حالت موفقیت: تابعrun
اکنون در حالت موفقیت مقدارOk
را بازمیگرداند. ما نوع موفقیت تابع را به عنوان()
در امضا تعریف کردهایم، که به این معنا است که باید مقدار نوع واحد را در مقدارOk
قرار دهیم. نحوOk(())
ممکن است در ابتدا کمی عجیب به نظر برسد، اما استفاده از()
به این صورت روش استاندارد برای نشان دادن این است که تابعrun
تنها برای تأثیرات جانبی فراخوانی شده و مقداری بازنمیگرداند که به آن نیاز داشته باشیم.
بررسی کد
اجرای این کد باعث میشود که کد کامپایل شود اما یک هشدار نمایش دهد:
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Rust به ما یادآوری میکند که کد ما مقدار Result
را نادیده گرفته است و این مقدار ممکن است نشاندهنده بروز خطا باشد. اما ما بررسی نمیکنیم که آیا خطایی رخ داده است یا خیر، و کامپایلر به ما یادآوری میکند که احتمالاً نیاز به مدیریت خطا در این بخش داریم. اکنون این مشکل را اصلاح خواهیم کرد.
مدیریت خطاهای بازگرداندهشده از run
در main
ما خطاها را بررسی کرده و با استفاده از تکنیکی مشابه آنچه در Config::build
در لیست ۱۲-۱۰ استفاده کردیم مدیریت میکنیم، اما با یک تفاوت کوچک:
Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
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();
Ok(Config { query, file_path })
}
}
ما به جای unwrap_or_else
از if let
استفاده میکنیم تا بررسی کنیم آیا run
یک مقدار Err
بازمیگرداند یا خیر و در صورت وقوع، process::exit(1)
را فراخوانی کنیم. تابع run
مقداری بازنمیگرداند که بخواهیم به همان شیوهای که Config::build
نمونه Config
را بازمیگرداند آن را unwrap
کنیم. از آنجایی که run
در صورت موفقیت مقدار ()
بازمیگرداند، ما فقط به شناسایی یک خطا اهمیت میدهیم، بنابراین نیازی به unwrap_or_else
برای بازگرداندن مقدار آن نداریم، که تنها ()
خواهد بود.
بدنههای if let
و unwrap_or_else
در هر دو حالت یکسان هستند: ما خطا را چاپ کرده و خارج میشویم.
تقسیم کد به یک کتابخانه
پروژه minigrep
ما تا اینجا خوب پیش میرود! اکنون کد فایل src/main.rs را تقسیم کرده و برخی از کد را به فایل src/lib.rs منتقل میکنیم. به این ترتیب، میتوانیم کد را تست کنیم و فایل src/main.rs مسئولیتهای کمتری داشته باشد.
بیایید تمام کدی که در تابع main
نیست از src/main.rs به src/lib.rs منتقل کنیم:
- تعریف تابع `run`
- دستورات `use` مرتبط
- تعریف `Config`
- تعریف تابع `Config::build`
محتویات فایل src/lib.rs باید امضاهایی که در لیست ۱۲-۱۳ آمده است را داشته باشد (بدنه توابع برای اختصار حذف شده است). توجه داشته باشید که این کد تا زمانی که src/main.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> {
// --snip--
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>> {
// --snip--
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
Config
و run
به src/lib.rsما به طور گسترده از کلمه کلیدی pub
استفاده کردهایم: در Config
، فیلدهای آن، متد build
و همچنین تابع run
. اکنون یک crate کتابخانهای داریم که یک API عمومی دارد و میتوانیم آن را تست کنیم!
حالا باید کدی که به src/lib.rs منتقل کردهایم را به محدوده crate باینری در src/main.rs بیاوریم، همانطور که در لیست ۱۲-۱۴ نشان داده شده است.
use std::env;
use std::process;
use minigrep::Config;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = minigrep::run(config) {
// --snip--
println!("Application error: {e}");
process::exit(1);
}
}
minigrep
در src/main.rsما خط use minigrep::Config
را اضافه کردهایم تا نوع Config
را از crate کتابخانهای به محدوده crate باینری بیاوریم، و تابع run
را با پیشوند نام crate فراخوانی کردهایم. اکنون همه قابلیتها باید متصل شوند و کار کنند. برنامه را با cargo run
اجرا کنید و مطمئن شوید که همه چیز به درستی کار میکند.
وای! این یک کار سخت بود، اما ما خودمان را برای موفقیت در آینده آماده کردیم. اکنون مدیریت خطاها بسیار آسانتر شده است و کد ما ماژولارتر شده است. از اینجا به بعد تقریباً تمام کارهای ما در فایل src/lib.rs انجام خواهد شد.
بیایید از این ماژولاریت جدید برای انجام کاری استفاده کنیم که با کد قبلی دشوار بود اما با کد جدید آسان است: نوشتن چند تست!