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

المشكلة ليست أن الفخ غامض، بل أن أعراضه تُشخَّص خطأً. المطور يرى أن الدالة “تتذكر” قيمة من استدعاء سابق، فيفترض أن هناك متغيرًا عامًا، أو مشكلة في الكاش، أو تداخلًا بين الطلبات. يقضي ساعات في تتبع متغيرات لا علاقة لها بالمشكلة، بينما السبب في سطر تعريف الدالة نفسه. هذا الدليل يفكك الفخ من الجذور: كيف يعمل، وأين يظهر، وكيف تكتشفه آليًا قبل أن يصل إلى الإنتاج.

نظرة سريعة: خريطة الفخ ومكوناته

الجدول التالي يلخص جوهر المشكلة في محاورها الأساسية، قبل الدخول في التفاصيل:

البعد السلوك في بايثون التوقع الخاطئ الشائع
وقت تقييم القيمة الافتراضية مرة واحدة عند تعريف الدالة عند كل استدعاء
مكان تخزين القيمة func.__defaults__ داخل نطاق الاستدعاء
هل تُنشأ نسخة جديدة؟ لا، نفس الكائن في كل استدعاء نعم، نسخة مستقلة
الأنواع المتأثرة list، dict، set، bytearray، أي كائن قابل للتغيير يُفترض أن أي قيمة افتراضية “آمنة”
أعراض الفشل تسرب حالة بين استدعاءات لا علاقة لها يُشخَّص كخطأ في المتغيرات العامة أو الكاش
قابلية الكشف الآلي عالية (flake8-bugbear B006، Ruff، pylint W0102) يعتمد كثيرون على المراجعة اليدوية فقط

الفخ في سطر واحد: المثال الكلاسيكي

مثال على فخ الوسائط الافتراضية القابلة للتغيير في بايثون

الكود التالي يبدو بريئًا تمامًا. الغرض منه إضافة عنصر إلى قائمة افتراضية وإرجاعها:

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

التوقع البديهي أن كل استدعاء يبدأ بقائمة فارغة جديدة. الواقع مختلف:

print(add_item("a"))   # ['a']
print(add_item("b"))   # ['a', 'b']
print(add_item("c"))   # ['a', 'b', 'c']

القائمة لم تُنشأ من جديد. إنها الكائن نفسه يُعاد استخدامه في كل استدعاء، وتتراكم العناصر فيه. الدالة “تتذكر” الحالة من الاستدعاء السابق، رغم أنه لا يوجد في الكود أي شيء يوحي بأنها متغيرة أو تحتفظ بحالة.

لماذا يحدث هذا؟ آلية التقييم عند التعريف

السر في أن بايثون تُقيّم القيم الافتراضية عند تعريف الدالة، لا عند استدعائها. هذا يختلف عن لغات أخرى، ويتطلب وعيًا خاصًا. عند تنفيذ السطر الذي يعرّف الدالة، تُنشأ القائمة [] مرة واحدة، وتُربط بمعامل bucket كقيمة افتراضية، وتُخزَّن في خاصية الدالة __defaults__.

يمكنك رؤية ذلك مباشرة:

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

print(add_item.__defaults__)  # ([],)
add_item("a")
print(add_item.__defaults__)  # (['a'],)
add_item("b")
print(add_item.__defaults__)  # (['a', 'b'],)

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

النقطة الجوهرية أن الفخ ليس “خطأً” في بايثون، بل نتيجة متسقة لقواعدها. اللغة تعمل كما صُممت. المشكلة أن التصميم نفسه ينتج عن سلوك غير متوقع عندما تكون القيمة قابلة للتغيير، وهذا ما يجعل الفخ خطيرًا: الكود ليس خاطئًا نحويًا، والسلوك ليس عشوائيًا، لكنه يخالف التوقع الذهني للمطور.

ما الذي يحوّله من مفاجأة إلى فخ خطير؟

لو كان الفخ يظهر دائمًا في مثال مثل المثال السابق، لكان اكتشافه سهلًا. لكن في الكود الحقيقي، يظهر بثلاث طرق تجعله خطيرًا:

1. لا يظهر إلا في الاستدعاء الثاني

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

2. يخلط الحالة بين مستخدمين أو طلبات

في تطبيق ويب، إذا كانت الدالة تُستدعى في سياق طلب من مستخدم A، ثم في طلب من مستخدم B، فقد يرى المستخدم B بيانات المستخدم A. هذا ليس “خطأً غريبًا” بل تسرب بيانات، وقد يكون له أثر قانوني وأمني، لا مجرد إزعاج.

3. الأعراض لا تشير إلى السبب

عندما تُشخَّص المشكلة، تُشخَّص في المكان الخطأ. المطور يبحث في المتغيرات العامة، وفي الكاش، وفي decorators، وفي سلوك الإطار المستخدم. بينما السبب في تعريف الدالة، وهو سطر يبدو سليمًا. الأدوات التي تجعل الأعراض “تبدو منطقية” (مثل decorators التي تغلف الدالة) تزيد صعوبة التشخيص.

أين يختبئ الفخ: القوائم والقواميس والكائنات المخصصة

القوائم

الشكل الأشهر، وقد رأيناه. الأعراض: تراكم عناصر بين استدعاءات، أو ظهور عناصر لم يُضفها الاستدعاء الحالي.

القواميس

المشكلة نفسها، لكن الأعراض مختلفة. قد تظهر مفاتيح لم تُضف في هذا الاستدعاء، أو تختفي قيم حُلَّت في استدعاء سابق:

def configure(options={}):
    options.setdefault("timeout", 30)
    return options

configure({"retries": 3})
print(configure())  # {'timeout': 30} — لكن من أين جاءت retries?
print(configure())  # القاموس نفسه يكبر مع كل استدعاء

المشكلة أن القاموس الافتراضي هو الكائن نفسه، وقد تغير في الاستدعاء الأول (أو استدعاءات سابقة)، فيظهر في الاستدعاء الذي لا يمرر قاموسًا.

المجموعات (sets)

أعراضها أخف، لأن المجموعة لا تحتفظ بالترتيب. قد تظهر عناصر لم تُضف في الاستدعاء الحالي، أو تختفي عناصر أضفتها، لأن هناك استدعاءًا آخر يعمل على المجموعة نفسها.

الكائنات المخصصة

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

class Worker:
    def __init__(self):
        self.jobs = []
        self.lock = threading.Lock()

def schedule(worker, job, queue=Worker().jobs):
    queue.append(job)
    return queue

هنا ليس فقط القائمة مشتركة، بل كائن Worker نفسه — بمؤقته وقفله — موجود كقيمة افتراضية. أي استدعاء يشارك الموارد نفسها، وهذا قد يسبب حالة سباق (race condition) أو قفلًا دائمًا، ليس مجرد بيانات متراكمة.

الفخ الشقيق: القيم المحسوبة وقت التعريف

المشكلة ليست في “القابلية للتغيير” فقط، بل في أن القيمة تُحسب مرة واحدة. هذا يعني أن القيم غير القابلة للتغيير قد تنتج أخطاءً أيضًا، لأنها “تتجمد” في اللحظة التي عُرّفت فيها الدالة:

from datetime import datetime
import time

def log_event(message, timestamp=datetime.now()):
    print(f"{timestamp}: {message}")

log_event("start")
time.sleep(2)
log_event("end")
# الطابع الزمني لن يتغير

الطابع الزمني يُحسب عند تعريف الدالة، لا عند استدعائها. الاستدعاء الثاني يطبع نفس الطابع الزمني. هذا فخ من النوع نفسه، لكنه لا علاقة له بـ“القابلية للتغيير”، بل بالتقييم المبكر.

لذلك، عند التفكير في الوسائط الافتراضية، القاعدة الأدق: أي قيمة افتراضية تُقيّم مرة واحدة ولا تتحدث مع الزمن — سواء كانت قائمة أو قاموسًا أو نتيجة استدعاء دالة — هي مصدر محتمل للأخطاء. التمرير بـ None وحساب القيمة داخل الدالة هو الحل الأكثر أمانًا.

لماذا صُمم بايثون بهذه الطريقة؟

من السهل أن ننظر إلى الفخ كخطأ في التصميم، لكن القرار له مبرر تقني. لو كانت بايثون تحسب القيمة الافتراضية في كل استدعاء، لكان كل استدعاء لدالة لها وسيط افتراضي يحمل تكلفة إضافية. في دالة تُستدعى ملايين المرات، هذا يتراكم.

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

كما أن التصميم يسمح بأنماط مفيدة: تخزين الكاش كقيمة افتراضية، وتثبيت سلوك الدالة لضمان الاتساق. هذه الأنماط تستخدم القيمة الافتراضية كـوسيلة لتحقيق الاستقرار، لا كوسيلة للتهيئة. المشكلة تنشأ عندما يستخدم المطور القيمة الافتراضية كوسيلة للتهيئة، وهي ليست كذلك.

أنماط حقيقية يظهر فيها الفخ في الكود الإنتاجي

1. دالة تجميع النتائج

def collect_errors(errors=[]):
    for e in validate():
        errors.append(e)
    return errors

الأعراض: أخطاء من طلب سابق تظهر في طلب جديد، وتتراكم بمرور الوقت. السبب في السطر الأول، الذي يبدو “قائمة فارغة”، لكنه قائمة مشتركة بين كل الاستدعاءات.

2. دالة بناء استعلام

def build_query(filters={}):
    filters["active"] = True
    return " AND ".join(f"{k}={v}" for k, v in filters.items())

الأعراض: استعلامات تحتوي على شروط لم يضفها الكود الذي استدعى الدالة. المشكلة أن القاموس الافتراضي يحتفظ بمفاتيح من استدعاءات سابقة.

3. Decorators

عندما يُغلِّف decorator دالة، ويحتفظ بمعاملاتها، فقد تنتقل القيمة الافتراضية عبر طبقات. النتيجة أن الأعراض تظهر في مكان لا علاقة له بالدالة الأصلية:

def cache_args(func):
    seen = []
    def wrapper(*args, **kwargs):
        seen.append((args, kwargs))
        return func(*args, **kwargs)
    return wrapper

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

4. طرق الفئات

إذا استُخدمت قيمة افتراضية قابلة للتغيير في طريقة داخل فئة، فالفخ يظهر أيضًا. والأخطر أن المطور قد يفترض أن self كافٍ لعزل الحالة، بينما القيمة الافتراضية مشتركة بين كل المثيلات:

class Report:
    def add(self, row, rows=[]):
        rows.append(row)
        return rows

التمرير إلى self لا يعزل شيئًا. القائمة مشتركة بين كل الاستدعاءات لكل المثيلات.

أنماط الإصلاح الصحيحة

1. النمط الأساسي: None ثم إنشاء

الحل الأشهر: استخدم None كقيمة افتراضية، ثم أنشئ الكائن داخل الدالة:

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

هذا يحل مشكلة القيمة المشتركة، لكنه يخلق مشكلة دلالية صغيرة: الآن None لا يمكن أن تكون قيمة صالحة للمعامل. إذا كان المعامل قد يقبل None كقيمة فعلية، فهذا النمط غير مناسب.

2. القيم غير القابلة للتغيير كقيمة افتراضية

إذا كان المعامل يقبل قائمة، يمكن استخدام tuple أو frozenset كقيمة افتراضية، ثم التحويل داخل الدالة:

def add_item(item, bucket=()):
    bucket = list(bucket)
    bucket.append(item)
    return bucket

هذا يحل الفخ، لكنه يُنشئ نسخة جديدة في كل استدعاء، وهذا مقبول في معظم الحالات. المشكلة أنه يفقد دلالة “قائمة فارغة” إذا كان النوع مهمًا.

نمط الحارس (Sentinel): الحل الأكثر تعبيرًا

عندما يكون None قيمة صالحة للمعامل، أو عندما تريد التمييز بدقة بين “لم يمرر المستخدم قيمة” و“مرر None”، استخدم كائنًا خاصًا كحارس:

_MISSING = object()

def add_item(item, bucket=_MISSING):
    if bucket is _MISSING:
        bucket = []
    bucket.append(item)
    return bucket

الكائن object() فريد ولا يساوي أي قيمة أخرى، لذلك يعمل كحارس لا لبس فيه. هذا النمط شائع في مكتبات بايثون الكبرى، خصوصًا في واجهات API حيث التمييز بين “قيمة افتراضية” و“قيمة فعلية” مهم.

الميزة الإضافية أن هذا النمط يوثق النية. عندما يقرأ مطور آخر الكود، يفهم فورًا أن الدالة تميز بين ثلاثة سياقات: لم تُمرر قيمة، مُرّرت قيمة، مُرّر None. هذا وضوح لا يوفره نمط None البسيط.

النمط الميزة القيد
None كقيمة افتراضية بسيط ومألوف لا يسمح بتمرير None كقيمة
قيمة غير قابلة للتغيير آمن من الفخ يفقد دلالة النوع الأصلي
Sentinel (object()) الأوضح دلاليًا، يميز بين ثلاث حالات يحتاج سطر تعريف إضافي
إعادة الهيكلة يحل المشكلة من الجذر قد يكون تغييرًا كبيرًا على واجهة API

متى يكون السلوك مقصودًا؟

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

def fib(n, cache={}):
    if n in cache:
        return cache[n]
    if n < 2:
        return n
    cache[n] = fib(n - 1) + fib(n - 2)
    return cache[n]

هنا القاموس المشترك هو الكاش نفسه. الهدف أن يحتفظ بين الاستدعاءات. لو أُنشئ من جديد في كل استدعاء، لاختفت فائدة الكاش. هذا نمط صحيح، لكنه يجب أن يكون صريحًا في النية، ويُفضَّل استخدام functools.lru_cache بدلًا منه في الكود الحديث.

حالات أخرى شائعة: تخزين مرجع لمورد مشترك (مثل اتصال بقاعدة بيانات) يفضل ألا يُعاد إنشاؤه في كل استدعاء. أو تثبيت سلوك دالة (مثل سجل تدقيق لا يتغير).

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

الكشف الآلي: flake8-bugbear وRuff وpylint وmypy

أفضل ما في هذا الفخ أنه قابل للكشف الآلي بدقة عالية. لا حاجة للاعتماد على المراجعة اليدوية.

flake8-bugbear

إضافة إلى flake8، تضيف تحذير B006: “Do not use mutable data structures for default arguments”. عندما تكتب def f(x=[])، تحصل على تحذير فوري، مع اقتراح الإصلاح.

# .flake8
[flake8]
extend-select = B006

Ruff

Ruff بديل حديث لـflake8، أسرع بكثير، ويطبق قاعدة B006 نفسها. إعداده بسيط:

# pyproject.toml
[tool.ruff.lint]
extend-select = ["B006"]

pylint

pylint يطلق تحذير W0102: “Dangerous default value as argument”. هو أقل تحديدًا من B006، لكنه يغطي حالات مشابهة.

mypy وtype hints

mypy لا يكشف هذا الفخ تلقائيًا، لأن المشكلة ليست في الأنواع بل في القيمة. لكن يمكن كتابة تعليقات نوعية تساعد في تجنب الأخطاء:

from typing import Optional

def add_item(item: str, bucket: Optional[list[str]] = None) -> list[str]:
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

الفائدة هنا أن Optional يوضح للقارئ أن None قيمة متوقعة، لكن التغيير الحقيقي هو النمط نفسه. النوعية وحدها لا تحمي.

السمات القابلة للتغيير على مستوى الفئة

المشكلة نفسها في الفئات:

class Counter:
    items = []
    def add(self, item):
        self.items.append(item)

items مشترك بين كل المثيلات، لأن السمات المعرفة على مستوى الفئة تُقيَّم مرة واحدة. الحل: تعريفها داخل __init__.

الـ closures المشتركة

الدالة الداخلية التي تحتفظ بحالة قابلة للتغيير من النطاق الخارجي، قد تنتج أثرًا مشابهًا. المشكلة ليست في الـ closure نفسه، بل في إعادة استخدامه بطريقة تنكر أن الحالة مشتركة.

القيم المحسوبة في تعريف الفئة

في decorators وواجهات API، قد يُخزَّن مرجع كائن مشترك كقيمة افتراضية لمعامل. المشكلة تظهر لاحقًا عندما يتغير الكائن في موضع، فيظهر التغيير في موضع آخر.

قائمة تحقق نهائية

  • هل لديك دالة بقيمة افتراضية [] أو أو set()؟ ← اعتبره فخًا حتى تثبت العكس
  • هل القيمة الافتراضية تُقيَّم مرة واحدة وتتغير مع الزمن (مثل datetime.now())؟ ← انقل الحساب إلى داخل الدالة
  • هل تستخدم None كقيمة افتراضية وتنشئ داخل الدالة؟ ← نمط آمن، لكنه يفقد القدرة على تمرير None
  • هل تحتاج التمييز بين “لم تُمرر” و“مُرّر None”؟ ← استخدم Sentinel
  • هل القيمة الافتراضية المشتركة مقصودة (كاش، مورد مشترك)؟ ← أضف تعليقًا يوضح النية
  • هل تشغّل flake8-bugbear أو Ruff في CI؟ ← أضف B006
  • هل تفحص سمات الفئة القابلة للتغيير؟ ← انقلها إلى __init__
  • هل تراجع الكود الذي يحتوي على decorators؟ ← قد يخفي الفخ بين طبقات
  • هل تعرف قيمة __defaults__ لكل دالة تستخدمها؟ ← افحصها عند الشك
  • هل توثق النية في كل حالة غير عادية؟ ← القارئ سيفترض الخطأ

فيما يلي روابط رسمية وأدوات يمكنك الرجوع إليها:

أسئلة شائعة

لماذا لا يعتبر هذا خطأً في بايثون؟

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

هل None آمن دائمًا؟

نعم كقيمة افتراضية، لأن None غير قابل للتغيير. المشكلة تظهر إذا كنت تحتاج تمرير None كقيمة فعلية. في هذه الحالة، استخدم Sentinel لتمييز الحالتين.

هل يمكن أن يحدث الفخ مع سلاسل نصية؟

لا، لأن السلاسل غير قابلة للتغيير. أي عملية “تعديل” على سلسلة تُنتج سلسلة جديدة، ولا تغير الكائن الأصلي. الفخ يقتصر على الأنواع القابلة للتغيير.

هل flake8-bugbear كافٍ لاكتشاف كل الحالات؟

يكشف الحالات الشائعة (list، dict، set)، لكنه لا يكشف الكائنات المخصصة القابلة للتغيير، ولا القيم المحسوبة وقت التعريف مثل datetime.now(). لهذه، تحتاج إلى مراجعة يدوية أو قواعد إضافية.

هل يمكن استخدام هذا النمط بشكل مقصود؟

نعم، مثل كاش داخلي. لكن يجب أن يكون ذلك صريحًا وموثقًا، وأن يُفضَّل functools.lru_cache في الكود الحديث. النية الصامتة هي ما يجعل الفخ خطيرًا.