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

Undefined Behavior ও নিরাপদ C++ — চুক্তি ভাঙলে কী হয়, কীভাবে ধরবেন

Undefined Behavior and Safe C++ — What Breaking the Contract Means, and How to Catch It

Taught in Bangla, this lesson explains undefined behavior as a broken contract: how it differs from unspecified and implementation-defined behavior, why optimizers exploit it, common cases like signed overflow, out-of-bounds access and strict aliasing, detection with sanitizers, clang-tidy and fuzzing, and safe C++ habits.

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

  • Undefined, unspecified আর implementation-defined behavior-এর পার্থক্য — একটি করে সহজ উদাহরণসহ
  • C++-এর দশটি প্রচলিত UB, আর প্রতিটির পাশে তার নিরাপদ রূপ
  • Optimizer কেন আর কীভাবে UB-এর অনুমানকে কাজে লাগায়
  • Compiler warning, চার রকম sanitizer, static analyzer, hardened library আর fuzzing — কোনটি কী ধরে
  • Memory safety নিয়ে আজকের আলোচনা, C++-এর জবাব, আর AI-এর লেখা কোডে UB খোঁজা

১ · C++ standard একটি চুক্তি — আর UB মানে আপনি নিজের অংশ ভেঙেছেন

মডিউল ৬-এর শেষ পর্বে এসে একটি প্রশ্ন করা যাক, যা এই কোর্সে বারবার ফিরে এসেছে: পর্ব ০৫-এ মান না দেওয়া ভ্যারিয়েবল, পর্ব ১৫-এ nullptr-এর ভেতরে ঢোকা, পর্ব ১৮-এ মৃত object ছোঁয়া — সব জায়গায় একই কথা লেখা ছিল, “এটি undefined behavior.” কিন্তু এর ঠিক মানে কী? “ভুল ফল আসবে”? “crash করবে”? কোনোটিই নয়। এর মানে আরও অদ্ভুত, আর সেটা বুঝলেই C++-এর অনেক রহস্য পরিষ্কার হয়ে যায়।

ধরুন আপনি একটি গার্মেন্টস কারখানার সাথে চুক্তি করলেন: “আমি প্রতিটি অর্ডারে সঠিক মাপ দেব; আপনারা যত দ্রুত সম্ভব শার্ট বানাবেন।” কারখানা এই কথার ওপর ভরসা করে কাজ সাজায় — মাপ আবার যাচাই করার লোক রাখে না, কাটার মেশিন সরাসরি আপনার মাপে চালায়। এতে কাজ খুব দ্রুত হয়। এখন আপনি একদিন ভুল মাপ পাঠালেন। কারখানা কী বানাবে? চুক্তিতে লেখা নেই। হয়তো বেমানান শার্ট, হয়তো কাপড় নষ্ট, হয়তো ভাগ্যক্রমে ঠিক শার্টই। চুক্তির বাইরে যা ঘটে, তার দায় কারখানা নেয় না। C++-এ আপনি হলেন অর্ডারদাতা, compiler হলো কারখানা, আর ভুল মাপের নাম undefined behavior.

C++ standard দুই পক্ষের দায়িত্ব লিখে রাখে। আপনার দায়িত্ব: array-র সীমার ভেতরে থাকবেন, signed সংখ্যার হিসাব সীমা ছাড়াবে না, মৃত object ছোঁবেন না, এমন আরও কিছু শর্ত। Compiler-এর দায়িত্ব: আপনি শর্ত মানলে প্রোগ্রাম ঠিক standard-এ লেখা আচরণ করবে। যে প্রোগ্রাম কোনো শর্ত ভাঙে, তার জন্য standard কোনো আচরণই দাবি করে না — একটুও না। Standard-এর ভাষায় তখন “this document imposes no requirements”.

এর সবচেয়ে গুরুত্বপূর্ণ ফল হলো উল্টো দিকে: compiler ধরে নিতে পারে যে UB কখনো ঘটবে না, আর সেই ধারণার ওপর ভিত্তি করে কোড অপটিমাইজ করতে পারে। কারখানা যেমন ধরে নেয় মাপ সবসময় ঠিক, তাই যাচাইয়ের ধাপ বাদ দেয়। এজন্যই UB-এর ফল “যা খুশি” — শুধু ভুল সংখ্যা নয়, কোডের কোনো অংশ পুরো উধাও হয়ে যাওয়া, অন্য কোনো লাইনের আচরণ বদলে যাওয়া, কিংবা আজ সব ঠিক চলে পরের compiler সংস্করণে ভেঙে পড়া। এর সবচেয়ে বিপজ্জনক রূপ হলো শেষটি: “seems to work” — আপনার মেশিনে, আপনার টেস্টে, আজ।

মূল ধারণা

UB কোনো “ত্রুটির ফল” নয়, এটি “নিয়মহীন এলাকা।” এখানে compiler-এর কোনো দায় নেই, তাই চালিয়ে দেখে কিছু শেখা যায় না — একবার চালিয়ে ঠিক ফল পেলেও প্রোগ্রামটি সঠিক হয়ে যায় না। UB-এর বিরুদ্ধে একমাত্র উপায় হলো ঘটার আগেই আটকানো, আর টুল দিয়ে খুঁজে বের করা।

C জানলে

UB-এর ধারণা আর বেশিরভাগ তালিকা C-র মতোই — signed overflow, সীমার বাইরে পড়া, null pointer, মান না দেওয়া ভ্যারিয়েবল। C++ এর সাথে যোগ করেছে নতুন উৎস (object-এর lifetime, iterator invalidation, dangling view, ODR ভাঙা) আর নতুন নিরাপদ যন্ত্র (.at(), std::span, RAII, std::bit_cast, std::numeric_limits). Optimizer আর sanitizer-এর দিকটি গভীরভাবে দেখতে C কোর্সের Undefined Behavior ও নিরাপদ কোডিং পর্বটি পড়ুন।

২ · তিন রকম “অনিশ্চয়তা” — শুধু একটিই বিপজ্জনক

Standard সব কিছু শতভাগ বেঁধে দেয় না। কিছু জায়গায় ইচ্ছা করেই ফাঁক রাখে, যাতে C++ ছোট মাইক্রোকন্ট্রোলার থেকে সুপারকম্পিউটার পর্যন্ত সব মেশিনে দক্ষভাবে চলতে পারে। কিন্তু সব ফাঁক এক রকম নয়। তিনটি শব্দ আলাদা করে চিনে রাখুন:

Implementation-defined

Compiler নিজে একটি আচরণ বেছে নেয়, আর ডকুমেন্টেশনে লিখে জানাতে বাধ্য। উদাহরণ: sizeof(int) কত, বা সাধারণ char signed নাকি unsigned. একই compiler আর মেশিনে ফল সবসময় একই। এর নাম implementation-defined behavior.

নিরাপদ, তবে portable নয়

Unspecified

কয়েকটি বৈধ ফলের যেকোনো একটি হতে পারে, compiler-কে জানাতেও হয় না। উদাহরণ: f(a(), b())-এ a() আগে চলবে নাকি b(). দুটোই চলবে, ক্রম নিশ্চিত নয়। এর নাম unspecified behavior.

নিরাপদ, তবে ক্রমের ওপর ভরসা নয়

Undefined

কোনো নিয়মই নেই; পুরো প্রোগ্রামের অর্থ হারিয়ে যায়। উদাহরণ: int-এর সর্বোচ্চ মানে ১ যোগ করা। সম্ভাব্য ফলের কোনো তালিকা নেই, আর প্রভাব শুধু ওই লাইনে সীমিত থাকে না।

বিপজ্জনক — সবসময় এড়ান

প্রথম দুটি প্রোগ্রামকে ভাঙে না — শুধু আপনাকে সতর্ক থাকতে বলে। sizeof(int) ৪ ধরে নিয়ে লেখা কোড অন্য মেশিনে ভুল হতে পারে, তাই নির্দিষ্ট মাপ লাগলে std::int32_t ব্যবহার করুন (পর্ব ০৫)। আর ফাংশনের প্যারামিটারের ক্রমের ওপর নির্ভর না করতে চাইলে a() আর b()-এর ফল আগে আলাদা ভ্যারিয়েবলে রাখুন। কিন্তু তৃতীয়টি অন্য জাতের। নিচের ডায়াগ্রামে কয়েকটি পরিচিত উদাহরণ সাজিয়ে দেখুন:

sizeof(int)৪ বা অন্য কিছু — লেখা থাকে
char signed কি নাx86-এ সাধারণত signed, অনেক ARM Linux-এ unsigned
f(a(), b())-এর ক্রমযেকোনোটি আগে
a + b-তে কোনটি আগে হিসাবদুটোই হবে, ক্রম অনিশ্চিত
INT_MAX + 1signed overflow
i++ + i++একই ভ্যারিয়েবল দুবার বদলানো, ক্রম ছাড়া
  1. Implementation-defined. GCC আর MSVC দুটোই ডকুমেন্টেশনে লিখে দেয় int কত বাইট। আপনার কম্পিউটারে প্রায় নিশ্চিতভাবে ৪, কিছু পুরোনো বা ছোট চিপে ২. ফল নির্দিষ্ট, শুধু মেশিনভেদে আলাদা।
  2. Unspecified. f(a(), b())-এ দুটি ফাংশনই চলবে, প্রতিটি ঠিক একবার — শুধু ক্রমটি compiler-এর ইচ্ছা। a() আর b() দুটোই যদি স্ক্রিনে কিছু ছাপে, ছাপার ক্রম বদলাতে পারে। প্রোগ্রাম ভাঙে না।
  3. Undefined. int-এর সর্বোচ্চ মানে ১ যোগ করলে “ঘুরে ঋণাত্মক” হবে — এটা অনেকে মনে করেন, কিন্তু standard তা বলে না। C++20 থেকে signed সংখ্যার ভেতরের গঠন two's complement বাধ্যতামূলক, তবুও overflow এখনো UB.
  4. আরও তিনটি, একসাথে। char-এর চিহ্ন implementation-defined. সাধারণ a + b-এ বাম আর ডান পাশের হিসাবের ক্রম unspecified. কিন্তু i++ + i++ — একই ভ্যারিয়েবলকে একটি expression-এ ক্রমহীনভাবে দুবার বদলানো — undefined. পরীক্ষা বা ইন্টারভিউতে এমন প্রশ্ন এলে সঠিক উত্তর “আউটপুট অনুমান করা যায় না।”

“আমার মেশিনে তো 5 আসে” কোনো উত্তর নয়। Implementation-defined প্রশ্নে আপনার compiler-এর উত্তর বৈধ। Unspecified প্রশ্নে সেটি সম্ভাব্য উত্তরগুলোর একটি। কিন্তু UB-তে আপনার মেশিনের ফল কিছুই প্রমাণ করে না — অন্য optimization স্তরে, অন্য compiler-এ, এমনকি একই কোডের পাশে একটি নতুন লাইন যোগ করলেও ফল বদলে যেতে পারে।

৩ · Optimizer কেন UB-কে কাজে লাগায়

অনেকে প্রথমবার শুনে রেগে যান: “Compiler ভুলটা ধরতে পারলে আমাকে বলে না কেন, বরং কোড মুছে দেয় কেন?” আসল কথা হলো, compiler কোনো ভুল “ধরে” না। সে শুধু একটি যুক্তিসঙ্গত ধারণা থেকে অঙ্ক কষে: এই প্রোগ্রাম বৈধ, তাই এখানে UB ঘটে না। সেই ধারণা থেকে যা সত্য হতে বাধ্য, সেটুকু ব্যবহার করে সে কোড ছোট আর দ্রুত বানায়। এর নাম optimization, আর standard-এর যে নিয়ম এটা অনুমতি দেয় তার নাম as-if rule: বৈধ প্রোগ্রামের দেখা যায় এমন আচরণ ঠিক রাখলে compiler ভেতরে যা খুশি বদলাতে পারে।

সবচেয়ে বিখ্যাত উদাহরণটি একটি নিরীহ “overflow যাচাই।” প্রোগ্রামার ভাবলেন, x যদি সর্বোচ্চ মানে থাকে, x + 1 ঘুরে ঋণাত্মক হবে, তাই x + 1 < x সত্য হবে। ধাপে ধাপে দেখুন compiler কী ভাবে:

1bool willOverflow(int x) {2    return x + 1 < x;   // overflow হলে true?3}4// -O2-এর পরে কার্যত যা থাকে:5bool willOverflow(int) { return false; }6// নিরাপদ: return x == INT_MAX;
x = 2147483647 (INT_MAX) হলে
x + 1
x + 1 < x
ফাংশনের ফল
  1. প্রোগ্রামারের ধারণা। মেশিনে যোগ করলে 2147483647 + 1 সত্যিই ঘুরে -2147483648 হয় — হার্ডওয়্যার তাই করে। তাই মনে হয় x + 1 < x ঠিক এই অবস্থাটি ধরবে।
  2. Compiler-এর নিয়ম। কিন্তু compiler হার্ডওয়্যারের কথা ভাবে না, standard-এর কথা ভাবে। Signed integer overflow হলো UB. বৈধ প্রোগ্রামে UB ঘটে না, তাই compiler ধরে নেয় x + 1 কখনো overflow করে না।
  3. অঙ্ক কষা। Overflow না হলে x + 1 সাধারণ গণিতের মতোই x-এর চেয়ে ১ বেশি। তাহলে x + 1 < x মানে 1 < 0 — যা কখনো সত্য নয়। পুরো শর্তটি একটি ধ্রুবক: false.
  4. যাচাই উধাও। GCC আর Clang সাধারণত -O2-এ ফাংশনটিকে “সবসময় false ফেরত দাও”-তে নামিয়ে আনে। কোনো warning ছাড়াই। -O0-এ (debug) হয়তো যোগটি সত্যিই হয় আর true আসে। ফলে debug-এ টেস্ট পাস করে, release-এ যাচাইটি কাজ করে না — এটাই UB-এর চেনা চেহারা।
  5. নিরাপদ রূপ: যোগ করার আগেই যাচাই। x == INT_MAX (বা std::numeric_limits<int>::max()) তুলনায় কোনো overflow নেই, তাই optimizer-এর মুছে ফেলার কিছু নেই। আর আগের রূপটি -fsanitize=undefined দিয়ে চালালে চলার সময় সরাসরি signed integer overflow রিপোর্ট আসে।

এটা কেন “ভালো” নিয়ম? কারণ একই যুক্তি প্রতিদিন লাখো জায়গায় আসল লাভ দেয়। একটি লুপ for (int i = 0; i < n; ++i)-এ compiler ধরে নিতে পারে i কখনো ঘুরে ঋণাত্মক হবে না, তাই সে i-কে ৬৪-বিট রেজিস্টারে চওড়া করে, ঠিকানার হিসাব সরল করে, আর লুপকে vector নির্দেশে (একসাথে কয়েকটি সংখ্যা) ভাঙে। যদি overflow-এর একটি নির্দিষ্ট আচরণ থাকত, প্রতিটি লুপে সেই সম্ভাবনা মাথায় রেখে ধীর কোড বানাতে হতো। C আর C++ ঐতিহাসিকভাবে এই বিনিময়টি বেছেছে: দায়িত্ব প্রোগ্রামারের, গতি সবার।

Null যাচাইও মুছে যেতে পারে। একই যুক্তির আরেক রূপ: কোনো ফাংশনে আগে p->size পড়া হলো, তার পরে if (p == nullptr) return;. Compiler ভাবে, “null-এর ভেতরে ঢোকা UB, প্রোগ্রাম ঢুকেছে, তাই p null নয়” — আর পরের যাচাইটি মুছে দিতে পারে। শিক্ষা: যাচাই সবসময় ব্যবহারের আগে, পরে নয়।

৪ · UB-এর তালিকা — ভুল রূপ আর নিরাপদ রূপ পাশাপাশি

C++ standard-এ UB-এর জায়গা শত শত, কিন্তু বাস্তব কোডের বেশিরভাগ বাগ দশটির মতো চেনা ধাঁচ থেকে আসে। নিচের ছোট কোডগুলো চালানোর জন্য নয় — শুধু চেনার জন্য। প্রতিটির পাশে তার নিরাপদ রূপ দেওয়া আছে।

১. Signed integer overflow

পর্ব ০৫-এ দেখেছেন, int সাধারণত প্রায় ২১৪ কোটি পর্যন্ত ধরে। এর বেশি হলেই overflow. Unsigned টাইপে overflow UB নয় — মান ইচ্ছা করেই ঘুরে শূন্যের দিকে যায় (modular arithmetic). তাই hash বা checksum-এর মতো জায়গায় যেখানে ঘুরে যাওয়াই উদ্দেশ্য, সেখানে unsigned সচেতনভাবে ব্যবহার করুন; কিন্তু টাকা, নম্বর, গণনার মতো জায়গায় ঘুরে যাওয়া মানেই ভুল।

int total = marksA * marksB;               // ভুল: গুণফল int ছাড়াতে পারে
long long total = 1LL * marksA * marksB;   // নিরাপদ: চওড়া টাইপে হিসাব
if (b > 0 && a > INT_MAX - b) { /* সামলান */ }  // নিরাপদ: আগে যাচাই

C++26-এ <numeric> header-এ saturation arithmetic-এর ফাংশন (std::add_sat, std::sub_sat ইত্যাদি) যোগ হয়েছে: ফল সীমা ছাড়ালে সর্বোচ্চ বা সর্বনিম্ন মানে আটকে থাকে, ঘোরে না। আপনার compiler-এ এখনো না থাকলে GCC আর Clang-এর __builtin_add_overflow বা হাতে লেখা যাচাই ব্যবহার করুন (অংশ ৫)।

২. সীমার বাইরে পড়া বা লেখা

v[i] কোনো সীমা যাচাই করে না — এটিই C++-এর “শূন্য খরচ” নীতি। i == v.size() হলেও সে চুপচাপ পাশের মেমরি পড়ে। এই out-of-bounds access থেকেই buffer overflow, ইতিহাসের সবচেয়ে বেশি দেখা নিরাপত্তা-ত্রুটির একটি।

for (std::size_t i = 0; i <= v.size(); ++i) use(v[i]);  // ভুল: <= মানে একটি বেশি
for (const auto& x : v) use(x);                          // নিরাপদ: index-ই নেই
use(v.at(i));                                            // নিরাপদ: ভুল হলে exception

Range-for (পর্ব ০৮) সবচেয়ে ভালো, কারণ ভুল index লেখার সুযোগই থাকে না। ফাংশনে array বা vector-এর অংশ পাঠাতে std::span নিন — সে ঠিকানার সাথে দৈর্ঘ্যও বয়ে বেড়ায়। আর যেখানে index লাগবেই, সেখানে .at() সীমা যাচাই করে std::out_of_range ছোড়ে। খেয়াল করুন, span-এর []-ও সাধারণ মোডে যাচাই করে না; সেটির জন্য hardened মোড লাগে (অংশ ৬)।

৩. Null pointer-এর ভেতরে ঢোকা

পর্ব ১৫-এ দেখেছেন, nullptr কোনো object-এর দিকে তাকিয়ে নেই। *p বা p->x করলে null dereference — সাধারণত crash, কিন্তু standard তা নিশ্চিত করে না, আর অংশ ৩-এর নোট দেখিয়েছে যাচাই মুছে যাওয়ার সম্ভাবনাও। নিরাপদ রূপ: যেখানে “না-ও থাকতে পারে” বোঝাতে চান, সেখানে pointer-এর বদলে std::optional বা reference ব্যবহার করুন; pointer লাগলে ব্যবহারের আগে যাচাই।

৪. Use-after-free আর dangling

মৃত object-কে reference, pointer, iterator বা view দিয়ে ছোঁয়া। এর পুরো বিবরণ — temporary, string_view, vector-এর reallocation, lambda capture — পর্ব ১৮-এ (lifetime ও dangling) দেখেছেন, এখানে পুনরাবৃত্তি করছি না। মনে রাখার নিয়ম একটাই: পর্যবেক্ষক কখনো মালিকের চেয়ে বেশি দিন বাঁচবে না। ASan এই ভুলকে use-after-free নামে ধরে।

৫. মান না দেওয়া ভ্যারিয়েবল পড়া

int total;            // ভুল: মান নেই, যা ছিল তাই
total += price;       // আবর্জনার সাথে যোগ
int total = 0;        // নিরাপদ: সবসময় শুরুতেই মান
int total{};          // নিরাপদ: {} মানে শূন্য

Uninitialized variable পড়া C++23 পর্যন্ত UB. C++26-এর জন্য একটি পরিবর্তন গৃহীত হয়েছে: local ভ্যারিয়েবলের মান না দিয়ে পড়া এখন erroneous behavior — “ভুল, কিন্তু নিয়মহীন নয়।” ভ্যারিয়েবলটি তখন compiler-এর ঠিক করা একটি নির্দিষ্ট মান পায়, আর compiler বা টুল চাইলে ভুলটি জানাতে পারে। এটা নিরাপত্তা বাড়ায়, কিন্তু কোডকে সঠিক বানায় না — total শূন্য না হয়ে অন্য কোনো মান হলে হিসাব তবুও ভুল। তাই নিয়ম বদলায় না: সবকিছু initialize করুন।

৬. Data race

দুটি thread একই ভ্যারিয়েবলে একসাথে কাজ করছে, অন্তত একটি লিখছে, আর মাঝে কোনো mutex বা atomic নেই — এর নাম data race, আর C++11-এর memory model অনুযায়ী এটি UB. শুধু “একটু ভুল সংখ্যা” নয়: compiler ধরে নিতে পারে অন্য কেউ ভ্যারিয়েবলটি বদলায় না, তাই মান রেজিস্টারে রেখে লুপ চিরকাল চালাতে পারে। এর নিরাপদ রূপ — std::mutex, std::atomic — পর্ব ৪৫ আর ৪৬-এ দেখবেন।

৭. Strict aliasing — অন্য টাইপের চোখে মেমরি পড়া

একটি float-এর ভেতরের বিটগুলো std::uint32_t হিসেবে দেখতে চান? পুরোনো ধাঁচে মানুষ pointer cast করত। কিন্তু C++-এর strict aliasing নিয়ম বলে: কোনো object-কে সাধারণত তার নিজের টাইপ (বা char, unsigned char, std::byte) দিয়েই পড়তে হবে। অন্য টাইপের pointer দিয়ে পড়া UB, কারণ compiler ধরে নেয় ভিন্ন টাইপের দুটি pointer একই মেমরির দিকে তাকায় না (পর্ব ১০-এর aliasing মনে করুন)।

float f = 1.0f;
auto u = *reinterpret_cast<std::uint32_t*>(&f);  // ভুল: strict aliasing ভাঙা
auto u = std::bit_cast<std::uint32_t>(f);          // নিরাপদ: C++20, <bit>
std::uint32_t u; std::memcpy(&u, &f, sizeof u);    // নিরাপদ: পুরোনো উপায়

এই কাজটিকে বলে type punning. std::bit_cast একই মাপের দুটি টাইপের মধ্যে বিটগুলো হুবহু কপি করে একটি নতুন object দেয় — কোনো aliasing নেই, আর compiler সাধারণত বাড়তি কোনো কাজও করে না।

৮–১০. আরও তিনটি চেনা ফাঁদ

UBভুল রূপকেন UBনিরাপদ রূপ
বেশি shift1 << n, যেখানে n ৩২ বা বেশিটাইপের বিট-সংখ্যার সমান বা বেশি, কিংবা ঋণাত্মক পরিমাণে shift করা UB; ভিন্ন CPU ভিন্ন আচরণ করেআগে n যাচাই করুন; ৬৪ বিট লাগলে 1ULL << n, আর n < 64 নিশ্চিত করুন
string literal বদলানোconst_cast দিয়ে "Dhaka"-এর অক্ষর বদলানোliteral সাধারণত শুধু-পড়ার মেমরিতে থাকে; C++11 থেকে char* s = "Dhaka"; লেখাই বৈধ নয়বদলাতে চাইলে নিজের কপি: std::string s = "Dhaka";
পার্শ্বপ্রতিক্রিয়াহীন অসীম লুপএমন লুপ যা কিছু পড়ে-লেখে না, I/O বা atomic কিছু করে না, আর কখনো থামে নাC++ ধরে নেয় এমন লুপ শেষ পর্যন্ত থামবে (forward progress); optimizer লুপটি মুছে দিতে পারেলুপে থামার শর্ত রাখুন; অপেক্ষা করতে হলে std::this_thread::sleep_for বা condition variable

শেষটি একটু ব্যাখ্যা চায়। Standard-এর forward progress নিয়ম অনুযায়ী, যে লুপ কোনো side effect (I/O, volatile বা atomic কাজ) ঘটায় না, compiler ধরে নিতে পারে সেটি একসময় শেষ হবে। ফলে ভুল করে লেখা অসীম infinite loop পুরো উধাও হয়ে যেতে পারে, আর প্রোগ্রাম এমন জায়গায় চলে যায় যেখানে যাওয়ার কথা নয়। C++26-এর জন্য একটি পরিবর্তন গৃহীত হয়েছে, যেখানে একদম ফাঁকা while (true) {}-এর মতো সরল লুপকে এই নিয়মের বাইরে রাখা হয়েছে, তবে সাধারণ নীতিটি একই আছে।

ব্যবহারের আগে যাচাই · চওড়া টাইপে হিসাব · index-এর বদলে range · সবকিছুর শুরুতে মান · মালিক আগে, view পরে

৫ · তিনটি বাস্তব বাগ — নম্বরের গড়, রসিদ প্রিন্টার আর শপিং কার্ট

তালিকা মুখস্থ করে লাভ কম, যদি বাস্তব কোডে ভুলটি চেনা না যায়। এখানে তিনটি ছোট গল্প — প্রতিটি সত্যিকারের প্রজেক্টে দেখা যায় এমন ধাঁচের।

গল্প ১ — জাতীয় পরীক্ষার গড় নম্বর

একটি শিক্ষা বোর্ডের সফটওয়্যার সব শিক্ষার্থীর মোট নম্বর যোগ করে গড় বের করে। লেখা হয়েছিল int sum = 0; for (int m : marks) sum += m;. পরীক্ষামূলক ফাইলে ৫০০ জন শিক্ষার্থী, সব ঠিক। আসল দিনে বিশ লাখ শিক্ষার্থী, প্রত্যেকের মোট নম্বর গড়ে ধরুন ১,১০০ — যোগফল দাঁড়ায় প্রায় ২২০ কোটি, যা int-এর প্রায় ২১৪ কোটির সীমার বাইরে। এটি signed overflow, অর্থাৎ UB; বাস্তবে সাধারণত একটি অর্থহীন ঋণাত্মক গড় আসে। সমাধান: যোগফল রাখুন চওড়া টাইপে, আর যেখানে সীমা নিশ্চিত নয় সেখানে যাচাই করুন। নিচের প্রোগ্রামে দুটি বোর্ডের যোগফল দিয়ে তিনটি নিরাপদ উপায় দেখুন:

#include <limits>
#include <optional>
#include <print>
#include <vector>

std::optional<int> checkedAdd(int a, int b) {
    if (b > 0 && a > std::numeric_limits<int>::max() - b) return std::nullopt;
    if (b < 0 && a < std::numeric_limits<int>::min() - b) return std::nullopt;
    return a + b;                                // safe: checked before adding
}

int main() {
    std::vector<int> boardTotals{1'200'000'000, 1'000'000'000};
    auto s = checkedAdd(boardTotals[0], boardTotals[1]);
    std::println("fits in int? {}", s.has_value());
    long long wide = 0;
    for (int t : boardTotals) wide += t;         // wider type: no overflow
    std::println("long long sum = {}", wide);
    int r = 0;
    bool over = __builtin_add_overflow(boardTotals[0], boardTotals[1], &r);
    std::println("builtin reports overflow: {}", over);
}

আউটপুটের তিনটি লাইন তিনটি কৌশল। fits in int? false — checkedAdd যোগ করার আগেই দেখে নেয় ফল সীমার ভেতরে থাকবে কি না: a > max - b তুলনায় কোনো overflow হয় না, কারণ b ধনাত্মক হলে max - b সবসময় সীমার ভেতরে। ফল না ধরলে std::nullopt — পর্ব ৩৮-এর std::optional দিয়ে “ফল নেই” বোঝানো। long long sum = 2200000000 — চওড়া টাইপে যোগ করলে সমস্যাই থাকে না; বড় যোগফলের জন্য এটাই সবচেয়ে সরল উপায়। শেষ লাইন builtin reports overflow: true — GCC আর Clang-এর __builtin_add_overflow যোগ করে ফল r-এ রাখে আর জানায় overflow হয়েছে কি না, কোনো UB ছাড়াই। এটি standard C++ নয় (MSVC-তে নেই), তবে খুব প্রচলিত। লক্ষ করুন, তিনটির কোনোটিতেই আমরা UB ঘটিয়ে “দেখি কী হয়” করিনি।

গল্প ২ — রসিদ প্রিন্টারের একটি বেশি ঘর

একটি মুদি দোকানের রসিদ প্রিন্টার লুপে i <= items.size() লেখে — একটি off-by-one ভুল। শেষ ঘরের পরের মেমরি থেকে একটি “দাম” পড়া হয়। কখনো রসিদের শেষে একটি অচেনা সংখ্যা ছাপা হয়, কখনো কিছুই হয় না, কারণ পাশের মেমরিতে ঘটনাক্রমে শূন্য ছিল। এই ভুলটি সাধারণ মোডে নিঃশব্দ — তাই ডেভেলপমেন্টের সময় আমরা চাই এটি জোরে চিৎকার করুক। নিচে .at() দিয়ে সেই চিৎকার, range-for দিয়ে ভুলের সুযোগই বন্ধ করা, আর সাথে std::bit_cast দিয়ে নিরাপদ type punning:

#include <bit>
#include <cstdint>
#include <print>
#include <stdexcept>
#include <vector>

int main() {
    std::vector<int> prices{120, 45, 300};          // valid index: 0, 1, 2
    try {
        for (std::size_t i = 0; i <= prices.size(); ++i)   // bug: <= goes one too far
            std::println("item {}: {} Tk", i, prices.at(i));
    } catch (const std::out_of_range&) {
        std::println("caught: index past the end");
    }
    int sum = 0;
    for (int p : prices) sum += p;                  // range-for: no index to get wrong
    std::println("total: {} Tk", sum);
    auto bits = std::bit_cast<std::uint32_t>(1.0f); // safe type punning (C++20)
    std::println("bits of 1.0f: {:#010x}", bits);
}

প্রথম তিনটি লাইন item 0: 120 Tk, item 1: 45 Tk, item 2: 300 Tk — বৈধ index. চতুর্থবার i == 3, আর prices.at(3) সীমা যাচাই করে std::out_of_range ছোড়ে; আমরা ধরে caught: index past the end ছাপি। একই জায়গায় prices[3] লিখলে কোনো খবর হতো না — নিঃশব্দ UB. পরের লাইন total: 465 Tk — range-for-এ কোনো index নেই, তাই off-by-one লেখার সুযোগই নেই। শেষ লাইন bits of 1.0f: 0x3f800000 — IEEE 754 নিয়মে 1.0f-এর ভেতরের বিট। std::bit_cast এগুলো pointer cast ছাড়াই, strict aliasing না ভেঙে, বের করে দেয়। (Exception আর try/catch-এর বিস্তারিত পর্ব ৩৭-এ দেখেছেন।)

গল্প ৩ — শপিং কার্টের মোট টাকা

একটি অনলাইন দোকানের কার্টে মোট টাকা হিসাবের ফাংশন int total; দিয়ে শুরু হয়েছিল, মান ছাড়া। ডেভেলপারের কম্পিউটারে stack-এর ওই জায়গায় ঘটনাক্রমে শূন্য ছিল, তাই সব টেস্ট পাস করল। গ্রাহকের ফোনে release সংস্করণে কখনো কখনো মোট টাকা হাজার হাজার টাকা বেশি আসত। এই বাগটিকে আমরা অংশ ৭-এ “নিরাপদ সিঁড়ি” দিয়ে ধাপে ধাপে ধরব আর ঠিক করব।

পরীক্ষা ও ইন্টারভিউতে

প্র: undefined, unspecified আর implementation-defined-এর পার্থক্য কী? উ: implementation-defined-এ compiler একটি আচরণ বেছে লিখে জানায়; unspecified-এ কয়েকটি বৈধ ফলের যেকোনো একটি; undefined-এ কোনো নিয়মই নেই।

প্র: INT_MAX + 1 আর UINT_MAX + 1u-এর ফল কী? উ: প্রথমটি UB; দ্বিতীয়টি নির্দিষ্টভাবে 0, কারণ unsigned হিসাব modular.

প্র: i++ + i++-এর আউটপুট কী? উ: অনুমান করা যায় না — একই ভ্যারিয়েবল ক্রমহীনভাবে দুবার বদলানো UB.

প্র: debug-এ চলে, release-এ ভুল ফল দেয় — প্রথম সন্দেহ কী? উ: UB; optimizer UB-এর অনুমান কাজে লাগিয়ে কোড বদলেছে। -fsanitize=address,undefined দিয়ে চালান।

প্র: float-এর বিট uint32_t হিসেবে নিরাপদে পড়বেন কীভাবে? উ: C++20-এ std::bit_cast, তার আগে std::memcpy; pointer cast নয়।

৬ · ধরার যন্ত্র — warning থেকে fuzzing পর্যন্ত

UB চালিয়ে দেখে ধরা যায় না, কিন্তু টুল দিয়ে ধরা যায়। প্রতিটি টুল একটি আলাদা দিক থেকে তাকায় — কেউ কোড পড়ে (compile-এর সময়), কেউ প্রোগ্রাম চলার সময় পাহারা দেয়। কোনোটিই সব ধরে না, তাই পেশাদাররা কয়েকটি একসাথে চালান।

স্তর ১ — compiler warning

পর্ব ০৩-এ -Wall -Wextra শিখেছেন। UB-এর জন্য আরও দুটি যোগ করুন: -Wpedantic (standard-এর বাইরের extension ব্যবহার করলে জানায়) আর -Wconversion (চুপচাপ ছোট টাইপে রূপান্তর, যেমন long long থেকে int). আর CI (প্রতিটি পরিবর্তনে স্বয়ংক্রিয় build ও টেস্ট)-এ -Werror দিন, যাতে নতুন warning এলে build ব্যর্থ হয় — warning জমতে জমতে কেউ আর পড়ে না, এই রোগ এভাবে বন্ধ হয়।

g++ -std=c++23 -Wall -Wextra -Wpedantic -Wconversion -O2 cart.cpp -o cart
g++ ... -Werror        # CI-তে: warning মানেই build ব্যর্থ

স্তর ২ — sanitizer: চলার সময়ের পাহারাদার

Sanitizer হলো compiler-এর একটি সুবিধা, যা প্রোগ্রামের ভেতরে বাড়তি যাচাইয়ের কোড বসায়। প্রোগ্রাম চলার সময় UB ঘটলেই সে থামিয়ে বিস্তারিত রিপোর্ট দেয়। GCC আর Clang-এ চারটি প্রধান sanitizer:

SanitizerFlagযা ধরেমনে রাখুন
AddressSanitizer (ASan)-fsanitize=addressheap ও stack-এ সীমার বাইরে পড়া-লেখা, use-after-free, double free, মেমরি leak (Linux-এ)প্রোগ্রাম মোটামুটি দুই গুণ ধীর হয়
UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefinedsigned overflow, বেশি shift, null-এর ভেতরে ঢোকা, শূন্য দিয়ে ভাগ, ভুল টাইপে রূপান্তরের মতো ভাষার UBখরচ কম; ASan-এর সাথে একসাথে চলে
ThreadSanitizer (TSan)-fsanitize=threaddata race — দুটি thread একই মেমরিতে পাহারা ছাড়া কাজ করছেASan-এর সাথে একসাথে চলে না; পর্ব ৪৫-এ কাজে লাগবে
MemorySanitizer (MSan)-fsanitize=memoryমান না দেওয়া মেমরি পড়ে সিদ্ধান্ত নেওয়াশুধু Clang-এ (Linux); সব library-কেও এভাবে build করতে হয়

প্রতিদিনের ডেভেলপমেন্ট আর টেস্টের জন্য সবচেয়ে কাজের জোড়া হলো -fsanitize=address,undefined, সাথে -g (রিপোর্টে লাইন নম্বর) আর -fno-omit-frame-pointer (পরিষ্কার stack trace). এগুলো টেস্টের জন্য, চূড়ান্ত সংস্করণের জন্য নয়। ASan-এর রিপোর্ট প্রথমবার দেখলে ভয় লাগে, কিন্তু এর গঠন সবসময় একই। গল্প ২-এর রসিদ প্রিন্টার [] দিয়ে লেখা হলে ASan যা বলত, তার একটি সরল রূপ ধাপে ধাপে পড়ুন:

1==4242==ERROR: AddressSanitizer:2  heap-buffer-overflow3READ of size 4 ... thread T04  #0 in printReceipt receipt.cpp:75  #1 in main receipt.cpp:136located 0 bytes after7  12-byte region8allocated by thread T0 here:9  #0 in operator new10  #3 in main receipt.cpp:1211SUMMARY: AddressSanitizer:12  heap-buffer-overflow receipt.cpp:7
রিপোর্ট পড়া
প্রশ্ন
উত্তর
  1. শিরোনাম: ভুলের নাম। ==4242== প্রসেসের নম্বর, এটি উপেক্ষা করুন। আসল তথ্য heap-buffer-overflow — heap-এ রাখা একটি buffer-এর সীমার বাইরে যাওয়া। অন্য নামও আসতে পারে: stack-buffer-overflow, heap-use-after-free, double-free.
  2. কী করা হচ্ছিল। READ of size 4 — ৪ বাইট পড়া হচ্ছিল, অর্থাৎ একটি int. WRITE হলে আরও গুরুতর, কারণ পাশের ডেটা নষ্ট হয়। thread T0 মানে মূল thread.
  3. Stack trace: কোথায়। #0 হলো ঠিক যে ফাংশনে ভুলটি ঘটেছে — printReceipt, receipt.cpp-এর লাইন ৭. #1 তাকে কে ডেকেছিল — main, লাইন ১৩. সবসময় #0 থেকে নিচের দিকে পড়ুন, আর নিজের ফাইলের প্রথম লাইনটি খুঁজুন।
  4. কতটা বাইরে। “12-byte region-এর 0 bytes after” — vector-এর মেমরি ১২ বাইট, মানে ৩টি int, আর পড়া হয়েছে ঠিক শেষের পরের ঘরে। “শূন্য বাইট পরে” প্রায় সবসময় off-by-one-এর চিহ্ন: <= লেখা হয়েছে <-এর জায়গায়।
  5. মেমরির জন্মস্থান। allocated by অংশ বলে এই মেমরি কোথায় তৈরি হয়েছিল — operator new-এর মাধ্যমে, main-এর লাইন ১২-এ, যেখানে vector-টি বানানো হয়েছে। মাঝের #1, #2 standard library-র ভেতরের ধাপ, তাই এখানে বাদ দেওয়া হয়েছে।
  6. সারাংশ আর shadow byte. SUMMARY লাইনটি এক লাইনে পুরো কথা — log-এ খুঁজতে এটাই সবচেয়ে সুবিধাজনক। এর নিচে আসল রিপোর্টে “Shadow bytes” নামে একটি সংখ্যার ছক থাকে: ASan প্রতি ৮ বাইট মেমরির জন্য একটি পাহারা-বাইট রাখে (shadow memory), যেখানে 00 মানে পুরো বৈধ, 04 মানে প্রথম ৪ বাইট বৈধ, fa মানে heap-এর সীমানার নিষিদ্ধ এলাকা। আপনার ভুলের ঘরটি সেখানে [ ] বন্ধনী দিয়ে চিহ্নিত থাকে।

একটি গুরুত্বপূর্ণ সীমা: sanitizer শুধু সেই পথের UB ধরে, যে পথে প্রোগ্রাম আসলে চলেছে। রসিদের লুপ যদি টেস্টে কখনো না চলে, ASan কিছুই বলবে না। তাই sanitizer যত ভালো, তার টেস্ট তত ভালো হতে হয় — এখানেই fuzzing আসে (নিচে)।

স্তর ৩ — static analyzer: চালানো ছাড়াই কোড পড়া

Static analyzer প্রোগ্রাম না চালিয়ে কোড বিশ্লেষণ করে, সম্ভাব্য সব পথ ভেবে দেখার চেষ্টা করে। clang-tidy (LLVM প্রজেক্টের) শত শত নিয়ম জানে — bugprone-* সাধারণ বাগ, cppcoreguidelines-* Core Guidelines-এর নিয়ম, modernize-* পুরোনো ধাঁচ বদলানোর পরামর্শ। cppcheck একটি স্বাধীন ওপেন-সোর্স টুল, যা কম ভুল সতর্কবার্তা দেওয়ার দিকে জোর দেয়। Static analyzer কখনো কখনো ভুল সতর্কবার্তা দেয়, আবার কিছু বাগ ছেড়েও যায় — তবু এটি সেই পথগুলো দেখে, যেগুলো আপনার টেস্ট কখনো চালায়নি।

clang-tidy cart.cpp --checks='bugprone-*,cppcoreguidelines-*' -- -std=c++23
cppcheck --enable=warning,style cart.cpp

স্তর ৪ — hardened standard library

পর্ব ১৩-এ hardened standard library-এর কথা শুনেছেন। এখন একটু বিস্তারিত: GCC-র library (libstdc++)-তে -D_GLIBCXX_ASSERTIONS দিলে vector, string, span-এর [], front(), back() সহ অনেক জায়গায় সীমা ও শর্ত যাচাই চালু হয়; ভাঙলে প্রোগ্রাম একটি স্পষ্ট বার্তা দিয়ে থেমে যায়। Clang-এর library (libc++)-তে একই কাজ করে _LIBCPP_HARDENING_MODE (যেমন _LIBCPP_HARDENING_MODE_FAST). এর খরচ সাধারণত খুব কম, তাই কিছু বড় প্রতিষ্ঠান এটি চূড়ান্ত সংস্করণেও চালু রাখে। C++26-এ “standard library hardening” standard-এরই অংশ হিসেবে গৃহীত হয়েছে, তাই ভবিষ্যতে এটি compiler-ভেদে আরও এক রকম হবে।

এর আত্মীয় হলো -D_FORTIFY_SOURCE (সাধারণত =2 বা =3), যা glibc-র memcpy, strcpy-এর মতো C ফাংশনে compile-এর সময় জানা মাপের সাথে যাচাই যোগ করে; এটি কাজ করতে optimization (-O1 বা বেশি) লাগে। GCC 14 থেকে -fhardened নামে একটি flag এর কয়েকটি সুরক্ষা একসাথে চালু করে।

স্তর ৫ — fuzzing আর constexpr

Fuzzing একটি টেস্টিং কৌশল: একটি টুল আপনার ফাংশনকে লাখ লাখ স্বয়ংক্রিয়ভাবে বানানো, অদ্ভুত ইনপুট দেয়, আর কোন ইনপুটে নতুন কোড-পথ খোলে তা দেখে আরও ইনপুট বানায়। Sanitizer-এর সাথে জোড়া দিলে এটি এমন সীমার বাইরের পড়া বা overflow খুঁজে পায়, যা কোনো মানুষ টেস্ট লিখে ভাবতেন না। libFuzzer (LLVM) আর AFL++ এর পরিচিত টুল, আর Google-এর OSS-Fuzz ২০১৬ সাল থেকে বহু ওপেন-সোর্স প্রজেক্টকে বিনামূল্যে নিয়মিত fuzz করে আসছে। আপনার প্রজেক্টে এর সবচেয়ে ভালো লক্ষ্য হলো যেকোনো parser — ফাইল, নেটওয়ার্ক বার্তা বা ব্যবহারকারীর ইনপুট পড়ার কোড।

শেষ অস্ত্রটি আপনি পর্ব ৩৯-এ (constexpr) দেখেছেন: compile-এর সময় হিসাব চলার সময় core language-এর UB ঘটলে compiler-কে সেটি error হিসেবে জানাতে হয়। অর্থাৎ constexpr ফাংশনকে static_assert-এ চালালে signed overflow বা সীমার বাইরে পড়া compile error হয়ে যায়। এটি ছোট, বিশুদ্ধ হিসাবের ফাংশন পরীক্ষার একটি বিনামূল্যের sanitizer.

৭ · নিরাপদ সিঁড়ি — একটি বাগ থেকে স্থায়ী সমাধান

টুলগুলো আলাদা আলাদা শিখলেন। এবার একসাথে, গল্প ৩-এর শপিং কার্টের বাগ দিয়ে। ভালো দল প্রতিটি বাগে একই সিঁড়ি বেয়ে ওঠে: warning দেখা, sanitizer দিয়ে প্রমাণ, মূল কারণে সমাধান, আর এমন টেস্ট যা বাগটিকে আর কখনো ফিরতে দেবে না।

  1. বাগint total;মাঝে মাঝে ভুল টাকা
  2. Warning-Wall -O2compiler সন্দেহ করে
  3. Sanitizer-fsanitize=memoryচলার সময় প্রমাণ
  4. সমাধানint total = 0;মূল কারণে
  5. টেস্টCIআর ফিরবে না
cart.cppডেভেলপারের মেশিনে সব ঠিক
int cartTotal(const std::vector<Item>& items) {
    int total;                     // মান নেই
    for (const auto& it : items)
        total += it.price * it.qty;
    return total;
}
g++ -Wall -Wextra -O2প্রথম সংকেত
cart.cpp:5:15: warning: 'total' is used uninitialized
    [-Wuninitialized]
clang++ -fsanitize=memory -gচলার সময় হাতেনাতে
WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 in main cart.cpp:12
  Uninitialized value was created by an allocation
    of 'total' in the stack frame of cartTotal
cart.cpp — ঠিক করামান শুরুতেই
int cartTotal(const std::vector<Item>& items) {
    long long total = 0;          // মান + চওড়া টাইপ
    for (const auto& it : items)
        total += 1LL * it.price * it.qty;
    return checkedToInt(total);   // যাচাই করা রূপান্তর
}
test_cart.cpp + CIregression test
assert(cartTotal({}) == 0);                   // খালি কার্ট
assert(cartTotal({{"Rice", 80, 5}}) == 400);
# CI: প্রতিটি পরিবর্তনে দুবার build
g++ -Wall -Wextra -Werror ...
g++ -fsanitize=address,undefined -g ... && ./tests
  1. বাগ। total-এর কোনো মান নেই, তাই যোগ শুরু হয় stack-এ আগে থেকে পড়ে থাকা কোনো সংখ্যা থেকে। ডেভেলপারের মেশিনে সেটা ঘটনাক্রমে শূন্য, গ্রাহকের ফোনে অন্য কিছু। খালি কার্টে আরও মজার — লুপ একবারও চলে না, তাই সরাসরি আবর্জনা ফেরত যায়।
  2. Warning. GCC -Wall-এ -Wuninitialized আর -Wmaybe-uninitialized আছে, আর এগুলো optimization (-O2) চালু থাকলে সবচেয়ে ভালো কাজ করে, কারণ তখন compiler ডেটার প্রবাহ বিশ্লেষণ করে। প্রথম সংকেত সবসময় এখান থেকে আসা উচিত — warning পরিষ্কার না রাখলে এই লাইনটি শত লাইনের ভিড়ে হারিয়ে যায়।
  3. Sanitizer. মান না দেওয়া মেমরি পড়া ASan ধরে না, UBSan-ও না। এর জন্য Clang-এর MSan: সে প্রতিটি বাইটের “মান দেওয়া হয়েছে কি না” হিসাব রাখে, আর সেই মান দিয়ে সিদ্ধান্ত নেওয়া বা ছাপার মুহূর্তে রিপোর্ট দেয় — কোথায় ব্যবহার হলো, আর কোন ভ্যারিয়েবল থেকে এল। MSan না থাকলে debugger বা valgrind-এর memcheck-ও এই ধরনের ভুল ধরতে পারে।
  4. মূল কারণে সমাধান। শুধু = 0 যোগ করাই যথেষ্ট ছিল, কিন্তু একবার থেমে পুরো ফাংশনটি দেখলে আরও একটি লুকোনো বিপদ চোখে পড়ে: বড় পাইকারি অর্ডারে price * qty-এর যোগফল int ছাড়াতে পারে। তাই হিসাব long long-এ, আর ফেরত দেওয়ার আগে একটি যাচাই করা রূপান্তর (অংশ ৮-এ checkedToInt-এর ধারণা)। ভালো সমাধান একটি বাগ ঠিক করে না, একটি বাগের শ্রেণী বন্ধ করে।
  5. টেস্ট আর CI. খালি কার্ট আর একটি জানা কার্টের জন্য regression test — ঠিক যে অবস্থায় বাগটি দেখা দিয়েছিল। CI প্রতিটি পরিবর্তনে দুবার build করে: একবার -Werror দিয়ে, একবার sanitizer দিয়ে টেস্ট চালিয়ে। কেউ ভবিষ্যতে আবার int total; লিখলে মানুষ নয়, মেশিনই আটকাবে। (টেস্টিং ও CI-এর বিস্তারিত পর্ব ৪৮-এ।)

একটি নিরাপত্তা-জাল: GCC 12 আর Clang-এ -ftrivial-auto-var-init=zero (বা =pattern) flag আছে, যা মান না দেওয়া সব local ভ্যারিয়েবলকে নিজে থেকে শূন্য বা একটি চেনা প্যাটার্ন দিয়ে ভরে দেয়। বড় কিছু প্রজেক্ট, যেমন অনেক Linux kernel build ও Android, এটি নিরাপত্তার জন্য ব্যবহার করে। কিন্তু এটা জাল, সমাধান নয় — শূন্য দিয়ে শুরু হওয়া সঠিক হিসাব কি না, তা শুধু আপনি জানেন।

৮ · নিরাপদ C++-এর অভ্যাস — আর memory safety নিয়ে আজকের আলোচনা

টুল বাগ ধরে, কিন্তু অভ্যাস বাগ জন্মাতেই দেয় না। এই কোর্সে যত “আধুনিক C++” শিখেছেন, তার বেশিরভাগই আসলে নিরাপত্তার নিয়ম। এক জায়গায় সাজিয়ে রাখি:

“যাচাই করা রূপান্তর” কেমন দেখায়? Microsoft-এর GSL (Guidelines Support Library)-এ gsl::narrow<int>(x) আছে: রূপান্তরের পর মান বদলে গেলে সে exception ছোড়ে। নিজে একটি সরল রূপ লেখা যায় C++20-এর std::in_range দিয়ে:

int checkedToInt(long long v) {
    if (!std::in_range<int>(v)) throw std::out_of_range("does not fit in int");
    return static_cast<int>(v);   // এখন নিশ্চিত: মান বদলাবে না
}

Memory safety — সরকার, শিল্প আর C++-এর জবাব

গত কয়েক বছরে UB শুধু প্রোগ্রামারদের আলোচনা নয়, নীতিনির্ধারকদেরও আলোচনা। কারণ বড় প্রতিষ্ঠানগুলো নিজেদের তথ্য প্রকাশ করেছে: Microsoft আর Chromium দুই দলই জানিয়েছে, তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০% memory safety-সংক্রান্ত (বিস্তারিত ভাবনার প্রশ্ন ০৩-এ)। এমন বাগ ইতিহাসে বহুবার গুরুতর নিরাপত্তা-দুর্বলতার জন্ম দিয়েছে।

২০২২ সালের নভেম্বরে যুক্তরাষ্ট্রের NSA “Software Memory Safety” নামে একটি নির্দেশিকা প্রকাশ করে, যেখানে সম্ভব হলে memory-safe ভাষা ব্যবহারের পরামর্শ দেওয়া হয়, আর C ও C++-কে উদাহরণ হিসেবে memory-unsafe দলে রাখা হয়। ২০২৩ সালের ডিসেম্বরে CISA, NSA আর কয়েকটি দেশের সাইবার-নিরাপত্তা সংস্থা মিলে “The Case for Memory Safe Roadmaps” প্রকাশ করে — সফটওয়্যার নির্মাতারা কীভাবে ধাপে ধাপে memory-safe পথে যাবেন, তার একটি পরিকল্পনা লিখে রাখার আহ্বান। ২০২৪ সালের ফেব্রুয়ারিতে হোয়াইট হাউসের সাইবার-নিরাপত্তা দপ্তরের একটি প্রতিবেদনও একই দিকে কথা বলে। লক্ষ করুন, এগুলো পরামর্শ ও নির্দেশিকা, আইন নয়; আর এগুলো মেনে নেয় যে বিদ্যমান বিপুল C ও C++ কোড রাতারাতি বদলানো সম্ভব নয়।

C++ সমাজের জবাব কয়েক স্তরের। প্রথমত, C++ Core Guidelines — Bjarne Stroustrup আর Herb Sutter-এর নেতৃত্বে লেখা নিয়মের সংকলন, যার অনেকগুলো clang-tidy আর MSVC-র টুল যাচাই করতে পারে। দ্বিতীয়ত, safety profile প্রস্তাব: কোডের কোনো অংশে “type”, “bounds”, “lifetime”-এর মতো নিরাপত্তা-নিয়মের একটি দল চালু করা, যাতে compiler সেই অংশে বিপজ্জনক ধাঁচ আটকায়। Stroustrup এই পথের প্রধান সমর্থক; তাঁর যুক্তি, C++-কে বাদ না দিয়ে, পুরোনো কোড না ভেঙে, ধাপে ধাপে নিরাপদ করা বেশি বাস্তবসম্মত, আর “safety” একটিমাত্র জিনিস নয়, অনেক রকম নিশ্চয়তার একটি পরিবার। Profiles C++26-এর অংশ হয়নি; কাজটি আলাদা নথি হিসেবে এগোচ্ছে, আর সমালোচকরা প্রশ্ন তোলেন এটি কতটা নিশ্চয়তা দিতে পারবে। তৃতীয়ত, যা ইতিমধ্যে C++26-এর জন্য গৃহীত — library hardening, uninitialized পড়ার erroneous behavior, আর contracts — এগুলো ছোট কিন্তু বাস্তব পদক্ষেপ।

৯ · AI-এর যুগে — AI-এর লেখা কোডে UB খোঁজা

AI টুল C++ কোড দ্রুত লেখে, আর সেই কোড সাধারণত compile হয়, ছোট উদাহরণে ঠিক চলে। ঠিক এই কারণেই UB-এর জন্য বিপজ্জনক: UB-এর বৈশিষ্ট্যই হলো “ছোট টেস্টে ঠিক চলা।” AI ইন্টারনেটের কোটি কোটি লাইন কোড থেকে শিখেছে, যার বড় অংশ পুরোনো ধাঁচের — C-style cast, int-এ বড় যোগফল, index দিয়ে লুপ। যে ভুলগুলো বারবার দেখা যায়:

AI যা লেখেসমস্যাযা হওয়া উচিত
int sum = 0; লাখো সংখ্যার যোগেsigned overflowlong long, বা যাচাই
v[i], যেখানে i ব্যবহারকারীর ইনপুটসীমার বাইরে পড়াআগে যাচাই, বা .at()
for (int i = 0; i <= n; ++i)off-by-onerange-for বা < n
int count; পরে count++মান না দেওয়া ভ্যারিয়েবলint count = 0;
(int)x, (Item*)bufলুকোনো narrowing, strict aliasingstatic_cast, std::bit_cast
1 << bit, bit ইনপুট থেকেবেশি shiftbit-এর সীমা যাচাই

AI-কে শুধু “কোড লেখো” না বলে, তাকে রিভিউয়ার হিসেবে ব্যবহার করুন — আর তার কাছে নির্দিষ্ট তালিকা ধরে উত্তর চান। নিচের prompt-টি কপি করে নিজের কোডের সাথে দিন:

তুমি একজন অভিজ্ঞ C++ রিভিউয়ার। নিচের C++23 কোডটি undefined behavior খুঁজতে রিভিউ করো। প্রতিটি সমস্যার জন্য লাইন নম্বর, UB-এর ধরন, আর নিরাপদ রূপ দাও। বিশেষ করে দেখো: ১. signed integer overflow (বড় যোগফল, গুণফল) ২. সীমার বাইরে index, off-by-one ৩. মান না দেওয়া ভ্যারিয়েবল ৪. dangling reference, string_view বা iterator ৫. C-style cast, strict aliasing, বেশি shift ৬. thread থাকলে data race নিয়ম: কোনো ভুল কোড চালিয়ে দেখানোর দরকার নেই। যেখানে নিশ্চিত নও, "সন্দেহ" লিখে কারণ বলো। শেষে আমার প্রজেক্টের জন্য দুটি build কমান্ড দাও: একটি warning-সহ (-Wall -Wextra -Wconversion), আরেকটি টেস্টের জন্য sanitizer-সহ (-fsanitize=address,undefined -g). [এখানে কোড]

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

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

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

প্র ০১UB এত ঝামেলার, তবুও C++ standard এটি রেখে দিয়েছে কেন? প্রতিটি ভুলের জন্য একটি নির্দিষ্ট আচরণ লিখে দিলেই তো হতো।

উত্তরের দুটি অংশ — portability আর performance — আর দুটোরই শিকড় ১৯৭০-৮০-র দশকে। C (আর তার উত্তরাধিকারী C++) এমন সময়ে standard হয়েছে, যখন কম্পিউটারের গঠন আজকের মতো এক রকম ছিল না। কিছু মেশিনে signed সংখ্যা two's complement, কিছুতে ones' complement বা sign-magnitude; কিছু CPU overflow-এ “trap” করে প্রোগ্রাম থামিয়ে দিত, কিছু চুপচাপ ঘুরে যেত। Shift-এর ক্ষেত্রেও একই: x86 CPU shift-এর পরিমাণের শুধু নিচের কয়েকটি বিট দেখে, অন্য কিছু CPU পুরো পরিমাণ দেখে শূন্য দেয়। Standard যদি একটি আচরণ বাধ্যতামূলক করত, তাহলে বাকি সব মেশিনে প্রতিটি যোগ বা shift-এর পাশে বাড়তি নির্দেশ বসাতে হতো। তাই কমিটি বলল: “এই অবস্থা বৈধ প্রোগ্রামে আসবে না” — যাতে প্রতিটি মেশিন তার স্বাভাবিক, দ্রুততম নির্দেশটিই ব্যবহার করতে পারে।

পরে দেখা গেল, এই ফাঁক optimizer-এর জন্য সোনার খনি। অংশ ৩-এ লুপের উদাহরণ মনে করুন: signed overflow UB বলে compiler int লুপ-কাউন্টারকে ৬৪-বিটে চওড়া করে, লুপকে vectorize করে, লুপ কতবার চলবে আগেই হিসাব করে। সীমার বাইরে পড়া UB বলেই v[i]-তে কোনো যাচাইয়ের খরচ নেই। Strict aliasing UB বলেই compiler ধরে নেয় float* দিয়ে লিখলে int-এর মান বদলায় না, তাই মান রেজিস্টারে রেখে দিতে পারে।

আজকের বাস্তবতা কিছুটা বদলেছে। C++20-এ (P0907 আর P1236 প্রস্তাবের মাধ্যমে) two's complement বাধ্যতামূলক হয়েছে, কারণ প্রায় সব আধুনিক মেশিন তাই ব্যবহার করে। কিন্তু খেয়াল করুন, তখনও signed overflow-কে UB রাখা হয়েছে — ঠিক optimizer-এর কারণে, আর এই কারণেও যে ঘুরে যাওয়া প্রায় সবসময়ই একটি ভুল, তাই একে “বৈধ” বানালে sanitizer আর সেটি ভুল হিসেবে ধরতে পারত না। এর মানে বিতর্কটি নিছক ইতিহাস নয়, একটি সচেতন নকশা-সিদ্ধান্ত।

তবে প্রবণতাটিও লক্ষ করার মতো: সাম্প্রতিক standard-গুলো ধীরে ধীরে কিছু UB কমাচ্ছে। Uninitialized পড়া erroneous behavior হচ্ছে, ফাঁকা অসীম লুপ নিয়মের বাইরে আসছে, library hardening standard-এ ঢুকছে। অর্থাৎ পারফরম্যান্স আর নিরাপত্তার পাল্লা প্রতিটি ক্ষেত্রে আলাদা করে মাপা হচ্ছে।

মূল উপলব্ধি: UB কোনো দুর্ঘটনা নয়, একটি বিনিময় — portability আর গতির দাম হিসেবে প্রোগ্রামারের কাঁধে দায়িত্ব। সেই দায়িত্ব আজ আপনি একা বহন করেন না; warning, sanitizer আর নিরাপদ library সেটি ভাগ করে নেয়, যদি আপনি তাদের চালু করেন।

প্র ০২২০১১ সালে Chris Lattner “What Every C Programmer Should Know About Undefined Behavior” লিখেছিলেন। LLVM-এর স্রষ্টা কেন এমন একটি লেখা লিখলেন, আর এর মূল বার্তা আজ C++ প্রোগ্রামারের জন্য কী?

Chris Lattner হলেন LLVM compiler প্রজেক্টের মূল স্রষ্টা (যার ওপর Clang তৈরি), পরে Swift ভাষারও। ২০১১ সালের মে মাসে তিনি LLVM Project Blog-এ তিন পর্বের একটি লেখা প্রকাশ করেন: “What Every C Programmer Should Know About Undefined Behavior.” প্রেক্ষাপট ছিল এই: compiler যত শক্তিশালী হচ্ছিল, তত বেশি পুরোনো প্রোগ্রাম নতুন সংস্করণে “ভেঙে” যাচ্ছিল, আর ব্যবহারকারীরা compiler-কেই দোষ দিচ্ছিলেন। একজন compiler-নির্মাতা হিসেবে Lattner ব্যাখ্যা করলেন, ভাঙন আসলে কোডের UB থেকে আসছে, আর compiler কেন এমন আচরণ করে।

লেখার প্রথম অংশে তিনি দেখান, UB আসলে optimization-এর সুযোগ: মান না দেওয়া ভ্যারিয়েবল, signed overflow, বেশি shift, null pointer, strict aliasing — প্রতিটি থেকে compiler কী ধরনের দ্রুততা পায়। দ্বিতীয় অংশে আসে বিপজ্জনক দিক: একাধিক optimization একের পর এক চললে UB-এর প্রভাব অপ্রত্যাশিত জায়গায় ছড়িয়ে পড়ে — একটি null যাচাই মুছে যাওয়া, একটি নিরাপত্তা-যাচাই উধাও হওয়া। এখানে তিনি স্পষ্ট বলেন, compiler সাধারণত UB-এর জন্য warning দিতে পারে না, কারণ বেশিরভাগ ক্ষেত্রে এই অনুমানগুলো কোনো বাগ নয়, স্বাভাবিক কোডেরই অংশ — প্রতিটি int লুপে “এখানে overflow হতে পারত” সতর্কবার্তা দিলে লাখো বাজে warning আসত। তৃতীয় অংশে তিনি Clang-এর দিক থেকে সমাধানের কথা বলেন: ভালো warning, আর চলার সময়ের যাচাই — যা পরে UBSan হিসেবে পরিণত রূপ পায়।

আজকের C++ প্রোগ্রামারের জন্য বার্তাটি তিন লাইনের। এক, “আমার compiler-এ চলে” কোনো প্রমাণ নয়; পরের সংস্করণ বা অন্য optimization স্তর ভিন্ন সিদ্ধান্ত নিতে পারে। দুই, compiler-এর কাছে প্রতিটি UB-র warning আশা করবেন না — এটা প্রযুক্তিগতভাবে প্রায় অসম্ভব। তিন, তাই চলার সময়ের টুল (sanitizer) আর নিরাপদ ধাঁচ — এ দুটিই আসল প্রতিরক্ষা। লেখাটি এখনো অনলাইনে পড়া যায়, আর পনেরো বছর পরেও প্রাসঙ্গিক।

মূল উপলব্ধি: যিনি compiler বানান, তিনিই সবচেয়ে জোর দিয়ে বলেছেন — UB-কে compiler-এর ভরসায় ছাড়বেন না। Optimizer আপনার শত্রু নয়, কিন্তু সে আপনার চুক্তি অক্ষরে অক্ষরে মানে।

প্র ০৩Microsoft আর Chromium জানিয়েছে তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০% memory safety-র। এই সংখ্যা থেকে প্রতিরক্ষার দিক দিয়ে কী শেখা যায় — বড় প্রজেক্টগুলো আসলে কী বদলেছে?

২০১৯ সালে BlueHat IL সম্মেলনে Microsoft-এর নিরাপত্তা প্রকৌশলী Matt Miller জানান, বহু বছর ধরে Microsoft যত নিরাপত্তা-ত্রুটির জন্য CVE নম্বর দিয়েছে, তার প্রায় ৭০% memory safety-র সমস্যা। ২০২০ সালে Chromium প্রজেক্ট তাদের উচ্চ ও গুরুতর মাত্রার নিরাপত্তা-বাগের প্রায় ৭০%-কে একই শ্রেণীতে ফেলে। পর্ব ১৮-এর ভাবনার প্রশ্নে এর lifetime-এর দিক (use-after-free) দেখেছেন; এখানে প্রশ্নটি অন্য — এই তথ্য পেয়ে প্রতিষ্ঠানগুলো কোন প্রতিরক্ষা বেছে নিল।

প্রথম শিক্ষা: সমস্যা দুই রকম — spatial আর temporal. Spatial মানে সীমার বাইরে যাওয়া (buffer overflow), temporal মানে সময়ের বাইরে যাওয়া (use-after-free). প্রথমটির প্রতিকার তুলনামূলক সহজ — সীমা যাচাই। Google ২০২৪ সালে জানায়, তারা নিজেদের বিশাল C++ কোডবেসে libc++-এর hardening চালু করেছে, আর পারফরম্যান্সের খরচ এত সামান্য এসেছে যে সেটি চূড়ান্ত সার্ভিসেও রাখা গেছে; সাথে বেশ কিছু লুকোনো বাগও বেরিয়েছে। অর্থাৎ “সীমা যাচাই অনেক ধীর” — এই পুরোনো বিশ্বাস আধুনিক CPU আর compiler-এ প্রায়ই সত্য নয়।

দ্বিতীয় শিক্ষা: টুল সিস্টেম হিসেবে চালাতে হয়। Chromium আর Google-এর ClusterFuzz অবকাঠামো দিনরাত ASan-সহ fuzzing চালায়; OSS-Fuzz একই সুবিধা ওপেন-সোর্স প্রজেক্টকে দেয়। একজন ডেভেলপার মাঝে মাঝে ASan চালালে যা পাওয়া যায় না, স্বয়ংক্রিয় ব্যবস্থা তা বছরের পর বছর ধরে খুঁজে পায়।

তৃতীয় শিক্ষা: নতুন কোডে নিয়ম বদলানো সবচেয়ে কার্যকর। Google Android-এ নতুন কোড বেশি করে memory-safe ভাষায় (Rust, Kotlin, Java) লেখা শুরু করে, আর পরে জানায় যে কয়েক বছরে মেমরি-সংক্রান্ত দুর্বলতার অংশ উল্লেখযোগ্যভাবে কমেছে — পুরোনো C++ কোড পুরো পুনর্লিখন না করেই। কারণ বাগ সবচেয়ে বেশি থাকে নতুন ও সম্প্রতি বদলানো কোডে। C++-এর ক্ষেত্রেও একই যুক্তি: নতুন কোডে কঠোর নিয়ম (এই পর্বের অভ্যাস, clang-tidy, hardened মোড) পুরোনো কোড ধীরে ধীরে বদলানোর চেয়ে দ্রুত ফল দেয়।

একটি সতর্কতা: ৭০% সংখ্যাটি নির্দিষ্ট কয়েকটি বড়, পুরোনো, বহুল-আক্রান্ত C/C++ কোডবেসের। আপনার ছোট প্রজেক্টে অনুপাত ভিন্ন হতে পারে, আর অন্য ভাষার প্রজেক্টে logic-এর ভুল, ইনপুট যাচাইয়ের ভুল ইত্যাদি প্রধান। সংখ্যাটির আসল বার্তা “C++ খারাপ” নয়, বরং “এই শ্রেণীর বাগ প্রতিরোধযোগ্য, আর বড় প্রভাব ফেলে।”

মূল উপলব্ধি: নিরাপত্তা আসে স্তর থেকে — নিরাপদ ভাষা-বৈশিষ্ট্য, সীমা যাচাই, স্বয়ংক্রিয় টেস্ট আর sanitizer, আর নতুন কোডে কঠোর নিয়ম। কোনো একটি একা যথেষ্ট নয়, কিন্তু সবগুলো একসাথে সংখ্যাটি বদলে দিতে পারে।

প্র ০৪Rust-এ “safe” আর “unsafe” কোড আলাদা করা থাকে; C++-এর safety profiles প্রস্তাব ভিন্ন পথে হাঁটে। দুটি নকশার মূল পার্থক্য কী, আর প্রতিটির সুবিধা-অসুবিধা কোথায়?

Rust-এ ডিফল্ট হলো “safe”: সাধারণ কোডে compiler (borrow checker সহ, পর্ব ১৮) প্রমাণ করে যে কোনো সীমার বাইরে পড়া, dangling reference বা data race নেই। কিছু কাজ অবশ্য প্রমাণ করা যায় না — হার্ডওয়্যারের সাথে সরাসরি কথা বলা, C library ডাকা, raw pointer-এর ভেতরে ঢোকা, অত্যন্ত দ্রুত ডেটা-গঠন বানানো। এগুলোর জন্য প্রোগ্রামারকে স্পষ্টভাবে একটি unsafe block লিখতে হয়। সেই block-এর ভেতরে মাত্র কয়েকটি বাড়তি ক্ষমতা খোলে (যেমন raw pointer dereference, unsafe ফাংশন ডাকা); বাকি সব নিয়ম তখনো চালু থাকে। Rust-এর নিজের standard library-র ভেতরেও unsafe কোড আছে — কিন্তু সেটি একটি নিরাপদ interface-এর পেছনে লুকোনো, আর বিশেষজ্ঞরা সেটি কঠোরভাবে রিভিউ করেন।

এই নকশার শক্তি হলো খোঁজার জায়গা ছোট করা। কোনো memory বাগ হলে সেটি (Rust-এর প্রতিশ্রুতি অনুযায়ী) কোনো না কোনো unsafe block-এর ভুল থেকে এসেছে। রিভিউয়ার পুরো কোডবেস নয়, শুধু সেই block-গুলো খুঁটিয়ে দেখেন। দুর্বলতা হলো শেখার খাড়া পথ, আর কিছু নিরাপদ ধাঁচও compiler প্রমাণ করতে না পারায় প্রত্যাখ্যাত হয়।

C++-এ ডিফল্ট উল্টো: সবকিছু অনুমোদিত, নিরাপত্তা আপনার ইচ্ছা। Safety profiles প্রস্তাব (Stroustrup-এর নথি, আর Herb Sutter-এর P3081 “Core safety profiles”-এর মতো প্রস্তাব) এখানে একটি মাঝামাঝি পথ চায়: কোডের একটি অংশে বা পুরো ফাইলে একটি profile চালু করলে compiler সেখানে নির্দিষ্ট বিপজ্জনক ধাঁচ — যেমন unchecked pointer arithmetic, C-style cast, বা নির্দিষ্ট dangling ধাঁচ — আটকাবে, আর দরকার হলে স্পষ্টভাবে “suppress” করে বের হওয়া যাবে। অর্থাৎ Rust-এর মতোই একটি “নিরাপদ এলাকা” আর “ব্যতিক্রম” ধারণা, কিন্তু opt-in, আর পুরোনো কোড না বদলেই।

সুবিধা: কোটি কোটি লাইনের বিদ্যমান কোডে ধাপে ধাপে প্রয়োগ করা যায়, নতুন ভাষা শিখতে হয় না, পুরোনো library-র সাথে মেলে। অসুবিধা ও সমালোচনা: স্থানীয় বিশ্লেষণে Rust-এর মতো পূর্ণ নিশ্চয়তা (বিশেষত lifetime-এ) দেওয়া কঠিন, আর opt-in হওয়ায় যারা চালু করবেন না, তাদের কোড আগের মতোই থাকে। এই লেখার সময় profiles C++26-এর অংশ নয়, আর এর চূড়ান্ত রূপ নিয়ে কমিটিতে আলোচনা চলছে। এর পাশাপাশি Sean Baxter-এর “Safe C++” প্রস্তাব borrow checker-ধাঁচের পথ দেখিয়েছিল, কিন্তু কমিটি সেটি বেছে নেয়নি।

মূল উপলব্ধি: দুই ভাষাই মেনে নেয় যে কিছু কোড “বিপজ্জনক” হতেই হবে; পার্থক্য হলো ডিফল্ট কোনটি, আর বিপজ্জনক অংশ কতটা স্পষ্টভাবে চিহ্নিত। C++-এ আজ আপনি নিজেই সেই চিহ্ন দিতে পারেন — বিপজ্জনক কোড ছোট ফাংশনে আলাদা রাখুন, মন্তব্যে কারণ লিখুন, আর বাকি সবখানে নিরাপদ ধাঁচ।

প্র ০৫C++26-এ মান না দেওয়া ভ্যারিয়েবল পড়াকে UB থেকে সরিয়ে “erroneous behavior” বানানো হয়েছে। এই নতুন শ্রেণীটি ঠিক কী, আর কেন UB বা “শূন্য দিয়ে শুরু” — কোনোটিই বেছে নেওয়া হলো না?

সমস্যাটি পুরোনো আর গুরুতর। মান না দেওয়া local ভ্যারিয়েবলের ভেতরে থাকে stack-এ আগে থেকে পড়ে থাকা ডেটা — হয়তো অন্য কোনো ফাংশনের পাসওয়ার্ড, হয়তো পুরোনো কোনো pointer. এমন ভ্যারিয়েবল পড়ে বাইরে পাঠালে তথ্য ফাঁস হতে পারে, আর pointer হিসেবে ব্যবহার করলে আরও খারাপ। C++23 পর্যন্ত এটি UB, তাই optimizer এর ওপরও অনুমান করতে পারত।

সমাধানের তিনটি পথ ছিল। প্রথম, UB রেখে দেওয়া — দ্রুততম, কিন্তু অনিরাপদ। দ্বিতীয়, standard বলে দিক সব local ভ্যারিয়েবল শূন্য দিয়ে শুরু হবে — নিরাপদ, কিন্তু এতে একটি সূক্ষ্ম ক্ষতি আছে: তখন int total; লেখা বৈধ ও সঠিক কোড হয়ে যেত, আর কোনো টুল আর এটিকে ভুল বলতে পারত না। অথচ বেশিরভাগ ক্ষেত্রে মান দিতে ভুলে যাওয়া আসলেই একটি বাগ। তৃতীয় পথটিই বেছে নেওয়া হয়েছে।

Thomas Köppe-র প্রস্তাব P2795 (“Erroneous behaviour for uninitialized reads”) ২০২৪ সালে C++26-এর জন্য গৃহীত হয়। এটি একটি নতুন শ্রেণী তৈরি করে: erroneous behavior — “এটি ভুল, কিন্তু এর একটি নির্দিষ্ট আচরণ আছে।” মান না দেওয়া automatic ভ্যারিয়েবল এখন compiler-এর ঠিক করা একটি “erroneous value” পায়; সেটি পড়লে প্রোগ্রাম নিয়মহীন হয় না, optimizer এর ওপর UB-এর মতো অনুমান করতে পারে না, কিন্তু compiler ও টুলকে উৎসাহ দেওয়া হয় ভুলটি জানাতে (এমনকি প্রোগ্রাম থামাতেও)। যাদের সত্যিই পারফরম্যান্সের জন্য মান ছাড়া বড় buffer দরকার, তারা [[indeterminate]] attribute দিয়ে স্পষ্টভাবে পুরোনো আচরণ চাইতে পারেন — বিপজ্জনক কাজটি এখন কোডে দৃশ্যমান।

এই নকশা আগে থেকে চলা বাস্তব অভিজ্ঞতা থেকে এসেছে: Clang আর GCC-র -ftrivial-auto-var-init বড় প্রজেক্টে ব্যবহার হয়ে দেখিয়েছিল যে local ভ্যারিয়েবল আগে থেকে ভরে দেওয়ার খরচ সাধারণত খুব কম। Standard শুধু সেই নিরাপত্তাকে নিয়মে পরিণত করল, কিন্তু “ভুল” লেবেলটি রেখে দিল। মনে রাখবেন, এটি শুধু automatic ভ্যারিয়েবলের জন্য; heap-এর মেমরি বা অন্য UB-তে এটি প্রযোজ্য নয়, আর আপনার compiler কোন সংস্করণে এটি পুরোপুরি বাস্তবায়ন করেছে, তা আলাদা করে দেখে নিতে হবে।

মূল উপলব্ধি: নিরাপত্তা আর সঠিকতা আলাদা জিনিস। Erroneous behavior প্রোগ্রামকে নিরাপদ করে, কিন্তু আপনার হিসাবকে সঠিক করে না — int total = 0; লেখার দায়িত্ব এখনো আপনারই।

অনুশীলন

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

  1. দল চিনুন: প্রতিটি implementation-defined, unspecified নাকি undefined? (ক) sizeof(long) (খ) print(read(), read())-এ কোন read() আগে চলবে (গ) int x = INT_MIN; x = -x; (ঘ) unsigned u = 0; u = u - 1;

    (ক) implementation-defined — Windows-এ সাধারণত ৪ বাইট, ৬৪-বিট Linux-এ ৮. (খ) unspecified — দুটিই চলবে, ক্রম নিশ্চিত নয়। (গ) undefined — INT_MIN-এর ধনাত্মক রূপ int-এ ধরে না, এটি signed overflow. (ঘ) কোনোটিই নয়, সম্পূর্ণ নির্দিষ্ট — unsigned হিসাব ঘুরে যায়, ফল UINT_MAX.

  2. Optimizer-এর চোখে: একটি ফাংশনে আছে int len = s->size; if (!s) return -1; return len;. -O2-এ compiler if (!s) যাচাইটি মুছে দিতে পারে কেন? ঠিক রূপ কী?

    প্রথম লাইনেই s->size পড়া হয়েছে। s null হলে সেটি UB, আর compiler ধরে নেয় UB ঘটে না — অর্থাৎ এই লাইন পেরিয়ে আসা মানে s null নয়। তাই পরের যাচাইটি সবসময় false, মুছে দেওয়া যায়। ঠিক রূপ: যাচাই আগে, ব্যবহার পরে — if (!s) return -1; return s->size;.

  3. টুল বাছাই: প্রতিটি বাগের জন্য কোন টুল সবচেয়ে উপযুক্ত — ASan, UBSan, TSan নাকি MSan? (ক) দুটি thread একই counter বাড়ায় (খ) 1 << 40 একটি int-এ (গ) vector-এর শেষের পরের ঘরে লেখা (ঘ) মান না দেওয়া bool দিয়ে if

    (ক) TSan — data race. (খ) UBSan — বেশি shift (-fsanitize=shift এর অংশ)। (গ) ASan — heap-buffer-overflow; hardened library-ও []-এ ধরবে। (ঘ) MSan — use-of-uninitialized-value (Clang); GCC-তে -Wall -O2-এর warning প্রথম সংকেত হতে পারে।

  4. নিজে লিখুন (কোড): অংশ ৫-এর checked_sum.cpp-এ একটি checkedMul(int a, int b) যোগ করুন, যা গুণফল int-এ না ধরলে std::nullopt দেয়। সবচেয়ে সরল নিরাপদ কৌশলটি কী?

    সবচেয়ে সরল: চওড়া টাইপে গুণ করে তারপর পরিসর দেখা — long long p = 1LL * a * b; if (!std::in_range<int>(p)) return std::nullopt; return static_cast<int>(p);. দুটি int-এর গুণফল সবসময় long long-এ ধরে, তাই মাঝের ধাপে কোনো UB নেই। বিকল্প: GCC/Clang-এ __builtin_mul_overflow(a, b, &r). এড়ানোর মতো: আগে a * b করে পরে “ফল কি অদ্ভুত?” দেখা — overflow ততক্ষণে ঘটে গেছে।

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

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

ক্রমএকটি সন্দেহজনক UB বাগ ধরা ও ঠিক করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. -Wall -Wextra দিয়ে compile করে warning পড়া
  2. -fsanitize=address,undefined দিয়ে টেস্ট চালানো
  3. Sanitizer রিপোর্ট থেকে লাইন আর মূল কারণ খুঁজে বের করা
  4. মূল কারণে কোড ঠিক করা
  5. বাগের অবস্থা নিয়ে regression test যোগ করা
  6. CI-তে warning আর sanitizer build স্থায়ীভাবে চালু রাখা

C++-এ নিচের কোনটি undefined behavior?

  • unsigned u = 0; u = u - 1;
  • sizeof(int)-এর মান
  • int x = INT_MAX; x = x + 1;
  • f(a(), b())-এ কোনটি আগে চলবে

C++ compiler -O2-এ if (x + 1 < x) (যেখানে x একটি int) শর্তটি সবসময় false ধরে মুছে দিতে পারে কেন?

  • এটি GCC আর Clang-এর একটি পরিচিত বাগ
  • Signed overflow UB, তাই compiler ধরে নেয় এটি ঘটে না
  • C++20-এ signed overflow নির্দিষ্টভাবে ঘুরে যায়
  • -O2 সব if শর্ত সরল করে ফেলে

মান না দেওয়া মেমরি পড়ে সিদ্ধান্ত নেওয়ার ভুল চলার সময় ধরতে কোন sanitizer তৈরি?

  • AddressSanitizer (-fsanitize=address)
  • ThreadSanitizer (-fsanitize=thread)
  • UndefinedBehaviorSanitizer (-fsanitize=undefined)
  • MemorySanitizer (-fsanitize=memory)

C++20-এ একটি float-এর বিটগুলো std::uint32_t হিসেবে পাওয়ার সবচেয়ে নিরাপদ উপায় কোনটি?

  • std::bit_cast<std::uint32_t>(f)
  • *reinterpret_cast<std::uint32_t*>(&f)
  • static_cast<std::uint32_t>(f)
  • (std::uint32_t)f

একটি AddressSanitizer রিপোর্টে লেখা আছে “heap-buffer-overflow ... located 0 bytes after 12-byte region”. এটি সবচেয়ে বেশি কোন ভুলের ইঙ্গিত দেয়?

  • মুছে ফেলা object ব্যবহার করা
  • মান না দেওয়া ভ্যারিয়েবল পড়া
  • একটি বেশি ঘর পড়া — off-by-one
  • stack-এ রাখা array-র সীমা ভাঙা

C++26-এর “erroneous behavior” সম্পর্কে কোনটি সঠিক?

  • মান না দেওয়া local ভ্যারিয়েবল এখন সঠিক কোড, সবসময় শূন্য
  • মান না দেওয়া local ভ্যারিয়েবল পড়া ভুল, কিন্তু এর আচরণ আর নিয়মহীন নয়
  • সব UB-কে erroneous behavior বানানো হয়েছে
  • মান না দেওয়া ভ্যারিয়েবল এখন compile error

শব্দকোষ

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

মনে রাখুন

  1. Standard একটি চুক্তি। UB মানে আপনি নিজের অংশ ভেঙেছেন; তখন compiler-এর কোনো দায় নেই, আর “আজ চলছে” কিছুই প্রমাণ করে না।
  2. তিনটি শব্দ আলাদা: implementation-defined (লিখে জানানো), unspecified (কয়েকটির একটি), undefined (নিয়মহীন)।
  3. Optimizer ধরে নেয় UB ঘটে না — তাই overflow বা null যাচাই ব্যবহারের পরে নয়, আগে করুন।
  4. টুল স্তরে স্তরে: -Wall -Wextra -Wconversion (CI-তে -Werror), ASan+UBSan, thread থাকলে TSan, clang-tidy, hardened library, fuzzing.
  5. অভ্যাসই প্রথম দেয়াল: সবকিছু initialize, RAII, range-for ও span, .at(), static_cast, যাচাই করা রূপান্তর, std::bit_cast.

পরের পর্বে: মডিউল ৬ শেষ — আধুনিক C++-এর ভাষা আর নিরাপত্তার যন্ত্র আপনার হাতে। মডিউল ৭ শুরু হবে প্রোগ্রামের বাইরের জগৎ দিয়ে: ফাইল পড়া-লেখা আর std::filesystem দিয়ে ফোল্ডার ও ফাইল সামলানো।

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

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