অধ্যায় 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 পড়ে বুঝতে কী ঘটেছে
  • বলতে কেন একটি লিস্ট ফাংশনে পাঠালে সেটি বদলে যেতে পারে
  • পাইথনের সবচেয়ে বিখ্যাত ফাঁদটি চিনতে ও এড়াতে: পরিবর্তনীয় ডিফল্ট

আগে যা জানা লাগবে: ফাংশনের আর্গুমেন্ট।


ভেতরের নাম ভেতরেই

ফাংশনের ভেতরে তৈরি প্রতিটি নাম — প্যারামিটারসহ — কেবল সেই কলটুকুর জন্য বাঁচে। ফাংশন শেষ হলে নামগুলো মুছে যায়।

একেই বলে স্কোপ: একটি নাম কোন এলাকায় পরিচিত।

বাইরের নাম পড়া যায়

python
RATE = 0.15


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


print(add_tax(100))
text
115.0

ফাংশনের ভেতরে RATE নেই, তাই পাইথন বাইরে খোঁজে আর পেয়ে যায়। ভেতর থেকে বাইরের নাম পড়া চলে।

সব বড় হাতের অক্ষরের নামটি একটি রীতি, পাইথনের নিয়ম নয় — এটি পড়ার মানুষকে বলে «এটি একটি ধ্রুবক, বদলায় না»। এভাবে ব্যবহার করাই বাইরের নামের সবচেয়ে নিরাপদ ব্যবহার।

কিন্তু লেখা যায় না

python
count = 10


def bump():
    count = 99
    return count


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

ভেতরের count = 99 বাইরের count-কে ছোঁয়নি। সে একটি নতুন, স্থানীয় নাম তৈরি করেছে যা কেবল ফাংশনের ভেতরে বাঁচে, আর বাইরেরটিকে ঢেকে রেখেছে।

এটিই নিয়ম, আর ইচ্ছাকৃত: একটি ফাংশন দুর্ঘটনাক্রমে বাইরের কিছু বদলে দিতে পারে না।

আর এখান থেকেই একটি অদ্ভুত ত্রুটি আসে:

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 নয়: মানটি আর্গুমেন্ট হিসেবে নিন আর ফল return করুন।

text
def bump(count):
    return count + 1

count = bump(count)

global শব্দটি পাইথনে আছে, কিন্তু এই কোর্সে সেটি শেখানো হচ্ছে না, কারণ যে সমস্যার সমাধান হিসেবে মানুষ সেটি ব্যবহার করে সেটির ভালো সমাধান আছে। যে ফাংশন বাইরের অবস্থা বদলায়, তার আচরণ কল করার জায়গা দেখে বোঝা যায় না — আর সেটাই পরীক্ষা করা ও বিশ্বাস করা কঠিন কোডের সংজ্ঞা।

পাঠানো জিনিসটা কিন্তু বদলাতে পারে

স্কোপ নাম রক্ষা করে, জিনিস নয়। এই পার্থক্যটা সূক্ষ্ম এবং গুরুত্বপূর্ণ।

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 অপরিবর্তনীয়, তাই ভাগ করে নেওয়ায় কোনো ক্ষতি নেই, আর নতুন লিস্টটি প্রতিটি কলে নতুন করে তৈরি হয়।

নিয়ম হিসেবে: ডিফল্ট মান হিসেবে কখনো লিস্ট, ডিকশনারি বা সেট লিখবেন না। সংখ্যা, লেখা, 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 — একই ইনপুটে সবসময় একই আউটপুট, আর কোথাও কোনো পার্শ্বপ্রতিক্রিয়া নেই। পরীক্ষা করা সবচেয়ে সহজ, আর ভুল হওয়ার সুযোগও সবচেয়ে কম।

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 = []।

দুটি ফাংশনে একই নাম ব্যবহার করেছি, সমস্যা হচ্ছে কি? হচ্ছে না, আর সেটাই স্কোপের পুরো উদ্দেশ্য। দুটি ফাংশনের দুটি total সম্পূর্ণ আলাদা।