الخطأ

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

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

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

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

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

if x > 5
    print("x")
ملاحظة

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

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

if True:
    print("x")
     print('y')
ملاحظة

نوع هذا الخطأ هو 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=' ')

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

for i in range(5, -1, -1):
    print(i, end=' ')

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

وهي التي نقصدها حين نقول: بَق (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)

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

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

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

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

ومن الخطأ الظاهر: الخطأ التشغيلي (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()

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

وغالبًا تظهر لك رسالة خطأ (كبيرة أحيانًا) وذلك يحصل حين يُترَك ولا يلتقط بجملة 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:

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')

كيف نتعامل مع الخطأ؟

يكون تعاملنا معه بإحدى طريقين:

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

وفيما يلي نسرد أكثر الأخطاء حدوثًا وكيفية التعامل معها..

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

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

1. SyntaxError

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

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

مثال:

if x > 5
    print("x")

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

ملاحظة

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

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
  • التفصيل: نوع المعطيات لعملية الجمع (+) غير متوافقة؛ وهي: العدد (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]

الحل: بأن نحترز باشتراط كون المؤشر لا يتعدى العنصر الأخير

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]

الحل: بالاحتراز بأن نشترط وجود المفتاح أصلاً في القاموس

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
some_function(55)
class A:
    pass

a = A()
a.x
a.do_something()

8. ModuleNotFoundError

السبب:

  • فشل جُملة الاستيراد import numpy
    • قد يكون ذلك بسبب اختلاف البيئة (venv)

الحل:

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

خلاصة التعامل مع الأخطاء

نوع الخطأ السبب الأساسي الحل المقترح
SyntaxError خطأ إملائي، نحوي، أو في الإزاحة (Indentation). مراجعة رسالة الخطأ واستخدام محرر أكواد ذكي.
TypeError تمرير قيمة ذات نوع خاطئ. فحص النوع باستخدام type() أو isinstance().
ValueError النوع صحيح لكن القيمة غير مقبولة. التحقق من صحة تمرير القيمة.
IndexError محاولة الوصول لعنصر خارج نطاق القائمة (List). التأكد من الطول عبر len() أو استخدام try-except.
KeyError محاولة الوصول لمفتاح غير موجود في القاموس (Dict). استخدام in للتحقق، أو دالة .get()، أو try-except.
AttributeError طلب خاصية أو دالة غير موجودة داخل كائن (Object). التأكد من تعريف الخاصية أو صحة اسم الدالة للكائن.
NameError محاولة استخدام متغير أو دالة لم يتم تعريفها مسبقاً. التأكد من تعريف الاسم (Variable/Function) قبل استدعائه.
ModuleNotFoundError فشل استيراد مكتبة أو وحدة (Module). التأكد من صحة الإملاء وتثبيت المكتبة عبر pip أو uv.

راجع كامل شجرة الأخطا في بايثون.