لا مهرب من الأخطاء في دورة تطوير البرمجيات، لذا توفّر رست عددًا من المزايا للتعامل مع الحالات التي يحدث فيها شيء خاطئ، وتطلب رست منك في العديد من الحالات معرفتك باحتمالية حدوث الخطأ واتخاذ فعل ما قبل أن تُصرَّف 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.
اقرأ أيضًا
- المقال التالي: الاختيار ما بين الماكرو panic! والنوع Result للتعامل مع الأخطاء في لغة Rust
- المقال السابق: كيفية استخدام النوع HashMap لتخزين البيانات في رست Rust
- أنواع البيانات Data Types في لغة رست Rust
- الحزم packages والوحدات المصرفة crates في لغة رست Rust
- الأخطاء السبع القاتلة لأيّ مشروع برمجيات
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.