Learn C++ STL

lesson ৪ / ৬ · STL জিনিসটা কী, আর এটা C++ লেখার ধরনটাই পাল্টে দেয় কেন

Module ০ · STL জিনিসটা কী, আর এটা C++ লেখার ধরনটাই পাল্টে দেয় কেন

iterator আর algorithm: টুকরোগুলো জোড়া লাগে কীভাবে

Freeপড়া

এই lesson-এ যা শিখবে

  • Iterator কী, আর begin() আর end() কোথায় আঙুল রাখে, বলতে পারবে।
  • Range কেন [begin, end) করে লেখা হয়, আর তাতে ফাঁকা range আর loop কেন সহজ হয়ে যায়, বুঝিয়ে বলতে পারবে।
  • একই চারটা algorithm একটা vector আর একটা deque-এ চালাতে পারবে, code-এর একই লাইন দিয়ে।

পরীক্ষার নম্বর রাখা একটা vector-এর জন্য Zara যত্ন করে একটা search লিখেছিল। অভ্যাসমতো সে আগে ফাঁকা vector দিয়ে পরীক্ষা করেছে, আর সেটা pass করেছে। তারপর তার team নম্বরগুলো একটা deque-এ সরিয়ে নিল। Zara দীর্ঘশ্বাস ফেলল: search-টা আবার লিখতে হবে।

লিখতে হয়নি। তার search ছিল std::find, আর std::find জানেই না vector কী জিনিস। সে শুধু জানে এক iterator থেকে আরেক iterator পর্যন্ত কীভাবে হাঁটতে হয়। Container বদলাও, একই লাইন কাজ করেই যায়।

এই lesson ওই হাঁটা নিয়েই। Lesson 3-এর প্রতিটা container-কে এই track-এর প্রতিটা algorithm-এর সঙ্গে জোড়ে এটাই। তাই চলতে দেখার মতো ছবি যদি একটাই থাকে, সেটা এটা।

একটা iterator তুমি আগে থেকেই চেনো: pointer

C-তে তুমি pointer দিয়ে array-র উপর দিয়ে হেঁটেছ। প্রথম element থেকে শুরু করেছ, *p দিয়ে পড়েছ, ++p দিয়ে এগিয়েছ, আর থেমেছ শেষ element-এর এক ঘর পরের একটা pointer-এ। ওই pointer-টাই একটা iterator: এমন কিছু, যেটা একটা element-এ আঙুল রাখে আর পরেরটায় সরে যেতে পারে।

STL code-এ এই হাঁটার চেহারা সব সময় একই। নিচে প্রতিটা অংশের নাম দেওয়া আছে; তারপর Example 1 একই জিনিস সাধারণ pointer দিয়ে দেখায়।

Iterator দিয়ে হাঁটা

for (auto it = c.begin(); it != c.end(); ++it) { use(*it); }
  • auto it = c.begin(): প্রথম element থেকে শুরু। auto থাকলে iterator-এর লম্বা type-টা compiler নিজেই লিখে নেয়।
  • it != c.end(): শেষ element-এর পরের ঘরে না পৌঁছানো পর্যন্ত চলতে থাকো। != ব্যবহার করো, < না, কারণ সব iterator-কে < দিয়ে তুলনা করা যায় না।
  • ++it: পরের element-এ যাও, এই container-এর কাছে "পরের" মানে যাই হোক না কেন।
  • *it: iterator যে element-এ আঙুল রেখেছে, সেটা।
Example 1: pointer-ও একটা iterator
#include <iostream>

int main()
{
    int marks[] = {72, 45, 90, 61};
    int *first = marks;
    int *last = marks + 4;
    std::cout << "marks:";
    for (int *p = first; p != last; ++p) std::cout << ' ' << *p;
    std::cout << '\n';
}
marks: 72 45 90 61

last আঙুল রাখে 61-এর এক ঘর পরে, এমন একটা ঘরে যেখানে আমাদের কিছুই নেই। Loop কখনো ওটা পড়ে না; শুধু ওটার সঙ্গে তুলনা করে। কথাটা মনে রেখো, কারণ STL হুবহু এটাই করে।

Compiler-এ চালাও

begin() আর end(): যেকোনো container-এ একই হাঁটা

প্রতিটা STL container দুইটা iterator দেয়। begin() আঙুল রাখে প্রথম element-এ। end() আঙুল রাখে শেষ element-এর এক ঘর পরে, উপরের last-এর মতো সেই একই ফাঁকা ঘরে। end() থেকে তুমি কখনো কিছু পড়ো না; ওখানে পৌঁছালে শুধু থামো।

Container-এর iterator সব সময় সাধারণ pointer না। একটা list iterator একটা link ধরে পরের node-এ যায়, আর একটা deque iterator memory-র এক block থেকে পরের block-এ লাফ দিতে পারে। তবে সবগুলোই একই তিনটা অনুরোধের জবাব দেয়: পড়া (*it), এগোনো (++it), আর তুলনা (it != end)।

এই যে সেই হাঁটা, এবার চলন্ত। আগে vector-এ ধাপে ধাপে চালাও, তারপর deque-এ। শেষ ধাপটা খেয়াল করো: iterator এসে পড়ে end()-এ, loop থেমে যায়, আর ওখানে কিছুই পড়া হয় না।

Iterator শুরু করে begin() থেকে, 72-এ, তারপর এক এক করে যায় 45, 90 আর 61-এ। তারপর পৌঁছায় end()-এ, 61-এর পরের ফাঁকা ঘরে, আর loop ওটা না পড়েই থেমে যায়। Deque-এ চারটা মান থাকে দুইটা block-এ, প্রতিটায় দুইটা করে। Iterator একই পাঁচ জায়গায় থামে, শুধু প্রথম block থেকে দ্বিতীয় block-এ লাফ দেয়। (আসল deque-এর একেকটা block-এ দুইটার চেয়ে অনেক বেশি element ধরে; লাফটা চোখে পড়ার জন্যই এখানে দুইটা।)

Example 2: iterator দিয়ে একটা vector-এর উপর হাঁটা
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v = {72, 45, 90, 61};
    std::cout << "v:";
    for (auto it = v.begin(); it != v.end(); ++it) std::cout << ' ' << *it;
    std::cout << '\n';
    std::cout << "size from the iterators: " << (v.end() - v.begin()) << '\n';
}
v: 72 45 90 61
size from the iterators: 4

Loop-টা Example 1-এর loop-ই, শুধু নামগুলো নতুন। আর end() - begin() হলো 4, মানে element-এর সংখ্যা, ঠিক যেমন pointer-এর বেলায় last - first ছিল 4। এই বিয়োগটার জন্যই end() বসে থাকে শেষ element-এর এক ঘর পরে।

Compiler-এ চালাও

Half-open range [begin, end)

দুইটা iterator, first আর last, মিলে একটা range বোঝায়: first থেকে শুরু করে last-এর ঠিক আগ পর্যন্ত প্রতিটা element, ওটাকে বাদ দিয়ে। অঙ্কে এটাকে লেখা হয় [first, last), আর এর নাম half-open range: তৃতীয় বন্ধনী [ মানে "ভেতরে আছে", আর প্রথম বন্ধনী ) মানে "ভেতরে নেই"।

Half-open range: begin() প্রথম element-এ, end() শেষটার এক ঘর পরে 72 45 90 61 কিছু নেই 0 1 2 3 4 begin() end() [begin, end)-এ আছে 4টা element, আর end() - begin() = 4 কিছু নেই ফাঁকা container: begin() == end(), দুইটাই এই ঘরে। Range ফাঁকা, আর প্রতিটা loop স্রেফ শূন্যবার চলে।
ছবি 1। Half-open range। প্রতিটা element range-এর ভেতরে, end() ঠিক বাইরে, আর ফাঁকা range মানে দুইটাই একই জায়গায় আঙুল রাখে।

তাহলে end() শেষ element-এ আঙুল রাখে না কেন? কারণ তিনটা, আর তার দুইটা তুমি এর মধ্যেই দেখে ফেলেছ।

  • গোনা মানে একটা বিয়োগ। end() - begin() হলো element-এর সংখ্যা, মনে রাখার মতো কোনো + 1 নেই।
  • ফাঁকার জন্য আলাদা কোনো case লাগে না। ফাঁকা container-এ begin() == end()। Loop-এর শর্ত শুরুতেই false, আর প্রতিটা algorithm কিছুই করে না, যেটা ঠিকই। Zara-র ফাঁকা vector-এর পরীক্ষা কোনো বাড়তি code ছাড়াই pass করে।
  • "পাওয়া যায়নি"-এর একটা ঠিকানা আছে। মানটা না থাকলে std::find ফেরত দেয় end(), এমন একটা অবস্থান যেটা কখনো আসল উত্তর হতে পারে না।

তাই range মানে সব সময় "এখান থেকে ওখান পর্যন্ত, তবে ওখানটা বাদে"। এই track-এর প্রতিটা algorithm তার range এভাবেই নেয়।

এক algorithm, সব container

একটা algorithm দুইটা iterator পায়, আর তাদের মাঝখান দিয়ে হাঁটে। Container-টা সে কখনো পায় না। তাই element-গুলো vector-এ আছে, deque-এ, নাকি সাধারণ একটা array-তে, সেটা সে বলতে পারে না, আর তার মাথাব্যথাও নেই। তার দরকার শুধু এমন একটা iterator, যেটা algorithm-এর চাওয়া কাজগুলো করতে পারে।

Example 3: সাধারণ C array-তে std::sort
#include <algorithm>
#include <iostream>

int main()
{
    int marks[] = {72, 45, 90, 61};
    std::sort(marks, marks + 4);
    std::cout << "sorted:";
    for (int m : marks) std::cout << ' ' << m;
    std::cout << '\n';
}
sorted: 45 61 72 90

কোনো container-ই নেই, শুধু দুইটা pointer, আর তাতেই std::sort খুশি। Algorithm যে শুধু iterator দেখে, এটাই তার সবচেয়ে জোরালো প্রমাণ: C array-র ভেতরের একটা pointer-ও একটা iterator।

Compiler-এ চালাও

এবার একটা vector-এ চারটা algorithm। sort range-টা সাজায়, আর find একটা মানের দিকে iterator ফেরত দেয় (বা end())। count গোনে একটা মান কতবার আছে, আর reverse range-টা উল্টে দেয়।

Example 4: একটা vector-এ চারটা algorithm
#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> c = {61, 72, 45, 90, 45};
    std::sort(c.begin(), c.end());
    auto it = std::find(c.begin(), c.end(), 72);
    std::cout << "72 found: " << (it != c.end()) << '\n';
    std::cout << "45 count: " << std::count(c.begin(), c.end(), 45) << '\n';
    std::reverse(c.begin(), c.end());
    std::cout << "reversed:";
    for (int x : c) std::cout << ' ' << x;
    std::cout << '\n';
}
72 found: 1
45 count: 2
reversed: 90 72 61 45 45

(it != c.end()) হলো true, আর std::cout true-কে ছাপে 1 হিসেবে। Sort নম্বরগুলো ছোট থেকে বড় করে সাজিয়েছে, আর reverse সেটাকে করেছে বড় থেকে ছোট।

Compiler-এ চালাও
Example 5: একই চার লাইন, একটা deque-এ

বদল মাত্র দুইটা: header, আর declaration-এর vector শব্দটা। Algorithm-এর প্রতিটা লাইন একই, অক্ষরে অক্ষরে।

#include <algorithm>
#include <deque>
#include <iostream>

int main()
{
    std::deque<int> c = {61, 72, 45, 90, 45};
    std::sort(c.begin(), c.end());
    auto it = std::find(c.begin(), c.end(), 72);
    std::cout << "72 found: " << (it != c.end()) << '\n';
    std::cout << "45 count: " << std::count(c.begin(), c.end(), 45) << '\n';
    std::reverse(c.begin(), c.end());
    std::cout << "reversed:";
    for (int x : c) std::cout << ' ' << x;
    std::cout << '\n';
}
72 found: 1
45 count: 2
reversed: 90 72 61 45 45

Output একই। ভেতরে deque তার element-গুলো আলাদা আলাদা block-এ রাখে, আর তার iterator ওগুলোর মাঝে লাফ দেয়, widget-এ যেমন দেখেছ। Algorithm-গুলো টেরই পায় না। এটাই Zara-র search: container বদলাল, একটা লাইনও বদলাতে হলো না।

Compiler-এ চালাও

Container-এর একটা অংশও একটা range

Algorithm যেকোনো দুইটা iterator নেয় বলে তুমি তাকে container-এর একটা অংশও দিতে পারো। v.begin() + 3 মানে "তিন ঘর ভেতরে", তাই [v.begin(), v.begin() + 3) হলো প্রথম তিনটা element।

Example 6: শুধু প্রথম তিনটা sort করা
#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v = {90, 72, 61, 45, 10};
    std::sort(v.begin(), v.begin() + 3);
    std::cout << "first three sorted:";
    for (int x : v) std::cout << ' ' << x;
    std::cout << '\n';

    std::vector<int> empty;
    std::sort(empty.begin(), empty.end());
    std::cout << "empty: begin == end is " << (empty.begin() == empty.end()) << '\n';
}
first three sorted: 61 72 90 45 10
empty: begin == end is 1

শুধু 90, 72 আর 61 জায়গা বদলেছে; 45 আর 10 range-এর বাইরে ছিল, তাই যেখানে ছিল সেখানেই আছে। আর ফাঁকা vector sort করা নিরাপদ: range-এ কোনো element নেই, তাই করার কিছুই নেই।

Compiler-এ চালাও

এখান থেকেই আসে সেই নিয়ম, যেটা এই পুরো track ধরে রাখে: algorithm কখনো জানে না সে কোন container-এর উপর দিয়ে হাঁটছে। সে জানে দুইটা iterator, আর কাজটা।

এটা কোথায় কাজে লাগছে

  • Algorithm call করে এমন প্রতিটা C++ program. std::sort(v.begin(), v.end()) চেহারাটা তুমি দেখবে LLVM-এ, Chromium-এ, আর প্রতিটা contest solution-এ। একটা range পড়তে পারলে সবগুলোই পড়তে পারবে।
  • C-র qsort, তুলনার জন্য. qsort শুধু পাশাপাশি জায়গায় থাকা (contiguous) array-তে কাজ করে, আর তুলনা করে void pointer দিয়ে, যেগুলো তোমাকে হাতে cast করতে হয়। Linked list সে sort করতেই পারে না। Iterator আছে বলেই একটা C++ algorithm এমন জায়গায় যেতে পারে, যেখানে qsort পারে না।
  • Java-র দুইটা sort. Java-তে array-র জন্য আছে Arrays.sort, আর list-এর জন্য Collections.sort, একই কাজের দুইটা দরজা। STL-এর একটাই std::sort দুই চেহারাতেই কাজ করে, কারণ সে container না, iterator নেয়।
  • Rust-এর iterator. Rust-এর standard library-ও তার loop আর অনেক algorithm iterator-এর উপর বানিয়েছে। ধারণা একই, "এমন কিছু, যেটা পরের element দেয়", শুধু ভাষাটা নতুন।

যে ভুলগুলো সবাই করে

১. end() পড়ে ফেলা।

for (auto it = v.begin(); it <= v.end(); ++it)
    std::cout << *it << ' ';

GCC 12 এটা একটা কথাও না বলে compile করে, Playground-এর flag-এ তো বটেই, -Wall -Wextra দিয়েও। Loop শেষবার ঘোরার সময় পড়ে *v.end(), মানে সেই ফাঁকা ঘর, আর সেটা undefined behaviour: হয়তো একটা আজেবাজে সংখ্যা ছাপবে, হয়তো crash করবে, হয়তো আজ ঠিকঠাক মনে হবে আর কাল ভেঙে পড়বে। এটা Bob-এর সেই off-by-one, iterator-এর চেহারায়। শর্তটা সব সময় it != v.end()।

২. std::sort দিয়ে একটা list sort করা।

std::list<int> l = {3, 1, 2};
std::sort(l.begin(), l.end());

GCC 12 library-র ভেতর থেকে লম্বা একটা message ছাপে। দরকারি লাইনটা হলো error: no match for 'operator-' (operand types are 'std::_List_iterator<int>' and 'std::_List_iterator<int>')। std::sort-কে range-এর এদিক ওদিক লাফাতে হয় আর iterator বিয়োগ করতে হয়, অথচ list-এর iterator একবারে শুধু এক node এগোতে পারে। List বরং নিজেই নিজেকে sort করে: l.sort();। কেন, সেটা Module 5 আর Module 11 বুঝিয়ে বলবে।

৩. দুইটা container-এর iterator মিশিয়ে ফেলা।

std::vector<int> a = {3, 1, 2};
std::vector<int> b = {9, 8};
std::sort(a.begin(), b.end());

এটা কোনো message ছাড়াই compile হয়: দুইটাই vector-এর iterator, তাই type মিলে যায়। কিন্তু a.begin() থেকে b.end() কোনো range না, আর sort হাঁটতে হাঁটতে এমন memory-তে ঢুকে পড়ে, যেটা তার না। একটা range-এর দুই মাথা সব সময় একই container থেকে আসে। কোনো লাইন copy করে একটা নাম বদলালে, দুই মাথাই মিলিয়ে দেখো।

মাথা খাটাও

এই lesson-এর প্রতিটা example v.end()-এর সঙ্গে তুলনা করে, কিন্তু কোনোটাই কখনো *v.end() লেখে না। std::vector<int> v = {72, 45, 90, 61};-এর বেলায় *v.end() তোমাকে কী দেবে, আর এই lesson কেন ওটা কখনো লেখে না?

ছবি 1 দেখো, আর ভাবো ভাঙা দাগের বাক্সটায় কী আছে। তারপর ভাবো, চার element-এর array-র marks[4] পড়ার অনুমতি C standard তোমাকে দিয়েছিল কি না।

অনুশীলন ১সহজ

Example 4 Playground-এ খোলো, আর vector-টা বদলে একটা std::string বানাও, যেটা ধরে রাখে "banana"। find-কে দিয়ে 'n' খোঁজাও, count-কে দিয়ে 'a' গোনাও, আর শেষে loop-এর বদলে string-টা ছাপাও।

নিজে যাচাই করো। বদলাতে হবে শুধু declaration, মান দুইটা আর ছাপানোর অংশ। Algorithm call-গুলো একই থাকে। Sort করে উল্টানো string-টা হবে nnbaaa।

অনুশীলন ২মাঝারি

Example 6-কে নমুনা ধরে {90, 72, 61, 45, 10}-এর শুধু শেষ তিনটা element sort করো। চালানোর আগে তোমার range-এর দুইটা iterator লিখে ফেলো।

নিয়ম। Range-এর এক মাথার জন্য v.end() ব্যবহার করো। সামনে থেকে গুনবে না।

নিজে যাচাই করো। Output হবে 90 72 10 45 61। তোমার range হলো [v.end() - 3, v.end()), আর এতে আছে end() - (end() - 3), মানে 3টা element।

অনুশীলন ৩কঠিন

Zara জানতে চায়, ফাঁকা vector-এ std::find কী ফেরত দেয়। কিছু না চালিয়ে লিখে ফেলো এটা কী ফেরত দেয়, আর তার code-এ সেটা কীভাবে পরীক্ষা করা উচিত। তারপর বুঝিয়ে বলো, "ফাঁকা কি না" সেই পরীক্ষা আলাদা করে কেন লাগে না।

নিয়ম। উত্তরে "half-open" আর "end()" শব্দ দুইটা ব্যবহার করো।

নিজে যাচাই করো। ফাঁকা vector-এ begin() == end(), তাই find কোনো element-ই দেখে না, আর ফেরত দেয় end(), মানে "পাওয়া যায়নি"। সাধারণ পরীক্ষাটা, it != v.end(), false হয়, আর সেটাই ঠিক উত্তর। তারপর Playground-এ চালিয়ে মিলিয়ে নাও।

যে প্রশ্নগুলো সবার মনে আসে

  • Iterator কি শুধুই একটা pointer?

    Pointer এক রকমের iterator, আর vector-এর iterator প্রায় হুবহু pointer-এর মতোই আচরণ করে। অন্য container-এর আরো চালাক iterator লাগে: list-এর iterator link ধরে এগোয়, map-এর iterator একটা tree-র উপর দিয়ে সাজানো ক্রমে হাঁটে। তোমার চোখে সবগুলো দেখতে একই রকম, আর আসল কথা ওটাই।

  • শেষ element না হলে এর নাম end() কেন?

    এটাকে ভাবো "range-এর শেষ" হিসেবে, দৌড়ের finish line-এর মতো, "সবার শেষের দৌড়বিদ" হিসেবে না। Vector-এ শেষ element থাকে end() - 1-এ, অথবা পাবে v.back() দিয়ে।

  • Range-for-এর বদলে iterator loop কখন লিখব?

    যখন অবস্থানটাই তোমার দরকার: element-টা মুছতে, কোথায় আছ মনে রাখতে, বা range-এর একটা অংশ কোনো algorithm-কে দিতে। শুধু প্রতিটা element পড়ার জন্য range-for for (int x : v) ছোট, আর ভেতরে ভেতরে তোমার হয়ে একই iterator দিয়েই হাঁটে।

  • Iterator কি অচল হয়ে যেতে পারে?

    হ্যাঁ। একটা vector বড় হয়ে তার element-গুলো নতুন memory-তে সরিয়ে নিলে, পুরনো iterator-গুলো পুরনো জায়গাতেই আঙুল রেখে বসে থাকে, আর ওগুলো আর ব্যবহার করা যায় না। এটাকে বলে invalidation। কখন এটা হয়, সেটা reference-এ কোথায় লেখা আছে Lesson 5 দেখাবে, আর Module 11 এটা চলন্ত অবস্থায় দেখাবে।

মূল কথা

  • Iterator একটা element-এ আঙুল রাখে। সেটা পড়তে পারে, পরেরটায় যেতে পারে, আর অন্য iterator-এর সঙ্গে তুলনা করতে পারে।
  • begin() আঙুল রাখে প্রথম element-এ; end() আঙুল রাখে শেষটার এক ঘর পরে, আর ওটা কখনো পড়া হয় না।
  • Range হলো half-open, [begin, end): element-এর সংখ্যা end - begin, আর ফাঁকা range-এ begin == end।
  • Algorithm নেয় দুইটা iterator, কখনো container না, তাই একই লাইন vector, deque বা C array, সবখানেই কাজ করে।
  • std::sort দিয়ে list sort করা যায় না, কারণ তার iterator একবারে শুধু এক node এগোতে পারে।

পরের lesson-এ তুমি সেই reference খুলবে, যেটা প্রতিটা C++ programmer খুলে রাখে। শিখবে, এখানকার প্রতিটা operation-এর জন্য ওটা যে খরচের কথা দেয়, সেটা কীভাবে পড়তে হয়।

lesson ৪ শেষ

শেষ হলে চিহ্ন দিন, অগ্রগতি আপনার সাথে থাকবে।

পরেরটা: cppreference পড়ব কীভাবে, আর O(log n) মানে আসলে কী promise