16 الخطأ الواقعي: الاستثناء
الاستثناء (Exception) هو خروج البرنامج عن المسار المثالي. ويسمى خطأً (Error).
مثل أن يؤمَر بقراءة ملف .. والواقع أن هذا الملف غير موجود! أو أن يطلب من المستخدم رقمًا فيعطيه كلاماً! أو أن يطلب من الشبكة شيئًا .. فتنقطع الشبكة! فكل هذه تعتبر مسارات غير مثالية لكنها تحصل في ظروف واقعيَّة. فيجب كتابة قطع في البرنامج تتعامل معها. ولذا فإن بعض الممارسين لا يفضلون استعمال كلمة استثناء لأنَّ مثل ذلك يحصل كثيرًا فهو ليس خارجًا عن العادة؛ بل من الطبيعي أن يحصل ذلك في الواقع.
تصوَّر أن البرنامج سلسلة من الأفعال بحيث يختص كل فعل بمهمة معيَّنة (بناءً على مدخلاته يُعطي مخرجاته) فإننا نتعمَّد أن يكون وعيُه محدودًا بمدخلاته حتى نقلل من تشابك قطع الكود حتى نيسِّر على أنفسنا التحقق من صحَّة المنطق. وهذه فكرة ظرف التنفيذ الذي سبق الحديث عنها في باب الفعل.
فللخطأ حالتان -لا غير- في علاقتها مع من يتعامل معه:
- يستوعبه السياق: أن يكون في وعي الفعل (الظرف) ما يمكن به معالجته
- لا يستوعبه السياق: ألا يكون كذلك فيرفعه (يرجع به) للفعل المستدعي له (الذي هو أكثرُ وَعيًا منه) ليعالجه
- وهذا يتسلسل حتى يصل إلى الإنسان (سواءً المستفيد أو المبرمج): برسالة تفيد في العثور عليه والتعامل معه.
ومنشؤ الخطأ أحد أمرين:
- خطأ مرفوع: تم رفعه من فعل مستدعى في السياق الحالي.
- خطأ جديد: تم اكتشاف حالة تتوقف تكملة الفعل على إصلاحها.
وللتعامل مع الخطأ حين يستوعبه السياق طريقتان:
- الوقاية: توقُّع حالات الخطأ والتحقق من عدمها قبل الإقدام على العملية.
- التدارك: ترك العملية لترجع بالخطأ؛ ثم نتعامل معه بحسبه.
16.1 الفرق بين الوقاية والتدارك
هذا الفعل مسؤول عن تهيئة البرنامج، ومن خطواته أنه يقرأ ملف الضبط المخصص custom_config.json
. وفي هذه الحالة قد يحصل خطأ. إذْ قد لا يكون الملف موجودًا أصلا!
هذا الفعل مسؤول عن تهيئة البرنامج، ومن خطواته أنه يقرأ ملف الضبط المخصص custom_config.json
. وفي هذه الحالة قد يحصل خطأ. إذْ قد لا يكون الملف موجودًا أصلا!
ففي هذه الحالة يحصل استثناء من نوع FileNotFoundError
.
فإذا اخترنا طريق الوقاية فإننا أولاً نلتمس وجوده قبل فتحه. ونتعامل مع حالة الخطأ هذه (أي: عدمه)، بأن -مثلاً- نقرأ ملف الضبط الافتراضي default.json
بدلاً من الملف المطلوب. والفرق بين هذا الملف والذي قبله أننا موقنون بوجوده، والأوَّل وجوده مظنون إذْ الذي يضعه هو المستخدم.
def initialize_program():
# ... code before
if os.path.exists('custom_config.json'):
file = open('custom_config.json')
else:
file = open('default.json')
# ... code after
وقد تقول، حسنًا ماذا يكون لو عُدم ملف default.json
أيضًا؟ فنقول: لا بأس أن يحصل خطأ يوقف البرنامج. إذْ ذاك يعتبر خطأ منطقيًّا يجب إصلاحه بإضافة الملف، وليس ثمة مجال لأن يصلحه الكود بنفسه.
أما التدارك فيكون بجملة try-except
على النحو التالي:
def initialize_program():
# ... code before
try:
file = open('custom_config.json')
except FileNotFoundError:
file = open('default.json')
# ... code after
أما استعمال الوقاية ففيه شك بوجود فجوة زمنية بين عملية التحقق من وجود الملف وبين قراءته. فالحاسب تتوارد عليه البرمجيات، ولا يستأثر به برنامج واحد. فإذا تغيَّر الحال في هذه الفجوة الزمنية وعاد بالنقض على التحقق بعد حصوله انتفت الفائدة منه. لذا فالتدارك أضمن.
الحالة الأولى: خطأ جديد يستوعبه السياق
مثاله ما لو لم تكن أحد الصفات محددة، فيمكن تعيينها بقيمة افتراضية، ولا يلزمنا تصعيد الخطأ:
الحالة الثانية: خطأ مرفوع يستوعبه السياق
وقد مثلنا له بفتح الملف، ونمثل له بمثال آخر:
الحالة الثالثة: خطأ جديد لا يستوعبه السياق
في هذا المثال يجب أن تكون الوحدة إحدى القيم المسموحة: C
أو F
. وإلا فلا حيلة للفعل أن يتم وظيفته. لذا نرفع خطأ جديدًا بجملة raise
.
الحالة الرابعة: خطأ مرفوع لا يستوعبه السياق
ففي هذا المثال عملية قسمة قد يحصل فيها قسمة على صفر في (a / b)
، فيرتفع خطأ اسمه ZeroDivisionError
من تلك العملية. والحقيقة أننا في هذا السياق ليس لنا أن نعدِّل الرقم، بل نريد لمن استدعى الفعل أن يعلم بالخطأ، ولسنا نريد إضافة معلومة أخرى فوق ذلك. لذا نسكت عنه (أي: لا نضع try-except
) وهذا يجعله ينتقل مباشرة للفعل المستدعي.
def divide_lists(list1: list, list2: list) -> list:
result = []
for a, b in zip(list1, list2):
result.append(a / b)
return result
فإن من الأخطاء ما يتعذر على البرنامج معالجته بنفسه:
- إذا كانت المشكلة بامتلاء الذاكرة في الجهاز؛ فإن البرنامج ليس له إلا أن يخرج برسالة للمستخدم أو المسؤول عن الجهاز .. وليس للبرنامج أن يمسح بيانات المستخدم!
- أما إذا كانت المشكلة في تأخر الإجابة من الخادم مثلاً، فقد نعيد المحاول مرة أخرى بعد ثوانٍ، ونعيدها لعددٍ محدد من المرات، أملاً في الحصول على إجابة. ثم بعد ذلك لا يمكن إلا أن نظهر رسالة خطأ إن نفدت جميع المحاولات.
16.2 إلتقاط جميع الأخطاء
من الخطأ في المنطق البرمجي إلتقاط جميع الأخطاء في الأفعال باستعمال except Exception
وهو النوع الشامل لجميع الأخطاء. فهذه الطريقة لا تخبرنا بنوع الخطأ وبالتالي لا نتعامل معه بحسبه، وإنما غاية ما تحقق هو منع تصعيد الخطأ لأعلى طبقة، إذْ حين يحصل ذلك توقف بايثون البرنامج (وعندها تظهر سلسلة الطلبات).
لكن يجوز ذلك في قطع الكود التي يُراد لها الاستمرار، وإن فشلَ شيئٌ فيها. مثلاً: لا تريد للخادم أن يتوقف تمامًا بمجرد حصول خطأ واحد في أحد خيوط التنفيذ الخاصة بخدمة أحد الطلبات. ففي مثل ذلك يسوغ استخدام except Exception
الشاملة. انظر مثلاً قطعة الكود في إطار الويب فلاسك المسؤول عن استقبال الطلبات.
16.3 أنواع الاستثناء في بايثون
وقد تم تعريف أنواع من الأخطاء في بايثون بحسب الحالة، وتم تقسيمها هكذا:
SyntaxError
السبب: خطأ نحوي في صياغة اللغة:
- كلمة غير صحيحة: خطأ في الإملاء
- في وضع كلمة صحيحة في غير سياقها
- محاذاة غير متسقة (
IndentationError
)
الحل: اقرأ رسالة الخطأ وستدلُّك على السبب والموضع الذي حصل فيه الخطأ.
TypeError
السبب:
- طلب فعل بعدد أكثر أو أقل من العوامل الواجبة (مثل:
len(1, 2)
) - طلب فعل بعوامل لا تطابق النوع المحدد في تعريفه (مثل:
math.sqrt('nine')
أو5 + '5'
)
الحل: الوقاية بـ type()
أو isinstance()
أو بالتأكد من تحويل النوع مسبقًا.
ValueError
السبب: أن يكون النوع صحيحًا (فلا يحصُل TypeError
) لكن القيمة غير مقبولة.
- مثلاً: طلب فعل بقيمة نوعها عددي لكنَّها سالبة وهو لا يقبل إلا الموجبة. نحو:
math.sqrt(-16)
فالجذر التربيعي لا يقبل السالب.
الحل: الوقاية بفحص مدى القيمة ، نحو: if x >=0: math.sqrt(x)
IndexError
& KeyError
السبب: الرقم الذي استعمل في عملية الإشارة [index]list
(قائمة) أو dict[key]
(قاموس) يشير لما هو خارج المجموعة.
نحو:
--------------------------------------------------------------------------- IndexError Traceback (most recent call last) Cell In[18], line 3 1 my_list = [10, 20, 30] 2 idx = 3 ----> 3 value = my_list[idx] IndexError: list index out of range
الحل بالوقاية:
أو بالتدارك:
وكذلك في القاموس، نحو:
--------------------------------------------------------------------------- KeyError Traceback (most recent call last) Cell In[19], line 3 1 my_dict = {'A': 10, 'B': 20, 'C': 30} 2 key = 'Z' ----> 3 value = my_dict[key] KeyError: 'Z'
الحل بالوقاية
أو هكذا (تعيين قيمة افتراضية):
أو بالتدارك:
AttributeError
& NameError
السبب: استعمال متغير أو فعل قبل تعريفه.
- فإن أسنِد إلى كائن؛ وقع
AttributeError
- وإلا وقع
NameError
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[20], line 2 1 a = 10 ----> 2 a + X NameError: name 'X' is not defined
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[21], line 1 ----> 1 some_function(55) NameError: name 'some_function' is not defined
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[22], line 5 2 pass 4 a = A() ----> 5 a.x AttributeError: 'A' object has no attribute 'x'
ModuleNotFoundError
السبب: فشل جُملة الاستيراد import numpy
الحل:
- تأكد من صحة الإملاء
- تأكد من تثبيت الوحدة في البيئة التي يعمل فيها البرنامج:
pip install numpy
16.4 تعريف أخطاء جديدة
تعريف الخطأ يكون بتعريف نوع جديد يرث من النوع Exception
، وهذا ما يحققه السطر الأول بين القوسين. وتستطيع أن ترث ممن يرث، فتتكون لديك فروع من هذا الخطأ:
وهذا الفعل يحصل فيه الخطأ بطريقة مصطنعة لكنها توضح ما نريد، وهو الخطأ الفرعي XError
الذي يرث من الخطأ الأصلي ParentError
:
ثم حين نفحص، تسطيع أن نطابق بالأصل أو الفرع:
16.5 منظور آخر للخطأ
فائدة: لدينا طريقتان في التعامل مع الخطأ الواقعي، واختارت بايثون طريقة الاستثناء:
الأولى: رفع قيمة الخطأ بالاسثناء: لغات مثل C++ (1979)، Java (1998)، Python (1991) و JavaScript (1995) ترمي/ترفع الاستثناءات باستعمال عبارة مثلة raise
أو عبارة throw
على حسب اللغة. وتكون معالجة الخطأ بجملة try-catch
أو try-except
.
الثانية: الرجوع بقيمة الخطأ: اللغات الحديثة مثل Go (2009) و Rust (2015) ببساطة تعيد الخطأ كشيء (قيمة) عند حدوثه باستعمال جملة return
التي في الفعل. وتكون معالجة الخطأ بجملة شرطية عاديَّة نحو: if error == error_type
وبعض اللغات مثل: C (1978) وكذلك C++ (1985) تخلط بين النوعين!
المراجع: