الفصل 21

نطاق المتغيرات والقيم الافتراضية القابلة للتعديل

أين يعيش الاسم ومن يمكنه رؤيته، الفرق بين قراءة اسم خارجي وتعديله، وخطأ UnboundLocalError، وأشهر فخاخ بايثون — القائمة الفارغة كقيمة افتراضية.

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

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

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

هذا هو ذلك الفصل بالضبط.

python
def add_tax(price):
    tax = price * 0.15
    return price + tax


print(add_tax(100))
print(tax)
text
115.0
NameError: name 'tax' is not defined. Did you mean: 'max'?

السطر الأول اشتغل بنجاح، مما يعني أن المتغير tax كان موجوداً بالفعل. ولكن في الخارج، لم يعد له وجود.

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

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

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

  • تحديد أين "يعيش" الاسم البرمجي ومن يستطيع رؤيته
  • قراءة اسم خارجي من داخل الدالة، وشرح سبب تعذر تعديله مباشرة
  • قراءة وفهم خطأ UnboundLocalError ومعرفة سبب حدوثه
  • شرح سبب إمكانية تعديل محتويات قائمة (List) مُررت إلى دالة حتى خارجها
  • التعرف على أشهر فخاخ بايثون وتجنبه: القيم الافتراضية القابلة للتعديل (Mutable Defaults)

المتطلبات السابقة: وسائط الدوال.


ما في الداخل يبقى في الداخل

كل اسم يُنشأ داخل دالة — بما في ذلك المعاملات (Parameters) — يعيش فقط أثناء تنفيذ ذلك الاستدعاء. وعندما تنتهي الدالة، تختفي هذه الأسماء تماماً.

يُطلق على هذا المفهوم اسم نطاق الرؤية (Scope): وهو النطاق أو المنطقة التي يكون فيها الاسم معروفاً ومتاحاً.

قراءة الاسم الخارجي تعمل بنجاح

python
RATE = 0.15


def add_tax(price):
    return price + price * RATE


print(add_tax(100))
text
115.0

لا يوجد متغير باسم RATE داخل الدالة، لذلك يبحث بايثون في الخارج ويجده هناك. قراءة اسم خارجي من داخل الدالة أمر مسموح ويعمل بسلاسة.

استخدام الأحرف الكبيرة في تسمية المتغيرات هو عرف اصطلاحي متبع وليس قاعدة إجبارية في بايثون — فهو يخبر القارئ: "هذا ثابِت (Constant)، وقيمته لا تتغير". وعند استخدامه بهذه الطريقة، يكون الاسم الخارجي في أكثر حالاته أماناً.

ولكن الكتابة والتعديل لا تعملان

python
count = 10


def bump():
    count = 99
    return count


print(bump())
print(count)
text
99
10

السطر الداخلي count = 99 لم يمس المتغير الخارجي count إطلاقاً. بل قام بإنشاء اسم محلي جديد (Local) يعيش داخل الدالة فقط وحجب الاسم الخارجي طوال فترة تنفيذها.

هذه هي القاعدة، وهي مقصودة تماماً: لا يمكن للدالة أن تغيّر شيئاً في الخارج عن طريق الخطأ أو المصادفة.

ومن هذه القاعدة يظهر خطأ قد يبدو غريباً للوهلة الأولى:

python
count = 10


def bump():
    count = count + 1
    return count


print(bump())
text
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

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

ثم، عند تقييم count + 1 في الطرف الأيمن، يبحث عن المتغير المحلي count الذي لم تُسند إليه أي قيمة بعد — فيتوقف فوراً ملقياً هذا الخطأ.

لذلك، رسالة الخطأ دقيقة للغاية: الاسم محلي، ولم تقترن به أي قيمة حتى الآن.

الحل في هذه الحالة شبه دائم وثابت، وليس باستخدام كلمة global: مرر القيمة كمعامل (Argument) وأرجع النتيجة عبر return.

text
def bump(count):
    return count + 1

count = bump(count)

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

لكن الكائن الذي مررته قد يتغير محتواه

يحمي نطاق الرؤية (Scope) الأسماء (Names)، ولا يحمي الكائنات (Objects) نفسها. هذا الفرق دقيق وفي غاية الأهمية.

python
def add_item(items):
    items.append("pen")
    return items


basket = []
add_item(basket)
add_item(basket)

print(basket)
text
['pen', 'pen']

تغيرت القائمة الخارجية basket، على الرغم من أننا لم نحتفظ بالقيمة المعادة عبر return.

والسبب يعود لما تعلمناه في الفصل الثاني عشر: الاسم items اسم جديد، لكنه يشير إلى القائمة نفسها في الذاكرة. التابع append يعدل تلك القائمة ذاتها، وبالتالي يظهر التغيير عبر كلا الاسمين.

قارن ذلك بما يلي:

python
def replace(items):
    items = ["new"]
    return items


basket = ["old"]
result = replace(basket)

print(basket)
print(result)
text
['old']
['new']

هنا قامت العبارة items = [...] بإسناد قيمة جديدة، موجهة الاسم المحلي ليشير إلى قائمة مختلفة تماماً. وظلت القائمة الخارجية basket كما كانت في مكانها دون أي تغيير.

القاعدة في سطر واحد: تعديل محتوى الكائن (append أو sort أو [i] =) يظهر في الخارج؛ بينما توجيه الاسم إلى كائن آخر (=) يقتصر تأثيره على النطاق المحلي ولا يظهر في الخارج.

أشهر فخ في لغة بايثون

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


print(add_item("pen"))
print(add_item("bag"))
print(add_item("ink"))
text
['pen']
['pen', 'bag']
['pen', 'bag', 'ink']

ثلاث استدعاءات منفصلة، ومع ذلك احتفظ كل استدعاء بما أضافته الاستدعاءات السابقة!

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

الحل الاصطلاحي القياسي بسيط جداً:

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


print(add_item("pen"))
print(add_item("bag"))
text
['pen']
['bag']

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

كقاعدة عامة: إياك أن تضع قائمة أو قاموساً أو مجموعة (Set) كقيمة افتراضية لمعامل. الأرقام والنصوص و True/False و None آمنة تماماً، وذلك تحديداً لأنها غير قابلة للتعديل.

لماذا نستخدم is None بدلاً من == None؟ المعامل is يسأل: "هل هذا هو الكائن ذاته في الذاكرة؟"، وهناك كائن وحيد من None في البرنامج بأكمله. أما == فيختبر التساوي في القيمة، وبعض الأنواع قد تعرّف التساوي بطرق غير متوقعة. لذلك مع None، يُعتبر is هو الخيار القياسي والموثوق.

مثال متكامل

ملف basket.py:

python
# Where a name lives decides who can see it and who can change it
TAX_RATE = 0.15


def line_total(price, quantity):
    """Pure: takes values, returns a value, touches nothing outside."""
    return round(price * quantity * (1 + TAX_RATE), 2)


def add_line(basket, name, price, quantity):
    """Impure on purpose: it changes the basket it was handed."""
    basket.append({"name": name, "total": line_total(price, quantity)})
    return basket


def summarise(basket, note=None):
    """`None` as the default, so no list or dict is shared between calls."""
    notes = [] if note is None else [note]
    total = sum(line["total"] for line in basket)
    notes.append(f"{len(basket)} lines, {total:.2f} in total")
    return notes


shopping = []
add_line(shopping, "pen", 15.0, 3)
add_line(shopping, "bag", 850.0, 1)

for line in shopping:
    print(f"{line['name']:<6} {line['total']:>8.2f}")

print()
for note in summarise(shopping, "morning order"):
    print(note)

print()
print("basket outside the function:", len(shopping), "lines")
text
pen       51.75
bag      977.50

morning order
2 lines, 1029.25 in total

basket outside the function: 2 lines

ثلاث دوال بثلاث علاقات مختلفة مع العالم الخارجي، وهذا هو جوهر ما يجب الانتباه إليه.

line_total لا تمس أي شيء خارجي. تأخذ قيماً وترجع قيمة. تقرأ TAX_RATE ولا تعدله أبداً. دالة كهذه تسمى دالة نقية (Pure function) — المدخلات ذاتها تعطي دائماً نفس المخرجات، دون أي آثار جانبية (Side effects) في أي مكان. إنها أسهل أنواع الدوال في الاختبار وأصعبها وقوعاً في الأخطاء.

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

summarise تستخدم None كقيمة افتراضية. لو كُتبت note=[]، لاشتركت كل الاستدعاءات في قائمة واحدة، ولحمل التقرير الثاني ملاحظة التقرير الأول.


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

NameError: name 'tax' is not defined تم استخدام اسم من داخل الدالة في النطاق الخارجي. استخدم return لإرجاع ما تحتاجه في الخارج.

UnboundLocalError: cannot access local variable ... where it is not associated with a value تمت محاولة إسناد قيمة للمتغير داخل الدالة وقراءته قبل ذلك الإسناد. اعتبر بايثون المتغير محلياً. خذ القيمة كمعامل وأرجع النتيجة عبر return.

تغيرت قائمتي بعد تمريرها إلى دالة هذا هو السلوك الطبيعي المتوقع — الاسم جديد، لكن القائمة هي نفسها. لتجنب ذلك، اكتب items = items.copy() في بداية الدالة، أو ابنِ قائمة جديدة وأرجعها عبر return.

تتذكر الدالة نتائج استدعاءاتها السابقة قيمة افتراضية قابلة للتعديل. لا تكتب def f(x, acc=[]) بل اكتب def f(x, acc=None)، وضع بالداخل if acc is None: acc = [].

استخدمت نفس الاسم في دالتين مختلفتين — هل يمثل هذا مشكلة؟ لا يمثل أي مشكلة على الإطلاق، وهذا هو الهدف الأساسي من نطاق الرؤية (Scope). فالمتغيران المسميان total في دالتين مختلفتين منفصلان تماماً عن بعضهما.