এই পর্বে যা শিখবেন
- অ্যারে কেন দরকার, আর মেমরিতে এটি ঠিক কীভাবে বসে থাকে
- যেকোনো ঘরের ঠিকানা হাতে হিসাব করা — আর সেখান থেকে বোঝা, গোনা কেন 0 থেকে
- সীমার বাইরে লেখা-পড়া কেন এত বিপজ্জনক, আর কীভাবে ধরবেন
- লুপ দিয়ে যোগফল, সর্বোচ্চ মান, খোঁজা ও উল্টানোর ছাঁচ; 2D array-র বিন্যাস
- AI-এর লেখা অ্যারের কোডে সীমা আর দৈর্ঘ্যের ভুল ধরার চেকলিস্ট
১ · ১০০ জন ছাত্রের নম্বর — ১০০টি ভ্যারিয়েবল?
ধরুন আপনার কলেজের একটি সেকশনে ১০০ জন ছাত্র। বোর্ড পরীক্ষার আগে টেস্ট পরীক্ষার নম্বর নিয়ে একটি প্রোগ্রাম লিখবেন — গড় কত, সর্বোচ্চ কে পেল, কতজন গড়ের ওপরে। এ পর্যন্ত যা শিখেছেন, তা দিয়ে শুরু করলে লিখতে হবে:
int mark1, mark2, mark3, mark4, mark5, /* … */ mark100;
total = mark1 + mark2 + mark3 + /* … আরও ৯৭টি … */ + mark100;
এটি শুধু বিরক্তিকর নয়, অবাস্তবও। কাল ছাত্র ১২০ জন হলে পুরো কোড নতুন করে লিখতে হবে। আর সবচেয়ে বড় সমস্যা: পর্ব ১২-এর লুপ দিয়ে mark1, mark2… একটির পর একটি ঘোরা যায় না, কারণ এগুলো ১০০টি আলাদা নাম — compiler-এর কাছে এদের মধ্যে কোনো সম্পর্ক নেই।
সমাধান হলো array (বাংলায় অনেকে বলেন “অ্যারে”) — একটি নাম, তার নিচে একই মাপের অনেকগুলো ঘর, একটির ঠিক পাশে আরেকটি। প্রতিটি ঘরকে বলে element, আর ঘরটি কত নম্বর, সেটিকে বলে index.
int marks[100]; /* ১০০টি int-এর ঘর, এক নামে */
marks[0] = 72; /* প্রথম ঘর */
marks[99] = 58; /* শেষ ঘর */
for (int i = 0; i < 100; i++) total += marks[i];
মিরপুর বা উত্তরার একটি রাস্তার কথা ভাবুন, যেখানে বাড়িগুলোর নম্বর পরপর — ১, ২, ৩… সব প্লট একই মাপের। কাউকে “রোড ৭-এর ১২ নম্বর বাড়ি” বললে তিনি রাস্তার মোড় থেকে গুনে গুনে সোজা পৌঁছে যান — প্রতিটি বাড়ির জন্য আলাদা ঠিকানা মুখস্থ রাখতে হয় না। Array ঠিক এমন একটি রাস্তা: রাস্তার নাম (marks) একটি, বাড়িগুলো (ঘর) পাশাপাশি, আর নম্বর (index) দিয়েই যেকোনো বাড়িতে পৌঁছানো যায়।
আসলে আপনি অ্যারে আগেই দেখেছেন। পর্ব ১৫-এ Fibonacci-র জন্য যে memo টেবিলে উত্তর লিখে রাখছিলাম — memo[2], memo[3]… — সেটি ছিল একটি অ্যারে। আর পর্ব ০২-এ পাঁচজন ছাত্রের নম্বরের তালিকা থেকে সর্বোচ্চ খোঁজার কাজটিও অ্যারের ওপরই হয়েছিল। এই পর্বে দেখব পর্দার পেছনে আসলে কী ঘটে।
Array-র তিনটি নিয়ম, এগুলোই সব কিছুর ভিত্তি: (১) সব ঘর একই টাইপের, তাই একই মাপের। (২) ঘরগুলো মেমরিতে ফাঁক ছাড়া পাশাপাশি থাকে। (৩) ঘরের সংখ্যা তৈরির সময়েই ঠিক হয়ে যায় — পরে বাড়ানো-কমানো যায় না। প্রথম দুটি নিয়ম অ্যারেকে খুব দ্রুত বানায়; তৃতীয়টি এর সবচেয়ে বড় সীমাবদ্ধতা।
২ · মেমরিতে অ্যারে — পাশাপাশি ঘর আর একটি ছোট সূত্র
পর্ব ০৬-এ দেখেছিলেন, প্রতিটি ভ্যারিয়েবল মেমরির কোনো একটি ঠিকানায় থাকে, আর int সাধারণত ৪ byte জায়গা নেয়। এখন int marks[5]; লিখলে compiler মেমরিতে একটানা ৫ × ৪ = ২০ বাইট জায়গা আলাদা করে রাখে — মাঝে কোনো ফাঁক নেই। একে বলে contiguous memory, অর্থাৎ “গায়ে গা লাগানো” জায়গা।
প্রথম ঘরটির ঠিকানাকে বলে base address. বাকি সব ঘরের ঠিকানা এখান থেকেই হিসাব করা যায়। কারণ ঘরগুলো সমান মাপের আর পাশাপাশি, তাই i নম্বর ঘরটি শুরু থেকে ঠিক i টি ঘর দূরে। নিচের ডায়াগ্রামে সহজ করার জন্য base address 1000 ধরা হলো (আসল ঠিকানা অনেক বড় সংখ্যা হয়)। “পরে” চাপুন আর প্রতিটি ধাপে হিসাবটি খেয়াল করুন:
iঠিকানা ঘর মান01000 marks[0] 7211004 marks[1] 8521008 marks[2] 6431012 marks[3] 9041016 marks[4] 5851020 ???? অ্যারের বাইরে
| index i | |
| হিসাব | |
| ঠিকানা | |
| পাওয়া মান |
- ঘোষণা।
int marks[5] = {72, 85, 64, 90, 58};লিখতেই ২০ বাইটের একটানা জায়গা তৈরি হলো, শুরু 1000 থেকে। পাঁচটি ঘর পাশাপাশি — প্রতিটি ঠিক ৪ বাইট পরপর। compiler শুধু মনে রাখে দুটি তথ্য: শুরুর ঠিকানা 1000, আর ঘরের মাপ ৪. marks[0]চাওয়া হলো। CPU হিসাব করে 1000 + 0 × 4 = 1000. প্রথম ঘরটি শুরু থেকে শূন্য ঘর দূরে — তাই তার index 0. সেখান থেকে ৪ বাইট পড়ে পাওয়া গেল 72.marks[1]. 1000 + 1 × 4 = 1004. শুরু থেকে এক ঘর দূরে। মান 85.marks[3]— সরাসরি লাফ। 1000 + 3 × 4 = 1012. খেয়াল করুন, CPU-কে marks[1] আর marks[2] পার হয়ে আসতে হয়নি — একটি গুণ আর একটি যোগ, ব্যস। ঘর ৩ হোক বা ৩০ লাখ, সময় একই। এটাই অ্যারের সবচেয়ে বড় শক্তি।marks[4]— শেষ ঘর। 1000 + 4 × 4 = 1016, মান 58. পাঁচটি ঘরের শেষটির index ৪, অর্থাৎ n − 1. বৈধ index সবসময় 0 থেকে n − 1 পর্যন্ত।marks[5]চাইলে? সূত্র তো একই: 1000 + 5 × 4 = 1020. CPU নির্দ্বিধায় 1020 থেকে ৪ বাইট পড়বে। কিন্তু 1020 অ্যারের অংশই নয় — সেখানে অন্য কিছু আছে। C কোনো আপত্তি করে না। এর পরিণাম কী হতে পারে, অংশ ৪-এ দেখব।
এই একটি লাইনই অ্যারের পুরো গল্প। sizeof এখানে ঘরের মাপ — int হলে সাধারণত ৪, double হলে ৮, char হলে ১. আপনি লেখেন marks[3], compiler সেটিকে এই গুণ-যোগের নির্দেশে বদলে দেয়। আপনাকে কখনো বাইট গুনতে হয় না, কিন্তু ভেতরে কী হচ্ছে জানলে পরের সব কিছু — pointer, string, cache — সহজ হয়ে যাবে।
গোনা কেন 0 থেকে
পর্ব ১২-এ কথা দিয়েছিলাম, এই পর্বে এর কারণ পরিষ্কার হবে। সূত্রটি আবার দেখুন: index আসলে “শুরু থেকে কত ঘর দূরে” — ইংরেজিতে offset. প্রথম ঘরটি শুরু থেকে শূন্য ঘর দূরে, তাই তার index 0. যদি 1 থেকে গোনা হতো, তাহলে প্রতিবার ঠিকানা বের করতে লাগত base + (i − 1) × 4 — প্রতিটি ঘর ছোঁয়ার আগে একটি বাড়তি বিয়োগ। ১৯৭০-এর দশকের ধীর কম্পিউটারে এটা ছিল অপচয়।
ঢাকার অনেক লিফটে বোতাম থাকে G, 1, 2, 3… নিচতলাটি “G” — মাটি থেকে শূন্য তলা উপরে। “৩” মানে মাটি থেকে তিন তলা উপরে। Index ঠিক এই রকম: এটি ঘরের ক্রমিক নাম নয়, শুরু থেকে দূরত্ব।
আরেকটি সুবিধা আছে, যা পর্ব ১২-এ half-open range নামে দেখেছেন: n টি ঘরের বৈধ index হলো 0 ≤ i < n. লুপের শর্ত i < n, ঘরের সংখ্যা n − 0 = n — কোনো বাড়তি ±1 নেই। Edsger Dijkstra ১৯৮২ সালের নোটে ঠিক এই যুক্তিই দিয়েছিলেন। তবে মনে রাখুন, এটি একটি নকশার সিদ্ধান্ত, প্রকৃতির নিয়ম নয় — Fortran, MATLAB ও Lua-তে গোনা 1 থেকে।
৩ · ঘোষণা, শুরুর মান আর দৈর্ঘ্য মাপা
অ্যারে ঘোষণা করতে লাগে তিনটি জিনিস: টাইপ, নাম, আর তৃতীয় বন্ধনীতে ঘরের সংখ্যা। শুরুর মান দিতে চাইলে দ্বিতীয় বন্ধনীতে কমা দিয়ে লিখুন। কয়েকটি নিয়ম একসাথে দেখে নিই:
| যা লিখলেন | মেমরিতে যা থাকে | মনে রাখার কথা |
|---|---|---|
int a[5] = {4, 8, 15, 16, 23}; | 4 8 15 16 23 | পাঁচটি ঘর, পাঁচটি মান — পুরো initialization |
int a[5] = {4, 8}; | 4 8 0 0 0 | কিছু মান দিলে বাকি ঘর নিজে থেকেই 0 হয় |
int a[5] = {0}; | 0 0 0 0 0 | সব ঘর শূন্য করার প্রচলিত ছোট উপায় |
int a[] = {4, 8, 15}; | 4 8 15 | সংখ্যা না লিখলে compiler মান গুনে মাপ ঠিক করে (এখানে ৩) |
int a[5]; (ফাংশনের ভেতরে) | ? ? ? ? ? | কোনো মান দেওয়া হয়নি — প্রতিটি ঘরে garbage value |
int a[5]; (সব ফাংশনের বাইরে) | 0 0 0 0 0 | global অ্যারে নিজে থেকেই শূন্য দিয়ে শুরু হয় |
দ্বিতীয় আর পঞ্চম সারির পার্থক্যটিই সবচেয়ে বেশি ভুল ঘটায়। {4, 8} লিখলে বাকি তিনটি ঘর নিশ্চিতভাবে 0. কিন্তু কিছুই না লিখলে ফাংশনের ভেতরের অ্যারেতে আগের কোনো কাজের বাসি মান পড়ে থাকে — পর্ব ০৬-এর garbage value. যোগফল জমানোর অ্যারে বানালে তাই সবসময় = {0} দিয়ে শুরু করুন।
অ্যারের দৈর্ঘ্য — sizeof দিয়ে মাপা
C-র অ্যারে নিজের দৈর্ঘ্য কোথাও লিখে রাখে না — মেমরিতে শুধু ঘরগুলোই থাকে। তবে যেখানে অ্যারেটি ঘোষণা করা হয়েছে, সেখানে compiler তার পুরো মাপ জানে। তাই এই কৌশল:
int marks[] = {72, 85, 64, 90, 58, 77};
int n = sizeof(marks) / sizeof(marks[0]); /* 24 / 4 = 6 */
sizeof(marks) হলো পুরো অ্যারের বাইট (৬ × ৪ = ২৪), আর sizeof(marks[0]) একটি ঘরের বাইট (৪). ভাগ করলে ঘরের সংখ্যা। সুবিধা হলো, পরে তালিকায় একটি নম্বর যোগ করলে n নিজে থেকেই ঠিক হয়ে যায় — কোথাও “6” বদলাতে ভুলে যাওয়ার সুযোগ নেই। তবে সাবধান: এই কৌশল কাজ করে শুধু যেখানে অ্যারেটি ঘোষণা করা হয়েছে। ফাংশনে পাঠানোর পর এটি চুপচাপ ভুল উত্তর দেয় — অংশ ৬-এ দেখবেন কেন।
আরও দুটি নিয়ম। এক, অ্যারে একবারে কপি করা যায় না: b = a; লিখলে compile-ই হবে না; লুপ দিয়ে ঘর ধরে ধরে কপি করতে হয়। দুই, a == b দুটি অ্যারের মান তুলনা করে না, তুলনা করে তাদের ঠিকানা — তাই প্রায় সবসময় মিথ্যা আসে। দুটির কারণই এক: অ্যারের নাম লিখলে C সাধারণত তার base address বোঝে। পর্ব ১৭-১৮-তে এর পুরো ব্যাখ্যা আসবে।
ঘরের সংখ্যা কী দিয়ে লিখবেন? প্রচলিত ও নিরাপদ পথ হলো একটি নির্দিষ্ট সংখ্যা, যেমন int marks[100];. C99 থেকে ভ্যারিয়েবল দিয়েও মাপ দেওয়া যায় (int marks[n];), কিন্তু বড় n হলে ফাংশনের সীমিত জায়গা (stack) ভরে যেতে পারে, আর C11-এ এই সুবিধাটি ঐচ্ছিক করা হয়েছে — সব compiler-এ নাও থাকতে পারে। চলার সময় মাপ ঠিক হলে সঠিক পথ dynamic memory, যা পর্ব ২০-এ আসবে।
৪ · সীমার বাইরে — যেখানে C পাহারা দেয় না
প্রথম ডায়াগ্রামের শেষ ধাপে দেখেছেন, marks[5] চাইলে CPU সূত্র মেনে 1020 ঠিকানায় চলে যায়। প্রশ্ন হলো, C কি একবারও যাচাই করে না যে 5 < 5 কি না? উত্তর: না। অ্যারের সীমার বাইরে পড়া বা লেখাকে বলে out-of-bounds access, আর C-র নিয়মে এর ফল undefined behavior — অর্থাৎ যা খুশি ঘটতে পারে, আর compiler কোনো প্রতিশ্রুতি দেয় না।
“যা খুশি” কথাটি শুনতে অস্পষ্ট লাগে। তাই একটি সম্ভাব্য ঘটনা ধাপে ধাপে দেখি। মনে রাখবেন, এটি যা ঘটতে পারে, তার একটি উদাহরণ — ভ্যারিয়েবলগুলো মেমরিতে কোন ক্রমে বসবে, তা compiler-এর ইচ্ছা, আর আপনার কম্পিউটারে হুবহু এমন না-ও ঘটতে পারে।
1int bonus = 10;2int marks[5] = {72, 85, 64, 90, 58};34marks[5] = 99; // সীমার বাইরে!5printf("bonus = %d\n", bonus);
| 1000 | marks[0] | |
| 1004 | marks[1] | |
| 1008 | marks[2] | |
| 1012 | marks[3] | |
| 1016 | marks[4] | |
| 1020 | bonus |
- দুটি ভ্যারিয়েবল, পাশাপাশি। ধরা যাক compiler
marks-কে 1000-1019 ঠিকানায় আরbonus-কে ঠিক তার পরে, 1020-তে বসিয়েছে। এমন হওয়া খুবই সম্ভব — compiler জায়গা বাঁচাতে ভ্যারিয়েবলগুলো গায়ে গায়ে রাখে।bonus-এ আছে 10. marks[5]-এর ঠিকানা হিসাব। লাইন ৪-এ CPU সেই চেনা সূত্র চালায়: 1000 + 5 × 4 = 1020. কোথাও যাচাই নেই “৫ কি বৈধ index?” — C-র অ্যারে নিজের দৈর্ঘ্যই জানে না, যাচাই করবে কীভাবে? আর 1020 ঠিকানাটিbonus-এর ঘর।- 99 লেখা হলো — ভুল ঘরে।
bonusএখন 99. কোনো error নেই, warning নেই, crash নেই। প্রোগ্রাম দিব্যি চলছে। কেউbonus-কে ছোঁয়নি, অথচ তার মান বদলে গেছে। - ভুলটি ধরা পড়ল অন্য জায়গায়। লাইন ৫ ছাপল
bonus = 99. কল্পনা করুন এটি ৫০০০ লাইনের প্রোগ্রাম — আপনিbonus-এর হিসাবের কোড ঘণ্টার পর ঘণ্টা খুঁজবেন, অথচ আসল ভুল একেবারে অন্য ফাংশনে, একটি<=-এ। এই কারণেই মেমরির বাগ সবচেয়ে কঠিন বাগ। - অন্য দিনে অন্য ফল। একই কোড অন্য compiler-এ বা
-O2দিয়ে বানালে হয়তো 1020-তে অন্য কিছু থাকবে, কিছুই চোখে পড়বে না — বাগটি লুকিয়ে থাকবে মাসের পর মাস। আবার ঠিকানাটি প্রোগ্রামের নিজের জায়গার বাইরে হলে OS প্রোগ্রাম বন্ধ করে দেবে (Segmentation fault). তিনটিই undefined behavior-এর চেহারা। নিরাপদ পথ একটাই: সীমার বাইরে কখনো যাবেন না।
বাস্তবে কেউ সরাসরি marks[5] = 99 লেখে না। ভুলটি আসে লুকিয়ে — for (i = 0; i <= 5; i++), যেখানে <-এর জায়গায় <=. পর্ব ১২-এর off-by-one ভুল অ্যারের সাথে মিললে সেটিই হয়ে যায় মেমরি নষ্ট করার ভুল।
কেন এটি নিরাপত্তার সমস্যা
পাশের ভ্যারিয়েবল নষ্ট হওয়া তো শুধু শুরু। সীমার বাইরে লেখাকে বলে buffer overflow, আর সীমার বাইরে পড়াকে বলে buffer over-read. দুটিই আক্রমণকারীর হাতে বড় অস্ত্র। লেখার বেলায় আক্রমণকারী ইচ্ছা করে লম্বা ইনপুট পাঠিয়ে পাশের গুরুত্বপূর্ণ তথ্য — এমনকি ফাংশন শেষে কোথায় ফিরবে সেই ঠিকানা (পর্ব ১৩-এর return address) — বদলে দিতে পারে। পড়ার বেলায় প্রোগ্রাম ভুল করে পাশের গোপন তথ্য বাইরে পাঠিয়ে দিতে পারে।
২০১৪ সালের এপ্রিলে ধরা পড়ে Heartbleed — পর্ব ০৩-এ নামটি শুনেছিলেন। ত্রুটিটি ছিল OpenSSL-এ, যে library দিয়ে তখন ইন্টারনেটের বিপুল সংখ্যক সার্ভার HTTPS সংযোগ নিরাপদ রাখত। “Heartbeat” নামের একটি ছোট সুবিধায় অপর পক্ষ একটি বার্তা পাঠিয়ে বলত, “এটি আমাকে ফেরত দাও, এর দৈর্ঘ্য এত বাইট।” সার্ভার সেই দৈর্ঘ্য যাচাই না করেই বিশ্বাস করত। আক্রমণকারী মাত্র কয়েক বাইটের বার্তা পাঠিয়ে দাবি করত “দৈর্ঘ্য প্রায় ৬৪ কিলোবাইট” — আর সার্ভার বার্তার পাশের মেমরি থেকে ততটুকুই পড়ে ফেরত পাঠাত। সেই মেমরিতে থাকতে পারত অন্য ব্যবহারকারীর পাসওয়ার্ড, session-এর তথ্য, এমনকি সার্ভারের গোপন চাবি। পুরো ঘটনাটি একটি buffer over-read — একটি অনুপস্থিত সীমা-যাচাই।
কীভাবে ধরবেন
Compiler অনেক সময় স্থির index-এর ভুল (যেমন marks[5]) ধরে warning দেয়, কিন্তু লুপের ভেতরের ভুল সাধারণত পারে না। সবচেয়ে কার্যকর অস্ত্র পর্ব ০৩-এ পরিচিত হওয়া sanitizer:
gcc -Wall -Wextra -g -fsanitize=address marks.c -o marksএভাবে বানানো প্রোগ্রাম চলার সময় প্রতিটি অ্যারের দুই পাশে “পাহারাদার” জায়গা রাখে। কেউ সেখানে ছুঁলেই প্রোগ্রাম থেমে যায় এবং stack-buffer-overflow লিখে ঠিক কোন লাইনে ঘটেছে দেখিয়ে দেয়। প্রোগ্রাম কিছুটা ধীর হয়, তাই এটি পরীক্ষার সময়ের জন্য, চূড়ান্ত সংস্করণের জন্য নয়।
৫ · লুপ দিয়ে পুরো অ্যারে ঘোরা — চারটি ছাঁচ
অ্যারে আর লুপ যেন একই জোড়ার দুই অংশ। লুপের গণনা-ভ্যারিয়েবল i একে একে 0, 1, 2… হয়, আর a[i] একে একে প্রতিটি ঘর ছুঁয়ে যায়। অ্যারের প্রায় সব কাজ চারটি পরিচিত ছাঁচের কোনো একটি:
জমানো (sum, count)
একটি accumulator শূন্য দিয়ে শুরু, প্রতিটি ঘরের মান তাতে যোগ। গড়, মোট, “কতজন পাস” — সবই এই ছাঁচ।
sum += a[i];সেরা খোঁজা (max, min)
প্রথম ঘরকে “এখন পর্যন্ত সেরা” ধরুন, বাকিদের সাথে তুলনা করুন। শূন্য দিয়ে শুরু করবেন না — সব মান ঋণাত্মক হলে ভুল উত্তর আসবে।
best = a[0];খোঁজা (search)
পর্ব ০২-এর linear search: শুরু থেকে একটি একটি করে মিলিয়ে দেখা, পেলেই থামা। না পেলে সাধারণত −1 ফেরত দেওয়া হয়, কারণ −1 কখনো বৈধ index নয়।
if (a[i] == key) return i;জায়গায় বদলানো (reverse)
দুই মাথা থেকে দুটি index এগিয়ে আসে, মান অদলবদল করে, মাঝে মিললে থামে। নতুন অ্যারে লাগে না — একে বলে in-place.
i++, j--চতুর্থ ছাঁচটি সবচেয়ে মজার, আর এতেই off-by-one ভুল সবচেয়ে বেশি হয়। “পরে” চাপুন — দুই প্রান্ত থেকে দুটি index কীভাবে মাঝখানে এসে মেলে, দেখুন:
1int a[5] = {10, 20, 30, 40, 50};2int i = 0, j = 4; // j = n - 13while (i < j) {4 int tmp = a[i];5 a[i] = a[j];6 a[j] = tmp;7 i++; j--;8}
| a[0] | |
| a[1] | |
| a[2] | |
| a[3] | |
| a[4] | |
| i | |
| j |
- শুরু।
iবসল প্রথম ঘরে (0),jশেষ ঘরে (4). খেয়াল করুন, শেষ ঘর হলো n − 1 = 4, n = 5 নয় —j = 5লিখলে প্রথম অদলবদলেই সীমার বাইরে চলে যেতাম। - প্রথম অদলবদল। শর্ত 0 < 4 সত্য।
tmp-এ 10 তুলে রাখা হলো, a[0]-এ বসল 50, তারপর a[4]-এ বসল tmp-এর 10. দুই প্রান্ত বদলে গেল।tmpনা রাখলে 10 হারিয়ে যেত — পানির দুই গ্লাস অদলবদলে যেমন তৃতীয় একটি খালি গ্লাস লাগে। - দুজনই এক ঘর ভেতরে।
iহলো 1,jহলো 3. - দ্বিতীয় অদলবদল। 1 < 3 সত্য। a[1] আর a[3] বদলাল: এখন 50 40 30 20 10.
- মাঝখানে দেখা হলো।
i= 2,j= 2 — দুজনেই মাঝের ঘরে। - থামা। 2 < 2 মিথ্যা, লুপ শেষ। মাঝের 30-কে নিজের সাথে বদলানোর দরকার নেই। পাঁচটি ঘরে মাত্র দুটি অদলবদল — n টি ঘরে n/2 টি। শর্তটি
i < j-এর বদলেi < nহলে কী হতো? সব আবার উল্টে আগের মতো হয়ে যেত! নিজে হাতে মিলিয়ে দেখুন।
এবার প্রথম তিনটি ছাঁচ একসাথে। নিচের প্রোগ্রাম ছয়জন ছাত্রের নম্বর থেকে মোট, গড়, সর্বোচ্চ আর গড়ের ওপরে কতজন — চারটি তথ্য বের করে। চালিয়ে দেখুন, তারপর হাতে হিসাব মিলিয়ে নিন:
#include <stdio.h>
int main(void) {
int marks[] = {72, 85, 64, 90, 58, 77};
int n = sizeof(marks) / sizeof(marks[0]);
int sum = 0, best = marks[0], above = 0;
for (int i = 0; i < n; i++) {
sum += marks[i];
if (marks[i] > best) best = marks[i];
}
double avg = (double)sum / n;
for (int i = 0; i < n; i++)
if (marks[i] > avg) above++;
printf("students = %d, total = %d\n", n, sum);
printf("average = %.2f, highest = %d\n", avg, best);
printf("above average: %d\n", above);
return 0;
}ছয়টি নম্বরের যোগফল 446, গড় 446 / 6 ≈ 74.33, সর্বোচ্চ 90, আর গড়ের ওপরে আছেন তিনজন (85, 90, 77). তিনটি বিষয় খেয়াল করুন। এক, n হিসাব হয়েছে sizeof দিয়ে — তালিকায় সপ্তম নম্বর যোগ করলে আর কিছু বদলাতে হবে না। দুই, best শুরু হয়েছে marks[0] থেকে, 0 থেকে নয়। তিন, “গড়ের ওপরে কতজন” জানতে অ্যারেটি দুবার ঘুরতে হয়েছে — প্রথমবার গড় জানতে, দ্বিতীয়বার তুলনা করতে। নম্বরগুলো আলাদা ভ্যারিয়েবলে না রেখে অ্যারেতে রাখার আসল সুবিধা এখানেই: ডেটা জমা থাকে, যতবার খুশি আবার পড়া যায়।
৬ · ফাংশনে অ্যারে পাঠানো — ঠিকানা যায়, দৈর্ঘ্য হারায়
পর্ব ১৩-এ শিখেছিলেন, C-তে ফাংশন পায় argument-এর একটি কপি — pass by value. ফাংশনের ভেতরে কপি বদলালে আসল ভ্যারিয়েবল বদলায় না। অ্যারের বেলায় নিয়মটি একটু অন্যভাবে কাজ করে, আর এটি না জানলে দুটি বড় চমক অপেক্ষা করছে।
ধরুন ১০ লাখ ঘরের একটি অ্যারে। প্রতিবার ফাংশন ডাকার সময় পুরো ৪০ লাখ বাইট কপি করা হলে প্রোগ্রাম ধীর হয়ে যেত। তাই C পুরো অ্যারে পাঠায় না — পাঠায় শুধু তার base address. ফাংশন জানে “অ্যারেটি কোথা থেকে শুরু”, কিন্তু জানে না “কতদূর পর্যন্ত”। নিচের প্রোগ্রামটি চালিয়ে দুটি চমকই নিজের চোখে দেখুন — এবারে compiler একটি warning দেবে, সেটিও এই শিক্ষার অংশ:
#include <stdio.h>
void add_grace(int a[], int n) {
printf("inside add_grace: sizeof(a) = %zu\n", sizeof(a));
for (int i = 0; i < n; i++)
a[i] += 5; /* grace marks */
}
int main(void) {
int marks[5] = {72, 85, 64, 90, 58};
printf("in main: sizeof(marks) = %zu\n", sizeof(marks));
for (int i = 0; i < 3; i++)
printf("&marks[%d] = %p\n", i, (void *)&marks[i]);
add_grace(marks, 5);
printf("after add_grace: marks[0] = %d\n", marks[0]);
return 0;
}ঠিকানাগুলো দেখুন: & মানে “এর ঠিকানা” (পর্ব ১০-এ scanf-এ দেখেছিলেন, পরের পর্বে বিস্তারিত)। ঠিকানাগুলো hexadecimal-এ লম্বা সংখ্যা, কিন্তু শেষের অংশ দেখুন — প্রতিটি আগেরটির চেয়ে ঠিক ৪ বেশি। Base + i × 4, এবার আসল মেশিনে।
চমক ১ — দৈর্ঘ্য হারাল: main-এ sizeof(marks) = 20, কিন্তু ফাংশনের ভেতরে sizeof(a) = 8 (৬৪-বিট কম্পিউটারে)। এই 8 অ্যারের মাপ নয় — একটি ঠিকানা রাখতে যত বাইট লাগে, তার মাপ। Compiler-ও সাবধান করেছে: 'sizeof' on array function parameter 'a' will return size of 'int *'. তাই ফাংশনের ভেতরে sizeof(a) / sizeof(a[0]) লিখলে ৫ নয়, ২ পাবেন — নিঃশব্দ ভুল।
চমক ২ — কপি নয়, আসল: ফাংশন ঘরগুলোতে ৫ যোগ করেছে, আর main-এ ফিরে marks[0] হয়েছে 77. কারণ ফাংশন ঠিকানা পেয়ে সরাসরি আসল ঘরগুলোতে লিখেছে। কাজের সময় এটি সুবিধা, অসাবধান হলে বিপদ। ফাংশন শুধু পড়বে, বদলাবে না — এমন হলে লিখুন const int a[]; তখন ভুল করে লিখতে গেলে compiler আটকাবে।
অ্যারে নেয় এমন প্রতিটি ফাংশনের সাথে দৈর্ঘ্য আলাদা parameter হিসেবে পাঠান: int find_max(const int a[], int n). C-র standard library-ও এই নিয়ম মানে — পরের দিকের পর্বে দেখবেন fgets, memcpy, qsort সবাই মাপ আলাদা করে চায়। আর ফাংশনের ভেতরে কখনো অ্যারে-parameter-এর ওপর sizeof দিয়ে দৈর্ঘ্য মাপবেন না। কেন অ্যারে এভাবে ঠিকানায় “ভেঙে যায়”, তার পুরো গল্প পর্ব ১৮-তে।
৭ · 2D অ্যারে — সারি আর কলামের টেবিল
পরীক্ষার ফলাফলের খাতা মনে করুন। প্রতিটি সারিতে একজন ছাত্র, প্রতিটি কলামে একটি বিষয়। এমন টেবিল রাখতে C-তে আছে 2D array — দুটি index-এর অ্যারে:
int result[3][4] = { /* ৩ জন ছাত্র × ৪টি বিষয় */
{78, 65, 82, 90}, /* ছাত্র 0: বাংলা, ইংরেজি, গণিত, বিজ্ঞান */
{55, 72, 68, 61}, /* ছাত্র 1 */
{88, 91, 95, 84} /* ছাত্র 2 */
};
result[2][1] /* ছাত্র 2-এর ইংরেজি = 91 */
প্রথম index সারি (কোন ছাত্র), দ্বিতীয় index কলাম (কোন বিষয়)। সব ঘর ঘুরতে লাগে পর্ব ১২-এর nested loop — বাইরের লুপ সারি ধরে, ভেতরের লুপ সেই সারির কলাম ধরে। সিনেমা হলের আসনের নকশা, দাবার বোর্ড, ছবির পিক্সেল — সবই এই আকারের। এখানে শুধু মূল ধারণাটি দেখব; ফাংশনে পাঠানো, heap-এ বানানো আর matrix-এর কাজ বিস্তারিত আসবে পর্ব ২১-এ।
কিন্তু একটি প্রশ্ন: মেমরি তো একটি লম্বা সারি মাত্র — ঠিকানা 1000, 1001, 1002… সেখানে “টেবিল” রাখা যায় কীভাবে? উত্তর: রাখা যায় না। C টেবিলটিকে কেটে সারিগুলো একটির পর একটি বসিয়ে দেয়। একে বলে row-major order. ছোট একটি int t[2][3] = {{1, 2, 3}, {4, 5, 6}}; দিয়ে দেখি (base address 2000 ধরে):
ঠিকানা = 2000 + 5 × 4 = 2020
মেমরিতে পাশাপাশি হাঁটা
প্রতিবার লাফ
- আমরা যা কল্পনা করি। দুটি সারি, প্রতি সারিতে তিনটি কলাম — একটি ছোট টেবিল। কোডে লেখা
{{1, 2, 3}, {4, 5, 6}}ঠিক এই ছবিটিই দেখায়। কিন্তু মেমরিতে “সারি” বা “কলাম” বলে কিছু নেই। - সারি 0 আগে বসে। প্রথম সারির তিনটি ঘর 2000, 2004, 2008 ঠিকানায় — একেবারে সাধারণ অ্যারের মতো পাশাপাশি।
- সারি 1 ঠিক তার পরে। দ্বিতীয় সারি শুরু হয় 2012-তে, প্রথম সারির শেষ ঘরের গা ঘেঁষে। পুরো টেবিলটি আসলে ৬টি int-এর একটি লম্বা, একটানা সারি — ২৪ বাইট।
t[1][2]খোঁজা। সারি 1-এ পৌঁছাতে আগের পুরো একটি সারি (৩টি ঘর) পার হতে হবে, তারপর আরও ২টি ঘর। মোট ঘর নম্বর 1 × 3 + 2 = 5, ঠিকানা 2000 + 5 × 4 = 2020. সাধারণ সূত্র:t[r][c]→ ঘর নম্বর r × কলাম সংখ্যা + c. এজন্যই 2D অ্যারে ফাংশনে পাঠাতে কলাম সংখ্যা জানাতে হয় — সেটি ছাড়া সারির দৈর্ঘ্য অজানা।- দুইভাবে ঘোরা। বাইরের লুপ সারি, ভেতরের লুপ কলাম হলে মেমরিতে ঘর 0, 1, 2, 3… পাশাপাশি হাঁটা হয়। উল্টো করলে (বাইরে কলাম, ভেতরে সারি) প্রতিবার একটি পুরো সারি লাফিয়ে যেতে হয়: 0, 3, 1, 4… উত্তর একই আসবে, কিন্তু বড় টেবিলে প্রথমটি অনেক দ্রুত। কেন? পরের অংশে।
সব ভাষা এই নিয়ম মানে না। Fortran আর MATLAB টেবিল রাখে কলাম ধরে (column-major). তাই এক ভাষা থেকে আরেক ভাষায় ম্যাট্রিক্স পাঠাতে গেলে বা বৈজ্ঞানিক library ব্যবহার করতে গেলে বিন্যাস উল্টে যাওয়ার ভুল খুব পরিচিত।
৮ · মেমরির বিন্যাস কেন গতি ঠিক করে — cache-এর গল্প
একটি অবাক করা সত্য: আধুনিক CPU এত দ্রুত যে মূল মেমরি (RAM) থেকে একটি মান আনতে যে সময় লাগে, ততক্ষণে CPU কয়েকশো হিসাব সেরে ফেলতে পারত। প্রতিবার RAM-এর জন্য অপেক্ষা করলে CPU-র বেশিরভাগ সময় বসে কাটত। সমাধান হলো cache — CPU-র একেবারে গায়ে লাগানো ছোট কিন্তু খুব দ্রুত মেমরি, যেখানে সম্প্রতি ব্যবহৃত ডেটার কপি রাখা হয়।
একটি চায়ের দোকানের কথা ভাবুন। দোকানির হাতের কাছে তাকে কয়েক প্যাকেট বিস্কুট (cache), আর পেছনের গুদামে কয়েক কার্টন (RAM). খদ্দের বিস্কুট চাইলে তাক থেকে দিতে এক সেকেন্ড, গুদাম থেকে আনতে দুই মিনিট। বুদ্ধিমান দোকানি গুদামে গেলে একটি প্যাকেট আনেন না — পুরো একটি বাক্স নিয়ে আসেন, কারণ জানেন পরের খদ্দেররাও সম্ভবত পাশের প্যাকেটগুলোই চাইবেন।
CPU ঠিক এই বুদ্ধিটাই খাটায়। RAM থেকে একটি int চাইলে সে শুধু ৪ বাইট আনে না, তার আশেপাশের একটি পুরো টুকরো আনে — আজকের সাধারণ কম্পিউটারে সাধারণত ৬৪ বাইট, যাকে বলে cache line. ৬৪ বাইটে ১৬টি int ধরে। তাই marks[0] আনতে গেলে marks[1] থেকে marks[15] বিনা খরচে চলে আসে। পরের ১৫ বার RAM-এ যেতেই হয় না। “এখন যেটা ছুঁলাম, তার পাশেরটা সম্ভবত পরে ছোঁব” — এই ধারণার নাম spatial locality.
এবার অংশ ৭-এর শেষ প্রশ্নের উত্তর। ধরুন int big[1000][1000] — প্রতি সারি ৪০০০ বাইট।
সারি ধরে ঘোরা
big[r][0], big[r][1], big[r][2]… মেমরিতে পাশাপাশি। প্রতিটি cache line-এর ১৬টি ঘরই কাজে লাগে। গুদামে যাওয়া প্রতি ১৬টি ঘরে একবার।
কলাম ধরে ঘোরা
big[0][c], big[1][c]… প্রতিটি ৪০০০ বাইট দূরে। আনা cache line থেকে মাত্র একটি ঘর কাজে লাগে, বাকি ১৫টি নষ্ট। প্রায় প্রতিটি ঘরের জন্য নতুন করে গুদামে যাওয়া।
দুটি কোড একই উত্তর দেয়, একই সংখ্যক যোগ করে। তবু বড় টেবিলে দ্বিতীয়টি কয়েক গুণ ধীর হতে পারে — কত গুণ, তা কম্পিউটার আর টেবিলের মাপের ওপর নির্ভর করে। পার্থক্যটা কোডের যুক্তিতে নয়, মেমরির বিন্যাসে।
গতি শুধু “কতগুলো কাজ” দিয়ে ঠিক হয় না — “ডেটা মেমরিতে কোথায়, কোন ক্রমে ছোঁয়া হচ্ছে” তাও সমান গুরুত্বপূর্ণ। অ্যারে এই খেলায় সবচেয়ে এগিয়ে, কারণ তার ঘরগুলো পাশাপাশি। এজন্যই গেম ইঞ্জিন, ডেটাবেস আর মেশিন লার্নিং library-গুলো যতটা সম্ভব ডেটা অ্যারেতে রাখে।
৯ · AI-এর যুগে — অ্যারের ভুল যা চোখ এড়িয়ে যায়
AI অ্যারের কোড খুব দ্রুত লেখে, আর বেশিরভাগ সময় ঠিকঠাক চলেও। সমস্যা হলো, অ্যারের ভুল প্রায়ই চোখে পড়ে না — প্রোগ্রাম চলে, সাধারণ ইনপুটে সঠিক উত্তর দেয়, আর ভুলটি প্রকাশ পায় কেবল শেষ ঘরে, খালি তালিকায়, বা অন্য কম্পিউটারে। এই পর্বের প্রতিটি ধারণা আসলে AI-এর কোড যাচাইয়ের একটি অস্ত্র।
AI-এর লেখা অ্যারের কোডে যেসব ভুল প্রায়ই থাকে
| AI-এর ভুল | কী ঘটে | কীভাবে ধরবেন |
|---|---|---|
লুপের শর্তে i <= n | শেষে একটি ঘর বেশি — সীমার বাইরে পড়া বা লেখা | শর্তে < আছে কি না দেখুন; -fsanitize=address |
ফাংশনের ভেতরে sizeof(a) / sizeof(a[0]) | দৈর্ঘ্য ভুল (প্রায়ই ২) — অর্ধেক ডেটা বাদ পড়ে | compiler-এর sizeof on array function parameter warning (GCC-তে নিজে থেকেই চালু) |
ফাংশনে দৈর্ঘ্য পাঠায়নি, ভেতরে 100 বা অন্য সংখ্যা বসিয়েছে | ছোট অ্যারে দিলে সীমার বাইরে | প্রতিটি অ্যারে-parameter-এর পাশে n আছে কি না |
যোগফলের অ্যারে {0} ছাড়া ঘোষণা | garbage value থেকে যোগ শুরু — অদ্ভুত ফল | হাতে একটি ছোট উদাহরণ মিলিয়ে দেখুন |
max শুরু করেছে 0 দিয়ে | সব মান ঋণাত্মক হলে ভুল উত্তর 0 | ঋণাত্মক সংখ্যার তালিকা দিয়ে পরীক্ষা |
| খালি তালিকা (n = 0) ভাবেনি | a[0] পড়া বা 0 দিয়ে ভাগ | n = 0 আর n = 1 দিয়ে আলাদা পরীক্ষা |
অ্যারের কোড চাইলে শুধু “ফাংশন লিখে দাও” বলবেন না — সীমা আর দৈর্ঘ্যের শর্তগুলো আগেই বলে দিন, আর AI-কে দিয়েই প্রতিটি index-এর পরিসর ব্যাখ্যা করিয়ে নিন। ব্যাখ্যা দিতে গেলে AI নিজের ভুল প্রায়ই নিজেই ধরে ফেলে:
AI-এর অ্যারে-কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১C কেন অ্যারের সীমা যাচাই করে না? যেসব ভাষা করে, তারা কী দাম দেয়?
উত্তরটা ইতিহাস আর দর্শন — দুটোতেই।
- সময়ের প্রেক্ষাপট: C তৈরি হয়েছিল ১৯৭০-এর দশকের শুরুতে, Unix লেখার জন্য — এমন কম্পিউটারে যার মেমরি আজকের একটি ছবির চেয়েও কম। প্রতিটি অ্যারে-ব্যবহারের আগে একটি তুলনা আর শাখা যোগ করা তখন বড় খরচ ছিল। আর যাচাই করতে হলে প্রতিটি অ্যারেকে নিজের দৈর্ঘ্যও সাথে রাখতে হতো — বাড়তি মেমরি।
- দর্শন: C-র মূলনীতি প্রায়ই এভাবে বলা হয় — “প্রোগ্রামারকে বিশ্বাস করো, যা চাওয়া হয়নি তার দাম নিও না।” যিনি নিশ্চিত জানেন index বৈধ, তাঁকে কেন যাচাইয়ের খরচ দিতে হবে?
অন্য ভাষাগুলো কী করে? তারা প্রতিটি ব্যবহারে সীমা যাচাই করে, যাকে বলে bounds checking. Java সীমার বাইরে গেলে ArrayIndexOutOfBoundsException দেয়, Python দেয় IndexError, Rust প্রোগ্রাম থামিয়ে দেয় (panic). এর দাম: প্রতিটি অ্যারে নিজের দৈর্ঘ্য বয়ে বেড়ায়, আর প্রতিটি ব্যবহারে একটি তুলনা হয়। মজার ব্যাপার হলো, আধুনিক compiler অনেক যাচাই নিজেই সরিয়ে দেয় — যেমন for (i = 0; i < len; i++) লুপে সে প্রমাণ করতে পারে i কখনো সীমা ছাড়াবে না। আর আধুনিক CPU-র branch prediction “প্রায় সবসময় সত্য” শর্তকে প্রায় বিনা খরচে পার করে দেয়। তাই বাস্তবে খরচটা সাধারণত ছোট।
আর না-যাচাইয়ের দাম? Microsoft আর Google-এর Chrome দল আলাদাভাবে জানিয়েছে, তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০ শতাংশ মেমরি-সংক্রান্ত। এজন্যই আজ memory safety নিয়ে এত আলোচনা, আর নতুন সিস্টেম-কোডে Rust-এর মতো ভাষার ব্যবহার বাড়ছে। ১৯৭০-এর দশকে যে সিদ্ধান্ত যৌক্তিক ছিল, আজকের ইন্টারনেট-সংযুক্ত দুনিয়ায় তার দাম অনেক বেশি।
মূল উপলব্ধি: প্রতিটি নকশা-সিদ্ধান্ত তার সময়ের সীমাবদ্ধতার উত্তর। সীমাবদ্ধতা বদলালে সিদ্ধান্তও নতুন করে বিচার করতে হয় — আর C লিখলে যাচাইয়ের দায়িত্বটা আপনার কাঁধে, সেটা জেনেই লিখুন।
প্র ০২গোনা 0 থেকে শুরু করাই কি “সঠিক”? যেসব ভাষা 1 থেকে গোনে, তারা কি ভুল?
“সঠিক” বলে কিছু নেই — দুটি পথেরই যুক্তি আছে, আর প্রতিটি আলাদা মানুষের সুবিধা দেখে।
- মেশিনের যুক্তি (0): এই পর্বে দেখেছেন, index হলো শুরু থেকে দূরত্ব। ঠিকানা = base + i × size — কোনো বাড়তি বিয়োগ নেই। C যেহেতু মেশিনের খুব কাছের ভাষা, এখানে মেশিনের যুক্তিই জিতেছে। C-এর পূর্বসূরি BCPL-এও এই ধারণা ছিল।
- গণিতের যুক্তি (0): Dijkstra ১৯৮২ সালের নোটে দেখান, পরিসর 0 ≤ i < n লিখলে ঘরের সংখ্যা সরাসরি n, দুটি পরিসর জোড়া লাগাতে কোনো ±1 লাগে না, আর খালি পরিসর লেখা যায় 0 ≤ i < 0 দিয়ে। Off-by-one ভুল কমে।
- মানুষের যুক্তি (1): সাধারণ মানুষ “প্রথম, দ্বিতীয়, তৃতীয়” গোনে। গণিতের ম্যাট্রিক্সে সারি-কলাম 1 থেকে শুরু। তাই বিজ্ঞানী ও প্রকৌশলীদের জন্য বানানো Fortran ও MATLAB, আর সহজ স্ক্রিপ্টিংয়ের জন্য Lua — 1 থেকে গোনে। সেখানে গাণিতিক সূত্র কোডে হুবহু তুলে আনা সহজ।
আসল বিপদ কোথায়? দুই রীতি মেশানোয়। কেউ MATLAB থেকে C-তে এলে, বা গণিতের বই থেকে সূত্র কোডে তুলতে গেলে, ±1-এর ভুল প্রায় নিশ্চিত। প্রতিদিনের জীবনেও এমন দ্বিধা আছে: লিফটের “G” আর “1”, কিংবা জন্মের দিন থেকে বয়স গোনা — শিশুর প্রথম বছরে বয়স 0, যদিও সেটা “প্রথম” বছর।
একটি ভালো অভ্যাস: কোডে নিজেকে প্রশ্ন করুন, “এই সংখ্যাটি কি ক্রমিক নম্বর, নাকি দূরত্ব?” ছাত্রের রোল নম্বর 1 থেকে হতে পারে, কিন্তু অ্যারের index দূরত্ব — তাই marks[roll - 1]. এই রূপান্তর একটি জায়গায় করুন, সারা কোডে ছড়িয়ে দেবেন না।
মূল উপলব্ধি: 0 আর 1 দুটোই রীতি, সত্য নয়। দক্ষ প্রোগ্রামার রীতির কারণ বোঝেন, আর দুই রীতির সীমানায় সবচেয়ে বেশি সতর্ক থাকেন।
প্র ০৩একই সংখ্যক কাজ করা দুটি কোড — একটি কয়েক গুণ দ্রুত কেন? মেমরির বিন্যাস গতিকে কীভাবে ঠিক করে?
সাধারণত শেখানো হয়, “কত ধাপ লাগে” দিয়েই গতি মাপা যায় (পর্ব ৩৯-এর Big-O). এটি সত্য, কিন্তু অসম্পূর্ণ। একটি ধাপ কত সময় নেয়, সেটি নির্ভর করে ডেটা কোথায় আছে তার ওপর।
- মেমরির সিঁড়ি: CPU-র নিজের register সবচেয়ে দ্রুত, তারপর কয়েক স্তরের cache (L1, L2, L3), তারপর RAM, তারপর SSD. প্রতিটি ধাপ নিচে নামলে জায়গা বাড়ে, গতি অনেক কমে। RAM থেকে আনা cache থেকে আনার চেয়ে বহু গুণ ধীর।
- CPU-র আগাম অনুমান: শুধু cache line নয়, আধুনিক CPU-তে একটি “prefetcher” থাকে। আপনি পরপর ঘর ছুঁতে থাকলে সে ধরে ফেলে, আর পরের ঘরগুলো আপনি চাওয়ার আগেই এনে রাখে। অ্যারেতে সারি ধরে হাঁটলে এই অনুমান নিখুঁত হয়; এলোমেলো লাফ দিলে ব্যর্থ।
- ম্যাট্রিক্স গুণ: দুটি ম্যাট্রিক্স গুণ করার তিনটি nested loop-এর ক্রম বদলালে (i-j-k না i-k-j) উত্তর একই থাকে, কিন্তু বড় ম্যাট্রিক্সে গতিতে বড় পার্থক্য হয় — কারণ একটি ক্রমে ভেতরের লুপ সারি ধরে হাঁটে, অন্যটিতে কলাম ধরে লাফায়।
এই উপলব্ধি থেকে একটি পুরো নকশা-পদ্ধতি এসেছে, যাকে গেম-জগতে বলা হয় “data-oriented design” — বস্তুগুলোকে আলাদা আলাদা না রেখে, একই ধরনের তথ্য (যেমন সব খেলোয়াড়ের অবস্থান) একটি অ্যারেতে পাশাপাশি রাখা, যাতে লুপ cache-বান্ধব হয়। ডেটাবেসেও “column store” একই চিন্তা থেকে এসেছে।
মূল উপলব্ধি: কম্পিউটার কোনো বিমূর্ত গণিত-যন্ত্র নয়; এটি বাস্তব হার্ডওয়্যার, যেখানে দূরত্বের দাম আছে। কাছাকাছি থাকা ডেটা দ্রুত — আর অ্যারে হলো “কাছাকাছি রাখা”-র সবচেয়ে সরল রূপ।
প্র ০৪Heartbleed থেকে আসল শিক্ষা কী? কেবল “সীমা যাচাই করুন” — নাকি এর চেয়ে গভীর কিছু?
ঘটনাটি এক লাইনে: সার্ভার বাইরের কারও দেওয়া একটি দৈর্ঘ্যের সংখ্যা বিশ্বাস করেছিল, আর সেই সংখ্যা অনুযায়ী মেমরি পড়ে ফেরত পাঠিয়েছিল। xkcd কমিকে এর একটি বিখ্যাত ব্যাখ্যা আছে — কেউ বলছে, “‘HAT’ শব্দটি ফেরত বলো, এটি ৫০০ অক্ষরের”, আর সার্ভার ‘HAT’-এর পরে মেমরিতে যা আছে, পাশাপাশি ৫০০ অক্ষর পড়ে শোনায়।
- শিক্ষা ১ — বাইরের ইনপুট কখনো বিশ্বাস করবেন না: নেটওয়ার্ক, ফাইল, ব্যবহারকারী — যে কোনো উৎস থেকে আসা দৈর্ঘ্য বা index আগে যাচাই, তারপর ব্যবহার। দাবি করা দৈর্ঘ্য নয়, আপনার হাতে আসলে যত বাইট আছে, সেটাই সত্য।
- শিক্ষা ২ — নিঃশব্দ ত্রুটি সবচেয়ে বিপজ্জনক: Heartbleed কোনো crash ঘটাত না, কোনো লগে চিহ্ন রাখত না। প্রায় দুই বছর এটি OpenSSL-এর প্রকাশিত সংস্করণে ছিল বলে জানা যায়। সীমার বাইরে পড়া এই অংশের ডায়াগ্রামের মতোই — সব “ঠিকঠাক” চলে।
- শিক্ষা ৩ — অবকাঠামোর পেছনের মানুষ: বিশ্বের বিপুল সংখ্যক সার্ভার যে library-র ওপর নির্ভর করত, তা দেখাশোনা করত খুব ছোট একটি দল, প্রায় স্বেচ্ছাসেবায়। ঘটনার পর Linux Foundation-এর উদ্যোগে বড় কোম্পানিগুলো এমন গুরুত্বপূর্ণ open-source প্রকল্পে অর্থায়ন শুরু করে। কোড নিরাপদ রাখা শুধু প্রযুক্তির নয়, সংগঠন ও অর্থেরও প্রশ্ন।
সমাধানও স্তরে স্তরে: কোডে সীমা-যাচাই; পরীক্ষায় sanitizer আর “fuzzing” (লক্ষ লক্ষ এলোমেলো ইনপুট দিয়ে প্রোগ্রামকে আঘাত করা — এভাবে বহু over-read ধরা পড়ে); আর দীর্ঘমেয়াদে এমন ভাষা বা library, যেখানে দৈর্ঘ্য সবসময় ডেটার সাথেই থাকে।
মূল উপলব্ধি: একটি অ্যারে আর তার দৈর্ঘ্য আলাদা হয়ে গেলেই বিপদ শুরু। দৈর্ঘ্য কে বলছে, আর সে কি সত্য বলছে — এই প্রশ্নটি প্রতিটি অ্যারের কোডে করুন।
প্র ০৫অ্যারের মাপ বদলানো যায় না, মাঝখানে কিছু ঢোকানো কষ্টের — তাহলে বিকল্প কী? Linked list কি সবসময় ভালো?
অ্যারের দুটি দুর্বলতা আছে। এক, মাপ আগেই ঠিক। দুই, মাঝখানে একটি মান ঢোকাতে বা সরাতে পরের সব ঘর এক ঘর করে সরাতে হয় — ১০ লাখ ঘরের অ্যারের শুরুতে একটি ঢোকালে ১০ লাখ সরানো। কারণটাও সেই একই নিয়ম: ঘরগুলোকে পাশাপাশি থাকতেই হবে।
- Linked list (পর্ব ৩৩): প্রতিটি মান আলাদা একটি “বাক্সে”, আর প্রতিটি বাক্স পরেরটির ঠিকানা মনে রাখে — ট্রেনের বগির মতো, যেকোনো জায়গায় একটি বগি খুলে নতুন বগি জোড়া যায়। মাঝে ঢোকানো সস্তা, মাপ যত খুশি।
- কিন্তু দাম আছে: ৫০ নম্বর বাক্সে যেতে হলে প্রথম থেকে ৪৯টি বাক্স পার হতে হয় — অ্যারের মতো সূত্র দিয়ে সরাসরি লাফ নেই। প্রতিটি বাক্সে বাড়তি ঠিকানা রাখতে বাড়তি মেমরি লাগে। আর বাক্সগুলো মেমরিতে ছড়ানো, তাই cache প্রায় কোনো কাজে আসে না।
- মাঝামাঝি পথ — বাড়তে পারা অ্যারে: জায়গা ফুরালে দ্বিগুণ মাপের নতুন অ্যারে নিয়ে সব কপি করা (পর্ব ২০-এর
realloc). কপি মাঝে মাঝে হয়, তাই গড়ে খরচ কম। Python-এর list, C++-এর vector — ভেতরে এমনই।
বাস্তবের অভিজ্ঞতা অনেককে অবাক করে: মাঝারি মাপের ডেটায় মাঝে ঢোকানোর কাজেও অনেক সময় অ্যারে linked list-এর চেয়ে দ্রুত হয়, কারণ ঢোকানোর জায়গা খুঁজতে linked list-কে বাক্সে বাক্সে লাফাতে হয়, আর সেখানে cache-এর সুবিধা হারায়। C++-এর স্রষ্টা Bjarne Stroustrup এই বিষয়টি একটি বক্তৃতায় দেখিয়েছিলেন বলে প্রায়ই উল্লেখ করা হয়। তাই “কোনটা সবসময় ভালো” নয় — মাপুন, তারপর বেছে নিন।
মূল উপলব্ধি: প্রতিটি ডেটা স্ট্রাকচার কিছু কাজ সস্তা করে আরেক কাজের খরচ বাড়িয়ে। অ্যারে সস্তা করে “যেকোনো ঘরে সরাসরি যাওয়া” আর “পরপর পড়া” — বেশিরভাগ প্রোগ্রামে এ দুটিই সবচেয়ে বেশি দরকার। এজন্যই অ্যারে কম্পিউটিংয়ের সবচেয়ে মৌলিক ডেটা স্ট্রাকচার।
অনুশীলন
প্রথম তিনটিতে কোড লিখতে হবে না — খাতা-কলমে হিসাব আর ভাবাই আসল কাজ।
-
ঠিকানা হিসাব: (ক)
double d[10]-এর base address 5000 হলেd[3]আরd[9]-এর ঠিকানা কত? (double৮ বাইট।) (খ)int t[3][4]-এর base address 2000 হলেt[2][1]-এর ঠিকানা কত?(ক) d[3] = 5000 + 3 × 8 = 5024; d[9] = 5000 + 9 × 8 = 5072. শেষ ঘর d[9] শেষ হয় 5079-এ; 5080 থেকে অ্যারের বাইরে।
(খ) প্রতি সারিতে ৪টি ঘর। ঘর নম্বর = 2 × 4 + 1 = 9, ঠিকানা = 2000 + 9 × 4 = 2036. আগের দুটি সারি (৮টি ঘর) পার হয়ে আরও একটি ঘর।
-
ফল আগে বলুন: একটি ফাংশনের ভেতরে লেখা আছে
int a[5] = {3, 7};আরint b[5];. দুটি অ্যারের পাঁচটি করে ঘর ছাপালে কী আসবে? আরint c[] = {1, 2, 3, 4};-এর জন্যsizeof(c) / sizeof(c[0])কত?a: 3 7 0 0 0 — কিছু মান দিলে বাকিগুলো নিশ্চিতভাবে 0.b: অনিশ্চিত — garbage value; একেক বার একেক রকম হতে পারে, কাকতালীয়ভাবে 0-ও আসতে পারে, কিন্তু তার ওপর ভরসা করা যায় না।c: compiler মান গুনে মাপ ৪ ঠিক করেছে, তাই 16 / 4 = 4. -
বাগ শিকারি: AI-এর লেখা এই ফাংশনে কয়টি ভুল?
int average(int a[]) { int n = sizeof(a) / sizeof(a[0]); int sum; for (int i = 0; i <= n; i++) sum += a[i]; return sum / n; }অন্তত পাঁচটি:
- দৈর্ঘ্য হারানো:
sizeof(a)এখানে ঠিকানার মাপ (সাধারণত ৮), তাই n প্রায়ই ২ — অ্যারের মাপ যা-ই হোক। দৈর্ঘ্য আলাদা parameter হিসেবে নিতে হবে। i <= n: একটি ঘর বেশি — সীমার বাইরে পড়া। হবেi < n.sum-এর শুরুর মান নেই: garbage value থেকে যোগ শুরু। হবেint sum = 0;- পূর্ণসংখ্যার ভাগ: গড়ের দশমিক অংশ হারায় (পর্ব ০৬). গড়
double-এ ফেরত দেওয়াই ভালো। - n = 0 হলে শূন্য দিয়ে ভাগ। আর অ্যারে শুধু পড়া হচ্ছে, তাই
constদেওয়া উচিত।
ঠিক রূপ:
double average(const int a[], int n), ভেতরেif (n == 0) return 0;,int sum = 0;,i < n, শেষেreturn (double)sum / n; - দৈর্ঘ্য হারানো:
-
নিজে লিখুন (একটিমাত্র কোডের কাজ): একটি ফাংশন লিখুন
int find_index(const int a[], int n, int key)—keyপ্রথম যে index-এ পাওয়া যায় সেটি ফেরত দেবে, না পেলে −1.main-এ{72, 85, 64, 90, 58}দিয়ে 90, 72 আর 100 খুঁজে দেখুন।মূল অংশ:
int find_index(const int a[], int n, int key) { for (int i = 0; i < n; i++) if (a[i] == key) return i; return -1; }প্রত্যাশিত ফল: 90 → 3, 72 → 0 (প্রথম ঘর — এটিও আলাদা করে পরীক্ষা করা জরুরি), 100 → −1. খেয়াল করুন,
return -1লুপের বাইরে — লুপের ভেতরেelse return -1লেখা AI-এর একটি পরিচিত ভুল, যাতে শুধু প্রথম ঘরটিই দেখা হয়।main-এ দৈর্ঘ্য দিনsizeof(marks) / sizeof(marks[0])দিয়ে — সেখানে এটি বৈধ, কারণ অ্যারেটি সেখানেই ঘোষণা করা।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমx = marks[i]; চলার সময় ভেতরে যা ঘটে, সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- অ্যারের base address নেওয়া হয়
- index
i-এর মান পড়া হয় i-কে একটি ঘরের মাপ (৪) দিয়ে গুণ করা হয়- গুণফল base address-এর সাথে যোগ হয়
- সেই ঠিকানা থেকে ৪ বাইট পড়ে
x-এ রাখা হয়
int a[10]; — বৈধ index কোনগুলো?
- 1 থেকে 10
- 0 থেকে 9
- 0 থেকে 10
- যেকোনো সংখ্যা, C তো আপত্তি করে না
int a[8]-এর base address 1000 (int = ৪ বাইট)। a[6]-এর ঠিকানা কত?
- 1006
- 1020
- 1024
- 1028
ফাংশনের ভেতরে int a[5] = {9}; লিখলে a[4]-এর মান কত?
- 0
- 9
- Garbage value — অনিশ্চিত
- Compile error
int marks[5]; থাকা অবস্থায় কোড marks[5] = 99; চালাল। C-তে কী ঘটে?
- প্রোগ্রাম সবসময় একটি পরিষ্কার error দিয়ে থেমে যায়
- অ্যারে নিজে থেকে ৬ ঘরের হয়ে যায়
- মানটি শেষ ঘরে (marks[4]) বসে
- কী ঘটবে নির্দিষ্ট নয় — পাশের ডেটা নষ্ট, crash, বা আপাতত কিছুই না
void f(int a[]) ফাংশনের ভেতরে ৬৪-বিট কম্পিউটারে sizeof(a) সাধারণত কত দেয়?
- পাঠানো অ্যারের পুরো মাপ
- 8 — একটি ঠিকানার মাপ, অ্যারের নয়
- 4
- Compile error
int t[2][3] মেমরিতে কোন ক্রমে থাকে?
- t[0][0], t[1][0], t[0][1], t[1][1], t[0][2], t[1][2]
- দুটি সারি মেমরির দুটি আলাদা, দূরের জায়গায়
- t[0][0], t[0][1], t[0][2], t[1][0], t[1][1], t[1][2]
- Compiler ইচ্ছামতো সাজায়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Array = একই টাইপের ঘর, মেমরিতে ফাঁক ছাড়া পাশাপাশি; মাপ তৈরির সময়েই ঠিক।
- ঠিকানা = base + i × sizeof(ঘর) — তাই যেকোনো ঘরে সমান সময়ে পৌঁছানো যায়, আর index হলো শুরু থেকে দূরত্ব, তাই 0 থেকে n − 1.
- C সীমা যাচাই করে না — সীমার বাইরে মানে undefined behavior; লুপে
< n, পরীক্ষায়-fsanitize=address. - 2D array আসলে row-major একটানা সারি; সারি ধরে ঘুরলে cache সবচেয়ে ভালো কাজ করে।
- ফাংশন পায় শুধু ঠিকানা, কপি নয় — দৈর্ঘ্য সবসময় আলাদা করে পাঠান, ভেতরে
sizeofদিয়ে মাপবেন না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ১৭ · পয়েন্টার ১ — ঠিকানা দিয়ে কাজপরবর্তী পর্বঠিকানা রাখার ভ্যারিয়েবল —
&আর*কী করে। - C প্রোগ্রামিং · কোর্স হোম৫৬টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- Data Structures & Algorithmsপাশের ট্র্যাকঅ্যারে থেকে linked list, stack, queue — কোন কাজে কোনটি।
- Computer Architecture & Digital Logicগভীরে যানCache, memory hierarchy আর CPU কীভাবে মেমরি পড়ে।
- Cybersecurityপাশের ট্র্যাকBuffer overflow থেকে Heartbleed — মেমরির ভুল কীভাবে আক্রমণ হয়ে ওঠে।