مقدمه

در دو درس گذشته، ما یک برنامه خط فرمان ساده ساختیم که آرگومان‌ها را خوانده و محتوای یک فایل را چاپ می‌کند. کد ما کار می‌کند، اما ساختار آن برای یک پروژه واقعی ایده‌آل نیست. در حال حاضر، تمام منطق برنامه در تابع main متمرکز شده است. با رشد برنامه، این کار باعث می‌شود که تابع main بسیار بزرگ و درک آن دشوار شود.

مشکلات اصلی کد فعلی عبارتند از:

  • متغیرهای پیکربندی (query و file_path) به صورت مجزا تعریف شده‌اند و ارتباط منطقی آنها مشخص نیست.
  • تابع main چندین مسئولیت دارد: هم آرگومان‌ها را پردازش می‌کند و هم منطق اصلی برنامه را اجرا می‌کند.
  • مدیریت خطا ضعیف است. ما از expect استفاده کرده‌ایم که در صورت بروز خطا، برنامه را panic می‌کند. این یک تجربه کاربری خوب برای یک ابزار CLI نیست.

در این درس، ما با «بازسازی» یا refactoring کد، این مشکلات را قدم به قدم حل خواهیم کرد.

گروه‌بندی متغیرهای پیکربندی با struct

اولین قدم، گروه‌بندی متغیرهای پیکربندی در یک ساختار واحد است. این کار باعث می‌شود که کد ما گویاتر شود. ما یک struct به نام Config می‌سازیم که این دو مقدار را در خود نگه دارد.

یک سازنده برای Config

به جای پردازش آرگومان‌ها در main، ما یک تابع سازنده برای Config خود می‌سازیم. این تابع، آرگومان‌ها را گرفته و یک نمونه از Config را برمی‌گرداند. مهم‌تر از آن، به جای panic کردن، این تابع یک Result برمی‌گرداند تا مدیریت خطا به صورت کنترل‌شده انجام شود.

Copy Icon src/main.rs
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 })
    }
}

تابع build() یک اسلایس از آرگومان‌ها را دریافت می‌کند. ابتدا بررسی می‌کند که آیا تعداد آرگومان‌ها کافی است یا خیر. اگر نباشد، یک Err با یک پیام خطا برمی‌گرداند. در غیر این صورت، مقادیر را clone کرده (تا مالکیت آنها را به دست آورد) و یک نمونه Ok(Config) را برمی‌گرداند.

اصلاح تابع main

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

Copy Icon src/main.rs
use std::{env, process};

// ... Config struct and impl block ...

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 logic here ...
}

در اینجا، ما از متد unwrap_or_else() روی Result بازگشتی از Config::build() استفاده می‌کنیم. این متد به ما اجازه می‌دهد تا در صورت بروز خطا (Err)، یک closure را اجرا کنیم. در این closure، ما یک پیام خطای کاربرپسند چاپ کرده و با استفاده از process::exit()، برنامه را با یک کد وضعیت خطا متوقف می‌کنیم. این روش بسیار بهتر از panic کردن است.

استخراج منطق اصلی به تابع run

آخرین مرحله، استخراج منطق اصلی برنامه (خواندن فایل) به یک تابع جداگانه به نام run است. این تابع، Config را به عنوان ورودی دریافت کرده و مسئول اجرای وظیفه اصلی برنامه است. این تابع نیز باید یک Result برگرداند تا خطاهای مربوط به خواندن فایل را مدیریت کند.

Copy Icon src/main.rs
use std::error::Error;
// ... other use statements and Config struct ...

fn main() {
    // ... same as before ...
    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(())
}

اکنون تابع main ما بسیار تمیز است. وظیفه آن تنها هماهنگی است. تابع run منطق اصلی را در خود دارد. نوع بازگشتی Result<(), Box<dyn Error>> یک الگوی رایج در Rust است. () یک نوع تاپل خالی است که نشان می‌دهد در صورت موفقیت، هیچ مقداری برنمی‌گردانیم. Box<dyn Error> یک «trait object» است که به ما اجازه می‌دهد هر نوع خطایی را که Error را پیاده‌سازی کرده، برگردانیم. استفاده از اپراتور ? در انتهای fs::read_to_string()، به صورت خودکار خطاها را به کد فراخواننده (یعنی main) منتشر می‌کند.

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