لغات البرمجة عملياً الأدلة التقنية

أخطاء شائعة وأسلوب Python الصحيح Pythonic

خمسة عشر خطأً يقع فيها من يأتي إلى Python من Java وJavaScript وC#، كل واحد بتشغيل خاطئ حقيقي وإصلاحه وما يمسكه ruff منه، ثم خطة قراءة لأربعة أسابيع من التوثيق الرسمي.

أخطاء شائعة وأسلوب Python الصحيح Pythonic

بعد شهر من العمل على خدمة الطلبات في المتجر الصغير الذي يبيع القهوة والتمور، صار في الفريق ثلاثة مطورين جاؤوا إلى Python من Java وJavaScript وC#، وكلهم يكتبون كوداً يعمل ويمر من الاختبارات. ولكن مراجعات الكود Code Review بدأت تتكرر فيها نفس الملاحظات: سلة عميل ظهرت فيها منتجات عميل آخر في بيئة التجربة، وخصم صفر في المئة تحول إلى خمسة، ومنتج اختفى من الطلب دون أي خطأ لأن قائمة الكميات كانت أقصر بعنصر، وطلب لم يحفظ لأن أحدهم نسي await. والمشترك بين هذه الأخطاء أنها ليست أخطاء برمجة عامة، فالمطور نفسه لا يقع فيها في لغته الأصلية، وإنما أخطاء من يكتب Python بعقلية لغة أخرى، فيفترض أن القيمة الافتراضية تنشأ مع كل استدعاء كما في JavaScript، أو أن zip يعترض إذا اختلف الطول كما يفعل الكثير من المكتبات في Java.

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

  • ما معنى الكود «الصحيح بأسلوب Python» Pythonic، ولماذا ليس مسألة ذوق.
  • أخطاء التقييم Evaluation: القيمة الافتراضية القابلة للتغيير، والـ Closures داخل الحلقات.
  • أخطاء القوائم والمراجع: الحذف أثناء المرور، والصفوف المشتركة، والـ Generator الذي يستهلك مرة واحدة.
  • أخطاء تخفي الخطأ: except بدون نوع، وor مع الصفر، وzip بأطوال مختلفة، ونسيان await.
  • أخطاء الأسماء والاستيراد: المتغير العام، والاسم الذي يغطي دالة مدمجة، والاستيراد الدائري، وتشغيل وحدة من حزمة كسكربت.
  • خطأ في الأداء بدمج النصوص، وخطأ في الوقت بتاريخ بلا منطقة زمنية.
  • أي هذه الأخطاء يمسكها ruff وأيها لا يمسكه إلا الاختبار، وخطة قراءة لأربعة أسابيع من التوثيق الرسمي.

ما معنى Pythonic؟ الأسلوب Style الذي تتوقعه اللغة

الكلمة Pythonic تعني كوداً يستعمل اللغة كما صممت أن تستعمل، وليس فقط كوداً يعمل. وأقرب ما يكون إلى تعريف رسمي لها هو PEP 20 المعروف باسم The Zen of Python، وهو تسعة عشر سطراً كتبها Tim Peters وتظهر إذا كتبت import this، ومنها «الصريح أفضل من الضمني» Explicit is better than implicit، و«يجب ألا تمر الأخطاء بصمت» Errors should never pass silently، و«يجب أن تكون هناك طريقة واحدة واضحة لعمل الشيء، ويفضل أن تكون واحدة فقط». ومعه PEP 8 الذي يحدد أسلوب الكتابة من أسماء ومسافات وطول أسطر، وهو ما يطبقه ruff تلقائياً.

وقد يتساءل البعض: إذا كان الكود يعمل، فلماذا أهتم بالأسلوب؟ والإجابة أن أغلب الأخطاء في هذا المقال هي بالضبط مخالفة لسطر من هذه الأسطر، فـ except بدون نوع يجعل الأخطاء تمر بصمت، وor للقيمة الافتراضية ضمني حيث يجب أن يكون صريحاً، وzip بدون strict=True يقبل ما لا يجب أن يقبل. أي أن الأسلوب في Python ليس مسألة جمال، وإنما مجموعة قواعد تعلمها المجتمع من أخطاء حقيقية، والطريقة «غير الـ Pythonic» غالباً هي الطريقة التي تحمل خطأً ينتظر أن يظهر. والشكل التالي يلخص الأخطاء الخمسة عشر وما ظهر في كل تشغيل:

جدول من خمسة عشر صفاً بأعمدة الخطأ وما ظهر في التشغيل الخاطئ والإصلاح وقاعدة ruff: القيمة الافتراضية القابلة للتغيير B006، والـ Closures داخل حلقة B023، والحذف أثناء المرور بدون قاعدة، و[[0] * 3] * 3 بدون قاعدة، وexcept بدون نوع E722 وF821، وتعديل متغير عام F823، والقيمة الافتراضية بـ or بدون قاعدة، وzip بأطوال مختلفة B905، والـ Generator مرتين بدون قاعدة، والاسم الذي يغطي دالة مدمجة A001، ودمج النصوص 49.2 ms مقابل 18.3 ms، ونسيان await، والاستيراد الدائري، وتشغيل وحدة من حزمة كسكربت
خمسة عشر خطأً، ومخرج كل واحد بالطريقة الخاطئة، وما أمسكه ruff منها

والملفات كلها في المجلد lab/m مع المقال، وكل ملف يأخذ معاملاً واحداً wrong أو fixed، والملف run.sh يشغل كل واحد بالطريقتين داخل Container من الـ Image الرسمية، ويطبع المخرج وآخر أسطر الخطأ ورمز الخروج:

docker run --rm --cpus 2 --memory 512m -v "$PWD/lab:/lab" python:3.15.2-slim-trixie sh /lab/run.sh

لماذا تقع في هذه الأخطاء؟ ثلاث قواعد Rules في نموذج اللغة

قبل أن نمر على الأخطاء واحداً واحداً من المفيد أن ترى أنها لا تأتي من خمسة عشر مكاناً مختلفاً، وإنما من ثلاث قواعد في تصميم Python، إذا فهمتها توقعت أغلب هذه الأخطاء قبل أن تقع فيها. القاعدة الأولى أن الاسم يشير إلى كائن ولا يحتويه، وهذا ما رأيناه بالتفصيل في مقال الأنواع والذاكرة: الإسناد يعلق اسماً، والتمرير إلى دالة يمرر المرجع، والضرب * على قائمة يكرر المراجع، فكل خطأ فيه «تعديل ظهر في مكان لم أتوقعه» هو هذه القاعدة.

والقاعدة الثانية أن def وclass وimport أوامر تنفذ وقت التشغيل، وليست تعريفات يقرؤها المترجم قبل أن يبدأ البرنامج كما في Java أو C#. فالسطر def ينفذ مرة واحدة عندما يصل إليه المفسر، وينشئ كائن دالة، ويحسب القيم الافتراضية في تلك اللحظة، والسطر import ينفذ الملف المستورد من أوله إلى آخره عند أول استيراد، ولذلك يحدث الاستيراد الدائري: الوحدة الأولى لم تكمل تنفيذ أسطرها عندما طلبت الثانية اسماً منها. وأما نطاق الأسماء Scope فيتحدد عند ترجمة الدالة إلى Bytecode كما رأينا في المقال الأول، بقاعدة البحث المعروفة: المحلي ثم الدالة المحيطة ثم العام ثم المدمج، ولذلك يصبح الاسم الذي يسند إليه في أي سطر من الدالة محلياً في الدالة كلها.

والقاعدة الثالثة أن لكل قيمة في Python «صدقاً» Truthiness في الشروط: الصفر والنص الفارغ والقائمة الفارغة والقاموس الفارغ وNone كلها كاذبة، وكل شيء آخر صادق تقريباً. وهذا يجعل if items: طريقة مختصرة للسؤال عن قائمة غير فارغة، ولكنه يجعل or وكل شرط مختصر يعامل الصفر الصحيح كأنه غياب للقيمة. وإذا وضعت هذه القواعد الثلاث أمامك وأنت تقرأ كوداً من زميل قادم من لغة أخرى، فسوف تجد أن أغلب ملاحظاتك تنتمي إلى واحدة منها.

أخطاء وقت التقييم Evaluation Time

أول مجموعة من الأخطاء تأتي من سؤال واحد: متى تحسب Python القيمة التي كتبتها؟ في JavaScript تحسب القيمة الافتراضية لمعامل الدالة عند كل استدعاء، وفي Python تحسب مرة واحدة فقط، عندما ينفذ سطر def نفسه. وفي الـ Closures يحدث العكس تماماً: الدالة الداخلية لا تحفظ قيمة المتغير وقت إنشائها، وإنما تحفظ المتغير نفسه، وتقرأ قيمته وقت استدعائها. والشكل التالي يبين الحالتين:

صندوقان: الأول الدالة add_item مع cart=[] حيث يشير استدعاء العميل الأول واستدعاء العميل الثاني إلى نفس القائمة add_item.__defaults__[0] فتصبح النتيجة قهوة ثم قهوة وتمر، والإصلاح cart=None، والثاني ثلاث دوال lambda في حلقة تعطي cardamom=1500 ثلاث مرات، ومع lambda name=name تعطي coffee=4500 وdates=990 وcardamom=1500، وتحتهما قاعدة أن ما يكتب في سطر def يقيم مرة واحدة وما في جسم الدالة يقيم عند كل استدعاء
القيمة الافتراضية تقيم مرة واحدة، والـ Closure يقرأ المتغير عند الاستدعاء

1. القيمة الافتراضية القابلة للتغيير Mutable Default Argument

الدالة التالية تضيف منتجاً إلى سلة، وإذا لم تمرر سلة تنشئ سلة جديدة، أو هكذا يظن من كتبها:

def add_item(item, cart=[]):
    cart.append(item)
    return cart

print("customer A:", add_item("قهوة"))
print("customer B:", add_item("تمر"))

والمخرج بالطريقة الخاطئة ثم بالإصلاح:

--- wrong
customer A: ['قهوة']
customer B: ['قهوة', 'تمر']
(exit 0)
--- fixed
customer A: ['قهوة']
customer B: ['تمر']
(exit 0)

العميل الثاني وجد قهوة العميل الأول في سلته. والسبب أن [] في سطر def تنشئ قائمة واحدة عند تعريف الدالة، وتحفظ في الخاصية add_item.__defaults__، وكل استدعاء بدون سلة يستعمل نفس القائمة ويضيف إليها. وفي خدمة ويب تعمل أياماً دون إعادة تشغيل تكبر هذه القائمة مع كل طلب، وتنتقل بيانات عميل إلى آخر، وهذا خطأ أمني وليس خطأً في العرض فقط. والإصلاح الذي تجده في كل كود Python ناضج:

def add_item(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart

والقيمة None لا تتغير، فلا مشكلة في مشاركتها، والقائمة الجديدة تنشأ داخل جسم الدالة عند كل استدعاء. وهذا السؤال موجود في الأسئلة الشائعة في التوثيق الرسمي، وruff يمسكه بالقاعدة B006 كما سوف نرى.

2. الربط المتأخر Late Binding في الـ Closures

نبني قائمة دوال، كل واحدة تنسق سعر منتج، ثم نستدعيها بعد الحلقة:

prices = {"coffee": 4500, "dates": 990, "cardamom": 1500}
formatters = [lambda: f"{name}={prices[name]}" for name in prices]
print([f() for f in formatters])
--- wrong
['cardamom=1500', 'cardamom=1500', 'cardamom=1500']
(exit 0)
--- fixed
['coffee=4500', 'dates=990', 'cardamom=1500']
(exit 0)

الدوال الثلاث أعطت الهيل، لأن كل lambda تحفظ مرجعاً إلى المتغير name وليس قيمته، وعندما استدعيناها كانت الحلقة قد انتهت والمتغير يحمل آخر قيمة. ويفاجئ هذا مطوري JavaScript بالذات، لأن let في حلقة for هناك تنشئ متغيراً جديداً في كل دورة، أما في Python فالمتغير واحد. والإصلاح الأقصر أن تجعل القيمة معاملاً افتراضياً lambda name=name: ...، فتستفيد من الخطأ الأول نفسه: القيمة الافتراضية تقيم مرة واحدة عند إنشاء كل دالة، أي في كل دورة. والإصلاح الأوضح للقارئ هو functools.partial أو دالة تنشئ الدالة، وهذا السؤال أيضاً في الأسئلة الشائعة.

أخطاء القوائم والمراجع References

رأينا في مقال الأنواع والذاكرة أن الاسم في Python يشير إلى كائن، وأن أكثر من اسم قد يشير إلى نفس الكائن، والأخطاء الثلاثة التالية كلها نتيجة مباشرة لذلك:

صندوقان: الأول ثلاثة صفوف row 0 وrow 1 وrow 2 تشير كلها بأسهم إلى قائمة واحدة [5, 0, 0]، والمخرج [[5, 0, 0], [5, 0, 0], [5, 0, 0]]، والإصلاح بـ comprehension، والثاني جدول خطوات الحذف أثناء المرور: الفهرس 0 يحذف c1، والفهرس 1 يصل إلى h1 فلا يفحص d1 أبداً، والفهرس 2 يحذف x1، والنتيجة الخاطئة d1 وh1 والصحيحة h1
الصفوف الثلاثة قائمة واحدة، والحذف يزيح العناصر فتقفز الحلقة فوق واحد منها

3. الحذف من قائمة أثناء المرور عليها Mutating While Iterating

نريد أن نحذف من السلة كل سطر كميته صفر قبل حساب الطلب:

cart = [{"sku": "c1", "qty": 0}, {"sku": "d1", "qty": 0}, {"sku": "h1", "qty": 2}, {"sku": "x1", "qty": 0}]
for line in cart:
    if line["qty"] == 0:
        cart.remove(line)
print([l["sku"] for l in cart])
--- wrong
['d1', 'h1']
(exit 0)
--- fixed
['h1']
(exit 0)

بقي السطر d1 وكميته صفر. والسبب أن حلقة for على القائمة تتقدم بفهرس داخلي: في الدورة الأولى عند الفهرس 0 حذفت c1، فانزاحت كل العناصر خطوة إلى اليسار، وصار d1 في الفهرس 0، ولكن الحلقة انتقلت إلى الفهرس 1 فوجدت h1، ولم تر d1 أبداً. ولا يوجد أي خطأ أو تحذير، والنتيجة خاطئة فقط عندما يوجد سطران متتاليان بكمية صفر، أي أن الاختبار البسيط بسطر واحد سوف ينجح. والإصلاح أن تبني قائمة جديدة بالشرط بدل أن تعدل القائمة التي تمر عليها:

cart = [line for line in cart if line["qty"] != 0]

وإذا كان لا بد أن تعدل نفس الكائن لأن أسماء أخرى تشير إليه فاكتب cart[:] = [...]، أو مر على نسخة منها for line in cart[:]. ولاحظ أن القاموس أكثر صرامة من القائمة هنا، فتعديل حجم قاموس أثناء المرور عليه يرمي RuntimeError مباشرة. وفي كود قائم تبحث عن هذا الخطأ بنمط واضح: حلقة for x in items وفي جسمها items.remove أو items.pop أو del items[...]، وأي واحدة منها تستحق أن تتوقف عندها في المراجعة.

4. الصفوف المشتركة Shared References في [[0] * 3] * 3

جدول بسيط لكميات ثلاثة رفوف في ثلاثة فروع، ثم نضع 5 في الخانة الأولى:

shelves = [[0] * 3] * 3
shelves[0][0] = 5
print(shelves)
--- wrong
[[5, 0, 0], [5, 0, 0], [5, 0, 0]]
(exit 0)
--- fixed
[[5, 0, 0], [0, 0, 0], [0, 0, 0]]
(exit 0)

العامل * على قائمة يكرر المراجع وليس الكائنات، فالقائمة الخارجية فيها ثلاثة مراجع لنفس القائمة الداخلية، وأي تعديل في صف يظهر في الثلاثة. ومع الأعداد في [0] * 3 لا توجد مشكلة لأن الأعداد لا تتغير، فالإسناد يضع عدداً جديداً في الخانة، أما مع قائمة داخل قائمة فالمشكلة مضمونة. والإصلاح [[0] * 3 for _ in range(3)]، لأن الـ Comprehension ينفذ [0] * 3 في كل دورة فينشئ قائمة جديدة لكل صف، وهذا السؤال في الأسئلة الشائعة أيضاً. ونفس الخطأ يأتي بأشكال أخرى أقل وضوحاً، مثل dict.fromkeys(skus, []) الذي يعطي كل المفاتيح نفس القائمة، والحل هناك {sku: [] for sku in skus} أو collections.defaultdict(list).

5. الـ Generator يستهلك Exhausted مرة واحدة

نحسب مجموع أسطر الطلب، ثم أكبر سطر، من نفس التعبير:

lines = [(990, 3), (4500, 1)]
totals = (p * q for p, q in lines)
print("subtotal:", sum(totals))
print("largest line:", max(totals, default="(nothing)"))
--- wrong
subtotal: 7470
largest line: (nothing)
(exit 0)
--- fixed
subtotal: 7470
largest line: 4500
(exit 0)

الأقواس العادية حول التعبير تنشئ Generator وليس قائمة، والمولد ينتج قيمه مرة واحدة فقط، فاستهلكها sum كلها، ووجده max فارغاً فأرجع القيمة الافتراضية. ولو لم نكتب default لرمى max خطأ ValueError وكان الخطأ أوضح. والمطور القادم من Java يتوقع هذا مع Streams لأن الـ Stream في Java يرمي IllegalStateException إذا استعملته مرتين، أما في Python فالمولد الفارغ لا يعترض، وإنما يتصرف كأنه لا يوجد فيه شيء. والإصلاح أن تستعمل قائمة بالأقواس المربعة إذا كنت تحتاج القيم أكثر من مرة، وأن تحتفظ بالمولد للحالات التي تمر فيها على البيانات مرة واحدة، كقراءة ملف كبير كما رأينا في مقال الذاكرة.

أخطاء تخفي الخطأ Silent Failures

المجموعة الثالثة أخطر من غيرها، لأن البرنامج فيها ينتهي برمز الخروج 0 ويعطي نتيجة تبدو معقولة، ولا يوجد ما يدل على المشكلة إلا أن تتحقق من القيمة نفسها. والشكل التالي يصنف التشغيلات الخاطئة كلها بحسب ما رآه المطور:

ثلاثة صناديق: قيمة خاطئة بصمت في ثمانية أخطاء ينتهي فيها البرنامج بـ exit 0، وتحذير فقط في خطأ واحد هو نسيان await، واستثناء واضح في خمسة أخطاء ينتهي فيها البرنامج بـ exit 1، وتحتها أن ruff بقواعد E وF وB وA وRUF وPLW أمسك 6 أخطاء من 14 بقواعده هذه هي B006 وB023 وE722 وF823 وB905 وA001، والثمانية الباقية لا يمسكها إلا اختبار أو مراجعة
ثمانية من الخمسة عشر أعطت قيمة خاطئة بدون أي رسالة

6. except بدون نوع Bare Except

def price_of(sku, catalog):
    try:
        return catalgo[sku]          # typo: NameError
    except:
        return 0
--- wrong
price: 0
(exit 0)
--- fixed
  File "/tmp/m/m05_bare_except.py", line 11, in price_of_fixed
    return catalgo[sku]
           ^^^^^^^
NameError: name 'catalgo' is not defined. Did you mean: 'catalog'?
(exit 1)

كتب المطور except: لكي يرجع صفراً إذا لم يكن المنتج موجوداً، ولكن الكلمة except وحدها تلتقط كل شيء، بما فيه NameError الناتج عن خطأ إملائي في اسم المتغير، فصار سعر كل منتج صفراً ولا يوجد أي أثر في السجل. والإصلاح أن تلتقط النوع الذي تتوقعه فقط، أي except KeyError، فظهر الخطأ الإملائي فوراً، ولاحظ أن Python اقترحت الاسم الصحيح بنفسها Did you mean: 'catalog'?. والـ except: بدون نوع تلتقط حتى KeyboardInterrupt وSystemExit، فقد تمنعك من إيقاف البرنامج بـ Ctrl+C، وحتى except Exception واسعة جداً إلا في الطبقة العليا من الخدمة التي تسجل الخطأ وترد بـ 500. وهذا تطبيق مباشر لسطر «يجب ألا تمر الأخطاء بصمت» في PEP 20.

7. القيمة الافتراضية بـ or تأكل الصفر

requests = [{"discount": 10}, {"discount": 0}, {}]
for body in requests:
    discount = body.get("discount") or 5      # default 5% when missing
    print(body, "->", discount)
--- wrong
{'discount': 10} -> 10
{'discount': 0} -> 5
{} -> 5
(exit 0)
--- fixed
{'discount': 10} -> 10
{'discount': 0} -> 0
{} -> 5
(exit 0)

الطلب الذي أرسل خصماً صفراً بوضوح حصل على خصم 5%، لأن or في Python ترجع القيمة الثانية إذا كانت الأولى «كاذبة» Falsy، والصفر والنص الفارغ والقائمة الفارغة وNone كلها كاذبة. وهذا النمط منتشر جداً لأنه قصير ويعمل في أغلب الحالات، ويشبه || في JavaScript التي فيها نفس المشكلة، ولذلك أضافت JavaScript العامل ??. والإصلاح في Python أن تقول ما تقصده بالضبط: إذا كان المفتاح غائباً فاستعمل body.get("discount", 5)، وإذا كان المقصود أن None فقط تعني «غير موجود» فاكتب 5 if x is None else x. وفي الخدمة الحقيقية لا تقرأ القاموس يدوياً أصلاً، وإنما تعرف الحقل في Pydantic بقيمة افتراضية كما فعلنا في مقال الخدمة. ونفس الفخ يظهر في الشروط المختصرة مثل if not qty: للتحقق من أن الكمية موجودة، فيرفض الكود الكمية صفراً بنفس رسالة الكمية الغائبة، والصيغة الصريحة if qty is None: تفرق بين الحالتين.

8. zip بأطوال مختلفة يحذف بصمت

skus = ["c1", "d1", "h1"]
qtys = [2, 1]                # one quantity is missing from the form
print(dict(zip(skus, qtys)))
--- wrong
{'c1': 2, 'd1': 1}
(exit 0)
--- fixed
  File "/tmp/m/m08_zip_truncates.py", line 8, in <module>
    print(dict(zip(skus, qtys, strict=True)))
          ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: zip() argument 2 is shorter than argument 1
(exit 1)

الدالة zip تتوقف عند أقصر قائمة، فاختفى المنتج h1 من الطلب لأن النموذج أرسل كميتين فقط، ولم يعترض أحد. ومنذ Python 3.10 يوجد المعامل strict=True كما في توثيق الدالة zip، فيرمي ValueError إذا اختلف الطول، وهذا ما تريده في كل مكان تقترن فيه قائمتان يجب أن تكونا بنفس الطول. وإذا كان الاختلاف مقصوداً وتريد ملء الناقص بقيمة فاستعمل itertools.zip_longest.

9. نسيان await

saved = []

async def save_order(order_id):
    await asyncio.sleep(0.01)
    saved.append(order_id)

async def main():
    save_order(1)
    print("saved:", saved)
--- wrong
saved: []
/tmp/m/m12_forgot_await.py:11: RuntimeWarning: coroutine 'save_order' was never awaited
  save_order(1)
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
(exit 0)
--- fixed
saved: [1]
(exit 0)

رأينا في مقال التزامن أن استدعاء دالة async لا ينفذها، وإنما ينشئ Coroutine ينتظر أن ينفذه أحد بـ await، فبدون await لم يحفظ الطلب، والقائمة فارغة. والفرق عن JavaScript أن الدالة async هناك تبدأ التنفيذ فوراً عند الاستدعاء حتى بدون await، أما في Python فلا تبدأ أبداً. والشيء الوحيد الذي ظهر هو RuntimeWarning في stderr مع رمز خروج 0، فإذا كانت خدمتك لا ترسل التحذيرات إلى السجل، أو كان السجل مليئاً بغيرها، فلن يراه أحد. وmypy يمسك هذا الخطأ في أغلب الحالات إذا كانت الدوال مكتوبة بتلميحات، لأن القيمة غير المستعملة من نوع Coroutine، وفي الاختبارات شغل pytest مع -W error::RuntimeWarning لكي يتحول التحذير إلى فشل.

أخطاء الأسماء Names والاستيراد Imports

10. تعديل متغير عام داخل دالة UnboundLocalError

orders_today = 0

def record_order():
    orders_today += 1
    return orders_today
--- wrong
  File "/tmp/m/m06_unbound_local.py", line 7, in record_order
    orders_today += 1
    ^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'orders_today' where it is not associated with a value
(exit 1)
--- fixed
2
(exit 0)

هذا خطأ يحير القادمين من JavaScript وC#، لأن المتغير موجود بوضوح في أعلى الملف. والسبب أن Python تحدد نطاق كل اسم داخل الدالة وقت الترجمة إلى Bytecode وليس وقت التشغيل: أي اسم يسند إليه في أي مكان داخل الدالة يعتبر محلياً في الدالة كلها، و+= إسناد، فصار orders_today محلياً، وعندما حاولت الدالة قراءته قبل الإسناد لم تجد له قيمة. والكلمة global تحل الخطأ، ولكنها ليست الإصلاح الذي نقترحه، لأن متغيراً عاماً يعدل من الدوال هو حالة مشتركة مخفية، ورأينا في مقال التزامن ماذا يحدث لها مع الخيوط. والإصلاح الذي في المختبر أن تضع الحالة في كائن يملكها ويعدلها، فطبع 2 بعد استدعاءين. وتفاصيل القاعدة في الأسئلة الشائعة.

11. اسم يغطي دالة مدمجة Shadowing a Built-in

list = ["قهوة", "تمر"]                 # the name of the built-in type
print(len(list))
items = list(range(3))
--- wrong
2
Traceback (most recent call last):
  File "/tmp/m/m10_shadow_builtin.py", line 6, in <module>
    items = list(range(3))
TypeError: 'list' object is not callable
(exit 1)
--- fixed
2
[0, 1, 2]
(exit 0)

الأسماء list وdict وid وtype وsum وinput ليست كلمات محجوزة في Python، وإنما دوال وكلاسات في نطاق المدمجات Built-ins، فيمكنك أن تسند إليها بلا أي اعتراض، ومن تلك اللحظة يغطي اسمك الاسم المدمج في نفس النطاق. والسطر الأول عمل، والخطأ ظهر بعد سطرين في مكان لا علاقة له بالسبب، وفي ملف حقيقي قد يظهر بعد مئة سطر. والإصلاح اسم يقول ما هو الشيء مثل products، وruff يمسكه بالقاعدة A001 من مجموعة flake8-builtins، وهي ليست في الإعداد الافتراضي، فأضفها إلى select. ولاحظ أن id بالذات اسم مغر جداً في كود قواعد البيانات، والاسم order_id أوضح على أي حال.

12. الاستيراد الدائري Circular Import ورسالة مضللة

الوحدة orders تستورد دالة الضريبة من pricing، وpricing تستورد نسبة الضريبة الافتراضية من orders:

# circ_wrong/orders.py
from pricing import vat_halalas

def DEFAULT_RATE():
    return 15

# circ_wrong/pricing.py
from orders import DEFAULT_RATE

def vat_halalas(subtotal):
    return (subtotal * DEFAULT_RATE() + 50) // 100
--- wrong
    from pricing import vat_halalas
  File "/tmp/m/circ_wrong/pricing.py", line 1, in <module>
    from orders import DEFAULT_RATE
ImportError: cannot import name 'DEFAULT_RATE' from 'orders' (consider renaming '/tmp/m/circ_wrong/orders.py' if it has the same name as a library you intended to import)
(exit 1)
--- fixed
446
(exit 0)

عندما بدأ تحميل orders وصل في سطره الأول إلى استيراد pricing، فبدأ تحميلها، وpricing طلبت DEFAULT_RATE من orders التي لم يكمل تحميلها بعد، فلم يجد الاسم. ولاحظ أن الرسالة في هذا الإصدار تقترح أن تغير اسم الملف ظناً أنه يغطي مكتبة بنفس الاسم، وهذا اقتراح مضلل في حالتنا، فالسبب الحقيقي هو الدائرة. والإصلاح أن تقطع الدائرة بوحدة ثالثة لا تستورد شيئاً من الاثنتين، فوضعنا الثابت في settings.py تستورده pricing، فأعطى البرنامج 446. والاستيراد داخل الدالة يعمل أيضاً، ولكنه يخفي المشكلة بدل أن يحل تصميمها، وتفاصيل كيف يحمل Python الوحدات في مرجع نظام الاستيراد.

صندوقان: الأول استيراد دائري بين orders.py وpricing.py بسهمين: from pricing import vat_halalas ثم from orders import DEFAULT_RATE، ورسالة ImportError التي تقترح تغيير الاسم، والإصلاح بوحدة ثالثة settings.py والنتيجة 446، والثاني حزمة shop فيها __init__.py وpricing.py وcli.py مع استيراد نسبي، وتشغيل python shop/cli.py يعطي attempted relative import with no known parent package، وتشغيل python -m shop.cli يعطي VAT on 2970: 446
رسالة الاستيراد الدائري تقترح سبباً آخر، والاستيراد النسبي يحتاج python -m

ماذا لو What If: استعملنا lazy import في الاستيراد الدائري؟

رأينا في المقال الأول أن Python 3.15 أضافت الاستيراد الكسول بالكلمة lazy، فماذا يحدث إذا غيرنا السطر الأول في orders.py وحده إلى lazy from pricing import vat_halalas وتركنا الدائرة كما هي؟

### circular import with lazy (3.15)
446
(exit 0)

عمل البرنامج وأعطى 446، والسبب أن orders لم تحمل pricing عند سطر الاستيراد، فأكملت تعريف DEFAULT_RATE، وعندما استدعى main.py الدالة vat_halalas حملت pricing في تلك اللحظة، وطلبت من orders اسماً صار موجوداً. ولكن لا تقم باستعمال lazy كإصلاح للدائرة، والسبب أن الدائرة نفسها ما زالت موجودة في التصميم، وأي سطر في المستقبل يستعمل vat_halalas أثناء تحميل orders سوف يعيد الخطأ، ومعه صعوبة أكبر في فهمه لأن ترتيب التحميل صار يعتمد على أول استعمال وليس على ترتيب الأسطر. فالـ lazy أداة لتسريع البدء كما قسناها، والوحدة الثالثة settings.py هي إصلاح التصميم.

13. تشغيل وحدة من حزمة كسكربت Relative Import

# rel/shop/cli.py
from .pricing import vat_halalas

print("VAT on 2970:", vat_halalas(2970))
--- wrong
Traceback (most recent call last):
  File "/tmp/m/rel/shop/cli.py", line 1, in <module>
    from .pricing import vat_halalas
ImportError: attempted relative import with no known parent package
(exit 1)
--- fixed
VAT on 2970: 446
(exit 0)

الأمر python shop/cli.py يشغل الملف كسكربت مستقل اسمه __main__، فلا تعرف Python أنه جزء من الحزمة shop، والنقطة في from .pricing تعني «من نفس الحزمة» التي لا توجد. والإصلاح python -m shop.cli من المجلد الذي فيه الحزمة، فتحمل Python الحزمة أولاً ثم الوحدة داخلها. والطريقة الأفضل في مشروع حقيقي هي ما رأيناه في المقال الأول: قسم [project.scripts] في pyproject.toml يعرف أمراً يستدعي دالة في الحزمة، فلا يحتاج أحد أن يعرف مسار الملف أصلاً. والمطور القادم من Node.js يقع في هذا الخطأ كثيراً، لأن node src/cli.js هناك يعمل مع الاستيراد النسبي دائماً.

خطأ في الأداء Performance: دمج النصوص

14. بناء نص كبير String Building بـ += في حلقة

rows = [f"{i},تمر سكري,990\n" for i in range(200_000)]

def concat():
    out = ""
    for r in rows:
        out += r
    return out

def join():
    return "".join(rows)
--- wrong
concat: 3888890 chars in 49.2 ms
(exit 0)
--- fixed
join: 3888890 chars in 18.3 ms
(exit 0)

النتيجة نفسها في الطريقتين، ولكن join أسرع بحوالي 2.7 مرة على مئتي ألف سطر. والسبب أن النصوص في Python لا تتغير، فكل += ينشئ نظرياً نصاً جديداً وينسخ كل ما قبله، وهذا يجعل الحلقة تربيعية في أسوأ الحالات. والفرق هنا أقل مما تتوقعه الشروحات القديمة لأن CPython يحسن += على النص عندما يكون هناك مرجع واحد له فيكبره في مكانه، ولكن هذا تحسين في CPython وحده ولا تعد به اللغة، كما تذكر الأسئلة الشائعة، و"".join هو الشكل المعروف الذي يحسب الطول الكلي مرة واحدة ويحجز مرة واحدة. ولملفات CSV الحقيقية لا تبن النص بنفسك أصلاً، وإنما استعمل الوحدة csv التي تعالج الفواصل وعلامات التنصيص.

خطأ في الوقت Time Zones: تاريخ بلا منطقة زمنية

15. المقارنة بين تاريخ Naive وتاريخ Aware

عرض التمر السكري ينتهي في آخر دقيقة من يوم 31 أكتوبر بتوقيت السعودية، والكود يسأل: هل العرض ما زال قائماً؟

KSA = timezone(timedelta(hours=3))                    # Saudi time, no daylight saving
offer_ends = datetime(2026, 10, 31, 23, 59, tzinfo=KSA)

now = datetime.utcnow()                           # naive: no zone attached
print("now:", now.isoformat(), "tzinfo:", now.tzinfo)
print("offer still on:", now < offer_ends)
--- wrong
/lab/m/m13_naive_datetime.py:10: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
  now = datetime.utcnow()                           # naive: no zone attached
now: 2026-10-31T10:09:13.957824 tzinfo: None
Traceback (most recent call last):
  File "/lab/m/m13_naive_datetime.py", line 12, in <module>
    print("offer still on:", now < offer_ends)
                             ^^^^^^^^^^^^^^^^
TypeError: can't compare offset-naive and offset-aware datetimes
(exit 1)
--- fixed
now: 2026-10-31T10:09:15.312017+00:00 | in KSA: 2026-10-31T13:09+03:00
offer still on: True
(exit 0)

في Python نوعان من التواريخ في نفس الكلاس: التاريخ الذي لا يعرف منطقته الزمنية ويسميه التوثيق Naive، وtzinfo فيه None كما طبع المخرج، والتاريخ «الواعي» Aware الذي يعرف فرقه عن UTC. والدالة utcnow() ترجع الوقت بتوقيت UTC ولكن في تاريخ بلا منطقة زمنية لا يقول إنه UTC، ولذلك صارت مهملة Deprecated كما قال التحذير في السطر الأول، وستحذف في إصدار قادم. والمقارنة بين النوعين ترمي TypeError، وهذا في الحقيقة أفضل ما يمكن أن يحدث، لأن الحالة الأخطر هي تاريخان بلا منطقة زمنية، أحدهما بتوقيت UTC من السيرفر والآخر بتوقيت السعودية من قاعدة البيانات، فتقارن Python بينهما بلا اعتراض، وينتهي العرض قبل موعده بثلاث ساعات أو بعده بثلاث. والإصلاح أن تكون كل التواريخ في الكود واعية: datetime.now(UTC) للوقت الحالي، والتحويل إلى توقيت السعودية عند العرض فقط بـ astimezone، كما في السطر الأخير الذي طبع الساعة 13:09+03:00. والعمود created_at في خدمة الطلبات من نوع timestamptz لهذا السبب بالذات، فـ psycopg يرجعه تاريخاً واعياً. ولاحظ أن ruff لم يمسك هذا الخطأ بالقواعد التي استعملناها، ولكنه أمسكه عندما أضفنا مجموعة flake8-datetimez بالأمر ruff check --select DTZ فأعطى DTZ003 `datetime.datetime.utcnow()` used، وتفاصيل النوعين في توثيق datetime.

اختبارات الحالات الحدية Edge Cases تمسك الأخطاء الصامتة

قلنا إن الأخطاء الصامتة لا يمسكها إلا اختبار يتحقق من القيمة، فلنكتب هذا الاختبار فعلاً. الملف test_edges.py في المختبر يحمل ثلاثة من الملفات الخاطئة ويسأل كل واحد سؤالاً واحداً من الحالات الحدية: هل يحصل كل عميل على سلة منفصلة؟ وهل يحذف سطران متتاليان بكمية صفر؟ وهل يبقى الخصم الصفري صفراً؟

def test_two_customers_get_separate_carts(capsys):
    m = load("m01_mutable_default")
    assert m.add_item("x") == ["x"]


def test_zero_lines_in_a_row_are_removed(capsys):
    m = load("m03_modify_while_iterating")
    assert [l["sku"] for l in m.cart] == ["h1"]


def test_zero_discount_is_kept(capsys):
    load("m07_or_default")
    out = capsys.readouterr().out
    assert "{'discount': 0} -> 0" in out

شغلنا الاختبارات الثلاثة على النسخ الخاطئة ثم على النسخ المصلحة، باختيار النسخة من متغير البيئة MODE:

### wrong
/tmp/test_edges.py:32: assert "{'discount': 0} -> 0" in "{'discount': 10} -> 10
{'discount': 0} -> 5
{} -> 5
"
FAILED test_edges.py::test_two_customers_get_separate_carts - AssertionError:...
FAILED test_edges.py::test_zero_lines_in_a_row_are_removed - AssertionError: ...
FAILED test_edges.py::test_zero_discount_is_kept - assert "{'discount': 0} ->...
3 failed in 0.04s
### fixed
...                                                                      [100%]
3 passed in 0.02s

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

أيها يمسكه ruff؟ الفحص الآلي Linting مقابل الاختبار

شغلنا ruff على الملفات الخاطئة كلها بمجموعة قواعد أوسع من الإعداد الافتراضي:

ruff check --select E,F,B,A,RUF,PLW --output-format concise --no-cache m*.py circ_wrong rel | sort
Found 14 errors.
[*] 3 fixable with `--fix` (2 unsafe fixes available with `--fix --unsafe-fixes`).
circ_wrong/orders.py:1:21: F401 [*] `pricing.vat_halalas` imported but unused
m01_mutable_default.py:4:29: B006 Do not use mutable data structures for argument defaults
m02_late_binding.py:5:30: B023 Function definition does not bind loop variable `name`
m02_late_binding.py:5:44: B023 Function definition does not bind loop variable `name`
m03_modify_while_iterating.py:10:21: E741 Ambiguous variable name: `l`
m03_modify_while_iterating.py:3:89: E501 Line too long (107 > 88)
m05_bare_except.py:11:16: F821 Undefined name `catalgo`
m05_bare_except.py:5:16: F821 Undefined name `catalgo`
m05_bare_except.py:6:5: E722 Do not use bare `except`
m06_unbound_local.py:7:9: F823 Local variable `orders_today` referenced before assignment
m08_zip_truncates.py:6:16: B905 `zip()` without an explicit `strict=` parameter
m10_shadow_builtin.py:4:5: A001 Variable `list` is shadowing a Python builtin
m11_string_concat.py:1:1: E401 [*] Multiple imports on one line
m12_forgot_await.py:1:1: E401 [*] Multiple imports on one line

وهذا التشغيل كان على الملفات الأربعة عشر الأولى، فأمسك ruff ستة منها: القيمة الافتراضية B006، والـ Closure B023، والـ except بدون نوع E722 ومعه الخطأ الإملائي F821 الذي أخفاه، والمتغير المحلي F823، وzip بدون strict B905، والاسم المدمج A001. والملاحظات الأخرى في المخرج حقيقية ولكنها في أسلوب الكتابة، مثل اسم المتغير l الذي يشبه الرقم 1. وأما الثمانية الباقية من الأربعة عشر الأولى، أي الحذف أثناء المرور والصفوف المشتركة وor مع الصفر والمولد المستهلك ونسيان await والاستيراد الدائري والاستيراد النسبي ودمج النصوص، فلم يرها الفاحص في هذا الإعداد، والخطأ الخامس عشر لم تره هذه القواعد أيضاً وأمسكته مجموعة DTZ كما رأينا. والقواعد كلها موثقة في قائمة قواعد ruff، وبعضها في وضع المعاينة Preview قد يمسك المزيد. والدرس العملي: ابدأ مشروعك بقواعد B وA إلى جانب E وF، فهي التي أمسكت أغلب أخطاء القادمين من لغات أخرى، ثم اعرف أن نصف الأخطاء تقريباً لا يمسكه إلا اختبار يتحقق من القيمة، مثل اختبار بسطرين متتاليين كميتهما صفر، أو خصم صفر صريح.

من أي لغة يأتي كل خطأ؟ عادات Habits من لغتك الأولى

إذا كنت تراجع كود زميل، فمعرفة اللغة التي جاء منها تساعدك على توقع أخطائه قبل أن تقرأ الكود. فالمطور القادم من JavaScript يحمل معه ثلاث عادات تتحول إلى أخطاء في Python: الأولى أن القيمة الافتراضية للمعامل تحسب عند كل استدعاء، فيكتب cart=[] بثقة كاملة، والثانية أن let في الحلقة ينشئ متغيراً جديداً في كل دورة، فلا يتوقع مشكلة الـ Closures، والثالثة أن || للقيمة الافتراضية عادة يومية، فينقلها إلى or ومعها مشكلة الصفر. ويضاف إليها أن الدالة async في JavaScript تبدأ فور استدعائها، فنسيان await هناك يعني فقط أنك لا تنتظر النتيجة، أما في Python فيعني أن العمل لم يحدث أصلاً.

والمطور القادم من Java أو C# يأتي بعادات مختلفة: يكتب كلاساً لكل شيء مع دوال Getters وSetters بدل dataclass، ويستعمل range(len(items)) لأنه تعود على حلقة الفهرس، ويلتقط Exception في كل مكان لأن المترجم في Java كان يجبره على معالجة الاستثناءات المفحوصة Checked Exceptions، فيبحث عن مكان يضع فيه try. ويتوقع أيضاً أن يعترض المترجم على متغير عام يعدل داخل دالة أو على تشغيل ملف خارج حزمته، ولذلك يفاجئه أن الخطأ يظهر وقت التشغيل فقط. وأما الاستيراد الدائري فيقع فيه القادمون من Java بالذات، لأن الكلاسات هناك يمكن أن يشير بعضها إلى بعض بحرية، والمترجم يحل كل الأسماء قبل التشغيل، بينما Python تنفذ الملفات بالترتيب.

وفي المقابل، هناك أخطاء لن يقع فيها هؤلاء لأن لغاتهم علمتهم الحذر منها: مطور Java يعرف أن تعديل قائمة أثناء المرور عليها يرمي ConcurrentModificationException، فإذا فعلها في Python فهو ينتظر خطأ لن يأتي، ومطور C# يعرف الفرق بين DateTime بنوعيه المحلي والعالمي، فيفهم مشكلة المنطقة الزمنية بسرعة. والخلاصة العملية لقائد الفريق: اجعل قائمة التحقق في المراجعة مختلفة قليلاً بحسب خلفية كاتب الكود في الأسابيع الأولى، وركز مع القادم من JavaScript على التقييم والقيم الكاذبة وawait، ومع القادم من Java وC# على الاستثناءات الواسعة والاستيراد والأسلوب.

كيف تدخل فريقاً من لغات أخرى إلى Python؟ Team Onboarding

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

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "A", "DTZ", "RUF"]

[tool.mypy]
strict = true

[tool.pytest.ini_options]
filterwarnings = ["error::RuntimeWarning", "error::DeprecationWarning"]

والسطر الأخير يحول التحذيرات إلى أخطاء في الاختبارات، فنسيان await الذي لم يظهر إلا كتحذير في المختبر، واستعمال utcnow() المهملة، يصبحان فشلاً في CI بدل سطر في stderr لا يقرؤه أحد. ثم تشغل نفس الأدوات قبل كل Commit على جهاز المطور بأداة مثل pre-commit، لكي يرى الخطأ في ثوان وليس بعد عشر دقائق في CI، وفي CI مرة أخرى لكي لا يعتمد الفريق على أن كل واحد ثبت الأداة على جهازه.

والخطوة الثانية أن تعلم الفريق كتابة الاختبارات التي تمسك ما لا يمسكه الفاحص: الحالات الحدية Edge Cases، أي الصفر والقائمة الفارغة والعنصرين المتتاليين بنفس الشرط والقائمتين بطولين مختلفين، فكل خطأ صامت في هذا المقال كان سيظهر في اختبار واحد من هذا النوع. والخطوة الثالثة مراجعة الكود بقائمة تحقق قصيرة مثل التي في آخر المقال، يمر عليها المراجع في كل طلب دمج حتى تصبح عادة، ثم تقل الحاجة إليها مع الوقت. وأما ما يتعلمه المطور القادم من لغة أخرى بأسرع طريقة فهو أن يقرأ كوداً جيداً مكتوباً بـ Python، مثل كود المكتبة القياسية نفسها، فمصدر كل وحدة مرتبط في أعلى صفحة توثيقها، وقراءة pathlib أو dataclasses تعلمه الأسلوب أكثر من أي مقال.

ما يكتبه مطور Python بدل ذلك: عادات Idioms تستحق أن تتعلمها

الأخطاء نصف الصورة، والنصف الآخر عادات صغيرة تجعل الكود يقرأ كما يتوقع أي مطور Python، وقد رأيناها كلها في مقالات هذه المرحلة:

  • المرور على العناصر مباشرة for item in cart، ومع الرقم enumerate(cart, start=1)، وعلى قائمتين معاً zip(a, b, strict=True)، بدل range(len(cart)) ثم الفهرس.
  • التفكيك Unpacking في كل مكان: subtotal, vat, total = order_total(lines) وfirst, *rest = items.
  • الـ Comprehensions للتحويل والتصفية البسيطة، والحلقة العادية عندما يصبح الشرط أكثر من سطر.
  • with لكل مورد يجب أن يغلق: الملفات والاتصالات والأقفال والمعاملات، كما في async with conn.transaction() في خدمة الطلبات.
  • dataclass لكائنات البيانات بدل كلاس بدالة __init__ طويلة ودوال Getters وSetters كما في Java، ومعها slots=True عندما تكون كثيرة كما قسنا في مقال الذاكرة، و@property إذا احتجت حساباً عند القراءة.
  • أسلوب EAFP مع استثناءات محددة النوع، وraise ... from e لكي لا يضيع السبب.
  • pathlib.Path للمسارات بدل دمج النصوص، وf-strings للتنسيق.
  • is None للمقارنة مع None، و== لكل قيمة أخرى.

خطة القراءة Reading Plan من المصادر الرسمية

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

أربعة أعمدة لأربعة أسابيع: الأسبوع الأول اللغة مع The Python Tutorial من الفصل 3 إلى 9 وProgramming FAQ وPEP 8 وPEP 20 وGlossary، والثاني النموذج مع Language Reference وData model وExecution model وImport system وDesign and History FAQ، والثالث المكتبة مع Built-in Functions وTypes وcollections وitertools وfunctools وpathlib وdatetime وlogging وasyncio وconcurrent.futures، والرابع المشروع مع Python Packaging User Guide وtyping وTyping Specification وHOWTOs وWhat's New in Python 3.15
أربعة أسابيع، وكل المصادر من مواقع Python الرسمية
  1. الأسبوع الأول، اللغة: اقرأ The Python Tutorial من الفصل الثالث إلى التاسع، فهو مكتوب لمن يعرف البرمجة أصلاً، وتجاوز الفصلين الأولين. ثم Programming FAQ كاملة، ففيها ستة من أخطاء هذا المقال بشرح المطورين أنفسهم، ثم PEP 8 وPEP 20، وأبق المسرد Glossary مفتوحاً لكل مصطلح جديد.
  2. الأسبوع الثاني، النموذج: من Language Reference اقرأ فصل نموذج البيانات Data model الذي يشرح الكائنات والـ Dunder Methods، وفصل نموذج التنفيذ Execution model الذي يشرح النطاقات وسبب UnboundLocalError، ونظام الاستيراد، ثم Design and History FAQ لتعرف لماذا صممت اللغة هكذا، كالمسافة البادئة وغياب switch القديم.
  3. الأسبوع الثالث، المكتبة القياسية: من Library Reference ابدأ بالدوال المدمجة والأنواع المدمجة، ثم الوحدات التي سوف تستعملها كل يوم: collections وitertools وfunctools وpathlib وdatetime وlogging، ثم asyncio وconcurrent.futures مع ما قسناه في مقال التزامن.
  4. الأسبوع الرابع، المشروع: Python Packaging User Guide للحزم والتوزيع، وتوثيق الأنواع ومواصفاتها لما بعد الأساسيات في mypy، وHOWTOs وأهمها logging والترتيب Sorting والـ Descriptors، ثم ما الجديد في Python 3.15، واجعل قراءة صفحة «ما الجديد» عادة مع كل إصدار في أكتوبر.

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

قائمة تحقق Checklist لمراجعة كود Python

  • هل يوجد في سطر def أي قيمة افتراضية قابلة للتغيير، [] أو {} أو كائن؟
  • هل تنشأ دوال أو lambda داخل حلقة وتستعمل متغير الحلقة؟
  • هل تعدل قائمة أو قاموس أثناء المرور عليه؟
  • هل يوجد except: أو except Exception في غير الطبقة العليا؟
  • هل يوجد x or default حيث يمكن أن يكون الصفر أو النص الفارغ قيمة صحيحة؟
  • هل كل zip على قوائم يجب أن تتساوى معه strict=True؟
  • هل يستعمل Generator أكثر من مرة؟
  • هل كل استدعاء لدالة async معه await، والتحذيرات تصل إلى السجل؟
  • هل يوجد متغير باسم list أو id أو type أو sum؟
  • هل قواعد ruff تشمل B وA، ويعمل mypy في CI؟
  • هل الاختبارات تتحقق من القيم في الحالات الحدية: الصفر، والقائمة الفارغة، والعنصران المتتاليان؟
  • هل كل التواريخ في الكود تعرف منطقتها الزمنية، والتحويل إلى توقيت السعودية يحدث عند العرض فقط؟
  • هل التحذيرات RuntimeWarning وDeprecationWarning تتحول إلى فشل في الاختبارات بدل أن تضيع في stderr؟

تدريب عملي

  • شغل ruff بالقواعد E,F,B,A,DTZ على مشروع Python كتبته أنت، وعد كم خطأً من أخطاء هذا المقال وجد، ثم اسأل نفسك عن كل خطأ لم يجده: هل يوجد اختبار في المشروع كان سيمسكه؟
  • اكتب اختباراً لخدمة الطلبات يرسل سطرين متتاليين بكمية صفر، وآخر يرسل خصماً صفراً، ولاحظ هل يحتاج الكود إلى تعديل.
  • أضف إلى test_edges.py اختباراً للخطأ الثامن، أي zip بقائمتين بطولين مختلفين، وتأكد أنه يفشل على النسخة الخاطئة وينجح على المصلحة.
  • جرب ruff check --preview على ملفات المختبر، ولاحظ هل تمسك قواعد المعاينة أخطاء أخرى من الثمانية الباقية.
  • اقرأ import this في المفسر، واختر لكل خطأ من الخمسة عشر السطر الذي يخالفه.

الخلاصة

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

  • أغلب أخطاء القادمين إلى Python ليست أخطاء برمجة، وإنما افتراضات من لغة أخرى: متى تقيم القيمة الافتراضية، وماذا يحفظ الـ Closure، ومتى تبدأ دالة async.
  • ثمانية أخطاء من خمسة عشر أعطت قيمة خاطئة برمز خروج 0، منها سلة عميل فيها قهوة عميل آخر، وخصم صفر صار 5%، ومنتج اختفى من الطلب.
  • ruff بقواعد B وA أمسك ستة منها، والباقي لا يمسكه إلا اختبار يتحقق من القيم في الحالات الحدية.
  • الأسلوب الـ Pythonic مجموعة قواعد تعلمها المجتمع من أخطاء حقيقية، و«يجب ألا تمر الأخطاء بصمت» تفسر نصف هذا المقال.
  • كل خطأ تقريباً عادة صحيحة في لغة أخرى: القادم من JavaScript يحمل معه || وlet والقيم الافتراضية التي تحسب كل مرة، والقادم من Java وC# يحمل الاستثناءات الواسعة والحلقة بالفهرس.
  • اختبار واحد لكل حالة حدية، كالصفر والسطرين المتتاليين، أمسك الأخطاء الصامتة الثلاثة التي اختبرناها في أقل من عشر ثانية.
  • التوثيق الرسمي يكفي لتكمل: الدرس التعليمي والأسئلة الشائعة في الأسبوع الأول، ثم مرجع اللغة، ثم المكتبة، ثم التحزيم والأنواع.

وبهذا المقال تكتمل مرحلة لغة Python للمطورين، والمقالات الخمسة معاً: الأدوات، والأنواع والذاكرة، والتزامن، والخدمة الحقيقية، والأخطاء الشائعة:

دليلPython للمطورين: التركيب Syntax والأدوات في يوم واحددليل لمن يبرمج بلغة أخرى ويريد أن يبدأ بـ Python 3.15 من أول يوم: التثبيت، وما يختلف في التركيب، والأدوات uv وruff وmypy وpytest على مشروع حقيقي بمخرجه الفعلي.دليلPython من الداخل: الأنواع Types والذاكرة والاستثناءاتما هو المتغير فعلاً في Python، ومن يحرر الذاكرة، وكيف تعبر اللغة عن الخطأ، بقياسات حقيقية على Python 3.15 من حجم مليون سجل إلى Container قتلته النواة دون رسالة.دليلالتزامن Concurrency في Python: الخيوط و asyncio وقفل GILقسنا مئة طلب HTTP وأربعة ملايين عملية هاش بالخيوط وasyncio والعمليات والبناء الخالي من القفل في Python 3.15، ثم أعدنا تسابقاً باع عشرين قطعة من مخزون عشرة وجموداً بين قفلين وأصلحناهما.دليلخدمة حقيقية بـ Python: FastAPI مع PostgreSQL والاختبارات Testsبنينا خدمة طلبات لمتجر صغير بـ FastAPI وPostgreSQL 17، بضريبة محسوبة بالهللة وقفل للمخزون اختبرناه بعشرين طلباً متوازياً، ثم قسنا الـ Docker Image وزمن البدء والذاكرة وwrk.دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتشغيل الأخطاء الخمسة عشر بالطريقتين على Python 3.15.2 في الـ Image الرسمية python:3.15.2-slim-trixie، وفحصها بـ ruff 0.17.3.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.