এই পর্বে যা শিখবেন
- lvalue ও rvalue-এর সহজ ধারণা, আর পাঁচটি value category (lvalue, prvalue, xvalue, glvalue, rvalue) একটি ছবিতে
T&&আরconst T&overload থাকলে compiler কোনটি বাছে, আরstd::move-এর ভেতরে আসলে কী আছে- কখন move নিজে থেকেই ঘটে: RVO, NRVO, C++17-এর নিশ্চিত copy elision, আর কেন
return std::move(x)ক্ষতিকর - sink parameter — by value নিয়ে ভেতরে move করা, আর move-only টাইপ নিয়ে কাজ
- forwarding reference, reference collapsing আর
std::forwardদিয়ে perfect forwarding; আর AI-এর ছিটানোstd::moveযাচাই
১ · একটিই প্রশ্ন — “এর ভেতর থেকে কি নিয়ে নেওয়া যায়?”
পর্ব ২২-এ আপনি move constructor লিখেছেন: source-এর pointer নিজের কাছে নিয়ে নেওয়া, আর source-কে খালি করে দেওয়া। সেখানে একটি প্রশ্ন আমরা পাশে সরিয়ে রেখেছিলাম — compiler কীভাবে জানে কখন copy constructor চালাতে হবে, আর কখন move constructor? দুটোই একই কাজের জন্য: একটি object থেকে আরেকটি বানানো। পার্থক্য শুধু এটুকু যে move source-কে খালি করে দেয়। তাই compiler-কে নিশ্চিত হতে হয়, source খালি হয়ে গেলে কারও কোনো ক্ষতি হবে না।
ধরুন একটি গার্মেন্টস কারখানায় দুই ধরনের কাপড়ের রোল আছে। একটি রোল লাইনের মাঝখানে রাখা, তার গায়ে লাইন সুপারভাইজারের নাম লেখা — বিকেলে সেটি দিয়ে আরও শার্ট কাটা হবে। আরেকটি রোল কাটিং টেবিলের পাশে পড়ে আছে, গায়ে লেখা “বাতিল, আজ রাতে গুদামে ফেরত যাবে।” নতুন একটি অর্ডারের জন্য কাপড় দরকার হলে দ্বিতীয় রোলটি তুলে নিতে কাউকে জিজ্ঞেস করতে হয় না — ওটা আর কারও কাজে লাগবে না। কিন্তু প্রথম রোলটি নিতে চাইলে? হয় তার মাপের নতুন কাপড় কিনে আনুন (কপি), নয়তো সুপারভাইজারের কাছ থেকে লিখিত অনুমতি নিন: “নিয়ে যান, বিকেলে আমার আর লাগবে না।”
C++-এ দ্বিতীয় রোলটি হলো temporary object — যেমন একটি ফাংশনের ফেরত দেওয়া মান, বা a + b-এর ফল। এর কোনো নাম নেই, তাই এই লাইনের পর কেউ একে আর ব্যবহার করতেই পারবে না। প্রথম রোলটি হলো নাম আছে এমন একটি ভ্যারিয়েবল। আর সুপারভাইজারের লিখিত অনুমতি? সেটাই std::move.
এই পর্যবেক্ষণটি থেকেই পুরো move semantics দাঁড়িয়ে আছে। প্রতিটি expression-এর একটি value category থাকে। এটি একটি লেবেল, যা compiler-কে বলে দেয় এই জিনিসটি কতদিন বাঁচবে, আর তার ভেতর থেকে জিনিস নিয়ে নেওয়া নিরাপদ কি না। শুরুতে দুটি মোটা দাগের ভাগ মনে রাখুন:
নাম আছে, ঠিকানা আছে
ভ্যারিয়েবল, array-এর ঘর, reference ফেরত দেওয়া ফাংশনের ফল। এরা এই লাইনের পরেও বেঁচে থাকে, তাই এদের থেকে চুপচাপ জিনিস নেওয়া যায় না। &x লিখে ঠিকানা নেওয়া যায়।
নাম নেই, এখনই শেষ
42, a + b, std::string("Dhaka"), by value ফেরত দেওয়া ফাংশনের ফল। পুরো expression শেষ হলেই এরা ধ্বংস হয়, তাই এদের ভেতরের জিনিস নেওয়া বিনা খরচে নিরাপদ।
নাম দুটি এসেছে C-এর পুরোনো যুগ থেকে: assignment-এর বাঁ দিকে (left) যা বসতে পারে তা lvalue, আর শুধু ডান দিকে (right) যা বসে তা rvalue. আজকের C++-এ এই ব্যাখ্যা আর পুরোপুরি খাটে না — একটি const int ভ্যারিয়েবল lvalue, অথচ সেটি assignment-এর বাঁ দিকে বসতে পারে না। তাই নামের উৎপত্তি ভুলে যান, একটি সহজ পরীক্ষা মনে রাখুন: & দিয়ে ঠিকানা নেওয়া গেলে সাধারণত lvalue; না গেলে rvalue.
value category কোনো ভ্যারিয়েবলের নয়, expression-এর বৈশিষ্ট্য। একই object, name, একটি জায়গায় lvalue (name লিখলে), আবার আরেক জায়গায় xvalue (std::move(name) লিখলে)। object বদলায় না, বদলায় আপনি তাকে কীভাবে দেখাচ্ছেন — আর সেটাই ঠিক করে compiler copy চালাবে, নাকি move.
২ · value category-র পরিবার — lvalue, prvalue, xvalue
C++11 move আনার সময় শুধু দুটি ভাগ যথেষ্ট রইল না। কারণ একটি তৃতীয় ধরনের জিনিস দরকার হলো: এমন object, যার নাম আছে, কিন্তু যাকে আপনি “মরে যাওয়ার মতো” ধরে নিতে বলছেন। তাই standard এখন তিনটি মৌলিক category রাখে, আর সেগুলোকে দুটি দলে সাজায়:
value category-র গাছ
Every C++ expression is exactly one of lvalue, xvalue, prvalue
expression
/ \
glvalue rvalue
(পরিচয় আছে) (নিয়ে নেওয়া যায়)
/ \ / \
lvalue xvalue prvalue
name, v[0], std::move(x), 42, a + b,
*p, "Dhaka" f() → T&& f() → T
| category | সহজ মানে | উদাহরণ | নিয়ে নেওয়া যায়? |
|---|---|---|---|
| lvalue | নাম বা স্থায়ী পরিচয় আছে এমন object | x, v[0], *p, ++i | না — পরে আবার লাগতে পারে |
| prvalue | “pure” rvalue — একেবারে নতুন মান, যার এখনো কোনো ঠিকানা নেই | 42, a + b, i++, std::string("x") | হ্যাঁ — এখনই শেষ হবে |
| xvalue | “expiring” value — পরিচয় আছে, কিন্তু মেয়াদ-শেষ বলে চিহ্নিত | std::move(x), T&& ফেরত দেওয়া ফাংশনের ফল | হ্যাঁ — আপনি অনুমতি দিয়েছেন |
আর দুটি দলের নাম: glvalue (“generalized lvalue”) মানে lvalue বা xvalue — যাদের একটি নির্দিষ্ট পরিচয় আছে। rvalue মানে prvalue বা xvalue — যাদের থেকে move করা যায়। বেশিরভাগ দৈনন্দিন কাজে “lvalue বনাম rvalue” ভাবলেই চলে; xvalue নামটি দরকার হয় শুধু বোঝাতে যে std::move(x)-এর ফল ঠিক কী।
তত্ত্ব পড়ে মুখস্থ করার চেয়ে কয়েকটি expression ধরে ধরে দেখা সহজ। নিচের প্রতিটি ধাপে একটি expression, তার category আর “নেওয়া যায় কি?” প্রশ্নের উত্তর। শেষ দুটি ধাপে দুটি ছোট ফাঁদ আছে:
1std::string city = "Dhaka";2std::string get_city(); // by value3std::string& get_ref(); // reference45city6city + "!"7std::move(city)8get_city()9get_ref()10"Dhaka"
| category | |
| নাম বা ঠিকানা? | |
| নেওয়া যায়? |
- নাম মানেই lvalue.
cityএকটি নাম, তার একটি ঠিকানা আছে, আর এই লাইনের পরেও সে বেঁচে থাকবে। compiler তার থেকে কিছু নিয়ে নিলে পরের লাইনেcityপড়লে খালি পাওয়া যেত — তাই lvalue থেকে নিজে থেকে কখনো move হয় না। - নতুন ফল মানেই prvalue.
city + "!"একটি একেবারে নতুন string বানায়, যার কোনো নাম নেই। পুরো expression শেষ হলেই সেটি ধ্বংস হবে। তাই একে দিয়ে নতুন object বানালে compiler নির্ভয়ে move ব্যবহার করে। - std::move দিলে xvalue. object সেই একই
city, কিন্তুstd::move(city)expression-টির টাইপstd::string&&, আর category xvalue. এটা আপনার লিখিত অনুমতি: “এর ভেতরের জিনিস নিয়ে যাও।” - by value ফেরত = prvalue.
get_city()একটিstd::stringফেরত দেয় (reference নয়)। ফেরত আসা মানটি একটি temporary, তাই prvalue. ফাংশন যা ফেরত দেয়, তা ধরে রাখতে চাইলে move বা সরাসরি তৈরি — কোনো কপি নয়। - ফাঁদ ১: reference ফেরত = lvalue.
get_ref()ফেরত দেয়std::string&— অর্থাৎ কোথাও বেঁচে থাকা একটি object-এর আরেক নাম। ফাংশন-ডাক হলেও এটি lvalue, আর এর থেকে নিজে থেকে move হয় না। (ফেরত টাইপT&&হলে xvalue হতো।) - ফাঁদ ২: string literal হলো lvalue.
"Dhaka"দেখতে temporary মনে হয়, কিন্তু এটি প্রোগ্রামের স্থির মেমরিতে রাখা একটিconst char[6]array, যার ঠিকানা নেওয়া যায়। অন্য সব literal, যেমন42বা'a', prvalue. এই ব্যতিক্রমটি আলাদা করে মনে রাখুন।
সবচেয়ে বড় ভুল ধারণা: “T&& টাইপের ভ্যারিয়েবল rvalue.” না। void f(std::string&& s)-এর ভেতরে s একটি নাম, তাই s লেখা expression-টি lvalue — যদিও তার টাইপ rvalue reference. টাইপ বলে সে কীসের সাথে যুক্ত হতে পারে; value category বলে এই মুহূর্তে তাকে কীভাবে দেখা হচ্ছে। তাই ফাংশনের ভেতরে s থেকে আবার move করতে চাইলে আবার std::move(s) লিখতে হয়। নিয়মটি ইচ্ছাকৃত: s-কে আপনি একই ফাংশনে দুবার ব্যবহার করতে পারেন, আর প্রথমবারেই নিঃশব্দে খালি হয়ে গেলে দ্বিতীয়বার বাগ হতো।
৩ · rvalue reference আর overload — কে কোন ফাংশনে যায়
পর্ব ১০-এ দেখেছেন, T& হলো একটি reference — অন্য নামে একই object. C++11 আরেক ধরনের reference আনে: rvalue reference, যা লেখা হয় T&&. দুটির পার্থক্য হলো কীসের সাথে তারা যুক্ত (bind) হতে পারে:
| reference | lvalue | const lvalue | rvalue | const rvalue |
|---|---|---|---|---|
T& | হ্যাঁ | না | না | না |
| const T& | হ্যাঁ | হ্যাঁ | হ্যাঁ | হ্যাঁ |
T&& | না | না | হ্যাঁ | না |
const T&& | না | না | হ্যাঁ | হ্যাঁ |
খেয়াল করুন, const T& সবার সাথেই যুক্ত হয় — temporary-র সাথেও। C++98 থেকেই এটা চলে আসছে, আর এ কারণেই move আসার আগে temporary থেকে object বানাতে copy constructor (const T& নেয়) চলত। C++11-এর বুদ্ধিটা হলো একই নামের দ্বিতীয় একটি overload যোগ করা, যা শুধু rvalue নেয়। তখন overload resolution-এর একটি নিয়ম কাজ করে: rvalue আর্গুমেন্টের জন্য T&& হলো const T&-এর চেয়ে ভালো মিল। ফলে:
- lvalue দিলে শুধু
const T&খাটে → কপির পথ। - rvalue (prvalue বা xvalue) দিলে দুটোই খাটে, কিন্তু
T&&জেতে → নিয়ে নেওয়ার পথ। - const object-এ
std::moveদিলে টাইপ হয়const T&&; সেটিT&&-তে যুক্ত হতে পারে না (const হারাতে পারে না) → আবারconst T&→ নিঃশব্দে কপি।
move constructor আর copy constructor ঠিক এই জোড়া: T(const T&) আর T(T&&). শুধু constructor নয়, যেকোনো ফাংশনে আপনি এই জোড়া লিখতে পারেন। standard library নিজেও তাই করে — std::vector::push_back-এর দুটি overload আছে, একটি const T& নেয় (কপি করে), অন্যটি T&& নেয় (move করে)। নিচের প্রোগ্রামে একই ফাংশনের দুটি সংস্করণ নিজের নাম প্রিন্ট করে:
#include <print>
#include <string>
#include <utility>
void take(const std::string& s) { std::println("const& -> must copy (size {})", s.size()); }
void take(std::string&& s) { std::println("&& -> may steal (size {})", s.size()); }
std::string make_title() { return "Monthly sales report"; }
int main() {
std::string name = "Rahim";
take(name); // lvalue
take(make_title()); // prvalue
take(std::move(name)); // xvalue
take(static_cast<std::string&&>(name)); // std::move er bhetore thik eta-i
const std::string city = "Dhaka";
take(std::move(city)); // const xvalue
std::println("name still holds \"{}\"", name);
}আউটপুটের ছয় লাইন মিলিয়ে নিন। take(name) → const&, কারণ name একটি lvalue. take(make_title()) → &&, size 20 — ফেরত আসা temporary একটি prvalue. take(std::move(name)) আর static_cast-এর লাইন দুটিই &&-এ যায়: দুটি হুবহু একই জিনিস। তারপর take(std::move(city)) আবার const&-এ গেল — city const, তাই move-এর অনুমতি দিলেও নেওয়ার উপায় নেই। আর শেষ লাইন name still holds "Rahim" — দুবার std::move(name) লেখা হলো, অথচ name একটুও বদলায়নি! কারণ আমাদের && সংস্করণ শুধু size পড়েছে, কিছু নেয়নি। std::move কিছু সরায় না; সরায় সেই ফাংশন, যে T&& নিয়ে সত্যিই ভেতরের জিনিস নিয়ে নেয়।
৪ · std::move আসলে একটি cast — আর moved-from object
নামটা বিভ্রান্ত করে, তাই এর ভেতরটা একবার নিজের চোখে দেখে নিন। <utility>-এ std::move-এর সংজ্ঞা মোটামুটি এরকম (C++14 থেকে):
template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}পুরো ফাংশনে একটিই লাইন, আর সেটি একটি static_cast. কোনো মেমরি নড়ে না, কোনো pointer বদলায় না, কোনো constructor চলে না। remove_reference_t অংশটি শুধু নিশ্চিত করে যে আর্গুমেন্ট নিজে reference হলেও ফলাফল ঠিক T&& হবে (কেন দরকার, অংশ ৭-এ বুঝবেন)। optimization চালু থাকলে compiler এই ফাংশনের কোনো চিহ্নই রাখে না। std::move-এর একমাত্র কাজ: একটি lvalue-কে xvalue হিসেবে দেখানো, যাতে overload resolution T&&-এর সংস্করণ বাছে। তারপর আসল কাজ — নিয়ে নেওয়া — করে সেই সংস্করণ। ধাপে ধাপে দেখুন একটি লম্বা string-এর ক্ষেত্রে কী হয়:
stack heap
s { ptr = @A000,
size = 40, cap = 40 } ────► [ 40 অক্ষর @A000 ]
s { ptr = @A000, size = 40, cap = 40 } ──► [ 40 অক্ষর ]
expression-এর টাইপ: std::string&&
category: xvalue
চলা machine instruction: ০টি
প্রার্থী ১: string(const string&) ← xvalue-ও নিতে পারে প্রার্থী ২: string(string&&) ← xvalue-এর জন্য ভালো মিল জয়ী: move constructor
stack heap
t { ptr = @A000,
size = 40, cap = 40 } ────► [ 40 অক্ষর @A000 ] ← একটুও নড়েনি
s { ptr = নিজের ভেতরের ছোট buffer,
size = 0 } (সাধারণ বাস্তবায়নে)
s.size(); // চলে — কোনো পূর্বশর্ত নেই
s = "নতুন নাম"; // চলে — নতুন মান দেওয়া নিরাপদ
s.clear(); // চলে
} // s ধ্বংস — নিরাপদ, কিছু মোছার নেই
std::print("{}", s); // চলে, কিন্তু কী ছাপবে তা ধরে নেবেন না
s[0]; // বিপদ: s খালি হলে undefined behavior
- শুরু। লম্বা string-এর অক্ষরগুলো heap-এ থাকে।
sনিজে শুধু তিনটি জিনিস রাখে: buffer-এর ঠিকানা, size আর capacity. (খুব ছোট string হলে ব্যাপারটা আলাদা — অংশ ৮-এ দেখবেন।) - শুধু cast.
std::move(s)লেখার পর মেমরিতে একটি বাইটও বদলায়নি। বদলেছে শুধু compiler-এর চোখে expression-টির পরিচয়: এখন এটিstd::string&&, একটি xvalue. এখানেই থেমে গেলে (যেমনstd::move(s);একা একটি লাইনে) কিছুই ঘটে না। - compiler বাছাই করে। নতুন
tবানাতে দুটি constructor প্রার্থী। xvalue-এর জন্যstring&&ভালো মিল, তাই move constructor জেতে। std::move-এর কাজ এখানেই শেষ — সে শুধু এই বাছাইয়ের ফল বদলে দিয়েছে। - আসল move. move constructor-এর ভেতরে t নিয়ে নিল s-এর ঠিকানা, size আর capacity — তিনটি সংখ্যা। ৪০টি অক্ষর একটুও নড়েনি; string যত লম্বাই হোক, খরচ একই। তারপর s-কে এমন অবস্থায় রাখা হলো, যাতে তার destructor ওই buffer না মোছে। প্রধান library-গুলোতে s তখন খালি string হয়ে যায়।
- moved-from অবস্থা। standard শুধু বলে s এখন “valid but unspecified” — বৈধ, কিন্তু মান অনির্দিষ্ট। নতুন মান দেওয়া, ধ্বংস হতে দেওয়া, আর পূর্বশর্তহীন ফাংশন (
size(),clear()) ডাকা নিরাপদ। কিন্তু “s এখন খালি” ধরে নিয়ে কোড লিখবেন না, আরs[0]বাfront()-এর মতো পূর্বশর্তওয়ালা কাজ তো নয়ই।
পর্ব ২২-এ moved-from state-এর নিয়ম দেখেছেন; এখানে একটি ব্যবহারিক নিয়মে মনে রাখুন: std::move লেখা মানে প্রতিজ্ঞা — “এই লাইনের পর আমি এই নামটি আর পড়ব না, যতক্ষণ না নতুন মান দিচ্ছি।” এই প্রতিজ্ঞা ভাঙাকে বলে use-after-move, আর এটি AI-এর লেখা কোডের একটি সাধারণ বাগ। clang-tidy-এর bugprone-use-after-move নামের check এমন জায়গা খুঁজে দেয়।
C-তে reference নেই, তাই lvalue/rvalue পার্থক্য শুধু assignment-এর নিয়মে সীমিত। struct assign করলে সবসময় বাইট ধরে কপি হয়। C-তে “move” করতে চাইলে আপনি হাতে লেখেন: dst->buf = src->buf; src->buf = NULL; — move constructor ঠিক এই কাজই করে। পার্থক্য হলো, C++-এ compiler নিজে বুঝে নেয় কখন এটা নিরাপদ (temporary), আর কখন আপনার অনুমতি লাগে (std::move). heap-এর মালিকানার মূল ধারণা দেখতে C কোর্সের dynamic memory পর্ব দেখুন।
৫ · move নিজে থেকে কখন হয় — ফেরত দেওয়া, RVO আর copy elision
সবচেয়ে সুখবর: ভালো C++ কোডে আপনাকে খুব কম জায়গায় std::move লিখতে হয়। বেশিরভাগ move compiler নিজেই করে, কারণ সে নিজেই চেনে কোন জিনিস মরতে চলেছে। দুটি জায়গা সবচেয়ে গুরুত্বপূর্ণ।
১. temporary পাঠানো। v.push_back(std::string("Rajshahi")) বা v.push_back(make_title()) — আর্গুমেন্ট prvalue, তাই T&&-এর overload নিজে থেকেই বাছা হয়। এখানে std::move লেখা অর্থহীন।
২. local ভ্যারিয়েবল ফেরত দেওয়া। ধরুন একটি দোকানের মাসিক রিপোর্ট বানানো হচ্ছে — হাজার লাইনের একটি লম্বা string:
std::string build_report(const std::vector<Sale>& sales) {
std::string out;
out.reserve(sales.size() * 32);
for (const Sale& s : sales)
out += std::format("{:<12}{:>10}\n", s.shop, s.amount);
return out; // কপি নয়: NRVO, না হলে নিজে থেকেই move
}
std::string report = build_report(dhaka_sales);C++98-এর যুগে অনেকে ভয় পেতেন, “বড় string by value ফেরত দিলে তো পুরোটা কপি হবে!” — তাই out-parameter (void build_report(..., std::string& out)) লিখতেন। আজ সেই ভয় অমূলক, তিনটি স্তরের কারণে:
- RVO (Return Value Optimization):
return std::string(...)-এর মতো prvalue ফেরত দিলে object টি সরাসরি caller-এর ভ্যারিয়েবলের জায়গাতেই তৈরি হয়। C++17 থেকে এটি আর optimization নয়, ভাষার নিয়ম — একে বলে guaranteed copy elision. তখন copy বা move constructor থাকারও দরকার নেই। - NRVO (Named RVO): নাম আছে এমন local (
out) ফেরত দিলে compiler চাইলেout-কেই শুরু থেকে caller-এর জায়গায় বানাতে পারে। এটা অনুমোদিত, কিন্তু নিশ্চিত নয়। উপরের মতো সরল ক্ষেত্রে GCC, Clang, MSVC সাধারণত এটা করে। - নিজে থেকে move (implicit move): NRVO না হলেও, যেমন দুটি আলাদা local-এর একটি শর্তসাপেক্ষে ফেরত দিলে, C++11 থেকে নিয়ম হলো
return out;-এ out-কে প্রথমে rvalue হিসেবে ধরা হয়। ফলে কপি নয়, move চলে। C++20 আর C++23 এই নিয়মটি আরও সরল ও বিস্তৃত করেছে।
main-এর stack frame
┌──────────────────────────────┐
│ report : [ খালি জায়গা, এখনো তৈরি হয়নি ] │ ← ঠিকানা @F100
└──────────────────────────────┘
compiler গোপনে build_report-কে দেয়: "ফল বানাও @F100-এ"
main frame: report @F100 ◄──┐
│ একই মেমরি
build_report frame: out ────────────┘
(out-এর আলাদা কোনো জায়গা নেই)
@F100 { ptr = @B000, size = 32000 } ──► [ রিপোর্টের ৩২ হাজার অক্ষর ]
caller-এর report ইতিমধ্যেই ভরে উঠছে,
যদিও ফাংশন এখনো শেষ হয়নি
build_report frame ভাঙল — out ধ্বংস হলো না, কারণ out আসলে report-ই main frame: report @F100 ──► [ ৩২ হাজার অক্ষর ] ✓
return std::string(n, '-'); // prvalue: C++17 থেকে নিশ্চিত ০ copy, ০ move return out; // NRVO হতে পারে; না হলে ১টি move, কপি কখনো নয় return std::move(out); // NRVO বন্ধ! সবসময় ১টি move — বেশি কাজ
- জায়গা আগেই ঠিক। by value ফেরত দেওয়া বড় object-এর জন্য caller নিজের stack frame-এ জায়গা রেখে দেয়, আর গোপন একটি আর্গুমেন্ট হিসেবে সেই ঠিকানা ফাংশনকে দেয়। বেশিরভাগ প্ল্যাটফর্মের ABI এভাবেই কাজ করে।
- out মানেই report. compiler দেখে, ফাংশনের প্রতিটি return পথ একই local
outফেরত দেয়। তাই সে out-কে আলাদা জায়গায় না বানিয়ে সরাসরি caller-এর দেওয়া ঠিকানায় বানায়। এটাই NRVO. - কাজ সরাসরি গন্তব্যে। লুপে যত অক্ষর যোগ হয়, সব জমা হয় caller-এর object-এ। ফাংশন শেষ হওয়ার অপেক্ষা নেই, শেষে স্থানান্তরও নেই।
- ফেরত দেওয়া প্রায় খরচহীন।
return out;-এ কোনো copy বা move constructor চলে না। out-এর destructor-ও চলে না, কারণ object টি report হিসেবে বেঁচে থাকে। এই কারণেই আধুনিক C++-এ বড় ফল by value ফেরত দেওয়াই স্বাভাবিক। - তুলনা। prvalue ফেরত দিলে elision ভাষার নিশ্চয়তা। নাম থাকা local-এ NRVO, আর না হলে move — দুটোই সস্তা। কিন্তু
std::move(out)লিখলে ফেরত দেওয়া expression আর “local-এর নাম” থাকে না, তাই NRVO-র নিয়মই খাটে না: compiler বাধ্য হয়ে একটি move চালায়, আর moved-from out-এর destructor-ও চালায়।
return std::move(local); লিখবেন না। উদ্দেশ্য ভালো (“কপি এড়াই”), কিন্তু ফল উল্টো: এতে NRVO বন্ধ হয়, আর একটি অপ্রয়োজনীয় move যোগ হয়। একে বলে pessimizing move — optimization-এর উল্টো। GCC (৯ সংস্করণ থেকে) আর Clang -Wall দিলেই -Wpessimizing-move warning দেয়। ব্যতিক্রম: যা local ভ্যারিয়েবল বা by value প্যারামিটার নয়, এমন কিছু ফেরত দিলে — যেমন একটি member, return std::move(this->cache_); — move নিজে থেকে হয় না, আর std::move সত্যিই দরকার। (C++20-এর আগে ফেরত টাইপ local-এর টাইপ থেকে আলাদা হলেও, যেমন derived local থেকে base ফেরত দিলে, কখনো কখনো এটি লাগত; C++20 সেই ফাঁক বন্ধ করেছে।)
একটি বাড়তি সুবিধা: C++17-এর নিশ্চিত elision-এর ফলে যে টাইপ কপি বা move কিছুই করা যায় না (যেমন std::mutex ধরে রাখা একটি class), তাকেও একটি factory ফাংশন থেকে prvalue হিসেবে ফেরত দেওয়া যায় — কোনো constructor ডাকারই প্রয়োজন পড়ে না। ভাবনার প্রশ্ন ৩-এ এর পেছনের নকশাটা দেখবেন।
৬ · sink parameter — by value নিন, তারপর move করুন
কিছু ফাংশনের কাজই হলো আর্গুমেন্টটি নিজের কাছে রেখে দেওয়া: একটি setter, যে নাম জমা রাখে; একটি constructor, যে playlist জমা রাখে; একটি add, যে vector-এ ঢোকায়। এমন প্যারামিটারকে বলে sink parameter — পানি যেমন sink-এ গিয়ে আর ফেরে না। এখানে দুটি অবস্থা সামলাতে হয়: caller হয়তো তার জিনিসটি পরেও রাখতে চায় (তাহলে কপি দরকার), নয়তো আর চায় না (তাহলে move যথেষ্ট)।
পুরোনো সমাধান ছিল দুটি overload: set_name(const std::string&) আর set_name(std::string&&). দুটি প্যারামিটার হলে চারটি overload, তিনটিতে আটটি — দ্রুত অসম্ভব হয়ে যায়। আধুনিক সহজ সমাধান: by value নিন, তারপর ভেতরে move করুন।
class Customer {
std::string name_;
public:
void set_name(std::string n) { name_ = std::move(n); } // একটি ফাংশন, দুই কাজ
};
c.set_name(input); // lvalue: n-এ ১টি কপি, তারপর ১টি move
c.set_name(read_line()); // prvalue: n সরাসরি তৈরি, তারপর ১টি move
c.set_name(std::move(input)); // xvalue: n-এ ১টি move, তারপর ১টি moveকপি তখনই হয়, যখন caller সত্যিই কপি চায় — lvalue দিয়ে। আর প্রতিটি ক্ষেত্রে মাত্র একটি বাড়তি move, যা string বা vector-এর বেলায় কয়েকটি pointer বদলানো মাত্র। এই কৌশলটি নিচে দেখুন — একটি music player অ্যাপ, যেখানে ব্যবহারকারীর playlist player-এর কাছে যায়। Song-এর copy আর move constructor নিজেদের হিসাব রাখে:
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Song {
std::string title; static inline int copies = 0, moves = 0;
explicit Song(const char* t) : title(t) {}
Song(const Song& o) : title(o.title) { ++copies; }
Song(Song&& o) noexcept : title(std::move(o.title)) { ++moves; }
};
struct Player { std::vector<Song> queue;
void load(std::vector<Song> list) { queue = std::move(list); } // sink parameter
};
void show(const char* step) { std::println("{:<24} copies={} moves={}", step, Song::copies, Song::moves); Song::copies = Song::moves = 0; }
int main() {
std::vector<Song> mine{Song("Ekla Cholo Re"), Song("Bhalo Theko"), Song("Amar Sonar Bangla")};
const std::vector<Song> backup = mine; Song::copies = 0; Player p;
p.load(mine); show("load(mine)");
p.load(std::move(mine)); show("load(std::move(mine))");
p.load(std::move(backup)); show("load(std::move(backup))");
}তিন লাইন আউটপুট: load(mine) copies=3 moves=0, load(std::move(mine)) copies=0 moves=0, load(std::move(backup)) copies=3 moves=0. প্রথমবার mine একটি lvalue, তাই প্যারামিটার list বানাতে vector-এর copy constructor চলল — তিনটি গানই কপি। দ্বিতীয়বার std::move(mine): vector-এর move constructor গোটা buffer-এর pointer নিয়ে নিল, তাই একটি গানও কপি বা move হয়নি — শুধু তিনটি pointer হাতবদল। ভেতরের queue = std::move(list)-ও buffer হাতবদল করে, তাই সেখানেও শূন্য। তৃতীয় লাইনটি একটি ফাঁদ: backup const, তাই std::move(backup)-এর টাইপ const vector&&, যা শুধু copy constructor-এ যুক্ত হতে পারে — std::move লেখা সত্ত্বেও নিঃশব্দে তিনটি কপি। (শুরুতে mine আর backup বানানোর কপিগুলো হিসাব থেকে বাদ দেওয়া হয়েছে।) moved-from mine-কে আমরা আর পড়িনি — ঠিক যেমনটা উচিত।
কখন sink-by-value নয়: যে ফাংশন আর্গুমেন্টটি শুধু পড়ে, রেখে দেয় না (যেমন print(const Song&)), সেখানে const T& বা পর্ব ১৪-এর std::string_view ভালো — by value নিলে অকারণে কপি হতো। আর যে setter বারবার একই আকারের লম্বা string পায়, সেখানে const std::string& নিয়ে name_ = n লিখলে name_-এর পুরোনো buffer আবার ব্যবহার হতে পারে, by value-তে প্রতিবার নতুন buffer লাগে। খুব বেশি গতির দরকার হলে মেপে দেখুন (পর্ব ৪৯)।
৭ · forwarding reference আর std::forward — perfect forwarding
এবার একটি নতুন সমস্যা। একটি রেস্তোরাঁর অ্যাপে make_order(...) নামের একটি factory ফাংশন চাই, যা অর্ডারের আইডি দেয়, লগ লেখে, তারপর যে আর্গুমেন্টগুলো পেয়েছে সেগুলো দিয়ে একটি Order বানায়। প্রশ্ন: factory-র প্যারামিটার কী টাইপের হবে, যাতে caller lvalue দিলে Order-এর কাছে lvalue যায় (কপি), আর temporary দিলে temporary যায় (move)? const T& নিলে সবকিছু lvalue হয়ে যায় — move হারায়। T&& নিলে lvalue নেওয়াই যায় না। প্রতিটি আর্গুমেন্টের জন্য দুটি overload লেখা অসম্ভব। এটাই “forwarding problem”, যা move semantics-এর সাথে একই সময়ে সমাধান করা হয়েছিল।
template-এর T&& আলাদা জিনিস
সমাধানটি লুকিয়ে আছে template argument deduction-এর একটি বিশেষ নিয়মে। যখন একটি function template-এর প্যারামিটার ঠিক T&&, আর T সেই ডাকের সময়েই deduce হচ্ছে, তখন এটি rvalue reference নয় — এটি forwarding reference. এটি lvalue আর rvalue দুটোই নিতে পারে, আর কোনটি পেল সেই তথ্য T-এর ভেতরে লিখে রাখে:
- lvalue
std::stringদিলে T deduce হয়std::string&(একটি reference!)। - rvalue
std::stringদিলে T deduce হয় শুধুstd::string.
কিন্তু T = std::string& হলে প্যারামিটার T&& হয়ে দাঁড়ায় std::string& && — reference-এর reference, যা সাধারণ কোডে লেখাই যায় না। এখানে কাজ করে reference collapsing-এর নিয়ম, পুরোটা একটি টেবিলে:
| T হলো | লেখা আছে | মিলে দাঁড়ায় | মনে রাখার উপায় |
|---|---|---|---|
X& | T& | X& | কোথাও একটি & থাকলেই ফল & |
X& | T&& | X& | |
X&& | T& | X& | |
X&& | T&& | X&& | দুটিই && হলে তবেই && |
তাহলে ফাংশনের ভেতরে x lvalue হিসেবে এসেছিল নাকি rvalue হিসেবে, সেটা T দেখেই বোঝা যায়। কিন্তু মনে আছে অংশ ২-এর সতর্কতা? x একটি নাম, তাই ভেতরে x লিখলে সবসময় lvalue. মূল পরিচয় ফিরিয়ে আনতে লাগে std::forward: std::forward<T>(x) T দেখে সিদ্ধান্ত নেয় — T reference হলে lvalue ফেরত দেয়, না হলে rvalue (ভেতরে এটিও একটি static_cast<T&&>, যা collapsing নিয়মে ঠিক ফল দেয়)। এক কথায়: std::move সবসময় rvalue বানায়; std::forward শর্তসাপেক্ষে — শুধু তখনই, যখন মূল আর্গুমেন্টটি rvalue ছিল।
1void target(const std::string&); // কপি2void target(std::string&&); // move3template <class T>4void wrap(T&& x) {5 target(x); // ভুল6 target(std::forward<T>(x)); // ঠিক7}8wrap(name); // lvalue9wrap(std::string("Rina")); // prvalue
| আর্গুমেন্ট | |
| T | |
| x-এর টাইপ | |
| পাঠানো হলো |
- lvalue পাঠানো হলো। caller দিল
name, একটি নাম থাকা string. এখন compiler-কে wrap-এর T ঠিক করতে হবে। - T-এর ভেতরে তথ্য লেখা। forwarding reference-এর বিশেষ নিয়মে, lvalue পেলে T হয়
std::string&. প্যারামিটারT&&হয়std::string& &&, আর collapsing নিয়মে সেটিstd::string&. অর্থাৎ x হলো caller-এর name-এর আরেক নাম। - সাদা x — lvalue.
target(x)লিখলে x একটি নাম, তাই lvalue → কপির সংস্করণ। lvalue এসেছিল, lvalue গেল — এবারের মতো ঠিক আছে। - forward — এখনো lvalue.
std::forward<std::string&>(x)দেখে যে T একটি reference, তাই lvalue-ই ফেরত দেয়। caller-এর name কেউ নিয়ে নেয়নি — perfect forwarding caller-এর জিনিস কখনো চুপচাপ খালি করে না। - এবার temporary.
wrap(std::string("Rina"))— prvalue পেলে T হয় শুধুstd::string, x-এর টাইপstd::string&&. কিন্তু সাবধান: ফাংশনের ভেতরে x এখনো একটি নাম, অর্থাৎ lvalue.target(x)লিখলে অকারণে কপি হতো। - forward — rvalue ফিরে এল।
std::forward<std::string>(x)দেখে T reference নয়, তাই xvalue ফেরত দেয় → move-এর সংস্করণ। আর্গুমেন্ট যেমন এসেছিল (temporary), ঠিক তেমন করেই গেল — একেই বলে perfect forwarding.
এবার রেস্তোরাঁর factory. variadic template দিয়ে যেকোনো সংখ্যক আর্গুমেন্ট নেওয়া যায়, আর ... দিয়ে প্রতিটিকে আলাদাভাবে forward করা যায়:
struct Order {
std::string customer;
std::vector<std::string> items;
int table;
Order(std::string c, std::vector<std::string> i, int t)
: customer(std::move(c)), items(std::move(i)), table(t) {}
};
template <class... Args>
Order make_order(Args&&... args) {
log_new_order(); // বাড়তি কাজ
return Order(std::forward<Args>(args)...); // প্রতিটি আর্গুমেন্ট যেমন এসেছিল
}
std::string regular = "Karim bhai";
auto o1 = make_order(regular, std::vector<std::string>{"cha", "singara"}, 4);
// regular → কপি হয়ে যায় (caller-এর নাম অক্ষত), vector-টি → move, 4 → intstandard library-র অনেক পরিচিত ফাংশন ঠিক এই ছাঁচে লেখা: std::make_unique আর্গুমেন্টগুলো object-এর constructor-এ forward করে, আর emplace_back সেগুলো vector-এর ভেতরের জায়গায় সরাসরি constructor-এ পাঠায় — তাই v.emplace_back("Karim", items, 4) লিখলে কোনো অস্থায়ী Order-ই তৈরি হয় না।
কোনটি forwarding reference, কোনটি নয়
নিয়মটি সংকীর্ণ, তাই এখানে ভুল হয় সবচেয়ে বেশি — মানুষেরও, AI-এরও:
| লেখা | কী | কেন |
|---|---|---|
template<class T> void f(T&& x) | forwarding reference | ঠিক T&&, আর T এই ডাকেই deduce হয় |
auto&& x = expr; | forwarding reference | auto-ও একই deduction নিয়ম মানে |
void f(std::string&& x) | rvalue reference | কোনো deduction নেই |
template<class T> void f(const T&& x) | rvalue reference | const যোগ হলেই বিশেষ নিয়ম বাতিল |
template<class T> void f(std::vector<T>&& v) | rvalue reference | প্যারামিটার ঠিক T&& নয় |
template<class T> struct Box { void put(T&& x); }; | rvalue reference | T আগেই ঠিক হয়ে গেছে Box<int> লেখার সময়; put ডাকার সময় deduce হয় না |
সহজ নিয়মে মনে রাখুন: rvalue reference পেলে std::move, forwarding reference পেলে std::forward. উল্টোটা করলে বাগ: forwarding reference-এ std::move লিখলে caller-এর lvalue-ও চুপচাপ খালি হয়ে যায়। আর একটি forwarded আর্গুমেন্টকে একবারের বেশি forward করবেন না — প্রথমবারেই সেটি move হয়ে যেতে পারে।
৮ · move-only টাইপ, container থেকে move, আর যখন move দ্রুত নয়
যাদের শুধু move করা যায়
পর্ব ২২-এ দেখেছেন, কিছু জিনিসের কপি করার কোনো অর্থ নেই। standard library-তে এমন move-only type অনেক: std::unique_ptr (একটি heap object-এর একমাত্র মালিক), std::thread (একটি চলমান thread-এর হাতল, পর্ব ৪৫), std::fstream আর std::ofstream (একটি খোলা ফাইল, পর্ব ৪৪), std::unique_lock, std::future. এদের সাথে কাজের নিয়ম সহজ: কপি চাইলে compile error, হাতবদল করতে চাইলে std::move.
std::vector<std::unique_ptr<Driver>> fleet;
auto d = std::make_unique<Driver>("Sohel", "Dhaka Metro-Ga 11");
fleet.push_back(d); // error: unique_ptr কপি করা যায় না
fleet.push_back(std::move(d)); // ঠিক — d এখন নিশ্চিতভাবে nullptr
fleet.push_back(std::make_unique<Driver>("Rina", "Ga 12")); // prvalue — move নিজে থেকেই
std::ofstream make_log() { return std::ofstream("trip.log"); } // move-only টাইপও by value ফেরতcontainer থেকে বের করে আনা
কখনো একটি vector-এর উপাদানগুলো আরেক জায়গায় পাঠাতে হয়, আর মূল vector আর লাগে না। সাধারণ range-for-এ auto& দিয়ে প্রতিটি উপাদানকে move করা যায়; আর <algorithm>-এ একটি তিন-আর্গুমেন্টের std::move algorithm আছে (একই নাম, কিন্তু <utility>-এর cast থেকে আলাদা ফাংশন), যা একটি range-এর প্রতিটি উপাদান গন্তব্যে move করে:
for (auto& song : today) player.add(std::move(song)); // প্রতিটি move
today.clear(); // moved-from উপাদানগুলো ফেলে দিন
std::move(old_q.begin(), old_q.end(), std::back_inserter(new_q)); // algorithm সংস্করণ
Task t = std::move(tasks.back()); // শেষেরটি বের করা — move, তারপর
tasks.pop_back(); // খালি খোলসটি সরানোদুটি সতর্কতা। এক, move করার পর মূল container-এ উপাদানগুলো এখনো আছে — moved-from অবস্থায়, সংখ্যা একই। তাই কাজ শেষে clear() করুন, বা আর পড়বেন না। দুই, std::map বা std::set-এর key const, তাই std::move(it->first) চুপচাপ কপি করে। key সত্যিই বের করে আনতে হলে C++17-এর extract ব্যবহার করুন (পর্ব ৩১)।
যখন move কপির চেয়ে দ্রুত নয়
move-এর গতি আসে একটি জিনিস থেকে: object-এর আসল ডেটা অন্য কোথাও (heap-এ) থাকে, আর object নিজে শুধু তার ঠিকানা রাখে। ঠিকানা হাতবদল সস্তা। কিন্তু যেখানে ডেটা object-এর নিজের ভেতরেই থাকে, সেখানে “নিয়ে নেওয়ার” মতো কোনো ঠিকানা নেই — move করতেও সব বাইট কপি করতে হয়:
| টাইপ | move-এর খরচ | কারণ |
|---|---|---|
int, double, pointer | কপির সমান | নেওয়ার মতো আলাদা কিছু নেই; move মানেই কপি |
struct Point { double x, y; } | কপির সমান | সব ডেটা object-এর ভেতরে |
std::array<int, 1000> | ১০০০টি int কপি | পর্ব ১৩: array-এর ঘরগুলো object-এর ভেতরেই |
ছোট std::string (যেমন "Dhaka") | কয়েকটি অক্ষর কপি | SSO: ছোট লেখা object-এর নিজের buffer-এ থাকে (library-ভেদে সাধারণত ১৫ থেকে ২২ অক্ষর পর্যন্ত) |
লম্বা string, std::vector, unique_ptr | কয়েকটি pointer বদল | ডেটা heap-এ; এখানেই move-এর আসল লাভ |
তাই int বা Point-এ std::move লেখা ক্ষতিকর নয়, কিন্তু অর্থহীন — আর পাঠকের মনে ভুল ধারণা জাগায় যে এখানে কিছু দামি ঘটছে। ছোট string-এর ক্ষেত্রেও move সস্তা, কারণ কয়েকটি বাইট কপি করা খুব দ্রুত। পরিমাপের আগে “move দিয়ে গতি বাড়াই” ভাবনা থেকে সাবধান থাকুন।
- lvalue আর rvalue-এর পার্থক্য কী? — lvalue-এর নাম বা স্থায়ী পরিচয় আছে,
&দিয়ে ঠিকানা নেওয়া যায়; rvalue হলো temporary বা মেয়াদ-শেষ বলে চিহ্নিত জিনিস, যার থেকে move করা যায়। - std::move কী করে? — কিছুই সরায় না; আর্গুমেন্টকে
T&&-এ cast করে (xvalue বানায়), যাতে move constructor বা move assignment বাছা হয়। return std::move(local);কেন খারাপ? — এটি NRVO বন্ধ করে একটি বাড়তি move যোগ করে;return local;লিখলে elision বা নিজে থেকে move — দুটোই ভালো।- rvalue reference আর forwarding reference-এর পার্থক্য? — deduce হওয়া template প্যারামিটারের ঠিক
T&&(বাauto&&) হলো forwarding reference, যা lvalue-ও নেয়; বাকি সবX&&শুধু rvalue নেয়। - moved-from object দিয়ে কী করা যায়? — ধ্বংস হতে দেওয়া, নতুন মান দেওয়া, আর পূর্বশর্তহীন ফাংশন ডাকা; মান সম্পর্কে কিছু ধরে নেওয়া যায় না (unique_ptr ছাড়া, যা নিশ্চিতভাবে null).
৯ · AI-এর যুগে — ছিটানো std::move চিনে ফেলা
AI টুলগুলো “আধুনিক, দ্রুত C++” লিখতে বললে প্রায়ই সব জায়গায় std::move ছিটিয়ে দেয়, যেন যত বেশি move, তত বেশি গতি। দেখে পেশাদার মনে হয়, কিন্তু এই পর্বে আপনি দেখেছেন — ভুল জায়গার move হয় অর্থহীন, হয় ধীর, হয় বাগ। AI-এর কোড পড়ার সময় এই টেবিলটি পাশে রাখুন:
| AI যা লেখে | সমস্যা | যা হওয়া উচিত |
|---|---|---|
return std::move(result); | NRVO বন্ধ, বাড়তি move; -Wpessimizing-move | return result; |
const object-এ std::move | নিঃশব্দে কপি — কোনো error বা warning নেই | const সরান, বা মেনে নিন যে কপি হবে |
v.push_back(std::move(s)); তারপর আবার s পড়া | use-after-move: মান অনির্দিষ্ট | move-এর পর s-এ নতুন মান দিন, বা move-ই করবেন না |
template-এর T&&-এ std::move | caller-এর lvalue চুপচাপ খালি | std::forward<T>(x) |
class template-এর void put(T&&)-কে “universal” ভেবে lvalue পাঠানো | compile error, বা AI তখন জোর করে std::move বসায় | put(const T&) আর put(T&&), বা by value sink |
নিজে লেখা move constructor-এ noexcept নেই | vector বড় হওয়ার সময় move না করে কপি (পর্ব ২২) | T(T&&) noexcept, বা Rule of Zero |
int, Point, std::array-তে std::move | কোনো লাভ নেই, শুধু পাঠককে বিভ্রান্ত করে | সাধারণ কপি |
AI-কে কোড লিখতে বলার সময় নিয়মগুলো আগেই দিয়ে দিন, আর প্রতিটি std::move-এর কারণ চেয়ে নিন:
AI-এর লেখা move-কোড যাচাইয়ের চেকলিস্ট
প্রতিটি প্রতিশ্রুতির পর জিজ্ঞেস করুন: “এই নামটি কি আমি আর পড়ব?”
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১move semantics-এর প্রস্তাব এসেছিল ২০০২ সালে, অথচ ভাষায় ঢুকল C++11-এ। এর আগে C++ প্রোগ্রামাররা কপির খরচ কীভাবে এড়াতেন, আর এত সময় লাগল কেন?
২০০২ সালে Howard Hinnant, Peter Dimov আর Dave Abrahams C++ standard কমিটিতে একটি প্রস্তাব দেন, যার নম্বর N1377 — “A Proposal to Add Move Semantics Support to the C++ Language”. একই বছরে একই লেখকেরা “forwarding problem” নিয়েও একটি আলাদা লেখা দেন (N1385), যেখানে দেখানো হয় যে C++98-এ একটি wrapper ফাংশন তার আর্গুমেন্ট নিখুঁতভাবে অন্য ফাংশনে পাঠাতে পারে না। দুটি সমস্যার একটিই সমাধান প্রস্তাব করা হয়: একটি নতুন ধরনের reference, &&.
তার আগে প্রোগ্রামাররা নানা কৌশলে কাজ চালাতেন। কেউ out-parameter ব্যবহার করতেন: ফল ফেরত না দিয়ে caller-এর দেওয়া reference-এ লিখতেন। কেউ swap ব্যবহার করতেন: একটি খালি vector বানিয়ে temporary-র সাথে অদলবদল। অনেক library-র std::string ছিল copy-on-write: কপি করলে আসলে শুধু একটি reference count বাড়ত, আর লেখার সময় তবেই আসল কপি হতো। আর ছিল std::auto_ptr — এমন একটি smart pointer, যার “copy constructor” আসলে মালিকানা নিয়ে নিত আর source-কে null করে দিত। এটি move-এর একটি প্রাথমিক রূপ, কিন্তু কপির ছদ্মবেশে। ফলে auto_ptr-কে container-এ রাখা বা sort করা বিপজ্জনক ছিল: algorithm কপি ভেবে যা করত, তাতে উপাদান হারিয়ে যেত। C++11-এ auto_ptr deprecated হয় আর C++17-এ পুরোপুরি সরানো হয়; তার জায়গা নেয় unique_ptr, যার move স্পষ্ট।
এত সময় লাগার কারণ, পরিবর্তনটা ছিল ভাষার একেবারে গভীরে। overload resolution, template deduction, special member তৈরির নিয়ম, standard library-র প্রতিটি container — সবকিছু নতুন করে ভাবতে হয়েছে। মাঝপথে বড় একটি সংশোধনও লাগে: শুরুর নকশায় && lvalue-র সাথেও যুক্ত হতে পারত, যা নিরাপত্তার ঝুঁকি তৈরি করত; ২০০৮–২০০৯ সালের দিকে নিয়ম বদলে সেটা বন্ধ করা হয়। আর value category-র lvalue/xvalue/prvalue নামগুলো চূড়ান্ত হয় ২০১০ সালে, যখন ভাষা আরও স্পষ্ট শব্দ চাইছিল। পুরো C++0x প্রক্রিয়াটাই দেরিতে শেষ হয় — নামের “0x” দেখায়, কমিটি প্রথমে ২০০৯-এর আগেই শেষ করার আশা করেছিল।
C++11-এর পর পরিবর্তনের ফল প্রায় বিনা প্রচেষ্টায় পাওয়া গেছে: পুরোনো কোড শুধু নতুন compiler আর library দিয়ে আবার compile করলেই অনেক কপি move-এ বদলে গেছে, কারণ vector, string-এর মতো টাইপ নিজেরাই move শিখেছে।
মূল উপলব্ধি: move semantics কোনো একটি ফিচার নয়, ভাষার একটি মৌলিক নিয়মের পরিবর্তন — “temporary থেকে নেওয়া নিরাপদ।” এমন পরিবর্তন পুরোনো কোড না ভেঙে আনতে প্রায় এক দশক লেগেছে, আর সেই সাবধানতার কারণেই আজ তা প্রায় অদৃশ্যভাবে সব C++ কোডকে দ্রুত করে।
প্র ০২অনেকে বলেন std::move-এর নাম আসলে হওয়া উচিত ছিল rvalue_cast. এই যুক্তিটা কী, আর ভুল নামের দাম কে দেয়?
যুক্তিটা সরল: একটি ফাংশনের নাম তার কাজ বলা উচিত। std::move কিছুই সরায় না — অংশ ৪-এ দেখেছেন, এর পুরো শরীর একটি static_cast. এটি একটি lvalue-কে rvalue হিসেবে দেখায়, ব্যস। তাই এর সৎ নাম হতো এমন কিছু, যা C++-এর অন্য cast-গুলোর সাথে মেলে: static_cast, const_cast, reinterpret_cast... rvalue_cast. Scott Meyers তাঁর “Effective Modern C++” (২০১৪) বইয়ে ঠিক এই কথাটি লিখেছেন: rvalue_cast-এর মতো একটি নাম কাজটি আরও ভালোভাবে বোঝাত।
তাহলে “move” নাম কেন? কারণ এটা উদ্দেশ্যের নাম, যান্ত্রিক কাজের নয়। কোড পড়ার সময় v.push_back(std::move(s)) দেখে পাঠক সঙ্গে সঙ্গে বোঝেন লেখকের ইচ্ছা: “s-এর জিনিস v-তে চলে যাক।” v.push_back(std::rvalue_cast(s)) যান্ত্রিকভাবে সঠিক, কিন্তু পড়তে ভারী, আর উদ্দেশ্যটা পাঠককে অনুবাদ করে নিতে হয়। প্রায় সব ক্ষেত্রে cast-এর পর সত্যিই একটি move হয়, তাই নামটি সাধারণ ক্ষেত্রে সত্যি।
কিন্তু সাধারণ ক্ষেত্রের বাইরে নামটি মিথ্যা বলে, আর তখনই দাম দিতে হয়। এক, const object-এ std::move “move” লেখা সত্ত্বেও কপি করে, কোনো warning ছাড়াই। দুই, যে টাইপের move constructor নেই (যেমন পুরোনো ধাঁচে শুধু destructor আর copy লেখা class), সেখানেও চুপচাপ কপি। তিন, নতুন শিক্ষার্থী ভাবেন std::move(s); একা লিখলেই s খালি হবে — হয় না। চার, AI মডেলগুলো লাখো কোড পড়ে এই নামকে “দ্রুত করার মন্ত্র” হিসেবে শিখেছে, তাই যেখানে সেখানে বসায়। এই প্রতিটি ক্ষেত্রেই সমস্যার মূল হলো নাম আর কাজের মধ্যে ফাঁক।
std::forward-এরও একই গল্প: এটি একটি শর্তসাপেক্ষ cast, যা T reference না হলে rvalue বানায়। নাম দেখে মনে হয় এটি কিছু “পাঠায়”, কিন্তু পাঠানোর কাজ করে যে ফাংশনে আপনি ফলটি দেন।
মূল উপলব্ধি: নাম উদ্দেশ্য বোঝায়, কিন্তু আচরণ ঠিক হয় সংজ্ঞায়। std::move দেখলে মনে মনে পড়ুন “rvalue হিসেবে দেখাও”, আর তারপর জিজ্ঞেস করুন — যে ফাংশন এটি পাচ্ছে, তার কি সত্যিই একটি T&& overload আছে, আর object টি কি const নয়?
প্র ০৩C++17-এর “guaranteed copy elision” (P0135) আগের RVO থেকে কীভাবে আলাদা? “prvalue কোনো object নয়” — এই কথার মানে কী?
C++98 থেকেই compiler-কে copy elision করার অনুমতি দেওয়া ছিল: ফেরত দেওয়া temporary-কে সরাসরি caller-এর জায়গায় বানানো, এমনকি তাতে copy constructor-এর প্রিন্ট বা অন্য পার্শ্বপ্রতিক্রিয়া বাদ পড়লেও। প্রায় সব compiler তা করত। কিন্তু অনুমতি মানে বাধ্যবাধকতা নয়। ফলে ভাষার নিয়মে copy বা move constructor তখনও “থাকতে হতো এবং ডাকা যাওয়ার মতো হতে হতো” — এমনকি যদি compiler সেটি কখনো না ডাকে। যে টাইপ কপি বা move করা যায় না, যেমন std::atomic বা mutex ধরে রাখা class, তাকে তাই একটি factory ফাংশন থেকে by value ফেরত দেওয়া যেত না।
২০১৫–২০১৬ সালে Richard Smith-এর প্রস্তাব P0135 (“Wording for guaranteed copy elision through simplified value categories”) সমস্যাটিকে অন্যভাবে দেখে। “কপি বাদ দেওয়ার অনুমতি” না দিয়ে, প্রস্তাবটি prvalue-এর সংজ্ঞাই বদলে দেয়। নতুন নিয়মে একটি prvalue কোনো object নয় — এটি একটি object বানানোর “রেসিপি” বা নির্দেশ। return Image(5); কোনো temporary বানায় না; এটি বলে, “যেখানে ফল যাবে, সেখানে Image(5) দিয়ে একটি object বানাও।” Image x = make_image();-এ সেই জায়গাটি x. তাই মাঝখানে কোনো temporary-ই নেই — বাদ দেওয়ার মতো কপিও নেই। copy elision আর optimization থাকে না, ভাষার অর্থ হয়ে যায়।
তাহলে temporary আসে কখন? যখন prvalue-কে এমন জায়গায় ব্যবহার করা হয় যেখানে সত্যিই একটি object লাগে — যেমন একটি reference-এ যুক্ত করা বা member পড়া (make_image().width). তখন ঘটে “temporary materialization conversion”: রেসিপি থেকে একটি আসল temporary object তৈরি হয়, আর সেটি একটি xvalue. এই কারণেই ছবি ৩৬.১-এর গাছে xvalue এত গুরুত্বপূর্ণ।
একটি সীমাবদ্ধতা মনে রাখুন: নিশ্চয়তা শুধু prvalue-এর জন্য। নাম থাকা local ফেরত দেওয়ার NRVO এখনো অনুমতি মাত্র, তাই সেখানে move constructor থাকতেই হয়। এ কারণেই পর্ব ২২-এর মতো কোডে copy/move গুনতে চাইলে prvalue দিয়ে উদাহরণ সাজাতে হয়, নইলে ফল compiler-ভেদে বদলাতে পারে।
মূল উপলব্ধি: কখনো কখনো একটি সমস্যা সমাধানের সেরা উপায় হলো সমস্যাটির সংজ্ঞাই বদলে দেওয়া। “কপি বাদ দেওয়া যাবে” থেকে “কপি কখনো ছিলই না” — এই ধারণাগত পরিবর্তন কোডকে দ্রুত ও সরল, দুটোই করেছে।
প্র ০৪C++-এর move “non-destructive” — source বেঁচে থাকে আর তার destructor চলে। Rust-এর move “destructive”. কোন নকশা কেন, আর এর দাম কী?
C++-এ auto t = std::move(s);-এর পরও s একটি বেঁচে থাকা object. scope শেষে তার destructor চলবে, আর এর মধ্যে কেউ তাকে নতুন মান দিতে পারে। তাই move constructor-কে সবসময় source-কে একটি বৈধ অবস্থায় রেখে যেতে হয় — যেমন pointer-কে nullptr করা। এই বাড়তি লেখা (অনেক সময় বাড়তি কাজও) হলো non-destructive move-এর দাম। আরেকটি দাম: কিছু টাইপের জন্য সত্যিকারের “খালি” অবস্থা স্বাভাবিক নয়। ধরুন একটি class, যার নিয়ম হলো “সবসময় একটি বৈধ সংযোগ থাকবে।” move-এর পর source-কে এই নিয়ম ভাঙা অবস্থায় রাখতে হয়, ফলে টাইপের invariant দুর্বল হয়ে যায়।
Rust ভিন্ন পথে যায় — তার move হলো destructive move. let t = s;-এর পর s নামটিই অকেজো — compiler তার ব্যবহার compile-এর সময়েই আটকে দেয়, আর s-এর destructor (Rust-এ drop) আর চলে না। move মানে শুধু বাইটগুলো নতুন জায়গায় কপি করা, তারপর পুরোনো জায়গাটি ভুলে যাওয়া। source-কে খালি করার কোনো কোড লাগে না, use-after-move বাগ হতেই পারে না।
C++ এই পথে গেল না কেন? ২০০২-এর মূল প্রস্তাব N1377 নিজেই destructive move নিয়ে আলোচনা করে, আর একটি কঠিন সমস্যা দেখায়: inheritance. একটি derived object move করার সময় প্রথমে base অংশ, তারপর derived অংশ move হয়। base অংশটি যদি “ধ্বংস” হয়ে যায়, তাহলে derived অংশ move করার মুহূর্তে object টি অর্ধেক মৃত — derived-এর কোড এমন base দেখে, যা আর নেই। এছাড়া C++-এ object-এর জীবন scope দিয়ে নিয়ন্ত্রিত, আর compiler প্রতিটি শাখায় (if, loop, exception) “এই নামটি কি move হয়েছে?” হিসাব রাখে না। Rust সেই হিসাব রাখার জন্য borrow checker আর ownership-এর কঠোর নিয়ম শুরু থেকেই নকশায় রেখেছে; C++ পুরোনো কোড না ভেঙে তা যোগ করতে পারত না।
তবে আলোচনা থেমে নেই। “relocation” নামে একটি ধারণা নিয়ে বহু বছর ধরে প্রস্তাব আসছে (যেমন Arthur O'Dwyer-এর P1144): অনেক টাইপের জন্য “move করে source ধ্বংস করা” আসলে শুধু বাইট কপি করার সমান, আর vector-এর মতো container এই তথ্য পেলে বড় হওয়ার সময় অনেক দ্রুত হতে পারে।
মূল উপলব্ধি: C++-এর moved-from অবস্থা কোনো ভুল নয়, একটি সচেতন আপস — পুরোনো object মডেল আর inheritance রক্ষা করে move আনা। দাম দিতে হয় শৃঙ্খলায়: compiler যা আটকায় না, তা আপনাকে নিজেকে মনে রাখতে হয় — move-এর পর নামটি আর পড়বেন না।
প্র ০৫const object থেকে std::move করলে error না দিয়ে নিঃশব্দে কপি হয় কেন? C++ এটা নিষেধ করল না কেন?
যান্ত্রিক কারণটা অংশ ৩-এর টেবিলে আছে। const std::string c-এ std::move(c)-এর টাইপ হয় const std::string&&. move constructor নেয় std::string&& — non-const, কারণ তাকে source বদলাতে হয়। const থেকে non-const-এ যুক্ত হওয়া নিষেধ, তাই move constructor বাদ পড়ে। বাকি থাকে copy constructor, যার const std::string& যেকোনো কিছুর সাথে যুক্ত হতে পারে — const rvalue-ও। overload resolution কোনো ভুল দেখে না: একটি বৈধ প্রার্থী আছে, সে জিতল। কোড compile হয়, চলে, ফলও সঠিক — শুধু ধীর।
এটা নিষেধ করলে কী হতো? এখানেই নকশার সূক্ষ্মতা। generic কোডে, যেমন একটি template যা std::move(x) লেখে, x-এর টাইপ কখনো const হতে পারে, কখনো নয়। নিষেধ থাকলে সেই template const টাইপের জন্য ভেঙে যেত, যদিও “move না পারলে কপি করো” আচরণটা সেখানে ঠিক যা দরকার। std::move_if_noexcept-এর মতো library-র নকশাও এই “পারলে move, না পারলে কপি” ধারণার ওপর দাঁড়িয়ে। তাই ভাষা ইচ্ছা করেই move-কে একটি অনুরোধ হিসেবে রেখেছে, আদেশ হিসেবে নয়: “নিয়ে নেওয়া গেলে নাও, না গেলে কপি করো।”
একই কারণে আরেকটি সূক্ষ্ম ফাঁদ আছে: const local ফেরত দেওয়া। const std::vector<int> v = ...; return v;-এ NRVO না হলে নিজে থেকে move-ও হতে পারে না, কারণ v const — কপি হয়। তাই ফেরত দেওয়ার local-কে const করবেন না, আর by value ফেরত টাইপকেও const লিখবেন না (const std::string f();) — এক সময় কিছু বইয়ে এমন পরামর্শ ছিল, কিন্তু C++11-এর পর এটি caller-এর move আটকে দেয়।
যেহেতু compiler নিজে এখানে সতর্ক করে না, সাহায্য আসে বাইরের টুল থেকে। clang-tidy-এর performance-move-const-arg check ঠিক এই প্যাটার্ন খোঁজে: const জিনিসে বা trivially copyable টাইপে std::move. বড় কোডবেসে, বিশেষ করে AI-এর সাহায্যে লেখা কোডে, এমন check CI-তে চালানো খুব কাজের।
মূল উপলব্ধি: C++-এ move একটি অনুরোধ, যা নিঃশব্দে কপিতে নেমে আসতে পারে। এই নমনীয়তা generic কোডকে নিরাপদ রাখে, কিন্তু আপনার উদ্দেশ্য পূরণ হলো কি না, তা নিশ্চিত করার দায়িত্ব আপনার — const, টুল আর পরিমাপ দিয়ে।
অনুশীলন
বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।
-
category চিনুন:
int x = 5; int* p = &x; std::vector<int> v{1, 2};ধরে নিয়ে প্রতিটির value category বলুন — (ক)x(খ)x + 1(গ)++x(ঘ)x++(ঙ)*p(চ)v[0](ছ)std::move(v)(জ)x = 7.(ক) lvalue — নাম। (খ) prvalue — নতুন মান। (গ) lvalue — prefix increment x নিজেকেই ফেরত দেয়, তাই
&++xচলে। (ঘ) prvalue — postfix increment পুরোনো মানের একটি কপি ফেরত দেয়। (ঙ) lvalue — dereference একটি বেঁচে থাকা object দেখায়। (চ) lvalue —operator[]একটি reference ফেরত দেয়। (ছ) xvalue. (জ) lvalue — built-in assignment বাঁ দিকের object-কেই ফেরত দেয়। পরীক্ষার কৌশল: প্রতিটির সামনে&বসিয়ে ভাবুন, compile হতো কি না। -
আউটপুট অনুমান:
which_overload.cpp-এ একটি নতুন ফাংশন যোগ করুন —void relay(std::string&& s) { take(s); take(std::move(s)); }— আর main-এ ডাকুনrelay(make_title());. কোন দুটি লাইন ছাপা হবে?প্রথমে
const& -> must copy (size 20), তারপর&& -> may steal (size 20). relay-এর প্যারামিটারের টাইপ rvalue reference হলেও ভেতরেsএকটি নাম — lvalue — তাই প্রথম ডাক const&-এ যায়। আবার “নিয়ে নাও” বলতে চাইলেstd::move(s)লাগে। দ্বিতীয় লাইনেও size 20, কারণ take-এর && সংস্করণ কিছু নেয়নি, শুধু পড়েছে। -
বাগ শিকার: একটি AI এই তিনটি ফাংশন দিয়েছে। কয়টি সমস্যা পান?
std::vector<std::string> load_names() { std::vector<std::string> out = read_file(); return std::move(out); } template <class T> void store(T&& x) { log(x); items.push_back(std::move(x)); } void greet(const std::string name) { names_.push_back(std::move(name)); std::println("hi {}", name); }চারটি। (১)
return std::move(out);— pessimizing move: NRVO বন্ধ করে,return out;লিখুন। (২) store-এ forwarding reference-এstd::move— caller lvalue দিলে তার জিনিসটি চুপচাপ খালি হবে; লিখুনstd::forward<T>(x). (৩) greet-এর প্যারামিটারconst, তাইstd::move(name)আসলে কপি — const সরিয়ে দিন। (৪) const সরালে পরের লাইনে moved-fromnameপড়া হচ্ছে — use-after-move. আগে প্রিন্ট করে তারপর move করুন। লক্ষ করুন, (৩) থাকায় বর্তমান কোডে (৪) আসলে ঘটছে না — একটি বাগ আরেকটিকে লুকিয়ে রেখেছে, যা ঠিক করলেই নতুন বাগ বেরিয়ে আসত। -
নিজে লিখুন (কোড):
playlist_sink.cpp-এ Player-এ একটি নতুন ফাংশন যোগ করুন —void add(Song s) { queue.push_back(std::move(s)); }— আর main-এর শেষে চারটি লাইন যোগ করুন:p.queue.reserve(10);, তারপরSong::moves = 0;, তারপরp.add(Song("Tumi Robe Nirobe"));আরshow("add(temporary)");. আগে অনুমান করুন copies আর moves কত হবে।copies=0 moves=1.Song("Tumi Robe Nirobe")একটি prvalue, তাই C++17-এর নিশ্চিত elision-এ সেটি সরাসরি প্যারামিটার s-এ তৈরি হয় — কোনো move নেই। তারপরpush_back(std::move(s))vector-এর ভেতরে একটি move constructor চালায় — ১টি move.reserve(10)আগেই জায়গা করে রাখে, যাতে push_back-এর সময় vector বড় হতে গিয়ে পুরোনো উপাদান সরাতে না হয়। reserve নিজে অবশ্য পুরোনো তিনটি গান নতুন জায়গায় move করে (noexcept বলে কপি নয়) — সেজন্যই তার পরেSong::moves = 0;দিয়ে হিসাব শূন্য করা হয়েছে। চাইলেp.queue.emplace_back("Tumi Robe Nirobe")লিখে দেখুন — তখন moves=0, কারণ Song সরাসরি vector-এর ভেতরে তৈরি হয়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমstd::string t = std::move(s); চলার সময় ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
std::move(s)s-কেstd::string&&-এ cast করে — মেমরিতে কিছু বদলায় না- expression-টি এখন একটি xvalue
- overload resolution copy constructor-এর বদলে move constructor বাছে
- move constructor s-এর buffer-এর ঠিকানা, size আর capacity নিয়ে নেয়
- s বৈধ কিন্তু অনির্দিষ্ট অবস্থায় থাকে; scope শেষে তার destructor নিরাপদে চলে
C++-এ std::move(x) নিজে কী করে?
- x-এর ভেতরের ডেটা নতুন জায়গায় সরিয়ে দেয়
- x-কে খালি করে দেয়
- x-কে rvalue reference-এ cast করে, যাতে move-এর overload বাছা যায়
- x-এর মেমরি সঙ্গে সঙ্গে মুক্ত করে
C++-এ std::string s = "Dhaka"; থাকলে নিচের কোন expression একটি prvalue?
- s
- s + "!"
- std::move(s)
- "Dhaka"
C++-এ একটি ফাংশনে std::string out; বানিয়ে তাতে লেখা জমা করে শেষে ফেরত দিতে হবে। কোনটি সবচেয়ে ভালো?
- return std::move(out);
- ফেরত টাইপ std::string& করে return out;
- ফেরত টাইপ const std::string করে return out;
- ফেরত টাইপ std::string রেখে return out;
C++-এ const std::vector<int> a = make_big(); থাকলে std::vector<int> b = std::move(a); লিখলে কী হয়?
- copy constructor চলে — কোনো error বা warning ছাড়াই পুরো vector কপি হয়
- move constructor চলে, a খালি হয়ে যায়
- compile error — const থেকে move করা যায় না
- undefined behavior
C++-এ template<class T> void f(T&& x); ঘোষণা আছে, আর std::string name; একটি ভ্যারিয়েবল। f(name); ডাকলে T কী deduce হয়?
- std::string
- std::string&&
- std::string&
- compile error — T&& lvalue নিতে পারে না
C++-এ নিচের কোন টাইপের ক্ষেত্রে move সাধারণত কপির চেয়ে উল্লেখযোগ্যভাবে দ্রুত?
- std::array<int, 1000>
- দশ লাখ উপাদানের std::vector<int>
- double
- "Dhaka"-র মতো ছোট std::string
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- value category = “নেওয়া যায় কি?” lvalue (নাম আছে) থেকে নয়; prvalue (নতুন temporary) থেকে বিনা অনুমতিতে; xvalue (
std::moveদেওয়া) থেকে অনুমতি নিয়ে।T&&টাইপের নামও lvalue. - std::move শুধু একটি cast; আসল কাজ করে
T&&নেওয়া overload. const object-এ move আসলে কপি। moved-from object-এ নতুন মান না দিয়ে তা পড়বেন না। - ফেরত দিন সাধারণভাবে:
return local;— prvalue হলে C++17-এর নিশ্চিত elision, নাম থাকলে NRVO বা নিজে থেকে move.return std::move(local)নয়। - sink parameter: জমা রাখার প্যারামিটার by value নিন, তারপর
std::move. শুধু পড়ার প্যারামিটারconst T&. - template-এর
T&&= forwarding reference; সেখানেstd::forward<T>, rvalue reference-এstd::move. ছোট বা inline-ডেটার টাইপে move কোনো গতি আনে না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৭ · exception আর std::expected — ভুল সামলানোপরবর্তী পর্বভুল হলে খবর দেওয়ার দুই পথ, আর exception safety-র গ্যারান্টি।
- পর্ব ২২ · copy, move আর Rule of 0/3/5ফিরে দেখুনmove constructor নিজে লেখা, noexcept আর Rule of Zero.
- C++ প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- C প্রোগ্রামিং · dynamic memoryC-তে মূলmalloc আর free — হাতে মালিকানা সামলানো, move-এর আদি রূপ।