الفصل 24

الاستثناءات — التعامل مع الأخطاء والانهيارات

التقاط خطأ محدد باستخدام try/except، لماذا يُعد except Exception: pass خطأ في كل الحالات تقريباً، كتلتا else و finally، إطلاق الاستثناءات باستخدام raise، واختيار الخطأ المناسب من شجرة الاستثناءات.

36 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

في السؤال الأخير من الفصل السابق، قام البرنامج بمعالجة ثلاثة أسطر بنجاح ثم توقف تماماً عند السطر الرابع. ولتوضيح الأمر بصورة أبسط:

python
numbers = ["10", "twenty", "30"]
total = 0

for text in numbers:
    total += int(text)

print(total)
text
ValueError: invalid literal for int() with base 10: 'twenty'

تمت إضافة 10 بنجاح. لكن 30 لم تُضف إطلاقاً. والسطر print(total) لم يُنفذ قط.

سطر واحد تالف أوقف المهمة بأكملها. وحتى هذه اللحظة، كان أمامنا خياران فقط — إما أن تكون كل قطعة من البيانات سليمة تماماً، وإما أن ينهار البرنامج ويتوقف.

python
numbers = ["10", "twenty", "30"]
total = 0

for text in numbers:
    try:
        total += int(text)
    except ValueError:
        print("skipping", text)

print(total)
text
skipping twenty
40

ولكن هناك خيار ثالث أفضل: دع المشكلة تحدث، ثم تعامل معها بحكمة.

في نهاية هذا الدرس ستكون قادراً على

  • التقاط خطأ محدد بدقة باستخدام try و except
  • شرح سبب كون كتابة except: وحدها أو except Exception: pass فكرة خاطئة وخطيرة في جميع الحالات تقريباً
  • الإمساك بكائن الخطأ عبر as err وقراءة الرسالة التوضيحية الخاصة به
  • معرفة دور وتوقيت تشغيل كتلتي else و finally
  • إطلاق أخطائك الخاصة باستخدام raise ومعرفة متى يكون ذلك ضرورياً
  • التعرف على شجرة عائلات الاستثناءات والتقاط المستوى المناسب منها

المتطلبات السابقة: قراءة الملفات وكتابتها.


الخطأ هو كائن بحد ذاته

كتابة as تضع كائن الخطأ بين يديك:

python
try:
    int("twenty")
except ValueError as err:
    print(type(err))
    print(err)
text
<class 'ValueError'>
invalid literal for int() with base 10: 'twenty'

النص الأحمر الذي كنت تراه يملأ الطرفية طوال هذه الفترة هو في الواقع هذه الكائنات، وطباعة الكائن تعرض رسالة التوضيح المرفقة به.

وهذا يقودنا إلى فائدة عملية بالغة الأهمية: يحمل كائن الخطأ أدق وأوضح تفسير لما حدث. طباعة err تكاد تكون دائماً أكثر فائدة من كتابة عبارة عامة من عندك مثل "حدث خطأ ما".

التقط الخطأ الصحيح فقط

python
try:
    int("twenty")
except TypeError:
    print("caught")
text
ValueError: invalid literal for int() with base 10: 'twenty'

تلتقط كتلة except فقط النوع الذي حددته بالاسم. هنا حددنا TypeError، بينما الخطأ الذي وقع فعلياً كان ValueError — لذا كأن كتلة except لم تكن موجودة على الإطلاق، وتوقف البرنامج.

قد يبدو ذلك عائقاً، لكنه في الواقع الميزة الأكثر فائدة! سطر except يعلن صراحة عن الخطأ الذي كنت مستعداً لمواجهته — وأي خطأ آخر لم تكن مستعداً له سيوقف البرنامج، وهو التصرف السليم تماماً.

ولهذا السبب تحديداً، يُحظر عليك كتابة كود كهذا:

python
numbers = ["10", "twenty", "30"]
total = 0

for text in numbers:
    try:
        total += int(text)
    except Exception:
        pass

print(total)
text
40

الإجابة صحيحة رياضياً، ومع ذلك فالكود سيئ للغاية ومضلل. عبارة except Exception: pass تبتلع كل شيء بصمت — الأعطال التي فكرت فيها وتلك التي لم تخطر ببالك. فإذا تحولت القائمة numbers غداً إلى قاموس عن طريق الخطأ، أو إذا أخطأت في كتابة اسم متغير بالداخل، فسيستمر هذا البرنامج في العمل ويعطيك بهدوء إجابة غير صحيحة دون أن تشعر.

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

أكثر من نوع من الأخطاء

python
def get(data, key):
    try:
        return data[key]
    except KeyError:
        return "no such key"
    except IndexError:
        return "no such position"


print(get({"pen": 15}, "pen"))
print(get({"pen": 15}, "bag"))
print(get([1, 2, 3], 9))
text
15
no such key
no such position

يمكن كتابة عدة كتل except متتالية، وأول كتلة تطابق نوع الخطأ هي التي تُنفذ — بينما يتم تجاهل البقية تماماً.

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

python
for value in ["10", None, "x"]:
    try:
        print(int(value))
    except (ValueError, TypeError) as err:
        print(type(err).__name__, "-", err)
text
10
TypeError - int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
ValueError - invalid literal for int() with base 10: 'x'

الخاصية type(err).__name__ تعطي اسم نوع الخطأ — وهي مفيدة جداً عند كتابة سجلات التتبع (Logs).

الأخطاء تأتي في عائلات وراثية

python
print(issubclass(FileNotFoundError, OSError))
print(issubclass(ValueError, Exception))
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))
text
True
True
True
True

تترتب الأخطاء في بايثون على شكل شجرة عائلية وراثية، والتقاط الصنف الأب يلتقط جميع الأصناف الأبناء تلقائياً:

python
try:
    raise FileNotFoundError("gone")
except OSError as err:
    print("caught as OSError:", err)
text
caught as OSError: gone

علاقات وراثية شائعة يجدر بك معرفتها: FileNotFoundError و PermissionError كلاهما ينحدر من OSError؛ و KeyError و IndexError كلاهما ينحدر من LookupError؛ وكل الاستثناءات تقريباً تنحدر من الصنف الأساسي Exception.

ومن هنا تأتي القاعدة الذهبية: التقط أضيق نوع خطأ ممكن يتناسب مع حالتك. فكتابة except Exception هي الأسهل في الكتابة والأقل نفعاً على الإطلاق.

كتلتا else و finally

python
def read(text):
    try:
        value = int(text)
    except ValueError:
        print("bad:", text)
        return None
    else:
        print("good:", value)
        return value
    finally:
        print("done with", text)


print(read("10"))
print(read("ten"))
text
good: 10
done with 10
10
bad: ten
done with ten
None

كتلة else تُنفذ فقط عندما تكتمل كتلة try بنجاح دون أي خطأ. فائدتها أنها تبقي كتلة try صغيرة ومحددة — فتحتوي فقط على السطر الذي قد يتعطل — بينما العمل الذي يتبع النجاح يوضع في else. فإذا وضعت كوداً زائداً داخل try، فستلتقط أخطاء جانبية لم تكن تقصد معالجتها أصلاً.

كتلة finally تُنفذ دائماً وبلا استثناء، مهما حدث. حتى وإن انتهت الدالة بعبارة return:

python
def risky():
    try:
        return "from try"
    finally:
        print("finally still runs")


print(risky())
text
finally still runs
from try

عبارة return حددت القيمة المعادة بالفعل، لكن finally تُنفذ قبل أن تغادر الدالة فعلياً. وهي المكان المخصص لعمليات التنظيف وتحرير الموارد — مع الإشارة إلى أن استخدام with في التعامل مع الملفات قد تولى هذا العبء بالكامل.

إطلاق الأخطاء بنفسك (Raising Exceptions)

إطلاق الخطأ يضاهي في الأهمية التقاطه.

python
def line_total(price, quantity):
    if quantity < 0:
        raise ValueError(f"quantity cannot be negative: {quantity}")
    return price * quantity


print(line_total(15.0, 3))
print(line_total(15.0, -1))
text
45.0
ValueError: quantity cannot be negative: -1

ماذا كان البديل عن استخدام raise؟ إرجاع None، أو إرجاع 0 — وحينها سيتسرب الخطأ ويتسلل بصمت. سيندمج في حسابات إجمالية أخرى، ليطفو على السطح لاحقاً كرقم غريب غير مفهوم ودون أي أثر يوضح مصدره الأصلي.

الخطأ الصريح يوقف المشكلة في مهدها لحظة ولادتها، ويوضح السبب بدقة في رسالته.

وعند كتابة تلك الرسالة، ضمّن القيمة المسببة للخطأ بداخلها. الفرق بين "invalid quantity" و "quantity cannot be negative: -1" شاسع — فالرسالة الثانية وحدها تمنحك نقطة بداية للحل.

يمكنك أيضاً التقاط خطأ وإعادة إطلاقه برسالة أوضح وأشمل:

python
def parse(text):
    try:
        return int(text)
    except ValueError:
        raise ValueError(f"not a number: {text!r}")


print(parse("10"))
print(parse("ten"))
text
10
ValueError: not a number: 'ten'

الصيغة {text!r} تدرج القيمة عبر دالة repr، مما يظهر علامات الاقتباس — والفرق بين not a number: 'ten' و not a number: ten بالغ الأهمية إذا كانت القيمة نصاً فارغاً تماماً أو مسافة بيضاء خفية.

الاستفسار أولاً، أم الاعتذار لاحقاً؟

python
prices = {"pen": 15}

if "bag" in prices:
    print(prices["bag"])
else:
    print("not stocked")

try:
    print(prices["bag"])
except KeyError:
    print("not stocked")
text
not stocked
not stocked

كلا الأسلوبين صحيح. والأسلوب الثاني أكثر شيوعاً في أوساط بايثون، لسبب وجيه: الأسلوب الأول يفحص مرتين — مرة عبر in، ومرة أخرى عند الاستخراج عبر [...] — وقد يتعطل إذا تغير شيء بين العمليتين.

أما بالنسبة للمسارات المعتادة المتكررة بكثرة، فإن جملة if تكون أكثر وضوحاً في القراءة. فالاستثناءات صُممت للحالات الاستثنائية؛ واسمها يدل على ذلك.


مثال متكامل

ملف orders.txt، ويحتوي عمداً على سطرين تالفين:

text
pen,15.0,3
bag,eight,1

ink,120.0,2
clip,5.0

ملف main.py:

python
"""Read an order file, and keep going when a line is wrong."""

from pathlib import Path

TAX_RATE = 0.15


def parse_line(raw, number):
    """Returns one order line, or raises ValueError saying which line was wrong."""
    parts = raw.split(",")
    if len(parts) != 3:
        raise ValueError(f"line {number}: expected 3 fields, got {len(parts)}")

    name, price, quantity = parts
    try:
        return name, float(price), int(quantity)
    except ValueError as err:
        raise ValueError(f"line {number}: {err}")


def read_order(path):
    """Returns the good lines and the complaints, never raising for bad data."""
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return [], [f"no such file: {path}"]

    lines = []
    problems = []

    for number, raw in enumerate(text.splitlines(), start=1):
        if not raw.strip():
            continue
        try:
            lines.append(parse_line(raw, number))
        except ValueError as err:
            problems.append(str(err))

    return lines, problems


def main():
    lines, problems = read_order(Path("orders.txt"))

    total = 0.0
    for name, price, quantity in lines:
        amount = round(price * quantity * (1 + TAX_RATE), 2)
        total += amount
        print(f"{name:<6} {amount:>9.2f}")

    print(f"{'total':<6} {total:>9.2f}")

    print()
    print(f"{len(lines)} lines used, {len(problems)} skipped")
    for problem in problems:
        print(" -", problem)


if __name__ == "__main__":
    main()
text
pen        51.75
ink       276.00
total     327.75

2 lines used, 2 skipped
 - line 2: could not convert string to float: 'eight'
 - line 5: expected 3 fields, got 2

أربعة أمور جديرة بالدراسة:

parse_line تطلق الخطأ و read_order تلتقطه. الدالة الدنيا لا تملك أدنى فكرة عما يجب أن يفعله البرنامج ككل — كل ما تعرفه هو أن هذا السطر تحديداً غير قابل للاستخدام. أما اتخاذ القرار بشأن ما يجب فعله فيعود للدالة الأعلى منها. تُطلق الأخطاء في المستويات الدنيا وتُلتقط في المستويات العليا، وهذا الفصل في المسؤوليات هو جوهر نظام الاستثناءات.

رقم السطر جزء من رسالة الخطأ. الرسالة could not convert string to float: 'eight' لا تخبرك أين تبحث؛ أما line 2: ... فتخبرك بدقة. أي معلومة تعرفها لحظة الخطأ ولن تتوفر لك لاحقاً يجب أن تكون جزءاً من الرسالة.

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

كتلة try الداخلية لا تتعدى سطراً واحداً. السطران float(price) و int(quantity) — فقط الجزء المعرض للفشل وُضع بالداخل. أما parts = raw.split(",") فخارجها، لأنه لا يُفترض به الفشل، وإن فشل فمن المهم جداً أن نعلم ذلك فوراً.


حالات الخطأ الشائعة

لم يتم التقاط الخطأ رغم وجود كتلة except نوع الخطأ غير مطابق. الكلمة الأولى في رسالة الخطأ هي اسم نوعه — حدد ذلك الاسم بالضبط، أو التقط الصنف الأب الذي ينحدر منه.

توجد كتلة except ومع ذلك لا يزال البرنامج يتوقف السطر الذي تعطل ليس واقعاً داخل كتلة try. تأكد من المسافات البادئة (Indentation).

SyntaxError: expected 'except' or 'finally' block تمت كتابة try دون أن تتبعها كتلة except أو finally. لا يمكن لكتلة try أن تقف وحدها.

يبدو البرنامج وكأنه "يعمل" لكن النتائج غير صحيحة توجد عبارة except Exception: pass في مكان ما. ابحث عنها — فهناك تكمن المشكلة الحقيقية.

أطلقت خطأ لكن الرسالة لا تظهر كتبت raise ValueError بدلاً من raise ValueError("..."). أضف القوسين والنص التوضيحي.

عبارة return داخل finally تبتلع الخطأ وتخفيه هذا صحيح، ولهذا السبب تحديداً يجب ألا تضع return داخل finally. فكتلة finally مخصصة للتنظيف فقط.