اذهب إلى المحتوى

الأخطاء والتعامل معها في لغة رست Rust


Naser Dakhel

لا مهرب من الأخطاء في دورة تطوير البرمجيات، لذا توفّر رست عددًا من المزايا للتعامل مع الحالات التي يحدث فيها شيء خاطئ، وتطلب رست منك في العديد من الحالات معرفتك باحتمالية حدوث الخطأ واتخاذ فعل ما قبل أن تُصرَّف compile الشيفرة البرمجية، ويجعل ذلك من برنامجك أكثر قوة بالتأكد من أنك ستكتشف الخطأ وستتعامل معه على نحوٍ مناسب قبل إطلاق شيفرتك البرمجية إلى مرحلة الإنتاج.

تصنِّف رست الأخطاء ضمن مجموعتين:

  • الأخطاء القابلة للحل recoverable errors
  • الأخطاء غير القابلة للحل unrecoverable errors

بالنسبة للأخطاء القابلة للحل، فهي أخطاء الهدف منها إعلام المستخدم بالمشكلة وإعادة محاولة العملية ذاتها مثل خطأ "لم يُعثَر على الملف file not found"، بينما تدل الأخطاء غير القابلة للحل دائمًا على أخطاء في الشيفرة البرمجية مثل محاولة الوصول إلى موقع يقع خارج نهاية مصفوفة وهذا يعني أننا نريد إيقاف تنفيذ البرنامج مباشرةً.

لا تميّز معظم لغات البرمجة بين النوعين السابقين وتتعامل معهما بنقس الطريقة باستخدام الاستثناءات exceptions، إلا أن رست لا تحتوي على الاستثناءات بل تحتوي على النوع Result<T, E>‎ للأخطاء القابلة للحل والماكرو panic!‎ الذي يوقف تنفيذ البرنامج عندما يصادف خطئًا غير قابل للحل، وسنغطّي في هذه المقال كلًا من استدعاء الماكرو panic!‎ والحصول على قيم النوع Result<T, E>‎.

قبل التعرف على أنواع الأخطاء وكيفية التعامل معها في لغة رست Rust، ندعوك للتعرف على الأخطاء البرمجية عامةً أولًا والتعرف على كيفية التعامل معها:

الأخطاء غير القابلة للحل باستخدام الماكرو panic!‎

قد تحدث بعض الأخطاء من حين إلى الآخر في شيفرتك البرمجية، ولا يوجد أي شيء تستطيع فعله لتمنع ظهورها، وفي هذه الحالة توفر لك رست الماكرو panic!‎.

هناك طريقتان لبدء حالة هلع panic، هما:

  • فعل شيء يتسبب بهلع الشيفرة البرمجية، مثل محاولة الوصول إلى مكان خارج نطاق مصفوفة.
  • أو استدعاء الماكرو panic!‎ مباشرةً.

تتسبب الحالتين السابقتين بحالة هلع لبرنامجنا وتطبع حالات الهلع هذه افتراضيًا رسالة فشل ومن ثم تفرّغ محتويات المكدس stack وتغادر البرنامج. يمكنك عرض محتويات استدعاء المكدس عند حدوث حالة الهلع في لغة رست باستخدام متغير بيئة environment variable وذلك حتى تصبح مهمة تتبع مصدر حالة الهلع أسهل.

كيفية الاستجابة إلى حالة هلع panic

يبدأ البرنامج باستعادة الحالة الأولية unwinding افتراضيًا عند حدوث حالة هلع، وهذا يعني أن رست تسترجع القيم الموجودة في المكدس وتفرغها، وتتضمن هذه العملية الكثير من العمل، لذلك تسمح لك رست باختيار الحل الثاني ألا وهو الخروج من البرنامج مباشرةً مما يُنهي تنفيذ البرنامج دون تفريغ المكدس.

عندها، تقع مسؤولية تحرير البيانات المستخدمة من البرنامج على عاتق نظام التشغيل. إذا أردت الحصول على ملف تنفيذي في مشروعك بحجم صغير قدر الإمكان فعليك عندها التحويل من استعادة الحالة الأولية إلى الخروج من البرنامج فور حدوث حالة هلع بكتابة panic = 'abort'‎ في قسم [profile] المناسب في ملف Cargo.toml. على سبيل المثال، إذا أردت الخروج من البرنامج فور حدوث حالة هلع في نمط الإطلاق release mode، فعليك بإضافة التالي:

[profile.release]
panic = 'abort'

دعنا نجرّب استدعاء الماكرو panic!‎ في برنامج بسيط:

اسم الملف: src/main.rs

fn main() {
    panic!("crash and burn");
}

عند تشغيل البرنامج ستحصل على خرج مشابه لما يلي:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

يتسبب استدعاء الماكرو panic!‎ برسالة الخطأ السابقة والموضحة في السطرين الأخيرين. يوضح السطر الأول رسالة الهلع ومكان حدوثه في شيفرتنا البرمجية، إذ يدل "src/main.rs:2:5" على أن حالة الهلع حدثت في السطر الثاني في المحرف الخامس ضمن الملف src/main.rs، ويكون السطر المشار إليه هو سطر ضمن شيفرتنا البرمجية التي كتبناها، وإذا ذهبنا إلى المكان المُحدّد فسنجد استدعاء الماكرو panic!‎.

قد يكون استدعاء الماكرو في حالات أخرى ضمن شيفرة برمجية أخرى تستدعيها شيفرتنا البرمجية وحينها سيكون اسم الملف ورقم السطر في رسالة الخطأ عائدين لشيفرة برمجية خاصة مكتوبة من قبل شخص آخر غيرنا وليس السطر الخاص بشيفرتنا البرمجية الذي أدى لاستدعاء panic!‎. يمكننا تتبع مسار backtrace الدالة التي استدعت panic!‎ لمعرفة الجزء الذي تسبب بالمشكلة ضمن شيفرتنا البرمجية، وسنناقش تتبع مسار الخطأ بالتفصيل تاليًا.

تتبع مسار panic!‎

دعنا ننظر إلى مثال آخر لرؤية ما الذي يحدث عندما يُستدعى الماكرو panic!‎ من مكتبة بسبب خطأ في شيفرتنا البرمجية، وذلك بدلًا من استدعائه من ضمن شيفرتنا البرمجية مباشرةً. توضح الشيفرة 1 محاولة الوصول إلى دليل في شعاع خارج نطاق الأدلة الصالحة.

اسم الملف: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

[شيفرة 1: محاولة الوصول إلى عنصر يقع خارج نهاية شعاع مما سيتسبب باستدعاء الماكرو panic!‎]

نحاول هنا الوصول إلى العنصر المئة في الشعاع (وهو العنصر ذو الدليل 99 لأن عدّ الأدلة يبدأ من الصفر)، إلا أن الشعاع يحتوي على ثلاثة عناصر فقط، وفي هذه الحالة تهلع رست؛ إذ من المفترض أن استخدام [] سيعيد قيمة عنصر إلا أن تمرير دليل غير صالح يتسبب بهلع رست لأنها لا تعلم القيمة التي يجب أن تُعيدها بصورةٍ صحيحة.

تتسبب هذه المحاولة في لغة سي C بسلوك غير معرف undefined behaviour، إذ من الممكن أن تحصل على قيمة عشوائية في مكان الذاكرة تلك على الرغم من أن حيز الذاكرة ذلك لا ينتمي إلى هيكل البيانات ويُدعى هذا الأمر بتجاوز المخزن المؤقت buffer overread، ويمكن أن يتسبب بخطورات أمنية إذا استطاع المهاجم التلاعب بالدليل بطريقة تمكّنه من قراءة معلومات لا يُفترض له أن يقرأها بحيث تكون مخزّنة بعد هيكل البيانات.

لحماية برنامجك من هذا النوع من الثغرات، توقف رست تنفيذ البرنامج وترفض المتابعة إذا حاولت قراءة عنصر موجود في دليل خارج النطاق، دعنا نجرّب ذلك ونرى ما الذي يحدث:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

تُشير رسالة الخطأ إلى السطر 4 ضمن ملف "main.rs" وذلك هو السطر الذي نحاول عنده الوصول إلى الدليل 99.

تُخبرنا الملاحظة التالية أنه يمكننا ضبط متغير البيئة RUST_BACKTRACE للحصول على مسار تتبع الخطأ ومعرفة سبب حدوثه، إذ يمثّل مسار تتبع الخطأ لائحةً من جميع الدوال التي استُدعيت إلى نقطة حدوث حالة الهلع، ويعمل في رست على نحوٍ مماثل للغات البرمجة الأخرى كما يلي: المفتاح في قراءة مسار تتبع الخطأ هو البدء من البداية إلى نقطة وصولك للملفات التي كتبتها إذ أن الملفات التي كتبتها ستكون نقطة ظهور المشكلة، والسطور التي تقع قبل تلك النقطة هي السطور التي استدعتها شيفرتك البرمجية والسطور التي تلي تلك النقطة هي السطور التي استدعَت شيفرتك البرمجية، وقد تتضمن كل من هذه السطور شيفرة برمجية خاصة برست أو شيفرة برمجية خاصة بالمكتبة القياسية أو وحدات مُصرّفة crates تستخدمها.

دعنا نجرب الحصول على مسار تتبع الخطأ بضبط متغير البيئة RUST_BACKTRACE إلى أي قيمة عدا 0، وسيكون خرج الشيفرة 2 التالي مشابهًا لما ستحصل عليه عندها.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:142:14
   2: core::panicking::panic_bounds_check
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/panicking.rs:84:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:242:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/slice/index.rs:18:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/alloc/src/vec/mod.rs:2591:9
   6: panic::main
             at ./src/main.rs:4:5
   7: core::ops::function::FnOnce::call_once
             at /rustc/e092d0b6b43f2de967af0887873151bb1c0b18d3/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

[الشيفرة 2: مسار تتبع الخطأ المولّد من استدعاء الماكرو panic!‎ والذي يُعرض عند ضبط متغير البيئة RUST_BACKTRACE]

هناك الكثبهسير من المعلومات في الخرج، وقد يكون الخرج الذي تراه أمامك مختلفًا عمّا ستحصل عليه بحسب نظام تشغيلك وإصدار رست. علينا تمكين رموز تنقيح الأخطاء debug symbols للحصول على مسار تتبع الأخطاء بالتفاصيل هذه، إذ تكون رموز تنقيح الأخطاء مفعّلة افتراضيًا باستخدام cargo build أو cargo run دون استخدام الراية ‎--release كما هو الحال هنا.

يدل السطر 6 في الشيفرة 2 إلى أن مسار تتبع الأخطاء يشير إلى السطر المسبب للمشكلة في مشروعنا ألا وهو السطر 4 في الملف src/main.rs، وإن لم نرد لبرنامجنا أن يهلع فعلينا البدء بالنظر إلى ذلك المكان المحدد في السطر الأول الذي يذكر الملف الذي كتبناه وهو الشيفرة 1 الذي يحتوي على شيفرة برمجية تتسبب بالهلع عمدًا، وتكمن طريقة حل حالة الهلع هذه في عدم محاولة الوصول إلى عنصر يقع خارج نطاق أدلة الشعاع. عليك أن تكتشف العمل الذي يتسبب بحالة الهلع في برنامجك في المستقبل وذلك بالنظر إلى القيم التي تتسبب بحالة الهلع ومن ثم النظر إلى الشيفرة البرمجية التي تسببت بها وتعديلها.

سنعود لاحقًا إلى الماكرو panic!‎ وإلى الحالات الواجب عدم استخدامها للتعامل مع الأخطاء في المقال التالي لاحقًا، إلا أننا سننتقل حاليًا إلى كيفية الحل من الأخطاء باستخدام Result.

الأخطاء القابلة للحل باستخدام Result

ليست معظم الأخطاء خطيرة وتتطلب إيقاف كامل البرنامج عند حدوثها، ففي بعض الأحيان يدل فشل عمل دالة ما على سبب ما يتطلب انتباهك واستجابتك له، فعلى سبيل المثال إذا فشلت عملية فتح ملف ما فهذا يعني غالبًا أن الملف الذي حددته غير موجود ولعلّك تفضّل إنشاء الملف وإعادة المحاولة بدلًا من إنهاء البرنامج كاملًا.

تذكر أننا ذكرنا سابقًا في مقال برمجة لعبة تخمين الأرقام بلغة رست Rust أن المعدد enum الذي يُدعى Result معرّف وداخله متغايرَان variants، هما Ok و Err كما يلي:

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

يمثّل كل من T و E معاملات نوع معمّم generic، وسنناقش الأنواع المعممة بتفصيل أكثر لاحقًا، ويكفي الآن معرفة أن T يمثّل نوع القيمة التي ستُعاد في حالة النجاح مع المتغاير Ok، بينما يمثل E نوع الخطأ الذي سيُعاد في حالة الفشل مع المتغاير Err، ولأن Result تحتوي على معاملات النوع المعمم فيمكننا استخدام النوع Result والدوال المعرفة ضمنها في العديد من الحالات، إذ تختلف كل من قيمة النجاح وقيمة الخطأ التي نريد أن نُعيدها.

دعنا نستدعي دالةً تُعيد القيمة Result لأن الدالة قد تفشل. نحاول في الشيفرة 3 فتح ملف.

اسم الملف: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

[الشيفرة 3: فتح ملف]

النوع المُعاد من File::open هو Result<T, E>‎. يُملأ المعامل المعمّم T من خلال تنفيذ File::open مع نوع قيمة النجاح ألا وهي std::fs::File والتي تمثّل مقبض الملف file handle، بينما يُستخدم النوع E لتخزين قيمة الخطأ std::io::Error.

يعني النوع المُعاد هذا أن استدعاء File::open قد ينجح ويُعيد مقبض ملف يمكن الكتابة إليه أو القراءة منه، إلا أن استدعاء الدالة قد يفشل في حال لم يكن الملف موجودًا على سبيل المثال، أو عند عدم توافر الصلاحيات المناسبة للوصول إليه؛ وبالتالي، ينبغي على الدالة File::open أن تمتلك القدرة على إخبارنا فيما إذا نجحت العملية ومنحنا مقبض الملف، أو إذا فشلت وتوفّر معلومات مناسبة عن الخطأ بنفس الوقت، وهذه هي المعلومات الموجودة فعلًا في المعدّد Result.

ستكون قيمة المتغير greeting_file_result في حال نجاح File::open نسخةً من Ok تحتوي على مقبض الملف، وإلا فستكون قيمة المتغير greeting_file_result في حال الفشل نسخةً من Err تحتوي على المزيد من المعلومات حول نوع الخطأ الذي حدث.

نحتاج الإضافة إلى الشيفرة 3 لتتخّذ بعض الإجراءات المختلفة بحسب قيمة File::open المُعادة، وتوضح الشيفرة 4 طريقة من الطرق للتعامل مع Result باستخدام أداة بسيطة ألا وهي تعبير match الذي ناقشناه سابقًا.

اسم الملف: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

[الشيفرة 4: استخدام تعبير match للتعامل مع متغايرات Result]

لاحظ أن المعدد Result ومتغايراته -كما هو الحال مع المعدد Option- أُضيف إلى النطاق في بداية الشيفرة البرمجية، لذا ليس علينا تحديد Result::‎ قبل المتغايرين Ok و Err في أذرع match.

تُعيد الشيفرة البرمجية قيمة file الداخلية من المتغاير Ok عندما تكون النتيجة Ok، ومن ثم نُسند قيمة مقبض الملف إلى المتغير greeting_file، ومن ثم يمكننا استخدام مقبض الملف للقراءة منه أو الكتابة إليه بعد التعبير match.

تتعامل الذراع الأخرى من match مع الحالات التي نحصل فيها على قيمة Err من File::open. استدعينا في هذا المثال الماكرو panic!‎، إذ سنحصل على الخرج التالي من الماكرو إذا لم يكن هناك أي ملف باسم "hello.txt" في المسار الحالي عند تشغيل الشيفرة البرمجية:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

يخبرنا الخرج عن الخطأ بالتحديد كما اعتدنا.

مطابقة عدة أخطاء

ستهلع الشيفرة 4 (أي ستتسبب باستدعاء الماكرو panic!‎) عند فشل File::open لأي سبب من الأسباب، إلا أنه من الممكن أن نتخذ إجراءات مختلفة لكل سبب من الأسباب: على سبيل المثال نريد إنشاء ملف وإعادة مقبضه إذا فشلت File::open بسبب عدم وجود الملف؛ وإلا فنريد الشيفرة أن تهلع باستخدام panic!‎ إذا كان السبب مختلفًا -مثل عدم امتلاكنا للأذونات المناسبة- بالطريقة ذاتها في الشيفرة 4، ولتحقيق ذلك نُضيف تعبير match داخلي كما هو موضح في الشيفرة 5.

اسم الملف: src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}

[الشيفرة 5: التعامل بصورةٍ مختلفة مع أخطاء مختلفة]

نوع القيمة التي تعيدها File::open داخل متغاير Err هو io::Error وهو هيكل struct موجود في المكتبة القياسية، ويحتوي هذا الهيكل على التابع kind الذي يمكننا استدعاءه للحصول على القيمة io::ErrorKind. يحتوي المعدّد io::ErrorKind الموجود في المكتبة القياسية على متغايرات تمثل الأنواع المختلفة من الأخطاء التي قد تنتج من عملية io، والمتغاير الذي نريد استخدامه هنا هو ErrorKind::NotFound الذي يشير إلى الملف الذي نحاول فتحه إلا أنه غير موجود بعد، لذا نُطابقه مع greeting_file_result إلا أنه يوجد تعبير match داخلي خاص بالتابع error.kind()‎.

الشرط الذي نريد أن نتحقق منه في تعبير match الداخلي هو فيما إذا كانت القيمة المُعادة من error.kind()‎ هي المتغاير NotFound من المعدد ErrorKind، فإذا كان هذا الحال فعلًا فسنحاول إنشاء ملف باستخدام File::create، وإذا فشل File::create أيضًا، فنحن بحاجة ذراع آخر في تعبير match الداخلي، وعندما لا يكون من الممكن إنشاء الملف تُطبع رسالة خطأ مختلفة. تبقى ذراع match الخارجية الثانية كما هي حتى يهلع البرنامج عند حدوث أي خطأ ما عدا خطأ عدم العثور على الملف.

بدائل لاستخدام match مع Result

استخدمنا كثيرًا من تعابير match، فهي مفيدةٌ جدًا إلا أنها بدائية، وسنتحدث لاحقًا عن المغلفات closures التي تُستخدم مع الكثير من التوابع المعرّفة في Result<T, E>‎، وقد تكون هذه التوابع أكثر اختصارًا من match عند التعامل مع قيم Result<T, E>‎ في شيفرتك البرمجية. على سبيل المثال، إليك طريقة أخرى لكتابة المنطق ذاته الموضح في الشيفرة 5، ولكن سنستخدم في هذه المرة المغلفات وتابع unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

على الرغم من أن هذه الشيفرة البرمجية تبدي السلوك ذاته الخاص بالشيفرة 5، إلا أنها لا تحتوي على أي تعبير match وهي أوضح للقراءة. ألقِ نظرةً على التابع unwrap_or_else وكيفية عمله في توثيق المكتبة القياسية إذا أردت وعُد مرةً ثانية لهذا المثال. تغنينا العديد من التوابع الأخرى عن الحاجة لاستخدام تعابير match متداخلة عند التعامل مع الأخطاء.

اختصارات للهلع عند حصول الأخطاء باستخدام unwrap و expect

يفي استخدام match بالغرض، إلا أن استخدامه يتطلب كتابة مطوّلة ولا يدلّ على الغرض منه بوضوح. بدلًا من ذلك يحتوي النوع Result<T, E>‎ العديد من التوابع المساعدة المعرفة بداخله لإنجاز مهام متعددة ومحددة، إذ يمثّل التابع unwrap مثلًا تابعًا مختصرًا يؤدي مهمّة التعبير match الذي كتبناه في الشيفرة 4، فإذا كانت قيمة Result هي المتغاير Ok، فسيعيد القيمة الموجودة في Ok وإلا إذا احتوى على المتغاير Err، فسيستدعي الماكرو panic!‎. إليك مثالًا عمليًا على unwrap:

اسم الملف: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

إذا نفذت الشيفرة البرمجية السابقة دون وجود الملف hello.txt، سنحصل على رسالة خطأ من استدعاء panic!‎ بسبب التابع unwrap:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:4:49

يسمح لنا التابع expect باختيار رسالة خطأ panic!‎ بصورةٍ مشابهة، كما يمكن أن ينقل استخدام expect بدلًا من unwrap وتقديم رسالة خطأ معبّرة قصدك جيدًا، مما يساعدك في تعقب مصدر الهلع بصورةٍ أفضل. يمكننا استخدام expect على الشكل التالي:

اسم الملف: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

نستخدم expect كما نستخدم unwrap، إما لإعادة مقبض الملف أو لاستدعاء الماكرو panic!‎. تمثل رسالة الخطأ التي سترسل باستخدام expect لاستدعاء panic!‎ معاملًا يُمرّر إلى expect بدلًا من رسالة panic!‎ الافتراضية التي يستخدمها التابع unwrap، إليك ما ستبدو عليه الرسالة:

thread 'main' panicked at 'hello.txt should be included in this project: Os {
code: 2, kind: NotFound, message: "No such file or directory" }',
src/main.rs:5:10

يختار معظم مبرمجي لغة رست عند كتابة شيفرة برمجية مُخرجة جيدًا التابع expect بدلًا من unwrap لمنح السياق المناسب عن سبب نجاح العملية دومًا، ويمكنك بهذه الطريقة الحصول على معلومات أكثر لتستخدمها في تنقيح الأخطاء في حال كانت افتراضاتك خاطئة.

نشر الأخطاء

يُمكنك إعادة الخطأ الناتج عن استدعاء دالةٍ ما شيئًا قد يفشل إلى الشيفرة البرمجية المُستدعية له للتعامل مع الخطأ بدلًا من التعامل مع الخطأ داخل الدالة نفسها، وهذا يُعرف بنشر propagating الخطأ ويُعطي تحكمًا أكبر بالشيفرة البرمجية التي استدعت هذا الخطأ، إذ يمكننا توفير المزيد من المعلومات أو المنطق الذي يتعامل مع الخطأ بصورةٍ أفضل عما هو موجود في سياق شيفرتك البرمجية.

على سبيل المثال، ألقِ نظرةً على الشيفرة 6 التي تقرأ اسم مستخدم من ملف، وتُعيد الدالة خطأ عدم وجود الملف أو عدم القدرة على قرائته إلى الشيفرة البرمجية التي استدعت الدالة.

اسم الملف: src/main.rs

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

[الشيفرة 6: دالة تعيد الأخطاء إلى الشيفرة البرمجية التي استدعتها باستخدام تعبير match]

يُمكن كتابة هذه الدالة بطريقة أقصر إلا أننا سنبدأ بكتابة معظمها يدويًا حتى نفهم التعامل مع الأخطاء أكثر، ثم سننظر إلى الطريقة الأقصر. دعنا ننظر إلى النوع المُعاد من الدالة أولًا ألا وهو Result<String, io::Error>‎ وهذا يعني أن الدالة تُعيد قيمةً من النوع Result< T, E>‎، إذ يُملأ النوع المعمّم T بالنوع String، بينما يُملأ النوع المعمم E بالنوع io::Error.

تحصل الشيفرة البرمجية التي استدعت الدالة في حال عمل الدالة دون أي مشاكل على القيمة Ok التي تخزِّن داخلها قيمةً من النوع String ألا وهو اسم المستخدم الذي قرأته الدالة من الملف، وإذا واجهت الدالة خلال عملها أي خطأ، تحصل الشيفرة البرمجية التي استدعت الدالة على القيمة Err التي تخزن داخلها نسخةً من io::Error تحتوي على مزيدٍ من المعلومات حول المشاكل التي جرت. اخترنا io::Error نوعًا للقيمة المُعادة لأنه يوافق نوع قيمة الخطأ المُعاد من كلا العمليتين التي نستدعي فيهما الدالة اللتان قد تفشلان ألا وهما الدالة File::open والتابع read_to_string.

يبدأ متن الدالة باستدعاءٍ للدالة File::open، ثمّ نتعامل مع القيمة Result في match بطريقة مماثلة للتعبير match في الشيفرة 4؛ فإذا نجح عمل الدالة File::open يصبح مقبض الملف في متغير النمط file بقيمة المتغير القابل للتغيير username_file ويستمر تنفيذ الدالة، إلا أننا نستخدم الكلمة المفتاحية return في حالة Err عوضًا عن استدعاء panic! للخروج من الدالة وتمرير قيمة الخطأ الناتجة عن File::open في متغير النمط e إلى الشيفرة البرمجية التي استدعت الدالة.

إذًا، تُنشئ الدالة قيمة String جديدة في المتغير username إذا كان لدينا مقبض ملف في username_file، ثم تستدعي التابع read_to_string باستخدام مقبض الملف في المتغير username_file لقراءة محتويات الملف إلى المتغير username.

يعيد التابع read_to_string قيمة Result أيضًا لأنها من الممكن أن تفشل على الرغم من نجاح File::open، لذا فنحن بحاجة تعبير match آخر للتعامل مع قيمة Result على النحو التالي: تنجح دالتنا إذا نجح التابع read_to_string ونُعيد اسم المستخدم من الملف الموجود في username والمغلّف بقيمة Ok، وإلا إذا فشل read_to_string نُعيد قيمة الخطأ بطريقة إعادة الخطأ ذاتها في match التي تعاملت مع القيمة المُعادة من File::open، إلا أننا لسنا بحاجة كتابة الكلمة المفتاحية return هنا لأن هذا هو آخر تعبير في الدالة.

ستتعامل الشيفرة البرمجية التي تستدعي هذه الشيفرة البرمجية مع حالة الحصول على قيمة Ok تحتوي على اسم مستخدم، أو قيمة Err تحتوي على قيمة من النوع io::Error، ويعود اختيار الإجراء المُتّخذ إلى الشيفرة البرمجية التي استدعت الدالة، فيمكن للشيفرة البرمجية أن تستدعي الماكرو panic!‎ وأن توقف البرنامج فورًا في حال الحصول على قيمة Err، أو استخدام اسم مستخدم افتراضي، أو البحث على اسم المستخدم في مكان آخر عوضًا عن الملف. لا نمتلك ما يكفي من المعلومات حول الشيء الذي ستفعله الشيفرة البرمجية التي استدعت الدالة، لذا فنحن ننشر معلومات الخطأ أو النجاح للشيفرة البرمجية للتعامل معها بصورةٍ مناسبة.

يُعد نمط نشر الأخطاء هذا شائع جدًا في رست، وتقدم لنا رست عامل إشارة الاستفهام ? لاستخدام هذا النمط بسهولة.

اختصار لنشر الأخطاء: عامل ?

توضح الشيفرة 7 تطبيقًا للدالة read_username_from_file بوظيفة مماثلة للشيفرة 6، إلا أننا نستخدم هنا العامل ?.

اسم الملف: src/main.rs

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

[الشيفرة 7: دالة تعيد أخطاء للشيفرة البرمجية المُستدعية باستخدام العامل ?]

العامل ? الموجود بعد القيمة Result مُعرَّف بحيث يعمل بطريقة مماثلة لعمل تعابير match التي عرفناها سابقًا بهدف التعامل مع قيم Result المختلفة في الشيفرة 6، فإذا كانت القيمة Result هي Ok تُعاد القيمة داخل Ok من التعبير هذا ويستمر تنفيذ البرنامج، بينما إذا كانت القيمة Err فتُعاد القيمة الموجودة داخل Err من الدالة ككُل وكأننا استخدمنا الكلمة المفتاحية return وبالتالي تُنشر قيمة الخطأ إلى الشيفرة البرمجية التي استدعت الدالة.

هناك فرقٌ ما بين ما يفعله التعبير match في الشيفرة 6 وبين ما يفعله العامل ?؛ إذ أن الأخطاء التي تُستدعى عن طريقة العامل ? تمرّ بدالة from، المعرّفة في السمة From في المكتبة القياسية التي تُستخدم لتحويل القيم من نوع إلى نوع آخر؛ فعندما يستدعي العامل ? الدالة from يُحوَّل الخطأ المُتلقى إلى نوع الخطأ المعرف في نوع القيمة المُعادة ضمن الدالة الحالية، وهذا الأمر مفيد عندما تُعيد الدالة نوعًا واحدًا من الخطأ لتمثيل جميع حالات فشل الدالة، حتى لو كانت الأجزاء التي قد تفشل ضمن الدالة تفشل لأسباب مختلفة.

على سبيل المثال، يمكننا التعديل على الدالة read_username_from_file في الشيفرة 7 لتُعيد نوع خطأ مخصص نعرّفه اسمه OurError. إذا عرفنا أيضًا impl From<io::Error> for OurError لإنشاء نسخة من OurError من io::Error فهذا يعني أن العامل ? المُستدعى في متن الدالة read_username_from_file سيستدعي from ويحوّل أنواع الأخطاء دون الحاجة لكتابة شيفرة برمجية إضافية لهذا الغرض.

في سياق الشيفرة 7: سيُعيد العامل ? في نهاية استدعاء File::open القيمة الموجودة داخل Ok إلى المتغير username_file، وإذا حدث خطأ ما فسيعيد العامل ? قيمةً من Err إلى الشيفرة التي استدعت الدالة وتوقف تنفيذ الدالة مبكرًا، وينطبق الأمر ذاته على العامل ? في نهاية استدعاء read_to_string.

يُغنينا العامل ? عن كتابة أي شيفرات برمجية متكررة ويجعل من كتابة الدالة عمليةً أسهل وأسرع، إلا أنه يمكننا جعل اللشيفرة البرمجية هذه أقصر أكثر عن طريق كتابة استدعاءات التوابع الواحدة تلو الأخرى مباشرةً بعد العامل ? كما هو موضح في الشيفرة 8.

اسم الملف: src/main.rs

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}

[الشيفرة 8: كتابة استدعاءات التوابع بصورة متسلسلة بعد العامل ?]

نقلنا عملية إنشاء String الجديد في username إلى بداية الدالة، ولم نغيّر ذلك الجزء، وبدلًا من إنشاء متغير username_file، كتبنا استدعاء read_to_string قبل نتيجة File::open("hello.txt:)?‎ مباشرةً، إلا أن العامل ? ما زال موجودًا في نهاية استدعاء read_to_string وما زلنا نُعيد قيمة Ok تحتوي على username عندما تنجح كل من File::open و read_to_string بدلًا من إعادة الأخطاء. وظيفة الشيفرة البرمجية مماثلة لكل من الشيفرة 6 والشيفرة 7 إلا أن الفارق هنا أن الشيفرة هذه أكثر سهولة للكتابة.

توضح الشيفرة 9 طريقةً أكثر اختصارًا باستخدام fs::read_to_string.

اسم الملف: src/main.rs

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

[الشيفرة 9: استخدام fs::read_to_string بدلًا من فتح وقراءة الملف بخطوتين منفصلتين]

تُعد قراءة محتويات ملف ما وإسنادها إلى سلسلة نصية عمليةً شائعةً جدًا، لذا تُقدّم المكتبة القياسية دالةً لتحقيق هذه العملية ألا وهي fs::read_to_string، إذ تفتح الملف ومن ثم تُنشئ سلسلةً نصيةً String جديدة وتقرأ محتويات الملف وتُسندها إلى السلسلة النصية وتُعيد السلسلة النصية String أخيرًا، إلا أن استخدام fs::read_to_string لا يُتيح لنا إمكانية شرح جميع حالات التعامل مع الأخطاء، وهذا السبب في تقديمنا للطريقة الأطول أولًا.

أماكن استخدام العامل ?

يُمكن استخدام العامل ? فقط في الدوال التي تُعيد نوعًا متوافقًا مع العامل ?، وذلك لأن العامل ? معرّف ليُجري عملية إعادة لقيمة بصورةٍ مبكرة خارج الدالة بطريقة تعبير match ذاتها الذي عرفناه في الشيفرة 6. نلاحظ في الشيفرة 6 أن match استخدم القيمة Result وأعاد الذراع القيمة Err(e)‎، ينبغي على النوع المُعاد من الدالة أن يكون Result لكي يكون متوافقًا مع التعليمة return هذه.

دعنا ننظر في الشيفرة 10 إلى الخطأ الذي سنحصل عليه في حال استخدمنا العامل ? في الدالة main بنوع مُعاد غير متوافق مع الأنواع الخاصة بالعامل ?:

اسم الملف: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

[الشيفرة 10: محاولة استخدام العامل ? في الدالة main التي تُعيد () وهي قيمة غير متوافقة]

تفتح الشيفرة البرمجية السابقة ملفًا، وقد تفشل عملية فتحه. يتبع العامل ? القيمة Result المُعادة من File::open إلا أن الدالة main تحتوي على النوع المُعاد () وليس Result، وعندما نصرّف الشيفرة البرمجية السابقة نحصل على رسالة الخطأ التالية:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | / fn main() {
4 | |     let greeting_file = File::open("hello.txt")?;
  | |                                                ^ cannot use the `?` operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error

يشير هذا الخطأ إلى أنه من غير المسموح استخدام العامل ? إلا في دالة تُعيد Result، أو Option، أو أي نوع آخر يطبّق FromResidual.

يوجد خياران لإصلاح الخطأ السابق: الأول هو تغيير نوع القيمة المُعادة من الدالة لتصبح متوافقةً مع القيمة التي تستخدم العامل ? عليها وهذا خيار جيّد طالما لا يوجد أي قيود أخرى تمنعك من ذلك، أما الخيار الثاني فهو باستخدام match أو إحدى توابع Result<T, E>‎ للتعامل مع Result<T, E>‎ بالطريقة المناسبة.

ذكرت رسالة الخطأ أيضًا أنه يمكننا استخدام العامل ? مع قيم Option<T>‎ أيضًا بالطريقة ذاتها التي نستخدم فيها العامل مع Result، إلا أنه يمكنك استخدام العامل على Option فقط في حال كانت الدالة تُعيد Option. يشبه سلوك العامل ? عند استدعائه على Option<T, E>‎ سلوكه عند استدعائه على Result<T, E>‎، إذ تُعاد القيمة None كما هي مبكرًا من الدالة، وإذا كانت القيمة Some فالقيمة التي بداخل Some هي القيمة الناتجة عن ذلك التعبير، وتستمر الدالة عندها بالتنفيذ. تحتوي الشيفرة 11 على مثال لدالة تعثر على المحرف الأخير من السطر الأول في سلسلة نصية.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

[الشيفرة 11: استخدام العامل ? على قيمة Option<T>‎]

تُعيد الدالة Option<char>‎ لأنه من الممكن أن يكون هناك محرف في النتيجة أو لا. تأخذ الشيفرة البرمجية السابقة شريحة السلسلة النصية string slice‏ text وسيطًا، وتستدعي التابع lines عليها مما يُعيد مُكرّرًا iterator عبر السطور في السلسلة النصية.

ولأن هذه الدالة تهدف لفحص السطر الأول فهي تستدعي next على المكرر للحصول على القيمة الأولى منه، وإذا كان text سلسلة نصية فارغة فسيُعيد استدعاء next القيمة None وفي هذه الحالة نستخدم العامل ? لإيقاف التنفيذ وإعادة القيمة None من الدالة last_char_of_first_line. إذا لم يكن text سلسلةً نصيةً فارغة فسيُعيد استدعاء next قيمة Some تحتوي على شريحة سلسلة نصية تحتوي على السطر الأول من text.

يستخلص العامل ? شريحة السلسلة النصية ويمكننا استدعاء chars على شريحة السلسلة النصية للحصول على مكرّر يحتوي على محارفه. ما نبحث عنه هنا هو المحرف الأخير من السطر الأول، لذلك نستدعي last للحصول على آخر عنصر موجود في المكرر وهي قيمة Option لأنه من الممكن أن يكون السطر الأول سلسلة نصية فارغة، إذا من الممكن مثلًا أن يبدأ text بسطر فارغ وأن يحتوي على محارف في السطور الأخرى مثل "‎\nhi"، فإذا كان هناك فعلًا محرف في نهاية السطر فإننا نحصل عليه داخل متغاير Some.

يُعطينا العامل ? الموجود في المنتصف طريقة موجزة للتعبير عن هذا المنطق مما يسمح لنا بتطبيق محتوى الدالة بسطر واحد، وإذا لم نستطع تطبيق العامل ? على Option، سيتوجب علينا كتابة المنطق ذاته باستخدام عدد أكبر من استدعاءات للدوال أو باستخدام التعبير match.

لاحظ أنه يمكنك استخدام العامل ? على Result داخل دالة تُعيد Result، ويمكنك استخدام العامل ? على Option داخل دالة تُعيد Option إلا أنه لا يمكنك الخلط ما بين الاثنين، إذ لن يحول العامل ? النوع Result إلى Option أو بالعكس تلقائيًا، ويمكنك في هذه الحالات استخدام توابع، مثل التابع ok على النوع Result، أو التابع ok_or على النوع Option لإجراء التحويل صراحةً.

استخدمت جميع دوال main حتى هذه اللحظة القيمة المُعادة (). تُعد الدالة mainمميزةً لأنها نقطة بداية ونهاية البرامج التنفيذية وبالتالي هناك بعض القيود على الأنواع المُعادة لكي تتصرف البرامج على النحو الصحيح كما هو متوقع.

لحسن الحظ، تُعيد الدالة main النوع Result<(), E>‎. تحتوي الشيفرة 12 على الشيفرة البرمجية الموجودة في الشيفرة 10، إلا أننا عدلنا النوع المُعاد من الدالة main ليصبح Result<(), Box<dyn Error>>‎ وأضفنا قيمة مُعادة Ok(())‎ في النهاية، وستُصرَّف الشيفرة البرمجية نتيجةً لهذه التعديلات بنجاح:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

[الشيفرة 12: تعديل الدالة main لتُعيد Result<(), E>‎ لتسمح لنا باستخدام العامل ? على قيم Result]

النوع Box<dyn Error>‎ هو كائن سمة trait object وهو ما سنغطيه لاحقًا، ويكفي الآن معرفتك أن Box<dyn Error>‎ يعني "أي نوع من الأخطاء". استخدام العامل ? على قيمة Result في دالة main باستخدام قيمة الخطأ Box<dyn Error>‎ هو أمر مسموح، وذلك لأنه يسمح لأي نوع Err أن يُعاد مبكرًا، وعلى الرغم من أن محتوى الدالة main سيعيد الأخطاء من النوع std::io::Error فقط إلا أن بصمة الدالة ستبقى صالحة بتحديد Box<dyn Error>‎ إذا أُضيفت شيفرة برمجية تُعيد أخطاء أخرى داخل الدالة main.

يتوقف الملف التنفيذي عندما تُعيد الدالة main القيمة Result<(), E>‎ وذلك بإعادة القيمة "0" إذا أعادت main القيمة Ok(())‎ وقيمة غير صفرية إذا أعادت الدالة قيمة Err. تُعيد الملفات التنفيذية المكتوبة بلغة سي أعدادًا صحيحة عند مغادرة البرنامج؛ فالبرنامج الذي يتوقف بنجاح يُعيد العدد الصحيح "0"؛ بينما يُعيد البرنامج الذي يتوقف بسبب خطأ قيمة عدد صحيح لا تساوي "0". تُعيد رست أيضًا أعدادًا صحيحة من الملفات التنفيذية بصورةٍ مماثلة لهذا الاصطلاح.

قد تُعيد الدالة main أي نوع يطبّق السمة std::process::Termination التي تحتوي بدورها على دالة تدعى report تُعيد قيمة ExitCode، انظر إلى توثيق المكتبة القياسية للمزيد من المعلومات حول استخدام سمة Termination ضمن أنواعك.

الآن، بعد مناقشتنا التفاصيل الخاصة بالماكرو panic!‎ وإعادة النوع Result، سنعود تاليًا إلى موضوع كيفية تحديد الاستخدام المناسب لكل حالة.

ترجمة -وبتصرف- لقسم من الفصل Error Handling من كتاب The Rust Programming Language.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...