وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

آموزش زبان برنامه‌نویسی Rust - قسمت ۲۱:‌ مدیریت خطاها

آموزش زبان برنامه‌نویسی Rust - قسمت ۲۱:‌ مدیریت خطاها

تا اینجا چیزهای زیادی را درمورد زبان Rust یادگرفتیم. امّا هنوز یکی از بخش‌های مهم زبان باقی مانده است.

خطا (Error) یکی از بخش‌های جدایی‌ناپذیر هر نرم‌افزاری است. همیشه امکان این وجود دارد که خطایی رخ بدهد. حالا وظیفه‌ی ما، به عنوان کسی که دارد این برنامه را می‌نویسد، این است که مطمئن شویم بخش‌هایی را که احتمال رخ‌دادن خطا در آن‌ها وجود به خوبی مدیریت کرده‌ایم.

زبان‌های مختلف راهکارهای مختلفی را برای مدیریت خطاها استفاده می‌کنند. حالا بیایید با هم ببینیم که زبان Rust چه رویکردی را در مقابل خطاها پیش گرفته است.

چرا Rust از Exception ها استفاده نمی‌کند؟

زبان‌های زیادی از Exception ها برای مدیریت حالات استثناء در برنامه استفاده می‌کنند. وقتی که یک حالت ناخواسته رخ می‌دهد، یک Exception به اصطلاح raise می‌شود و کدهای بعدی دیگر اجرا نمی‌شوند.

امّا Rust تصمیم گرفته است که از Exception ها در این زبان پشتیبانی نکند. این تصمیم دلایل زیادی داشته و مثل هر تصمیم دیگری جنبه‌های خوب و بد زیادی دارد. امّا اینجا ما دو تا از این جنبه‌ها (طبیعتاً خوب‌هایش را) با هم بررسی می‌کنیم.

شکسته‌شدن روند عادی جریان برنامه

یکی از مشکلات Exception ها این است که جریان عادی اجرای برنامه شکسته می‌شود. این یعنی اینکه کدهای عادی بعدی دیگر اجرا نمی‌شوند.

خب این، مخصوصاً وقتی که حجم کدها زیاد می‌شود، خودش مشکل آفرین است. درست به همان دلیلی که استفاده از goto مشکل‌آفرین می‌شود و خیلی‌ها استفاده از آن را توصیه نمی‌کنند.

کد پایتون ساده‌ی زیر را در نظر بگیرید:

def some_function(index: int) -> int:
    # 20 line of code
    local_var = local_list[index * 2]
    result = other_fucntion(local_var) * 10
    return result

خب حالا فرض‌کنید که تابع some_function در یک پکیج خارجی قرار دارد و توسّط تابع x در یک پکیج دیگر دارد استفاده می‌شود که آن هم توسّط تابع y در یک بخشی از برنامه‌ی ما صدا زده می‌شود.

حالا فرض‌کنید که درون تابع some_function ما به خطای درست نبودن ایندکس بخوریم. حالا روند اجرای تمامی این توابع شکسته می‌شود و اجرای برنامه وسط فراخوانی تابع y قطع می‌شود.

چنین سناریویی در یک پروژه‌ی بزرگ خیلی راحت می‌تواند باعث ایجاد خطا در برنامه شود و تا زمانی که مشتری‌های عصبانی با شما تماس نگرفته‌اند، متوجّه خطا نخواهید شد.

امّا اگر مدیریت خطا هم بخشی از روند اجرای برنامه بود، ما همیشه مطمئن بودیم که مشکلی در زمان اجرا رخ نمی‌دهد.

سربار زیاد

مشکل بعدی این است که عموماً پیاده‌سازی Exception دارای سربار زیادی است. ما عموماً به اطّلاعات فراخوانی‌هایی که باعث رخ‌دادن این خطا شده‌اند، مقدار داده‌ها و… نیاز داریم. پس باید کدی در زمان اجرای برنامه وجود داشته باشد که این‌ها را برای ما ایجاد کند.

به علاوه وقتی که یک Exception رخ می‌دهد ما باید درون استک و هیپ بگردیم و حافظه‌ها را آزاد کنیم. در غیر این صورت رخ‌دادن Exception های پشت سر هم می‌تواند حافظه‌ی سیستم را هم نابود کند.

وجود این کدهای اضافی باعث می‌شود که برنامه‌ی ما حجیم‌تر و کندتر شود.

باز هم بیایید یک کد کوچک با پایتون بزنیم. ما دو تا حلقه می‌نویسیم. در اوّلی ما از Exception ها استفاده می‌کنیم. در دومی به جای این کار در روند اجرای برنامه بررسی می‌کنیم که اگر در حالت‌های خاص نیستیم، کد اجرا شود.

from time import time

l = range(100)

start = time()
for i in range(100000):
    try:
        print(l[i])
    except IndexError:
        pass

exception_time = time() - start

start = time()
for j in range(100000):
    if j < len(l):
        print(l[j])

print(f"exception time: {exception_time}")
print(f"no exception time: {time() - start}")

این برنامه خیلی ساده است. مراحلی که گفتیم برای حالت با Exception و بدون Exception هرکدام صدهزاربار تکرار می‌شود.

اجراهای متفاوت این برنامه روی سیستم من نشان می‌دهد که کدی که از Exception دارد استفاده می‌کند به طور میانگین ۳٫۵ برابر آن یکی زمان می‌برد. (می‌توانید روی سیستم خودتان این کد را اجرا کنید و تفاوت دو حالت را بر اساس آن حساب کنید.)

حالا ما در Rust کلاً Exception نداریم. پس قرار نیست با هیچ‌کدام از این مشکل‌ها سر و کلّه بزنیم. حالا بیایید با هم ببینیم که بدون Exception چطوری می‌توان خطاها را مدیریت کرد.

انواع خطاها در یک برنامه

ما می‌توانیم دسته‌بندی‌های مختلفی را ارائه کنیم. ولی سازندگان Rust یک دسته‌بندی کلّی و البته خیلی ساده را برای خطاها به کار می‌برند: خطاهای قابل بازیابی و خطاهای غیر قابل بازیابی.

خطاهای قابل بازیابی خطاهایی هستند که بخشی از روند برنامه حساب می‌شوند و ما باید آن‌ها را مدیریت کنیم. مثلاً فرض‌کنید که از کاربر می‌خواهید نام کاربری دوستش را وارد کند تا پیامش به او ارسال شود.

اگر ورودی‌ای که از کاربر می‌گیریم نام کاربری هیچ‌کسی نباشد، یک خطا رخ داده است. امّا این یک خطای قابل بازیابی است. کافی است که وقتی دیدیم چنین کاربری وجود ندارد، از کاربر بخواهیم که نام کاربری درست را وارد کند.

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

ما در Rust دو رویکرد متفاوت برای این خطاهای متفاوت داریم.

وقتی که برنامه وحشت می‌کند

اوّل بیایید به سراغ خطاهای غیر قابل بازیابی برویم. چون رویکرد Rust برای کارکردن با آن‌ها ساده‌تر است.

ما یک ماکرو (بعداً می‌بینیم که ماکروها چی هستند) به نام panic! داریم. هروقت که روند اجرای برنامه‌ی ما به این ماکرو برسد، در خروجی استاندارد پیام خطا قرار می‌گیرد، استک تمیز می‌شود و برنامه خاتمه پیدا می‌کند.

بیایید ساده‌ترین مثال ممکن را برای آن بنویسیم:

fn main() {
    panic!("پیام خطا");
}

حالا اگر این برنامه را اجرا کنید، با خروجی زیر در ترمینال مواجه می‌شوید:

thread 'main' panicked at 'پیام خطا', main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

البته همیشه لازم نیست که ما خودمان به صورت دستی panic! را فراخوانی کنیم. خیلی جاها خود کامپایلر حواسش به ما بوده و در صورتی که خطای بدی رخ بدهد، panic! خود به خود انجام می‌شود.

مثلاً فرض‌کنید که ما یک وکتور با ۶ تا مقدار داریم. حالا می‌خواهیم به ایندکس شماره‌ی ۱۰۰ آن دسترسی داشته‌باشیم:

fn main() {
    let a = vec![1, 2, 3, 4, 5, 6];
    println!("This program will panic and this line never will print. {}", a[100]);
}

حالا اگر برنامه را اجرا کنیم خروجی‌ای که می‌گیریم، درست همان خروجی است که موقع فراخوانی panic! می‌گرفتیم.

thread 'main' panicked at 'index out of bounds: the len is 6 but the index is 100', /rustc/b8cedc00407a4c56a3bda1ed605c6fc166655447/src/libcore/slice/mod.rs:2791:10

ما با فراخوانی ماکرو vec! می‌توانیم یک وکتور با مقدایری که بین قلّا‌ب‌ها آمده است بسازیم. ما اینجا از وکتور به جای آرایه استفاده کردیم. چون خود کامپایلر درستی مقادیر را وقتی که ایندکس هاردکد شده است برای آرایه‌ها بررسی می‌کند و اصلاً برنامه‌ی ما کامپایل نمی‌شد که بخواهد panic کند. به همین خاطر برای رسیدن به خطا باید از وکتورها استفاده کنیم.

همان‌طوری که می‌بینید، برخلاف زبان‌‌هایی مثل C که اجرای چنین کدی را مجاز می‌دانند و باعث می‌شوند کد اشتباه شما اجرا شود و برنامه مقادیر غلط بدهد، Rust حتّی موقع اجرا هم حواسش به ایمنی کار با حافظه هست.

فهمیدن اینکه چرا برنامه panic کرده است

حالا ما شاید بخواهیم که اطّلاعات تمامی مسیری که تا رسیدن به این panic طی شده است را بدانیم. به اطّلاعات تمامی توابعی که فراخوانی شده‌اند تا ما به اینجا برسیم، اصطلاحاً backtrace می‌گویند.

ما، وقتی که برنامه را در حالت release کامپایل نکرده‌ایم، با تعیین کردن متغیّر محیطی (environment variable) RUST_BACKTRACE با مقدار ۱، می‌توانیم به اطّلاعات backtrace هنگام panic کردن برسیم.

مثلاً در لینوکس می‌توان پس از کامپایل کردن برنامه، آن را اینطوری اجرا کرد تا backtrace هم خروجی داده شود:

rustc main.rs -o main
RUST_BACKTRACE=1 ./main

حالا فرض‌کنید که ما درون main.rs کد زیر را قرار داده‌ایم:

fn main() {
    let a = vec![1, 2, 3, 4, 5, 6];
    access_the_vector(a);
}

fn access_the_vector(a: Vec<i32>) {
    println!("This program will panic and this line never will print. {}", a[100]);
}

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

thread 'main' panicked at 'index out of bounds: the len is 6 but the index is 100', /rustc/b8cedc00407a4c56a3bda1ed605c6fc166655447/src/libcore/slice/mod.rs:2791:10
stack backtrace:
   0: backtrace::backtrace::libunwind::trace
             at /cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/libunwind.rs:88
   1: backtrace::backtrace::trace_unsynchronized
             at /cargo/registry/src/github.com-1ecc6299db9ec823/backtrace-0.3.40/src/backtrace/mod.rs:66
   2: std::sys_common::backtrace::_print_fmt
             at src/libstd/sys_common/backtrace.rs:77
   3: <std::sys_common::backtrace::_print::DisplayBacktrace as core::fmt::Display>::fmt
             at src/libstd/sys_common/backtrace.rs:59
   4: core::fmt::write
             at src/libcore/fmt/mod.rs:1052
   5: std::io::Write::write_fmt
             at src/libstd/io/mod.rs:1426
   6: std::sys_common::backtrace::_print
             at src/libstd/sys_common/backtrace.rs:62
   7: std::sys_common::backtrace::print
             at src/libstd/sys_common/backtrace.rs:49
   8: std::panicking::default_hook::{{closure}}
             at src/libstd/panicking.rs:204
   9: std::panicking::default_hook
             at src/libstd/panicking.rs:224
  10: std::panicking::rust_panic_with_hook
             at src/libstd/panicking.rs:472
  11: rust_begin_unwind
             at src/libstd/panicking.rs:380
  12: core::panicking::panic_fmt
             at src/libcore/panicking.rs:85
  13: core::panicking::panic_bounds_check
             at src/libcore/panicking.rs:63
  14: <usize as core::slice::SliceIndex<[T]>>::index
  15: core::slice::<impl core::ops::index::Index<I> for [T]>::index
  16: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
  17: main::access_the_vector
  18: main::main
  19: std::rt::lang_start::{{closure}}
  20: std::rt::lang_start_internal::{{closure}}
             at src/libstd/rt.rs:52
  21: std::panicking::try::do_call
             at src/libstd/panicking.rs:305
  22: __rust_maybe_catch_panic
             at src/libpanic_unwind/lib.rs:86
  23: std::panicking::try
             at src/libstd/panicking.rs:281
  24: std::panic::catch_unwind
             at src/libstd/panic.rs:394
  25: std::rt::lang_start_internal
             at src/libstd/rt.rs:51
  26: std::rt::lang_start
  27: main
  28: __libc_start_main
             at ../csu/libc-start.c:308
  29: _start
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

همانطوری که می‌بینید مقدار خیلی زیادی اطّلاعات به ما خروجی داده شده است. تازه این همه‌اش نیست و آخرش نوشته شده که برای گرفتن تمام اطّلاعات متغیّر RUST_BACKTRACE را به جای 1 برابر full قرار دهید.

مدیریت خطا با Result

خب حالا می‌رسیم به مدیریت خطاهایی که قابل بازیابی هستند. خطاهایی که در بخش قبل دیدیم خیلی محدود هستند و در یک برنامه‌ی خوب، نباید ایجاد شوند. مگر اینکه حالتی پیش بیاید که خارج از کنترل برنامه‌ی ما است.

امّا اینجا می‌خواهیم با خطاهایی کار کنیم که در برنامه‌های عادی زیاد رخ می‌دهند و ما می‌خواهیم آن‌ها را در روند عادی برنامه‌ی خودمان مدیریت کنیم.

معرّقی enum جذّاب و شگفت‌انگیز Result

ما در Rust یک enum پیش‌فرض به نام Result داریم. منظورم از پیش‌فرض این است که در خود زبان پیاده شده است و برای استفاده از آن لازم نیست از ماژول خارجی‌ای استفاده کنید.

تعریف Result این شکلی است:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

ما در Result دو حالت داریم:‌

حالت اوّل Ok است که یعنی عملیّات با موفّقیّت انجام شده است و نتیجه آماده است.

حالت بعدی Err است که یعنی خطایی رخ داده و حالا مقدار خطا اینجا است. (مقادیر T و E مقادیر generic هستند که قبلاً آن‌ها را یادگرفته‌ایم.)

ما در توابع و متدهایی که امکان رخ‌دادن خطا در آن‌ها وجود دارد، به جای استفاده از مقادیر معمولی برای نوع خروجی، از این enum استفاده می‌کنیم.

اینطوری می‌توان بررسی کرد که آیا خطا رخ‌داده یا نه و اگر خطا رخ داد می‌توانیم آن را مدیریت کنیم.

گرفتن ورودی از کاربر

یکی از کارهای معمولی که می‌توان در زبان‌های مختلف انجام داد، گرفتن ورودی از کاربر است. ما تا اینجا این کار را عقب انداختیم، چون موقع گرفتن ورودی از کاربر درست با همین مفاهیم سر و کلّه می‌زنیم.

برای گرفتن ورودی از کاربر اوّل از همه باید با کد زیر ماژول io را به کد خودمان معرّفی کنیم:

use std::io;

ما برای گرفتن ورودی استاندارد باید از تابع stdin استفاده کنیم. این تابع یک نمونه از ساختار Stdin را خروجی می‌دهد که یک هندل به ورودی استاندارد است.

حالا مثلاً‌ اگر بخواهیم یک خط را از ورودی بخوانیم، کافی است که متد read_line را روی آن نمونه از ساختار صدا بزنیم.

بیایید این کار را امتحان کنیم:

use std::io;

fn main() {
    let mut user_input = String::new();
    let read_result = io::stdin().read_line(&mut user_input);
    println!("user input is: {}", user_input);
    println!("user input read result: {:?}", read_result);
}

حالا برنامه را اجرا می‌کنیم تا ببینیم که خروجی‌اش چیست.

ما اوّل با فراخوانی تابع new روی String یک رشته‌ی خالی ساخته‌ایم. بعد یک رفرنس mutable از آن را به متد read_line دادیم و خروجی این تابع را در متغیّر read_result ذخیره کردیم.

حالا همانطوری که در بازپخش اجرای برنامه می‌بینید، وقتی که ما برنامه را اجرا می‌کنیم منتظر دریافت ورودی از کاربر می‌ماند.

وقتی که ورودی را وارد کردیم، رشته‌ی ورودی در متغیّری که رفرنسش را به read_lie داده بودیم ذخیره می‌شود. مسئله‌ی مهم خروجی خود متد است که درون متغیّر read_result ذخیره‌اش کرده‌ایم.

همانطوری که می‌بینید، خروجی این متد یک Result است. چون گرفتن ورودی از کاربر موفّقیّت‌آمیز بوده است، مقدار Ok آن استفاده شده است که درونش تعداد کاراکترهایی است که از ورودی خوانده شده است (۱۲ تا کاراکتر + یک کاراکتر خط جدید که با زدن اینتر نوشته می‌شود).

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

use std::io;

fn main() {
    let mut user_input = String::new();
    let read_result = io::stdin().read_line(&mut user_input);
    match read_result {
        Ok(num_of_characters) => {
            println!("user input is: {}", user_input);
            println!("user input read result: {:?}", read_result);
        },
        Err(error) => println!("Error happened!")
    }
}

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

مدیریت انواع مختلف خطاها

ما در زبان‌هایی که از Exception ها پشتیبانی می‌کنند، می‌توانیم عملی که باید برای مدیریت شرایط انجام شود را از روی نوع Exception تعیین کنیم. در روش مدیریت خطای Rust هم امکان تشخیص خطا و داشتن انواع مختلف خطا وجود دارد.

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

بیایید اوّل مسیر فایل را از کاربر بگیریم:

fn get_file_path(user_input: &mut String) {
    println!("Input file name:");
    match io::stdin().read_line(user_input) {
        Err(_) => println!("Error happened in getting input from user"),
        _ => {}
    }
}

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

خب حالا بیایید تا فایلی را که اسمش را از کاربر گرفته‌ایم باز کنیم و محتویاتش را بخوانیم.

use std::fs;

fn read_file(file_name: &String) -> String {
    let read_result = fs::read_to_string(file_name);
    match read_result {
        Ok(content) => content,
        Err(error) => {
            println!("Error happened: {:?}", error);
            String::new()
        }
    }
}

ما برای کار با فایل‌ها در Rust به ماژول fs نیاز داریم که باید ابتدا با استفاده از کلمه‌ی کلیدی use آن را به برنامه‌ی خودمان معرّفی کنیم.

تابع read_file مسیر قرارگیری فایل را به صورت یک رفرنس به نوع String می‌گیرد. سپس ما با استفاده از تابع read_to_string می‌خواهیم محتویات فایل را بخوانیم.

این تابع ابتدا تلاش می‌کند که فایل را باز کند. در صورت موفّقیّت تلاش می‌کند که محتویاتش را بخواند و به عنوان یک String خروجی بدهد.

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

ما اینجا خیلی ساده با اتّفاقات ممکن رفتار کرده‌ایم. اگر نتیجه خوب بود که رشته‌ی حاوی محتویات فایل را برمی‌گردانیم. در غیر این صورت خطا را چاپ می‌کنیم و یک رشته‌ی خالی را برمی‌گردانیم.

حالا بیایید با یک کد خیلی ساده این تابع را امتحان کنیم.

fn main() {
    let path = String::from("./not-found.txt");
    let file_content = read_file(&path);
    println!("file content:\n {}", file_content);
}

اگر این برنامه را اجرا کنیم به خروجی زیر می‌رسیم:

Error happened: Os { code: 2, kind: NotFound, message: "No such file or directory" }
file content:

همانطوری که می‌بینید خطا یک ساختار است که یک کد، یک نوع و یک پیام همراه خودش دارد.

حالا ما می‌خواهیم برنامه‌ی خودمان را به این شکل تغییر بدهیم:

۱. کاربر مسیر قرارگیری فایل را وارد کند.

۲. اگر فایل وجود داشت محتویاتش نمایش داده شود و برنامه خاتمه پیدا کند.

۳. اگر فایل وجود نداشت از کاربر بخواهیم که برنامه را دوباره اجرا کند و این بار یک فایل دیگر را امتحان کند.

ما با ۲ تابع read_file و get_file_path می‌توانیم ۲ بخش اوّل را پیاده کنیم. ولی برای بخش سوم باید کدمان را طوری تغییر بدهیم که نسبت به خطاهای مختلف واکنش‌های مختلفی نشان بدهد.

ما برای تشخیص نوع خطا می‌توانیم از نوع (kind) استفاده کنیم. مقادیر این فیلد، مقادیر مختلف یک enum به اسم ErrorKind است که درون ماژول io قرار دارد.

تعریف (تمیزشده‌ی) این enum تا این لحظه این شکلی است:

enum ErrorKind {
    /// An entity was not found, often a file.
    NotFound,
    /// The operation lacked the necessary privileges to complete.
    PermissionDenied,
    /// The connection was refused by the remote server.
    ConnectionRefused,
    /// The connection was reset by the remote server.
    ConnectionReset,
    /// The connection was aborted (terminated) by the remote server.
    ConnectionAborted,
    /// The network operation failed because it was not connected yet.
    NotConnected,
    /// A socket address could not be bound because the address is already in
    /// use elsewhere.
    AddrInUse,
    /// A nonexistent interface was requested or the requested address was not
    /// local.
    AddrNotAvailable,
    /// The operation failed because a pipe was closed.
    BrokenPipe,
    /// An entity already exists, often a file.
    AlreadyExists,
    /// The operation needs to block to complete, but the blocking operation was
    /// requested to not occur.
    WouldBlock,
    /// A parameter was incorrect.
    InvalidInput,
    /// Data not valid for the operation were encountered.
    ///
    /// Unlike [`InvalidInput`], this typically means that the operation
    /// parameters were valid, however the error was caused by malformed
    /// input data.
    ///
    /// For example, a function that reads a file into a string will error with
    /// `InvalidData` if the file's contents are not valid UTF-8.
    ///
    /// [`InvalidInput`]: #variant.InvalidInput
    InvalidData,
    /// The I/O operation's timeout expired, causing it to be canceled.
    TimedOut,
    /// An error returned when an operation could not be completed because a
    /// call to [`write`] returned [`Ok(0)`].
    ///
    /// This typically means that an operation could only succeed if it wrote a
    /// particular number of bytes but only a smaller number of bytes could be
    /// written.
    ///
    /// [`write`]: ../../std/io/trait.Write.html#tymethod.write
    /// [`Ok(0)`]: ../../std/io/type.Result.html
    WriteZero,
    /// This operation was interrupted.
    ///
    /// Interrupted operations can typically be retried.
    Interrupted,
    /// Any I/O error not part of this list.
    Other,

    /// An error returned when an operation could not be completed because an
    /// "end of file" was reached prematurely.
    ///
    /// This typically means that an operation could only succeed if it read a
    /// particular number of bytes but only a smaller number of bytes could be
    /// read.
    UnexpectedEof,
}

من کامنت‌های پیاده‌سازی Rust را پاک نکردم تا بتوانید با خواندن آن با خطاهای مختلف آشنا بشوید. همان‌طوری که می‌بینید این enum شامل تمام خطاهای مرتبط با ورودی و خروجی می‌شود.

طبیعتاً کارکردن با مقادیر این enum و متدهای آن خیلی تمیزتر و بهتر از کارکردن با مقدار عددی کد خطا است. هرچند می‌توانید از کد خطایی که سیستم عامل برمی‌گرداند هم استفاده کنید.

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

بیایید تابع read_file را مطابق نیاز جدید تغییر بدهیم.

fn read_file(file_name: &String) -> String {
    let read_result = fs::read_to_string(file_name);
    match read_result {
        Ok(content) => content,
        Err(error) => {
            match error.kind() {
                io::ErrorKind::NotFound => {
                    println!("The {} file not found. Please re-run the program and try another file.", file_name);
                    exit(0);
                }
                _ => panic!("Something bad happened. :(")
            }
        }
    }
}

این بار اگر خطایی پیش آمد ما با یک match دیگر نوع آن را بررسی می‌کنیم. اگر نوع خطا برابر NotFound بود، ما به کاربر می‌گوییم که فایل پیدا نشد و از او می‌خواهیم که دوباره برنامه را اجرا کند.

بعد از آن هم با فراخوانی تابع exit برنامه را متوقّف می‌کنیم. (در خیلی از سیستم‌های عامل، اگر برنامه با کد ۰ تمام شود یعنی اینکه برنامه با موفّقیّت اجرا شده است. طبیعتاً ما نباید از این کد برای خروج استفاده می‌کردیم. ولی چون نمی‌خواستم توضیح مقادیر خروج را هم اینجا بیاورم و الکی نوشته را شلوغ کنم، از همین مقدار ۰ استفاده کردم.)

حالا بیایید برنامه‌ی کامل را با این توابعی که داریم بسازیم.

use std::io;
use std::fs;
use std::process::exit;

fn main() {
    let mut path = String::new();
    get_file_path(&mut path);
    let file_content = read_file(&path);
    println!("file content:\n {}", file_content);
}

fn get_file_path(user_input: &mut String) {
    println!("Input file path:");
    match io::stdin().read_line(user_input) {
        Err(_) => println!("Error happened in getting input from user"),
        _ => {}
    }
}

fn read_file(file_name: &String) -> String {
    let read_result = fs::read_to_string(file_name);
    match read_result {
        Ok(content) => content,
        Err(error) => {
            match error.kind() {
                io::ErrorKind::NotFound => {
                    println!("The {} file not found. Please re-run the program and try another file.", file_name);
                    exit(0);
                }
                _ => panic!("Something bad happened. :(")
            }
        }
    }
}

حالا من یک فایل به نام sample.txt ساخته‌ام و درونش هم متن آهنگ End of All Hope گروه Nighwish را قرار داده‌ام و می‌خواهم محتویات آن را با استفاده از این برنامه بخوانم.

همانطوری که می‌بینید با اینکه من مسیر درستی را وارد کرده‌ام، ولی برنامه با خطا مواجه می‌شود و نمی‌تواند فایل را پیدا کند.

دلیلش همان‌چیزی است که موقع گرفتن ورودی از کاربر دیدیم. ما کاراکتر \n را هم که در انتهای ورودی و با فشردن دکمه‌ی Enter وارد می‌شود در انتهای رشته‌ای که از کاربر ورودی می‌گیریم داریم. به همین دلیل به جای اینکه ورودی تابع read_file رشته‌ی ./smaple.txt باشد، رشته‌ی ./sample.txt\n خواهد بود. از آنجایی که چنین فایلی وجود ندارد، برنامه‌ی ما به خطا می‌خورد.

برای رفع این مشکل ما باید ورودی کاربر را نرمال کنیم. برای این کار کافی است که تمامی کاراکترهای whitespace را از ابتدا و انتهای ورودی کاربر حذف کنیم.

حذف whitespace ها از رشته و trim کردن آن

برای این کار ما می‌توانیم از متد tirm نوع String استفاده کنیم. این متد whitespace ها را از ابتدا و انتهای رشته حذف می‌کند و یک &str به ما برمی‌گرداند که از اوّلین کاراکتری که جزو whitespace ها نیست شروع می‌شود و تا قبل از شروع whitespace های انتهای رشته ادامه پیدا می‌کند.

در مثال ما یعنی اینکه از اوّل ورودی کاربر تا کاراکتر یکی مانده به آخر.

برای این کار باید تابع main ما یکم تغییر کند:

fn main() {
    let mut path = String::new();
    get_file_path(&mut path);
    let trimmed_path = path.trim();
    let file_content = read_file(trimmed_path);
    println!("file content:\n {}", file_content);
}

خب حالا علاوه بر این، باید تعریف تابع read_file را هم عوض کنیم. در آنجا باید به جای رفرنس به String یک &str را ورودی بگیریم.

fn read_file(file_name: &str) -> String {
حالا بیایید یک بار دیگر برنامه را اجرا کنیم.

کار کرد. 🙂

مدیریت خطا با unwrap

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

در این وقت‌ها می‌توانید متد unwrap را که برای Result پیاده‌سازی شده است صدا کنید. در این صورت اگر خطایی رخ بدهد برنامه panic! می‌کند. اگر هم خطایی رخ ندهد محتویات Ok بازگردانده می‌شود.

در برنامه‌ای که با هم نوشتیم می‌توانیم چنین حالتی را برای تابع get_file_path پیاده کنیم. اگر نتوانیم از کاربر ورودی بگیریم عملاً ادامه‌ی برنامه‌ی ما بی‌معنی است.

fn get_file_path(user_input: &mut String) {
    println!("Input file path:");
    io::stdin().read_line(user_input).unwrap();
}

حالا کدمان کم‌تر و ساده‌تر شده است. هروقت هم که امکان گرفتن ورودی از کاربر وجود نداشته باشد، برنامه و کاربر هر دو با هم panic! می‌کنند و خیالمان راحت می‌شود.

مدیریت خطا با expect

ما می‌توانیم دقیقاً کار قبلی را expect هم انجام بدهیم. با این تفاوت که expect از ما یک پیام هم به عنوان ورودی می‌گیرد و به عنوان پیام panic به کاربر نمایش می‌دهد.

fn get_file_path(user_input: &mut String) {
    println!("Input file path:");
    io::stdin().read_line(user_input).expect("We are unable to get input from the standard input.");
}

انتقال خطا به بخش‌های دیگر برنامه

خب ما توانستیم برنامه‌ی خودمان را بنویسیم و خطاهای احتمالی را مدیریت کنیم. درست است که نحوه‌ی مدیریت خطای ما باعث شد که روند اجرای برنامه دچار مشکل نشود، ولی عملاً با هر خطا روند اجرای برنامه به اتمام می‌رسد و ما نتوانستیم برنامه را از خطا بازیابی کنیم.

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

کاری که باید بکنیم این است که اگر خطای پیدا نشدن فایل رخ داد اجرای برنامه را متوقّف نکنیم. امّا اگر این کار را بکنیم، چطوری بفهمیم که فایل پیدا نشده؟ خب، همانطوری که تا اینجا توابعی که درون Rust نوشته شده بودند به ما این موضوع را می‌فهماندند.

کافی است که تابع read_file به جای String یک Result خروجی بدهد.

fn read_file(file_name: &str) -> Result<String, io::Error> {
    let read_result = fs::read_to_string(file_name);
    match read_result {
        Ok(content) => Ok(content),
        Err(error) => {
            match error.kind() {
                io::ErrorKind::NotFound => Err(error),
                _ => panic!("Something bad happened. :(")
            }
        }
    }
}

ما نوع خروجی را از String به Result<String, io::Error> تغییر دادیم. همانطوری که در نحوه‌ی تعریف Result در ابتدای این نوشته دیدیم، این enum موقع تعریف دو نوع Generic را می‌گیرد. اوّلی نوعی است که قرار است در صورت موفّقیّت‌آمیز بودن فرآیند در Ok قرار بگیرد. دومی هم نوعی است که در صورت بروز خطا در Err جای خواهد گرفت.

ما می‌خواهیم در صورت موفّقیّت، محتویات فایل را به صورت یک رشته برگردانیم، پس باید نوع اوّل را String انتخاب کنیم.

درون ماژول os هم ما یک ساختار Error داریم. اینجا می‌خواهیم از همان ساختار برای نمایش خطا در بخش‌های دیگر استفاده کنیم.

خب حالا باید کد main را هم تغییر بدهیم. این بار می‌خواهیم کد برنامه را درون یک loop اجرا کنیم. اجرای برنامه تا زمانی که خطایی وجود دارد ادامه پیدا می‌کند و ما از کاربر ورودی می‌گیریم. هروقت هم که توانستیم یک فایل را باز کنیم، محتویاتش را چاپ می‌کنیم و اجرای برنامه را به اتمام می‌رسانیم.

fn main() {
    let mut path= String::new();
    loop {
        path.clear();
        
        get_file_path(&mut path);
        let trimmed_path = path.trim();
        let file_reading_result = read_file(trimmed_path);
        
        match file_reading_result {
            Ok(file_content) => {
                println!("file content:\n {}", file_content);
                return;
            }
            Err(error) => {
                println!("{}", error);
                println!("Please try again.");
            }
        }

    }
}

ما ابتدا یک حلقه‌ی loop ساخته‌ایم. در اوّل این حلقه ما با فراخوانی متد clear روی رشته‌ی path مطمئن می‌شویم که مقدار size این رشته برابر با صفر قرار می‌گیرد. اگر این کار را نکنیم، وقتی که کاربر برای اوّلین بار ورودی اشتباه وارد کند، ورودی‌های بعدی به جای اینکه جایگزین ورودی اشتباه قبلی در path شوند، به انتهای آن اضافه می‌شوند که باعث می‌شود برنامه‌ی ما همیشه اشتباه کار کند.

بعد مانند قبل ورودی را می‌گیریم و تلاش می‌کنیم که فایل را بخوانیم. این بار نتیجه‌ی خواندن فایل را با یک match بررسی می‌کنیم. اگر همه‌چیز مرتّب بود، محتوای فایل را چاپ می‌کنیم و برنامه تمام می‌شود.

امّا اگر خطایی وجود داشت پیام خطا چاپ می‌شود و دوباره به ابتدای حلقه باز می‌گردیم تا از کاربر ورودی بگیریم.

بیایید این بار اجرای این برنامه را ببینیم:

نوشتن کد کم‌تر با عملگر ?

ما می‌توانیم تابع read_file را ساده‌تر بنویسیم. کاری که ما در این تابع انجام دادیم کار رایجی است و خیلی زیاد پیش می‌آید که در برنامه چنین کاری را انجام بدهیم. به همین خاطر در زبان Rust امکانی برای راحت‌تر انجام دادن آن فراهم شده است.

ما می‌توانیم در انتهای متد یا تابعی که یک Result را برمی‌گرداند، یک عملگر ? قرار بدهیم. کاری که این عملگر انجام می‌دهد خیلی ساده است.

اگر همه‌چیز مرتب بود که مقدار درون Ok را باز می‌گرداند. امّا اگر مشکلی پیش آمد، خطا را درون Err برمی‌گرداند و از تابع reutrn می‌کند.

این عملگر یک خوبی دیگر هم دارد. وقتی که از این عملگر استفاده می‌کنیم، در صورت لزوم با استفاده از ویژگی From (که بعداً با آن آشنا می‌شویم) خطای گرفته شده از متد یا تابع را به نوع خطایی که ما در خروجی مشخّص کرده‌ایم تبدیل می‌کند.

خب بیایید تابع read_file را دوباره با استفاده از ? بنویسیم.

fn read_file(file_name: &str) -> Result<String, io::Error> {
    let file_content: String = fs::read_to_string(file_name)?;
    return Ok(file_content);
}

ما در خط اوّل تلاش می‌کنیم که محتویات فایل را بخوانیم و درون متغیّر file_content بریزیم. اگر در اینجا با خطا روبه‌رو بشویم، اتّفاقی که می‌افتد این است که به صورت خودکار خطا درون Err قرار می‌گیرد و به عنوان یک نمونه از نوع io::Error به جایی که تابع را فراخوانی کرده است بازگردانده می‌شود.

امّا اگر همه‌چیز درست پیش برود، محتوای فایل به شکل یک String درون متغیّر file_content قرار می‌گیرد. در انتها هم ما چیزهایی که از فایل خوانده شده است را درون یک Ok باز می‌گردانیم.

البته حواستان باشد که الان منطق برنامه‌ی ما دیگر مثل قبل نیست. ما در پیاده‌سازی پیشین این تابع تنها خطای پیدا نکردن فایل را بازمی‌گرداندیم و اگر خطای دیگری رخ می‌داد panic می‌کردیم. ولی الان تمام خطاها به کسی که تابع را صدا زده برگردانده می‌شوند.

این یعنی اگر بخواهیم برنامه دقیقاً مثل قبل کار کند باید منطق بررسی نوع خطا را درون تابع main پیاده‌سازی کنیم.

ساختن خطای سفارشی

خب تا اینجا خیلی چیزها درمورد مدیریت خطاها یادگرفتیم. حالا شاید از خودتان بپرسید که چطوری می‌توانیم به جای استفاده از ساختارهای موجود، برای خودمان یک خطای سفارشی درست کنیم؟

این کار در Rust خیلی راحت است. کافی است که یک ساختار درست کنیم و ویژگی Error را برای آن پیاده‌سازی کنیم.

اوّل از همه یک ساختار به نام MyError تعریف می‌کنیم.

#[derive(Debug, Clone)]
struct MyError;

حواستان باشد که خطای ما باید از ویژگی‌های Debug و Clone هم پشتیبانی کند. حالا باید ویژگی Error را برای آن پیاده‌سازی کنیم. فعلاً همه‌چیز همان مقدار پیش‌فرضش را خواهد داشت.

use std::error;
impl error::Error for MyError {}

خب حالا برای اینکه مثل خطاهای پیش‌فرض، وقتی کسی خطای ما را درون println! قرار داد بتواند پیامش را هم چاپ کند، باید متد fmt ویژگی Debug را هم برایش پیاده‌سازی کنیم.

use std::fmt;

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Oooops.")
    }
}

فعلاً همین پیام ساده کافی است. حالا بیایید یک برنامه بنویسیم که صرفاً خطا تولید کند.

fn main() {
    let func_result = get_always_error();
    match func_result {
        Err(error) => println!("Error message: {}", error),
        _ => {}
    }
}

fn get_always_error() -> Result<i8, MyError> {
    let my_error: MyError = MyError{};
    return Err(my_error);
}

ما همیشه از get_always_error خطا می‌گیریم و آن را چاپ می‌کنیم. نتیجه می‌شود این:

Error message: Oooops.

به همین سادگی توانستیم برای خودمان یک خطای سفارشی بسازیم. شما می‌توانید هر داده‌ی دیگری که دوست‌داشتید را هم به ساختارتان اضافه کنید و خطای قابل استفاده‌تری برای خودتان بسازید.

فقط حواستان باشد که بهتر است متد source را هم همیشه برای خطاها پیاده‌سازی کنید. از آن‌جایی که هنوز Option را یادنگرفته‌ایم، فعلاً از خیر آن می‌گذریم.

خب این هم از این جلسه. امیدوارم متوجّه فلسفه‌ی مدیریت خطای Rust شده باشید و به خوبی مدیریت خطاها را یادگرفته باشید.

در قسمت‌های بعدی آموزش می‌بینم‌تان.

کدهای این قسمت در مخزن این آموزش‌ها در گیت‌هاب قرار گرفته است. می‌توانید با کلیک روی این نوشته به آنجا بروید و آن‌ها را ببینید.

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

دفعه‌ی اوّل است که این آموزش‌ها را می‌بینی؟ با کلیک روی این نوشته به قسمت اوّل برو و یادگیری زبان برنامه‌نویسی Rust را شروع کن.

یا حرفه‌ای شو یا برنامه‌نویسی را رها کن.

چطور می‌شود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلف‌کردن خودت را تبدیل به یک نیروی باتجربه بکنی؟

پاسخ ساده است. باید حرفه‌ای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.

«نوشته‌های مرتبط»

6 پاسخ به “آموزش زبان برنامه‌نویسی Rust – قسمت ۲۱:‌ مدیریت خطاها”

  1. towhid گفت:

    خیلی خوب بود
    منتظر بخش جذاب mutli thread هستم

  2. مجید گفت:

    عالی میشه ادامه بدید

  3. mr.Samadani گفت:

    فکر نمی کردم تو ایران کسی راست کار کنه… به هر حال خوشهالم که شما هارو پیدا کردم 😁

  4. bra گفت:

    سلام و درود
    ممنونم بابت این سری اموزش خیلی خوب
    ادامه مطالب چه زمانی قرار میگیره؟

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»