Refactoring to Improve Modularity and Error Handling
To improve our program, we’ll fix four problems that have to do with the
program’s structure and how it’s handling potential errors. First, our main
function now performs two tasks: it parses arguments and reads files. As our
program grows, the number of separate tasks the main
function handles will
increase. As a function gains responsibilities, it becomes more difficult to
reason about, harder to test, and harder to change without breaking one of its
parts. It’s best to separate functionality so each function is responsible for
one task.
This issue also ties into the second problem: although query
and file_path
are configuration variables to our program, variables like contents
are used
to perform the program’s logic. The longer main
becomes, the more variables
we’ll need to bring into scope; the more variables we have in scope, the harder
it will be to keep track of the purpose of each. It’s best to group the
configuration variables into one structure to make their purpose clear.
The third problem is that we’ve used expect
to print an error message when
reading the file fails, but the error message just prints Should have been able to read the file
. Reading a file can fail in a number of ways: for
example, the file could be missing, or we might not have permission to open it.
Right now, regardless of the situation, we’d print the same error message for
everything, which wouldn’t give the user any information!
Fourth, we use expect
to handle an error, and if the user runs our program
without specifying enough arguments, they’ll get an index out of bounds
error
from Rust that doesn’t clearly explain the problem. It would be best if all the
error-handling code were in one place so future maintainers had only one place
to consult the code if the error-handling logic needed to change. Having all the
error-handling code in one place will also ensure that we’re printing messages
that will be meaningful to our end users.
Let’s address these four problems by refactoring our project.
Separation of Concerns for Binary Projects
The organizational problem of allocating responsibility for multiple tasks to
the main
function is common to many binary projects. As a result, the Rust
community has developed guidelines for splitting the separate concerns of a
binary program when main
starts getting large. This process has the following
steps:
- Split your program into a main.rs file and a lib.rs file and move your program’s logic to lib.rs.
- As long as your command line parsing logic is small, it can remain in main.rs.
- When the command line parsing logic starts getting complicated, extract it from main.rs and move it to lib.rs.
The responsibilities that remain in the main
function after this process
should be limited to the following:
- Calling the command line parsing logic with the argument values
- Setting up any other configuration
- Calling a
run
function in lib.rs - Handling the error if
run
returns an error
This pattern is about separating concerns: main.rs handles running the
program and lib.rs handles all the logic of the task at hand. Because you
can’t test the main
function directly, this structure lets you test all of
your program’s logic by moving it into functions in lib.rs. The code that
remains in main.rs will be small enough to verify its correctness by reading
it. Let’s rework our program by following this process.
Extracting the Argument Parser
We’ll extract the functionality for parsing arguments into a function that
main
will call to prepare for moving the command line parsing logic to
src/lib.rs. Listing 12-5 shows the new start of main
that calls a new
function parse_config
, which we’ll define in src/main.rs for the moment.
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
عالی! این خروجی برای کاربران ما بسیار دوستانهتر است.
Extracting Logic from main
Now that we’ve finished refactoring the configuration parsing, let’s turn to
the program’s logic. As we stated in “Separation of Concerns for Binary
Projects”, we’ll
extract a function named run
that will hold all the logic currently in the
main
function that isn’t involved with setting up configuration or handling
errors. When we’re done, main
will be concise and easy to verify by
inspection, and we’ll be able to write tests for all the other logic.
Listing 12-11 shows the extracted run
function. For now, we’re just making
the small, incremental improvement of extracting the function. We’re still
defining the function in 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
در هر دو حالت یکسان هستند: ما خطا را چاپ کرده و خارج میشویم.
Splitting Code into a Library Crate
Our minigrep
project is looking good so far! Now we’ll split the
src/main.rs file and put some code into the src/lib.rs file. That way, we
can test the code and have a src/main.rs file with fewer responsibilities.
Let’s move all the code that isn’t in the main
function from src/main.rs to
src/lib.rs:
- The
run
function definition - The relevant
use
statements - The definition of
Config
- The
Config::build
function definition
The contents of src/lib.rs should have the signatures shown in Listing 12-13 (we’ve omitted the bodies of the functions for brevity). Note that this won’t compile until we modify src/main.rs in Listing 12-14.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
Config
and run
into src/lib.rsWe’ve made liberal use of the pub
keyword: on Config
, on its fields and its
build
method, and on the run
function. We now have a library crate that has
a public API we can test!
Now we need to bring the code we moved to src/lib.rs into the scope of the binary crate in src/main.rs, as shown in Listing 12-14.
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --snip--
use minigrep::search;
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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
// --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 })
}
}
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(())
}
minigrep
library crate in src/main.rsWe add a use minigrep::Config
line to bring the Config
type from the
library crate into the binary crate’s scope, and we prefix the run
function
with our crate name. Now all the functionality should be connected and should
work. Run the program with cargo run
and make sure everything works correctly.
وای! این یک کار سخت بود، اما ما خودمان را برای موفقیت در آینده آماده کردیم. اکنون مدیریت خطاها بسیار آسانتر شده است و کد ما ماژولارتر شده است. از اینجا به بعد تقریباً تمام کارهای ما در فایل src/lib.rs انجام خواهد شد.
بیایید از این ماژولاریت جدید برای انجام کاری استفاده کنیم که با کد قبلی دشوار بود اما با کد جدید آسان است: نوشتن چند تست!