المستوى: مبتدئ — لو لسه بتتعلم بايثون وبتكتب دوال، المقال ده هيوفّرلك ساعات من البحث عن باج غريب مش بيظهر إلا بعد تشغيل تاني.
لو دالة بتاعتك المفروض ترجّع قائمة فاضية في كل مرة، وطلعت مليانة بقيم من نداءات قديمة، ده مش عطل ولا اختراق. ده أشهر فخ في بايثون: الوسيط الافتراضي القابل للتغيير (Mutable Default Argument).
فخ الوسيط الافتراضي في بايثون: القائمة اللي مش بتنسى
المشكلة باختصار
بتكتب دالة بتاخد قائمة اختيارية، وبتحط لها قيمة افتراضية [] عشان لو الشخص ما بعتش حاجة تبدأ من قائمة فاضية. المنطق سليم في دماغك. لكن اللي بيحصل فعلاً إن القائمة دي بتتعمل مرة واحدة بس، ونفس القائمة بتترجع في كل نداء. النتيجة: القيم بتتراكم بدل ما تبدأ من الصفر.
خليها بمثال قبل الكلام العلمي
تخيّل إنك اشتغلت في مكتب، وأول يوم المدير قالك: "خُد الكراسة اللي على المكتب واكتب فيها المهام بتاعتك." أنت افترضت إنك هتلاقي كراسة جديدة فاضية كل يوم. لكن في الحقيقة، فيه كراسة واحدة بس على المكتب، وكل موظف بيكتب فيها فوق اللي قبله. تيجي بعد أسبوع تلاقي الكراسة مليانة كلام مش بتاعك.
ده بالظبط اللي بيعمله box=[]. الكراسة (القائمة) اتحطّت على المكتب مرة واحدة يوم ما الدالة اتعرّفت، ومن يومها الكل بيكتب في نفس الكراسة.
ليه بيحصل ده علميًا؟
ركز في النقطة دي، لأنها سبب المشكلة كلها: القيمة الافتراضية للوسيط بتتحسب مرة واحدة وقت تعريف الدالة (def)، مش في كل نداء. يعني [] اللي كتبتها بتتنفّذ ساعة ما بايثون بيقرأ سطر الـ def، وبتتخزّن جوّه الدالة نفسها في خاصية اسمها __defaults__.
القوائم في بايثون قابلة للتغيير (mutable)، فلما تعمل append أنت بتعدّل نفس الكائن المخزّن، مش بتعمل واحد جديد. عشان كده التعديلات بتفضل موجودة للنداء اللي بعده.
الكود اللي بيوريك المشكلة
# الطريقة الغلط
def add_item(item, box=[]):
box.append(item)
return box
print(add_item("a")) # ['a'] متوقّع
print(add_item("b")) # ['a', 'b'] مش متوقّع!
print(add_item("c")) # ['a', 'b', 'c'] الكارثة
عايز دليل ملموس إن دي نفس القائمة مش قائمة جديدة؟ اطبع عنوانها في الذاكرة بـ id():
print(id(add_item("x"))) # مثلاً: 140244665929344
print(id(add_item("y"))) # نفس الرقم بالظبط: 140244665929344
نفس الـ id في النداءين معناه حرفيًا نفس الكائن في الذاكرة. القياس هنا واضح: بعد 3 نداءات القائمة فيها 3 عناصر بدل عنصر واحد، ونسبة القيم الملوّثة بتزيد مع كل استدعاء.
الحل: استخدم None كإشارة
الحيلة القياسية إنك تحط القيمة الافتراضية None (وهي كائن غير قابل للتغيير وآمن)، وتعمل القائمة الجديدة جوّه الدالة، عشان تتنفّذ في كل نداء:
# الطريقة الصح
def add_item(item, box=None):
if box is None:
box = [] # قائمة جديدة فاضية في كل نداء
box.append(item)
return box
print(add_item("a")) # ['a']
print(add_item("b")) # ['b'] تمام
print(add_item("c")) # ['c'] تمام
لاحظ إننا استخدمنا is None مش if not box، لأن not box هيتعامل مع أي قائمة فاضية بعتها المستخدم بنفسه كأنها None ويستبدلها، وده سلوك غلط.
سيناريو واقعي: باج بيسرّب بيانات بين المستخدمين
افرض عندك API بيستقبل طلبات بحث، وكاتب الدالة كده:
def build_filters(new_filter, filters=None):
if filters is None:
filters = []
filters.append(new_filter)
return filters
لو الأصل كان مكتوب بـ filters فاضية افتراضية على مستوى الـ process، وعندك سيرفر بيخدم مثلاً 500 طلب في الدقيقة، فلاتر المستخدم الأول هتفضل موجودة في طلب المستخدم التاني والتالت. النتيجة مش بس نتائج غلط، ده تسريب فعلي لبيانات بحث بين ناس مختلفة. باج زي ده بيعدّي الاختبارات المحلية اللي بتشغّل نداء واحد، وبيظهر بس تحت الضغط في الإنتاج، وده بيخليه من أصعب الباجات في التتبّع.
الـ trade-off هنا
طريقة None بتكلّفك سطرين زيادة وif بسيطة في كل دالة. المكسب: سلوك متوقّع ومفيش حالة مشتركة مخبّية. الافتراض إن القيمة الافتراضية المفروض تكون فاضية في كل مرة، وده الحالة الغالبة مع القوائم والقواميس.
في المقابل، القيم غير القابلة للتغيير زي الأرقام والنصوص والـ tuple آمنة تمامًا كقيم افتراضية، لأنها أصلاً مش بتتعدّل. يعني قيمة افتراضية زي رقم صفر أو نص فاضي مفيهاش أي مشكلة.
متى لا تشغل بالك بهذا
لو القيمة الافتراضية غير قابلة للتغيير (int، str، bool، None، tuple)، مفيش داعي لأي احتياط. كمان لو أنت قاصد فعلاً تحتفظ بحالة بين النداءات (زي كاش بسيط أو memoization يدوي)، وقتها الوسيط الافتراضي المشترك ممكن يبقى حيلة مقصودة، بس اكتب كومنت واضح إنك عارف بتعمل إيه، عشان اللي بعدك ما يفتكرهاش باج.
الخطوة التالية
افتح أي ملف بايثون شغّال عندك، ودوّر جوّه تعريفات الدوال على أي قيمة افتراضية عبارة عن قائمة فاضية أو قاموس فاضي. أي واحدة تلاقيها، حوّلها لـ None بالطريقة اللي فوق. لو لقيت واحدة كانت شغّالة غلط بالصدفة وصلّحتها فبقت بتتصرف بشكل مختلف، ده معناه إنك لقيت باج كان مستني يضربك.
المصادر
- Python Official Tutorial — Default Argument Values: docs.python.org/3/tutorial/controlflow.html
- Python Programming FAQ — Why are default values shared between objects: docs.python.org/3/faq/programming.html
- The Hitchhiker's Guide to Python — Common Gotchas: docs.python-guide.org/writing/gotchas