class ParentError(Exception):
pass
class XError(ParentError):
pass
class YError(ParentError):
passالخطأ
تعريف أخطاء جديدة
تعريف الخطأ يكون بتعريف نوع جديد يرث من النوع Exception، وهذا ما يحققه السطر الأول بين القوسين. وتستطيع أن ترث ممن يرث، فتتكون لديك فروع من هذا الخطأ:
flowchart TD
A[ParentError] --> B[XError]
A --> C[YError]
وهذا الإجراء يحصل فيه الخطأ بطريقة مصطنعة لكنها توضح ما نريد، وهو الخطأ الفرعي XError الذي يرث من الخطأ الأصلي ParentError:
def do_something():
raise XError('Something went wrong')ثم حين نفحص، نستطيع أن نطابق بالأصل أو الفرع:
try:
do_something()
except ParentError as e:
print("caught you:", e)الاحتراز والاستجابة
يهندس المبرمج الإجراءات بحيث تكون نتيجة كل واحدٍ منها مبنيًّا على معطياته المباشرة؛ بحيث لو تم تمرير نفس المعطيات فإن نفس النتائج تخرج. فأن تقليل العوامل المؤثرة في كل خطوة يجعل التحقق من صحتها أسهل. ثم يتم تركيب البرنامج بمجموعها لتعمل جميع العوامل؛ وحينها نكون أقدر على التركيب الصحيح.
وذكرنا أن الإجراء له أحد أمرين لا ثالث لهما:
- المعالجة: أن يتعامل مع الاستثناء المرفوع؛ وذلك إن كان سياق الإجراء يسوعبه؛ أي: أن يكون في وعي الإجراء (في معطياته ومتغيراته) ما يمكن به معالجته.
- الرفع: أن يكرر رفعه إلى من استدعاه هو؛ وذلك حين لا يكون في سياق الإجراء ما يمكن به معالجته فيرفعه (يرجع به) للإجراء المستدعي له (الذي هو أكثرُ وَعيًا منه) ليعالجه.
والرفع يتسلسل حتى يخرج من البرنامج؛ حيث يصل إلى الإنسان (سواءً المستفيد أو المبرمج): برسالة مفادها أمران:
- العثور عليه
- التعامل معه
فأما حين يستوعبه السياق؛ فلدينا طريقتان لمعالجة الخطأ:
- الاحتراز: توقُّع حالات الخطأ والتحقق من عدمها قبل الإقدام على العملية.
- الاستجابة: ترك العملية لترجع بالخطأ؛ ثم نتعامل معه بحسبه.
الفرق بين: الاحتراز والاستجابة
هذا الإجراء مسؤول عن تهيئة البرنامج، ومن خطواته أنه يقرأ ملف الضبط المخصص custom_config.json. وفي هذه الحالة قد يحصل خطأ. إذْ قد لا يكون الملف موجودًا أصلا!
def initialize_program():
# ... code before
file = open('custom_config.json')
# ... code afterففي هذه الحالة يحصل استثناء من نوع 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ما هو الأفضل إذاً؟
أما استعمال الاحتراز ففيه شك بوجود فجوة زمنية بين عمليتي الإدراك والفعل. فالحاسب تتوارد عليه البرمجيات، ولا يستأثر به برنامج واحد. فإذا تغيَّر الحال في هذه الفجوة الزمنية (بعد إدراك وجود الملف .. تم حذفه) فعاد بالنقض على صواب الإدراك؛ لذلك تكون الاستجابة أضمن من الاحتراز فيما هو عُرضة لحالة التسابق.
الحالة الأولى: أن يستوعبه السياق
فإن كان خطأ جديدًا
مثاله الاحتراز مما لو لم تكن إحدى الخصائص موجودة (خطأ جديد)، فيمكن تعيينها بقيمة ابتدائية (الحل)، ولا يلزمنا تصعيد الخطأ. فهنا ننظر في المفتاح language هل هو موجود في القاموس user .. فإن لم توجَد عيَّنا له قيمة افتراضية: ar وأكمل الإجراء سيره:
def save_user(user: dict):
if 'language' not in user:
user['language'] = 'ar'
# ... rest of the code
save_to_database(user)وبهذا يكون الإجراء قد تعامل مع الخطأ بنفسه.
وإن كان خطًا مرفوعًا
وقد مثلنا له بفتح الملف، ونمثل له بمثال آخر: وهو صعود خطأ من إجراء تحويل القيمة النصية الآتية من المستخدم (guess) إلى قيمة عدد صحيح (int)؛ فإننا نستجيب لحدوث الخطأ ValueError ونتعامل معه بإظهار رسالة تفيد المستخدم بمكمن الخطأ وكيفية إصلاحه من جهته: “القيمة ليست عددًا .. من فضلك أدخل قيمة عددية”.
def get_user_guess() -> int:
print('Please enter a number')
guess = input()
try:
guess = int(guess)
except ValueError:
print(f'The value "{guess}" is not a number')
guess = get_user_guess() # recursive call
return guessوبهذا يكون الإجراء يصحح نفسه حتى يضمن عند الرجوع return guess أنه أتمَّ وظيفته بشكل صحيح.
الحالة الثانية: ألا يستوعبه السياق
فإن كان خطأ جديدًا
في هذا المثال نريد أن نُلزم كون وحدة القياس إحدى القيمتين: C (سيليلوز) أو F (فهرنهايت). وإلا فلا حيلة للإجراء أن يتمَّ وظيفته. لذا نرفع خطأ جديدًا بجملة الرفع raise على هذا النحو:
def convert_temperature(value: float, unit: str) -> float:
if unit not in {'C', 'F'}:
raise ValueError(f"Invalid unit: {unit}")
if unit == 'C':
return value * 9/5 + 32
elif unit == 'F':
return (value - 32) * 5/9لاحظ أننا اخترنا ValueError لما تقدَّم بيانه عن أن هذا النوع من الأخطاء هو لما يكون صحيح النوع (type) لكن خاطئ القيمة.
وإن كان خطأً مرفوعًا
فإن من الأخطاء ما يتعذر على البرنامج معالجته بنفسه:
- إذا كانت المشكلة بامتلاء الذاكرة في الجهاز (Out-of-Memory - OOM)؛ فإن البرنامج ليس له إلا أن يخرج برسالة للمستخدم أو المسؤول عن الجهاز .. وليس للبرنامج أن يتعرَّض للمساحة المخصصة لغيره من البرامج في الذاكرة ويتعدى عليها فيسمحها ليتمدد هو!
- أما إذا كانت المشكلة في تأخر الإجابة من الخادم مثلاً، فقد نعيد المحاول مرة أخرى بعد ثوانٍ، ونعيدها لعددٍ محدد من المرات، أملاً في الحصول على إجابة. ثم بعد ذلك لا يمكن إلا أن نظهر رسالة خطأ إن نفذت جميع المحاولات.
ففي هذا المثال عملية قسمة؛ ونتوقع بحسب معرفتنا الرياضية بإمكان حصول القسمة على صفر في (a / b)، فيرتفع بذلك خطأ اسمه ZeroDivisionError من تلك العملية. والحقيقة أننا في هذا السياق ليس لنا أن نعدِّل الرقم، بل نريد لمن استدعى الإجراء أن يعلم بالخطأ، ولسنا نريد إضافة معلومة أخرى فوق ذلك. لذا نسكت عنه (أي: لا نضع try-except) وهذا يجعله ينتقل مباشرة للإجراء المستدعي.
def divide_lists(list1: list, list2: list) -> list:
"""Divide the elements of list1 by the elements of list2
:raises ZeroDivisionError: If the denominator `b` is zero.
"""
result = []
for a, b in zip(list1, list2):
result.append(a / b)
return resultفالطريقة الصحيحة للتعامل مع مثل هذه الأخطاء، أن نتركها تصعد لوحدها، وندوِّن إمكان حصولها في شرح الإجراء (وهو النص الذي في أوَّل جسده).
من الخطأ إلتقاط جميع الأخطاء
من الخطأ في المنطق البرمجي إلتقاط جميع الأخطاء في الإجراءات باستعمال except Exception وهو النوع الشامل لجميع الأخطاء. فهذه الطريقة لا تخبرنا بنوع الخطأ وبالتالي لا نتعامل معه بحسبه، وإنما غاية ما تحقق هو منع تصعيد الخطأ لأعلى طبقة، إذْ حين يحصل ذلك توقف بايثون البرنامج (وعندها تظهر سلسلة الاستدعاءات).
لكن الحالة النادرة تكون في أطر العمل (Frameworks) إذْ يجوز ذلك في قطع النص البرمجي التي يُراد لها الاستمرار، وإن فشلَ شيئٌ فيها. مثلاً: لا تريد للخادم أن يتوقف تمامًا بمجرد حصول خطأ واحد في أحد خيوط التنفيذ الخاصة بخدمة أحد الطلبات. ففي مثل ذلك يسوغ استخدام except Exception الشاملة. انظر مثلاً قطعة النص البرمجي في إطار الويب فلاسك المسؤول عن استقبال الطلبات.
المراجع:
- Miguel Grinberg: The Ultimate Guide to Error Handling in Python