استخدام panic! أو عدم استخدامه (To panic! or Not to panic!)
إذاً، كيف تقرر متى يجب عليك استدعاء panic! ومتى يجب عليك إعادة Result؟ عندما يصاب الكود بـ (الذعر) panic ، لا توجد طريقة للاسترداد. يمكنك استدعاء panic! في أي حالة خطأ، سواء كانت هناك طريقة ممكنة للاسترداد أم لا، ولكنك بذلك تتخذ القرار نيابة عن الكود المستدعي بأن الموقف غير قابل للاسترداد. عندما تختار إعادة قيمة Result ، فإنك تمنح الكود المستدعي خيارات؛ حيث يمكن للكود المستدعي اختيار محاولة الاسترداد بطريقة مناسبة لموقفه، أو يمكنه تقرير أن قيمة Err في هذه الحالة غير قابلة للاسترداد، وبالتالي يمكنه استدعاء panic! وتحويل خطئك القابل للاسترداد إلى خطأ غير قابل للاسترداد. لذلك، تعد إعادة Result خياراً افتراضياً جيداً عند تعريف دالة قد تفشل.
في حالات مثل الأمثلة، وكود النماذج الأولية، والاختبارات، يكون من الأنسب كتابة كود يصاب بالذعر بدلاً من إعادة Result. دعنا نستكشف السبب، ثم نناقش الحالات التي لا يستطيع فيها المترجم معرفة أن الفشل مستحيل، ولكن يمكنك أنت كبشر معرفة ذلك. سينتهي الفصل ببعض الإرشادات العامة حول كيفية تقرير ما إذا كان يجب استخدام panic! في كود المكتبة.
الأمثلة، وكود النماذج الأولية، والاختبارات (Examples, Prototype Code, and Tests)
عندما تكتب مثالاً لتوضيح مفهوم ما، فإن تضمين كود قوي للتعامل مع الأخطاء يمكن أن يجعل المثال أقل وضوحاً. في الأمثلة، من المفهوم أن استدعاء (منهج) method مثل unwrap الذي قد يؤدي إلى panic هو بمثابة مكان محجوز للطريقة التي تريد أن يتعامل بها تطبيقك مع الأخطاء، والتي يمكن أن تختلف بناءً على ما يفعله باقي الكود الخاص بك.
وبالمثل، فإن منهجي unwrap و expect مفيدان جداً عندما تقوم ببناء (نموذج أولي) prototyping ولم تكن مستعداً بعد لتقرير كيفية التعامل مع الأخطاء؛ فهما يتركان علامات واضحة في كودك للموعد الذي ستكون فيه مستعداً لجعل برنامجك أكثر قوة.
إذا فشل استدعاء منهج في اختبار ما، فستحتاج إلى فشل الاختبار بأكمله، حتى لو لم يكن هذا المنهج هو الوظيفة قيد الاختبار. ولأن panic! هي الطريقة التي يتم بها وضع علامة على الاختبار كفاشل، فإن استدعاء unwrap أو expect هو بالضبط ما يجب أن يحدث.
عندما تمتلك معلومات أكثر من المترجم (When You Have More Information Than the Compiler)
سيكون من المناسب أيضاً استدعاء expect عندما يكون لديك منطق آخر يضمن أن Result ستحتوي على قيمة Ok ، ولكن هذا المنطق ليس شيئاً يفهمه المترجم. ستظل لديك قيمة Result تحتاج إلى التعامل معها: فأي عملية تستدعيها لا تزال لديها إمكانية الفشل بشكل عام، على الرغم من أنها مستحيلة منطقياً في حالتك الخاصة. إذا كان بإمكانك التأكد من خلال فحص الكود يدوياً أنك لن تحصل أبداً على (متغير) variant من نوع Err ، فمن المقبول تماماً استدعاء expect وتوثيق السبب الذي يجعلك تعتقد أنك لن تحصل أبداً على Err في نص الوسيطة. إليك مثالاً:
fn main() {
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1"
.parse()
.expect("Hardcoded IP address should be valid");
}
نحن نقوم بإنشاء (نموذج) instance من IpAddr عن طريق تحليل سلسلة نصية مكتوبة برمجياً (Hardcoded). يمكننا أن نرى أن 127.0.0.1 هو عنوان IP صالح، لذا فمن المقبول استخدام expect هنا. ومع ذلك، فإن وجود سلسلة نصية صالحة ومكتوبة برمجياً لا يغير نوع الإرجاع لمنهج parse: فنحن لا نزال نحصل على قيمة Result ، وسيظل المترجم يجبرنا على التعامل مع Result كما لو كان متغير Err ممكناً لأن المترجم ليس ذكياً بما يكفي ليرى أن هذه السلسلة هي دائماً عنوان IP صالح. إذا جاءت سلسلة عنوان IP من مستخدم بدلاً من كونها مكتوبة برمجياً في البرنامج وبالتالي كانت لديها إمكانية الفشل، فسنرغب بالتأكيد في التعامل مع Result بطريقة أكثر قوة بدلاً من ذلك. إن ذكر الافتراض بأن عنوان IP هذا مكتوب برمجياً سيحثنا على تغيير expect إلى كود أفضل للتعامل مع الأخطاء إذا احتجنا في المستقبل إلى الحصول على عنوان IP من مصدر آخر بدلاً من ذلك.
إرشادات للتعامل مع الأخطاء (Guidelines for Error Handling)
يُنصح بجعل كودك يصاب بالذعر عندما يكون من الممكن أن ينتهي به المطاف في (حالة سيئة) bad state. في هذا السياق، تكون الحالة السيئة عندما يتم كسر بعض الافتراضات أو الضمانات أو العقود أو (الثوابت) invariants ، مثل تمرير قيم غير صالحة أو قيم متناقضة أو قيم مفقودة إلى كودك - بالإضافة إلى واحد أو أكثر مما يلي:
- الحالة السيئة هي شيء غير متوقع، على عكس شيء من المحتمل أن يحدث أحياناً، مثل إدخال المستخدم لبيانات بتنسيق خاطئ.
- يحتاج كودك بعد هذه النقطة إلى الاعتماد على عدم وجوده في هذه الحالة السيئة، بدلاً من التحقق من المشكلة في كل خطوة.
- لا توجد طريقة جيدة لترميز هذه المعلومات في الأنواع التي تستخدمها. سنعمل من خلال مثال لما نعنيه في “ترميز الحالات والسلوك كأنواع” في الفصل 18.
إذا قام شخص ما باستدعاء كودك ومرر قيماً غير منطقية، فمن الأفضل إعادة خطأ إذا استطعت حتى يتمكن مستخدم المكتبة من تقرير ما يريد فعله في هذه الحالة. ومع ذلك، في الحالات التي قد يكون فيها الاستمرار غير آمن أو ضاراً، قد يكون الخيار الأفضل هو استدعاء panic! وتنبيه الشخص الذي يستخدم مكتبتك إلى وجود (خطأ برمجى) bug في كوده حتى يتمكن من إصلاحه أثناء التطوير. وبالمثل، غالباً ما يكون panic! مناسباً إذا كنت تستدعي كوداً خارجياً خارجاً عن سيطرتك ويعيد حالة غير صالحة ليس لديك طريقة لإصلاحها.
ومع ذلك، عندما يكون الفشل متوقعاً، فمن الأنسب إعادة Result بدلاً من إجراء استدعاء panic!. تشمل الأمثلة إعطاء (محلل) parser بيانات مشوهة أو إعادة طلب HTTP لحالة تشير إلى أنك وصلت إلى حد المعدل (Rate limit). في هذه الحالات، تشير إعادة Result إلى أن الفشل هو احتمال متوقع يجب على الكود المستدعي تقرير كيفية التعامل معه.
عندما يؤدي كودك عملية قد تعرض المستخدم للخطر إذا تم استدعاؤها باستخدام قيم غير صالحة، يجب أن يتحقق كودك من صحة القيم أولاً ويصاب بالذعر إذا لم تكن القيم صالحة. هذا في الغالب لأسباب تتعلق بالسلامة: فمحاولة العمل على بيانات غير صالحة يمكن أن تعرض كودك لثغرات أمنية. هذا هو السبب الرئيسي الذي يجعل المكتبة القياسية تستدعي panic! إذا حاولت الوصول إلى ذاكرة خارج الحدود: فمحاولة الوصول إلى ذاكرة لا تنتمي إلى هيكل البيانات الحالي هي مشكلة أمنية شائعة. غالباً ما يكون للدوال (عقود) contracts: حيث يكون سلوكها مضموناً فقط إذا كانت المدخلات تلبي متطلبات معينة. إن الإصابة بالذعر عند انتهاك العقد أمر منطقي لأن انتهاك العقد يشير دائماً إلى خطأ من جانب المستدعي، وليس نوعاً من الأخطاء التي تريد أن يضطر الكود المستدعي للتعامل معها صراحة. في الواقع، لا توجد طريقة معقولة للكود المستدعي للاسترداد؛ بل يحتاج المبرمجون المستدعون إلى إصلاح الكود. يجب شرح عقود الدالة، خاصة عندما يتسبب الانتهاك في حدوث ذعر، في توثيق API للدالة.
ومع ذلك، فإن وجود الكثير من عمليات التحقق من الأخطاء في جميع دوالك سيكون مطولاً ومزعجاً. لحسن الحظ، يمكنك استخدام (نظام الأنواع) type system في Rust (وبالتالي فحص الأنواع الذي يقوم به المترجم) للقيام بالعديد من عمليات التحقق نيابة عنك. إذا كانت دالتك تحتوي على نوع معين كمعلمة، فيمكنك المضي قدماً في منطق كودك مع العلم أن المترجم قد تأكد بالفعل من أن لديك قيمة صالحة. على سبيل المثال، إذا كان لديك نوع بدلاً من Option ، فإن برنامجك يتوقع الحصول على شيء ما بدلاً من لا شيء. لا يتعين على كودك بعد ذلك التعامل مع حالتين لمتغيري Some و None: سيكون لديه حالة واحدة فقط للحصول على قيمة بالتأكيد. الكود الذي يحاول تمرير “لا شيء” إلى دالتك لن يتم تجميعه حتى، لذا لا يتعين على دالتك التحقق من هذه الحالة في وقت التشغيل. مثال آخر هو استخدام نوع صحيح غير موقع مثل u32 ، والذي يضمن أن المعلمة ليست سالبة أبداً.
أنواع مخصصة للتحقق من الصحة (Custom Types for Validation)
دعنا نأخذ فكرة استخدام نظام الأنواع في Rust لضمان حصولنا على قيمة صالحة خطوة إلى الأمام وننظر في إنشاء نوع مخصص للتحقق من الصحة. تذكر لعبة التخمين في الفصل الثاني التي طلب فيها كودنا من المستخدم تخمين رقم بين 1 و 100. لم نقم أبداً بالتحقق من أن تخمين المستخدم كان بين تلك الأرقام قبل التحقق منه مقابل رقمنا السري؛ لقد تحققنا فقط من أن التخمين كان موجباً. في هذه الحالة، لم تكن العواقب وخيمة للغاية: فمخرجاتنا “عالية جداً” أو “منخفضة جداً” ستظل صحيحة. ولكن سيكون من التحسينات المفيدة توجيه المستخدم نحو التخمينات الصالحة ويكون له سلوك مختلف عندما يخمن المستخدم رقماً خارج النطاق مقابل عندما يكتب المستخدم، على سبيل المثال، حروفاً بدلاً من ذلك.
إحدى الطرق للقيام بذلك هي تحليل التخمين كـ i32 بدلاً من u32 فقط للسماح بالأرقام السالبة المحتملة، ثم إضافة فحص لكون الرقم في النطاق، هكذا:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
loop {
// --snip--
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}
match guess.cmp(&secret_number) {
// --snip--
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
يتحقق تعبير if مما إذا كانت قيمتنا خارج النطاق، ويخبر المستخدم بالمشكلة، ويستدعي continue لبدء التكرار التالي للحلقة وطلب تخمين آخر. بعد تعبير if ، يمكننا المضي قدماً في المقارنات بين guess والرقم السري مع العلم أن guess بين 1 و 100.
ومع ذلك، هذا ليس حلاً مثالياً: فإذا كان من الضروري تماماً أن يعمل البرنامج فقط على القيم بين 1 و 100، وكان لديه العديد من الدوال بهذا المتطلب، فإن وجود فحص مثل هذا في كل دالة سيكون مملاً (وقد يؤثر على الأداء).
بدلاً من ذلك، يمكننا إنشاء نوع جديد في وحدة مخصصة ووضع عمليات التحقق من الصحة في دالة لإنشاء نموذج من النوع بدلاً من تكرار عمليات التحقق في كل مكان. بهذه الطريقة، يكون من الآمن للدوال استخدام النوع الجديد في (توقيعاتها) signatures واستخدام القيم التي تتلقاها بثقة. تعرض القائمة 9-13 إحدى الطرق لتعريف نوع Guess الذي سيقوم فقط بإنشاء نموذج من Guess إذا تلقت دالة new قيمة بين 1 و 100.
#![allow(unused)]
fn main() {
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
}
لاحظ أن هذا الكود في src/guessing_game.rs يعتمد على إضافة إعلان وحدة mod guessing_game; في src/lib.rs لم نعرضه هنا. داخل ملف هذه الوحدة الجديدة، نعرف هيكلاً يسمى Guess يحتوي على حقل يسمى value يحمل i32. هذا هو المكان الذي سيتم فيه تخزين الرقم.
بعد ذلك، نقوم بتنفيذ دالة مرتبطة تسمى new على Guess تقوم بإنشاء نماذج من قيم Guess. تم تعريف دالة new لتكون لها معلمة واحدة تسمى value من نوع i32 وتعيد Guess. يختبر الكود الموجود في جسم دالة new القيمة value للتأكد من أنها بين 1 و 100. إذا لم تجتز value هذا الاختبار، فإننا نجري استدعاء panic! ، والذي سينبه المبرمج الذي يكتب الكود المستدعي بأن لديه خطأ برمجياً يحتاج إلى إصلاحه، لأن إنشاء Guess بقيمة value خارج هذا النطاق سينتهك العقد الذي تعتمد عليه Guess::new. يجب مناقشة الظروف التي قد تؤدي فيها Guess::new إلى حدوث ذعر في توثيق API العام الخاص بها؛ سنغطي اصطلاحات التوثيق التي تشير إلى إمكانية حدوث panic! في توثيق API الذي تنشئه في الفصل 14. إذا اجتازت value الاختبار، فإننا ننشئ Guess جديداً مع ضبط حقل value الخاص به على معلمة value ونعيد Guess.
بعد ذلك، نقوم بتنفيذ منهج يسمى value يستعير self ، وليس له أي معلمات أخرى، ويعيد i32. يسمى هذا النوع من المناهج أحياناً (جالب) getter لأن الغرض منه هو الحصول على بعض البيانات من حقوله وإعادتها. هذا المنهج العام ضروري لأن حقل value في هيكل Guess خاص. من المهم أن يكون حقل value خاصاً حتى لا يُسمح للكود الذي يستخدم هيكل Guess بضبط value مباشرة: يجب على الكود خارج وحدة guessing_game استخدام دالة Guess::new لإنشاء نموذج من Guess ، مما يضمن عدم وجود طريقة لـ Guess للحصول على value لم يتم فحصها بواسطة الشروط الموجودة في دالة Guess::new.
يمكن للدالة التي تحتوي على معلمة أو تعيد فقط أرقاماً بين 1 و 100 أن تعلن في توقيعها أنها تأخذ أو تعيد Guess بدلاً من i32 ولن تحتاج إلى إجراء أي فحوصات إضافية في جسمها.
ملخص (Summary)
تم تصميم ميزات التعامل مع الأخطاء في Rust لمساعدتك على كتابة كود أكثر قوة. يشير (ماكرو) macro المسمى panic! إلى أن برنامجك في حالة لا يمكنه التعامل معها ويسمح لك بإخبار العملية بالتوقف بدلاً من محاولة المضي قدماً بقيم غير صالحة أو غير صحيحة. يستخدم (تعداد) enum المسمى Result نظام الأنواع في Rust للإشارة إلى أن العمليات قد تفشل بطريقة يمكن لكودك الاسترداد منها. يمكنك استخدام Result لإخبار الكود الذي يستدعي كودك بأنه يحتاج إلى التعامل مع النجاح أو الفشل المحتمل أيضاً. إن استخدام panic! و Result في المواقف المناسبة سيجعل كودك أكثر موثوقية في مواجهة المشكلات الحتمية.
الآن بعد أن رأيت طرقاً مفيدة تستخدم بها المكتبة القياسية (الأنواع العامة) generics مع تعدادي Option و Result ، سنتحدث عن كيفية عمل الأنواع العامة وكيف يمكنك استخدامها في كودك.