بازسازی برای بهبود ماژولار بودن و مدیریت خطاها

برای بهبود برنامه خود، چهار مشکلی که به ساختار برنامه و نحوه مدیریت خطاهای بالقوه مربوط می‌شوند را رفع خواهیم کرد.

  • تک‌مسئولیتی کردن تابع 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 تعریف خواهیم کرد.

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

عالی! این خروجی برای کاربران ما بسیار دوستانه‌تر است.

جداسازی منطق از main

اکنون که بازآرایی برای تجزیه تنظیمات را به پایان رسانده‌ایم، بیایید به منطق برنامه بپردازیم. همان‌طور که در «تفکیک نگرانی‌ها برای پروژه‌های باینری» بیان کردیم، تابعی به نام run استخراج خواهیم کرد که تمام منطقی که در حال حاضر در تابع main وجود دارد و مربوط به تنظیمات یا مدیریت خطا نیست را نگه می‌دارد. هنگامی که کار ما تمام شود، main مختصر و آسان برای بررسی خواهد بود و می‌توانیم تست‌هایی برای سایر منطق‌ها بنویسیم.

لیست ۱۲-۱۱ تابع استخراج‌شده run را نشان می‌دهد. فعلاً فقط بهبود کوچکی انجام می‌دهیم که تابع را استخراج کنیم. همچنان تابع را در فایل 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 در هر دو حالت یکسان هستند: ما خطا را چاپ کرده و خارج می‌شویم.

تقسیم کد به یک کتابخانه

پروژه 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 را همانطور که در لیست ۱۲-۱۴ نشان داده شده است تغییر ندهیم کامپایل نمی‌شود.

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> {
        // --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(())
}
Listing 12-13: انتقال Config و run به src/lib.rs

ما به طور گسترده از کلمه کلیدی pub استفاده کرده‌ایم: در Config، فیلدهای آن، متد build و همچنین تابع run. اکنون یک crate کتابخانه‌ای داریم که یک API عمومی دارد و می‌توانیم آن را تست کنیم!

حالا باید کدی که به src/lib.rs منتقل کرده‌ایم را به محدوده crate باینری در src/main.rs بیاوریم، همانطور که در لیست ۱۲-۱۴ نشان داده شده است.

Filename: 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);
    }
}
Listing 12-14: استفاده از crate کتابخانه‌ای minigrep در src/main.rs

ما خط use minigrep::Config را اضافه کرده‌ایم تا نوع Config را از crate کتابخانه‌ای به محدوده crate باینری بیاوریم، و تابع run را با پیشوند نام crate فراخوانی کرده‌ایم. اکنون همه قابلیت‌ها باید متصل شوند و کار کنند. برنامه را با cargo run اجرا کنید و مطمئن شوید که همه چیز به درستی کار می‌کند.

وای! این یک کار سخت بود، اما ما خودمان را برای موفقیت در آینده آماده کردیم. اکنون مدیریت خطاها بسیار آسان‌تر شده است و کد ما ماژولارتر شده است. از اینجا به بعد تقریباً تمام کارهای ما در فایل src/lib.rs انجام خواهد شد.

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