نطاق المتغيرات والقيم الافتراضية القابلة للتعديل
أين يعيش الاسم ومن يمكنه رؤيته، الفرق بين قراءة اسم خارجي وتعديله، وخطأ UnboundLocalError، وأشهر فخاخ بايثون — القائمة الفارغة كقيمة افتراضية.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
تركت قائمة "حالات الخطأ الشائعة" في الفصل التاسع عشر سطراً واحداً دون إيضاح كامل: "المتغير الموجود داخل الدالة لا يمكن رؤيته من خارجها — وهذا أمر متوقع، وسيكون موضوع الفصل الحادي والعشرين."
هذا هو ذلك الفصل بالضبط.
def add_tax(price):
tax = price * 0.15
return price + tax
print(add_tax(100))
print(tax)115.0
NameError: name 'tax' is not defined. Did you mean: 'max'?السطر الأول اشتغل بنجاح، مما يعني أن المتغير tax كان موجوداً بالفعل. ولكن في الخارج، لم يعد له وجود.
هذا تدبير وقائي وليس عيباً أو قيداً. ففي برنامج يحتوي على خمسين دالة، إذا تسربت كل الأسماء والمتغيرات الداخلية إلى الخارج، لحدث تصادم بين متغيرات total في دالتين مختلفتين وتجاوزت إحداهما الأخرى دون أن ينتبه أحد. الأسماء داخل الدالة تبقى محصورة داخلها، لذا فإن كتابة دالة واحدة لا تتطلب معرفة أسماء المتغيرات في الدوال التسع والأربعين الأخرى.
يتناول هذا الفصل هذه القاعدة تحديداً، بالإضافة إلى إحدى نتائجها التي تشكّل أشهر فخ في لغة بايثون.
في نهاية هذا الدرس ستكون قادراً على
- تحديد أين "يعيش" الاسم البرمجي ومن يستطيع رؤيته
- قراءة اسم خارجي من داخل الدالة، وشرح سبب تعذر تعديله مباشرة
- قراءة وفهم خطأ
UnboundLocalErrorومعرفة سبب حدوثه - شرح سبب إمكانية تعديل محتويات قائمة (List) مُررت إلى دالة حتى خارجها
- التعرف على أشهر فخاخ بايثون وتجنبه: القيم الافتراضية القابلة للتعديل (Mutable Defaults)
المتطلبات السابقة: وسائط الدوال.
ما في الداخل يبقى في الداخل
كل اسم يُنشأ داخل دالة — بما في ذلك المعاملات (Parameters) — يعيش فقط أثناء تنفيذ ذلك الاستدعاء. وعندما تنتهي الدالة، تختفي هذه الأسماء تماماً.
يُطلق على هذا المفهوم اسم نطاق الرؤية (Scope): وهو النطاق أو المنطقة التي يكون فيها الاسم معروفاً ومتاحاً.
قراءة الاسم الخارجي تعمل بنجاح
RATE = 0.15
def add_tax(price):
return price + price * RATE
print(add_tax(100))115.0لا يوجد متغير باسم RATE داخل الدالة، لذلك يبحث بايثون في الخارج ويجده هناك. قراءة اسم خارجي من داخل الدالة أمر مسموح ويعمل بسلاسة.
استخدام الأحرف الكبيرة في تسمية المتغيرات هو عرف اصطلاحي متبع وليس قاعدة إجبارية في بايثون — فهو يخبر القارئ: "هذا ثابِت (Constant)، وقيمته لا تتغير". وعند استخدامه بهذه الطريقة، يكون الاسم الخارجي في أكثر حالاته أماناً.
ولكن الكتابة والتعديل لا تعملان
count = 10
def bump():
count = 99
return count
print(bump())
print(count)99
10السطر الداخلي count = 99 لم يمس المتغير الخارجي count إطلاقاً. بل قام بإنشاء اسم محلي جديد (Local) يعيش داخل الدالة فقط وحجب الاسم الخارجي طوال فترة تنفيذها.
هذه هي القاعدة، وهي مقصودة تماماً: لا يمكن للدالة أن تغيّر شيئاً في الخارج عن طريق الخطأ أو المصادفة.
ومن هذه القاعدة يظهر خطأ قد يبدو غريباً للوهلة الأولى:
count = 10
def bump():
count = count + 1
return count
print(bump())UnboundLocalError: cannot access local variable 'count' where it is not associated with a valueيبدو السطر وكأنه يأخذ المتغير الخارجي count ويضيف إليه واحداً. ولكن بايثون يفحص الدالة بأكملها قبل تشغيلها؛ وحين يرى أن هناك عملية إسناد إلى count، يقرر أن count متغير محلي خاص بهذه الدالة من البداية وحتى النهاية.
ثم، عند تقييم count + 1 في الطرف الأيمن، يبحث عن المتغير المحلي count الذي لم تُسند إليه أي قيمة بعد — فيتوقف فوراً ملقياً هذا الخطأ.
لذلك، رسالة الخطأ دقيقة للغاية: الاسم محلي، ولم تقترن به أي قيمة حتى الآن.
الحل في هذه الحالة شبه دائم وثابت، وليس باستخدام كلمة global: مرر القيمة كمعامل (Argument) وأرجع النتيجة عبر return.
def bump(count):
return count + 1
count = bump(count)تمتلك بايثون بالفعل الكلمة المفتاحية global، لكن هذه الدورة التدريبية لا تعلّمها عمداً، لأن المشكلة التي يلجأ إليها الناس لحلها تمتلك بديلاً أفضل بكثير. الدالة التي تعدل حالة عامة في الخارج لا يمكن فهم سلوكها بسهولة من مكان استدعائها — وهذا هو عين الكود الذي يصعب اختباره ويصعب الوثوق به.
لكن الكائن الذي مررته قد يتغير محتواه
يحمي نطاق الرؤية (Scope) الأسماء (Names)، ولا يحمي الكائنات (Objects) نفسها. هذا الفرق دقيق وفي غاية الأهمية.
def add_item(items):
items.append("pen")
return items
basket = []
add_item(basket)
add_item(basket)
print(basket)['pen', 'pen']تغيرت القائمة الخارجية basket، على الرغم من أننا لم نحتفظ بالقيمة المعادة عبر return.
والسبب يعود لما تعلمناه في الفصل الثاني عشر: الاسم items اسم جديد، لكنه يشير إلى القائمة نفسها في الذاكرة. التابع append يعدل تلك القائمة ذاتها، وبالتالي يظهر التغيير عبر كلا الاسمين.
قارن ذلك بما يلي:
def replace(items):
items = ["new"]
return items
basket = ["old"]
result = replace(basket)
print(basket)
print(result)['old']
['new']هنا قامت العبارة items = [...] بإسناد قيمة جديدة، موجهة الاسم المحلي ليشير إلى قائمة مختلفة تماماً. وظلت القائمة الخارجية basket كما كانت في مكانها دون أي تغيير.
القاعدة في سطر واحد: تعديل محتوى الكائن (append أو sort أو [i] =) يظهر في الخارج؛ بينما توجيه الاسم إلى كائن آخر (=) يقتصر تأثيره على النطاق المحلي ولا يظهر في الخارج.
أشهر فخ في لغة بايثون
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))
print(add_item("ink"))['pen']
['pen', 'bag']
['pen', 'bag', 'ink']ثلاث استدعاءات منفصلة، ومع ذلك احتفظ كل استدعاء بما أضافته الاستدعاءات السابقة!
افهم السبب ولن تنساه أبداً: يتم تقييم القيمة الافتراضية مرة واحدة فقط عند قراءة سطر تعريف الدالة def وتنفيذه — وليس عند كل استدعاء للدالة. تلك القائمة الفارغة هي قائمة وحيدة ارتبطت بالدالة، ويشترك فيها كل استدعاء لا يمرر قيمته الخاصة.
الحل الاصطلاحي القياسي بسيط جداً:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))['pen']
['bag']القيمة None غير قابلة للتعديل (Immutable)، لذا فإن مشاركتها لا تسبب أي ضرر، ويتم إنشاء قائمة جديدة نظيفة في كل استدعاء.
كقاعدة عامة: إياك أن تضع قائمة أو قاموساً أو مجموعة (Set) كقيمة افتراضية لمعامل. الأرقام والنصوص و True/False و None آمنة تماماً، وذلك تحديداً لأنها غير قابلة للتعديل.
لماذا نستخدمis Noneبدلاً من== None؟ المعاملisيسأل: "هل هذا هو الكائن ذاته في الذاكرة؟"، وهناك كائن وحيد منNoneفي البرنامج بأكمله. أما==فيختبر التساوي في القيمة، وبعض الأنواع قد تعرّف التساوي بطرق غير متوقعة. لذلك معNone، يُعتبرisهو الخيار القياسي والموثوق.
مثال متكامل
ملف basket.py:
# 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")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 في دالتين مختلفتين منفصلان تماماً عن بعضهما.
Step 4 of 6 — Predict
Check your understanding
A value is assigned to count inside the function. What do the two lines print?
count = 10
def bump():
count = 99
return count
print(bump())
print(count)- A99 10
- B99 99
- C10 10
- DAn `UnboundLocalError`
There is no return, yet the outer list is printed. What appears?
def add_item(items):
items.append("pen")
basket = []
add_item(basket)
add_item(basket)
print(basket)- A['pen', 'pen']
- B[]
- C['pen']
- DNone
Two separate calls, with an empty list as the default. What is printed?
def collect(item, box=[]):
box.append(item)
return box
print(collect("pen"))
print(collect("bag"))- A['pen'] ['pen', 'bag']
- B['pen'] ['bag']
- C['pen', 'bag'] ['pen', 'bag']
- D['pen'] ['pen']
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
أنشئ ملفاً باسم cart.py يحتوي على ثلاث دوال:
line_total(price, quantity, tax_rate=0.15)— دالة نقية، لا تمس أي شيء بالخارجadd_to(cart, name, price, quantity)— تضيف عنصراً إلى سلة المشتريات التي تُمرر إليهاreceipt(cart, header=None)— ترجع قائمة من الأسطر، وتنشئ عنواناً افتراضياً إذا لم يُمرر عنوان
ثم أنشئ سلة مشتريات فارغة، وأضف إليها بعض العناصر، واطبع الإيصال.
أخيراً، أجرِ ثلاث تجارب عملية يجدر بك رؤيتها بعينيك:
- غيّر القيمة الافتراضية في
receiptلتصبحheader=[]، ثم استدعِ الدالة مرتين واطبع النتيجتين. ماذا تلاحظ في المرة الثانية؟ - أضف
cart = cart.copy()كأول سطر في الدالةadd_to، ثم اطبع سلة المشتريات الخارجية. كم عنصراً بداخلها، ولماذا؟ - أسنِد قيمة للمتغير
TAX_RATE = 0.2داخل إحدى الدوال، ثم اطبعTAX_RATEخارجها. ما هي القيمة التي تظهر لك؟
الإجابات عن هذه التجارب الثلاث تختصر الفصل بأكمله: أي الأسماء تعيش وأين، وما هي التعديلات التي تمتد لتصل إلى الخارج.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz