সরাসরি পর্বে যান
পর্ব ২৫ · ৫৫-এর মধ্যে · মডিউল ৪

polymorphism ও virtual ফাংশন — একই ডাক, ভিন্ন কাজ

Polymorphism and Virtual Functions — One Call, Many Behaviours

Taught in Bangla, this lesson explains runtime polymorphism in C++ through a payment checkout: static versus dynamic binding, virtual functions, override and final, virtual destructors, how vtables and vptrs work, virtual calls in constructors, dynamic_cast and polymorphic containers.

এই পর্বে যা শিখবেন

  • Polymorphism কোন সমস্যার সমাধান করে, আর type-অনুযায়ী if-else-এর শিকল কেন বিপজ্জনক
  • static binding আর dynamic binding-এর পার্থক্য; base reference/pointer দিয়ে virtual ফাংশন ডাকা
  • override, final আর virtual destructor-এর নিয়ম — কোন ভুল এরা আটকায়
  • vtable ও vptr দিয়ে virtual ডাক ভেতরে কীভাবে কাজ করে, আর এর আসল খরচ কত
  • constructor-এ virtual ডাক, covariant return, dynamic_cast, polymorphic container, আর AI-এর কোডে এসব ভুল ধরা

১ · সমস্যাটা আগে — একটি checkout, অনেক রকম পেমেন্ট

ধরুন আপনি একটি অনলাইন দোকানের checkout অংশ লিখছেন। ক্রেতা দাম দিতে পারেন bKash, Nagad বা ব্যাংকের কার্ড দিয়ে। প্রতিটি পদ্ধতির নিজের নিয়ম আছে — কারও চার্জ শতাংশে, কারও একটি নির্দিষ্ট টাকা যোগ করে, কারও যাচাইয়ের ধাপ আলাদা। আগের পর্বে আপনি inheritance দেখেছেন: একটি base class Payment, আর তা থেকে তৈরি derived class Bkash, Nagad, Card. কিন্তু শুধু উত্তরাধিকার দিয়ে আসল প্রশ্নের উত্তর মেলে না: checkout-এর কোড কীভাবে জানবে, এই মুহূর্তের ক্রেতা কোন পদ্ধতি বেছে নিয়েছেন?

প্রথম যে সমাধান মাথায় আসে, তা হলো টাইপ জিজ্ঞেস করে করে কাজ করা:

// পুরোনো পথ: টাইপ দেখে দেখে সিদ্ধান্ত (type switch)
int fee(const Payment& p, int taka) {
    switch (p.kind) {                    // প্রতিটি object-এ একটি "kind" লেবেল
        case Kind::Bkash: return taka * 18 / 1000;
        case Kind::Nagad: return taka * 15 / 1000;
        case Kind::Card:  return 20 + taka / 100;
    }
    return 0;
}
// ...আর একই switch আবার refund()-এ, receipt()-এ, verify()-এ...

কোডটি চলে। সমস্যা দেখা দেয় ছয় মাস পরে, যখন ব্যবসায় Rocket বা নতুন কোনো ওয়ালেট যোগ করতে হয়। তখন খুঁজে খুঁজে বের করতে হয়, প্রোগ্রামের কোথায় কোথায় এমন switch আছে — fee-তে, refund-এ, রসিদ ছাপায়, যাচাইয়ে। একটি জায়গা বাদ পড়লে নতুন ওয়ালেটের ক্রেতা হয় শূন্য চার্জ দেবেন, নয়তো ভুল রসিদ পাবেন। আর কেউ টের পাবে না, কারণ compiler এখানে কোনো ভুল দেখে না। নতুন টাইপ যোগ করা মানে পুরোনো, পরীক্ষিত কোড খুলে বদলানো — ঠিক যেটা বড় সফটওয়্যারে সবচেয়ে ঝুঁকির কাজ।

একটি বড় সুপারশপের ক্যাশ কাউন্টারের কথা ভাবুন। ক্যাশিয়ার প্রত্যেক ক্রেতাকে একটি কথাই বলেন: “দাম দিন।” কেউ bKash-এর QR স্ক্যান করেন, কেউ কার্ড ঘষেন, কেউ Nagad-এ পাঠান। ক্যাশিয়ারকে প্রতিটি ব্যাংক বা ওয়ালেটের ভেতরের নিয়ম জানতে হয় না — প্রতিটি পদ্ধতি নিজের কাজ নিজে জানে। কাল একটি নতুন ওয়ালেট চালু হলে ক্যাশিয়ারকে নতুন করে প্রশিক্ষণ দিতে হয় না; কাউন্টারে শুধু নতুন মেশিনটি বসে। একটি অনুরোধ, অনেক রকম সাড়া — এটাই polymorphism.

Polymorphism শব্দটি গ্রিক — poly মানে “অনেক”, morph মানে “রূপ”। প্রোগ্রামিংয়ে এর মানে: একই নামের একটি ডাক, কিন্তু কোন কোডটি চলবে তা নির্ভর করে কার ওপর ডাকা হলো তার ওপর। C++-এ polymorphism কয়েক রকম আছে — পর্ব ৯-এর function overloading-ও এক অর্থে polymorphism, পর্ব ২৮-এর template-ও। আজকের বিষয় সবচেয়ে পরিচিত রূপটি: চলার সময়ের (run-time) polymorphism, যার যন্ত্র হলো virtual ফাংশন।

মূল ধারণা

লক্ষ্যটা হলো এমন কোড লেখা, যা “একটি Payment”-এর সাথে কথা বলে, নির্দিষ্ট কোনো Payment-এর সাথে নয়। checkout শুধু বলবে p.fee(taka) — আর চলার সময় আসল object-টি নিজে ঠিক করবে কোন নিয়ম খাটবে। নতুন ওয়ালেট এলে নতুন একটি class লিখবেন; checkout-এর একটি লাইনও বদলাবে না।

২ · Static binding বনাম dynamic binding — কে, কখন সিদ্ধান্ত নেয়

একটি ফাংশন-ডাক লিখলে কাউকে না কাউকে ঠিক করতে হয়, ঠিক কোন ফাংশনের কোড চলবে। এই সংযোগ করাকে বলে binding. C++-এ এটি দুটি সময়ে হতে পারে।

প্রথমে দুটি শব্দ পরিষ্কার করে নিন। const Payment& p = bk; লিখলে p-এর static type হলো Payment& — যা কোডে লেখা আছে, compiler যা দেখে। কিন্তু p আসলে যে object-কে দেখাচ্ছে, সেটি একটি Bkash — এটি dynamic type, যা শুধু চলার সময় নিশ্চিতভাবে জানা যায়। derived object-কে base reference বা pointer দিয়ে ধরাকে বলে upcast, আর C++ এটি নিজে থেকেই, কোনো cast ছাড়াই করতে দেয় — কারণ প্রতিটি Bkash একটি Payment (“is-a” সম্পর্ক, পর্ব ২৪)।

Static binding

compile-এর সময় compiler ঘোষিত টাইপ (static type) দেখে ফাংশন ঠিক করে ফেলে। চলার সময় আর কোনো প্রশ্ন নেই — সরাসরি সেই ফাংশনে লাফ। সাধারণ, non-virtual member function-এ এটাই হয়।

early binding · দ্রুত, inline হতে পারে

Dynamic binding

compiler এমন কোড বসায়, যা চলার সময় object-এর আসল টাইপ (dynamic type) দেখে ঠিক ফাংশনটি খুঁজে নেয়। virtual ফাংশনকে reference বা pointer দিয়ে ডাকলে এটা হয়।

late binding · নমনীয়, একটু বাড়তি ধাপ

Static binding হলো ট্রেনের নির্দিষ্ট টিকিটের মতো — টিকিট কাটার সময়েই সিট নম্বর লেখা, স্টেশনে গিয়ে আর কিছু ভাবতে হয় না। Dynamic binding হলো রাইড-শেয়ারিং অ্যাপে গাড়ি ডাকার মতো — আপনি শুধু বলেন “একটি গাড়ি চাই”, কোন চালক আসবেন, তা ঠিক হয় ডাকার মুহূর্তে।

প্রশ্নStatic bindingDynamic binding
কখন সিদ্ধান্তcompile-এর সময়চলার সময়
কী দেখেভ্যারিয়েবলের ঘোষিত টাইপobject-এর আসল টাইপ
কখন হয়non-virtual ফাংশন; অথবা object-কে সরাসরি (by value) ডাকলেvirtual ফাংশন, base reference বা pointer দিয়ে ডাকলে
খরচপ্রায় শূন্য, inline হতে পারেএকটি পরোক্ষ লাফ; সাধারণত inline হয় না
নতুন টাইপ যোগেডাকার কোড বদলাতে হতে পারেডাকার কোড বদলায় না

একটি জিনিস খেয়াল করুন: overloading (পর্ব ৯) আর template-ও এক রকম polymorphism, কিন্তু দুটিতেই সিদ্ধান্ত হয় compile-এর সময়। তাই এদের বলে compile-time polymorphism. আর virtual ফাংশন দেয় run-time polymorphism. দুটিরই জায়গা আছে — এই পর্বের শেষে আবার এ প্রসঙ্গে ফিরব।

৩ · virtual ফাংশন — base-এর মাধ্যমে ডাক, derived-এর কাজ

base class-এ কোনো member function-এর আগে virtual লিখলে আপনি compiler-কে বলছেন: “এই ফাংশনটি derived class বদলে দিতে পারে; reference বা pointer দিয়ে ডাকা হলে আসল object-এর সংস্করণটিই চালাবে।” এমন ফাংশনকে বলে virtual function. derived class-এ একই নাম, একই প্যারামিটার আর একই const-সহ ফাংশন লিখলে সেটি base-এর সংস্করণকে override করে।

এবার checkout-এর পুরো ধারণাটি একটি ছোট প্রোগ্রামে দেখুন। চার্জের হারগুলো শুধু উদাহরণ (আসল হার নয়), আর হিসাব পূর্ণসংখ্যায়, যাতে আউটপুট সব জায়গায় এক থাকে। চারটি ভিন্ন টাইপের object একটি তালিকায় রাখা আছে, আর লুপের একটিমাত্র লাইন সবার জন্য একই ডাক দেয়:

#include <memory>
#include <print>
#include <vector>

struct Payment {                                   // base class
    virtual ~Payment() = default;                  // virtual destructor
    virtual int fee(int) const { return 0; }       // default: cash, no fee
};
struct Bkash : Payment { int fee(int t) const override { return t * 18 / 1000; } };
struct Nagad : Payment { int fee(int t) const override { return t * 15 / 1000; } };
struct Card final : Payment { int fee(int t) const override { return 20 + t / 100; } };

int main() {
    std::vector<std::unique_ptr<Payment>> pay;     // many types, one list
    pay.push_back(std::make_unique<Payment>());
    pay.push_back(std::make_unique<Bkash>());
    pay.push_back(std::make_unique<Nagad>());
    pay.push_back(std::make_unique<Card>());
    for (const auto& p : pay) std::println("fee on 5000 = {}", p->fee(5000));
}

আউটপুট চার লাইন: fee on 5000 = 0, = 90, = 75, = 70. প্রথমটি base-এর নিজের সংস্করণ (নগদ টাকা, চার্জ নেই), পরের তিনটি যথাক্রমে Bkash (৫০০০-এর ১.৮%), Nagad (১.৫%) আর Card (২০ টাকা + ১%). লুপের লাইনে p-এর টাইপ সবসময় একই — std::unique_ptr<Payment> — তবু প্রতিবার ভিন্ন ফাংশন চলেছে। কোথাও switch নেই, কোথাও টাইপ জিজ্ঞেস করা হয়নি। struct-এ inheritance ডিফল্টভাবে public, তাই : Payment-ই যথেষ্ট। override, final আর virtual destructor-এর কথা একটু পরেই।

লুপের সেই একটি লাইন চারবার চলার সময় কোন ফাংশন কেন বেছে নেওয়া হলো, ধাপে ধাপে দেখুন। প্রতিটি ধাপে আগে নিজে অনুমান করুন:

1struct Payment { virtual int fee(int) const { return 0; } };2struct Bkash : Payment { ... return t * 18 / 1000; };3struct Nagad : Payment { ... return t * 15 / 1000; };4struct Card final : Payment { ... return 20 + t / 100; };5for (const auto& p : pay)6    std::println("fee on 5000 = {}", p->fee(5000));
লুপের এই পাকে
p-এর static type
আসল object (dynamic type)
যে fee চলল
হিসাব
  1. প্রথম পাক — আসল Payment. তালিকার প্রথম object-টি একটি সাধারণ Payment. derived কেউ নেই, তাই base-এর নিজের fee চলল, ফেরত দিল 0. virtual ফাংশনের base সংস্করণও একটি পুরোদস্তুর ফাংশন — কেউ বদলে না দিলে সেটিই চলে।
  2. দ্বিতীয় পাক — একই লাইন, ভিন্ন ফল। এবার p দেখাচ্ছে একটি Bkash-কে। কোডে p-এর টাইপ এখনো Payment-এর pointer, কিন্তু fee virtual বলে চলার সময় আসল object-এর টাইপ দেখা হলো, আর চলল Bkash::fee — 90.
  3. তৃতীয় পাক — Nagad. একই যন্ত্র, এবার Nagad::fee, ফল 75. লুপের লাইনটি জানেই না Nagad বলে কিছু আছে; সে শুধু জানে “এটি একটি Payment, তাই এর fee আছে”।
  4. চতুর্থ পাক — Card. Card::fee চলল, ফল 70. চারবারে একটি লাইন চারটি ভিন্ন ফাংশন চালাল। কাল Rocket যোগ করতে চাইলে শুধু একটি নতুন class আর একটি push_back — লুপ যেমন আছে তেমনই থাকবে। পুরোনো কোড না খুলে নতুন আচরণ যোগ করার এই গুণকে বলে open-closed principle (পর্ব ২৭-এ বিস্তারিত)।

কেন reference বা pointer লাগে — object slicing

dynamic binding কাজ করে শুধু reference বা pointer দিয়ে ডাকলে। base টাইপের একটি সাধারণ ভ্যারিয়েবলে derived object কপি করলে কী হয়, দেখুন:

Bkash bk;
Payment byValue = bk;          // কপি: শুধু Payment অংশটুকু কপি হলো
const Payment& byRef = bk;     // reference: আসল bk-কেই দেখছে

byValue.fee(5000);             // 0  — এটি এখন সত্যিই একটি Payment
byRef.fee(5000);               // 90 — Bkash::fee
// একই ভুল ফাংশনের প্যারামিটারে: void checkout(Payment p) — by value নেবেন না

Payment টাইপের একটি ভ্যারিয়েবলে শুধু Payment-এর মাপের জায়গা আছে। Bkash-কে সেখানে কপি করলে বাড়তি অংশ কেটে বাদ পড়ে, আর নতুন object-টি আগাগোড়া একটি Payment. একে বলে object slicing — যেন একটি পুরো মাছের শুধু লেজের দিকটা কেটে রাখলেন, আর ভাবলেন মাছটি এখনো আস্ত আছে। তাই polymorphic object ফাংশনে পাঠান const Payment& বা Payment& দিয়ে, আর একসাথে অনেকগুলো রাখুন pointer-এ — যেমন উপরের unique_ptr-এর vector.

৪ · override আর final — নীরব ভুলকে জোরালো error বানানো

virtual ফাংশনের সবচেয়ে বিপজ্জনক ভুলগুলো compile হয়, চলে, আর চুপচাপ ভুল ফল দেয়। সবচেয়ে সাধারণটি: base-এ virtual লিখতে ভুলে যাওয়া। তখন derived-এর একই নামের ফাংশনটি override করে না — শুধু base-এরটিকে আড়াল করে (hide). ফলে base reference দিয়ে ডাকলে base-এর ফাংশনই চলে। bKash-এর ক্রেতার কাছ থেকে চার্জ কাটা হলো না, আর কোনো error-ও এল না। ঘটনাটি ধাপে ধাপে দেখুন:

base-এ virtual নেইদেখতে নিরীহ কোড
struct Payment { int fee(int) const { return 0; } };       // virtual নেই!
struct Bkash : Payment { int fee(int t) const { return t * 18 / 1000; } };

Bkash bk;
const Payment& p = bk;
std::println("{}", p.fee(5000));
compiler যা দেখেstatic type = Payment&
p.fee(5000)
  p-এর ঘোষিত টাইপ: const Payment&
  Payment::fee কি virtual?  না
  => সিদ্ধান্ত এখনই: সরাসরি Payment::fee ডাকো
     (মেশিন কোডে: call Payment::fee — ঠিকানা স্থির)
চলার সময়নীরব বাগ
আউটপুট:  0

p আসলে একটি Bkash-কে দেখাচ্ছে, কিন্তু তাতে কিছু যায় আসে না —
ডাকের ঠিকানা compile-এর সময়েই বাঁধা হয়ে গেছে।
কোনো error নেই, কোনো warning নেই। ক্রেতার চার্জ: 0 টাকা।
সমাধান: virtual + overridedynamic binding
struct Payment { virtual int fee(int) const { return 0; } };
struct Bkash : Payment { int fee(int t) const override { return t * 18 / 1000; } };

আউটপুট:  90

আর base-এ virtual না থাকলে override-ই error দিত:
error: 'virtual int Bkash::fee(int) const' marked 'override', but does not override
  1. দেখতে ঠিকঠাক। base আর derived — দুজনেরই fee আছে, নাম আর প্যারামিটার মেলে। কিন্তু base-এ virtual শব্দটি নেই। পাঠক আর AI — দুজনেরই চোখ এড়িয়ে যাওয়া খুব সহজ।
  2. Compiler-এর সিদ্ধান্ত compile-এর সময়েই। compiler শুধু p-এর ঘোষিত টাইপ দেখে — Payment&. ফাংশনটি virtual নয়, তাই চলার সময় কিছু খোঁজার প্রশ্নই নেই। সে মেশিন কোডে সরাসরি Payment::fee-এর ঠিকানা বসিয়ে দেয়। এটাই static binding.
  3. চলার সময় নীরব ভুল। আসল object Bkash হলেও চলল base-এর ফাংশন, আউটপুট 0. compiler-এর চোখে এটি পুরোপুরি বৈধ C++ — derived-এর fee base-এরটিকে শুধু আড়াল করেছে, আর bk.fee(5000) সরাসরি ডাকলে ঠিকই 90 আসত। বাগটি ধরা পড়বে মাস শেষে হিসাব মেলাতে গিয়ে।
  4. দুটি শব্দে সমাধান। base-এ virtual দিলে ডাক dynamic হয়, আউটপুট 90. আর derived-এ override লেখার অভ্যাস থাকলে প্রথম ধাপের কোডটি compile-ই হতো না — compiler বলত, “আপনি override দাবি করছেন, কিন্তু override করার মতো কোনো virtual ফাংশন নেই।” নীরব বাগ হয়ে গেল চোখের সামনের error.

override — “আমি বদলাতে চাইছি”, compiler যাচাই করুক

C++11 থেকে derived-এর ফাংশনের শেষে override লেখা যায়। এর মানে: “এই ফাংশন base-এর একটি virtual ফাংশনকে বদলাচ্ছে।” মিল না পেলে compile error. কোন কোন ভুল এটি ধরে:

struct Notifier {
    virtual void send(const std::string& msg) const;
};
struct Sms : Notifier {
    void send(const std::string& msg) override;        // error: const বাদ পড়েছে
    void sned(const std::string& msg) const override;  // error: বানান ভুল
    void send(std::string msg) const override;         // error: প্যারামিটারের টাইপ আলাদা
    void send(const std::string& msg) const override;  // ঠিক
};

override ছাড়া প্রথম তিনটি লাইন নীরবে নতুন, আলাদা ফাংশন তৈরি করত — আর base reference দিয়ে ডাকলে চলত base-এর send. বিশেষ করে const বাদ পড়া খুব সাধারণ, কারণ চোখে প্রায় একই দেখায়। আরেকটি সুবিধা: কেউ পরে base-এর ফাংশনের স্বাক্ষর বদলালে (ধরুন একটি প্যারামিটার যোগ করলেন), সব derived class-এ সঙ্গে সঙ্গে error আসবে — কোনো সংস্করণ পেছনে পড়ে থাকবে না। নিয়মটা তাই সরল: derived class-এ প্রতিটি override-এ override লিখুন; সেখানে আবার virtual লেখার দরকার নেই। (C++ Core Guidelines-ও ঠিক এটাই বলে। GCC-তে -Wsuggest-override দিলে override বাদ পড়া জায়গাগুলো দেখায়।)

final — “এখানেই শেষ”

final দুই জায়গায় বসে। class-এর নামের পরে (struct Card final : Payment) বসালে Card থেকে আর কোনো class তৈরি করা যাবে না। একটি virtual ফাংশনের পরে (int fee(int t) const final;) বসালে আরও নিচের কোনো class এই ফাংশন আর বদলাতে পারবে না। এটি নকশার একটি স্পষ্ট ঘোষণা — যেমন নিরাপত্তা-সংক্রান্ত যাচাইয়ের ফাংশন, যা কেউ ভুল করে বদলে দিক তা আপনি চান না। বাড়তি লাভ: compiler জানে এর নিচে আর কেউ নেই, তাই কিছু ক্ষেত্রে virtual ডাককে সরাসরি ডাকে বদলে দিতে পারে (৬ নম্বর অংশ দেখুন)। তবে সব class-এ অভ্যাসবশত final বসাবেন না; যেখানে সত্যিই আর বাড়ানোর কথা নয়, শুধু সেখানে।

৫ · Virtual destructor — যে নিয়ম ভাঙলে undefined behavior

polymorphic object প্রায়ই heap-এ থাকে আর base টাইপের pointer দিয়ে ধরা থাকে — ঠিক উপরের std::unique_ptr<Payment>-এর মতো। সেই pointer যখন object-টি মোছে, তখন কোন destructor চলবে? এখানেও একই binding-এর নিয়ম খাটে। destructor virtual না হলে compiler শুধু pointer-এর ঘোষিত টাইপ দেখে — ~Payment().

struct Payment {
    ~Payment() { }                        // virtual নয় — বিপদ
    virtual int fee(int) const { return 0; }
};
struct Card : Payment {
    std::vector<std::string> log;         // Card-এর নিজের heap রিসোর্স
    int fee(int t) const override { return 20 + t / 100; }
};

std::unique_ptr<Payment> p = std::make_unique<Card>();
// p ধ্বংস হলে: delete (Payment*) — Card অংশের destructor চলার নিশ্চয়তা নেই
// C++ standard-এর ভাষায়: undefined behavior. (এই কোড চালাবেন না)

derived object-কে এমন base pointer দিয়ে delete করা, যার destructor virtual নয় — C++ standard একে সরাসরি undefined behavior বলে। বাস্তবে সাধারণত যা ঘটে: শুধু Payment অংশের destructor চলে, Card-এর log vector-এর মেমরি আর কখনো ফেরত যায় না (leak); derived অংশে ফাইল বা lock থাকলে সেগুলোও আটকে থাকে। একাধিক base থাকলে ভুল ঠিকানা মুছে প্রোগ্রাম crash-ও করতে পারে। কিন্তু “সাধারণত” কথাটাই এখানে ফাঁদ — UB মানে compiler কোনো কিছুরই নিশ্চয়তা দেয় না।

সমাধান একটি লাইন, যা আমরা কোডে আগেই লিখেছি:

struct Payment {
    virtual ~Payment() = default;         // এখন delete আসল টাইপের destructor চালায়
    virtual int fee(int) const { return 0; }
};
মূল নিয়ম

যে class-এ অন্তত একটি virtual ফাংশন আছে, তার destructor-ও virtual করুন — virtual destructor. তখন base pointer দিয়ে মুছলে আগে derived-এর destructor, তারপর base-এর destructor চলে, ঠিক যেমন হওয়া উচিত। derived class-গুলোর destructor নিজে থেকেই virtual হয়ে যায়। (বিকল্প একটি নিয়মও আছে: base-এর destructor-কে protected আর non-virtual রাখা, যাতে base pointer দিয়ে delete করাই না যায় — এটি বিশেষ ক্ষেত্রে কাজে লাগে।) GCC -Wall-এ -Wdelete-non-virtual-dtor চালু থাকে, যা এমন delete অনেক ক্ষেত্রে ধরে ফেলে — warning-গুলো উপেক্ষা করবেন না।

৬ · ভেতরে কী ঘটে — vtable আর vptr

চলার সময় “আসল টাইপ দেখে ঠিক ফাংশন খুঁজে নেওয়া” কথাটি শুনতে জাদুর মতো লাগে। C++ standard শুধু আচরণটি বলে দেয়, কীভাবে করতে হবে তা বলে না। কিন্তু GCC, Clang, MSVC — বাস্তবের প্রায় সব compiler একই মূল কৌশল ব্যবহার করে। নিচের ছবিটি সেই সাধারণ কৌশল, সরল করে দেখানো। এটি implementation detail — প্রচলিত, কিন্তু standard-এর নিশ্চয়তা নয়।

  • vtable (virtual table): virtual ফাংশন আছে এমন প্রতিটি class-এর জন্য compiler একটি তালিকা বানায় — সেই class-এর প্রতিটি virtual ফাংশনের ঠিকানা, নির্দিষ্ট ক্রমে। Bkash-এর তালিকার “fee”-এর ঘরে থাকে Bkash::fee-এর ঠিকানা, Nagad-এর তালিকায় Nagad::fee-এর। তালিকাটি class-প্রতি একটি, সব object ভাগ করে নেয়, আর থাকে প্রোগ্রামের শুধু-পড়ার অংশে।
  • vptr (virtual pointer): প্রতিটি object-এর ভেতরে compiler একটি লুকানো pointer রাখে, যা নিজের class-এর vtable দেখায়। প্রচলিত ব্যবস্থায় এটি object-এর একেবারে শুরুতে থাকে। constructor-ই এটি ঠিক করে দেয় — object কোন টাইপ হিসেবে জন্মাল, সেই অনুযায়ী।

তিনটি object, তিনটি vtable

Each object carries a hidden vptr to its class's table of function addresses (simplified)

Bkash object[vptr] + নিজের ডেটা
Nagad object[vptr] + নিজের ডেটা
আরেকটি Bkash object[vptr] — একই vtable দেখায়
Bkash-এর vtable[0] ~Bkash · [1] Bkash::fee
Nagad-এর vtable[0] ~Nagad · [1] Nagad::fee
Payment-এর vtable[0] ~Payment · [1] Payment::fee
মেশিন কোডে ফাংশনগুলোBkash::fee, Nagad::fee, Payment::fee …
ছবি ২৫.১ — object-প্রতি একটি vptr, class-প্রতি একটি vtable. প্রতিটি derived class-এর তালিকায় ঘরের ক্রম base-এর মতোই, শুধু ঠিকানা আলাদা। (আসল vtable-এ আরও কিছু থাকে, যেমন টাইপের তথ্য; এখানে বাদ দেওয়া।)

এবার p->fee(5000) লাইনটি চলার মুহূর্তে CPU আসলে কী করে, ধাপে ধাপে দেখুন। compiler আগেই জানে, fee হলো vtable-এর ১ নম্বর ঘর — কারণ ক্রমটি base class দেখেই ঠিক করা:

  1. p→ @5000object-এর ঠিকানা
  2. vptr*(@5000)object-এর শুরুতে
  3. vtableBkash-এরclass-এর তালিকা
  4. ঘর [1]feeঠিকানা পড়া
  5. ডাকBkash::feethis = @5000
p->fee(5000)শুরু: হাতে শুধু একটি ঠিকানা
p  ──►  @5000  (ঠিকানাটি উদাহরণমাত্র)
compiler জানে: এখানে একটি Payment আছে — কিন্তু কোন ধরনের, জানে না।
object-এর ভেতরেপ্রথম লুকানো ঘর
@5000:  [ vptr = @V_Bkash ]   ← constructor এটি বসিয়েছিল
@5008:  [ Bkash-এর নিজের ডেটা ... ]
@V_BkashBkash class-এর vtable
[0]  ~Bkash()
[1]  Bkash::fee     ← compiler আগেই জানত: fee = ঘর 1
ঘর [1]একটি ফাংশনের ঠিকানা
ঠিকানা পাওয়া গেল: Bkash::fee-এর কোড যেখানে আছে
(Nagad object হলে এখানে পেত Nagad::fee — বাকি ধাপ হুবহু এক)
পরোক্ষ ডাকindirect call
call [ঠিকানা]  with this = @5000, t = 5000
=> return 5000 * 18 / 1000  =  90
  1. শুধু একটি ঠিকানা। p-এর কাছে আছে object-এর ঠিকানা। compile-এর সময় compiler জানত না এটি কোন ধরনের Payment হবে — জানার দরকারও নেই, কারণ বাকি কাজের নিয়ম সব Payment-এর জন্য এক।
  2. vptr পড়া। object-এর শুরু থেকে লুকানো pointer-টি পড়া হলো। object তৈরির সময় Bkash-এর constructor এখানে Bkash-এর vtable-এর ঠিকানা লিখে রেখেছিল। এই মুহূর্তেই “আসল টাইপ” জানা হয়ে যায়।
  3. vtable-এ যাওয়া। vptr ধরে পৌঁছানো গেল Bkash class-এর তালিকায়। প্রোগ্রামে যত Bkash object-ই থাকুক, সবার vptr এই একই তালিকা দেখায়।
  4. নির্দিষ্ট ঘর পড়া। fee সবসময় ঘর [1]-এ — Payment, Bkash, Nagad, Card সবার তালিকায়। তাই কোনো খোঁজাখুঁজি বা তুলনা নেই, সরাসরি ঘর [1] থেকে ফাংশনের ঠিকানা নেওয়া হলো। switch-এর মতো “এটা কি Bkash? Nagad?” প্রশ্ন একবারও করতে হয়নি।
  5. পরোক্ষ ডাক। পাওয়া ঠিকানায় ফাংশনটি ডাকা হলো, this হিসেবে object-এর ঠিকানা দিয়ে। পুরো বাড়তি কাজ: দুটি মেমরি-পড়া আর একটি indirect call. এটাই একটি virtual ডাকের পুরো “দাম”।

খরচ কত — আর কখন সেটা গুরুত্বপূর্ণ

virtual ফাংশনের দাম তিন রকম। এক, প্রতি ডাকে একটি বাড়তি পরোক্ষ ধাপ — আধুনিক CPU-তে সাধারণত কয়েক ন্যানোসেকেন্ডের কম, বিশেষ করে যখন CPU আগের অভিজ্ঞতা থেকে লাফের গন্তব্য ঠিক অনুমান করতে পারে। দুই, প্রতিটি object-এ একটি বাড়তি pointer — ৬৪-বিট সিস্টেমে সাধারণত ৮ বাইট। কোটি কোটি ছোট object হলে এটি চোখে পড়ে। তিন, সবচেয়ে বড়টি: compiler সাধারণত virtual ডাককে inline করতে পারে না, কারণ কোন ফাংশন চলবে তা সে জানে না। ছোট ফাংশন inline হলে তার চারপাশের কোড একসাথে অপ্টিমাইজ হয়; virtual ডাক সেই সুযোগ কেড়ে নেয়।

বাস্তবে বেশিরভাগ প্রোগ্রামে এই খরচ চোখেই পড়ে না — একটি পেমেন্ট প্রক্রিয়ায় নেটওয়ার্কে যে সময় লাগে, তার তুলনায় virtual ডাক নগণ্য। খরচ টের পাওয়া যায় শুধু খুব ঘন লুপে, যেখানে লাখ লাখ ছোট object-এ ক্ষুদ্র কোনো virtual ফাংশন বারবার ডাকা হয় — যেমন গেম ইঞ্জিন বা সিমুলেশনে। সেখানে মেপে দেখে (পর্ব ৪৯) তবেই বিকল্প ভাবুন। অনুমানে virtual বাদ দেবেন না, মেপে দেখে দেবেন।

C জানলে

C-তে virtual নেই, কিন্তু একই ধারণা হাতে তৈরি করা যায়: একটি struct-এ ফাংশন pointer রাখা, আর প্রতিটি “টাইপ” নিজের ফাংশনগুলো বসিয়ে দেয়। Linux kernel-এর struct file_operations ঠিক এমনই একটি হাতে-বানানো vtable. পার্থক্য হলো, C++-এ compiler নিজেই তালিকা বানায়, vptr বসায় আর টাইপ মিলিয়ে দেখে — ভুল ঘরে ভুল ফাংশন বসার সুযোগ থাকে না। হাতে-বানানো সংস্করণটি দেখতে C কোর্সের ফাংশন পয়েন্টার ও callback পর্ব পড়ুন।

৭ · Constructor-এ virtual ডাক, আর covariant return

একটি প্রশ্ন দিয়ে শুরু করুন: base class-এর constructor থেকে একটি virtual ফাংশন ডাকলে কোন সংস্করণ চলবে? উত্তর অনেককে অবাক করে — base-এর সংস্করণ। কারণটা যুক্তিসংগত। derived object তৈরি হয় ভেতর থেকে বাইরে: আগে base অংশ, তারপর derived অংশ (পর্ব ২১ আর ২৪)। base-এর constructor চলার সময় derived-এর সদস্যরা এখনো তৈরিই হয়নি। তখন derived-এর ফাংশন চালাতে দিলে সেটি অপ্রস্তুত ডেটা ছুঁয়ে ফেলত। তাই C++ নিয়ম করেছে: constructor বা destructor চলার সময় object-এর dynamic type ধরা হয় যে class-এর constructor/destructor চলছে, সেটি। একটি বিজ্ঞপ্তি পাঠানোর ছোট উদাহরণে দেখুন — এখানে একটি non-virtual ফাংশনও রাখা আছে, যাতে static binding-ও চোখে দেখা যায়:

#include <print>

struct Notifier {
    Notifier() { std::println("ctor sees: {}", channel()); }  // virtual call in ctor
    virtual ~Notifier() = default;
    virtual const char* channel() const { return "base"; }
    const char* tag() const { return "generic"; }             // NOT virtual
    void send() const { std::println("[{}, {}] sent", channel(), tag()); }
};
struct Sms : Notifier {
    const char* channel() const override { return "SMS"; }
    const char* tag() const { return "sms-tag"; }             // hides, does not override
};

int main() {
    Sms s;
    const Notifier& n = s;
    n.send();
    std::println("direct: {} / via base ref: {}", s.tag(), n.tag());
}

আউটপুট তিন লাইন: ctor sees: base, [SMS, generic] sent, আর direct: sms-tag / via base ref: generic. প্রথম লাইন: Sms তৈরির সময় Notifier-এর constructor channel() ডেকেছে, আর পেয়েছে base-এর সংস্করণ — তখনো object-টি “Sms” হয়ে ওঠেনি। দ্বিতীয় লাইন: তৈরি শেষে send()-এর ভেতরে একই channel() ডাক এবার SMS দিল, কিন্তু non-virtual tag() দিল generic — কারণ send Notifier-এর ফাংশন, আর সেখানে tag()-এর static type Notifier. তৃতীয় লাইনে একই object-এ দুইভাবে tag(): সরাসরি Sms দিয়ে ডাকলে Sms-এর, base reference দিয়ে ডাকলে base-এর। virtual না হলে “কে চলবে” নির্ভর করে আপনি কোন নামে ডাকছেন তার ওপর — object-এর ওপর নয়।

constructor-এর ভেতরে ঠিক কী ঘটে, vptr-এর চোখ দিয়ে দেখুন:

1Sms s;                                  // তৈরি শুরু2  Notifier() { println(channel()); }     // base অংশ আগে3  Sms() { }                             // তারপর derived অংশ4n.send();                               // channel() + tag()5}                                       // ~Sms, তারপর ~Notifier
object s-এর অবস্থা (সাধারণ বাস্তবায়নে)
কোন অংশ চলছে
vptr দেখায়
channel() দিলে
  1. তৈরি শুরু। Sms s; চলতেই পুরো object-এর জন্য মেমরি নেওয়া হলো। কিন্তু object এখনো কোনো টাইপ হিসেবে “জীবিত” নয় — constructor-রা এখনো চলেনি।
  2. base-এর constructor আগে। Notifier-এর constructor শুরুতেই vptr-এ Notifier-এর vtable বসায়। তাই তার ভেতরে channel() ডাক vtable দেখে পায় Notifier::channel — আউটপুট base. Sms-এর সদস্যরা এখনো তৈরি হয়নি, তাই এটাই নিরাপদ আচরণ।
  3. এবার derived অংশ। Sms-এর constructor চলার শুরুতে vptr বদলে Sms-এর vtable হয়। এই মুহূর্ত থেকে object পুরোপুরি একটি Sms. constructor-এর ক্রম আর vptr-এর বদল — দুটি একসাথে হাঁটে।
  4. স্বাভাবিক ব্যবহার। n.send()-এর ভেতরে virtual channel() এবার দেয় SMS. কিন্তু tag() virtual নয় — compiler send-এর ভেতরে সরাসরি Notifier::tag বসিয়ে দিয়েছে, তাই আসে generic.
  5. ধ্বংস উল্টো পথে। scope শেষে আগে ~Sms, তারপর ~Notifier. ~Notifier চলার সময় vptr আবার Notifier-এর vtable-এ ফেরে, কারণ Sms অংশ ইতিমধ্যে ধ্বংস। তাই destructor থেকে virtual ডাকলেও base সংস্করণ চলে। নিয়ম মনে রাখুন: constructor আর destructor-এ virtual ডাক “নিচের দিকে” যায় না।

constructor থেকে virtual ফাংশন ডেকে derived-এর আচরণ আশা করা একটি ক্লাসিক ভুল — Java বা C#-এ derived সংস্করণ চলে, তাই সেসব ভাষা থেকে আসা প্রোগ্রামাররা প্রায়ই এখানে হোঁচট খান। আর base-এ ফাংশনটি যদি pure virtual হয় (কোনো body নেই — পর্ব ২৬-এ দেখবেন), constructor থেকে তা ডাকা undefined behavior; অনেক সময় প্রোগ্রাম “pure virtual method called” বলে বন্ধ হয়ে যায়। derived-এর তথ্য দরকার হলে সেটি constructor-এর প্যারামিটার হিসেবে পাঠান, অথবা তৈরির পরে আলাদা একটি ফাংশন ডাকুন।

Covariant return — override-এ একটু নির্দিষ্ট ফেরত-টাইপ

সাধারণত override-এর ফেরত-টাইপ base-এর মতোই হতে হয়। একটি ছাড় আছে: base যদি কোনো class-এর pointer বা reference ফেরত দেয়, derived তার নিজের, আরও নির্দিষ্ট টাইপের pointer বা reference ফেরত দিতে পারে। একে বলে covariant return type.

struct Shape {
    virtual ~Shape() = default;
    virtual Shape* clone() const = 0;       // = 0-এর অর্থ পর্ব ২৬-এ
};
struct Circle : Shape {
    Circle* clone() const override { return new Circle(*this); }  // Shape* নয়, Circle*
};
// কিন্তু unique_ptr<Circle> আর unique_ptr<Shape> covariant নয় —
// smart pointer ফেরত দিতে চাইলে দুই জায়গাতেই std::unique_ptr<Shape> লিখতে হয়।

এর লাভ হলো, কেউ যদি সরাসরি একটি Circle-এর ওপর clone() ডাকেন, তিনি cast ছাড়াই Circle* পান। তবে খেয়াল করুন, এখানে raw new আছে — মালিকানা কে নেবে, তা কোড দেখে বোঝা যায় না। আধুনিক কোডে clone সাধারণত std::unique_ptr<Shape> ফেরত দেয়, আর covariance-এর সুবিধাটুকু ছেড়ে দেয়। ধারণাটি জেনে রাখুন, কিন্তু খুব বেশি দরকার পড়বে না।

৮ · dynamic_cast, polymorphic container আর compile-time বিকল্প

dynamic_cast — “এটা কি আসলে একটি Card?”

কখনো কখনো base pointer হাতে নিয়ে জানতে ইচ্ছা করে, এটি আসলে কোন derived টাইপ। base থেকে derived-এর দিকে নামাকে বলে downcast, আর নিরাপদে করার যন্ত্র dynamic_cast. এটি চলার সময় object-এর টাইপের তথ্য (RTTI) দেখে যাচাই করে:

void audit(const Payment& p) {
    if (auto* c = dynamic_cast<const Card*>(&p)) {   // ভুল টাইপ হলে nullptr
        c->checkChip();                               // শুধু Card-এর ফাংশন
    }
}
// reference-এ cast ব্যর্থ হলে nullptr দেওয়ার উপায় নেই, তাই std::bad_cast ছোড়ে:
// const Card& c = dynamic_cast<const Card&>(p);

dynamic_cast কাজ করে শুধু polymorphic টাইপে — যে class-এ অন্তত একটি virtual ফাংশন আছে। static_cast দিয়েও downcast লেখা যায়, কিন্তু সেটি কিছু যাচাই করে না; ভুল টাইপ হলে undefined behavior. তাই অনিশ্চিত হলে dynamic_cast, আর নিশ্চিত হলে… আসলে প্রশ্নটা অন্যখানে।

নকশার গন্ধ

কোডে বারবার dynamic_cast দরকার হলে সেটি প্রায়ই খারাপ নকশার লক্ষণ (design smell). if (dynamic_cast<Bkash*>) ... else if (dynamic_cast<Nagad*>) ... — এটি প্রথম অংশের সেই type switch-ই, শুধু নতুন পোশাকে। নতুন ওয়ালেট এলে আবার সব জায়গায় নতুন শাখা যোগ করতে হবে। ভালো সমাধান সাধারণত হলো প্রশ্নটিকে একটি virtual ফাংশন বানানো: “এটা কি Card?” না জিজ্ঞেস করে base-এ virtual void audit() const রাখুন, আর প্রত্যেক টাইপ নিজের যাচাই নিজে করুক। “জিজ্ঞেস কোরো না, বলে দাও” (Tell, don’t ask). dynamic_cast নিষিদ্ধ নয় — বাইরের লাইব্রেরির টাইপের সাথে কাজ করতে বা বিরল বিশেষ ক্ষেত্রে এটি দরকারি — কিন্তু প্রতিটি ব্যবহারের পেছনে একটি কারণ থাকা উচিত।

নানা টাইপ একসাথে — std::vector<std::unique_ptr<Base>>

polymorphic object-দের একটি তালিকায় রাখতে চাইলে std::vector<Payment> চলবে না — প্রতিটি উপাদান slice হয়ে সাধারণ Payment হয়ে যাবে। raw pointer-এর vector (std::vector<Payment*> আর হাতে new) চলে, কিন্তু কে delete করবে তা অস্পষ্ট, আর একটি exception-ই leak ঘটাতে পারে। আধুনিক উত্তর পর্ব ১৭-এর std::unique_ptr: std::vector<std::unique_ptr<Payment>>, আর object বানানো std::make_unique দিয়ে — ঠিক ৩ নম্বর অংশের কোডে যেমন। vector ধ্বংস হলে প্রতিটি unique_ptr তার object মোছে, আর virtual destructor নিশ্চিত করে যে প্রতিটির আসল টাইপের destructor চলছে। দুটি নিয়ম একসাথে কাজ করে: unique_ptr মালিকানা সামলায়, virtual destructor সঠিক পরিষ্কার নিশ্চিত করে। এক নিয়ম বাদ দিলে অন্যটি একা যথেষ্ট নয়।

যখন virtual-এর দরকার নেই — compile-time বিকল্প (এক ঝলক)

run-time polymorphism-এর আসল শক্তি হলো, টাইপের তালিকা খোলা: যে কেউ, যেকোনো সময়, নতুন derived class যোগ করতে পারে — এমনকি একটি plugin হিসেবে, আপনার কোড আবার compile না করেই। কিন্তু সবসময় এই নমনীয়তা দরকার হয় না। তখন C++-এর কিছু compile-time পথ আছে:

Template (পর্ব ২৮)

template<class P> int checkout(const P& p) — প্রতিটি টাইপের জন্য compiler আলাদা সংস্করণ বানায়। কোনো vtable নেই, সব inline হতে পারে। দাম: টাইপ জানা থাকতে হয় compile-এর সময়েই, আর এক vector-এ নানা টাইপ রাখা যায় না।

static polymorphism

std::variant (পর্ব ৩৮)

std::variant<Bkash, Nagad, Card> — টাইপের তালিকা বন্ধ ও নির্দিষ্ট হলে। heap লাগে না, inheritance লাগে না; std::visit দিয়ে প্রতিটি টাইপ সামলানো হয়, আর কোনো টাইপ বাদ পড়লে compiler ধরে।

closed set of types

CRTP

একটি template কৌশল, যেখানে derived class নিজেকেই base-এর template argument হিসেবে দেয়: struct Bkash : PaymentBase<Bkash>. base তখন compile-এর সময়েই derived-এর ফাংশন ডাকতে পারে। লাইব্রেরি-লেখকদের প্রিয়, শুরুতে দরকার নেই।

curiously recurring

কোনটি কখন? নতুন টাইপ বাইরের কেউ যোগ করবে, বা টাইপ চলার সময় ঠিক হয় (ব্যবহারকারী মেনু থেকে বেছে নেন) — virtual. টাইপের তালিকা ছোট আর আপনার নিয়ন্ত্রণে — std::variant ভাবুন। গতির শেষ বিন্দুটুকু দরকার আর টাইপ compile-এর সময়েই জানা — template বা CRTP. বাকি পর্বগুলোতে এগুলো একে একে আসবে।

virtual শব্দটি C++-এর আবিষ্কার নয়। ১৯৬০-এর দশকে নরওয়েতে Ole-Johan Dahl আর Kristen Nygaard তৈরি করেন Simula 67 — প্রথম ভাষা, যেখানে class, subclass আর “virtual procedure” একসাথে ছিল। Bjarne Stroustrup কেমব্রিজে PhD গবেষণায় Simula ব্যবহার করে এর নকশায় মুগ্ধ হন, কিন্তু এর ধীরগতিতে হতাশ হন। ১৯৭৯ সালে Bell Labs-এ তিনি শুরু করেন “C with Classes”, আর ভাষাটি যখন C++ নাম পায় (১৯৮৩ সালের দিকে), প্রায় তখনই virtual ফাংশন যোগ হয় — Simula-র ধারণা, C-এর গতিতে। আর override ও final এসেছে অনেক পরে, C++11-এ।

পরীক্ষা ও ইন্টারভিউতে
  • static আর dynamic binding-এর পার্থক্য কী? — static-এ compile-এর সময় ঘোষিত টাইপ দেখে ফাংশন ঠিক হয়; dynamic-এ চলার সময় object-এর আসল টাইপ দেখে, virtual ফাংশন reference/pointer দিয়ে ডাকলে।
  • base class-এর destructor virtual কেন? — base pointer দিয়ে derived object delete করলে virtual না হলে undefined behavior; virtual হলে আগে derived, তারপর base destructor চলে।
  • constructor থেকে virtual ফাংশন ডাকলে কোনটি চলে? — যে class-এর constructor চলছে তার সংস্করণ (base constructor-এ base-এরটি), কারণ derived অংশ তখনো তৈরি হয়নি।
  • vtable আর vptr কয়টি থাকে? — প্রচলিত বাস্তবায়নে virtual ফাংশনওয়ালা প্রতিটি class-এর একটি vtable, আর প্রতিটি object-এ একটি vptr (সরল উত্তরাধিকারে); এটি standard-এর নিয়ম নয়, compiler-এর কৌশল।
  • virtual ফাংশনের default argument কোথা থেকে আসে? — ঘোষিত (static) টাইপ থেকে, অর্থাৎ ফাংশন derived-এর চললেও default মান base-এর ঘোষণার হতে পারে; তাই virtual ফাংশনে default argument এড়িয়ে চলুন।

৯ · AI-এর যুগে — polymorphic কোডের ভুল ধরা

AI টুল class hierarchy বেশ দ্রুত লিখে দেয়, আর কোড প্রায়ই compile হয়। সমস্যা হলো, polymorphism-এর ভুলগুলোর বড় অংশ compile হয় এবং চলেও — শুধু ভুল ফাংশন চালায়, বা চুপচাপ মেমরি ফেলে রাখে। অনলাইনে C++11-এর আগের প্রচুর কোড আছে, যেখানে override নেই আর হাতে new/delete, তাই AI প্রায়ই সেই রীতি অনুসরণ করে। আরেকটি প্রবণতা: “টাইপ দেখে কাজ” — dynamic_cast-এর শিকল বা enum-এর switch — কারণ সেটি ছোট উদাহরণে সরল দেখায়। সাধারণ ভুলগুলো:

AI যা করেসমস্যাযা হওয়া উচিত
derived-এ override নেই, বা আবার virtual লেখেস্বাক্ষর না মিললে নীরবে নতুন ফাংশন; base-এর সংস্করণ চলেপ্রতিটি override-এ override
base class-এ virtual destructor নেইbase pointer দিয়ে delete করলে undefined behavior, প্রায়ই leakvirtual ~Base() = default;
std::vector<Base*> আর push_back(new Derived)মালিক অস্পষ্ট; delete ভুললে leak, exception-এ leakstd::vector<std::unique_ptr<Base>> + std::make_unique
dynamic_cast-এর শিকল বা type ফিল্ডের switchনতুন টাইপ এলে সব জায়গায় শাখা যোগ; একটি বাদ পড়লে নীরব ভুলআচরণটিকে virtual ফাংশন বানান
polymorphic object by value নেয়: void pay(Payment p)object slicing — সবসময় base-এর আচরণconst Payment& বা Payment&
constructor থেকে virtual ফাংশন ডেকে derived-এর কাজ আশা করেbase সংস্করণ চলে; pure virtual হলে UBতথ্য constructor-এর প্যারামিটারে দিন
প্রতিটি ছোট ফাংশন virtual, “ভবিষ্যতের জন্য”অকারণে খরচ, নকশা অস্পষ্টশুধু যা সত্যিই বদলাবে, তাই virtual

AI-কে class hierarchy লিখতে বলার সময় নিয়মগুলো আগেই দিয়ে দিন, আর প্রতিটি নকশা-সিদ্ধান্তের কারণ জানতে চান:

আমি C++23 শিখছি (g++, -Wall -Wextra). একটি বিজ্ঞপ্তি পাঠানোর ছোট সিস্টেম লেখো: base class Notifier, আর তিনটি derived — SmsNotifier, EmailNotifier, PushNotifier. প্রত্যেকের send(const std::string& msg) আর cost() আলাদা (cost পূর্ণসংখ্যায়, পয়সায়)। নিয়ম: ১. base-এ virtual destructor থাকবে; derived-এর প্রতিটি override-এ override শব্দ লিখবে, আর যে class থেকে আর কিছু বানানো হবে না, সেখানে final. ২. কোথাও new বা delete নয়; সব notifier থাকবে std::vector<std::unique_ptr<Notifier>>-এ, বানাবে std::make_unique দিয়ে। ৩. কোথাও dynamic_cast, typeid বা টাইপের switch থাকবে না — টাইপ-নির্ভর সব আচরণ virtual ফাংশনে। ৪. যে ফাংশন একটি notifier ব্যবহার করে, সে const Notifier& নেবে, by value নয়। ৫. constructor থেকে কোনো virtual ফাংশন ডাকবে না। কোডের পরে একটি তালিকা দাও: কোন ফাংশন virtual আর কেন, কোনটি non-virtual আর কেন, আর নতুন একটি WhatsAppNotifier যোগ করতে কোন কোন ফাইল বা লাইন বদলাতে হবে।

AI-এর polymorphic কোড যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১“virtual” শব্দটি এল Simula থেকে। একটি সিমুলেশন ভাষায় কেন এই ধারণার জন্ম হলো, আর C++ এটি কীভাবে বদলে নিল?

Simula তৈরি হয়েছিল নরওয়ের Norwegian Computing Center-এ, Ole-Johan Dahl আর Kristen Nygaard-এর হাতে — প্রথমে Simula I, তারপর ১৯৬৭ সালে Simula 67. লক্ষ্য ছিল বাস্তব ব্যবস্থার সিমুলেশন: একটি বন্দরে জাহাজ আসা-যাওয়া, একটি কারখানার লাইনে কাজের সারি, একটি নেটওয়ার্কে বার্তার চলাচল। এমন সিমুলেশনে স্বাভাবিকভাবেই অনেক “ধরনের” জিনিস থাকে, যাদের মধ্যে মিল আছে কিন্তু আচরণ আলাদা — সব যানবাহন চলে, কিন্তু ট্রাক আর জাহাজ ভিন্নভাবে চলে। Simula 67 এর জন্য আনে class, subclass, আর virtual procedure: base class-এ একটি procedure-কে virtual ঘোষণা করলে subclass তার নিজের সংস্করণ দিতে পারে, আর সাধারণ কোড যেকোনো subclass-এর object-এর ওপর সেটি ডাকলে ঠিক সংস্করণটি চলে। অর্থাৎ আজকের object-oriented programming-এর মূল যন্ত্রগুলো একটি সিমুলেশন সমস্যা থেকেই জন্মেছে — বাস্তব জগতের “একই প্রশ্ন, ভিন্ন উত্তর” কোডে ধরার চেষ্টা থেকে।

Bjarne Stroustrup কেমব্রিজে PhD গবেষণার সময় একটি বিতরণ-ব্যবস্থার সিমুলেশন লিখতে Simula ব্যবহার করেন। তিনি পরে বারবার লিখেছেন, Simula-র class আর virtual-এর ধারণা তাঁকে বড় প্রোগ্রাম সাজাতে অসাধারণ সাহায্য করেছিল, কিন্তু প্রোগ্রামটি এত ধীর চলছিল যে বাস্তব আকারে চালানো সম্ভব হচ্ছিল না। শেষে তাঁকে কাজটি অনেক নিচু স্তরের একটি ভাষায় আবার লিখতে হয়। এই অভিজ্ঞতা থেকেই C++-এর মূল লক্ষ্য তৈরি হয়: Simula-র মতো সংগঠনের ক্ষমতা, কিন্তু C-এর মতো গতি।

C++ তাই virtual-কে নতুন করে গড়ে। Simula-তে object-এর ব্যবস্থাপনা ছিল garbage collector-নির্ভর, আর ভাষার অনেক কিছুর চলার-সময়ের খরচ ছিল বেশি। C++-এ virtual ফাংশন হলো সম্পূর্ণ ঐচ্ছিক, আর বাস্তবায়ন এতটাই সরল — একটি vptr, একটি টেবিল, একটি পরোক্ষ ডাক — যে হাতে লেখা C-এর ফাংশন-pointer কোডের চেয়ে খুব একটা ধীর নয়। যিনি virtual ব্যবহার করেন না, তিনি এর কোনো দাম দেন না। এই নকশাই পরে Java, C# আর আরও অনেক ভাষার “method dispatch”-এর মডেল হয়ে ওঠে।

মূল উপলব্ধি: polymorphism কোনো তাত্ত্বিক বিলাসিতা নয় — এটি এসেছে বাস্তব ব্যবস্থার বৈচিত্র্য সামলানোর প্রয়োজন থেকে। আর C++-এর অবদান ধারণাটি আবিষ্কার করা নয়, বরং এটিকে এত সস্তা করা যে সিস্টেম প্রোগ্রামিংয়েও নির্ভয়ে ব্যবহার করা যায়।

প্র ০২Java-তে প্রায় সব method নিজে থেকেই virtual, অথচ C++-এ ডিফল্ট হলো non-virtual. দুই ভাষা উল্টো সিদ্ধান্ত নিল কেন — আর কোনটি “ঠিক”?

C++-এর সিদ্ধান্তের পেছনে আছে তার মূলনীতি — zero-overhead: “যা ব্যবহার করেন না, তার দাম দেবেন না।” প্রতিটি ফাংশন virtual হলে প্রতিটি object-এ vptr লাগত, এমনকি একটি সাধারণ struct Point { int x, y; }-এও, যদি তাতে একটি member function থাকে। তখন এটি আর C-এর struct-এর সাথে মেমরিতে মিলত না, C লাইব্রেরিকে পাঠানো যেত না, আর প্রতিটি ছোট getter ডাক inline না হয়ে পরোক্ষ লাফ হতো। ১৯৮০-র দশকের ধীর মেশিনে, আর আজকের ঘন লুপে, এই দাম অনেক। তাই C++ বলে: polymorphism চাইলে আপনি স্পষ্ট করে virtual লিখুন; বাকি সব ফাংশন সাধারণ, দ্রুত, সরাসরি ডাক।

Java ১৯৯০-এর দশকে অন্য অগ্রাধিকার নিয়ে এল। এটি চলে একটি virtual machine-এর ওপর, যার JIT compiler চলার সময় দেখতে পায় কোন class আসলে লোড হয়েছে। কোনো method-এর একটিই সংস্করণ থাকলে JIT সেটিকে সরাসরি ডাক বানিয়ে inline-ও করে ফেলতে পারে, পরে নতুন subclass লোড হলে সিদ্ধান্ত বাতিল করে। ফলে “সবকিছু virtual” রাখার দাম Java-তে অনেক কমে যায়। আর Java-র নকশাকারদের লক্ষ্য ছিল নমনীয়তা আর সরলতা: প্রোগ্রামারকে আগে থেকে ভাবতে হয় না কোনটি বদলানো যাবে। বদলানো ঠেকাতে চাইলে final লেখেন। (static, private আর final method virtual নয়।)

মজার ব্যাপার হলো, পরে আসা C# (২০০০) এখানে C++-এর পথে গেছে: ডিফল্ট non-virtual, বদলানোর জন্য base-এ virtual আর derived-এ বাধ্যতামূলক override. এর পেছনে গতির চেয়ে বড় যুক্তি ছিল নকশার স্থায়িত্ব — যে method override করা যায়, সেটি একটি প্রতিশ্রুতি। লাইব্রেরি-লেখক পরে এর আচরণ বদলালে অজানা subclass ভেঙে যেতে পারে (একে “fragile base class” সমস্যা বলে)। তাই কোনটি বদলানো যাবে, তা সচেতনভাবে বেছে নেওয়া ভালো। Kotlin-ও ডিফল্টভাবে class আর method “final” রাখে।

কোনটি ঠিক? প্রেক্ষাপটের ওপর নির্ভর করে। C++-এর ডিফল্ট গতি আর C-সামঞ্জস্য রক্ষা করে, আর নকশায় স্পষ্টতা আনে। Java-র ডিফল্ট পরীক্ষার জন্য mock বানানো আর framework-এর জন্য সুবিধাজনক, কিন্তু অনিচ্ছাকৃত override-এর ঝুঁকি রাখে।

মূল উপলব্ধি: ডিফল্ট হলো একটি ভাষার মূল্যবোধের ঘোষণা। C++ বলে “দাম দেবেন জেনে-বুঝে”, Java বলে “নমনীয়তা আগে, দাম runtime মেটাবে” — আর দুটি ভাষাই নিজের প্রেক্ষাপটে যুক্তিসংগত।

প্র ০৩Compiler কি কখনো virtual ডাককে সাধারণ ডাক বানিয়ে ফেলে? devirtualization কীভাবে কাজ করে, আর final এখানে কী সাহায্য করে?

হ্যাঁ, আর আধুনিক compiler এতে বেশ দক্ষ। virtual ডাকের আসল খরচ পরোক্ষ লাফটি নয় — সেটি আজকের CPU-তে প্রায়ই সস্তা — বরং inline করতে না পারা। তাই compiler চেষ্টা করে প্রমাণ করতে যে একটি নির্দিষ্ট ডাকে আসলে কোন ফাংশন চলবে। প্রমাণ পেলে সে vtable-এর পথ বাদ দিয়ে সরাসরি ডাক বসায়, আর তারপর inline-ও করতে পারে। এই কৌশলকে বলে devirtualization. GCC-তে -O2 থেকে এটি চালু থাকে (-fdevirtualize).

কখন প্রমাণ সহজ? প্রথমত, object যখন সরাসরি দেখা যাচ্ছে: Bkash b; b.fee(5000); — এখানে b-এর টাইপ নিশ্চিতভাবে Bkash, virtual হলেও static ডাক। দ্বিতীয়ত, compiler ডেটার প্রবাহ অনুসরণ করে দেখতে পারলে: একই ফাংশনের ভেতরে auto p = std::make_unique<Bkash>(); p->fee(5000); — p কোথা থেকে এসেছে, সে জানে। তৃতীয়ত, final: Card যদি final হয়, তাহলে একটি Card& বা Card* দিয়ে ডাকলে আসল object Card ছাড়া আর কিছু হতেই পারে না — কারণ Card থেকে কোনো class তৈরি নিষেধ। তাই compiler নির্ভয়ে সরাসরি Card::fee ডাকে। একইভাবে একটি ফাংশন final হলে নিচের দিকে আর কোনো override নেই।

আরও উচ্চাভিলাষী কৌশলও আছে। link-time optimization (LTO) পুরো প্রোগ্রাম একসাথে দেখে বুঝতে পারে, কোনো base-এর আসলে একটিই derived class আছে। আর speculative devirtualization-এ compiler অনুমান করে “এখানে সম্ভবত Bkash” — কোডে একটি দ্রুত পরীক্ষা বসায়: vptr যদি Bkash-এর vtable হয়, তাহলে inline করা Bkash::fee, নইলে সাধারণ virtual ডাক। অনুমান ঠিক হলে লাভ, ভুল হলেও প্রোগ্রাম সঠিক থাকে। Java-র JIT একই কাজ চলার সময়ের প্রকৃত পরিসংখ্যান দেখে করে।

সীমাও আছে: object যদি অন্য ফাইল বা লাইব্রেরি থেকে আসে, বা একটি vector-এ নানা টাইপ মেশানো থাকে, তখন compiler সাধারণত কিছু প্রমাণ করতে পারে না — আর তখনই virtual ডাক তার আসল কাজ করে।

মূল উপলব্ধি: “virtual মানে ধীর” — এটি একটি অর্ধসত্য। যেখানে টাইপ আসলে জানা, compiler প্রায়ই খরচটি মুছে দেয়; আর final লিখে আপনি তাকে সেই প্রমাণ নিজেই দিয়ে দিতে পারেন। খরচ থাকে শুধু সেখানে, যেখানে নমনীয়তা সত্যিই কাজে লাগছে।

প্র ০৪override এল মাত্র C++11-এ — virtual-এর প্রায় তিন দশক পরে। এতদিন এর অভাবে কোন বাগগুলো হতো, আর এটি কেন “keyword” নয়, বরং একটি বিশেষ শনাক্তকারী?

C++98/03-এ derived class-এ base-এর মতো একটি ফাংশন লিখলেই সেটি override হতো — যদি নাম, প্যারামিটারের টাইপ আর const হুবহু মিলত। না মিললে? কোনো অভিযোগ নেই; derived-এ একটি নতুন, আলাদা ফাংশন তৈরি হতো, আর base reference দিয়ে ডাকলে চলত base-এর পুরোনো সংস্করণ। এই নীরবতা থেকে কয়েক ধরনের বাগ নিয়মিত হতো। (১) const বাদ পড়া — base-এ void draw() const, derived-এ void draw(). (২) প্যারামিটারের টাইপের সূক্ষ্ম পার্থক্য — int বনাম long, বা const std::string& বনাম std::string. (৩) বানান ভুল বা বড়-ছোট হাতের অক্ষরের পার্থক্য। (৪) সবচেয়ে কপট: base class-এর লেখক পরে ফাংশনের স্বাক্ষর বদলালেন — হয়তো একটি প্যারামিটার যোগ করলেন। সব derived class-এর ফাংশন রাতারাতি “অনাথ” হয়ে গেল, কিন্তু কোড compile হতে থাকল। বড় framework-এ, যেখানে base এক দলের আর derived আরেক দলের হাতে, এটি মাসের পর মাস ধরা না পড়তে পারত।

C++11 দুটি শব্দ আনল: override আর final. override একটি দাবি — “এটি অবশ্যই base-এর একটি virtual ফাংশন বদলাচ্ছে” — আর compiler সেটি যাচাই করে। এখন উপরের চার ধরনের বাগই compile error. বিশেষ করে চতুর্থটি: base-এর স্বাক্ষর বদলালেই সব derived-এ error আসে, তাই কেউ পেছনে পড়ে থাকে না।

মজার বিষয় হলো, এরা reserved keyword নয় — standard-এর ভাষায় “identifier with special meaning” (প্রচলিত নাম contextual keyword). শুধু ফাংশন ঘোষণার নির্দিষ্ট জায়গায় এদের বিশেষ অর্থ, অন্য জায়গায় এরা সাধারণ নাম। কারণ: কোটি কোটি লাইনের পুরোনো C++ কোডে কেউ হয়তো int override; বা final নামে ভ্যারিয়েবল বা ফাংশন রেখেছেন। নতুন reserved keyword আনলে সেই সব কোড ভেঙে যেত। C++ কমিটি সবসময় পুরোনো কোডের সামঞ্জস্যকে অনেক গুরুত্ব দেয়, তাই এই আপসের পথ।

একটি স্বাভাবিক প্রশ্ন: তাহলে override বাধ্যতামূলক করা হলো না কেন, C#-এর মতো? আবারও একই কারণ — বাধ্যতামূলক হলে আগের সব কোড ভাঙত। তাই দায়িত্বটা এসেছে টুলের কাছে: GCC-র -Wsuggest-override, Clang-এর -Winconsistent-missing-override (একই class-এ কোথাও override আছে কিন্তু কোথাও নেই — এমন জায়গা ধরে), আর clang-tidy-র modernize-use-override, যা পুরোনো কোডে নিজে থেকে override বসিয়ে দিতে পারে।

মূল উপলব্ধি: ভালো ভাষা-নকশা মানে প্রোগ্রামারের উদ্দেশ্য লিখে রাখার সুযোগ দেওয়া, যাতে compiler সেটি যাচাই করতে পারে। override একটি শব্দে একটি পুরো শ্রেণির নীরব বাগ শেষ করেছে — তাই এটি না লেখার কোনো কারণ নেই।

প্র ০৫CRTP বা static polymorphism দিয়েও “একই ডাক, ভিন্ন আচরণ” পাওয়া যায়, কোনো vtable ছাড়াই। তাহলে virtual কেন? দুটির আসল পার্থক্য কোথায়?

CRTP — Curiously Recurring Template Pattern — নামটি দেন James Coplien, ১৯৯৫ সালে, যখন তিনি লক্ষ্য করেন যে এই অদ্ভুত-দেখতে কৌশলটি অনেকে আলাদা আলাদা ভাবে আবিষ্কার করছেন। ধারণা: base একটি template, আর derived নিজেকেই base-এর template argument হিসেবে দেয় — struct Bkash : PaymentBase<Bkash>. base-এর ভেতরে static_cast<const Derived&>(*this).fee(t) লিখলে compiler compile-এর সময়েই জানে কোন fee ডাকতে হবে। কোনো vptr নেই, কোনো vtable নেই, সব inline হতে পারে। C++23-এর “deducing this” (explicit object parameter) এই কৌশলের অনেক ব্যবহার আরও সরল করেছে।

কিন্তু এখানে একটি মৌলিক পার্থক্য আছে। CRTP-তে PaymentBase<Bkash> আর PaymentBase<Nagad> দুটি সম্পূর্ণ আলাদা টাইপ — তাদের কোনো সাধারণ base নেই। তাই একটি std::vector-এ দুজনকে একসাথে রাখা যায় না, একটি ফাংশন “যেকোনো Payment” নিতে পারে না যদি না সেটিও template হয়। অর্থাৎ static polymorphism-এ সব টাইপ compile-এর সময়েই জানা থাকতে হয়। ব্যবহারকারী চলার সময় মেনু থেকে পেমেন্ট পদ্ধতি বেছে নিলে, বা একটি plugin পরে লোড হলে, CRTP কোনো কাজে আসে না। আর template-ভারী কোড প্রতিটি টাইপের জন্য আলাদা সংস্করণ তৈরি করে, ফলে প্রোগ্রাম বড় হতে পারে, compile ধীর হয়, আর error message দীর্ঘ হয়।

virtual polymorphism এর বিপরীত সুবিধা দেয়: একটি স্থির interface, যার পেছনে টাইপের তালিকা খোলা। checkout-এর কোড একবার compile হয়ে একটি লাইব্রেরিতে থাকতে পারে, আর অন্য একটি দল বছর পরে নতুন পেমেন্ট পদ্ধতি যোগ করতে পারে — checkout আবার compile না করেই। এজন্য GUI framework, গেম ইঞ্জিনের entity, ডিভাইস ড্রাইভারের interface — যেখানে বাইরের কেউ নতুন টাইপ যোগ করবে, সেখানে virtual-ই স্বাভাবিক।

বাস্তবে অভিজ্ঞ C++ কোড প্রায়ই দুটি মেশায়। standard library নিজেই উদাহরণ: std::function ভেতরে চলার-সময়ের dispatch ব্যবহার করে, অথচ STL algorithm-গুলো পুরোপুরি template-ভিত্তিক। একটি সাধারণ কৌশল হলো বাইরে একটি সরল virtual interface রাখা, আর ভেতরের ঘন লুপগুলো template দিয়ে লেখা। পর্ব ২৮-২৯-এ template আর পর্ব ৩৮-এ std::variant শেখার পর আপনি তিনটি পথই হাতে পাবেন।

মূল উপলব্ধি: প্রশ্নটা গতি নয়, প্রশ্নটা — “টাইপের তালিকা কে জানে, আর কখন?” compile-এর সময় সব জানা থাকলে static polymorphism; চলার সময় বা অন্য কেউ ঠিক করলে virtual. সঠিক যন্ত্র বেছে নেওয়াই নকশার দক্ষতা।

অনুশীলন

বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।

  1. আউটপুট অনুমান: struct Shape { virtual int area() const { return 0; } int sides() const { return 0; } }; আর struct Square : Shape { int a = 4; int area() const override { return a * a; } int sides() const { return 4; } };. এবার Square sq; const Shape& r = sq; Shape copy = sq; — r.area(), r.sides(), copy.area() আর sq.sides() কী দেবে?

    r.area() = 16 (virtual, reference দিয়ে — dynamic binding, Square-এর সংস্করণ)। r.sides() = 0 (non-virtual — static type Shape&, তাই Shape-এর সংস্করণ)। copy.area() = 0 (object slicing: copy একটি আসল Shape, Square অংশ কেটে বাদ)। sq.sides() = 4 (সরাসরি Square দিয়ে ডাক, তাই Square-এর লুকানো সংস্করণ)।

  2. বাগ শিকার: একটি AI এই কোড দিয়েছে — class Notifier { public: virtual void send(const std::string& m) const { } }; class Email : public Notifier { public: void send(const std::string& m) { std::println("mail: {}", m); } }; std::vector<Notifier*> list; list.push_back(new Email); for (auto* n : list) n->send("hi");. কয়টি সমস্যা খুঁজে পান, আর mail: hi কি প্রিন্ট হবে?

    তিনটি, আর তাদের একটি জোড়া ফাঁদ। (১) Email-এর send-এ const নেই, তাই এটি override করে না — নতুন একটি ফাংশন। ফলে mail: hi প্রিন্ট হবে না; base-এর খালি send চলবে। override লিখলে compiler তখনই ধরত। (২) Notifier-এ virtual destructor নেই। (৩) raw new আর Notifier*-এর vector — কেউ delete করছে না, leak; std::vector<std::unique_ptr<Notifier>> আর std::make_unique<Email>() লিখুন। জোড়া ফাঁদটি হলো: (২) ঠিক না করে শুধু leak সারাতে delete n; যোগ করলে undefined behavior হতো — base pointer দিয়ে non-virtual destructor-এ delete. তাই (২) আর (৩) একসাথে ঠিক করতে হবে।

  3. নকশা ভাবুন: একটি বিজ্ঞপ্তি ব্যবস্থায় SMS, Email আর Push — তিন ধরনের sender আছে। নিচের কোনগুলো base class-এর virtual ফাংশন হওয়া উচিত, কোনগুলো সাধারণ (non-virtual) ফাংশন, আর কোনগুলো শুধু একটি derived class-এর: (ক) বার্তা পাঠানো (খ) বার্তার দৈর্ঘ্য ১৬০ অক্ষরে কাটা (গ) প্রতিটি পাঠানোর লগ লেখা, একই ফরম্যাটে (ঘ) Email-এর subject ঠিক করা।

    (ক) virtual — প্রতিটি sender ভিন্নভাবে পাঠায়, আর সাধারণ কোড “যেকোনো sender”-কে দিয়ে পাঠাবে। (খ) শুধু SMS-এর নিজস্ব নিয়ম, তাই SmsSender-এর ভেতরের ফাংশন (বা তার send-এর অংশ)। (গ) base-এর non-virtual ফাংশন — নিয়ম সবার জন্য এক, কেউ বদলাক তা চান না; এই non-virtual ফাংশনটি ভেতরে virtual send ডাকতে পারে। (ঘ) শুধু EmailSender-এর ফাংশন; base-এ রাখলে SMS আর Push-কে একটি অর্থহীন ফাংশন বইতে হতো। কোন জিনিস “বদলাতে পারে” আর কোনটি “সবার জন্য এক” — এই ভাগই ভালো hierarchy-র ভিত্তি।

  4. নিজে লিখুন (কোড): উপরের payment_fee.cpp-এ একটি নতুন class Rocket যোগ করুন, যার চার্জ t * 18 / 1000 + 5 (উদাহরণের হার)। তারপর ভেক্টরে একটি Rocket যোগ করুন। লুপের কোনো লাইন কি বদলাতে হলো? আউটপুটের শেষ লাইন কী হবে, আগে অনুমান করুন।

    যোগ করুন: struct Rocket : Payment { int fee(int t) const override { return t * 18 / 1000 + 5; } }; আর main-এ pay.push_back(std::make_unique<Rocket>());. লুপের একটি অক্ষরও বদলাতে হয় না — এটাই polymorphism-এর লাভ। শেষ লাইন: fee on 5000 = 95 (৯০ + ৫)। চাইলে পরীক্ষা করুন: override রেখে const মুছে দিন — compile error আসবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমপ্রচলিত vtable-বাস্তবায়নে C++-এর একটি virtual ডাক p->fee(5000) চলার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. p থেকে object-এর ঠিকানা নেওয়া হয়
  2. object-এর ভেতরের লুকানো vptr পড়া হয়
  3. vptr ধরে object-এর class-এর vtable-এ যাওয়া হয়
  4. vtable-এর fee-এর নির্দিষ্ট ঘর থেকে ফাংশনের ঠিকানা নেওয়া হয়
  5. সেই ঠিকানায় ফাংশন ডাকা হয়, this = object-এর ঠিকানা

C++-এ struct B { void f() const; }; struct D : B { void f() const; }; — D d; const B& r = d; r.f(); লিখলে কোন f চলে?

  • D::f, কারণ আসল object D
  • B::f, কারণ r-এর ঘোষিত টাইপ B
  • Compile error — একই নামের দুটি f
  • Undefined behavior

C++-এ base class-এর destructor virtual না রেখে Base* p = new Derived; delete p; করলে কী হয়?

  • আগে Derived, তারপর Base-এর destructor — সব ঠিকঠাক
  • Compile error
  • নিশ্চিতভাবে শুধু Base-এর destructor চলে, আর কিছু নয়
  • Undefined behavior

C++-এ derived class-এর একটি ফাংশনের শেষে override লেখার প্রধান লাভ কী?

  • ফাংশন-ডাক দ্রুত হয়
  • নিচের class আর ফাংশনটি বদলাতে পারে না
  • স্বাক্ষর না মিললে compiler error দেয়, নীরব ভুল হয় না
  • base-এর ফাংশনটিকে নিজে থেকে virtual বানিয়ে দেয়

C++-এ base class-এর constructor থেকে একটি virtual ফাংশন ডাকা হলো, আর derived class সেটি override করেছে। derived object তৈরির সময় কোন সংস্করণ চলে?

  • base class-এর সংস্করণ
  • derived class-এর সংস্করণ
  • সবসময় undefined behavior
  • Compile error

প্রচলিত C++ বাস্তবায়নে vtable আর vptr কীভাবে থাকে?

  • প্রতিটি object-এর নিজের vtable
  • class-প্রতি একটি vtable, object-প্রতি একটি vptr
  • পুরো প্রোগ্রামে একটিই vtable
  • vptr থাকে শুধু derived class-এ, base-এ নয়

C++-এ নানা ধরনের Shape (Circle, Square…) একটি তালিকায় রেখে প্রতিটির virtual area() ডাকতে হবে। কোন container সবচেয়ে উপযুক্ত?

  • std::vector<Shape>
  • std::vector<Shape*> আর হাতে new
  • std::vector<std::unique_ptr<Shape>>
  • std::vector<Shape&>

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Polymorphism: “একটি Payment”-এর সাথে কথা বলা কোড চলার সময় আসল object-এর আচরণ চালায় — নতুন টাইপ যোগে পুরোনো কোড বদলায় না।
  2. virtual + reference/pointer = dynamic binding; virtual না হলে, বা object by value হলে, ঘোষিত টাইপের ফাংশন চলে (static binding, slicing).
  3. derived-এ সবসময় override; আর বাড়ানো বন্ধ করতে final.
  4. virtual ফাংশন আছে মানে destructor-ও virtual — নইলে base pointer দিয়ে delete করা undefined behavior.
  5. ভেতরে সাধারণত vtable + vptr; constructor/destructor-এ virtual ডাক base-এর সংস্করণ চালায়; বারবার dynamic_cast মানে নকশা ভাবুন; container-এ std::vector<std::unique_ptr<Base>>.

পরের পর্বে: আমাদের base Payment-এর নিজের একটি “নগদ” fee ছিল। কিন্তু যদি base-এর কোনো যুক্তিসংগত নিজস্ব আচরণই না থাকে? = 0 দিয়ে pure virtual ফাংশন, abstract class, আর C++-এ interface নকশা করার নিয়ম।

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

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান