পাঠ ৪০ · ৫৮-এর মধ্যে · মডিউল ৮

ক্লোজার ও লেক্সিক্যাল এনভায়রনমেন্ট

Closures & lexical environments
৯ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • ক্লোজারের নির্ভুল সংজ্ঞা এবং এটি কীভাবে L39-এর লাইফটাইম-বনাম-স্কোপ পার্থক্য সমাধান করে
  • লেক্সিক্যাল এনভায়রনমেন্ট — ক্লোজারের underlying implementation mechanism
  • Python-এ nonlocal দিয়ে একটি genuine mutable ক্লোজার বানানো
  • দুটো স্বাধীন ক্লোজার ইনস্ট্যান্স সত্যিকারের আলাদা state রাখে তা হাতে-কলমে যাচাই করা

১ · ক্লোজার কী

ক্লোজারClosureএকটি ফাংশন ভ্যালু, যেটি তৈরি হওয়ার সময়কার তার এনক্লোজিং লেক্সিক্যাল স্কোপের ভ্যারিয়েবলের রেফারেন্সসহ বান্ডল হয়ে থাকে — সেই ফাংশন যেখানেই কল হোক না কেন, ক্যাপচার করা ভ্যারিয়েবলগুলো accessible থাকে। হলো একটি ফাংশন ভ্যালু, যেটি তার এনক্লোজিং লেক্সিক্যাল স্কোপ-এর ভ্যারিয়েবলের রেফারেন্সসহ, তৈরি হওয়ার মুহূর্তেই বান্ডল হয়ে যায়। এটি L39-এর স্কোপ-বনাম-লাইফটাইম পার্থক্যের সরাসরি পরিণতি ও সমাধান — একটি ক্লোজার হলো ঠিক সেই মেকানিজম যার মাধ্যমে একটি ভ্যারিয়েবলের লাইফটাইম তার মূলত-প্রত্যাশিত স্ট্যাক-বেসড স্কোপের চেয়েও দীর্ঘ হতে পারে — কারণ রিটার্ন করা ক্লোজারটি সেই ভ্যারিয়েবলগুলোকে "জীবিত" রাখে (L39-এর স্বাভাবিক স্ট্যাক-বেসড ডিঅ্যালোকেশন আটকে দিয়ে), যতক্ষণ ক্লোজারটি নিজে বেঁচে থাকে — এমনকি যে ফাংশনটি সেই ভ্যারিয়েবলগুলো মূলত ডিক্লেয়ার করেছিল, সেটি ইতিমধ্যে রিটার্ন করে গেলেও।

২ · worked example — make_counter()

সবচেয়ে ক্লাসিক উদাহরণ (JavaScript callback ও Python decorator-এ যা প্রতিনিয়ত অজান্তেই ব্যবহৃত হয়) — একটি ফাংশন make_counter() যা একটি লোকাল count = 0 ডিক্লেয়ার করে এবং একটি ইনার ফাংশন রিটার্ন করে যা প্রতিবার কল হলে count বাড়িয়ে রিটার্ন করে। make_counter() নিজে ইতিমধ্যে রিটার্ন করে গেছে (তার normal স্ট্যাক ফ্রেম অনেক আগেই চলে গেছে), তবুও রিটার্ন করা ইনার ফাংশনটি প্রতিটি পরবর্তী কলে সেই একই count ভ্যারিয়েবল সঠিকভাবে অ্যাক্সেস ও mutate করতে থাকে — কারণ ক্লোজারটি সেটিকে জীবিত রেখেছে।

৩ · লেক্সিক্যাল এনভায়রনমেন্ট

লেক্সিক্যাল এনভায়রনমেন্টLexical Environmentভ্যারিয়েবল-বাইন্ডিং ফ্রেমের একটি চেইন যা একটি ক্লোজার তার সাথে বহন করে — একটি ফ্রি ভ্যারিয়েবল লুকআপ হলে ভেতর থেকে বাইরে দিকে চেক করা হয়। হলো underlying implementation মেকানিজম — ভ্যারিয়েবল-বাইন্ডিং ফ্রেমের একটি চেইন যা একটি ক্লোজার তার সাথে বহন করে, ভেতরের-স্কোপ থেকে বাইরের-স্কোপ দিকে চেক করে যখন ক্লোজারের কোড কোনো ফ্রি (নন-লোকাল, নন-গ্লোবাল) ভ্যারিয়েবল লুকআপ করে। এটি সরাসরি L38-এর স্ট্যাটিক-স্কোপ লেক্সিক্যাল-চেইন-লুকআপ পদ্ধতির স্ট্রাকচারাল পুনঃব্যবহার — পার্থক্য শুধু এটুকু: এখন চেইনটি ক্লোজার ভ্যালুটি নিজেই ক্যাপচার করে ও বহন করে, শুধু (transient) কল স্ট্যাকে টিকে থাকার বদলে।

৪ · কোড: একটি genuine mutable ক্লোজার

নিচের কোড সেলে আমরা nonlocal কীওয়ার্ড ব্যবহার করে একটি real make_counter() ক্লোজার বানাব — nonlocal ব্যবহার করাটা জরুরি, কারণ এটি নিশ্চিত করে ইনার ফাংশনটি count-এর একটি নতুন লোকাল কপি তৈরি করছে না, বরং outer scope-এর সেই একই count-কে সত্যিকারভাবে mutate করছে — একটি genuine mutable closure state। এরপর দুটো আলাদা make_counter() কল থেকে দুটো স্বাধীন কাউন্টার তৈরি করে দেখাব তারা সত্যিই আলাদা state রাখে।

Python
def make_counter():
    count = 0  # -- outer scope-এর ভ্যারিয়েবল, ইনার ফাংশন এটিকে ক্যাপচার করবে

    def increment():
        nonlocal count  # -- নতুন লোকাল count নয়, বাইরের count-কেই mutate করছি
        count += 1
        return count

    return increment  # -- make_counter() রিটার্ন করে গেলেও, count জীবিত থাকবে


# --- প্রথম কাউন্টার ---
counter_a = make_counter()
print("counter_a:", counter_a())  # 1
print("counter_a:", counter_a())  # 2
print("counter_a:", counter_a())  # 3

print()

# --- সম্পূর্ণ স্বাধীন দ্বিতীয় কাউন্টার (আলাদা make_counter() কল -> আলাদা captured environment) ---
counter_b = make_counter()
print("counter_b:", counter_b())  # 1  -- counter_a থেকে সম্পূর্ণ স্বাধীন, আবার 1 থেকে শুরু
print("counter_b:", counter_b())  # 2

print()

# counter_a-কে আবার কল করলে এখনো তার নিজের count-এই এগিয়ে চলে, counter_b প্রভাবিত হয় না
print("counter_a আবার:", counter_a())  # 4

# --- হাতে-কলমে যাচাই ---
assert counter_a() == 5, "counter_a-এর নিজস্ব state বজায় থাকার কথা"
assert counter_b() == 3, "counter_b-এর state counter_a থেকে সম্পূর্ণ স্বাধীন থাকার কথা"
print("\nযাচাই সফল -- counter_a ও counter_b সত্যিকারের আলাদা, স্বাধীন captured state বহন করছে।")

    
লক্ষ্য করুন make_counter()-কে দুইবার কল করলে count-এর দুইটি সম্পূর্ণ আলাদা বাইন্ডিং তৈরি হয় — একটি counter_a-এর ক্যাপচার করা এনভায়রনমেন্টে, আরেকটি counter_b-এর নিজের এনভায়রনমেন্টে। increment ফাংশনের কোড দুইবারই হুবহু একই, কিন্তু প্রতিটি কল তার নিজস্ব fresh লেক্সিক্যাল এনভায়রনমেন্ট ক্যাপচার করে — এটিই নিশ্চিত করে counter_a() কল করলে counter_b-এর ভেতরের অবস্থা একদমই প্রভাবিত হয় না।
মূল কথা · Key takeaway

একটি ক্লোজার একটি ফাংশন-কোড ও তার তৈরি হওয়ার সময়কার লেক্সিক্যাল এনভায়রনমেন্টের বান্ডল — এটি L39-এর স্কোপ-বনাম-লাইফটাইম পার্থক্যকে বাস্তবে কাজে লাগানোর প্রধান মেকানিজম, একটি ভ্যারিয়েবলের লাইফটাইমকে তার মূল স্ট্যাক-বেসড স্কোপের বাইরেও প্রসারিত করে দেয়। প্রতিটি ক্লোজার-তৈরিকারী কল তার নিজস্ব স্বাধীন এনভায়রনমেন্ট ক্যাপচার করে — এই কারণেই দুটো কাউন্টার একে অপরকে প্রভাবিত করে না, এবং এই একই মেকানিজম M9-এর পরের পাঠগুলোতে (কন্ট্রোল ফ্লো, কোরুটিন) আবার দেখা যাবে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ উপরের কোডে nonlocal count লাইনটি বাদ দিলে কী হতো?

nonlocal ছাড়া, increment-এর ভেতরে count += 1 লেখাটি Python-কে বাধ্য করত count-কে একটি নতুন লোকাল ভ্যারিয়েবল হিসেবে ট্রিট করতে (কারণ assignment থাকলে Python ডিফল্টভাবে লোকাল বাইন্ডিং তৈরি করে) — এবং যেহেতু count += 1 আসলে count = count + 1, ডান পাশের count রিড করার সময় সেই লোকাল ভ্যারিয়েবলটি এখনো অ্যাসাইন হয়নি বলে একটি UnboundLocalError ছুড়ত। nonlocal স্পষ্টভাবে বলে দেয় — এই count outer scope-এরটাই, নতুন কোনো লোকাল নয়।

প্র ০২ counter_a-এর count ভ্যারিয়েবলটি আসলে "কোথায়" থাকে, যেখানে make_counter()-এর স্ট্যাক ফ্রেম তো অনেক আগেই পপ হয়ে গেছে?

এটি আর সাধারণ স্ট্যাক-ডায়নামিক স্টোরেজে (L39) থাকে না — Python-এর বাস্তবায়নে, যখন কোনো ইনার ফাংশন একটি আউটার ভ্যারিয়েবল ক্যাপচার করে, সেই ভ্যারিয়েবলের স্টোরেজ effectively হিপে (বা একটি বিশেষ "cell" অবজেক্টে) স্থানান্তরিত হয়ে যায়, যাতে এটি সেই ফাংশন-কলের normal স্ট্যাক লাইফটাইমকে outlive করতে পারে — ঠিক L39-এর হিপ-ডায়নামিক অ্যালোকেশনের মতোই, যদিও প্রোগ্রামারকে এটি explicitly ম্যানেজ করতে হয় না।

প্র ০৩ যদি counter_a = make_counter()-এর বদলে counter_a = counter_b = make_counter() (একই একটি কলের ফলাফল দুই নামে অ্যাসাইন) লেখা হতো, তাহলে counter_a() কল করলে counter_b()-এর পরবর্তী কলেও কি প্রভাব পড়ত?

হ্যাঁ — কারণ এক্ষেত্রে counter_a ও counter_b দুটোই একই make_counter() কল থেকে তৈরি একই ক্লোজার অবজেক্টকে (একই ক্যাপচার করা count-সহ) রেফার করত, শুধু দুটো ভিন্ন নামে। এই প্রশ্নটি স্পষ্ট করে দেয় — স্বাধীন state পাওয়ার আসল কারণ হলো make_counter()-কে দুইবার আলাদাভাবে কল করা (দুটো আলাদা captured environment তৈরি হওয়া), শুধু দুটো ভিন্ন ভ্যারিয়েবল নাম ব্যবহার করা নয়।

অনুশীলন

  1. চিন্তা করুন: Python decorator-গুলো (যেমন @my_decorator) প্রায়ই ভেতরে একটি ক্লোজার ব্যবহার করে। কেন একটি decorator-এর জন্য ক্লোজার একটি স্বাভাবিক ফিট — কী "state" এটি সাধারণত ক্যাপচার করে রাখে?

    একটি decorator সাধারণত আসল (undecorated) ফাংশনটিকেই ক্যাপচার করে রাখে — একটি ক্লোজার তৈরি করে যা সেই মূল ফাংশনের রেফারেন্স মনে রাখে, এবং প্রতিবার wrapped ফাংশন কল হলে (এমনকি অনেক পরে, decorator ফাংশন নিজে অনেক আগেই রিটার্ন করে যাওয়ার পরও) সেই মূল ফাংশনটিকে কল করে অতিরিক্ত আচরণ (লগিং, টাইমিং, ক্যাশিং) যোগ করে — ঠিক make_counter()-এর মতোই, শুধু ক্যাপচার করা ভ্যালুটি একটি সংখ্যার বদলে একটি ফাংশন।

  2. পরীক্ষা করুন: উপরের কোড সেলে make_counter()-এর ভেতরে একটি তৃতীয় ফাংশন reset() যোগ করুন যা count-কে আবার 0 করে দেয় (এটিও nonlocal count লাগবে), এবং increment, reset দুটো ফাংশনই টাপল হিসেবে রিটার্ন করুন — যাচাই করুন reset() কলের পর increment() আবার 1 থেকে শুরু করে কিনা।

    সঠিক বাস্তবায়ন এমন হবে — def reset(): nonlocal count; count = 0 — এবং return increment, reset। যেহেতু increment ও reset দুটোই একই make_counter() কল থেকে তৈরি, তারা একই ক্যাপচার করা count শেয়ার করে — তাই reset() কল করার পর পরের increment() কল আবার 1 রিটার্ন করবে, প্রমাণ করে একই এনভায়রনমেন্টের একাধিক ফাংশন একই captured state শেয়ার করতে পারে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

আগের পাঠ
লাইফটাইম ও স্টোরেজ অ্যালোকেশন