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

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.

Filename: 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)
}
Listing 12-5: استخراج تابع 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 را نشان می‌دهد.

Filename: src/main.rs
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 }
}
Listing 12-6: بازسازی 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 ایجاد کنیم. لیست ۱۲-۷ تغییرات لازم را نشان می‌دهد.

Filename: src/main.rs
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 }
    }
}
Listing 12-7: تغییر 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 اضافه می‌کنیم که بررسی می‌کند آیا آرایه به‌اندازه کافی طولانی است تا بتوان به اندیس‌های ۱ و ۲ دسترسی داشت. اگر طول آرایه کافی نباشد، برنامه دچار وحشت می‌شود و یک پیام خطای بهتر نمایش می‌دهد.

Filename: src/main.rs
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 }
    }
}
Listing 12-8: افزودن بررسی برای تعداد آرگومان‌ها

این کد شبیه به تابع 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 را نیز به‌روزرسانی نکنیم کامپایل نمی‌شود، که این کار را در لیست بعدی انجام خواهیم داد.

Filename: src/main.rs
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 })
    }
}
Listing 12-9: بازگرداندن یک 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! گرفته و به صورت دستی پیاده‌سازی خواهیم کرد. کد خروجی غیر صفر به عنوان یک قرارداد برای اعلام وضعیت خطا به فرآیندی که برنامه ما را فراخوانده است، استفاده می‌شود.

Filename: src/main.rs
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 })
    }
}
Listing 12-10: خروج با کد خطا در صورت شکست در ساخت یک 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.

Filename: 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 })
    }
}
Listing 12-11: استخراج تابع run از main

با این تغییرات، main اکنون تابع run را فراخوانی می‌کند و مسئولیت اجرای منطق اصلی برنامه را به آن واگذار می‌کند. این جداسازی باعث می‌شود تابع main ساده‌تر شود و ما بتوانیم تست‌های دقیقی برای بخش‌های مختلف کد بنویسیم. این روش به بهبود قابلیت نگهداری و خوانایی کد کمک شایانی می‌کند.

بازگرداندن خطاها از تابع run

اکنون که منطق باقی‌مانده برنامه را در تابع run جدا کرده‌ایم، می‌توانیم مانند Config::build در لیستینگ 12-9، مدیریت خطا را بهبود بخشیم. به جای اجازه دادن به برنامه برای اجرای panic با فراخوانی expect، تابع run در صورت بروز مشکل یک Result<T, E> بازمی‌گرداند. این رویکرد به ما امکان می‌دهد منطق مرتبط با مدیریت خطا را به صورت کاربرپسندانه‌ای در تابع main متمرکز کنیم. تغییرات لازم برای امضا و بدنه تابع run در لیستینگ 12-12 نشان داده شده است:

Filename: src/main.rs
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 })
    }
}
Listing 12-12: تغییر تابع 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.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: Moving Config and run into src/lib.rs

We’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.

Filename: src/main.rs
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(())
}
Listing 12-14: Using the minigrep library crate in src/main.rs

We 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 انجام خواهد شد.

بیایید از این ماژولاریت جدید برای انجام کاری استفاده کنیم که با کد قبلی دشوار بود اما با کد جدید آسان است: نوشتن چند تست!