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

সাবপ্রোগ্রাম, অ্যাক্টিভেশন রেকর্ড ও কল স্ট্যাক

Subprograms, activation records & the call stack
৯ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

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

  • সাবপ্রোগ্রাম ও অ্যাক্টিভেশন রেকর্ড কী ধরে রাখে
  • কল স্ট্যাক কীভাবে push (কল) ও pop (রিটার্ন) ডিসিপ্লিনে চলে
  • রিকার্শন কীভাবে এই একই মডেল থেকে স্বাভাবিকভাবে বেরিয়ে আসে
  • একটি বাস্তব CallStack ক্লাস দিয়ে main()->f()->g() কল-চেইন সিমুলেট ও ট্রেস করা

১ · সাবপ্রোগ্রাম কী

সাবপ্রোগ্রামSubprogramফাংশন/প্রসিডিউর/মেথড — একটি নাম-দেওয়া, একাধিকবার INVOKE করা যায় এমন কোড ব্লক। (ফাংশন/প্রসিডিউর/মেথড — একটি সাধারণ ছাতা-টার্ম) হলো একটি নাম-দেওয়া, পুনঃব্যবহারযোগ্য কোড ব্লক যা একাধিক জায়গা থেকে কল/ইনভোক করা যায়। এই কোর্সে এই ধারণা ইতিমধ্যে অনানুষ্ঠানিকভাবে বহুবার ব্যবহৃত হয়েছে — M2/L06-এর ইম্পারেটিভ প্রসিডিউর থেকে শুরু করে M5/L22-এর পার্সার ফাংশন পর্যন্ত।

২ · অ্যাক্টিভেশন রেকর্ড — একটি কলের সব তথ্য

অ্যাক্টিভেশন রেকর্ডActivation Record (Stack Frame)একটি সাবপ্রোগ্রাম প্রতিবার কল হলে অ্যালোকেট হওয়া একটি মেমরি ব্লক — সেই নির্দিষ্ট ইনভোকেশনের জন্য প্রয়োজনীয় সবকিছু ধরে রাখে। (স্ট্যাক ফ্রেম) হলো একটি সাবপ্রোগ্রাম প্রতিবার কল হলে অ্যালোকেট হওয়া একটি মেমরি ব্লক — সেই নির্দিষ্ট ইনভোকেশনের জন্য দরকারি সবকিছু ধরে রাখে:

Return address
কল শেষ হলে ক্যালারের ঠিক কোথায় ফিরে যেতে হবে।
প্যারামিটার
এই নির্দিষ্ট কলে পাস করা আর্গুমেন্টের মান/রেফারেন্স।
লোকাল ভ্যারিয়েবল
L39-এর stack-dynamic অ্যালোকেশনের বাস্তবায়ন।
ক্যালারের রেকর্ডের রেফারেন্স
চেইন ধরে হাঁটা যায় — L43-এর স্ট্যাক-আনওয়াইন্ডিং-এ পুনর্ব্যবহৃত।

৩ · কল স্ট্যাক — push/pop ডিসিপ্লিন

কল স্ট্যাকCall Stackবর্তমানে সক্রিয় সব অ্যাক্টিভেশন রেকর্ডের একটি স্ট্যাক (LIFO)। হলো বর্তমানে-সক্রিয় সব অ্যাক্টিভেশন রেকর্ডের একটি স্ট্যাক — একটি CALL একটি নতুন অ্যাক্টিভেশন রেকর্ড push করে; একটি RETURN সেটি pop করে, ক্যালারের কনটেক্সট ফিরিয়ে দেয়। এটাই L39-এর "stack-dynamic" স্টোরেজ ক্যাটাগরির সুনির্দিষ্ট বাস্তবায়ন — ঠিক এই push-on-call/pop-on-return ডিসিপ্লিন দিয়েই।

স্ট্যাক বৃদ্ধি (CALL) main() f() g() ← সর্বোচ্চ গভীরতা স্ট্যাক সংকোচন (RETURN) main() f() (g() pop হয়েছে) প্রতিটি নতুন কল উপরে push হয় প্রতিটি return সবচেয়ে-উপরেরটি pop করে
g() সবচেয়ে গভীরে কল হওয়ায় স্ট্যাকের একদম উপরে থাকে — এটিই সবার আগে return করবে (LIFO)।

৪ · রিকার্শনের সাথে সম্পর্ক

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

৫ · কোড: main() → f() → g() কল-চেইন সিমুলেশন

নিচের কোড সেলে একটি বাস্তব CallStack ক্লাস দিয়ে একটি ছোট কল-চেইন সিমুলেট করা হয়েছে — প্রতিটি push/pop-এর পরে সম্পূর্ণ স্ট্যাক প্রিন্ট করে বৃদ্ধি ও সংকোচন দুটোই দেখানো হয়েছে।

Python
class CallStack:
    def __init__(self):
        self.frames = []
        self._next_return_address = 100  # সিমুলেটেড ইনস্ট্রাকশন-ঠিকানা, শুধু ট্রেসিংয়ের জন্য

    def call(self, function_name, params=None, locals_=None):
        frame = {
            "function_name": function_name,
            "return_address": self._next_return_address,
            "params": dict(params or {}),
            "locals": dict(locals_ or {}),
        }
        self.frames.append(frame)
        self._next_return_address += 10
        return frame

    def return_from_call(self):
        if not self.frames:
            raise RuntimeError("খালি call stack থেকে return করা যায় না")
        return self.frames.pop()

    def print_stack(self, label=""):
        print(f"--- কল স্ট্যাক [{label}]  (গভীরতা={len(self.frames)}) ---")
        if not self.frames:
            print("  (খালি)")
        for depth, frame in enumerate(reversed(self.frames), start=1):
            print(f"  #{depth} {frame['function_name']}  params={frame['params']}  "
                  f"locals={frame['locals']}  return_addr={frame['return_address']}")


stack = CallStack()

stack.call("main", params={}, locals_={"x": 5})
stack.print_stack("main() কল হওয়ার পর")

stack.call("f", params={"n": 5}, locals_={})
stack.print_stack("f() কল হওয়ার পর")

stack.call("g", params={"n": 4}, locals_={})
stack.print_stack("g() কল হওয়ার পর -- সর্বোচ্চ গভীরতা")
assert len(stack.frames) == 3

popped = stack.return_from_call()
print(f"\ng() থেকে return -- pop হলো: {popped['function_name']}")
stack.print_stack("g() থেকে return-এর পর")

popped = stack.return_from_call()
print(f"\nf() থেকে return -- pop হলো: {popped['function_name']}")
stack.print_stack("f() থেকে return-এর পর")

popped = stack.return_from_call()
print(f"\nmain() থেকে return -- pop হলো: {popped['function_name']}")
stack.print_stack("main() থেকে return-এর পর")

assert len(stack.frames) == 0
print("\nস্ট্যাক সম্পূর্ণ খালি -- growth ও shrink দুটোই সঠিকভাবে সম্পন্ন হয়েছে।")

    
লক্ষ্য করুন — return_from_call() সবসময় স্ট্যাকের একদম শেষে যোগ হওয়া ফ্রেমটিই pop করে (Python-এর list.pop() ডিফল্টভাবে শেষ এলিমেন্ট সরায়) — এটাই কল স্ট্যাকের LIFO (Last-In, First-Out) আচরণ। g() সবার শেষে push হয়েছে বলে সবার আগে pop হয়, ঠিক যেমন আসলেই ঘটে: সবচেয়ে সাম্প্রতিক কলটিই সবার আগে রিটার্ন করে।
মূল কথা · Key takeaway

একটি সাবপ্রোগ্রাম কল মানে "একটি অ্যাক্টিভেশন রেকর্ড push করা"; একটি রিটার্ন মানে "সবচেয়ে উপরের অ্যাক্টিভেশন রেকর্ডটি pop করা"। এই সরল, নিয়মবদ্ধ push/pop ডিসিপ্লিনই — কোনো জাদু ছাড়াই — নেস্টেড কল, রিটার্ন-অ্যাড্রেস ট্র্যাকিং, এবং (M11/L50-এ দেখা যাবে) রিকার্শনকে সঠিকভাবে কাজ করায়।

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

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

প্র ০১ উপরের সিমুলেশনে g() কল হওয়ার পর স্ট্যাকের গভীরতা কত ছিল, এবং কেন g() সবার আগে return করলো?

গভীরতা ছিল 3 (main, f, g — তিনটি সক্রিয় ফ্রেম)। g() সবার আগে return করলো কারণ কল স্ট্যাক LIFO — সবচেয়ে সাম্প্রতিক push (g()) সবার উপরে থাকে, এবং return_from_call() সবসময় সবচেয়ে-উপরেরটিই pop করে। main() ততক্ষণ pop হবে না যতক্ষণ না তার উপরের সব ফ্রেম (এখানে f ও g) আগে pop হয়ে গেছে।

প্র ০২ যদি g() কল হওয়ার আগেই ভুলবশত return_from_call() চারবার কল করা হতো, তাহলে কী হতো?

প্রথম দুইবার সঠিকভাবে f ও main pop হয়ে যেত (যেহেতু তখন push হওয়া ছিল main ও f)। তৃতীয়বার কল করার সময় স্ট্যাক ইতিমধ্যে খালি — কোডে দেখুন, return_from_call() স্পষ্টভাবে if not self.frames: raise RuntimeError(...) চেক করে এই অবস্থা ধরে ফেলে, চুপচাপ ভুল আচরণ (যেমন একটি খালি লিস্ট থেকে pop করে ক্র্যাশ করা বা None রিটার্ন করা) না করে স্পষ্ট এরর দেয়।

প্র ০৩ অ্যাক্টিভেশন রেকর্ডে "ক্যালারের রেকর্ডের রেফারেন্স" রাখা কেন দরকারি, যদি কল স্ট্যাক নিজেই ইতিমধ্যে একটি ক্রম বজায় রাখে?

কল স্ট্যাক (এই লেসনের self.frames লিস্টের মতো) সাধারণত একটি ইমপ্লিমেন্টেশন ডিটেইল — বাস্তব সিস্টেমে সবসময় এভাবে একটি সরল লিস্টে দেখা যায় না। প্রতিটি ফ্রেমে সরাসরি তার ক্যালারের ফ্রেমের রেফারেন্স রাখলে (একটি লিংকড-লিস্ট-এর মতো চেইন), M9/L44-এর এক্সেপশন হ্যান্ডলিং-এর মতো কোনো মেকানিজম দ্রুত "উপরের দিকে হেঁটে" ক্যালার-চেইন ধরে খুঁজতে পারে (যেমন: কোন ফ্রেমে try/except আছে) — কল স্ট্যাকের গ্লোবাল গঠন না জেনেই, শুধু স্থানীয় "আমার ক্যালার কে" তথ্য দিয়ে।

অনুশীলন

  1. পরীক্ষা করুন: উপরের কোডে g()-এর পরে আরেকটি ধাপ যোগ করুন — stack.call("h", params={"n": 3}, locals_={}) — এবং print_stack চালিয়ে দেখুন গভীরতা কত হয়।

    গভীরতা হবে 4 (main, f, g, h)। print_stack-এর আউটপুটে h সবার উপরে (#1 হিসেবে) দেখাবে, কারণ এটি সবার শেষে push হয়েছে — এবং এখন এটিকেই সবার আগে return_from_call() দিয়ে pop করতে হবে।

  2. চিন্তা করুন: stack.call("f", ...)-কে একটি লুপে ৫ বার কল করলে (প্রতিবার আগেরটি return না করেই), স্ট্যাকে কয়টি আলাদা f ফ্রেম থাকবে — এবং এরা কি একে অপরের locals শেয়ার করবে?

    ৫টি সম্পূর্ণ আলাদা অ্যাক্টিভেশন রেকর্ড push হবে — প্রতিটির নিজস্ব, স্বাধীন locals ডিকশনারি থাকবে (কোডে প্রতিবার dict(locals_ or {}) দিয়ে একটি নতুন dict তৈরি হয়)। এরা কখনো locals শেয়ার করবে না — এটাই ঠিক সেই নীতি যা এই লেসনের §৪-এ বলা হয়েছে এবং যা রিকার্শনকে (M11/L50) সঠিকভাবে কাজ করায়।

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

পূর্ববর্তী পাঠ
অ্যারে, রেকর্ড ও পয়েন্টার