এই পর্বে যা শিখবেন
- Lifetime আর scope-এর পার্থক্য, আর চার রকম storage duration
- Temporary ঠিক কখন জন্মায় ও মরে, আর lifetime extension-এর সীমা
- Dangling-এর পাঁচটি চেনা রূপ — প্রতিটির ছোট উদাহরণ আর ধাপে ধাপে ছবি
- নিরাপদ নকশা: by value ফেরত, RVO, মালিকানা, index, view-এর নিয়ম
- Compiler warning, AddressSanitizer আর AI-এর লেখা কোডে lifetime-এর ভুল ধরা
১ · প্রতিটি object-এর জন্ম আছে, মৃত্যুও আছে
পর্ব ১০-এ আপনি একটি নিয়ম শিখেছিলেন: “reference কখনো তার object-এর চেয়ে বেশি দিন বাঁচবে না।” আর পর্ব ১৫ থেকে ১৭-এ দেখেছেন pointer, heap আর smart pointer. এই পর্বে সেই সব টুকরো এক জায়গায় জোড়া লাগবে, একটি মাত্র প্রশ্নকে ঘিরে: এই object-টি কখন জন্মায়, আর কখন মরে? এই সময়কালের নাম lifetime — বাংলায় আয়ু।
ঢাকার একটি কোচিং সেন্টারে একটি ব্যাচ ভাড়া করা ঘরে ক্লাস করে। ব্যাচের মেয়াদ ছয় মাস। শিক্ষার্থীদের হাতে আছে ঘরের ঠিকানা লেখা একটি কার্ড। ছয় মাস পর ব্যাচ শেষ হলো, ঘর অন্য এক দোকানকে ভাড়া দেওয়া হলো। কার্ডটি কিন্তু এখনো শিক্ষার্থীর পকেটে, ঠিকানাও একই। কার্ড দেখে সেখানে গেলে কী পাবেন? কখনো খালি ঘর, কখনো অন্য কারও দোকান, আর কখনো, ভাগ্যক্রমে, পুরোনো ব্ল্যাকবোর্ডটি তখনো দেয়ালে ঝুলছে। ঠিকানা টিকে আছে, কিন্তু যার ঠিকানা, সে আর নেই। C++-এ এই অবস্থার নাম dangling.
C++-এর প্রতিটি object — একটি int, একটি std::string, একটি Student — মেমরির কোনো এক জায়গায় থাকে। তার আয়ু শুরু হয় যখন জায়গাটি পাওয়া যায় আর object-টি তৈরি (initialize) হয়। আয়ু শেষ হয় যখন object-টি ধ্বংস হয়: class টাইপ হলে তার destructor চলে, তারপর জায়গাটি অন্য কাজে ফেরত যায়। এর পরে সেই জায়গার দিকে যা কিছু “তাকিয়ে” আছে — reference, pointer, iterator, string_view — সব হয়ে যায় অর্থহীন। এগুলো দিয়ে পড়া বা লেখাকে standard বলে undefined behavior: ফল যা খুশি হতে পারে।
Scope আর lifetime — দুটি আলাদা প্রশ্ন
নতুনরা প্রায়ই দুটি ধারণা গুলিয়ে ফেলেন। Scope হলো কোডের কোন অংশে একটি নাম চেনা যায় — এটি compile-এর সময়ের, লেখার প্রশ্ন। Lifetime হলো প্রোগ্রাম চলার সময় object-টি কখন থেকে কখন পর্যন্ত মেমরিতে আছে — এটি সময়ের প্রশ্ন। সাধারণ local ভ্যারিয়েবলে দুটি প্রায় মিলে যায়: block শেষে নামটিও হারায়, object-ও মরে। কিন্তু সব ক্ষেত্রে মেলে না। একটি pointer-এর নাম হয়তো এখনো scope-এ আছে, অথচ যে object-এর দিকে সে তাকিয়ে আছে, সেটি অনেক আগেই মরে গেছে। এই ফাঁকেই বাগ জন্মায়। নিচের ছবিতে একটি object-এর পুরো জীবন দেখুন:
1const Student* p = nullptr;2{3 Student s{"Rahim", 70};4 p = &s;5} // s-এর আয়ু শেষ6std::println("{}", p->name); // UB
| s (object) | |
| নাম s চেনা যায়? | |
| p |
- একটি খালি pointer.
pতৈরি হলো, এখনো কারও দিকে তাকিয়ে নেই।p-এর নিজের আয়ু পুরো বাইরের block জুড়ে। - ভেতরের block শুরু।
{একটি নতুন scope খোলে। এর ভেতরে যা ঘোষণা হবে, তার আয়ু এই block-এর মধ্যেই সীমিত। - জন্ম।
sতৈরি হলো — নাম আর নম্বর নিয়ে। এই মুহূর্ত থেকেsএকটি বেঁচে থাকা object. - Pointer তাকাল।
p = &s;—pএখনs-এর ঠিকানা ধরে আছে। এখানে সব বৈধ:p->nameপড়লে “Rahim” পাবেন। - মৃত্যু — কিন্তু pointer জানে না।
}-এs-এর আয়ু শেষ: destructor চলল, নামের string-এর মেমরি ফেরত গেল।pকোনো খবর পায় না; সে পুরোনো ঠিকানাটাই ধরে আছে। এখনpএকটি dangling pointer. - মৃত object ছোঁয়া।
p->nameপড়া undefined behavior. AddressSanitizer দিয়ে চালালে এটিstack-use-after-scopeনামে ধরা পড়ে। ছাড়া চালালে প্রায়ই “Rahim” দেখায় — কারণ জায়গাটি এখনো কেউ মুছে লেখেনি — আর সেটাই সবচেয়ে বিপজ্জনক, কারণ বাগটি লুকিয়ে থাকে।
Reference, pointer, iterator আর view — এই চারটির কেউই যে object-এর দিকে তাকায়, তাকে বাঁচিয়ে রাখে না। এরা “পর্যবেক্ষক”, মালিক নয়। তাই প্রতিটি পর্যবেক্ষকের পাশে মনে মনে একটি প্রশ্ন লিখে রাখুন: “যার দিকে তাকিয়ে আছি, সে কি আমার চেয়ে বেশি দিন বাঁচবে?” উত্তর “হ্যাঁ” নিশ্চিত না হলে নকশা বদলান।
২ · চার রকম আয়ু — storage duration
কোন object কতক্ষণ বাঁচবে, তা মূলত ঠিক হয় সে কোথায় আর কীভাবে তৈরি হলো তার ওপর। C++ standard একে বলে storage duration, আর এর চারটি রূপ আছে:
Automatic — block-এর সমান
ফাংশন বা block-এর ভেতরের সাধারণ ভ্যারিয়েবল। Block-এ ঢুকলে জন্ম, }-এ মৃত্যু — ঘোষণার উল্টো ক্রমে। সাধারণত stack-এ থাকে। এটিই automatic storage duration.
Static — পুরো প্রোগ্রাম
Global ভ্যারিয়েবল, namespace-এর ভেতরের ভ্যারিয়েবল আর ফাংশনের ভেতরের static ভ্যারিয়েবল। প্রোগ্রাম শেষ হওয়া পর্যন্ত বাঁচে — static storage duration.
Dynamic — মালিকের ইচ্ছায়
new দিয়ে বা std::make_unique-এর ভেতরে heap-এ তৈরি object. নিজে থেকে মরে না; কেউ delete না করা পর্যন্ত বাঁচে। তাই একজন মালিক লাগে — dynamic storage duration.
Thread — thread-এর সমান
thread_local দিয়ে ঘোষিত ভ্যারিয়েবল: প্রতিটি thread-এর নিজস্ব কপি, thread শেষ হলে মরে। এটি thread storage duration; বিস্তারিত পর্ব ৪৫-এ।
Static local — যে ভ্যারিয়েবল ফাংশন শেষেও মরে না
ফাংশনের ভেতরে static লিখলে একটি মজার মিশ্রণ পাওয়া যায়। নামটির scope শুধু ফাংশনের ভেতরে, কিন্তু আয়ু পুরো প্রোগ্রামের। একে বলে static local. প্রথমবার ফাংশন ডাকার সময় এটি তৈরি হয়, পরের ডাকগুলোতে আগের মান মনে রাখে। ধরুন একটি টোকেন মেশিন, যা ব্যাংকের কাউন্টারে প্রতিবার পরের নম্বর দেয়:
int nextToken() {
static int last = 0; // একবারই তৈরি, প্রোগ্রাম শেষ পর্যন্ত বাঁচে
return ++last; // 1, 2, 3, ...
}এর একটি লাভ lifetime-এর দিক থেকেও আছে: static local-এর reference ফেরত দেওয়া নিরাপদ, কারণ ফাংশন শেষ হলেও object-টি মরে না। C++11 থেকে standard নিশ্চয়তা দেয় যে একাধিক thread একসাথে প্রথমবার ফাংশনটি ডাকলেও static local ঠিক একবারই তৈরি হবে। তবে মনে রাখবেন, এটি আসলে একটি লুকোনো global — বেশি ব্যবহারে প্রোগ্রাম বোঝা আর পরীক্ষা করা কঠিন হয়।
Dynamic মানে “কেউ একজনের দায়িত্বে”
পর্ব ১৬-এ দেখেছেন, new দিয়ে বানানো object-এর আয়ু কোনো block-এর সাথে বাঁধা নয়। এটা শক্তি, আবার বিপদও। পর্ব ১৭-এর মূল কথা ছিল RAII: heap-এর object-কে একটি automatic object-এর হাতে তুলে দিন — যেমন std::unique_ptr — যাতে মালিক মরলে object-ও মরে। এভাবে dynamic আয়ুকেও একটি block-এর আয়ুর সাথে বেঁধে ফেলা যায়। একে বলে ownership বা মালিকানা। এই পর্বের প্রতিটি নিরাপদ সমাধানের পেছনে একই প্রশ্ন: ডেটাটির মালিক কে, আর সে কতক্ষণ বাঁচে?
C-তেও চারটি storage duration আছে — automatic, static, allocated আর thread; C++ “allocated”-কে বলে dynamic. Local-এর ঠিকানা ফেরত দেওয়ার ফাঁদও একই। নতুন হলো দুটি জিনিস: C++-এ আয়ু শেষে destructor নিজে থেকে চলে (তাই RAII সম্ভব), আর reference, string_view, iterator ও lambda-র মতো নতুন “পর্যবেক্ষক” মানে dangling হওয়ার নতুন পথ। C-র দিকটি গভীরভাবে দেখতে C কোর্সের স্কোপ ও স্টোরেজ ক্লাস পর্বটি পড়ুন।
৩ · নিজের চোখে আয়ু দেখা — constructor আর destructor-এর বার্তা
আয়ু চোখে দেখা যায় না — যদি না object নিজে জন্ম আর মৃত্যুর সময় একটি বার্তা ছাপে। Constructor (জন্মের মুহূর্তে চলা ফাংশন) আর destructor (মৃত্যুর মুহূর্তে চলা ফাংশন) পর্ব ২১-এ বিস্তারিত শিখবেন। এখন শুধু এটুকু জানুন: Tracer(...) জন্মের সময় চলে, ~Tracer() মৃত্যুর সময়। নিচের প্রোগ্রামে চার রকম আয়ু আর একটি temporary একসাথে আছে। চালানোর আগে অনুমান করুন — কোন লাইনের পরে কোনটি মরবে?
#include <memory>
#include <print>
#include <string>
struct Tracer {
std::string name;
Tracer(std::string n) : name(n) { std::println(" born : {}", name); }
~Tracer() { std::println(" dies : {}", name); }
};
Tracer make(std::string n) { return Tracer(n); }
Tracer office("global"); // static: puro program
int main() {
std::println("main starts");
{ Tracer a("block"); } // automatic: block-er sheshe
auto owner = std::make_unique<Tracer>("heap"); // dynamic: owner-er hate
std::println(" using {}", make("temp").name); // temporary: ';'-e mrittu
const Tracer& kept = make("extended"); // const&: ayu bare
owner.reset(); // malik chhere dilo
std::println("main ends, kept = {}", kept.name);
}আউটপুটের ক্রমটিই এই পর্বের মানচিত্র। প্রথম লাইন born : global — main শুরুর আগেই, কারণ static object প্রোগ্রাম শুরুতে তৈরি হয়। তারপর main starts, আর block জন্মেই মরে — তার block ওই এক লাইনেই শেষ। heap জন্মায়, কিন্তু মরে না যতক্ষণ না owner.reset() মালিকানা ছেড়ে দেয়। temp-এর ক্রম খেয়াল করুন: born : temp, তারপর using temp, তারপর dies : temp — পুরো লাইনটি শেষ হওয়া পর্যন্ত সে বেঁচে ছিল, তার পরেই মৃত্যু। extended সরাসরি একটি const&-এ bind হওয়ায় বেঁচে থাকে main ends পর্যন্ত, আর মরে main-এর }-এ। সবশেষে dies : global — main ফেরত যাওয়ার পরে। (make ফাংশন ফেরত দেওয়ার সময় কোনো বাড়তি কপি হয়নি — বাড়তি born লাইন নেই। এর কারণ অংশ ৭-এ।)
এই কৌশলটি মনে রাখুন: কোনো জটিল কোডে বুঝতে পারছেন না object কখন মরছে? একটি ছোট “Tracer” টাইপ বানিয়ে সেখানে বসিয়ে দিন। বড় প্রজেক্টে মানুষ ঠিক এভাবেই lifetime-এর বাগ খোঁজেন — debugger-এর পাশাপাশি জন্ম-মৃত্যুর বার্তা দিয়ে।
৪ · Temporary — সেমিকোলনেই যার মৃত্যু
উপরের temp একটি নাম-ছাড়া অস্থায়ী object — temporary. a + b-এর ফল, by value ফেরত দেওয়া কোনো string, std::string("Dhaka") — সবই temporary. এদের আয়ুর নিয়ম সরল: যে full-expression-এ তৈরি, তার শেষেই মৃত্যু। Full-expression মানে মোটামুটি পুরো statement, সেমিকোলন পর্যন্ত। তাই একই লাইনের ভেতরে temporary নিরাপদে ব্যবহার করা যায় — std::println("{}", make("temp").name) ঠিক আছে। সমস্যা শুরু হয় যখন লাইন পেরিয়ে temporary-র দিকে কেউ তাকিয়ে থাকে।
Lifetime extension — মনে করিয়ে দেওয়া, আর তার সীমা
পর্ব ১০-এ আপনি lifetime extension-এর নিয়ম দেখেছেন: temporary সরাসরি একটি local const T& (বা T&&)-এ bind হলে, সে reference-এর সমান দিন বাঁচে। আর দেখেছেন std::max(3, 7)-এর ফাঁদ — মাঝখানে ফাংশন থাকলে নিয়মটি খাটে না। এখানে শুধু সীমাগুলো একটি তালিকায় রাখি:
| লেখা | আয়ু বাড়ে? | কেন |
|---|---|---|
const std::string& s = makeName(); | হ্যাঁ | by value ফেরত দেওয়া temporary সরাসরি reference-এ bind হয়েছে |
const auto& n = makeStudent().name; | হ্যাঁ | temporary-র সরাসরি member — পুরো object-এর আয়ু বাড়ে |
const int& m = std::max(3, 7); | না | temporary bind হয়েছিল ফাংশনের প্যারামিটারে; ফেরত আসা reference-এর মধ্য দিয়ে আয়ু বাড়ে না |
const auto& n = makeStudent().getName(); | না | member function একটি reference ফেরত দিচ্ছে — compiler জানে না সেটি কার |
std::string_view v = makeName(); | না | string_view কোনো reference নয়; এটি একটি নতুন object, যা শুধু ঠিকানা কপি করে রাখে |
শেষ লাইনটি নতুন আর সবচেয়ে প্রতারক। পর্ব ১৪-এ দেখেছেন, std::string_view একটি হালকা “জানালা”: অন্য কারও লেখার শুরুর ঠিকানা আর দৈর্ঘ্য রাখে, নিজে কোনো লেখার মালিক নয়। এমন টাইপকে বলে view. Reference-এর জন্য language-এ বিশেষ আয়ু-বাড়ানোর নিয়ম আছে, view-এর জন্য নেই। একটি দোকানের রসিদ দিয়ে ধাপে ধাপে দেখুন:
1std::string receipt(int tk) { return "Total amount: " + std::to_string(tk) + " Taka"; }2std::string_view v = receipt(450);3std::println("{}", v); // UB4std::string r = receipt(450);5std::string_view ok = r;6std::println("{}", ok); // ঠিক আছে
| temporary string | |
| v | |
| r (মালিক) | |
| ok |
- Temporary জন্মাল।
receipt(450)by value একটিstd::stringফেরত দেয় — নাম-ছাড়া temporary, যার ২২টি অক্ষর এত লম্বা যে সেগুলো সাধারণত heap-এ রাখা হয়। - View তাকাল।
vতৈরি হলো temporary-র অক্ষরগুলোর শুরুর ঠিকানা আর দৈর্ঘ্য (22) নিয়ে। কোনো কপি নেই — এটাইstring_view-এর সুবিধা, আর এখানে এটাই বিপদ। - সেমিকোলনে মৃত্যু। Full-expression শেষ, temporary string ধ্বংস হলো, তার মেমরি ফেরত গেল।
string_viewreference নয়, তাই কোনো lifetime extension হয়নি।vএখন একটি ফাঁকা জায়গার জানালা। - মৃত অক্ষর পড়া।
vপ্রিন্ট করা undefined behavior. AddressSanitizer এখানেheap-use-after-freeজানায়। খুব ছোট লেখা হলে string অক্ষরগুলো নিজের ভেতরেই রাখে (small string optimization), তখন ভুলটিstack-use-after-scopeহিসেবেও দেখা দিতে পারে — নাম যা-ই হোক, ভুল একই। - আগে মালিক, পরে view. নিরাপদ রূপে
rএকটি নাম-ওয়ালাstd::string— লেখাটির মালিক। তারপরokশুধুr-এর দিকে তাকায়। - মালিক বেঁচে থাকলে view বৈধ।
okততক্ষণ বৈধ, যতক্ষণrবেঁচে আছে আর বদলায়নি। শেষ শর্তটি অংশ ৫-এ রসিদের দ্বিতীয় গল্পে দেখবেন।
C++23-এর একটি স্বস্তি: পর্ব ১০-এর ভাবনার প্রশ্নে দেখেছেন, for (auto& x : getSchool().students())-এর মতো লুপে temporary আগে লুপ শুরুর আগেই মরে যেত। C++23 থেকে range-for-এর ডান পাশের সব temporary পুরো লুপ শেষ হওয়া পর্যন্ত বাঁচে। কিন্তু নিয়মটি শুধু range-for-এর জন্য — সাধারণ লাইনে temporary আগের মতোই সেমিকোলনে মরে।
৫ · Dangling-এর তালিকা — পাঁচটি চেনা রূপ
বাস্তব কোডে dangling প্রায় সবসময় এই পাঁচটি রূপের কোনো একটিতে আসে। প্রতিটির একটি ছোট উদাহরণ দেখুন। এগুলো চালানোর জন্য নয় — ভুল কোড চালিয়ে “দেখি কী হয়” পদ্ধতি এখানে কাজ করে না, কারণ undefined behavior আজ ঠিক ফল দেখিয়ে কাল ভেঙে যেতে পারে।
রূপ ১ — local-এর বা temporary-র reference ফেরত
পর্ব ১০-এ local ভ্যারিয়েবলের reference ফেরত দেওয়ার ভুলটি দেখেছেন। এর একটি সূক্ষ্ম আত্মীয় আছে, যেখানে ফাংশনটি নিজে একদম ঠিক, ভুল হয় ডাকার জায়গায়। ধরুন একটি ফাংশন নামের তালিকা থেকে সবচেয়ে লম্বা নামটি reference হিসেবে ফেরত দেয়:
const std::string& longestName(const std::vector<std::string>& names); // তালিকার ভেতরের নাম ফেরত
const std::string& a = longestName(students); // ঠিক: students বেঁচে আছে
const std::string& b = longestName(loadNames()); // ভুল: temporary তালিকা ';'-এ মরে
std::println("{}", b); // dangling — heap-use-after-freeloadNames() একটি temporary vector ফেরত দেয়। longestName সেই temporary-র ভেতরের একটি string-এর reference ফেরত দেয়। লাইন শেষে temporary vector মরে, সাথে তার সব নামও — আর b হয়ে যায় একটি dangling reference. ফাংশনটি একই, ব্যবহারে পার্থক্য। GCC 13 থেকে -Wall-এর অংশ -Wdangling-reference ঠিক এই ধাঁচটি ধরতে চেষ্টা করে।
রূপ ২ — view, যার মালিক বদলে গেছে বা মরে গেছে
অংশ ৪-এ temporary-র ওপর string_view দেখেছেন। এবার আরও চতুর রূপ: মালিক বেঁচে আছে, কিন্তু বদলে গেছে। একটি মুদি দোকানের সফটওয়্যার রসিদ বানায়, আর মোট টাকার অংশটি একটি view দিয়ে আলাদা করে রাখে। পরে গ্রাহক আরেকটি জিনিস কিনলেন, রসিদ নতুন করে বানানো হলো:
std::string receipt = buildReceipt(items); // "...Total: 450 Tk"
std::string_view total = findTotal(receipt); // receipt-এর ভেতরের একটি অংশ
items.push_back({"Egg", 120});
receipt = buildReceipt(items); // নতুন লেখা, হয়তো নতুন মেমরি
std::println("SMS: {}", total); // dangling — পুরোনো মেমরিreceipt নতুন লেখা পেলে তার ভেতরের অক্ষরের মেমরি বদলে যেতে পারে — পুরোনোটি ফেরত যায়। total জানে শুধু পুরোনো ঠিকানা। ফলে গ্রাহকের কাছে SMS-এ আবর্জনা যেতে পারে, অথবা পুরোনো মোট টাকা — যা আরও খারাপ, কারণ কেউ টেরই পাবে না। একই কথা std::span (C++20) আর c_str()-এর ফেরত দেওয়া pointer-এর জন্যও খাটে: মালিককে বদলালে বা মারলে তার সব view অবৈধ।
রূপ ৩ — vector বড় হলে পুরোনো reference
এটি এত প্রচলিত যে এর জন্য পুরো অংশ ৬ রাখা হয়েছে। সংক্ষেপে: push_back-এ জায়গা ফুরালে vector নতুন মেমরিতে সরে যায়, আর আগের সব reference, pointer, iterator অবৈধ হয়ে যায়।
রূপ ৪ — lambda, যে local-কে reference দিয়ে ধরেছে
Lambda হলো কোডের মাঝে লেখা নাম-ছাড়া ছোট ফাংশন, পর্ব ৩৪-এ বিস্তারিত। Lambda বাইরের ভ্যারিয়েবল “ধরে” (capture) রাখতে পারে — কপি করে [x], অথবা reference দিয়ে [&x]. Reference দিয়ে ধরা lambda যদি সেই ভ্যারিয়েবলের চেয়ে বেশি দিন বাঁচে, তাহলে কী হয়, ধাপে ধাপে দেখুন। (std::function এখানে শুধু lambda রাখার একটি বাক্স।)
1std::function<int()> makeCounter() {2 int count = 0;3 return [&count] { return ++count; };4}5auto next = makeCounter();6next(); // UB7// ঠিক: [count]() mutable { return ++count; }
| makeCounter frame | |
| count | |
| lambda-র ভেতরে |
- Frame আর local.
makeCounter()ডাকা হলো; তার stack frame-এcount= 0. - Reference দিয়ে ধরা।
[&count]lambda-র ভেতরেcount-এর কোনো কপি রাখে না, রাখে শুধু তার দিকে একটি reference — কার্যত ঠিকানা। - Lambda বাঁচল, count মরল।
return-এ lambda-টি বাইরেnext-এ চলে এল। কিন্তুmakeCounter-এর frame মুছে গেল, সাথেcount-ও। Lambda এখন একটি মৃত ভ্যারিয়েবলের ঠিকানা বয়ে বেড়াচ্ছে। - মৃত stack-এ লেখা।
next()চালালে++countএমন জায়গায় লেখে, যেখানে এখন হয়তো অন্য কোনো ফাংশনের ডেটা বসেছে। AddressSanitizer একে বলে stack-use-after-return (কিছু সেটআপে এই পরীক্ষা আলাদা করে চালু করতে হয়)। - সমাধান: কপি করে ধরুন।
[count]() mutable { ... }লিখলে lambda নিজের ভেতরেcount-এর একটি কপি রাখে — সে নিজেই মালিক। এখনnext()নিরাপদে 1, 2, 3 দেয়। নিয়ম: যে lambda তৈরির জায়গা ছেড়ে বাইরে যাবে (ফেরত, সংরক্ষণ, অন্য thread), সে কখনো[&]দিয়ে local ধরবে না।
রূপ ৫ — unique_ptr-এর মালিকানা গেছে, raw pointer রয়ে গেছে
পর্ব ১৭-এ দেখেছেন, p.get() একটি সাধারণ (raw) pointer দেয়, মালিকানা দেয় না। মালিক যদি object ছেড়ে দেয়, raw pointer সেটা জানতে পারে না:
auto order = std::make_unique<Order>(1042);
Order* view = order.get(); // শুধু দেখার জন্য — মালিক নয়
order.reset(); // মালিক object-টিকে delete করল
std::println("{}", view->id); // dangling — heap-use-after-freeএই ভুল কোডে খুব কমই এমন পাশাপাশি থাকে। সাধারণত get() হয় এক ফাইলে, reset() বা মালিকের মৃত্যু হয় আরেক ফাইলে, অনেক পরে। তাই নিয়ম: raw pointer বা reference শুধু অল্প সময়ের জন্য নিন — একটি ফাংশন ডাকের ভেতরে — আর কখনো এমন জায়গায় জমিয়ে রাখবেন না, যা মালিকের চেয়ে বেশি দিন বাঁচতে পারে। এটি use-after-free-এর সবচেয়ে পরিচিত পথ, আর নিরাপত্তা-গবেষকদের কাছে খুব দামি একটি ভুল (ভাবনার প্রশ্ন ০১)।
৬ · Vector বড় হলে — reference আর iterator-এর হঠাৎ মৃত্যু
পর্ব ১৩-এ দেখেছেন, std::vector উপাদানগুলো মেমরিতে পাশাপাশি রাখে, আর আগে থেকেই কিছু বাড়তি জায়গা নিয়ে রাখে। কয়টি উপাদান রাখা যায়, তা হলো capacity; কয়টি আছে, তা size. যতক্ষণ size < capacity, push_back শুধু পরের খালি ঘরে লেখে। কিন্তু জায়গা ফুরালে vector-কে নতুন, বড় একটি মেমরি নিতে হয়, পুরোনো উপাদানগুলো সেখানে সরাতে হয়, আর পুরোনো মেমরি ফেরত দিতে হয়। একে বলে reallocation. ঠিক এই মুহূর্তে কী ভাঙে, দেখুন:
@1000 [ 70 ][ 58 ] <- ঘর ভর্তি, আর জায়গা নেই
@1000 [ 70 ][ 58 ]
^
first <- @1000-এর নাম
@1000 [ 70 ][ 58 ] <- পুরোনো @2000 [ 70 ][ 58 ][ 91 ][ ] <- নতুন, capacity 4
@1000 [ ?? ][ ?? ] (ফেরত দেওয়া) ^ first <- dangling! @2000 [ 70 ][ 58 ][ 91 ][ ] <- marks এখানে
@1000 [ 75? ][ ?? ] (অন্য কারও জায়গা হতে পারে) @2000 [ 70 ][ 58 ][ 91 ][ ] <- আসল নম্বর বদলায়নি! ASan: heap-use-after-free
@2000 [ 75 ][ 58 ][ 91 ][ ] ^ marks[0] <- প্রতিবার নতুন করে খোঁজা
- ভর্তি vector. দুটি নম্বর, দুটি ঘর — ধরুন capacity আর size দুটোই 2. (ঠিকানা আর capacity উদাহরণমাত্র; বাস্তবে লাইব্রেরি শুরুতে আরও বেশি জায়গা নিতে পারে, কিন্তু কোনো এক সময় জায়গা ফুরাবেই।)
- Reference নেওয়া।
firstহলো ঠিকানা 1000-এর ঘরটির নাম — এখনো একদম বৈধ। - নতুন বাসা। তৃতীয় নম্বর রাখার জায়গা নেই। Vector একটি বড় মেমরি নেয় (GCC-তে সাধারণত দ্বিগুণ, MSVC-তে দেড়গুণ), পুরোনো দুটি উপাদান সেখানে সরায়, তারপর 91 যোগ করে।
- পুরোনো বাসা ফেরত। Vector পুরোনো মেমরি ফেরত দেয়।
marksনিজে ঠিক আছে — সে নতুন ঠিকানা জানে। কিন্তুfirstকোনো খবর পায়নি। এটাই iterator invalidation: reference, pointer আর iterator — তিনটিই এখন অবৈধ। - ভুতুড়ে লেখা।
first += 5লেখে পুরোনো, ফেরত দেওয়া মেমরিতে। আসলmarks[0]70-ই থাকে। হয়তো অন্য কোনো object-এর ডেটা নষ্ট হয়। AddressSanitizer এখানেheap-use-after-freeজানায়। - Index দিয়ে সমাধান। Index (
0) কোনো ঠিকানা নয়, একটি অবস্থান। প্রতিবারmarks[i]লিখলে vector তার বর্তমান মেমরিতে খুঁজে দেয় — যতবারই সরুক। বাড়তে থাকা vector-এ দীর্ঘ সময়ের জন্য কিছু মনে রাখতে হলে pointer নয়, index রাখুন।
এই ভুলের সবচেয়ে চেনা রূপটি লুকিয়ে থাকে লুপে: for (auto& s : students) if (...) students.push_back(...); — range-for-এর ভেতরে আসলে iterator চলছে, আর push_back সেটিকে অবৈধ করে দিতে পারে। আগে থেকে কয়টি উপাদান লাগবে জানা থাকলে marks.reserve(n) দিয়ে জায়গা নিয়ে রাখলে তখন আর reallocation হয় না — কিন্তু সেটা পারফরম্যান্সের কৌশল, নিরাপত্তার নিশ্চয়তা হিসেবে এর ওপর ভরসা করবেন না। কোন container-এর কোন কাজে কী অবৈধ হয়, তার পুরো তালিকা পর্ব ৩০ আর ৩২-এ দেখবেন।
erase-ও একই কাজ করে: vector থেকে একটি উপাদান মুছলে তার পরের সব উপাদান এক ঘর বাঁয়ে সরে, আর মুছে ফেলা জায়গা ও তার পরের সব reference ও iterator অবৈধ হয়ে যায়। “Vector বদলেছে” মানেই ধরে নিন, পুরোনো ঠিকানাগুলো আর বিশ্বাসযোগ্য নয়।
৭ · কীভাবে এড়াবেন — চারটি নকশা-নিয়ম
সুখবর হলো, প্রায় সব dangling এড়ানো যায় চারটি সরল অভ্যাসে। এগুলো আলাদা আলাদা কৌশল নয় — একটিই নীতির চার রূপ: পর্যবেক্ষক কম রাখুন, মালিক বেশি রাখুন।
১. By value ফেরত দিন
নতুন তৈরি ফল — string, vector, struct — reference নয়, value হিসেবে ফেরত দিন। Compiler সাধারণত কোনো কপিই করে না (নিচে ব্যাখ্যা)।
std::string build()২. ডেটার মালিক object-এ
struct বা class-এর member হিসেবে std::string, std::vector রাখুন — string_view, reference বা raw pointer নয়।
৩. বাড়তে থাকা vector-এ index
পরে আবার খুঁজতে হবে এমন উপাদানের pointer বা reference না রেখে তার index (বা কোনো id) রাখুন।
std::size_t i৪. View শুধু প্যারামিটারে
string_view আর span ফাংশনের প্যারামিটার হিসেবে চমৎকার — ডাকার সময়টুকু মালিক বেঁচে থাকে। জমিয়ে রাখা বা ফেরত দেওয়ায় সাবধান।
By value ফেরত — কপির ভয় কেন অমূলক
অনেকে reference ফেরত দেন এই ভয়ে যে বড় string বা vector ফেরত দিলে পুরোটা কপি হবে। আধুনিক C++-এ এই ভয় প্রায় অমূলক। প্রথমত, copy elision: compiler ফেরত দেওয়া object-কে সরাসরি ডাকার জায়গার ভ্যারিয়েবলেই তৈরি করে — একে বলে RVO. C++17 থেকে return Tracer(n);-এর মতো নাম-ছাড়া object ফেরত দিলে এটি বাধ্যতামূলক — অংশ ৩-এর প্রোগ্রামে বাড়তি কোনো born লাইন না দেখার কারণ এটাই। দ্বিতীয়ত, নাম-ওয়ালা local (return best;) ফেরত দিলেও compiler প্রায় সবসময় কপি বাদ দেয়, আর না পারলেও কপির বদলে move করে — অর্থাৎ শুধু ভেতরের মেমরির মালিকানা হাতবদল, যা প্রায় বিনা খরচে হয় (পর্ব ৩৬)। নিচের প্রোগ্রামে অংশ ৫-এর “সবচেয়ে লম্বা নাম” আর অংশ ৬-এর vector — দুটির নিরাপদ রূপ:
#include <print>
#include <string>
#include <vector>
std::string longestName(const std::vector<std::string>& names) { // by value ferot
std::string best;
for (const auto& s : names) if (s.size() > best.size()) best = s;
return best;
}
int main() {
std::string top = longestName({"Rahim", "Nusrat Jahan", "Karim"}); // temporary list mrituk
std::println("longest: {}", top);
std::vector<int> marks{70, 58};
std::size_t idx = 0, moves = 0; // reference noy, index
for (int i = 0; i < 100; ++i) {
auto old = marks.capacity();
marks.push_back(i);
if (marks.capacity() != old) ++moves; // notun memory-te shorlo
}
marks[idx] += 5;
std::println("first = {}, size = {}, moved {} times", marks[idx], marks.size(), moves);
}প্রথম অংশে {"Rahim", "Nusrat Jahan", "Karim"} থেকে একটি temporary vector তৈরি হয়, যা লাইনের শেষে মরে। কিন্তু longestName by value ফেরত দেয়, তাই top নিজেই লেখাটির মালিক: longest: Nusrat Jahan — নিরাপদ। দ্বিতীয় অংশে vector ১০০ বার বেড়েছে। আমাদের পরীক্ষায় (GCC-র নতুন সংস্করণ) আউটপুট এসেছে first = 75, size = 102, moved 5 times — capacity ধাপে ধাপে 8, 16, 32, 64, 128 হয়েছে, অর্থাৎ পাঁচবার পুরো ডেটা নতুন মেমরিতে সরেছে। সংখ্যাটি লাইব্রেরি আর সংস্করণভেদে আলাদা হতে পারে — আর ঠিক সেই কারণেই কখন সরবে, তার ওপর ভরসা করা যায় না। শুরুতে int& first = marks[0]; রাখলে প্রথমবার সরার মুহূর্তেই সেটি অবৈধ হতো। Index-এর কিছু যায়-আসে না — marks[idx] প্রতিবার বর্তমান মেমরিতে খোঁজে।
View রাখার নিয়ম
তার মানে কি string_view খারাপ? একদম না। void printLine(std::string_view s) — এমন প্যারামিটারে এটি std::string, "লেখা" বা অন্য কোনো লেখার টুকরো, সবই কপি ছাড়া নিতে পারে, আর ফাংশন চলার পুরো সময় ডাকার জায়গার মালিক বেঁচে থাকে। বিপদ তখনই, যখন view মালিককে ছাড়িয়ে বাঁচে: struct-এর member হিসেবে, ফেরতের টাইপ হিসেবে, বা কোনো container-এ জমিয়ে রাখলে। এমন কিছু সত্যিই দরকার হলে (যেমন বড় একটি ফাইলের টুকরোগুলোর দিকে view, যেখানে ফাইলটি পুরো সময় বেঁচে থাকে) কোডে মন্তব্য লিখে স্পষ্ট করুন — “এই view-এর মালিক fileText, যা Parser-এর চেয়ে বেশি দিন বাঁচে।”
- Dangling pointer বা reference কী? — যা এমন object-এর দিকে তাকিয়ে আছে, যার আয়ু শেষ; তা দিয়ে পড়া বা লেখা undefined behavior.
- Temporary কখন ধ্বংস হয়? — যে full-expression-এ তৈরি তার শেষে, যদি না সরাসরি একটি
const&বা&&-এ bind হয়ে আয়ু বাড়ে। push_back-এর পর আগের reference কেন অবৈধ হতে পারে? — capacity ফুরালে vector নতুন মেমরিতে সরে, পুরোনো মেমরি ফেরত দেয়।- Function-এর ভেতরের static ভ্যারিয়েবল কতক্ষণ বাঁচে? — প্রথম ব্যবহারে তৈরি হয়ে প্রোগ্রাম শেষ পর্যন্ত; scope শুধু ফাংশনের ভেতরে।
- বড় object by value ফেরত দেওয়া কি ধীর? — সাধারণত না; RVO/copy elision কপি বাদ দেয়, আর C++17-এ prvalue ফেরতে এটি বাধ্যতামূলক।
৮ · টুল দিয়ে ধরা — warning, AddressSanitizer আর lifetime-এর ভবিষ্যৎ
মানুষের চোখ সব dangling ধরতে পারে না, তাই টুলের সাহায্য নিন। তিনটি স্তর আছে — compile-এর সময়, চলার সময়, আর নকশার নিয়মে।
স্তর ১ — compiler-এর সতর্কবার্তা
সবসময় -Wall -Wextra দিয়ে compile করুন। GCC-র -Wreturn-local-addr (ডিফল্টভাবেই চালু) local-এর ঠিকানা বা reference ফেরত দিলে সতর্ক করে। GCC 13 থেকে -Wdangling-reference (-Wall-এর অংশ) অংশ ৫-এর longestName(loadNames())-এর মতো ধাঁচ ধরার চেষ্টা করে, আর GCC 12 থেকে আছে -Wdangling-pointer. Clang-এর -Wdangling পরিবারের সতর্কবার্তা temporary-র ওপর string_view-এর মতো কিছু ক্ষেত্র ধরে। তবে এগুলোর কোনোটিই সব ধরতে পারে না, আর কিছু ক্ষেত্রে ভুল সতর্কবার্তাও দেয় — কেন, তা ভাবনার প্রশ্ন ০৩-এ।
স্তর ২ — AddressSanitizer: চলার সময় হাতেনাতে ধরা
পর্ব ০৩-এ sanitizer-এর নাম শুনেছেন। Lifetime-এর বাগের জন্য সবচেয়ে কাজের হলো AddressSanitizer (ASan). এটি প্রোগ্রামের প্রতিটি মেমরি-ব্যবহারের পাশে পাহারা বসায়, আর মৃত বা ফেরত দেওয়া মেমরি ছুঁলেই প্রোগ্রাম থামিয়ে বিস্তারিত রিপোর্ট দেয়। নিজের কম্পিউটারে চালাতে:
g++ -std=c++23 -g -fsanitize=address -fno-omit-frame-pointer order.cpp -o order
./orderঅংশ ৫-এর unique_ptr-এর ভুল কোডটি এভাবে চালালে রিপোর্টের মূল অংশ দেখতে এমন হয় (ঠিকানা আর লাইন নম্বর আপনার মেশিনে আলাদা হবে):
==4127==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
#0 in main order.cpp:12
freed by thread T0 here:
#0 in operator delete(void*, unsigned long)
#1 in std::unique_ptr<Order>::reset(Order*) ...
#2 in main order.cpp:11
previously allocated by thread T0 here:
#0 in operator new(unsigned long)
#1 in main order.cpp:9
রিপোর্টটি তিনটি প্রশ্নের উত্তর দেয়: কোথায় মৃত মেমরি ছোঁয়া হলো (লাইন ১২), কোথায় সেটি ফেরত গিয়েছিল (reset, লাইন ১১), আর কোথায় জন্মেছিল (লাইন ৯)। Dangling-এর বাগ খুঁজতে ঠিক এই তিনটি তথ্যই লাগে। ASan যে নামগুলো দেয়, তা চিনে রাখুন: heap-use-after-free (heap-এর মৃত object), stack-use-after-scope (block শেষ হওয়া local), stack-use-after-return (ফেরত যাওয়া ফাংশনের local). ASan প্রোগ্রামকে কয়েক গুণ ধীর করে আর মেমরি বেশি নেয়, তাই এটি টেস্ট আর ডিবাগের জন্য, চূড়ান্ত সংস্করণের জন্য নয় (পর্ব ৪৮)। আর মনে রাখুন, ASan শুধু সেই বাগ ধরে, যে পথে প্রোগ্রাম আসলে চলেছে — না-চলা কোডের বাগ সে দেখে না।
স্তর ৩ — নকশার নিয়ম: Core Guidelines আর profiles
Bjarne Stroustrup আর Herb Sutter-এর নেতৃত্বে লেখা C++ Core Guidelines-এ একটি “lifetime profile” আছে — কিছু নিয়ম, যা মানলে কোনো টুল স্থানীয়ভাবে (একেকটি ফাংশন দেখে) প্রমাণ করতে পারে যে pointer আর reference dangling হবে না। Herb Sutter ২০১৮ সালে এর বিস্তারিত নকশা প্রকাশ করেন, আর Microsoft-এর Visual C++ ও Clang-এর পরীক্ষামূলক সংস্করণে এর কিছু অংশ বাস্তবায়িত হয়েছে। এর পেছনের বড় আলোচনা হলো safety profile — কোডের একটি অংশে “এই নিরাপদ নিয়মগুলো চালু” বলে দিলে compiler বিপজ্জনক ধাঁচ আটকাবে। এটি নিয়ে C++ standard কমিটিতে কয়েক বছর ধরে জোর বিতর্ক চলছে, আর এখনো (এই লেখার সময়) এটি standard-এর অংশ হয়নি।
এই বিতর্কের পটভূমিতে আছে Rust. ২০১৫ সালে ১.০ সংস্করণে পৌঁছানো এই ভাষার compiler-এ আছে borrow checker, যা compile-এর সময়েই প্রমাণ করে যে কোনো reference তার object-এর চেয়ে বেশি দিন বাঁচবে না — না পারলে কোড compile-ই হয় না। এই পর্বের পাঁচটি রূপের প্রায় সবই Rust-এ compile error. ২০২২ সালে যুক্তরাষ্ট্রের NSA আর ২০২৪ সালে হোয়াইট হাউসের একটি প্রতিবেদন সফটওয়্যার নির্মাতাদের memory-safe ভাষার দিকে যাওয়ার পরামর্শ দেয়। C++ সমাজের জবাব একমুখী নয়: কেউ profiles-এর পক্ষে, কেউ Sean Baxter-এর “Safe C++” প্রস্তাবের মতো borrow checker ধাঁচের সমাধানের পক্ষে। তুলনাটি ভাবনার প্রশ্ন ০৪-এ।
৯ · AI-এর যুগে — lifetime-এর ভুল খুঁজে বের করা
AI টুলের লেখা C++ কোডে lifetime-এর ভুল বিশেষ করে বিপজ্জনক, কারণ এগুলো compile হয়, ছোট টেস্টে ঠিক চলে, আর দেখতে “পরিপাটি আধুনিক” লাগে — string_view আর reference দেখে মনে হয় লেখক পারফরম্যান্স নিয়ে সচেতন। AI টুল কোনো object-এর আয়ু সত্যিই হিসাব করে না; সে প্রচলিত ধাঁচ অনুকরণ করে। যে ভুলগুলো বারবার দেখা যায়:
| AI যা করে | সমস্যা | যা হওয়া উচিত |
|---|---|---|
const std::string& getLabel() { std::string s = ...; return s; } | local-এর reference ফেরত | std::string getLabel() |
struct Customer { std::string_view name; }; | member view — মালিক কে, কতক্ষণ বাঁচে, জানা নেই | std::string name; |
std::string_view v = a + b; | temporary-র ওপর view, লাইন শেষে dangling | std::string v = a + b; |
লুপের আগে auto it = v.begin();, লুপে v.push_back, পরে *it | iterator invalidation | index রাখুন, বা বদলানোর পর নতুন করে খুঁজুন |
callback বা thread-এ [&] capture | lambda local-কে ছাড়িয়ে বাঁচে | কপি করে ধরুন [=]/[x], বা মালিকানা সরিয়ে দিন |
Order* raw = ptr.get(); কোনো member-এ জমিয়ে রাখা | মালিক ছেড়ে দিলে use-after-free | মালিকানা স্পষ্ট করুন; raw pointer শুধু অল্প সময়ের জন্য |
AI-কে কোড লিখতে বলার সময় lifetime-এর নিয়ম আগেই বলে দিন, আর প্রতিটি reference ও view-এর জন্য “মালিক কে” জানতে চান:
AI-এর কোডে lifetime যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Microsoft আর Chromium দুই দলই বলেছে তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০% মেমরি-সংক্রান্ত। কেন এই ধরনের বাগই এত বেশি, আর use-after-free আক্রমণকারীর কাছে এত দামি কেন?
২০১৯ সালে BlueHat IL সম্মেলনে Microsoft-এর নিরাপত্তা প্রকৌশলী Matt Miller জানান, এক দশকেরও বেশি সময় ধরে Microsoft প্রতি বছর যত নিরাপত্তা-ত্রুটির জন্য CVE (সর্বজনীন ত্রুটি-নম্বর) দেয়, তার প্রায় ৭০% memory safety-র সমস্যা। ২০২০ সালে Chromium (Google Chrome-এর মূল প্রজেক্ট) প্রায় একই হিসাব প্রকাশ করে: তাদের উচ্চ ও গুরুতর মাত্রার নিরাপত্তা-বাগের প্রায় ৭০% memory safety-র, আর তার প্রায় অর্ধেক use-after-free — অর্থাৎ ঠিক এই পর্বের বিষয়। দুটি আলাদা প্রতিষ্ঠান, আলাদা কোডবেস, কাছাকাছি সংখ্যা — এটা কাকতালীয় নয়।
কেন এত বেশি? প্রথমত, এই কোডবেসগুলো বিশাল আর বহু বছরের পুরোনো — Windows আর Chrome-এ কোটি কোটি লাইন C ও C++. দ্বিতীয়ত, lifetime-এর ভুল স্থানীয় নয়: pointer নেওয়া হয় এক ফাইলে, object মরে আরেক ফাইলে, হয়তো আরেকজনের লেখা কোডে, হয়তো একটি event বা callback-এর পরে। কোনো একজন রিভিউয়ার পুরো ছবিটি একসাথে দেখেন না। তৃতীয়ত, ভুলটি প্রায়ই সাধারণ ব্যবহারে ধরা পড়ে না — মৃত মেমরিতে পুরোনো মান থেকে যায় বলে প্রোগ্রাম ঠিক চলে।
আক্রমণকারীর কাছে use-after-free দামি, কারণ মৃত object-এর জায়গা heap আবার অন্য কাজে দেয়। আক্রমণকারী যদি নিজের পছন্দের ডেটা দিয়ে ঠিক সেই জায়গাটি ভরাতে পারেন (ব্রাউজারে এটা জাভাস্ক্রিপ্ট দিয়ে করা যায়), তাহলে প্রোগ্রাম পুরোনো pointer দিয়ে যখন “object”-টি ব্যবহার করে, তখন আসলে আক্রমণকারীর সাজানো ডেটা পড়ে। Object-এর ভেতরে যদি virtual ফাংশনের ঠিকানা থাকে (পর্ব ২৫), তাহলে প্রোগ্রাম আক্রমণকারীর ঠিক করে দেওয়া ঠিকানায় লাফ দিতে পারে — কম্পিউটারের নিয়ন্ত্রণ চলে যায়।
এর জবাবে শিল্পে কয়েকটি পথ দেখা গেছে। Chromium “MiraclePtr” নামে একটি ব্যবস্থা চালু করেছে, যা মুছে ফেলা object-এর মেমরি সঙ্গে সঙ্গে আবার ব্যবহার হতে দেয় না যতক্ষণ কোনো pointer তার দিকে তাকিয়ে আছে। Google Android-এ নতুন কোড বেশি করে Rust ও Kotlin-এর মতো memory-safe ভাষায় লেখা শুরু করে, আর ২০২২ সালে জানায় যে Android-এর নিরাপত্তা-ত্রুটির মধ্যে মেমরি-সংক্রান্তের অংশ কয়েক বছরে উল্লেখযোগ্যভাবে কমেছে। লক্ষ করুন, পুরোনো কোড পুনর্লিখন না করেও শুধু নতুন কোডে নিয়ম বদলেই ফল এসেছে।
মূল উপলব্ধি: lifetime-এর বাগ শুধু “crash”-এর বিষয় নয়, এটি নিরাপত্তার বিষয়। আপনার লেখা একটি dangling pointer কোনো দিন কারও bKash অ্যাকাউন্ট বা হাসপাতালের সার্ভারের দরজা খুলে দিতে পারে। তাই মালিকানা স্পষ্ট রাখা আর ASan চালানো পেশাদারি দায়িত্ব।
প্র ০২C++17-এ “guaranteed copy elision” এসেছে। এটি ঠিক কী নিশ্চয়তা দেয়, কী দেয় না, আর এটি কেন dangling কমাতে সাহায্য করে?
C++98 থেকেই standard compiler-কে অনুমতি দিত যে ফেরত দেওয়া object-এর কপি বাদ দেওয়া যাবে — এমনকি কপি constructor-এ কোনো বার্তা ছাপানোর মতো দৃশ্যমান কাজ থাকলেও। এটাই RVO. কিন্তু অনুমতি মানে বাধ্যবাধকতা নয়। তাই প্রোগ্রামাররা নিশ্চিত হতে পারতেন না, আর অনেকে ভয়ে বড় ফল ফেরত না দিয়ে void build(std::vector<Row>& out)-এর মতো out parameter লিখতেন, অথবা আরও খারাপ — reference বা pointer ফেরত দিতেন।
C++17-এ Richard Smith-এর প্রস্তাব (P0135, “guaranteed copy elision through simplified value categories”) গৃহীত হয়। এখন নাম-ছাড়া একটি মান (prvalue) — যেমন return Tracer(n); বা return std::string("Total"); — ফেরত দিলে কোনো কপি বা move হয় না, এটি ভাষার নিয়ম। আসলে নিয়মটি আরও গভীর: prvalue এখন আর “একটি তৈরি object” নয়, বরং “একটি object তৈরির নির্দেশ”, যা সরাসরি শেষ গন্তব্যে বাস্তবায়িত হয়। এর একটি মজার ফল: যে টাইপের কপি বা move constructor একেবারেই নেই (যেমন std::atomic বা lock-এর টাইপ), সেটিও এখন ফাংশন থেকে by value ফেরত দেওয়া যায়।
যা নিশ্চিত নয়: নাম-ওয়ালা local (std::string best; ... return best;) ফেরত দেওয়া — একে বলে NRVO. এটি এখনো শুধু অনুমোদিত, বাধ্যতামূলক নয়। তবে বাস্তবে GCC, Clang আর MSVC সাধারণ ক্ষেত্রগুলোতে এটি করে, আর না পারলে standard বলে local-টিকে move করতে হবে, কপি নয়। একটি প্রচলিত ভুল হলো return std::move(best); লেখা — এটি NRVO আটকে দেয়, তাই GCC আর Clang-এ -Wpessimizing-move সতর্কবার্তা আছে। সরল return best;-ই সবচেয়ে ভালো।
Dangling-এর সাথে সম্পর্কটি মনস্তাত্ত্বিক আর নকশাগত। Reference ফেরত দেওয়ার প্রধান অজুহাত ছিল “কপি এড়ানো।” সেই অজুহাত চলে গেলে by value ফেরতই স্বাভাবিক পছন্দ হয়, আর by value ফেরত মানে ডাকার মানুষ নিজেই মালিক — কোনো আয়ুর হিসাব রাখতে হয় না। অংশ ৭-এর longestName ঠিক এই কারণে temporary তালিকা পেলেও নিরাপদ।
মূল উপলব্ধি: ভাষার একটি “অপটিমাইজেশন” নিয়ম আসলে নিরাপত্তার নিয়মও। যখন নিরাপদ পথটিই দ্রুত পথ হয়ে যায়, তখন মানুষ স্বাভাবিকভাবেই সেটি বেছে নেয় — ভালো ভাষা-নকশা এভাবেই কাজ করে।
প্র ০৩Compiler যদি -Wreturn-local-addr ধরতে পারে, তাহলে সব dangling কেন ধরতে পারে না? আর -Wdangling-reference কেন কখনো ভুল সতর্কবার্তা দেয়?
Local-এর ঠিকানা সরাসরি ফেরত দেওয়া সবচেয়ে সহজ ক্ষেত্র: compiler একটি ফাংশনের ভেতরেই পুরো ঘটনাটি দেখতে পায় — এই ভ্যারিয়েবল local, আর তারই ঠিকানা return-এ যাচ্ছে। কিন্তু বাস্তবের বেশিরভাগ dangling এমন স্থানীয় নয়, আর সেখানে তিনটি মৌলিক বাধা আছে।
প্রথম বাধা — ফাংশনের স্বাক্ষর আয়ুর কথা বলে না। const std::string& longestName(const std::vector<std::string>&) দেখে compiler জানে না, ফেরত আসা reference প্যারামিটারের ভেতরের কোনো string-এর, নাকি কোনো static string-এর, নাকি একেবারে অন্য কিছুর। পর্ব ০৩-এর separate compilation মনে করুন — ফাংশনের শরীর হয়তো অন্য ফাইলে, compile করার সময় দেখাই যায় না। GCC 13-এর -Wdangling-reference তাই অনুমান করে: “ফাংশন reference ফেরত দিচ্ছে, আর একটি temporary পেয়েছে — সম্ভবত ফেরতটি temporary-র।” অনুমান প্রায়ই ঠিক, কিন্তু সবসময় নয়। যে ফাংশন temporary নেয় কিন্তু ফেরত দেয় একটি global-এর reference, সেখানেও সতর্কবার্তা আসে — তাই ভুল সতর্কবার্তা। Clang এ সমস্যার একটি সমাধান দিয়েছে: [[clang::lifetimebound]] attribute দিয়ে প্যারামিটারে লিখে দেওয়া যায় “ফেরতটি এই প্যারামিটারের সমান দিন বাঁচে।” অর্থাৎ মানুষ তথ্য দিলে compiler আরও ভালো পারে।
দ্বিতীয় বাধা — aliasing আর চলার সময়ের সিদ্ধান্ত। একটি pointer কোন object-এর দিকে তাকিয়ে আছে, তা অনেক সময় ইনপুটের ওপর নির্ভর করে: Order* o = (x > 5) ? &a : findInCache(id);. Vector reallocate করবে কি না, তা নির্ভর করে চলার সময়ের capacity-র ওপর। Compile-এর সময় এগুলো জানা অসম্ভব।
তৃতীয় বাধা — গাণিতিক সীমা। Rice-এর উপপাদ্য (১৯৫৩) বলে, কোনো প্রোগ্রামের আচরণ নিয়ে যেকোনো অ-তুচ্ছ প্রশ্ন — যেমন “এই প্রোগ্রাম কি কখনো মৃত মেমরি ছোঁবে?” — সাধারণভাবে কোনো algorithm সব প্রোগ্রামের জন্য নির্ভুলভাবে উত্তর দিতে পারে না। এটি halting problem-এর আত্মীয়। তাই যেকোনো টুলকে বেছে নিতে হয়: হয় কিছু বাগ ছেড়ে দেবে (false negative), নয়তো কিছু ঠিক কোডকেও ভুল বলবে (false positive).
Rust এই গাণিতিক সীমা ভাঙেনি — সে দ্বিতীয় পথটি বেছেছে, আর কঠোরভাবে। Borrow checker এমন অনেক কোডও প্রত্যাখ্যান করে, যা আসলে নিরাপদ, কিন্তু প্রমাণ করা যায় না। প্রোগ্রামারকে কোড এমনভাবে লিখতে হয় যাতে প্রমাণ করা যায়। C++-এর warning-গুলো উল্টো পথে: কম ভুল সতর্কবার্তা, কিন্তু অনেক বাগ ধরা পড়ে না। ASan-এর মতো runtime টুল আবার ভিন্ন — সে শুধু যা আসলে ঘটেছে তা দেখে, তাই তার রিপোর্ট প্রায় সবসময় সত্য, কিন্তু না-চলা পথ সে দেখে না।
মূল উপলব্ধি: কোনো একক টুল সব dangling ধরতে পারবে না — এটা প্রকৌশলের দুর্বলতা নয়, গণিতের সীমা। তাই প্রতিরক্ষা স্তরে স্তরে: নিরাপদ নকশা, compiler warning, sanitizer দিয়ে টেস্ট, আর কোড রিভিউ।
প্র ০৪Rust-এর borrow checker আর C++-এর “safety profiles” — দুটি একই সমস্যার দুই রকম উত্তর। পার্থক্য কোথায়, আর C++ কেন সরাসরি borrow checker নিয়ে আসছে না?
Rust-এর নিয়ম দুটি। Ownership: প্রতিটি মানের একজন মালিক, মালিক scope ছাড়লে মান মরে — অনেকটা C++-এর RAII-কে ভাষার বাধ্যতামূলক নিয়ম বানানো। Borrowing: একই সময়ে একটি মানের হয় অনেকগুলো শুধু-পড়ার reference (&T), নয়তো ঠিক একটি বদলানোর reference (&mut T) থাকতে পারে — দুটো একসাথে নয়। দ্বিতীয় নিয়মটিই vector-এর ফাঁদ বন্ধ করে: কোনো উপাদানের reference ধরে রাখা অবস্থায় push করতে গেলে &mut লাগে, তাই compile error.
ফাংশনের স্বাক্ষরেও Rust আয়ু লিখতে বাধ্য করে, যখন compiler নিজে বুঝতে পারে না। Rust-এর অফিসিয়াল বইয়ের বিখ্যাত উদাহরণটি অবাক করার মতো মিলে যায় অংশ ৫-এর সাথে: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str — “ফেরতটি x আর y দুটোর চেয়ে বেশি দিন বাঁচবে না।” এই তথ্য স্বাক্ষরে থাকায় ডাকার জায়গায় compiler প্রমাণ করতে পারে, temporary পাঠিয়ে ফল ধরে রাখা চলবে না। C++ স্বাক্ষরে এই তথ্য নেই — প্রশ্ন ০৩-এর প্রথম বাধা।
তাহলে C++ কেন একই জিনিস আনছে না? মূল কারণ বিপুল পুরোনো কোড আর backward compatibility. Borrow checker-এর নিয়ম মানতে হলে C++-এর প্রচলিত বহু ধাঁচ — একই object-এর দিকে কয়েকটি বদলানোর pointer, iterator-এর জোড়া, এমনকি standard library-র অনেক নকশা — বদলাতে হবে। Sean Baxter ২০২৪ সালে “Safe C++” প্রস্তাবে (নিজের Circle compiler-এ বাস্তবায়িত) দেখিয়েছেন এটা প্রযুক্তিগতভাবে সম্ভব, কিন্তু তাতে কার্যত নতুন reference টাইপ আর একটি নিরাপদ নতুন standard library লাগে। কমিটির বড় অংশ তখন profiles-এর দিকে ঝুঁকেছে — Bjarne Stroustrup আর Herb Sutter এর প্রধান সমর্থক — যেখানে বিদ্যমান কোডে স্থানীয় বিশ্লেষণ আর কিছু নিষেধাজ্ঞা দিয়ে যতটা সম্ভব নিরাপত্তা আনা হবে, পুরোনো কোড না ভেঙে।
সমালোচকরা বলেন, স্থানীয় বিশ্লেষণ দিয়ে Rust-এর মতো নিশ্চয়তা পাওয়া সম্ভব নয় — প্রশ্ন ০৩-এর সীমা এখানেও খাটে। সমর্থকরা বলেন, ১০০% নিশ্চয়তা না দিলেও কোটি কোটি লাইন পুরোনো কোডে সবচেয়ে প্রচলিত ভুলগুলো বন্ধ করা বেশি বাস্তবসম্মত। এই লেখার সময় profiles C++ standard-এর অংশ হয়নি, আর কাজটি আলাদা নথি হিসেবে এগোচ্ছে; চূড়ান্ত রূপ কী হবে, তা এখনো খোলা প্রশ্ন।
মূল উপলব্ধি: দুই পথেরই মূল কথা এক — আয়ুর তথ্য কোডে স্পষ্ট করা। Rust সেটি ভাষার নিয়মে বাধ্য করে; C++-এ আজ সেটি আপনার নকশার দায়িত্ব। এই পর্বের নিয়মগুলো (by value ফেরত, মালিক member, view শুধু প্যারামিটারে) আসলে হাতে-চালানো একটি borrow checker.
প্র ০৫এমন API কীভাবে নকশা করবেন, যা ভুলভাবে ব্যবহার করা কঠিন — যাতে ডাকার মানুষ চাইলেও সহজে dangling বানাতে না পারেন?
Scott Meyers তাঁর Effective C++ বইয়ে একটি নীতি জনপ্রিয় করেছিলেন: “interface এমন বানান, যা ঠিকভাবে ব্যবহার করা সহজ আর ভুলভাবে ব্যবহার করা কঠিন।” Lifetime-এর ক্ষেত্রে এর মানে — ডাকার মানুষকে যেন আয়ুর হিসাব মাথায় রাখতে না হয়, আর ভুল ধাঁচ যেন compile-ই না হয়।
১. ফেরতের টাইপেই মালিকানা। নতুন তৈরি ডেটা by value ফেরত দিন। Heap-এর object তৈরি করে দিলে std::unique_ptr<T> ফেরত দিন — টাইপটিই বলে দেয় “এখন থেকে আপনি মালিক।” Raw T* ফেরত দিলে ডাকার মানুষকে ডকুমেন্টেশন পড়ে জানতে হয় delete করবেন কি না, আর কতক্ষণ বৈধ।
২. Temporary-র ওপর বিপজ্জনক কাজ নিষিদ্ধ করা। C++-এ member function-কে বলা যায় “শুধু নাম-ওয়ালা object-এ চলবে” আর “temporary-তে অন্যভাবে চলবে” — একে বলে ref-qualifier: const std::string& name() const&; আর std::string name() &&;. প্রথমটি বেঁচে থাকা object থেকে reference দেয়, দ্বিতীয়টি temporary থেকে মানটি সরিয়ে by value দেয় — ফলে makeStudent().name() আর dangling হয় না। একইভাবে একটি ফাংশনের temporary-গ্রহণকারী overload = delete করে দিলে longestName(loadNames()) লেখাই compile error হয়।
৩. Standard library-র নিজের উদাহরণ। C++20-এর ranges algorithm-গুলো এই নীতির সুন্দর প্রয়োগ। std::ranges::find(getMarks(), 70) — যেখানে getMarks() একটি temporary vector ফেরত দেয় — একটি iterator ফেরত দেয় না, ফেরত দেয় std::ranges::dangling নামের একটি ফাঁকা টাইপ। সেটিকে * দিয়ে পড়তে গেলেই compile error. অর্থাৎ library নিজেই বুঝে ফেলে “আপনি একটি মরণশীল container-এর ভেতরের iterator চাইছেন” আর ভুলটি compile-এর সময়েই আটকে দেয়। পর্ব ৩৫-এ ranges-এর সাথে এটি আবার দেখবেন।
৪. View-এর জায়গা সীমিত রাখা। প্যারামিটারে std::string_view আর std::span উদারভাবে নিন, কিন্তু ফেরত বা member-এ দিতে হলে নাম ও মন্তব্যে স্পষ্ট করুন — অনেক দল এমন টাইপের নামের শেষে View বা Ref লেখে, যাতে পড়তেই বোঝা যায় এটি মালিক নয়। আর index বা id-ভিত্তিক API (যেমন getOrder(orderId)) প্রায়ই pointer-ভিত্তিক API-র চেয়ে নিরাপদ, কারণ id কখনো dangling হয় না — বড়জোর “খুঁজে পাওয়া যায়নি” হয়।
মূল উপলব্ধি: ভালো API-তে lifetime-এর নিয়ম ডকুমেন্টেশনে লুকিয়ে থাকে না, থাকে টাইপে। যে ভুল compile-এর সময় আটকানো যায়, সেটি কখনো গ্রাহকের ফোনে crash হয়ে পৌঁছায় না।
অনুশীলন
বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।
-
ক্রম অনুমান: অংশ ৩-এর
Tracerদিয়ে এইmainলেখা হলো:Tracer x("x"); { Tracer y("y"); Tracer z("z"); } static Tracer s("s");.mainশেষ হওয়া পর্যন্ত আর তার পরে কোন ক্রমেbornআরdiesছাপা হবে? (globalofficeবাদ দিন।)born x,born y,born z,dies z,dies y,born s, তারপরmain-এর শেষেdies x, আর তারও পরেdies s. Block-এর local-রা ঘোষণার উল্টো ক্রমে মরে (z আগে, y পরে)। Static local প্রথম যখন সেই লাইনে পৌঁছায় তখন তৈরি হয়, আর মরেmainফেরত যাওয়ার পরে — automaticx-এর পরে। -
বাগ শিকার: একটি AI এই কোড দিয়েছে:
struct Customer { std::string_view name; int due; };আরCustomer readCustomer() { std::string line; std::getline(std::cin, line); return Customer{line, 0}; }. সমস্যাটি কী, AddressSanitizer কী জানাতে পারে, আর ঠিক রূপ কী?nameহলো localline-এর অক্ষরের দিকে একটি view. ফাংশন শেষেlineমরে, তাই ফেরত আসাCustomer-এরnamedangling. পরে নাম পড়লে ASan সাধারণতheap-use-after-free(লম্বা নাম) বা stack-সংক্রান্ত ভুল (খুব ছোট নাম, string নিজের ভেতরে রাখলে) জানাবে। ঠিক রূপ: member হবেstd::string name;— struct নিজেই নামের মালিক। কপির খরচ নিয়ে ভাবনা নেই;linemove করে দেওয়া যায় (std::move(line), পর্ব ৩৬)। -
কোনটি নিরাপদ? প্রতিটির জন্য বলুন নিরাপদ কি না, কারণসহ — (ক)
const std::string& s = std::string("Dhaka") + "-1205";(খ)std::string_view v = std::string("Dhaka") + "-1205";(গ)auto& last = v.back(); v.push_back(1); last = 0;(ঘ)const int& t = nextToken();যেখানেnextTokenby value int ফেরত দেয়।(ক) নিরাপদ — temporary সরাসরি
const&-এ bind হয়েছে, আয়ু বাড়ে। (খ) বিপজ্জনক —string_viewreference নয়, temporary সেমিকোলনে মরে। (গ) বিপজ্জনক —push_backreallocate করলেlastdangling; capacity বাকি থাকলে সেই মুহূর্তে কাজ করবে, কিন্তু তার ওপর ভরসা করা যায় না। (ঘ) নিরাপদ — by value ফেরত দেওয়া temporary সরাসরি bind হয়েছে; তবে সরলint t = nextToken();লেখাই ভালো। -
নিজে লিখুন (কোড): অংশ ৭-এর
safe_lifetimes.cpp-এ লুপের আগে একটি লাইন যোগ করুন, যাতে আউটপুটেmoved 0 timesআসে। তারপর ভাবুন: এই লাইনটি থাকলে কিint& first = marks[0];নিরাপদ হয়ে যায়?লাইনটি
marks.reserve(102);(বা তার বেশি) — শুরুতেই ১০২টির জায়গা নিয়ে রাখলে আর reallocation হয় না, তাইmoved 0 times. এই অবস্থায় standard অনুযায়ী size capacity-র নিচে থাকা পর্যন্তpush_backপুরোনো reference অবৈধ করে না, তাইfirstকারিগরিভাবে বৈধ থাকে। কিন্তু এটি ভঙ্গুর: কেউ পরে লুপ ২০০ বার চালালে বা reserve-এর সংখ্যা বদলালে বাগ ফিরে আসবে, আর কোনো compile error হবে না। তাই index-ই ভালো নকশা;reserveরাখুন গতির জন্য।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমvector-এর একটি উপাদানের reference রেখে push_back করলে যা ঘটে, সেই ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- vector-এর একটি উপাদানের reference নেওয়া হয়
- push_back ডাকা হয়, কিন্তু capacity ফুরিয়ে গেছে
- নতুন বড় মেমরি নেওয়া হয়, উপাদানগুলো সেখানে সরানো হয়
- পুরোনো মেমরি ফেরত দেওয়া হয়, reference সেখানেই থেকে যায়
- পুরোনো reference দিয়ে লেখা হয় — undefined behavior
C++-এ একটি temporary object (যেমন a + b-এর ফলের std::string) সাধারণত কখন ধ্বংস হয়?
- যে block-এ তৈরি, সেই block শেষ হলে
- প্রোগ্রাম শেষ হলে বা delete করলে
- যে full-expression-এ তৈরি, তার শেষে
- তৈরি হওয়ার সাথে সাথেই, পরের অপারেশনের আগে
C++-এ কোন ভ্যারিয়েবলটির storage duration static, অর্থাৎ প্রোগ্রাম শেষ পর্যন্ত বাঁচে?
- ফাংশনের ভেতরে
int count = 0; - ফাংশনের ভেতরে
static int count = 0; int* p = new int(0);-এর heap object- thread_local int count = 0;
C++-এ std::string_view v = std::string("Rahim") + " Uddin"; লেখার পরের লাইনে v প্রিন্ট করলে কী হয়?
- temporary-র আয়ু v-এর সমান হয়, তাই নিরাপদে “Rahim Uddin” ছাপা হয়
- v নিজের ভেতরে লেখাটির কপি রাখে, তাই নিরাপদ
- Compile error হয়
- Undefined behavior — v একটি ধ্বংস হওয়া string-এর দিকে তাকিয়ে আছে
C++-এ int& r = v[0]; নেওয়ার পর v.push_back(x); করা হলো (v একটি std::vector<int>). r সম্পর্কে কোনটি সঠিক?
- Reallocation হলে r অবৈধ হয়ে যায়, আর তখন r ব্যবহার করা undefined behavior
- r নিজে থেকে নতুন মেমরির v[0]-এর দিকে সরে যায়
- push_back-এর পর r সবসময় অবৈধ, এমনকি reallocation না হলেও
- Compiler push_back লাইনে compile error দেয়
একটি C++ ফাংশন ভেতরে একটি নতুন std::string তৈরি করে ফল হিসেবে দেয়। সবচেয়ে নিরাপদ আর সাধারণত দক্ষ ফেরতের টাইপ কোনটি?
const std::string&std::stringstd::string_viewstd::string*(local-এর ঠিকানা)
C++-এ Order* raw = p.get(); p.reset(); raw->id; কোড -fsanitize=address দিয়ে চালালে AddressSanitizer সাধারণত কোন ধরনের ভুল জানায়?
- stack-use-after-return
- heap-buffer-overflow
- heap-use-after-free
- কোনো ভুল জানায় না, কারণ কোডটি compile হয়েছে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- প্রতিটি object-এর আয়ু আছে। শেষের পরে reference, pointer, iterator বা view দিয়ে ছুঁলে undefined behavior — আর ভুলটি প্রায়ই লুকিয়ে থাকে।
- চার রকম storage duration: automatic (block), static (প্রোগ্রাম), dynamic (মালিকের হাতে), thread (thread).
- Temporary মরে full-expression-এর শেষে। সরাসরি
const&-এ আয়ু বাড়ে; ফাংশনের ফেরত reference বাstring_view-এর মধ্য দিয়ে নয়। - Vector বদলালে পুরোনো ঠিকানা অবিশ্বাস্য। দীর্ঘ সময় মনে রাখতে index রাখুন; বাইরে যাওয়া lambda-য়
[&]নয়;get()-এর pointer জমাবেন না। - পর্যবেক্ষক কম, মালিক বেশি: by value ফেরত (RVO), member-এ মালিক টাইপ, view শুধু প্যারামিটারে — আর
-Wallও ASan সবসময়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ১৯ · মাঝপথের প্রজেক্ট — একটা CLI টুলপরবর্তী পর্বমডিউল ১–৩-এর সব শেখা একটি আসল প্রোগ্রামে।
- C++ প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- C প্রোগ্রামিং · Undefined Behavior ও নিরাপদ কোডিংগভীরে যানচুক্তি ভাঙলে compiler কী করে, আর sanitizer দিয়ে কীভাবে ধরবেন।
- C প্রোগ্রামিং · স্কোপ ও স্টোরেজ ক্লাসপাশের ট্র্যাককোথায় দেখা যায় আর কতক্ষণ বাঁচে — C-র দৃষ্টিতে।