10  الخطأ

الخطأ في البرمجيات على نوعين:

  1. خطأ ظاهر: حيث تم برمجة مسار لكشفه؛ سواءٌ فيما كتبناه أو من المكتبة التي استعملناها أو في بايثون نفسها.
  2. خطأ خفي: عدم اعتبار جميع الاحتمالات في كل المسارات الممكنة للبرنامج. فهو ناتج عن نقص في السبر.

أولاً: الخطأ النحوي

فمن الخطأ الظاهر: الخطأ النحوي (Syntactic Error): وهو الخطأ في مبنى اللغة؛ أي: مخالفة قواعدها وقوانينها.

مثال ذلك فقد النطقتين الرأسيتين (:) كفاصلة للجملة الشرطية.. كما سيظهر الخطأ الآن في هذه القطعة:

if x > 5
    print("x")
  Cell In[10], line 1
    if x > 5
            ^
SyntaxError: expected ':'
ملاحظة

كي تقرأ هذا الخطأ: انظر أولاً للسطر الأخير حيث كُتب SyntaxError فذاك نوع الخطأ. وكتب بعده تخصيص له، حيث قال: expected ':' .. أي: كان من المتوقع وجود : هنا. ثم انظر فوقه لتجد سهمًا صغيرًا يشير إلى المكان الذي يظن مفسر بايثون أن قد حصل فيه الخطأ الإملائي.

ومنه أيضًا عدم تطابق المسافاة البادئة للجمل ضمن القطعة الواحدة:

if True:
    print("x")
     print('y')
  Cell In[11], line 3
    print('y')
    ^
IndentationError: unexpected indent
ملاحظة

نوع هذا الخطأ هو IndentationError وهو نوع من الخطأ الإملائي.

ثانياً: الخطأ المنطقي

وأما الخطأ الخفي فأساسه الخطأ المنطقي (Logical Error): وهو تعبيرٌ صحيحٌ نحويًّا لكنَّه لا يؤدي في الواقع إلى المقصود الذي أراده كاتبه منه. فالنية صحيحة لكن السهم أخطأ الهدف.

ومثال ذلك محاولة تفسير النص المكتوب بترميز مختلف عن الذي كُتِبَ به:

برنامج نوتباد في وندوز بكلام عربي يظهر بشكل استفهامات

برنامج نوتباد في وندوز بكلام عربي يظهر بشكل استفهامات

ومنه أيضًا: أن يريد المبرمج استعمال دالَّة التربيع (Square) فظنَّها math.sqrt لكن هذه (Square Root) أي: الجذر التربيعي. والصحيح أن يختار: math.pow(4, 2) لرفع 4 للقوة 2.

import math

square = math.sqrt(4)

وكذلك المعروف باسم “خطأ الحافَّة” (Off-by-one error)، ويكاد أن يكون أشهر الأخطاء الخفية المنطقية في البرمجة.

نشرحه بمثال: النية هنا هي طباعة الأرقام بالعكس من الرقم الأعلى (5) إلى (0) بما في ذلك (0)، ولكن الحلقة تتوقف عند (1). وذلك أن آلية عمل النطاق (range) عدم شمول النهاية.

for i in range(5, 0, -1):
    print(i, end=' ')
5 4 3 2 1 

والصحيح المطابق لنية الكاتب كان:

for i in range(5, -1, -1):
    print(i, end=' ')
5 4 3 2 1 0 

الاحتراز من الخطأ المنطقي

وهي التي نقصدها حين نقول: بَق (Bug) بمعنى: مشكلة في البرنامج. ويسمى البرنامج الذي يساعد في إصلاح المشاكل البرمجية: المدقق (Debugger). وتسمى وعملية البحث عنها وإصلاحها: التدقيق (Debugging).

الأخطاء المنطقية صامتة

الأخطاء المنطقية صامتة

الأخطاء المنطقية صامتة. إذ لا يكتشفها المترجم، وتتسبب في تصرف البرنامج بشكل غير صحيح. الأخطاء المنطقية هي الأصعب في التتبع والإصلاح لأنها ليست واضحة. يمكن أن تكون ناجمة عن:

  • افتراضات غير صحيحة
  • خطوات غير مؤديَّة للمقصود

لكونها جملاً صحيحة في ذاتها؛ لا يستطيع البرنامج كشف هذه الأخطاء لوحده. بل يجب على من يُدركُ حقيقة المطلوب من البرنامج أن يتكفل بذلك. وهنا تكون الحاجة ماسَّة لجمل التوكيد: assert.

والتدقيق؛ إذْ أفضل طريقة لحل الأخطاء المنطقية هي تنفيذ القطعة البرمجية والنظر في الناتج، وتتبع المنطق مرة أخرى إلى النص البرمجي سطرًا بسطر. يمكنك استخدام عبارات الطباعة print لتصحيح الأخطاء وفهم تدفق البرنامج. وقد يكون الأفضل من ذلك استعمال المدقق (Debugger).

ومراجعة الأقران: بحيث يطلع على النص البرمجي شخص آخر، فإنه قد يرى منه ما تعذر عليك رؤيته. وقد يتم تنظيمه بين أعضاء الفريق الواحد بأحد برمجيات التعاون مثل: GitHub وGitLab وBitbucket وغيرها. لكن ليس شرطًا أن يكون بها حتى تستفيد منه.

تجويد العبارة

ومما يسهل الاحتراز من الأخطاء المنطقية: تجويد العبارة البرمجية.

ومن تجويد العبارة تسمية المتغيرات بما يدل على وظيفتها، مثل:

rate = 50
hours_per_day = 6
days = 5
pay = rate * hours_per_day * days
print(pay)
1500

وإن كان ليس من الخطأ النحوي كتابتها بطريقة مختلفة وبأسماء غير معبِّرة، إلا أنه فعلٌ غير مستحسن:

r, hpd, d = 50, 6, 5
p = r * hpd * d
print(p)
1500

وفي هذا نصائح كثيرة، يراجع فيها دليل أسلوب الكتابة في بايثون.

ثالثاً: الخطأ التشغيلي

ومن الخطأ الظاهر: الخطأ التشغيلي (Runtime Error)؛ أي الذي يصادَف أثناء عمل البرنامج. ويعبَّر عنه في عدة لغات باسم الاستثناء (Exception).

الاستثناء (Exception) هو إعلام بخروج البرنامج عن المسارات المعتادة إلى مسار لم تتم برمجته.

مثال ذلك:

  1. أن يؤمَر بقراءة ملف .. والواقع أن هذا الملف غير موجود!
  2. أو أن يطلب من المستخدم رقمًا فيعطيه كلاماً!
  3. أو أن يطلب من الشبكة شيئًا .. فتنقطع الشبكة!

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

ولاحظ أننا في جميع الحالات السابقة نكشف الخطأ بفحص الحالة:

  1. فأمر قراءة الملف يتضمن التحقق من وجوده؛ فإن لم يوجَد فإن الإجراء يقوم برفع استثناء
  2. وأمر التحويل من النص إلى الرقم فيه أيضًا فحص للحروف التي في النص؛ فإن لم تكن قابلة للتحويل فإنه يرفع استثناء
  3. وطلب شيء من الشبكة يتضمن توقيتًا لو تعداه ولم تحصل النتيجة؛ فإنه يتوقع أن ذلك بسبب انقطاع الشبكة، فيرفع استثناء

لكننا غالبًا ما كنا نتعامل مع إجراءات من مكتبات، وهي التي ترفع الاستثناءات.

وقد يتبادر لذهنك أن الاستثناء ما هو إلا حالة تكون في جملة الرجوع return. فهذا المثال (وهو مثال غير صحيح) يوضِّح هذه الفكرة:

def some_function():
    if some_condition:
        return Exception("something went wrong")
    return "everything is fine"

وهذا صحيح في لغات برمجة أخرى غير بايثون؛ مثل: جو (Go) ورَسْت (Rust) وغيرها. لكن بايثون تشبه في هذا الأمر جافا (Java) وجافاسكريبت (JavaScript)، حيث تسمى رمي (throw) وأما في بايثون فتسمى رفع (raise) الاستثناءات.

وعملية الرفع (raise) مثل جملة الرجوع، إلا أنها تُجبِر الإجراء المستدعي على أحد خيارين:

  1. أن يتعامل مع الاستثناء المرفوع
  2. أن يكرر رفعه إلى من استدعاه هو

أي أن هذه الآلية تجعل الاستثناء يرجع ويرجع إلى أن يصل لقطعة تتعامل معه، وإلا فإنه يخرج من البرنامج بالكلية فيتوقف. وهذه الحالة نسميها الانهيار (Crash)، وهي محمودة في الأغلب، إذْ قد يؤدي البرنامج إلى إيقاف الجهاز الذي يعمله عليه.

والشكل التالي يوضح أن الاستثناء يُرفع (raise) بعد التحقق (if) من حالة معيَّنة. فإن لم تحصل (False) هذه الحالة الخاطئة؛ فإن البرنامج يتم سيره:

الاستثناء يُرفع (raise) بعد التحقق (if) من حالة معيَّنة

الاستثناء يُرفع (raise) بعد التحقق (if) من حالة معيَّنة

وإليك تمثيل هذه الصورة بقطعة بايثون:

some_condition = True

def f4():
    print('f4')

def f3():
    print('f3:start')
    if some_condition:
        raise Exception("something went wrong")
    f4()            # <-- لن يتم تنفيذ هذا السطر بسبب رفع الاستثناء
    print('f3:end') # <-- لن يتم تنفيذ هذا السطر بسبب رفع الاستثناء

def f2():
    print('f2:start')
    try:
        f3()
    except Exception as e:
        print("Caught the exception:", e)
        print("dealing with it...")
        # ... some logic to deal with the error ...
    print('f2:end')

def f1():
    print('f1:start')
    f2()
    print('f1:end')

def main():
    print("main:start")
    f1()
    print("main:end")

main()
main:start
f1:start
f2:start
f3:start
Caught the exception: something went wrong
dealing with it...
f2:end
f1:end
main:end

كيف نقرأ رسالة الخطأ؟

وغالبًا تظهر لك رسالة خطأ (كبيرة أحيانًا) وذلك يحصل حين يُترَك ولا يلتقط بجملة try-except إذْ أن بايثون في تلك الحالة تقوم بالآتي:

  1. وضع ملاحظات على سلسلة الاستدعاءات التي أدت إلى الخطأ
    1. اسم الملف
    2. رقم السطر مع سهم يشير إليه
  2. إيقاف البرنامج
  3. إظهار رسالة الخطأ

فهذا نفس المثال، لكننا سنحذف try-catch لنترك الخطأ ليصعد إلى الأعلى:

some_condition = True

def f4():
    print('f4')

def f3():
    print('f3:start')
    if some_condition:
        raise Exception("something went wrong")
    f4()            # <-- لن يتم تنفيذ هذا السطر بسبب رفع الاستثناء
    print('f3:end') # <-- لن يتم تنفيذ هذا السطر بسبب رفع الاستثناء

def f2():
    print('f2:start')
    f3()
    print('f2:end')

def f1():
    print('f1:start')
    f2()
    print('f1:end')

def main():
    print("main:start")
    f1()
    print("main:end")

main()
main:start
f1:start
f2:start
f3:start
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[18], line 28
     25     f1()
     26     print("main:end")
---> 28 main()

Cell In[18], line 25, in main()
     23 def main():
     24     print("main:start")
---> 25     f1()
     26     print("main:end")

Cell In[18], line 20, in f1()
     18 def f1():
     19     print('f1:start')
---> 20     f2()
     21     print('f1:end')

Cell In[18], line 15, in f2()
     13 def f2():
     14     print('f2:start')
---> 15     f3()
     16     print('f2:end')

Cell In[18], line 9, in f3()
      7 print('f3:start')
      8 if some_condition:
----> 9     raise Exception("something went wrong")
     10 f4()            # <-- لن يتم تنفيذ هذا السطر بسبب رفع الاستثناء
     11 print('f3:end')

Exception: something went wrong

فيما يلي نتأمل رسالة الخطأ التي ظهرت..

فترى في أول قطعة الإجراء الذي بدأ ذلك التسلسل كله وهو main:

Exception                                 Traceback (most recent call last)
Cell In[18], line 28
     25     f1()
     26     print("main:end")
---> 28 main()

ثم بعد ذلك ترى كومة الاستدعاءات (Stack Trace) وفي أسفل ذلك كله، ترى السبب المباشر للخطأ:

Cell In[18], line 25, in main()
     23 def main():
     24     print("main:start")
---> 25     f1()
     26     print("main:end")

Cell In[18], line 20, in f1()
     18 def f1():
     19     print('f1:start')
---> 20     f2()
     21     print('f1:end')

Cell In[18], line 15, in f2()
     13 def f2():
     14     print('f2:start')
---> 15     f3()
     16     print('f2:end')

Cell In[18], line 9, in f3()
      7 print('f3:start')
      8 if some_condition:
----> 9     raise Exception("something went wrong")
     10 f4()
     11 print('f3:end')

وأما السطر الأخير بعد ذلك كله، فإنه ملخص للخطأ، وهو أول ما يجب أن تقرأ:

  1. النوع (مثل: Exception وهو أب جميع الأخطاء)
  2. التفاصيل بلغة طبيعية (مثل: something went wrong)
Exception: something went wrong

جملة المحاولة (try-except)

تنفذ التعليمات في لغة البرمجة الأمرية (Imperative) كبايثون بحسب ترتيبها (من الأعلى إلى الأسفل). لكن عند حدوث خطأ، يتغيَّر سيْر الأوامر باستعمال جملة try-except. وشكل جملة التعامل مع الخطأ على هذا النحو:

  • المحاولة: try تتضمن الجملة التي نتوقع حدوث خطأٍ فيها
  • حالة الخطأ: except Exception هي مثل if تنفذ ما تتضمنه إن كان الخطأ من نوع Excpetion (وهو أب جميع الأخطاء)
    • أما e فهو المتغير الذي يمثِّل تفاصيل الاستثناء إن وجدت؛ وعادة ما يكون رسالة نصيَّة تلخص الخطأ
  • حالة عدم الخطأ: else تعمل عند عدم الخطأ (وفي هذا المثال لن تعمل أبدًا لأننا نتوقع حدوث أي خطأ على الإطلاق)
  • التعقيب: finally وهي جُملة تعمل سواء وقع الخطأ أم لم يقع؛ لكنَّ بايثون تضمن عملها إن حصل خطأ أثناء التعامل مع الخطأ
def do_something():
    print('before')
    try:
        # حاول تشغيل هذه القطعة
    except Exception as e:
        # إذا حدث خطأ من نوع Exception
        # فشغل هذه القطعة
    else:
        # وإن لم يحصل فهذه القعطة
    finally:
        # وشغل هذه على أية حال
        # سواءٌ حصل الخطأ أم لا
        # وفائدتها أنها تعمل قبل رجوع الخطأ لموضع النداء
    print('after')

أنواع الاستثناء

تم تعريف أنواع من الخطأ في بايثون متبوعة بكلمة Error (عُرفًا)، وذلك باعتبار حالات خطأ نمطية ومتكررة:

1. SyntaxError

السبب: خطأ نحوي في صياغة اللغة:

  • كلمة غير صحيحة: خطأ في الإملاء
  • في وضع كلمة صحيحة في غير سياقها
  • محاذاة غير متسقة (IndentationError)

مثال:

if x > 5
    print("x")
  Cell In[19], line 1
    if x > 5
            ^
SyntaxError: expected ':'

الحل: اقرأ رسالة الخطأ وستدلُّك على السبب والموضع الذي حصل فيه الخطأ.

ملاحظة

في الواقع هذا ليس من الأخطاء التشغيلية، بل هو خطأ نحوي / إملائي. ويمكن ضبط المحرر كي يكشفها لك قبل تشغيل البرنامج أصلاً.

2. TypeError

السبب:

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

الحل: الاحتراز بفحص النوع عن طريق الإجراء type() أو isinstance() أو بالتأكد من تحويل النوع مسبقًا.

مثال:

a = 5
b = input('Enter a number: ')
result = a + int(b)
5 + '5'
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[20], line 1
----> 1 5 + '5'

TypeError: unsupported operand type(s) for +: 'int' and 'str'

ستجد الخلاصة في السطر الأخير:

  • نوع الخطأ: TypeError
  • التفصيل: نوع المعطيات لعملية الجمع (+) غير متوافقة؛ وهي: العدد (int) والنص (str). ولاحظ أنه ذكر نوع العدد (int) لأنه قبله في ترتيب الكتابة.
ملاحظة

يمكن تفادي هذا النوع من الأخطاء باستعمال أدوات مثل mypy. لكن لا يتسع المقام لذكرها هنا.

3. ValueError

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

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

الحل: الاحتراز بفحص مدى القيمة قبل تنفيذ الأمر ، نحو:

if x >= 0:
    math.sqrt(x)
else:
    # do something else

4. IndexError & 5. KeyError

السبب: الرقم الذي استعمل في عملية الإشارة list[index] (قائمة) أو dict[key] (قاموس) يشير لما هو خارج المجموعة. وهذا يؤدي إلى كوارث لو كان في لغة “غير آمنة” مثل سي (C) لأنها لا تتحقق من صحة المؤشر، إلا إذا فعلنا ذلك بأنفسنا. لكن في بايثون يتم كشف هذا الخطأ ورفعه حال وقوعه مباشرة، ونتعامل معه كاستثناء.

نحو:

my_list = [10, 20, 30]
idx = 3
my_list[idx]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[22], line 1
----> 1 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[24], line 1
----> 1 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

6. AttributeError & 7. NameError

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

  • فإن أسنِد إلى كائن؛ رُفِع AttributeError (مثل: a.x)
  • وإلا رُفِع NameError (مثل: X)
a = 10
a + X
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[25], 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[26], 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[27], 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[28], line 1
----> 1 a.do_something()

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

8. ModuleNotFoundError

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

الحل:

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

قراءة تسلسل الاستدعاءات إلى الخطأ

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

تعريف الخطأ يكون بتعريف نوع جديد يرث من النوع 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

الاحتراز والاستجابة

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

وذكرنا أن الإجراء له أحد أمرين لا ثالث لهما:

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

والرفع يتسلسل حتى يخرج من البرنامج؛ حيث يصل إلى الإنسان (سواءً المستفيد أو المبرمج): برسالة مفادها أمران:

  1. العثور عليه
  2. التعامل معه

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

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

الفرق بين: الاحتراز والاستجابة

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


المراجع: