الاستثناءات في Python: try وexcept وelse وfinally وraise والاستثناءات المخصصة
الاستثناء في Python كائن يمثل خطأً ويوقف التنفيذ العادي حتى يلتقطه كتلة try/except. تُنفَّذ else إذا لم يحدث خطأ، وتُنفَّذ finally دائماً، ويرفع الخطأ يدوياً بالتعليمة raise. ويُقرأ الـ traceback من الأسفل، وتُلتقط الأخطاء المحددة لا كل شيء، لأن التقاط كل شيء يخفي المشكلات الحقيقية.
الاستثناء في Python كائن يدل على خطأ ويوقف المجرى الطبيعي للبرنامج. إذا لم يلتقطه أحد يطبع Python الـ traceback وينتهي التنفيذ. يُلتقط الاستثناء بالتركيب try/except، ويُرفع يدوياً بالتعليمة raise. وفيما يلي شرح لطريقة عمل ذلك، وأكثر الأخطاء شيوعاً، وكيف تعالج الأخطاء دون أن تخفيها.
ما هو الاستثناء في Python
عندما لا تستطيع عملية ما أن تنفذ، كالقسمة على صفر، أو الوصول إلى مفتاح غير موجود في قاموس، أو تحويل كلمة إلى رقم، ينشئ Python كائن استثناء و«يرميه». يتوقف تنفيذ الدالة الحالية، ويصعد الاستثناء عبر سلسلة الاستدعاءات حتى يجد معالجاً مناسباً. فإن لم يجد شيئاً انهار البرنامج.
>>> 10 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
وإجابة على سؤال متكرر: القسمة على صفر في Python تُنتج الاستثناء ZeroDivisionError. وهذا ينطبق على / و// و%.
كيف تقرأ الـ traceback
الـ traceback تقرير عن الطريق الذي وصل منه البرنامج إلى الخطأ. ويُقرأ من الأسفل إلى الأعلى. السطر الأخير هو الأهم: نوع الاستثناء ونصّ الرسالة. وفوقه سطر الكود الذي وقع فيه الخطأ، وفوقه من استدعاه، وهكذا حتى نقطة الدخول.
Traceback (most recent call last):
File "tracker.py", line 9, in <module>
total = parse_amount(row)
File "tracker.py", line 4, in parse_amount
return float(raw.strip())
~~~~~^^^^^^^^^^^^^
ValueError: could not convert string to float: 'مئة'
منذ Python 3.11 ترسم الرموز ~~~^^^ تحت سطر الكود (PEP 657)، فتُظهر التعبير الدقيق الذي تعطل داخل السطر. وهذا مفيد جداً عندما تتسلسل عدة استدعاءات في سطر واحد.
تلميحات «Did you mean»
منذ Python 3.10 تقترح رسالة الخطأ، عند وجود خطأ مطبعي في الاسم، أقرب تسمية صحيحة. وفي Python 3.15 صارت التلميحات أذكى: يعرف المفسّر العادات القادمة من لغات أخرى. فاستدعاء list.push يقترح append، واستدعاء "قهوة".toUpperCase() يقترح الطريقة upper. وتمتد التلميحات إلى الحقول المتداخلة: إذا كان القاموس موجوداً في الخاصية totals للكائن، واستدعيت items() على الكائن نفسه، فستقترح الرسالة .totals.items. كما تقترح الدالة delattr() الأسماء القريبة أيضاً.
أنواع الاستثناءات في Python
الاستثناءات المدمجة عشرات، لكنك ستراها أكثر من غيرها في البداية:
| الاستثناء | متى يحدث | مثال |
|---|---|---|
NameError | اسم غير معرّف، غالباً خطأ مطبعي | print(totl) |
TypeError | العملية لا تناسب هذا النوع | "5" + 3 |
ValueError | النوع صحيح لكن القيمة لا | int("مئة") |
KeyError | المفتاح غير موجود في القاموس | d["missing"] |
IndexError | الفهرس خارج حدود القائمة | [1, 2][5] |
AttributeError | الكائن لا يملك هذه الطريقة أو الخاصية | [].push(1) |
ZeroDivisionError | القسمة على صفر | 1 / 0 |
FileNotFoundError | الملف غير موجود | open("nope.txt") |
يستحق SyntaxError (وحالته الخاصة IndentationError) ملاحظة خاصة: يظهر أثناء قراءة الملف قبل تشغيل الكود، لذلك لا يمكن التقاطه بـ try في الملف نفسه.
التسلسل الهرمي للاستثناءات
الاستثناءات أصناف مرتبة في شجرة. في القمة BaseException، ومنه ينحدر Exception (معظم الأخطاء العادية) وبعض الاستثناءات النظامية: KeyboardInterrupt وSystemExit وGeneratorExit.
BaseException
├── KeyboardInterrupt
├── SystemExit
└── Exception
├── ArithmeticError
│ └── ZeroDivisionError
├── LookupError
│ ├── KeyError
│ └── IndexError
├── OSError
│ └── FileNotFoundError
├── TypeError
└── ValueError
التسلسل مهم عند الالتقاط: except LookupError تلتقط KeyError وIndexError معاً. وKeyboardInterrupt ليس خطأً في الكود، بل إشارة من Python بأن المستخدم ضغط Ctrl+C. ولا يرث من Exception عمداً، حتى لا تمنع except Exception إيقاف البرنامج.
معالجة الاستثناءات: try وexcept وelse وfinally
يوضع الكود الذي قد يفشل داخل كتلة try. وفي كتلة except تحدد نوع الخطأ وما ستفعله به. وكتلة except هي التي تُستخدم في Python لمعالجة الاستثناءات.
def parse_amount(raw):
try:
value = float(raw)
except ValueError as e:
print(f"أتجاوز السطر: {e}")
return None
else:
return value # تُنفَّذ إذا لم يحدث خطأ
finally:
print("انتهى التحليل") # تُنفَّذ دائماً
print(parse_amount("250"))
ترتيب الطباعة هنا: أولاً «انتهى التحليل»، ثم 250.0. إذ يحفظ return الموجود في كتلة else القيمة، ثم ينفّذ Python كتلة finally بالضرورة، وبعدها فقط يعيد النتيجة. لذلك كتلة finally موثوقة للتنظيف: إغلاق الملف، وتحرير الاتصال.
إذا كتبت return داخل finally فإنه يلغي الذي قبله، وقد «يبتلع» الاستثناء. ومنذ Python 3.14 يصدر لهذا الكود تحذير SyntaxWarning (PEP 765)، فلا تكتب return داخل finally.
عدة استثناءات معاً
يمكن كتابة عدة كتل except لأنواع مختلفة، أو ذكر الأنواع في صف واحد:
try:
amount = int(data["amount"])
except KeyError:
amount = 0
except (ValueError, TypeError) as e:
print("مبلغ غير صالح:", e)
amount = 0
تُفحص الكتل من الأعلى إلى الأسفل، وتُنفَّذ أول كتلة مطابقة، لذلك تُوضع الأنواع الأدق فوق الأعم. ومنذ Python 3.14 (PEP 758) يمكن ذكر عدة أنواع دون أقواس، مثل except ValueError, TypeError:، لكن إذا وُجدت as فالأقواس واجبة.
كيف تعرض نص الخطأ وتعرف نوعه
يتوفر كائن الاستثناء عبر as e. فـ str(e) تعطي الرسالة، وtype(e).__name__ تعطي اسم النوع:
try:
int("مئة")
except ValueError as e:
print(type(e).__name__) # ValueError
print(e) # invalid literal for int() with base 10: 'مئة'
الوحدة traceback: النص الكامل للخطأ
أحياناً تريد أن تلتقط الخطأ لكن تحفظ الـ traceback كاملاً، مثلاً في ملف سجل أو تقرير. لذلك توجد الوحدة traceback:
import traceback
try:
1 / 0
except ZeroDivisionError:
traceback.print_exc() # يطبع الـ traceback في stderr
text = traceback.format_exc() # النص نفسه كسلسلة
إذا كنت تكتب السجلات بالوحدة logging، فالأسهل استدعاء logging.exception("تعذر الحساب") داخل except، فيُضاف الـ traceback إلى الرسالة تلقائياً.
raise في Python: رفع استثناء
التعليمة raise ترفع استثناءً يدوياً. وبهذا تعلن الدالة أنها لا تستطيع العمل مع هذه البيانات:
def add_expense(amount):
if amount < 0:
raise ValueError(f"المبلغ لا يمكن أن يكون سالباً: {amount}")
...
add_expense(-5)
# ValueError: المبلغ لا يمكن أن يكون سالباً: -5
الأمر raise في Python تعليمة وليست دالة، لذلك لا تُكتب بعدها أقواس: الوسائط تُمرَّر إلى منشئ الاستثناء داخل القوسين. اختر النوع حسب المعنى: القيمة الخاطئة تكون ValueError، والنوع الخاطئ يكون TypeError.
raise بلا وسائط وraise from
داخل except تعيد raise المجردة رفع الاستثناء الحالي كما هو. وهذا مفيد عندما تريد تسجيل الخطأ في السجل، وتترك المعالجة لمن استدعى الدالة.
وإذا استبدلت استثناءً باستثناء آخر فاستخدم raise ... from e: يبقى الخطأ الأصلي محفوظاً في السلسلة، ويظهر الاثنان في الـ traceback:
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
raise RuntimeError(f"الإعدادات غير موجودة: {path}") from e
add_note: إضافة سياق للخطأ
منذ Python 3.11 (PEP 678) يمكن إضافة ملاحظة إلى الاستثناء بالطريقة add_note() ثم رفعه من جديد. ولا يتغير نوع الخطأ، لكن الملاحظة تُطبع تحت الرسالة:
def parse(raw, line_no):
try:
return float(raw)
except ValueError as e:
e.add_note(f"الملف expenses.csv، السطر {line_no}")
raise
parse("مئة", 17)
# ValueError: could not convert string to float: 'مئة'
# الملف expenses.csv، السطر 17
كيف تكتب استثناءً خاصاً بك في Python
الاستثناءات الخاصة مفيدة لأخطاء مجال العمل: تجاوز الميزانية، أو طلب مدفوع من قبل، أو مستخدم محظور. يكفي صنف يرث من Exception:
class BudgetExceeded(Exception):
def __init__(self, category, limit, spent):
super().__init__(f"{category}: الحد {limit}، المصروف {spent}")
self.category = category
self.limit = limit
self.spent = spent
def check_budget(category, limit, spent):
if spent > limit:
raise BudgetExceeded(category, limit, spent)
try:
check_budget("طعام", 10000, 12500)
except BudgetExceeded as e:
print("تنبيه:", e) # تنبيه: طعام: الحد 10000، المصروف 12500
الآن يلتقط الكود المستدعي تجاوز الميزانية تحديداً، ولا يخلط بينه وبين الأخطاء المطبعية. وفي المشروع الكبير يُفضَّل إنشاء صنف أساسي مشترك (class TrackerError(Exception)) ترث منه كل استثناءاتك، فتُلتقط بـ except TrackerError واحدة. وارث من Exception لا من BaseException.
مجموعات الاستثناءات و except*
أحياناً تحدث عدة أخطاء معاً، كأن تفحص ملفاً كاملاً، أو في المهام المتوازية مع asyncio.TaskGroup. منذ Python 3.11 يمكن جمعها في ExceptionGroup، ومعالجتها حسب الأنواع بـ except*:
def check_all(rows):
errors = []
for r in rows:
try:
float(r)
except ValueError as e:
errors.append(e)
if errors:
raise ExceptionGroup("أخطاء في الملف", errors)
try:
check_all(["1", "a", "b"])
except* ValueError as eg:
print(f"سطور معطوبة: {len(eg.exceptions)}") # سطور معطوبة: 2
try except لكل الأخطاء: لماذا لا يجوز ذلك
أسوأ عادة هي التقاط كل شيء: except: pass. يتوقف البرنامج عن الانهيار، لكنه في الحقيقة يتوقف عن الإبلاغ عن المشكلات. فالـ except المجردة تلتقط حتى KeyboardInterrupt وSystemExit، فلا يمكن إيقاف البرنامج بـ Ctrl+C. وexcept Exception: pass أفضل قليلاً، لكنها تبتلع بصمت الأخطاء المطبعية في الأسماء وأخطاء المنطق.
كيف تعالج الاستثناءات بصورة صحيحة في Python:
- التقط النوع الذي تعرف كيف تعالجه فقط، مثل
except ValueError، لا كل شيء؛ - اجعل كتلة
tryقصيرة، وتضم السطور التي قد تفشل بذلك النوع تحديداً؛ - إذا التقطت بشكل واسع (
except Exception) في المستوى الأعلى للبرنامج، فسجّل الـ traceback دائماً؛ - لا تستخدم الاستثناءات للتفريع العادي حيث يكفي فحص: مثلاً
dict.get()بدلاً منtry/except KeyErrorفي الحالات البسيطة؛ - للموارد استخدم
with، فهو يغلق الملف بنفسه حتى عند الخطأ.
الخلاصة
الاستثناءات في Python آلية طبيعية للإبلاغ عن الأخطاء، وليست سبباً لإخفائها. اقرأ الـ traceback من الأسفل، والتقط الأنواع المحددة في try/except، واستخدم else للكود الذي يعمل «إذا نجح كل شيء»، وfinally للتنظيف. وتسمح raise بالإبلاغ عن الخطأ بنفسك، وتحفظ raise from وadd_note السياق، وترث استثناءاتك الخاصة من Exception. وقد شُرح هذا الموضوع عملياً في الدورة، حيث يتوقف متتبّع المصروفات عن الانهيار بسبب سطر CSV واحد معطوب، ويعبّر عن تجاوز الميزانية باستثناء خاص به.
الأسئلة الشائعة
ما الفرق بين الخطأ والاستثناء في Python؟
في الكلام اليومي هما مترادفان. تقنياً، يظهر SyntaxError أثناء قراءة الكود، أما بقية الأخطاء فتنشأ أثناء التنفيذ كأشياء استثنائية يمكن التقاطها.
هل يمكن متابعة التنفيذ بعد الاستثناء من المكان نفسه؟
لا. بعد الالتقاط يستمر التنفيذ بعد كتلة try/except، لا من السطر الذي وقع فيه الخطأ. وإذا أردت معالجة كل سطر من ملف فضع try داخل الحلقة.
ماذا يحدث إذا وقع استثناء داخل except؟
يصعد الاستثناء الجديد إلى الأعلى، ويُحفظ الاستثناء الأصلي في الخاصية __context__ الخاصة به. وتظهر في الـ traceback العبارة During handling of the above exception, another exception occurred.
كيف أتحقق في الاختبارات من أن الدالة ترفع استثناءً؟
في pytest تُستخدم العبارة with pytest.raises(ValueError):، وفي unittest تُستخدم self.assertRaises(ValueError). ينجح الاختبار فقط إذا وقع استثناء من النوع المطلوب داخل الكتلة.
ما هو assert وما الفرق بينه وبين raise؟
العبارة assert شرط, "رسالة" ترفع AssertionError إذا كان الشرط خاطئاً. وهي أداة للتحقق أثناء التطوير، وعند التشغيل بالعلم -O تُعطَّل، لذلك تحقق من مدخلات المستخدم بـ if و raise.
يُشرح هذا الموضوع بالتفصيل مع التطبيق العملي في دورة «Python 3.15: от новичка до профи».
الانتقال إلى الدورة