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

iterator — container-এর ভেতর দিয়ে হাঁটা

Iterators — Walking Through Any Container

Taught in Bangla, this lesson explains iterators as generalized pointers: begin and end with half-open ranges, what range-for really expands to, const and reverse iterators, iterator categories, advance, next and distance, invalidation rules, inserters and stream iterators, and writing your own iterator.

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

  • iterator কোন সমস্যার সমাধান করে, আর কেন এটি container আর algorithm-এর মাঝের আঠা
  • begin()/end(), half-open range, আর range-for-এর ভেতরের আসল কোড
  • const iterator, reverse iterator, iterator-এর পাঁচটি category আর std::next/std::distance-এর মতো সহায়ক ফাংশন
  • কোন container-এ কোন কাজ iterator অকেজো করে, আর নিরাপদে মোছার নিয়ম
  • std::back_inserter, stream iterator, নিজের একটি ছোট iterator বানানো, আর AI-এর iterator-ভুল ধরা

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

আগের দুটি পর্বে আপনি অনেক container দেখেছেন: পাশাপাশি সাজানো std::vector, শিকলের মতো std::list, দুই মাথা খোলা std::deque, key দিয়ে সাজানো std::map, hash দিয়ে রাখা std::unordered_map. এখন একটি সাধারণ কাজ ভাবুন: “সব উপাদানের যোগফল বের করো।” অথবা “প্রথম যে উপাদান ১০০-এর বেশি, সেটি খুঁজে দাও।”

প্রতিটি container-এর জন্য আলাদা করে লিখলে কী হয়? vector-এ index দিয়ে v[i], list-এ এক node থেকে পরের node-এ যাওয়া, map-এ একটি গাছের (tree) ভেতর দিয়ে বাঁয়ে-ডানে ঘোরা। মোটামুটি দশটি container আর শ-খানেক কাজ (খোঁজা, গোনা, কপি, সাজানো…) থাকলে লিখতে হতো হাজারখানেক ফাংশন। প্রতিটি নতুন container এলে আবার সব কাজ নতুন করে লেখা। এটাকে বলা যায় M × N সমস্যা: M রকম container আর N রকম কাজ মানে M × N টুকরো কোড।

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

iterator-এর ধারণাটি নতুন নয় — পর্ব ১৫-এ আপনি pointer দিয়ে array-এর ভেতর দিয়ে হেঁটেছেন। একটি C-ধাঁচের array আর একটি vector পাশাপাশি দেখুন:

int a[3] = {31, 33, 30};
for (int* p = a; p != a + 3; ++p) sum += *p;      // pointer দিয়ে হাঁটা

std::vector<int> v = {31, 33, 30};
for (auto it = v.begin(); it != v.end(); ++it) sum += *it;   // iterator দিয়ে

std::list<int> l = {31, 33, 30};
for (auto it = l.begin(); it != l.end(); ++it) sum += *it;   // হুবহু একই আকার!

তিনটি লুপের চেহারা এক। * দিয়ে উপাদান পড়া, ++ দিয়ে পরেরটিতে যাওয়া, != দিয়ে শেষ হলো কি না দেখা। pointer-এর বেলায় ++p মানে মেমরিতে পাশের ঘর; list-এর iterator-এর বেলায় ++it মানে ভেতরে লুকানো “next” ঠিকানা ধরে পরের node-এ যাওয়া। কীভাবে যাবে, সেটা iterator জানে; লুপকে জানতে হয় না। এই কারণে iterator-কে বলা হয় সাধারণীকৃত pointer — pointer-এর মতো আচরণ, কিন্তু যেকোনো গঠনের ডেটার জন্য।

মূল ধারণা

container জানে ডেটা কীভাবে রাখা আছে; algorithm জানে কাজটি কীভাবে করতে হয়। iterator এই দুইয়ের মাঝখানে একটি ছোট, নির্দিষ্ট ভাষা: “পড়ো, এগোও, তুলনা করো।” প্রতিটি container শুধু নিজের iterator দেয়, প্রতিটি algorithm শুধু iterator-এর ভাষায় লেখা হয়। তখন M × N টুকরো কোডের বদলে লাগে M + N — এটাই STL-এর মূল নকশা, আর একে বলে generic programming.

২ · begin(), end() আর half-open range

প্রতিটি standard container-এর দুটি member function আছে। begin() দেয় প্রথম উপাদানের iterator. end() দেয় এমন একটি iterator, যা শেষ উপাদানের ঠিক পরের জায়গা দেখায় — কোনো উপাদানকে নয়। এই দুটি মিলে একটি range বোঝায়, যা গণিতের ভাষায় লেখা হয় [begin, end): বাঁ দিকে বন্ধনী বর্গাকার, মানে begin ভেতরে; ডান দিকে গোল, মানে end বাইরে। একে বলে half-open range, আর end-কে বলে past-the-end iterator.

প্রথমবার দেখলে মনে হতে পারে, শেষ উপাদানটিকেই কেন end বলা হলো না? নিচের ধাপগুলো দেখুন — একটি তাপমাত্রা-সেন্সরের তিনটি রিডিং, আর তারপর একটি খালি vector:

1std::vector<int> t = {31, 33, 30};2for (auto it = t.begin(); it != t.end(); ++it)3    std::print("{} ", *it);4std::vector<int> none;          // খালি5// none.begin() == none.end()
এই মুহূর্তে
it কোথায়
*it
it != end?
end() - begin()
  1. শুরু: begin() প্রথম ঘরে। it দাঁড়িয়েছে ঘর [0]-এ, *it পড়লে 31. শর্ত it != t.end() সত্য, তাই লুপের ভেতরে ঢুকে 31 প্রিন্ট হলো।
  2. ++it — এক ঘর এগোনো। এখন ঘর [1], মান 33. খেয়াল করুন, লুপ কখনো জিজ্ঞেস করছে না “আর কয়টি বাকি?” — শুধু “এখনো end-এ পৌঁছাইনি তো?”
  3. শেষ উপাদান। ঘর [2], মান 30, প্রিন্ট হলো। শেষ উপাদানেও শর্ত সত্য, কারণ end শেষ উপাদান নয়, তার পরের জায়গা। তাই শেষটি বাদ পড়ে না — একটিও off-by-one ভুলের সুযোগ নেই।
  4. end-এ পৌঁছানো। আরেকবার ++it করায় it এখন ঘর [3]-এ — যেখানে কোনো উপাদান নেই। এটাই end(). শর্ত মিথ্যা, লুপ থামল। এখানে *it পড়া undefined behavior — end হলো সীমানার খুঁটি, ঘর নয়। আর end() - begin() = 3, ঠিক উপাদানের সংখ্যা।
  5. খালি range — কোনো বিশেষ নিয়ম লাগে না। খালি vector-এ begin আর end একই জায়গা দেখায়। শর্ত শুরুতেই মিথ্যা, তাই লুপের ভেতরে একবারও ঢোকে না। end যদি “শেষ উপাদান” হতো, খালি container-এ সেটি কাকে দেখাত? সেই প্রশ্নের উত্তর নেই — half-open range-এ প্রশ্নটিই ওঠে না।

half-open range-এর তিনটি সুবিধা মনে রাখুন। এক, খালি range লেখা যায় স্বাভাবিকভাবে: begin == end. দুই, উপাদানের সংখ্যা = end − begin, কোনো +1 বা −1 ছাড়া। তিন, একটি range-কে মাঝখান থেকে ভাগ করলে [begin, mid) আর [mid, end) — দুটি অংশ একটি উপাদানও ভাগাভাগি করে না, একটিও বাদ পড়ে না। binary search বা merge sort-এর মতো ভাগ-করা algorithm এতে অনেক সহজ হয়। এর পেছনের ইতিহাস ভাবনার প্রশ্ন ০২-এ।

C-ধাঁচের array-এর নিজের member function নেই, তাই free function std::begin(a) আর std::end(a) ব্যবহার করুন — এরা array, vector, যেকোনো container-এ কাজ করে। generic কোডে (যেমন template-এর ভেতরে) v.begin()-এর চেয়ে এটি বেশি নমনীয়।

C জানলে

C-তে array-এর শেষ ঘরের ঠিক পরের ঠিকানা (a + n) বানানো আর তুলনা করা বৈধ, কিন্তু সেখান থেকে পড়া নিষেধ — end() হুবহু এই নিয়মের উত্তরাধিকারী। আর C-তে linked list-এ হাঁটতে লিখতেন p = p->next; C++-এ সেই কাজটি list-এর iterator-এর ++-এর ভেতরে লুকানো। নতুন হলো একটাই জিনিস: একই লুপ সব container-এ চলে। pointer দিয়ে array-এ হাঁটার খুঁটিনাটি দেখতে C কোর্সের পয়েন্টার ২ — অ্যারে ও ফাংশনের সাথে পর্ব পড়ুন।

৩ · তিনটি কাজ — *it, ++it, it != end — আর range-for-এর আসল রূপ

প্রায় সব iterator-এর মৌলিক কাজ তিনটি:

লেখামানেpointer-এর সাথে মিল
*itযে উপাদানে দাঁড়িয়ে আছে, তার reference — পড়া বা লেখা যায়dereference, পর্ব ১৫
it->nameউপাদান যদি struct বা class হয়, তার member(*it).name-এর সংক্ষেপ
++itপরের উপাদানে যাওয়াpointer-এর ++p
it != endদুটি iterator কি আলাদা জায়গায়?দুটি ঠিকানার তুলনা

লুপের শর্তে it < end নয়, it != end লেখার অভ্যাস করুন। < শুধু vector-এর মতো container-এর iterator-এ চলে; list বা map-এর iterator জানেই না কে “বড়”, তারা শুধু জানে দুটি জায়গা এক কি না। আর it++-এর বদলে ++it: postfix increment পুরোনো মানের একটি কপি ফেরত দেয়, যা জটিল iterator-এ অকারণ কাজ। সাধারণ ক্ষেত্রে compiler দুটিকে একই করে ফেলে, তবু ++it লেখাই প্রচলিত রীতি।

range-for — compiler আপনার জন্য iterator লুপ লেখে

পর্ব ৮-এ আপনি range-for শিখেছেন: for (auto x : v). এটি কোনো আলাদা যন্ত্র নয়। C++ standard বলে দিয়েছে, compiler এই লুপকে কোন iterator কোডে বদলে নেবে। একটি গানের playlist (std::list) দিয়ে ধাপে ধাপে দেখুন:

1for (const auto& song : playlist) std::println("{}", song);2// compiler-এর চোখে (C++17 থেকে) প্রায় এমন:3{   auto&& __range = playlist;4    auto __begin = __range.begin();5    auto __end   = __range.end();6    for ( ; __begin != __end; ++__begin) {7        const auto& song = *__begin;8        std::println("{}", song);9    }10}
এই মুহূর্তে
__begin দেখায়
song
__begin != __end
  1. আপনি যা লেখেন। একটি লাইন: playlist-এর প্রতিটি গানের নাম প্রিন্ট করো। এখানে index নেই, আকার নেই, iterator-ও চোখে পড়ে না। কিন্তু compiler এটিকে নিচের কোডে বদলে নেয় — নামগুলো (__range, __begin) শুধু বোঝানোর জন্য।
  2. range একবারই ধরা হয়। auto&& __range হলো playlist-এর একটি reference — কপি নয়। :-এর ডানে যে expression থাকে, সেটি ঠিক একবার চলে।
  3. begin আর end — একবারই নেওয়া হয়। লুপ শুরুর আগেই দুটি iterator বানিয়ে রাখা হলো। এর একটি গুরুত্বপূর্ণ ফল আছে: লুপের ভেতরে container-এ push_back বা erase করলে এই রেখে দেওয়া __end আর __begin পুরোনো হয়ে যেতে পারে — সেই বিপদ ৭ নম্বর অংশে।
  4. প্রথম পাক। শর্ত __begin != __end সত্য। *__begin থেকে আপনার ভ্যারিয়েবল song তৈরি হলো, আর লুপের দেহ চলল। আপনার লেখা const auto& ঠিক এখানেই বসে — তাই auto& লিখলে উপাদান বদলানো যায়, auto লিখলে প্রতিবার কপি হয়।
  5. ++__begin করে বাকি পাক। প্রতিবার এক ঘর এগিয়ে আবার একই কাজ: Pagla Hawa, তারপর Lal Paharir. list-এর node মেমরিতে ছড়ানো, কিন্তু লুপকে তা জানতে হলো না — ++ নিজে পরের node খুঁজে নিল।
  6. end-এ পৌঁছে থামা। শেষ গানের পরে __begin এখন end-এ, শর্ত মিথ্যা। তাই যেকোনো টাইপ range-for-এ চলবে, যদি তার begin() আর end() থাকে, আর iterator-এ থাকে *, ++ আর != — এই তিনটি দিয়েই ৯ নম্বর অংশে আমরা নিজের একটি range বানাব।

C++17 থেকে __begin আর __end-এর টাইপ আলাদা হতে পারে — শুধু != দিয়ে তুলনা করা গেলেই হলো। ছোট এই পরিবর্তনটির পেছনে বড় একটি ধারণা আছে (sentinel), যা ৯ নম্বর অংশের শেষে দেখবেন।

৪ · শুধু পড়ার iterator আর উল্টো দিকে হাঁটা

const_iterator — “দেখব, ছোঁব না”

সাধারণ iterator দিয়ে *it = 0; লিখে উপাদান বদলানো যায়। const_iterator দিয়ে যায় না — শুধু পড়া চলে। cbegin() আর cend() সবসময় const_iterator দেয়, container নিজে const না হলেও। কোনো ফাংশন যদি শুধু হিসাব করে, তাকে const_iterator দিন: ভুল করে কেউ ডেটা বদলালে compiler তখনই error দেবে। এটি পর্ব ৫-এর const-এর সেই একই নীতি — অনুমতি যত কম, ভুলের সুযোগ তত কম।

দুটি জিনিস গুলিয়ে ফেলবেন না। const std::vector<int>::iterator it মানে iterator নিজে স্থির — ++it করা যাবে না, কিন্তু *it বদলানো যাবে। আর std::vector<int>::const_iterator it মানে উল্টো: iterator সরানো যাবে, কিন্তু উপাদান বদলানো যাবে না। পর্ব ১৫-এর “const pointer বনাম pointer to const”-এর হুবহু প্রতিচ্ছবি — পার্থক্যটি ভালো করে মনে রাখুন।

reverse iterator — শেষ থেকে শুরুর দিকে

ধরুন একটি সেন্সর প্রতি মিনিটে তাপমাত্রা লিখে রাখে, পুরোনো রিডিং আগে। আপনি দেখতে চান সাম্প্রতিক রিডিংগুলো আগে। reverse iterator ঠিক এই কাজ করে: rbegin() শেষ উপাদানে দাঁড়ায়, আর ++ করলে সে পেছনে যায়। rend() হলো প্রথম উপাদানেরও আগের কাল্পনিক জায়গা। অর্থাৎ উল্টো দিকেও সেই একই half-open range, একই !=, একই ++ — তাই যে algorithm সামনে হাঁটতে জানে, সে কোনো বদল ছাড়াই পেছনেও হাঁটতে পারে। শুধু-পড়ার উল্টো সংস্করণ crbegin()/crend().

এবার এ পর্যন্ত যা দেখলেন, সব একটি প্রোগ্রামে। একটি template ফাংশন (পর্ব ২৮) শুধু দুটি iterator নেয় — সে জানে না কোন container থেকে এসেছে:

#include <list>
#include <print>
#include <vector>

template <typename It>                 // It = any iterator type
int total(It first, It last) {
    int sum = 0;
    for (It it = first; it != last; ++it) sum += *it;   // only *, ++, !=
    return sum;
}

int main() {
    std::list<int> playlist = {215, 187, 242};   // song lengths (s)
    std::vector<int> temp = {31, 33, 30, 36};    // sensor log, oldest first
    std::println("playlist: {} s", total(playlist.begin(), playlist.end()));
    std::println("sensor sum: {}", total(temp.cbegin(), temp.cend()));
    std::println("empty range: {}", total(temp.end(), temp.end()));
    std::print("newest first:");
    for (auto r = temp.crbegin(); r != temp.crend(); ++r) std::print(" {}", *r);
    std::println("");
}

আউটপুট চার লাইন: playlist: 644 s, sensor sum: 130, empty range: 0, আর newest first: 36 30 33 31. একটিমাত্র total ফাংশন list আর vector — দুই জায়গাতেই চলল; compiler প্রতিটি iterator টাইপের জন্য আলাদা সংস্করণ বানিয়ে নিয়েছে, তাই চলার সময় কোনো বাড়তি খরচ নেই। তৃতীয় লাইনে begin আর end একই জায়গায় — খালি range, যোগফল 0, কোনো বিশেষ if লাগেনি। শেষ লুপে reverse iterator দিয়ে নতুন রিডিং আগে এল, অথচ লুপের আকার সামনে হাঁটার লুপের মতোই। এখানে sensor-এর জন্য cbegin() দেওয়া হয়েছে, কারণ যোগফল বের করতে ডেটা বদলানোর দরকার নেই।

একটি খুঁটিনাটি, যা পরে কাজে লাগবে: reverse iterator ভেতরে একটি সাধারণ iterator রাখে, যা পাওয়া যায় r.base() দিয়ে। কিন্তু base() একঘর সরে থাকে — rbegin().base() হলো end(). half-open range-কে উল্টে দিলে এই এক ঘরের ফাঁক অনিবার্য, তাই reverse iterator থেকে erase করার সময় সাবধান থাকুন।

৫ · iterator category — কে কতটা পারে

ডাকপিয়নের উপমায় ফিরে যান। সোজা রাস্তার নম্বর দেওয়া বাড়িতে সরাসরি ৫০ নম্বরে যাওয়া যায়; গ্রামের পথে ৫০ নম্বরে যেতে হলে ৪৯টি বাড়ি পার হতে হয়। iterator-ও তেমন — container-এর গঠন যা সস্তায় করতে দেয়, iterator শুধু সেটুকুই দেয়। C++ এই ক্ষমতাগুলোকে কয়েকটি ধাপে ভাগ করেছে, যাকে বলে iterator category. প্রতিটি ধাপ আগের ধাপের সব ক্ষমতা রাখে, সাথে কিছু যোগ করে:

  1. Input*it ++it ==একবার পড়ে এগিয়ে যাওয়া · কীবোর্ড থেকে পড়া
  2. Forward+ বারবার হাঁটাকপি রেখে আবার একই পথে · forward_list, unordered_map
  3. Bidirectional+ --itপেছনেও যাওয়া · list, map, set
  4. Random access+ it + 5, it[i], <যেকোনো দূরত্বে এক লাফে · deque
  5. Contiguous+ পাশাপাশি মেমরিpointer-এর সমান · vector, array, string
  1. Input iterator — একবারের পথ। পড়া যায়, এগোনো যায়, তুলনা করা যায় — কিন্তু পথটি একবারই হাঁটা যায়। যেমন কীবোর্ড বা ফাইল থেকে আসা সংখ্যার স্রোত: একটি সংখ্যা পড়ে এগিয়ে গেলে সেটি চলে গেছে, iterator-এর কপি রেখেও আবার পড়া যায় না। এর জোড়া হলো output iterator — শুধু লেখার জন্য, একবারে একটি (৮ নম্বর অংশে দেখবেন)।
  2. Forward iterator — একই পথ বারবার। এখন iterator কপি করে রাখা যায়, আর কপি দিয়ে পরে একই উপাদানে ফেরা যায়। কিন্তু শুধু সামনে। একমুখী লিংকড লিস্ট std::forward_list আর std::unordered_map-এর iterator এই শ্রেণির — hash table-এ “আগের উপাদান” বলে কিছু সস্তায় পাওয়া যায় না।
  3. Bidirectional iterator — পেছনেও। --it যোগ হলো। দ্বিমুখী list-এর প্রতিটি node আগের আর পরের দুটি ঠিকানাই রাখে; map আর set-এর গাছেও আগে-পরে যাওয়া যায়। reverse iterator বানাতে অন্তত এই ক্ষমতা লাগে — তাই forward_list-এ rbegin() নেই।
  4. Random access iterator — এক লাফে যেকোনো জায়গায়। it + 5, it - 3, it[i], দুটি iterator-এর বিয়োগ, < দিয়ে তুলনা — সব এক ধাপে, দূরত্ব যত বড়ই হোক। deque এই শ্রেণির: তার ডেটা কয়েকটি আলাদা খণ্ডে থাকে, কিন্তু কোন খণ্ডের কোন ঘর, তা সরাসরি হিসাব করা যায়।
  5. Contiguous iterator — মেমরিতে পাশাপাশি। random access-এর সব ক্ষমতা, আর নিশ্চয়তা যে উপাদানগুলো মেমরিতে গায়ে গায়ে থাকে — পর্ব ১৩-এর contiguous memory-র মতো। তাই এমন iterator থেকে সরাসরি pointer পাওয়া যায়, আর ডেটা C ফাংশনকেও দেওয়া যায়। vector, array, string আর C-ধাঁচের array-এর pointer এই সর্বোচ্চ ধাপে। C++17-এ এর সংজ্ঞা আসে, আর C++20-এ এটি আলাদা concept ও tag হিসেবে পুরো স্বীকৃতি পায়।
containeriterator categoryit + 5?--it?
vector, array, stringcontiguousহ্যাঁ, এক ধাপেহ্যাঁ
dequerandom accessহ্যাঁ, এক ধাপেহ্যাঁ
list, map, set, multimapbidirectionalনা (compile error)হ্যাঁ
forward_list, unordered_map, unordered_setforwardনানা
কীবোর্ড/ফাইল থেকে পড়া স্রোতinputনানা

কেন std::sort list-এ চলে না

std::sort ভেতরে সাধারণত introsort নামের একটি কৌশল চালায় — quicksort-এর ধাঁচে মাঝখানের উপাদান বেছে নেওয়া, দূরের দুটি উপাদান অদলবদল করা, দুই প্রান্ত থেকে একসাথে ভেতরে আসা। এসব কাজে বারবার দরকার “এক লাফে i নম্বর ঘরে যাও”, যা শুধু random access iterator সস্তায় পারে। তাই standard বলে দিয়েছে, std::sort-এর দুটি iterator অবশ্যই random access হবে। list-এর iterator দিলে compile error আসে — প্রায়ই লাইব্রেরির গভীর থেকে আসা কয়েক পর্দা লম্বা error, যার কোথাও লেখা থাকে operator- পাওয়া যায়নি।

std::list<int> marks = {72, 58, 91};
// std::sort(marks.begin(), marks.end());   // error: list-এর iterator random access নয়
marks.sort();                               // ঠিক: list-এর নিজের sort

তাহলে list কি সাজানো যায় না? যায় — list-এর নিজের member function list::sort() আছে। সেটি merge sort-এর ধাঁচে কাজ করে, আর উপাদান কপি না করে শুধু node-এর লিংক বদলে দেয়। এটাই নকশার শিক্ষা: যে কাজ একটি container-এর গঠনে বিশেষভাবে ভালো করা যায়, সেটি member function; বাকি সব সাধারণ algorithm. iterator category সেই সীমারেখা compiler-কে দিয়ে পাহারা দেওয়ায় — ধীর পথে চুপচাপ চলার বদলে আগেই থামিয়ে দেয়। C++20-এর std::ranges::sort (পর্ব ৩৩, ৩৫) একই নিয়ম concept দিয়ে লেখে, তাই error অনেক ছোট আর পরিষ্কার হয়।

৬ · advance, next, prev, distance — যেকোনো iterator-এ দূরত্বের কাজ

list-এ it + 3 লেখা যায় না, কিন্তু তিন ঘর এগোনোর দরকার তো হয়ই। <iterator> header-এর চারটি ফাংশন যেকোনো category-তে এই কাজগুলো করে দেয়, আর iterator যতটা পারে, ততটা দ্রুত পথ বেছে নেয়:

ফাংশনকাজvector-এlist-এ
std::advance(it, n)it-কেই n ঘর সরায়, কিছু ফেরত দেয় নাএক ধাপn ধাপ
std::next(it, n)n ঘর পরের নতুন iterator দেয়, it যেমন ছিল থাকেএক ধাপn ধাপ
std::prev(it, n)n ঘর আগের নতুন iterator; অন্তত bidirectional লাগেএক ধাপn ধাপ
std::distance(a, b)a থেকে b-তে যেতে কয় ধাপএক ধাপ (বিয়োগ)গুনে গুনে, O(n)
std::list<std::string> playlist = {"Shada Kalo", "Pagla Hawa", "Lal Paharir"};
auto third = std::next(playlist.begin(), 2);    // "Lal Paharir"
auto last  = std::prev(playlist.end());         // শেষ গান — *end() নয়!
auto n = std::distance(playlist.begin(), third);  // 2

ভেতরে এরা iterator-এর category দেখে আলাদা কোড বেছে নেয়: random access হলে সরাসরি it += n, নাহলে লুপে n বার ++it. আপনি একটিই নাম লেখেন, compiler সেরা পথটি বসিয়ে দেয় — এটাই generic programming-এর ক্ষমতা। কিন্তু দুটি সতর্কতা আছে। এক, এরা সীমানা পরীক্ষা করে না: তিনটি গানের list-এ std::next(begin, 5) লিখলে end পেরিয়ে যায় — undefined behavior. দুই, list-এ এরা “দেখতে এক লাইন” হলেও আসলে লুপ; একটি বড় লুপের ভেতরে বারবার std::distance ডাকলে পুরো কাজ নিঃশব্দে O(n²) হয়ে যেতে পারে (Big-O, পর্ব ৮)।

৭ · iterator invalidation — বুকমার্ক যখন ভুল পাতায়

iterator একটি জায়গার স্মৃতি। container নিজের ডেটা সরিয়ে ফেললে সেই স্মৃতি মিথ্যা হয়ে যায়। একে বলে iterator invalidation, আর পর্ব ১৩ ও ১৮-এ আপনি এর মূল কারণ দেখেছেন: vector-এর ধারণক্ষমতা (capacity) ফুরালে সে নতুন, বড় জায়গায় সব ডেটা সরিয়ে নেয় — reallocation. পুরোনো জায়গার দিকে তাকানো iterator তখন একটি dangling pointer-এর মতো। একই কাজ list-এ করলে কী হয়, পাশাপাশি দেখুন (ঠিকানাগুলো উদাহরণমাত্র; বিপজ্জনক লাইনগুলো চালানোর জন্য নয়):

vector: শুরুতেsize 3, capacity 3
std::vector<int> v = {10, 20, 30};
auto it = v.begin();          // it ──► @A000 (মান 10)

@A000: [10][20][30]           ← খালি ঘর আর নেই
vector: v.push_back(40)reallocation
জায়গা নেই → নতুন বড় ব্লক নেওয়া হলো:
@B000: [10][20][30][40][  ][  ]
পুরোনো @A000 ফেরত দেওয়া হলো (free)

it এখনো ধরে আছে @A000 — যে মেমরি আর v-এর নয়
vector: এখন *itundefined behavior
std::print("{}", *it);    // চালাবেন না

হয়তো 10 দেখাবে, হয়তো আবর্জনা, হয়তো crash হবে।
সবচেয়ে খারাপ: নিজের মেশিনে “ঠিকঠাক” চলে, পরে ব্যবহারকারীর মেশিনে ভাঙে।
AddressSanitizer দিলে: heap-use-after-free
list: একই কাজnode সরে না
std::list<int> l = {10, 20, 30};
auto it = l.begin();          // it ──► node @C100 (মান 10)
l.push_back(40);              // নতুন node @D700, শুধু লিংক জোড়া হলো

@C100[10] ⇄ @C2F0[20] ⇄ @C580[30] ⇄ @D700[40]
*it  →  10                    // সম্পূর্ণ বৈধ
নিরাপদ নিয়মবদলের পরে নতুন করে নাও
vector-এ:
  v.push_back(40);
  it = v.begin();             // বদলের পরে আবার নাও
  // অথবা iterator-এর বদলে index (size_t i) মনে রাখো
  // অথবা আগে থেকে v.reserve(n) — যতক্ষণ size ≤ capacity

list-এ: যে উপাদান মুছেছেন, শুধু তার iterator অকেজো
  1. vector-এর তিনটি ঘর, তিনটিই ভরা। it প্রথম ঘরের ঠিকানা ধরে আছে। vector-এর iterator মূলত একটি pointer-এর মোড়ক — এটি মনে রাখুন।
  2. push_back জায়গা পায় না। vector নতুন, বড় একটি ব্লক নেয়, সব উপাদান সেখানে সরায়, আর পুরোনো ব্লক ফেরত দেয়। v নিজে ঠিকঠাক — তার ভেতরের pointer নতুন ঠিকানায়। কিন্তু বাইরে রেখে দেওয়া it-কে কেউ খবর দেয়নি।
  3. পুরোনো iterator ব্যবহার = undefined behavior. *it এমন মেমরি পড়ে, যা এখন আর প্রোগ্রামের এই অংশের নয়। ফল কিছুই নিশ্চিত নয়। AddressSanitizer (-fsanitize=address, পর্ব ১৮) দিয়ে চালালে এ ধরনের ভুল সাধারণত সঙ্গে সঙ্গে ধরা পড়ে।
  4. list-এ একই কাজ নিরাপদ। list-এর প্রতিটি উপাদান নিজের আলাদা node-এ থাকে। নতুন উপাদান যোগ করলে শুধু একটি নতুন node তৈরি হয় আর শেষের লিংক জোড়া লাগে — পুরোনো কোনো node নড়ে না। তাই পুরোনো iterator এখনো সঠিক node দেখায়।
  5. নিয়মটি container-ভেদে আলাদা। vector-এ পরিবর্তনের পরে iterator নতুন করে নিন, অথবা iterator-এর বদলে index রাখুন। list, map, set-এ শুধু মুছে ফেলা উপাদানের iterator মরে। কোন container-এ কী — নিচের টেবিলে।
containerযোগ করলে (insert, push)মুছলে (erase, pop)
vector, stringreallocation হলে সব; না হলে যোগের জায়গা থেকে শেষ পর্যন্ত (end সহ)মোছার জায়গা থেকে শেষ পর্যন্ত (end সহ)
dequeদুই প্রান্তে: সব iterator অকেজো, কিন্তু উপাদানের reference/pointer টিকে থাকে; মাঝখানে: সবপ্রথম বা শেষ উপাদান: শুধু মোছা উপাদানের (শেষটি মুছলে end-ও); মাঝখানে: সব
list, forward_listকিছুই নাশুধু মোছা উপাদানের
map, set (multimap, multiset)কিছুই নাশুধু মোছা উপাদানের
unordered_map, unordered_setrehash হলে সব iterator (reference টিকে থাকে); না হলে কিছুই নাশুধু মোছা উপাদানের

লুপের ভেতরে মোছা — সবচেয়ে প্রচলিত ফাঁদ

একটি vector থেকে সব ঋণাত্মক রিডিং (ভুল মাপ) বাদ দিতে হবে। প্রথম যে কোড মাথায় আসে, আর AI-ও প্রায়ই যা লেখে:

// ভুল — চালাবেন না
for (auto it = v.begin(); it != v.end(); ++it)
    if (*it < 0) v.erase(it);        // erase-এর পরে it মৃত, তবু ++it

// ঠিক — erase যে নতুন iterator ফেরত দেয়, সেটি নিন
for (auto it = v.begin(); it != v.end(); )
    if (*it < 0) it = v.erase(it);   // মোছা উপাদানের পরেরটি
    else ++it;

// সবচেয়ে সহজ (C++20 থেকে)
std::erase_if(v, [](int x) { return x < 0; });

প্রথম লুপে erase(it)-এর পরে it অকেজো, অথচ লুপ তার ওপরই ++it করে — undefined behavior. দ্বিতীয় লুপ erase-এর ফেরত দেওয়া iterator ব্যবহার করে, যা মোছা উপাদানের ঠিক পরেরটিকে দেখায়; তাই মুছলে ++it করা হয় না। তৃতীয়টি — std::erase_if — পুরো কাজ একবারে করে, সব container-এর জন্য সঠিক কৌশলে। আর মনে রাখুন, range-for-এর ভেতরে container-এ erase বা push_back একেবারেই নয়: আপনি ভেতরের __begin/__end দেখতে পান না, তাই ঠিক করারও উপায় নেই। lifetime আর dangling নিয়ে বিস্তারিত পর্ব ১৮-এ।

৮ · inserter আর stream iterator — iterator, কিন্তু container নয়

iterator-এর ধারণা এতটাই সাধারণ যে, এমন জিনিসও iterator হতে পারে, যার পেছনে কোনো সাজানো ডেটাই নেই। দুটি উদাহরণ দেখুন।

std::back_inserter — লিখলেই push_back

std::copy(first, last, out) একটি range-এর প্রতিটি উপাদান out-এ লেখে, আর প্রতিবার ++out করে। লক্ষ্য যদি একটি খালি vector হয়, সাধারণ dst.begin() দিলে সর্বনাশ — সেখানে লেখার মতো কোনো ঘর নেই। std::back_inserter(dst) একটি output iterator দেয়, যার ওপর *out = x লেখা মানে আসলে dst.push_back(x):

std::list<int> today = {31, 33, 30};
std::vector<int> archive;                                       // খালি
std::copy(today.begin(), today.end(), std::back_inserter(archive));  // archive = 31 33 30

এভাবে algorithm জানে না যে সে আসলে container বড় করছে — সে শুধু “লিখছে আর এগোচ্ছে”। একইভাবে std::front_inserter (সামনে যোগ) আর std::inserter (নির্দিষ্ট জায়গায়) আছে।

stream iterator — কনসোলকেও range ভাবা

std::ostream_iterator একটি output iterator, যাতে লিখলে মান চলে যায় একটি stream-এ, যেমন std::cout. আর std::istream_iterator একটি input iterator, যা প্রতিবার ++-এ stream থেকে একটি মান পড়ে; ইনপুট ফুরালে সে একটি বিশেষ “শেষ” iterator-এর সমান হয়ে যায়।

std::vector<int> marks(std::istream_iterator<int>(std::cin), {});  // ইনপুট শেষ পর্যন্ত পড়ো
std::copy(marks.begin(), marks.end(), std::ostream_iterator<int>(std::cout, " "));

এখানে ইনপুট-স্রোতই একটি range: begin হলো “পড়তে থাকা iterator”, end হলো “ইনপুট শেষ” অবস্থা। এগুলো input iterator-এর আসল উদাহরণ — একবার পড়া সংখ্যা আর ফেরানো যায় না। আধুনিক কোডে ছাপার কাজে std::println আর পর্ব ৩৫-এর views বেশি পরিষ্কার, কিন্তু পুরোনো কোডে এগুলো প্রায়ই দেখবেন।

৯ · নিজের iterator — একটি লঞ্চ-টাইমারের Countdown

range-for-এর বিস্তার মনে আছে? চাই শুধু begin(), end(), আর iterator-এ *, ++ ও !=. তাহলে এমন একটি “range” বানানো যায়, যার পেছনে কোনো container নেই — যেমন রকেট উৎক্ষেপণের কাউন্টডাউন: 10, 9, …, 1. সংখ্যাগুলো কোথাও জমা থাকবে না; iterator প্রতিবার হিসাব করে পরেরটি দেবে।

#include <print>

class Countdown {                       // Countdown(10): 10, 9, ..., 1
    int from_;
public:
    explicit Countdown(int from) : from_(from) {}
    struct Iter {
        int n;
        int operator*() const { return n; }
        Iter& operator++() { --n; return *this; }      // count down
        bool operator!=(const Iter& o) const { return n != o.n; }
    };
    Iter begin() const { return {from_}; }
    Iter end() const { return {0}; }    // one past the last value (1)
};

int main() {
    for (int s : Countdown(10)) std::print("{} ", s);
    std::println("LIFTOFF");
    for (int s : Countdown(0)) std::print("{} ", s);   // begin == end
    std::println("(empty range)");
}

আউটপুট দুই লাইন: 10 9 8 7 6 5 4 3 2 1 LIFTOFF আর (empty range). Iter শুধু একটি int রাখে। * সেই সংখ্যা দেয়, ++ এক কমায় — “পরের উপাদান” মানে এখানে ছোট সংখ্যা। end() হলো 0, যা শেষ মান 1-এর “পরের জায়গা”, তাই 0 নিজে কখনো প্রিন্ট হয় না — একদম half-open range. Countdown(0)-এ begin আর end দুটোই 0, লুপ একবারও চলে না। মেমরিতে দশটি সংখ্যা কোথাও জমা নেই — এটি পর্ব ৮-এর std::views::iota-র মতোই অলস (lazy) range. এখানে operator overloading (পর্ব ২৩) ঠিক যে কাজে তৈরি, সেই কাজ করছে: একটি নিজের টাইপকে pointer-এর ভাষায় কথা বলানো।

এটি range-for-এর জন্য যথেষ্ট, কিন্তু একটি “পূর্ণাঙ্গ” standard iterator নয়। std::sort বা std::distance-এর মতো algorithm-এ দিতে হলে আরও কিছু লাগে: ==, postfix ++, আর কিছু টাইপ-তথ্য (value_type, difference_type, আর category জানানো) — যা লাইব্রেরি iterator_traits দিয়ে পড়ে। আর একটি ফাঁদ আছে, যা অনুশীলন ৪-এ ধরবেন: Countdown(-3) দিলে কী হবে?

C++20-এর sentinel — end সবসময় iterator না-ও হতে পারে

উপরের end() একটি পুরো Iter বানায়, শুধু তুলনা করার জন্য। কিন্তু কিছু range-এর শেষ আগে থেকে কোনো জায়গা নয়, বরং একটি শর্ত: C-string-এর শেষ মানে “'\0' অক্ষর পাওয়া গেলে”, কীবোর্ডের ইনপুটের শেষ মানে “আর কিছু নেই”। C++20 এর জন্য এনেছে sentinel: end-এর জায়গায় এমন একটি আলাদা টাইপ, যার সাথে iterator-কে তুলনা করা যায়, কিন্তু যা নিজে কোথাও দাঁড়িয়ে নেই। Countdown-এ চাইলে end() একটি খালি struct Done {} ফেরত দিতে পারত, আর তুলনা হতো “n কি 0 হয়ে গেছে?”। C++17 থেকে range-for আলাদা টাইপের begin/end মেনে নেয় ঠিক এই কারণেই। পর্ব ৩৫-এর ranges আর views এই ধারণার ওপরই দাঁড়িয়ে।

পরীক্ষা ও ইন্টারভিউতে
  • end() কোথায় দেখায়, আর কেন? — শেষ উপাদানের পরের জায়গায়; তাতে খালি range = begin == end, আর উপাদানের সংখ্যা = end − begin. *end() undefined behavior.
  • iterator category কয়টি, আর list-এর কোনটি? — input, output, forward, bidirectional, random access, আর C++20-এ contiguous; list-এর bidirectional, vector-এর contiguous.
  • std::sort কেন list-এ চলে না? — এটি random access iterator চায়; list-এর জন্য member function list.sort() আছে।
  • vector-এ push_back-এর পরে পুরোনো iterator? — reallocation হলে সব অকেজো; না হলেও end অকেজো। list-এ যোগ করলে কোনো iterator অকেজো হয় না।
  • const_iterator আর const iterator-এর পার্থক্য? — প্রথমটি দিয়ে উপাদান বদলানো যায় না কিন্তু সরানো যায়; দ্বিতীয়টি নিজে সরে না কিন্তু উপাদান বদলাতে পারে।

১০ · AI-এর যুগে — iterator-এর ভুল ধরা

iterator-এর ভুলগুলোর একটি বিশেষ বৈশিষ্ট্য আছে: বেশিরভাগই compile হয়, ছোট পরীক্ষায় ঠিকঠাক চলে, আর ভাঙে পরে — বড় ডেটায়, অন্য compiler-এ, বা reallocation ঠিক কোন মুহূর্তে হলো তার ওপর নির্ভর করে। AI টুল এখানে বিশেষ ঝুঁকিপূর্ণ, কারণ অনলাইনে লুপের ভেতরে erase করার ভুল উদাহরণ প্রচুর, আর AI প্রায়ই এক container-এর অভ্যাস (vector-এর it + 5) অন্য container-এ (list) চালিয়ে দেয়। সাধারণ ভুলগুলো:

AI যা করেসমস্যাযা হওয়া উচিত
range-for-এর ভেতরে v.erase(...) বা push_backভেতরের begin/end অকেজো — undefined behaviorstd::erase_if, অথবা it = v.erase(it) ধাঁচের লুপ
erase(it)-এর পরে আবার ++itমৃত iterator সরানো — UBerase-এর ফেরত মান নিন
দুটি আলাদা container-এর iterator তুলনা: a.begin() != b.end()অর্থহীন তুলনা, UB; প্রায়ই অসীম লুপ বা crashএকটি range-এর দুই প্রান্ত সবসময় একই container থেকে
list-এ it + 5; error এলে std::next(it, 5) বসিয়ে দেয়compile হলো, কিন্তু উপাদান ৫টির কম হলে end পেরিয়ে যায়আগে size() বা std::distance দিয়ে দূরত্ব যাচাই
vector-এর iterator struct বা map-এ জমা রেখে পরে vector বদলায়পরের reallocation-এ সব জমা iterator মৃতindex জমা রাখুন, বা স্থির ঠিকানার container
শেষ উপাদান পেতে *v.end() বা *(v.end())end কোনো উপাদান নয় — UBv.back() বা *std::prev(v.end()), আগে খালি কি না দেখে
list বা map-এ std::sort, লুপের শর্তে it < endcompile error, বা ভুল category-র অনুমানlist.sort(); শর্তে সবসময় !=

AI-কে container-এর কোড লিখতে বলার সময় iterator-এর নিয়মগুলো আগেই দিয়ে দিন, আর প্রতিটি লুপের পক্ষে যুক্তি চান:

আমি C++23 শিখছি (g++, -Wall -Wextra -fsanitize=address). একটি সেন্সর-লগ প্রোগ্রাম লেখো: std::vector<int> readings-এ তাপমাত্রা আছে। কাজ: ঋণাত্মক (ভুল) রিডিং বাদ দাও, তারপর সাম্প্রতিক ৫টি রিডিং উল্টো ক্রমে দেখাও, আর একটি std::list<std::string>-এ রাখা সতর্কবার্তার তালিকা থেকে "OK" লেখা বার্তাগুলো মুছে দাও। নিয়ম: ১. কোনো range-for লুপের ভেতরে container-এ erase, insert বা push_back করবে না। ২. মোছার জন্য std::erase_if ব্যবহার করবে; হাতে লুপ লিখলে it = c.erase(it) ধাঁচ মানবে। ৩. কোনো iterator container বদলানোর পরে ব্যবহার করবে না, আর iterator কোথাও জমা রাখবে না। ৪. সাম্প্রতিক ৫টি নেওয়ার আগে উপাদানের সংখ্যা যাচাই করবে; *end() কোথাও থাকবে না। ৫. list-এ it + n বা std::sort নয়। কোডের পরে প্রতিটি লুপের জন্য বলো: কোন iterator category লাগছে, আর কোন লাইনের পরে কোন iterator অকেজো হতে পারত।

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

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

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

প্র ০১Alexander Stepanov বলেছিলেন, algorithm লেখা উচিত container-এর ওপর নয়, iterator-এর ওপর। এই ধারণা কোথা থেকে এল, আর কেন এটি এত শক্তিশালী?

Stepanov ছিলেন গণিতের ছাত্র, আর তাঁর প্রশ্নটা ছিল গাণিতিক: একটি algorithm আসলে কী কী চায়? ধরুন “একটি মান খুঁজে বের করো” (find). এর জন্য দরকার শুধু উপাদান পড়া, পরেরটিতে যাওয়া, আর শেষ হলো কি না জানা। এটি vector চায় না, list চায় না — চায় একটি ন্যূনতম ক্ষমতার সেট। আবার binary search চায় এক লাফে মাঝখানে যাওয়ার ক্ষমতা। গণিতে যেমন একটি উপপাদ্য “সব group-এর জন্য” প্রমাণ করা হয়, নির্দিষ্ট কোনো সংখ্যার জন্য নয়, Stepanov চেয়েছিলেন algorithm-ও লেখা হোক ন্যূনতম শর্তের ওপর, যাতে সেই শর্ত মানে এমন সব ডেটা-গঠনে একবারে কাজ করে। এই চিন্তা থেকেই iterator category: প্রতিটি category একটি শর্তের সেট, আর প্রতিটি algorithm ঘোষণা করে সে কোনটি চায়।

১৯৮০-এর দশকে David Musser-এর সাথে তিনি Scheme আর Ada-তে এই generic programming নিয়ে কাজ করেন। পরে Hewlett-Packard-এর গবেষণাগারে Meng Lee-র সাথে C++-এ লেখেন Standard Template Library. C++-এর template তখন নতুন, আর Stepanov দেখলেন এটি দিয়ে তাঁর স্বপ্নের দ্বিতীয় অংশটিও পূরণ হয়: generic কোড হবে হাতে লেখা কোডের মতোই দ্রুত। template-এ compiler প্রতিটি iterator টাইপের জন্য আলাদা কোড বানায়, তাই vector-এর ওপর std::find শেষ পর্যন্ত একটি সাধারণ pointer লুপ হয়ে যায় — কোনো virtual ডাক নেই। Andrew Koenig-এর আমন্ত্রণে ১৯৯৩ সালের শেষ দিকে Stepanov এটি C++ standard কমিটির সামনে তুলে ধরেন, আর ১৯৯৪ সালে কমিটি STL-কে standard লাইব্রেরিতে নেওয়ার সিদ্ধান্ত নেয় — প্রথম C++ standard (C++98) প্রকাশের কয়েক বছর আগে।

শক্তিটা গুণের হিসাবে। দশটি container আর একশোটি algorithm-এ হাতে লিখলে এক হাজার টুকরো; iterator দিয়ে লাগে একশো দশটি। আরও বড় কথা, যে container আজও কেউ লেখেননি, সেটিও কাল থেকে একশোটি algorithm পাবে, শুধু নিজের iterator দিলেই — যেমন আপনার Countdown. Stepanov আরেকটি জিনিস জোর দিয়ে বলতেন: algorithm-এর জটিলতা (কত ধাপ লাগে) তার interface-এর অংশ। তাই standard std::sort-এর জন্য O(n log n) নিশ্চিত করে, আর list-কে random access দেওয়ার “ভান” করতে দেয় না। পরে তিনি এসব চিন্তা বই আকারে লেখেন — Paul McJones-এর সাথে “Elements of Programming” (২০০৯)।

মূল উপলব্ধি: iterator কোনো লুপের সুবিধা নয়, এটি একটি চুক্তি — algorithm কতটুকু চায় আর container কতটুকু দেয়, তার মাপজোখ। সেই চুক্তি ঠিক থাকলে কোড একবার লেখা হয়, সব জায়গায় চলে, আর গতিও হারায় না।

প্র ০২half-open range [begin, end) কেন? Edsger Dijkstra ১৯৮২ সালে শূন্য থেকে গোনা নিয়ে যে ছোট লেখাটি লিখেছিলেন, তার সাথে C++-এর end()-এর সম্পর্ক কী?

১৯৮২ সালে ওলন্দাজ কম্পিউটার বিজ্ঞানী Edsger W. Dijkstra হাতে লেখা একটি ছোট নোট লেখেন — EWD831, শিরোনাম “Why numbering should start at zero”. প্রশ্নটা ছিল সরল: ২, ৩, …, ১২ — এই ধারাবাহিক সংখ্যাগুলোকে কীভাবে লেখা উচিত? চারটি উপায়: 2 ≤ i < 13, 1 < i ≤ 12, 2 ≤ i ≤ 12, আর 1 < i < 13. Dijkstra প্রথমটির পক্ষে যুক্তি দেন — নিচের সীমা ভেতরে, ওপরের সীমা বাইরে।

তাঁর যুক্তিগুলো ঠিক আমাদের ২ নম্বর অংশের সুবিধাগুলো। ওপরের সীমা বাইরে রাখলে দুই সীমার পার্থক্যই উপাদানের সংখ্যা: 13 − 2 = 11. পাশাপাশি দুটি range-এ একটির ওপরের সীমা পরেরটির নিচের সীমা, তাই কোনো ফাঁক বা ওভারল্যাপ নেই। আর খালি range লেখা যায় 2 ≤ i < 2 দিয়ে — নিচের সীমার চেয়ে ছোট কোনো ওপরের সীমা বানাতে হয় না, যা দুই দিক বন্ধ রাখার নিয়মে (2 ≤ i ≤ 1) কৃত্রিম আর বিভ্রান্তিকর। নিচের সীমা ভেতরে রাখার কারণ হলো, সবচেয়ে ছোট স্বাভাবিক সংখ্যা থেকে শুরু হওয়া range-এর জন্য তার চেয়েও ছোট একটি “অস্বাভাবিক” সংখ্যা লাগবে না। Dijkstra এটাও উল্লেখ করেন যে Xerox PARC-এর Mesa ভাষায় চারটি নিয়মই চলত, আর সেখানকার অভিজ্ঞতা ছিল: অন্য তিনটি নিয়ম বারবার অস্বস্তি আর ভুলের উৎস হয়েছে। শেষে সিদ্ধান্ত: N উপাদানের ক্রমের index হওয়া উচিত 0 ≤ i < N.

C এই নিয়ম আগেই নিয়েছিল: array-এর index 0 থেকে, লুপ i < n, আর শেষের পরের ঠিকানা a + n বৈধ (কিন্তু পড়া নিষেধ)। Stepanov STL-এ এই নিয়মকে সব container-এ সাধারণ করলেন। list-এ কোনো index নেই, কিন্তু “শেষের পরের জায়গা” ধারণাটি ঠিকই থাকে — তাই find কিছু না পেলে end ফেরত দেয়, “পাওয়া যায়নি” বোঝানোর জন্য আলাদা কোনো -1 বা null লাগে না। এর একটি দাম আছে: উল্টো দিকে হাঁটতে reverse iterator-এর base এক ঘর সরে থাকে, আর “শেষ উপাদান” পেতে std::prev(end) লিখতে হয়। তবু সব মিলিয়ে ভুল অনেক কম হয়।

মূল উপলব্ধি: half-open range কোনো C++-এর খেয়াল নয়; এটি একটি পুরোনো, ভেবেচিন্তে নেওয়া গাণিতিক সিদ্ধান্ত, যা খালি range, দৈর্ঘ্য আর ভাগ করা — তিনটিকেই বিশেষ নিয়ম ছাড়া সামলায়। off-by-one ভুল কমানোর সবচেয়ে সস্তা উপায় হলো সব জায়গায় একই নিয়ম মানা।

প্র ০৩পুরোনো iterator category আর C++20-এর iterator concept — দুটোই “এই iterator কী পারে” বলে। তাহলে concept আনার দরকার হলো কেন, আর দুটি কি হুবহু এক?

C++98-এ iterator category ছিল মূলত documentation-এ লেখা নিয়ম — standard বলত “এই algorithm-এর জন্য random access iterator লাগবে”, আর random access iterator কী কী করবে তার তালিকা দিত। compiler-এর কাছে এর প্রকাশ ছিল শুধু কিছু “tag” টাইপ: std::random_access_iterator_tag, std::bidirectional_iterator_tag ইত্যাদি, যা std::iterator_traits দিয়ে পড়া যায়। লাইব্রেরি এগুলো দিয়ে overload বেছে নিত — std::advance random access tag দেখলে += করে, নাহলে লুপ চালায়। একে বলে tag dispatch.

সমস্যা ছিল দুটি। এক, নিয়মটি আগে থেকে যাচাই হতো না। list-এর iterator std::sort-এ দিলে compiler sort-এর ভেতরে ঢুকে যায়, কোনো এক গভীর সহায়ক ফাংশনে গিয়ে দেখে it1 - it2 লেখা যায় না, আর তখন দেয় লম্বা, দুর্বোধ্য error — যার সাথে আপনার লেখা লাইনের সম্পর্ক খুঁজে পাওয়াই কঠিন। দুই, tag শুধু একটি দাবি; একটি টাইপ নিজেকে random access বলে ঘোষণা করেও operator[] না দিতে পারত।

C++20-এর concept (পর্ব ৪০) এই নিয়মগুলোকে ভাষার অংশ বানায়: std::random_access_iterator<I> একটি compile-time প্রশ্ন, যার উত্তর হ্যাঁ বা না। std::ranges::sort ঘোষণাতেই এই শর্ত রাখে, তাই list দিলে error আসে ডাকার জায়গায়, আর বলে কোন শর্ত মেলেনি। তবে concept শুধু গঠন যাচাই করে — ++ আর * আছে কি না। “একই পথ বারবার হাঁটা যায়” (forward iterator-এর multi-pass নিশ্চয়তা) বা “++ সত্যিই এক ধাপ এগোয়” — এমন অর্থগত নিয়ম compiler যাচাই করতে পারে না; সেগুলো এখনো লেখকের প্রতিশ্রুতি।

আর দুটি হুবহু এক নয়। C++20-এর নিয়ম কিছু জায়গায় ঢিলা করা হয়েছে। পুরোনো নিয়মে forward iterator-এর *it-কে একটি আসল reference ফেরত দিতে হতো; যে iterator প্রতিবার হিসাব করে একটি নতুন মান দেয় (যেমন std::views::iota-র iterator বা আমাদের Countdown), সে পুরোনো নিয়মে input-এর ওপরে উঠতে পারত না। C++20-এর concept-এ সেটি random access পর্যন্ত হতে পারে। তাই একই iterator পুরোনো category-তে একটি আর নতুন concept-এ অন্য ধাপের হতে পারে, আর লাইব্রেরি দুটি তথ্য আলাদা রাখে: iterator_category আর iterator_concept নামে দুটি আলাদা টাইপে।

মূল উপলব্ধি: category ছিল একটি ভদ্রলোকের চুক্তি, concept হলো compiler-এর পাহারা দেওয়া চুক্তি। নতুন কোডে std::ranges-এর algorithm বেছে নিলে ভুলের বার্তা ছোট হয়, আর আপনার নিজের iterator কোন ধাপের, তা static_assert দিয়ে যাচাই করা যায়।

প্র ০৪Java-র Iterator একটি একক object, যে নিজেই জানে কখন শেষ (hasNext). C++ ব্যবহার করে দুটি iterator-এর জোড়া। দুই নকশার লাভ-ক্ষতি কী?

Java ১.২-এর (১৯৯৮) Collections Framework-এ আসে Iterator interface: hasNext() জিজ্ঞেস করে আরও আছে কি না, next() একসাথে পরের উপাদান দেয় আর এগিয়ে যায়, আর ঐচ্ছিক remove() সর্বশেষ দেওয়া উপাদানটি মোছে। একটি object-ই পুরো যাত্রা — সে জানে কোথায় আছে আর কোথায় থামতে হবে। Python-এর __next__ আর StopIteration, বা Rust-এর next() যা Option ফেরত দেয় — এরাও মোটামুটি এই পরিবারের।

এই নকশার বড় লাভ নিরাপত্তা আর সরলতা। দুটি আলাদা container-এর begin আর end মিশিয়ে ফেলার মতো ভুল এখানে করাই যায় না, কারণ “end” আলাদা কোনো জিনিস নয়। Java-র বেশিরভাগ collection “fail-fast” — iteration চলার সময় collection-কে অন্য পথে বদলালে পরের next() সাধারণত ConcurrentModificationException ছুড়ে দেয়। Java নিজেই বলে এটি সর্বোত্তম চেষ্টা, নিশ্চয়তা নয় — তবু C++-এর নীরব undefined behavior-এর তুলনায় অনেক বন্ধুসুলভ। আর remove() থাকায় লুপের ভেতরে নিরাপদে মোছার একটি স্বীকৃত পথ আছে।

C++-এর জোড়ার লাভ হলো, iterator একটি জায়গা, যাত্রা নয়। জায়গা কপি করা যায়, জমা রাখা যায়, তুলনা করা যায়। তাই একটি range-এর যেকোনো অংশ — [it, end), [begin, mid) — দুটি iterator দিয়েই বোঝানো যায়, কোনো নতুন object ছাড়া। binary search মাঝখানে ভাগ করে, sort দুই প্রান্ত থেকে ভেতরে আসে, find পাওয়া জায়গাটি ফেরত দেয়, যেখান থেকে আরেকটি algorithm শুরু করতে পারে। Java-র iterator-এ “মাঝখান থেকে আরেকটি iterator শুরু করো” বলার সহজ উপায় নেই। গতির দিকেও পার্থক্য আছে: Java-র Iterator একটি interface, তাই প্রতিটি next() নীতিগতভাবে একটি virtual ডাক (JIT অনেক সময় সেটি সরিয়ে দেয়); C++-এর template-এ ++it সাধারণত একটি যন্ত্র-নির্দেশে নেমে আসে।

দাম কী? জোড়া মানে দুটি জিনিস মেলাতে হয়, আর ভুল জোড়া compiler ধরে না। প্রতিটি ডাকে v.begin(), v.end() লেখা ক্লান্তিকর। আর ভুলভাবে ব্যবহারের শাস্তি নীরব। C++20-এর ranges ঠিক এখানে দুই জগতের মাঝামাঝি গেছে: একটি range object (যেমন পুরো vector) একসাথে begin আর end বহন করে, অথচ ভেতরে জায়গা-ভিত্তিক iterator রয়ে যায়।

মূল উপলব্ধি: Java সাজিয়েছে “একবার হাঁটো, নিরাপদে”-র জন্য; C++ সাজিয়েছে “জায়গা নিয়ে হিসাব করো, দ্রুত”-র জন্য। কোনটি ভালো তা নির্ভর করে কোন ভুলের দাম আপনার কাছে বেশি — ধীরগতি, না নীরব বিপদ।

প্র ০৫iterator এত শক্তিশালী হলে C++20-এ ranges (পর্ব ৩৫) আনতে হলো কেন? দুটি iterator-এর জোড়ায় কী কী অসুবিধা ছিল?

প্রায় বিশ বছর ধরে C++ প্রোগ্রামাররা একই অভিযোগ করছিলেন। প্রথমটি নিছক লেখার ঝামেলা: “পুরো vector সাজাও” বলতে লিখতে হয় std::sort(v.begin(), v.end()) — একই নাম দুবার, আর প্রতিবার ভুল জোড়া লেখার সুযোগ। দ্বিতীয়টি গঠনগত: algorithm-গুলো জোড়া লাগানো যায় না। “জোড় সংখ্যাগুলো নাও, প্রতিটির বর্গ করো, প্রথম তিনটি দেখাও” — iterator দিয়ে এটি করতে হলে হয় মাঝখানে অস্থায়ী vector বানাতে হয়, নয়তো নিজে জটিল iterator adapter লিখতে হয়। তৃতীয়টি শেষ চেনার সমস্যা: C-string বা কীবোর্ড ইনপুটের end আগে থেকে কোনো জায়গা নয়; সেই শেষকে একটি “নকল iterator” বানিয়ে প্রকাশ করতে হতো, যেমন std::istream_iterator-এর খালি রূপ।

সমাধানের চেষ্টা অনেক দিনের। ২০০০-এর দশকে Boost.Range লাইব্রেরি range-কে একটি জিনিস হিসেবে ধরার পথ দেখায়। ২০০৯ সালে BoostCon-এ Andrei Alexandrescu-র বক্তৃতা “Iterators Must Go” আলোড়ন তোলে — তিনি বললেন, মৌলিক একক হওয়া উচিত range, iterator নয়; D ভাষার standard লাইব্রেরি সেই পথেই যায়। তারপর Eric Niebler-এর range-v3 লাইব্রেরি দেখাল কীভাবে C++-এর বিদ্যমান iterator-কে বাদ না দিয়েই range আর অলস (lazy) view আনা যায়। সেই কাজ প্রথমে Ranges TS (২০১৭) হিসেবে পরীক্ষিত হয়, তারপর C++20-এ standard-এ ঢোকে।

C++ ইচ্ছা করে D-র মতো iterator বাদ দেয়নি। C++20-এর range মানে মোটামুটি “যার begin() আর end() আছে” — আর end একটি sentinel হতে পারে, iterator না হলেও চলে। ফলে পুরোনো সব container সঙ্গে সঙ্গে range, পুরোনো algorithm-এর জ্ঞান কাজে লাগে, আর “জায়গা” ধারণাটিও হারায় না। নতুন যা এল: std::ranges::sort(v)-এর মতো এক-আর্গুমেন্টের ডাক, concept দিয়ে যাচাই, | দিয়ে view জোড়া লাগানো, আর অস্থায়ী (মরে যাওয়া) container থেকে iterator ফেরত দেওয়ার বদলে একটি বিশেষ টাইপ std::ranges::dangling দিয়ে আগেভাগে সাবধান করার ব্যবস্থা।

মূল উপলব্ধি: ranges iterator-এর বিকল্প নয়, তার ওপরে একটি তলা। iterator বুঝলে আপনি বুঝবেন একটি view ভেতরে কী করে, কেন কিছু view-এর ওপর std::sort চলে না, আর কোন মুহূর্তে একটি view dangling হয়ে যায় — পর্ব ৩৫ ঠিক এই ভিতের ওপর দাঁড়াবে।

অনুশীলন

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

  1. আউটপুট অনুমান: std::vector<int> v = {5, 10, 15, 20}; আর auto it = v.begin() + 1;. তাহলে *std::next(it, 2), *std::prev(v.end()), std::distance(it, v.end()) আর *v.rbegin() — চারটির মান কী?

    it আছে ঘর [1]-এ, মান 10-এ। std::next(it, 2) ঘর [3], মান 20. std::prev(v.end()) শেষ উপাদান, 20. std::distance(it, v.end()) = 3 (ঘর ১, ২, ৩ — half-open range-এ end গোনা হয় না)। *v.rbegin() উল্টো দিকের প্রথম, অর্থাৎ শেষ উপাদান 20. খেয়াল করুন, std::next নতুন iterator দেয় — it এখনো 10-এ।

  2. বাগ শিকার: একটি AI লিখেছে — for (auto& r : readings) if (r < 0) readings.erase(std::find(readings.begin(), readings.end(), r)); (readings একটি vector). ছোট পরীক্ষায় এটি “চলে গেছে”। কেন এটি তবু ভুল, আর ঠিক কোড কী?

    range-for ভেতরে begin আর end একবার নিয়ে রেখেছে। erase মোছার জায়গা থেকে শেষ পর্যন্ত সব iterator অকেজো করে — রেখে দেওয়া __end-সহ। এরপর লুপের ++__begin আর != __end দুটিই undefined behavior. ছোট পরীক্ষায় চলে যাওয়া মানে শুধু ভাগ্য; একটানা দুটি ঋণাত্মক মান থাকলে দ্বিতীয়টি বাদ পড়ার, বা শেষ পেরিয়ে পড়ার ঘটনা সহজেই ঘটে। তার ওপর প্রতিবার std::find পুরো vector খোঁজে — অকারণ O(n²)। ঠিক কোড: std::erase_if(readings, [](int x) { return x < 0; });

  3. নকশা ভাবুন: একটি গানের অ্যাপে (ক) পুরো playlist নামের ক্রমে সাজাতে হবে, (খ) “এখন বাজছে” গানের iterator ধরে রেখে অন্য গান যোগ-বিয়োগ করতে হবে, (গ) একটি একমুখী তালিকায় শেষ থেকে শুরুর দিকে হাঁটতে হবে। প্রতিটির জন্য কোন container আর কোন iterator category-র ক্ষমতা লাগে, আর কোথায় সমস্যা?

    (ক) std::sort চাইলে random access — vector বা deque. list হলে list.sort() ব্যবহার করুন। (খ) iterator ধরে রেখে যোগ-বিয়োগ: list বা map ভালো, কারণ সেখানে যোগ করলে কোনো iterator মরে না, আর মুছলে শুধু মোছা গানের। vector হলে প্রতিটি যোগে “এখন বাজছে” iterator অকেজো হতে পারে — index রাখতে হবে। (গ) forward_list-এর iterator শুধু forward; -- নেই, rbegin()-ও নেই। হয় দ্বিমুখী std::list নিন, নয়তো একবার সামনে হেঁটে উপাদানগুলো একটি vector-এ নিয়ে তারপর উল্টো দিকে পড়ুন।

  4. নিজে লিখুন (কোড): উপরের countdown.cpp-এ Countdown(-3) দিলে কী হয়, আগে অনুমান করুন। তারপর constructor বদলে এমনভাবে ঠিক করুন, যাতে শূন্য বা ঋণাত্মক সংখ্যা দিলে range খালি হয়।

    begin হয় -3, end হয় 0. ++ সংখ্যা কমায়: -3, -4, -5 … — কখনো 0-তে পৌঁছায় না, তাই != সবসময় সত্য, লুপ অসীম — আর int একসময় overflow করে, যা undefined behavior-এর ঘটনা। এটাই !=-ভিত্তিক লুপের নিয়ম: begin থেকে ++ করতে করতে end-এ পৌঁছানো নিশ্চিত হতে হবে। সমাধান: explicit Countdown(int from) : from_(from < 0 ? 0 : from) {}. এখন Countdown(-3)-এ begin = end = 0, খালি range. (<=-জাতীয় তুলনা দিয়ে ঢাকা দেওয়ার চেয়ে এটি ভালো, কারণ range-এর নিয়ম — [begin, end) বৈধ হতে হবে — তৈরির জায়গাতেই রক্ষা হলো।)

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

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

ক্রমC++-এর একটি range-for লুপ for (auto x : c) compiler-এর বিস্তারে যে ক্রমে চলে, সেভাবে সাজান (▲▼ চেপে সরান)

  1. c-কে একটি reference-এ একবার ধরা হয়
  2. begin আর end — দুটি iterator একবার নেওয়া হয়
  3. শর্ত যাচাই: begin != end কি না
  4. *begin থেকে লুপের ভ্যারিয়েবল x তৈরি হয়
  5. লুপের দেহ চলে
  6. ++begin করে আবার শর্তে ফেরা

C++-এ ৫টি উপাদানের একটি std::vector-এর v.end() কী দেখায়?

  • শেষ উপাদান, অর্থাৎ ঘর [4]
  • একটি nullptr
  • শেষ উপাদানের পরের জায়গা, যেখান থেকে পড়া নিষেধ
  • প্রথম উপাদানের আগের জায়গা

C++-এর কোন container-এর iterator bidirectional, কিন্তু random access নয়?

  • std::vector
  • std::deque
  • std::list
  • std::forward_list

C++-এ std::list<int> l-এর জন্য std::sort(l.begin(), l.end()) লিখলে কী হয়?

  • ঠিকঠাক সাজায়, শুধু একটু ধীরে
  • compile error, কারণ std::sort random access iterator চায়
  • চলার সময় undefined behavior
  • list-কে নিজে থেকে vector-এ বদলে নিয়ে সাজায়

C++-এ একটি std::vector-এ push_back-এর ফলে reallocation হলো। আগে নেওয়া iterator-গুলোর কী অবস্থা?

  • সবগুলো অকেজো; ব্যবহার করা undefined behavior
  • শুধু end() অকেজো, বাকিগুলো ঠিক আছে
  • vector নিজে থেকে সব iterator নতুন ঠিকানায় আপডেট করে
  • কোনোটিই অকেজো হয় না, কারণ উপাদানের মান বদলায়নি

C++-এ n উপাদানের একটি std::list-এ std::distance(l.begin(), l.end())-এর সময়-জটিলতা কত?

  • O(1)
  • O(log n)
  • O(n²)
  • O(n)

C++-এ নিজের একটি টাইপকে for (int x : obj) লুপে চালাতে ন্যূনতম কী লাগে?

  • একটি standard container class থেকে inheritance
  • begin() ও end(), আর iterator-এ *, ++ ও !=
  • operator[] আর size()
  • একটি পূর্ণাঙ্গ random access iterator

শব্দকোষ

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

মনে রাখুন

  1. iterator = সাধারণীকৃত pointer: *it, ++it, it != end — একই ভাষায় সব container-এ হাঁটা, তাই algorithm একবার লিখলেই সব জায়গায় চলে।
  2. [begin, end): end শেষের পরের জায়গা; খালি range মানে begin == end; *end() কখনো নয়।
  3. range-for একবার begin/end নিয়ে iterator লুপ চালায় — তাই এর ভেতরে container বদলানো নিষেধ।
  4. category: input → forward → bidirectional → random access → contiguous; std::sort চায় random access, list-এর আছে list.sort(); দূরত্বের কাজে std::next/std::distance, কিন্তু সীমা নিজে যাচাই করুন।
  5. invalidation: vector-এ যোগ-বিয়োগের পরে iterator নতুন করে নিন; মুছতে std::erase_if বা it = c.erase(it).

পরের পর্বে: iterator-এর ভাষা শিখে ফেলেছেন — এবার সেই ভাষায় লেখা শতাধিক তৈরি সমাধান। খোঁজা, গোনা, সাজানো, কপি, রূপান্তর: STL algorithm দিয়ে হাতে লুপ না লিখে কাজ সারা, আর কোন algorithm কোন category চায়।

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

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