آیا باید از panic! استفاده کنیم یا نه؟

چگونه تصمیم می‌گیرید که چه زمانی باید panic! را فراخوانی کنید و چه زمانی باید یک Result بازگردانید؟ وقتی کد دچار خطا می‌شود، هیچ راهی برای بازیابی وجود ندارد. شما می‌توانید در هر وضعیت خطایی، چه قابل بازیابی باشد و چه نباشد، panic! را فراخوانی کنید، اما در این صورت، شما به جای کد فراخوانی‌کننده تصمیم می‌گیرید که وضعیت غیرقابل بازیابی است. وقتی تصمیم می‌گیرید یک مقدار Result بازگردانید، به کد فراخوانی‌کننده گزینه‌هایی می‌دهید. کد فراخوانی‌کننده می‌تواند انتخاب کند که تلاش کند خطا را به روشی که برای وضعیت خودش مناسب است بازیابی کند، یا می‌تواند تصمیم بگیرد که مقدار Err در این مورد غیرقابل بازیابی است و بنابراین panic! را فراخوانی کرده و خطای قابل بازیابی شما را به یک خطای غیرقابل بازیابی تبدیل کند. بنابراین، بازگرداندن Result یک انتخاب پیش‌فرض خوب است وقتی تابعی تعریف می‌کنید که ممکن است شکست بخورد.

در وضعیت‌هایی مانند مثال‌ها، کد نمونه‌سازی (prototype) و آزمون‌ها، مناسب‌تر است که کدی بنویسید که متوقف شود به جای بازگرداندن یک Result. بیایید بررسی کنیم چرا، سپس وضعیت‌هایی را بحث کنیم که کامپایلر نمی‌تواند بفهمد که شکست غیرممکن است، اما شما به عنوان یک انسان می‌توانید. این فصل با برخی دستورالعمل‌های کلی درباره تصمیم‌گیری درباره اینکه آیا در کد کتابخانه باید از panic! استفاده کرد یا نه، به پایان خواهد رسید.

مثال‌ها، کد نمونه‌سازی، و آزمون‌ها

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

به همین ترتیب، متدهای unwrap و expect بسیار مفید هستند وقتی که در حال نمونه‌سازی هستید و هنوز تصمیم نگرفته‌اید که چگونه خطاها را مدیریت کنید. آن‌ها نشانه‌های واضحی در کد شما می‌گذارند برای زمانی که آماده باشید برنامه خود را قدرتمندتر کنید.

اگر یک متد در یک آزمون شکست بخورد، می‌خواهید کل آزمون شکست بخورد، حتی اگر آن متد ویژگی‌ای که تحت آزمون قرار دارد نباشد. از آنجا که panic! راهی است که یک آزمون به عنوان شکست‌خورده علامت‌گذاری می‌شود، فراخوانی unwrap یا expect دقیقاً همان چیزی است که باید اتفاق بیفتد.

مواردی که شما اطلاعات بیشتری نسبت به کامپایلر دارید

همچنین مناسب است که unwrap یا expect را فراخوانی کنید وقتی منطق دیگری دارید که تضمین می‌کند مقدار Result دارای یک مقدار Ok خواهد بود، اما این منطق چیزی نیست که کامپایلر آن را بفهمد. شما همچنان یک مقدار Result دارید که باید مدیریت کنید: عملیاتی که فراخوانی می‌کنید همچنان امکان شکست خوردن دارد، حتی اگر به صورت منطقی در وضعیت خاص شما غیرممکن باشد. اگر می‌توانید با بازرسی دستی کد تضمین کنید که هرگز یک حالت Err نخواهید داشت، کاملاً قابل قبول است که unwrap را فراخوانی کنید و حتی بهتر است که دلیل خود را در متن expect مستند کنید. در اینجا یک مثال آورده شده است:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

ما یک نمونه IpAddr را با تجزیه یک رشته ثابت‌شده ایجاد می‌کنیم. ما می‌توانیم ببینیم که 127.0.0.1 یک آدرس IP معتبر است، بنابراین استفاده از expect در اینجا قابل قبول است. با این حال، داشتن یک رشته ثابت‌شده و معتبر نوع بازگشتی متد parse را تغییر نمی‌دهد: ما همچنان یک مقدار Result دریافت می‌کنیم و کامپایلر همچنان ما را مجبور می‌کند که با Result برخورد کنیم، انگار که حالت Err ممکن است، زیرا کامپایلر به اندازه کافی هوشمند نیست تا ببیند این رشته همیشه یک آدرس IP معتبر است. اگر رشته آدرس IP از یک کاربر می‌آمد به جای اینکه در برنامه ثابت شده باشد و بنابراین امکان شکست وجود داشت، قطعاً می‌خواستیم که Result را به روشی قدرتمندتر مدیریت کنیم. اشاره به این فرض که این آدرس IP ثابت‌شده است، ما را ترغیب می‌کند که در صورت نیاز به دریافت آدرس IP از منبع دیگری در آینده، expect را به کد مدیریت خطای بهتر تغییر دهیم.

دستورالعمل‌هایی برای مدیریت خطاها

توصیه می‌شود که کد شما زمانی که ممکن است به وضعیت نامناسبی برسد، دچار panic! شود. در این زمینه، یک وضعیت نامناسب زمانی رخ می‌دهد که برخی فرضیات، تضمین‌ها، قراردادها، یا تغییرناپذیری‌ها شکسته شوند، مانند زمانی که مقادیر نامعتبر، مقادیر متناقض، یا مقادیر گمشده به کد شما پاس داده می‌شوند—به علاوه یکی یا بیشتر از شرایط زیر:

  • وضعیت نامناسب چیزی غیرمنتظره است، بر خلاف چیزی که احتمالاً گهگاهی رخ می‌دهد، مانند کاربری که داده‌ها را در قالب اشتباه وارد می‌کند.
  • کد شما پس از این نقطه نیاز دارد که به عدم وجود در این وضعیت نامناسب تکیه کند، به جای اینکه مشکل را در هر مرحله بررسی کند.
  • راه مناسبی برای رمزگذاری این اطلاعات در نوع‌هایی که استفاده می‌کنید وجود ندارد. ما در بخش “رمزگذاری وضعیت‌ها و رفتار به عنوان نوع‌ها” در فصل ۱۸ یک مثال از آنچه که منظورمان است را بررسی خواهیم کرد.

اگر کسی کد شما را فراخوانی کند و مقادیری که منطقی نیستند را پاس دهد، بهتر است که یک خطا بازگردانید تا کاربر کتابخانه بتواند تصمیم بگیرد که در آن مورد چه کاری انجام دهد. با این حال، در مواردی که ادامه دادن می‌تواند ناامن یا مضر باشد، بهترین انتخاب ممکن است فراخوانی panic! و هشدار به شخصی که از کتابخانه شما استفاده می‌کند درباره باگ در کد آن‌ها باشد تا بتوانند آن را در حین توسعه رفع کنند. به همین ترتیب، panic! اغلب مناسب است اگر کد خارجی که از کنترل شما خارج است را فراخوانی می‌کنید و آن کد یک وضعیت نامعتبر بازمی‌گرداند که شما هیچ راهی برای رفع آن ندارید.

با این حال، زمانی که شکست مورد انتظار است، مناسب‌تر است که یک Result بازگردانید تا یک فراخوانی panic!. مثال‌ها شامل پردازشی هستند که داده‌های نادرست دریافت می‌کند یا یک درخواست HTTP که بازگشت وضعیت نشان می‌دهد که به محدودیت نرخ برخورد کرده‌اید. در این موارد، بازگرداندن یک Result نشان می‌دهد که شکست یک احتمال مورد انتظار است که کد فراخوانی‌کننده باید تصمیم بگیرد چگونه آن را مدیریت کند.

وقتی کد شما عملیاتی انجام می‌دهد که می‌تواند در صورت فراخوانی با مقادیر نامعتبر کاربر را در معرض خطر قرار دهد، کد شما باید ابتدا مقادیر را تأیید کند و اگر مقادیر نامعتبر هستند دچار panic! شود. این بیشتر به دلایل ایمنی است: تلاش برای انجام عملیات روی داده‌های نامعتبر می‌تواند کد شما را در معرض آسیب‌پذیری‌ها قرار دهد. این دلیل اصلی است که کتابخانه استاندارد اگر شما تلاش کنید به حافظه خارج از محدوده دسترسی پیدا کنید، دچار panic! می‌شود: تلاش برای دسترسی به حافظه‌ای که به ساختار داده جاری تعلق ندارد یک مشکل امنیتی رایج است. توابع اغلب قراردادهایی دارند: رفتار آن‌ها فقط در صورتی تضمین می‌شود که ورودی‌ها نیازمندی‌های خاصی را برآورده کنند. دچار panic! شدن وقتی که قرارداد نقض می‌شود منطقی است زیرا نقض قرارداد همیشه نشان‌دهنده یک باگ در طرف فراخوانی‌کننده است و نوع خطایی نیست که بخواهید کد فراخوانی‌کننده به طور صریح مدیریت کند. در واقع، هیچ راه معقولی برای بازیابی کد فراخوانی‌کننده وجود ندارد؛ برنامه‌نویسان فراخوانی‌کننده باید کد را اصلاح کنند. قراردادهای یک تابع، به خصوص زمانی که نقض آن باعث panic! می‌شود، باید در مستندات API تابع توضیح داده شوند.

با این حال، داشتن بررسی‌های خطا در تمام توابع شما بسیار طولانی و ناخوشایند خواهد بود. خوشبختانه، شما می‌توانید از سیستم نوع Rust (و در نتیجه بررسی نوعی که توسط کامپایلر انجام می‌شود) برای انجام بسیاری از بررسی‌ها استفاده کنید. اگر تابع شما یک نوع خاص را به عنوان پارامتر داشته باشد، می‌توانید با اطمینان از اینکه کامپایلر قبلاً تضمین کرده است که یک مقدار معتبر دارید، منطق کد خود را پیش ببرید. برای مثال، اگر شما یک نوع به جای یک Option داشته باشید، برنامه شما انتظار دارد که چیزی به جای هیچ‌چیز وجود داشته باشد. سپس کد شما نیازی به مدیریت دو حالت برای حالت‌های Some و None ندارد: فقط یک حالت برای داشتن یک مقدار به طور قطعی خواهد داشت. کدی که سعی می‌کند هیچ‌چیز به تابع شما پاس دهد حتی کامپایل نخواهد شد، بنابراین تابع شما نیازی به بررسی این حالت در زمان اجرا ندارد. مثال دیگر استفاده از یک نوع عددی بدون علامت مانند u32 است که تضمین می‌کند پارامتر هرگز منفی نخواهد بود.

ایجاد انواع سفارشی برای اعتبارسنجی

بیایید ایده استفاده از سیستم نوع Rust برای اطمینان از داشتن یک مقدار معتبر را یک قدم فراتر ببریم و به ایجاد یک نوع سفارشی برای اعتبارسنجی نگاه کنیم. بازی حدس عدد در فصل ۲ را به یاد بیاورید که کد ما از کاربر خواست تا یک عدد بین ۱ تا ۱۰۰ حدس بزند. ما هرگز اعتبارسنجی نکردیم که حدس کاربر بین این اعداد باشد قبل از اینکه آن را با عدد مخفی مقایسه کنیم؛ فقط بررسی کردیم که حدس مثبت باشد. در این مورد، پیامدها چندان شدید نبودند: خروجی ما با پیام‌های “خیلی بزرگ” یا “خیلی کوچک” همچنان درست بود. اما این می‌تواند بهبودی مفید باشد که کاربر را به سمت حدس‌های معتبر هدایت کنیم و رفتار متفاوتی داشته باشیم وقتی کاربر عددی خارج از محدوده حدس می‌زند در مقابل زمانی که، برای مثال، حروف تایپ می‌کند.

یک راه برای انجام این کار این است که حدس را به جای فقط یک u32، به صورت یک i32 تجزیه کنیم تا اجازه دهیم اعداد منفی نیز در نظر گرفته شوند، و سپس یک بررسی برای اینکه عدد در محدوده است یا نه اضافه کنیم، مانند زیر:

Filename: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

عبارت if بررسی می‌کند که آیا مقدار ما خارج از محدوده است، به کاربر درباره مشکل اطلاع می‌دهد و continue را فراخوانی می‌کند تا تکرار بعدی حلقه شروع شود و درخواست یک حدس دیگر شود. بعد از عبارت if، می‌توانیم با مقایسه بین guess و عدد مخفی ادامه دهیم، زیرا می‌دانیم که guess بین ۱ و ۱۰۰ است.

با این حال، این یک راه‌حل ایده‌آل نیست: اگر بسیار حیاتی باشد که برنامه فقط بر روی مقادیر بین ۱ و ۱۰۰ عمل کند، و برنامه توابع زیادی با این نیاز داشته باشد، داشتن چنین بررسی‌هایی در هر تابع خسته‌کننده خواهد بود (و ممکن است عملکرد را تحت تأثیر قرار دهد).

در عوض، می‌توانیم یک نوع جدید ایجاد کنیم و اعتبارسنجی‌ها را در یک تابع برای ایجاد یک نمونه از نوع جدید قرار دهیم به جای تکرار اعتبارسنجی‌ها در همه‌جا. به این ترتیب، استفاده از نوع جدید در امضاهای توابع ایمن است و می‌توان با اطمینان از مقادیری که دریافت می‌کنند استفاده کرد. لیست ۹-۱۳ یک روش برای تعریف یک نوع Guess را نشان می‌دهد که فقط یک نمونه از Guess ایجاد می‌کند اگر تابع new مقداری بین ۱ و ۱۰۰ دریافت کند.

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: یک نوع Guess که فقط با مقادیر بین ۱ و ۱۰۰ ادامه می‌دهد

ابتدا یک ساختار داده به نام Guess تعریف می‌کنیم که دارای یک فیلد به نام value است که یک i32 نگه می‌دارد. اینجا جایی است که عدد ذخیره خواهد شد.

سپس یک تابع وابسته به نام new روی Guess پیاده‌سازی می‌کنیم که نمونه‌هایی از مقادیر Guess ایجاد می‌کند. تابع new به گونه‌ای تعریف شده که یک پارامتر به نام value از نوع i32 داشته باشد و یک Guess بازگرداند. کدی که در بدنه تابع new قرار دارد مقدار value را بررسی می‌کند تا مطمئن شود که بین ۱ و ۱۰۰ است. اگر مقدار value این آزمون را پاس نکند، یک فراخوانی به panic! انجام می‌دهیم، که به برنامه‌نویسی که کد فراخوانی‌کننده را می‌نویسد هشدار می‌دهد که باگی دارد که باید برطرف کند، زیرا ایجاد یک Guess با مقدار value خارج از این محدوده قرارداد تابع Guess::new را نقض می‌کند. شرایطی که ممکن است باعث panic! در Guess::new شود باید در مستندات عمومی API آن مورد بحث قرار گیرد؛ ما در فصل ۱۴ درباره قراردادهای مستندات که نشان‌دهنده احتمال وقوع panic! هستند صحبت خواهیم کرد. اگر مقدار value آزمون را پاس کند، یک Guess جدید با فیلد value تنظیم شده به پارامتر value ایجاد می‌کنیم و Guess را بازمی‌گردانیم.

سپس یک متد به نام value پیاده‌سازی می‌کنیم که self را قرض می‌گیرد، هیچ پارامتر دیگری ندارد و یک i32 بازمی‌گرداند. این نوع متد گاهی اوقات getter نامیده می‌شود زیرا هدف آن دریافت داده‌ای از فیلدهای خود و بازگرداندن آن است. این متد عمومی ضروری است زیرا فیلد value ساختار داده Guess خصوصی است. مهم است که فیلد value خصوصی باشد تا کدی که از ساختار Guess استفاده می‌کند مجاز نباشد مقدار value را مستقیماً تنظیم کند: کدی که خارج از ماژول است باید از تابع Guess::new برای ایجاد یک نمونه از Guess استفاده کند، و بنابراین تضمین می‌شود که هیچ راهی برای ایجاد یک Guess با مقدار value وجود ندارد که توسط شرایط در تابع Guess::new بررسی نشده باشد.

تابعی که یک پارامتر می‌گیرد یا فقط اعدادی بین ۱ و ۱۰۰ بازمی‌گرداند می‌تواند در امضای خود اعلام کند که یک Guess می‌گیرد یا بازمی‌گرداند به جای یک i32 و نیازی به انجام بررسی‌های اضافی در بدنه خود ندارد.

خلاصه

ویژگی‌های مدیریت خطای Rust طراحی شده‌اند تا به شما کمک کنند کدی قدرتمندتر بنویسید. ماکروی panic! نشان می‌دهد که برنامه شما در حالتی قرار دارد که نمی‌تواند آن را مدیریت کند و به شما امکان می‌دهد فرآیند را متوقف کنید به جای اینکه سعی کنید با مقادیر نامعتبر یا نادرست ادامه دهید. Enum Result از سیستم نوع Rust استفاده می‌کند تا نشان دهد که عملیات ممکن است به روشی شکست بخورد که کد شما می‌تواند از آن بازیابی کند. می‌توانید از Result برای اطلاع دادن به کدی که کد شما را فراخوانی می‌کند استفاده کنید که باید موفقیت یا شکست احتمالی را نیز مدیریت کند. استفاده از panic! و Result در شرایط مناسب باعث می‌شود کد شما در برابر مشکلات اجتناب‌ناپذیر قابل اطمینان‌تر شود.

حالا که راه‌های مفید استفاده کتابخانه استاندارد از جنریک‌ها با Enums Option و Result را دیده‌اید، درباره نحوه عملکرد جنریک‌ها و نحوه استفاده از آن‌ها در کد خود صحبت خواهیم کرد.