تنقسم الأخطاء التي قد تحدث في البرنامج إلى فئتين رئيسيتين هما أخطاء يتوقع المبرمج حدوثها وأخطاء لم يتوقع حدوثها، وتُعالِج الواجهة error
التي تحدّثنا عنها في المقال السابق إلى حد كبير الأخطاء التي نتوقعها أثناء كتابة البرامج حتى تلك الأخطاء التي تكون احتمالات حدوثها نادرةً.
تندرج حالات الانهيار Panics تحت الفئة الثانية والتي تؤدي إلى إنهاء البرنامج تلقائيًا والخروج منه، وعادةً تكون الأخطاء الشائعة هي سبب حالات الانهيار، لذا سندرس في هذا المقال بعض الحالات الشائعة التي يمكن أن تؤدي إلى حالات الانهيار، كما سنلقي نظرةً أيضًا على بعض الطرق التي يُمكن أن نتجنب من خلالها حالات الانهيار، وسنستخدم أيضًا تعليمات التأجيل defer statements جنبًا إلى جنب مع الدالة recover
لالتقاط حالات الانهيار قبل أن تؤدي إلى إيقاف البرنامج.
ما هي حالات الانهيار؟
هناك عمليات محددة في لغة جو تؤدي إلى حالات الانهيار ومن ثم إيقاف البرنامج مباشرةً، وتتمثّل بعض هذه العمليات في محاولة الوصول إلى فهرس لا تتضمنه حدود المصفوفة -أي تجاوز حدود المصفوفة- أو إجراء عمليات توكيد النوع type assertions أو استدعاء توابع على مؤشرات لا تُشير إلى أيّ شيء nil
أو استخدام كائنات المزامنة mutexes بطريقة خاطئة أو محاولة العمل مع القنوات المغلقة closed channels، فمعظم هذه المواقف تنتج عن أخطاء أثناء البرمجة ولا يستطيع المُصرِّف اكتشافها أثناء تصريف البرنامج.
بما أن حالات الانهيار تتضمن تفاصيل مفيدةً لحل مشكلة ما، فعادةً ما يستخدِم المطورون حالات الانهيار بوصفها مؤشرًا على ارتكابهم خطأ أثناء تطوير البرنامج.
حالات الانهيار الناتجة عن تجاوز الحدود
ستولد جو حالة انهيار في وقت التشغيل runtime عند محاولة الوصول إلى فهرس خارج حدود المصفوفة أو الشريحة، ويجسِّد المثال التالي هكذا حالة، إذ سنحاول الوصول إلى آخر عنصر من الشريحة من خلال القيمة المُعادة من الدالة len
، أي من خلال طول الشريحة.
package main import ( "fmt" ) func main() { names := []string{ "lobster", "sea urchin", "sea cucumber", } fmt.Println("My favorite sea creature is:", names[len(names)]) }
يكون الخرج كما يلي:
panic: runtime error: index out of range [3] with length 3 goroutine 1 [running]: main.main() /tmp/sandbox879828148/prog.go:13 +0x20
لاحظ أننا حصلنا على تلميح panic: runtime error: index out of range
والذي يشير إلى أننا نحاول الوصول إلى فهرس خارج حدود المصفوفة.
أنشأنا شريحة names
بثلاث قيم ثم حاولنا طباعة العنصر الأخير من خلال القيمة المُعادة من الدالة ()len
والتي ستُعيد القيمة 3، لكن آخر عنصر في المصفوفة يملك الفهرس 2 وليس 3 بما أنّ الفهرسة تبدأ من الصفر وليس من الواحد، لذا في هذه الحالة لا يوجد خيارًا لوقت التشغيل إلا أن يتوقف ويُنهي البرنامج، أيضًا لا يمكن لجو أن تُبرهِن أثناء عملية التصريف أن الشيفرة ستحاول فعل ذلك، لذلك لا يمكن للمُصرِّف التقاط هذا.
تُسبب حالة الانهيار إيقاف تنفيذ برنامجك تمامًا، وبالتالي لن تُنفّذ أيّة تعليمات برمجية تالية، كما تحتوي الرسالة الناتجة على العديد من المعلومات المفيدة في تشخيص سبب حالة الانهيار.
مكونات حالة الانهيار
تتكون حالات الانهيار من رسالة تحاول توضيح سبب حالة الانهيار إضافةً إلى مسار المكدس stack trace الذي يساعدك على تحديد مكان حدوث حالة الانهيار في تعليماتك البرمجية، إذ تبدأ رسالة الانهيار بالكلمة panic:
تتبعها سلسلة نصية توضيحية تختلف تبعًا لسبب حالة الانهيار، فرسالة الانهيار في المثال السابق كانت كما يلي:
panic: runtime error: index out of range [3] with length 3
تخبرنا السلسلة runtime error:
التي تتبع الكلمة panic:
أنّ الخطأ قد حدث في وقت التشغيل؛ أما بقية الرسالة، فتخبرنا بأن الفهرس 3 خارج حدود الشريحة.
الرسالة التالية هي رسالة مسار المكدس، وهي خريطة يمكننا تتبُعها لتحديد سطر التعليمات البرمجية الذي أدى تنفيذه إلى حدوث حالة الانهيار وكيفية استدعاء هذه الشيفرة من شيفرة أخرى.
goroutine 1 [running]: main.main() /tmp/sandbox879828148/prog.go:13 +0x20
يوضّح مسار المكدس هذا أن حالة الانهيار قد حدثت في الملف /tmp/sandbox879828148/prog.go في السطر رقم 13، كما يخبرنا أيضًا أنه نشأ في الدالة ()main
من الحزمة الرئيسية main
.
ينقسم مسار المكدس إلى عدة أقسام بحيث يكون هناك قسم لكل روتين goroutine
فكل عملية تنفيذ لبرنامج في جو تُنجز من خلال روتين واحد أو أكثر، أي كل روتين يُنفذ جزء محدد من الشيفرة، وكل من هذه الروتينات يُمكن تنفيذها باستقلالية عن باقي الروتينات وبالتزامن معها.
تبدأ كل كتلة بالرأس :[goroutine X [state
، تُمثّل الإشارة X
إلى رقم الروتين ID (مُعرّفه) مع الحالة التي كان عليها عندما حدثت حالة الانهيار، ويعرض مسار المكدس بعد الرأس الدالة التي حدثت فيها حالة الانهيار أثناء تنفيذها إلى جانب اسم الملف ورقم سطر تنفيذ الدالة.
الإشارة إلى العدم
يمكن أن تحدث حالة الانهيار أيضًا عند محاولة استدعاء تابع على مؤشر لا يُشير إلى أيّ شيء، إذ تُمكننا المؤشرات في جو من الإشارة إلى نسخ مُحددة من بعض الأنواع الموجودة في الذاكرة خلال وقت التشغيل، وعندما لا يُشير المؤشر إلى أيّ شيء، فستكون قيمة المؤشر nil
، وعندما نحاول استدعاء توابع على مؤشر لا يشير إلى شيء، فستظهر حالة انهيار، والأمر ذاته بالنسبة للمتغيرات التي تكون من أنواع الواجهات، إذ تولّد أيضًا حالة الانهيار إذا استُدعيت توابع عليها كما في المثال التالي:
package main import ( "fmt" ) type Shark struct { Name string } func (s *Shark) SayHello() { fmt.Println("Hi! My name is", s.Name) } func main() { s := &Shark{"Sammy"} s = nil s.SayHello() }
يكون الخرج كما يلي:
panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0xffffffff addr=0x0 pc=0xdfeba] goroutine 1 [running]: main.(*Shark).SayHello(...) /tmp/sandbox160713813/prog.go:12 main.main() /tmp/sandbox160713813/prog.go:18 +0x1a
عرّفنا في هذا المثال سجل struct
اسميناه Shark
يحتوي على تابع وحيد مُعرَّف ضمن مُستقبل مؤشرها واسمه SayHello
والذي يطبع تحية عند استدعائه، كما نُنشئ داخل جسم الدالة main
نسخةً جديدةً من السجل Shark
ونحاول أخذ مؤشر عليها باستخدام العامِل &
، إذ يُسنَد المؤشر إلى المتغير s
ثم نُسند المتغير s
مرةً أخرى إلى القيمة nil
من خلال التعليمة s = nil
ثم نحاول استدعاء التابع SayHello
على المتغيرs
.
نتيجةً لذلك نحصل على حالة انهيار تُشير إلى محاولة الوصول إلى عنوان ذاكرة غير صالح لأن المتغير s
كانت قيمته nil
، وبالتالي عند استدعاء الدالة SayHello
فإنه يحاول الوصول إلى الحقل Name
الموجود على النوع *Shark
، وبالتالي تحدث حالة انهيار لأنه لا يمكن تحصيل dereference قيمة غير موجودة nil
بما أنّ المؤشر هو مؤشر استقبال والمستقبل في هذه الحالة هو nil
.
حددنا في هذا المثال القيمة nil
صراحةً، لكن تظهر هذه القيم في الحالات العملية من دون انتباهنا، إذًا، عندما تظهر لديك حالات انهيار تتضمن الرسالة nil pointer dereference
، ينبغي عليك التحقق مما إذا كنت تُسند قيمة nil
إلى أحد المؤشرات التي تستخدِمها في برنامجك.
استخدام دالة panic المضمنة
حالات الانهيار الناتجة عن تجاوز حدود الشريحة أو تلك الناتجة عن المؤشرات ذات القيمة nil
هي الأشهر، لكن من الممكن أيضًا أن نُظهر حالة انهيار يدويًا من خلال الدالة المُضمّنة panic
التي تأخذ وسيط واحد فقط يُمثِّل السلسلة النصية التي ستمثّل رسالة الانهيار، وعادةً ما يكون عرض هذه الرسالة أكثر راحةً من تعديل الشيفرة بحيث تعيد خطأً، كما يمكننا أيضًا استخدامها داخل الحزم الخاصة بنا لتنبيه المطورين إلى أنهم قد ارتكبوا خطأً عند استخدام شيفرة الحزمة خاصتنا، عمومًا، يُفضَّل إعادة قيم الخطأ error
إلى مُستخدمِي الحزمة، وسيطلق المثال التالي حالة انهيار ناتجةً عن دالة تُستدعى من دالة أخرى:
package main func main() { foo() } func foo() { panic("oh no!") }
ستكون حالة الانهيار الناتجة كما يلي:
panic: oh no! goroutine 1 [running]: main.foo(...) /tmp/sandbox494710869/prog.go:8 main.main() /tmp/sandbox494710869/prog.go:4 +0x40
عرّفنا في هذا المثال الدالة foo
التي تستدعي الدالة panic
والتي نُمرر لها السلسلة "oh no!" ثم استدعينا هذه الدالة foo
من داخل الدالة main
.
لاحظ أنّ الخرج يتضمن الرسالة التي حددناها للدالة panic
ويعرض مسار المكدس لنا روتينًا واحدًا وسطرين من المسارات؛ الأول للدالة ()main
والثاني للدالة ()foo
.
وجدنا مما سبق أنّ حالات الانهيار تؤدي إلى إنهاء البرنامج لحظة حدوثها، ويمكن أن يؤدي ذلك إلى حدوث مشكلات عند وجود موارد مفتوحة يجب إغلاقها بطريقة سليمة، وهنا توفِّر جو آليةً لتنفيذ بعض التعليمات البرمجية دائمًا حتى عندما تظهر حالة الانهيار.
الدوال المؤجلة
قد يحتوي برنامجك على موارد يجب تنظيفها بطريقة سليمة، حتى أثناء معالجة حالات الانهيار في وقت التشغيل، إذ تسمح لك جو بتأجيل تنفيذ استدعاء دالة ريثما تكون الدالة المُستدعاة ضمنها قد أكتمل تنفيذها.
يُمكن للدوال المؤجلة أن تُنفّذ حتى عند ظهور حالة انهيار وتستخدم بوصفها تقنية أمان للحماية من الفوضى والمشكلات التي قد تسببها حالة الانهيار، ولتأجيل دالة نستخدِم الكلمة المفتاحية defer
قبل اسم الدالة كما يلي ()defer sayHello
، ولاحظ في المثال التالي أن الرسالة ستظهر رغم حدوث حالة انهيار:
package main import "fmt" func main() { defer func() { fmt.Println("hello from the deferred function!") }() panic("oh no!") }
يكون الخرج كما يلي:
hello from the deferred function! panic: oh no! goroutine 1 [running]: main.main() /Users/gopherguides/learn/src/github.com/gopherguides/learn//handle-panics/src/main.go:10 +0x55
نؤجل استدعاء دالة مجهولة الاسم داخل الدالة ()main
والتي تطبع الرسالة "hello from the deferred function!" ثم تُطلق الدالة ()main
حالة انهيار من خلال استدعاء الدالة panic
، ونلاحظ من الخرج أنّ الدالة المؤجلة تُنفّذ أولًا وتُطبع الرسالة الخاصة بها، ثم تظهر لنا رسالة حالة الانهيار.
توفر جو لنا أيضًا فرصة منع حالة الانهيار من إنهاء البرنامج من داخل الدوال المؤجلة من خلال دالة مُضمّنة أخرى.
معالجة حالات الانهيار
تمتلك جو تقنية معالجة واحدة تتجسّد في الدالة recover
، إذ تسمح لك هذه الدالة في اعتراض حالة الانهيار من مسار المكدس ومنع الإنهاء المُفاجئ للبرنامج.
هناك قواعد صارمة عند التعامل معها، لكنها دالة لا تقُدّر بثمن في تطبيقات الإنتاج، ويمكن استدعاء الدالة recover
مباشرةً دون الحاجة إلى استيراد أيّ حزمة لأنها دالة مُضمّنة كما ذكرنا.
package main import ( "fmt" "log" ) func main() { divideByZero() fmt.Println("we survived dividing by zero!") } func divideByZero() { defer func() { if err := recover(); err != nil { log.Println("panic occurred:", err) } }() fmt.Println(divide(1, 0)) } func divide(a, b int) int { return a / b }
يكون الخرج كما يلي:
2009/11/10 23:00:00 panic occurred: runtime error: integer divide by zero we survived dividing by zero!
تُستدعى الدالة divideByZero
من داخل الدالة main
، كما داخل هذه الدالة نؤجل استدعاء دالة مجهولة الاسم ومسؤولة عن التعامل مع أيّ حالات انهيار قد تنشأ أثناء تنفيذ divideByZero
، ونستدعي داخل هذه الدالة مجهولة الاسم الدالة المُضمّنة recover
ونسند الخطأ الذي تُعيده إلى المتغير err
.
إذا ظهرت حالة انهيار في الدالة ()divideByZero
، فستُهيّأ قيمة هذا الخطأ، وإلا فستكون nil
، ومن خلال مقارنة قيمة المتغير err
بالقيمة nil
، سنستطيع تحديد فيما إذا كانت حالة الانهيار قد حدثت، وفي هذه الحالة نسجّل حالة الانهيار من خلال الدالة log.Println
كما لو كانت أيّ خطأ آخر.
نلاحظ أننا نستدعي الدالة divide
ونحاول طباعة ناتجها باستخدام الدالة ()fmt.Println
بتتبع الدالة المؤجلة مجهولة الاسم بما أن وسيط هذه الدالة سيُسبب القسمة على صفر، وبالتالي ستظهر حالة انهيار.
يُظهر خرج البرنامج رسالة الدالة ()log.println
من الدالة مجهولة الاسم والتي تُعالج حالة الانهيار متبوعة برسالة we survived dividing by zero!
، والتي تُشير إلى أننا منعنا حالة الانهيار من إيقاف البرنامج، وذلك بفضل استخدام دالة المعالجة recover
.
قيمة الخطأ err
المُعادة من الدالة ()recover
هي نفسها القيمة المُعطاة لاستدعاء الدالة ()panic
، لذا من المهم التأكد مما إذا كانت قيمتها nil
.
اكتشاف حالات الانهيار باستخدام recover
تعتمد الدالة recover
على قيمة الخطأ في اكتشاف حدوث حالات الانهيار، وبما أن وسيط الدالة panic
هو واجهة فارغة، لذا يمكن أن تكون من أي نوع، كما أنّ القيمة الصفرية لأي نوع بيانات يُمثّل واجهةً هو nil
، ويوضِّح المثال التالي أنه يجب تجنب تمرير القيمة nil
على أساس وسيط للدالة panic
:
package main import ( "fmt" "log" ) func main() { divideByZero() fmt.Println("we survived dividing by zero!") } func divideByZero() { defer func() { if err := recover(); err != nil { log.Println("panic occurred:", err) } }() fmt.Println(divide(1, 0)) } func divide(a, b int) int { if b == 0 { panic(nil) } return a / b }
يكون الخرج كما يلي:
we survived dividing by zero!
هذا المثال هو المثال السابق نفسه مُتضمنًا الدالة recover
مع بعض التعديلات الطفيفة، إذ عدّلنا دالة القسمة للتحقق مما إذا كان المقسوم عليه b
يساوي 0، فإذا كان كذلك، فسيولّد حالة انهيار باستخدام الدالة panic
مع وسيط من القيمة nil
.
لن يتضمن الخرج في هذه المرة رسالة الدالة log
التي تعرض حدوث حالة الانهيار بالرغم من حدوث حالة انهيار نتيجة القسمة، ويؤكد هذا السلوك الصامت أهمية التحقق من قيمة الخطأ فيما إذا كانت nil
.
الخاتمة
رأينا العديد من الطرق التي تؤدي إلى ظهور حالات انهيار في البرنامج، كما أننا عرضنا طرقًا لإنشائها يدويًا، ثم راجعنا الدالة المضمنة recover
التي تُمكّننا من معالجة حالات الانهيار، وليس من الضروري أن تحتاج إلى التعامل مع حالات الانهيار في برامجك، لكن عندما يتعلق الأمر بتطبيقات الإنتاج فهي بغاية الأهمية.
ترجمة -وبتصرُّف- للمقال Handling Panics inGo لصاحبه Gopher Guides.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.