16  الخطأ الواقعي: الاستثناء

الاستثناء (Exception) هو خروج البرنامج عن المسار المثالي. ويسمى خطأً (Error).

مثل أن يؤمَر بقراءة ملف .. والواقع أن هذا الملف غير موجود! أو أن يطلب من المستخدم رقمًا فيعطيه كلاماً! أو أن يطلب من الشبكة شيئًا .. فتنقطع الشبكة! فكل هذه تعتبر مسارات غير مثالية لكنها تحصل في ظروف واقعيَّة. فيجب كتابة قطع في البرنامج تتعامل معها. ولذا فإن بعض الممارسين لا يفضلون استعمال كلمة استثناء لأنَّ مثل ذلك يحصل كثيرًا فهو ليس خارجًا عن العادة؛ بل من الطبيعي أن يحصل ذلك في الواقع.

تصوَّر أن البرنامج سلسلة من الأفعال بحيث يختص كل فعل بمهمة معيَّنة (بناءً على مدخلاته يُعطي مخرجاته) فإننا نتعمَّد أن يكون وعيُه محدودًا بمدخلاته حتى نقلل من تشابك قطع الكود حتى نيسِّر على أنفسنا التحقق من صحَّة المنطق. وهذه فكرة ظرف التنفيذ الذي سبق الحديث عنها في باب الفعل.

فللخطأ حالتان -لا غير- في علاقتها مع من يتعامل معه:

  1. يستوعبه السياق: أن يكون في وعي الفعل (الظرف) ما يمكن به معالجته
  2. لا يستوعبه السياق: ألا يكون كذلك فيرفعه (يرجع به) للفعل المستدعي له (الذي هو أكثرُ وَعيًا منه) ليعالجه
    • وهذا يتسلسل حتى يصل إلى الإنسان (سواءً المستفيد أو المبرمج): برسالة تفيد في العثور عليه والتعامل معه.

ومنشؤ الخطأ أحد أمرين:

  1. خطأ مرفوع: تم رفعه من فعل مستدعى في السياق الحالي.
  2. خطأ جديد: تم اكتشاف حالة تتوقف تكملة الفعل على إصلاحها.

وللتعامل مع الخطأ حين يستوعبه السياق طريقتان:

  1. الوقاية: توقُّع حالات الخطأ والتحقق من عدمها قبل الإقدام على العملية.
  2. التدارك: ترك العملية لترجع بالخطأ؛ ثم نتعامل معه بحسبه.

16.1 الفرق بين الوقاية والتدارك

هذا الفعل مسؤول عن تهيئة البرنامج، ومن خطواته أنه يقرأ ملف الضبط المخصص custom_config.json. وفي هذه الحالة قد يحصل خطأ. إذْ قد لا يكون الملف موجودًا أصلا!

file = open('custom_config.json')

هذا الفعل مسؤول عن تهيئة البرنامج، ومن خطواته أنه يقرأ ملف الضبط المخصص 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

أما استعمال الوقاية ففيه شك بوجود فجوة زمنية بين عملية التحقق من وجود الملف وبين قراءته. فالحاسب تتوارد عليه البرمجيات، ولا يستأثر به برنامج واحد. فإذا تغيَّر الحال في هذه الفجوة الزمنية وعاد بالنقض على التحقق بعد حصوله انتفت الفائدة منه. لذا فالتدارك أضمن.

الحالة الأولى: خطأ جديد يستوعبه السياق

مثاله ما لو لم تكن أحد الصفات محددة، فيمكن تعيينها بقيمة افتراضية، ولا يلزمنا تصعيد الخطأ:

def save_user(user: dict):
    if 'language' not in user:
        user['language'] = 'ar'
    # ... rest of the code
    save_to_database(user)

الحالة الثانية: خطأ مرفوع يستوعبه السياق

وقد مثلنا له بفتح الملف، ونمثل له بمثال آخر:

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

الحالة الثالثة: خطأ جديد لا يستوعبه السياق

في هذا المثال يجب أن تكون الوحدة إحدى القيم المسموحة: 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

الحالة الرابعة: خطأ مرفوع لا يستوعبه السياق

ففي هذا المثال عملية قسمة قد يحصل فيها قسمة على صفر في (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

السبب:

  1. طلب فعل بعدد أكثر أو أقل من العوامل الواجبة (مثل: len(1, 2))
  2. طلب فعل بعوامل لا تطابق النوع المحدد في تعريفه (مثل: math.sqrt('nine') أو 5 + '5')

الحل: الوقاية بـ type() أو isinstance() أو بالتأكد من تحويل النوع مسبقًا.

a = 5
b = input('Enter a number: ')
result = a + int(b)

ValueError

السبب: أن يكون النوع صحيحًا (فلا يحصُل TypeError) لكن القيمة غير مقبولة.

  • مثلاً: طلب فعل بقيمة نوعها عددي لكنَّها سالبة وهو لا يقبل إلا الموجبة. نحو: math.sqrt(-16) فالجذر التربيعي لا يقبل السالب.

الحل: الوقاية بفحص مدى القيمة ، نحو: if x >=0: math.sqrt(x)

IndexError & KeyError

السبب: الرقم الذي استعمل في عملية الإشارة [index]list (قائمة) أو dict[key] (قاموس) يشير لما هو خارج المجموعة.

نحو:

my_list = [10, 20, 30]
idx = 3
value = my_list[idx]
---------------------------------------------------------------------------
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

الحل بالوقاية:

if idx < len(my_list):
    value = my_list[idx]
else:
    # do something else

أو بالتدارك:

try:
    value = my_list[idx]
except IndexError:
    # do something else

وكذلك في القاموس، نحو:

my_dict = {'A': 10, 'B': 20, 'C': 30}
key = 'Z'
value = my_dict[key]
---------------------------------------------------------------------------
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'

الحل بالوقاية

if key in my_dict:
    value = my_dict[key]
else:
    # do something else

أو هكذا (تعيين قيمة افتراضية):

value = my_dict.get(key, 0)

أو بالتدارك:

try:
    value = my_dict[key]
except KeyError:
    # do something else

AttributeError & NameError

السبب: استعمال متغير أو فعل قبل تعريفه.

  • فإن أسنِد إلى كائن؛ وقع AttributeError
  • وإلا وقع NameError
a = 10
a + X
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[20], line 2
      1 a = 10
----> 2 a + X

NameError: name 'X' is not defined
some_function(55)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[21], line 1
----> 1 some_function(55)

NameError: name 'some_function' is not defined
class A:
    pass

a = A()
a.x
---------------------------------------------------------------------------
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'
a.do_something()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[23], line 1
----> 1 a.do_something()

AttributeError: 'A' object has no attribute 'do_something'

ModuleNotFoundError

السبب: فشل جُملة الاستيراد import numpy

الحل:

  • تأكد من صحة الإملاء
  • تأكد من تثبيت الوحدة في البيئة التي يعمل فيها البرنامج: pip install numpy

16.4 تعريف أخطاء جديدة

تعريف الخطأ يكون بتعريف نوع جديد يرث من النوع Exception، وهذا ما يحققه السطر الأول بين القوسين. وتستطيع أن ترث ممن يرث، فتتكون لديك فروع من هذا الخطأ:

class ParentError(Exception):
    pass

class XError(ParentError):
    pass

class YError(ParentError):
    pass

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)
caught you: Something went wrong

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) تخلط بين النوعين!


المراجع: