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

ক্যাপস্টোন — একটি আসল C++ সিস্টেম: লাইব্রেরির বই ধারের ব্যবস্থা

Capstone — A Real C++ System: A Layered Library Lending Manager

Taught in Bangla, this capstone builds a real C++ system for library book lending: a small command language, a four-layer architecture from CLI to service, domain and storage, invariant-protecting classes with Money and std::expected, a Storage interface with two implementations, milestones, tests, CMake and portfolio tips.

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

  • একটি অস্পষ্ট চাহিদা থেকে কমান্ডের ছোট টেক্সট-ভাষা, নিয়ম আর MVP-র সীমারেখা লেখা
  • চার স্তরের নকশা (CLI → service → domain → storage), আর প্রতিটি নকশার সিদ্ধান্তের কারণ
  • invariant রক্ষাকারী domain class, Money টাইপ আর std::expected দিয়ে যাচাই
  • Storage interface, নিরাপদে ফাইলে লেখা, save → load-এর round trip, আর ranges দিয়ে রিপোর্ট
  • মাইলস্টোন, টেস্ট harness, CMake, AI-কে দায়িত্বশীল pair programmer বানানো, আর portfolio-তে প্রজেক্ট দেখানো

১ · প্রোগ্রাম থেকে সিস্টেম — অনেক ছোট অংশ, প্রতিটির একটি কাজ

পঞ্চাশটি পর্বে আপনি C++-এর প্রায় পুরো যন্ত্রপাতি হাতে নিয়েছেন — std::vector থেকে শুরু করে class, template, ranges, move semantics, std::expected, ফাইল, thread, CMake আর টেস্ট। পর্ব ১৯-এর মাঝপথের প্রজেক্টে আপনি একটি দোকানের হিসাবের CLI টুল বানিয়েছিলেন; সেখানে সব ডেটা খোলা struct-এ ছিল, আর নিয়ম রক্ষা করছিল শুধু comment. এবার শেষ বড় ধাপ: এমন একটি সিস্টেম বানানো, যার প্রতিটি অংশ আলাদা করে বোঝা, বদলানো আর টেস্ট করা যায়।

প্রোগ্রাম আর সিস্টেমের পার্থক্য আকারে নয়, গঠনে। একটি ৩০০ লাইনের ফাইলও “সিস্টেম” হতে পারে, যদি তার ভেতরের অংশগুলোর সীমানা পরিষ্কার হয়; আবার ৩০,০০০ লাইনের প্রোগ্রামও একটি বড় জট হতে পারে। ঢাকার মেট্রো রেলের কথা ভাবুন। টিকিট মেশিন জানে না ট্রেন কীভাবে চলে, চালক জানেন না কার্ডে কত টাকা আছে, আর সিগন্যাল-ব্যবস্থা জানে না কোন যাত্রী কোথায় নামবেন। প্রত্যেকে নিজের একটি কাজ করে, আর নির্দিষ্ট “দরজা” দিয়ে একে অপরের সাথে কথা বলে। এই দরজাগুলোর নাম সফটওয়্যারে interface.

মূল ধারণা

একটি আসল সিস্টেম হলো অনেক ছোট, ভালোভাবে নকশা করা অংশ, যাদের প্রতিটি একটি কাজ করে। প্রতিটি অংশের থাকে একটি চুক্তি (কী নেয়, কী দেয়, কী কখনো ঘটবে না) আর একটি গোপন ভেতর। চুক্তি স্থির থাকলে ভেতরটা নির্ভয়ে বদলানো যায়।

প্রজেক্ট: “আলোঘর” লাইব্রেরির বই ধারের ব্যবস্থা

রাজশাহীর একটি পাড়ায় স্বেচ্ছাসেবীরা “আলোঘর” নামে একটি ছোট লাইব্রেরি চালান। কয়েকশো বই, দুইশোর মতো সদস্য — স্কুলের ছাত্রছাত্রী থেকে অবসরপ্রাপ্ত শিক্ষক। এখন হিসাব রাখা হয় একটি খাতায়, আর তিনটি সমস্যা বারবার হয়: যে বইয়ের সব কপি বাইরে, সেটাও “দেওয়া হয়েছে” লেখা হয়; দেরিতে ফেরতের জরিমানা কেউ এক রকম, কেউ আরেক রকম হিসাব করেন; আর খাতা ভিজে গেলে সব হারায়। দায়িত্বে থাকা নাসরিন আপা চান একটি সহজ প্রোগ্রাম।

কেন দোকান নয়, লাইব্রেরি? পর্ব ১৯-এর দোকানে মাল একবার বিক্রি হলে চলে যায়; লাইব্রেরিতে বই ফেরত আসে। ফলে নতুন একটি জিনিস মডেল করতে হয় — সম্পর্ক (কোন সদস্যের কাছে কোন বই, কবে থেকে) আর সময় (কতদিন দেরি)। C কোর্সের ক্যাপস্টোনে বানানো হয়েছিল একটি মিনি ডেটাবেস; এখানে আমরা ডেটাবেস বানাচ্ছি না, বরং ব্যবসার নিয়মসহ একটি পূর্ণ অ্যাপ্লিকেশন — যেখানে নকশার স্তর, ভুল সামলানো আর টেস্টই মূল বিষয়। শেষ পর্যন্ত প্রোগ্রামের সাথে কথোপকথন হবে এমন:

> add B1 2
ok
> lend rina B1 1
ok
> lend tamim B1 2
ok
> lend sakib B1 3
error: no copy of B1 left
> return rina B1 18
ok, fine 6.00 Tk
> report
B1   1/2 on shelf
loans: 1  fines: 6.00 Tk

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

🧱 Domain — নিয়ম

class, constructor, invariant (পর্ব ২০–২১), Money টাইপ আর operator (পর্ব ২৩), std::expected (পর্ব ৩৭)।

মডিউল ৪, ৬

🗂️ Storage — টিকে থাকা

abstract class ও interface (পর্ব ২৬), ফাইল I/O ও filesystem (পর্ব ৪৪), RAII (পর্ব ১৭)।

মডিউল ৩, ৪, ৭

📊 রিপোর্ট — প্রশ্নের উত্তর

map, set (পর্ব ৩১), algorithm (পর্ব ৩৩), lambda (পর্ব ৩৪), ranges ও views (পর্ব ৩৫)।

মডিউল ৫

✅ প্রমাণ — টেস্ট ও বিল্ড

CMake (পর্ব ৪৭), টেস্টিং ও ডিবাগিং (পর্ব ৪৮), আর ঐচ্ছিক background thread (পর্ব ৪৫)।

মডিউল ৭

২ · চাহিদা — কমান্ডের ছোট টেক্সট-ভাষা আর লাইব্রেরির নিয়ম

পর্ব ১৯-এ দেখেছেন, কোড লেখার আগে লিখিত specification দরকার — “কী” করবে, “কীভাবে” নয়। নাসরিন আপার সাথে আধা ঘণ্টা কথা বলে আমরা প্রথমে লিখলাম প্রোগ্রামের সাথে কথা বলার ভাষা। প্রতিটি লাইন একটি কমান্ড, শব্দগুলো space দিয়ে আলাদা। এমন নিয়মবদ্ধ ছোট ভাষাকে বলে text protocol — ঠিক যেমন SMS-এ “BAL” লিখে ব্যালেন্স জানা যায়। সুবিধা: মানুষ হাতে টাইপ করতে পারে, আবার একটি ফাইলে পুরো সেশন লিখে স্বয়ংক্রিয় টেস্টও করা যায়।

কমান্ডকাজ (দোকানের ভাষায় তুলনা)কখন “হয়েছে” বলব
add B1 2নতুন বই, বা আগের বইয়ের কপি বাড়ানো (restock)কপির সংখ্যা ধনাত্মক হলে বাড়ে; ০, ঋণাত্মক বা অক্ষর হলে কিছুই বদলায় না
member rinaনতুন সদস্য নিবন্ধনএকই নাম দুবার হলে বাতিল; পূর্ণ সংস্করণে আছে, এক ফাইলের রূপে বাদ
lend rina B1 1বই ধার দেওয়া (order) — দিন নম্বর ১-এবই থাকলে, কপি বাকি থাকলে, আর সদস্যের কাছে একই বই না থাকলে তবেই সফল
return rina B1 18বই ফেরত, দেরি হলে জরিমানা১৪ দিনের বেশি হলে প্রতিদিন ২ টাকা; জরিমানার পরিমাণ জানায়
cancel rina B1ভুল করে লেখা ধার একই দিনে বাতিলজরিমানা ছাড়া কপি ফেরত; অন্য দিনে চাইলে বাতিল নয়, ফেরত দিতে হবে
report, overdue 30তাকে কত কপি, কার কাছে দেরি হচ্ছেবইয়ের id-র ক্রমে সারি; খালি লাইব্রেরিতেও crash নয়
save / loadখাতা ফাইলে রাখা আর ফিরিয়ে আনাsave → load করলে report হুবহু এক; ভাঙা ফাইলে আগের অবস্থা অক্ষত

লক্ষ করুন, তারিখের জায়গায় একটি পূর্ণসংখ্যা “দিন নম্বর” — লাইব্রেরির খাতা খোলার দিন ১, পরের দিন ২। এটি একটি সচেতন সরলীকরণ: টেস্ট সহজ হয় আর ফল প্রতিবার একই আসে। পূর্ণ সংস্করণে পর্ব ৪১-এর std::chrono দিয়ে আসল তারিখ বসানো যায়, আর বাকি নকশা একটুও বদলায় না, কারণ “দিন” টাইপটি এক জায়গাতেই সংজ্ঞায়িত।

নিয়মগুলো — যা কখনো ভাঙবে না

spec-এর সবচেয়ে মূল্যবান অংশ কমান্ডের তালিকা নয়, বরং সেই নিয়মগুলো যা সবসময় সত্য থাকতে হবে। পর্ব ২০-এ এদের নাম শিখেছেন: invariant. আলোঘরের তিনটি invariant:

  1. I1: প্রতিটি বইয়ের জন্য ০ ≤ বাইরে থাকা কপি ≤ মোট কপি।
  2. I2: একজন সদস্যের কাছে একই বইয়ের একাধিক কপি থাকে না; আর প্রতিটি ধার একটি জানা বই আর জানা সদস্যের।
  3. I3: মোট জরিমানা কখনো কমে না, আর সবসময় পূর্ণ পয়সায় — ভগ্নাংশ পয়সা বলে কিছু নেই।

এই তিনটি লাইন পুরো প্রজেক্টের মেরুদণ্ড। প্রতিটি ফাংশন লেখার সময় প্রশ্ন একটাই: “এই ফাংশন শেষ হওয়ার পর কি তিনটি নিয়মই সত্য থাকবে — সফল হলেও, ব্যর্থ হলেও?”

✅ প্রথম সংস্করণে (MVP)

add, lend, return, report, save/load; প্রতিটি ভুলে পরিষ্কার বার্তা; পয়সায় নির্ভুল জরিমানা; টেস্ট।

MVP

⏳ পরে (লিখে রাখা আছে)

বইয়ের নাম ও লেখক, reservation-এর লাইন, SMS-এ মনে করিয়ে দেওয়া, একাধিক কাউন্টার একসাথে, JSON export, GUI.

পরের সংস্করণ

নাসরিন আপা চাইলেন মোবাইল অ্যাপও! চাওয়াটা যৌক্তিক, কিন্তু প্রথম দিনেই অ্যাপ, সার্ভার আর ডেটাবেস একসাথে শুরু করলে প্রজেক্ট সম্ভবত কখনো শেষ হবে না — পর্ব ১৯-এর scope creep. সমাধান নকশায়: যুক্তির অংশ যদি কিছু না ছাপে, কিছু না পড়ে, তাহলে কাল তার ওপর একটি অ্যাপ বা ওয়েব API বসানো যাবে। আজ CLI, কিন্তু দরজা খোলা।

৩ · স্থাপত্য — চার স্তর: CLI → service → domain → storage

পর্ব ১৯-এ আমরা তিন ভাগ করেছিলাম: কথা বলা, হিসাব, ডেটা। এবার সংরক্ষণ যোগ হওয়ায় চারটি স্তর। এমন নকশার নাম layered architecture: প্রতিটি স্তর শুধু নিচের স্তরকে চেনে, ওপরেরটাকে কখনো নয়। হাসপাতালের কথা ভাবুন — রিসেপশন রোগীর কথা শুনে টিকিট বানায়, ওয়ার্ডের নার্স ঠিক করেন কোন ডাক্তারের কাছে যাবেন, ডাক্তার চিকিৎসার নিয়ম মানেন, আর রেকর্ড-রুম ফাইল রাখে। ডাক্তার কখনো রিসেপশনে বসে টিকিট কাটেন না।

আলোঘর — চার স্তরের নকশা

বাঁ থেকে ডানে: কে কাকে ডাকে। ডানের স্তর বাঁয়েরটাকে চেনে না।

cli/main.cppgetline, parse, বার্তা ছাপা
parse_command()লেখা → Command (variant)
LibraryServiceকাজ সাজায়: domain + storage
save() / load()serialize, parse, যাচাই
Libraryবই, সদস্য, ধার — invariant
Money, Errorপয়সা আর ভুলের কারণ
Storage (interface)read(), write()
FileStorage / MemoryStorageডিস্ক বা টেস্টের মেমরি
ছবি ৫১.১ — domain কোনো স্তরকেই চেনে না: সে শুধু নিয়ম জানে। Storage-কে চেনে শুধু service, আর তাও interface দিয়ে — কোন রূপটি পেল, তা জানে না।

প্রতিটি স্তরের দায়িত্ব এক বাক্যে বলা যায় — এটাই পর্ব ২৭-এর single responsibility নীতির পরীক্ষা:

  • CLI: মানুষের লেখা লাইনকে একটি টাইপ-করা Command-এ রূপান্তর, আর ফলাফলকে মানুষের ভাষায় ফেরানো। কোনো নিয়ম জানে না।
  • Service: একটি কমান্ড পূর্ণ করতে কাকে কোন ক্রমে ডাকতে হবে, তা জানে — domain-এর কাজ, তারপর দরকার হলে storage. নিজে কোনো ব্যবসার নিয়ম রাখে না।
  • Domain: লাইব্রেরির নিয়ম — invariant, জরিমানার হিসাব। কিছু পড়ে না, কিছু ছাপে না, ফাইল চেনে না। একে বলে domain model.
  • Storage: একটি লেখা কোথাও রাখা আর ফিরিয়ে দেওয়া। লাইব্রেরি কী, জানে না।

ফাইল-কাঠামোতেও এই ভাগ চোখে পড়ে। প্রতিটি ফোল্ডার একটি স্তর; কেউ নতুন যোগ দিলে ফোল্ডারের নাম দেখেই বোঝেন কোথায় কী আছে:

alo-library/
├── CMakeLists.txt
├── README.md
├── src/
│   ├── domain/    money.h  error.h  library.h  library.cpp
│   ├── storage/   storage.h  file_storage.h  file_storage.cpp  memory_storage.h
│   ├── service/   library_service.h  library_service.cpp
│   └── cli/       command.h  command.cpp  main.cpp
└── tests/         test_main.cpp  test_library.cpp  sessions/*.txt

একটি কমান্ডের যাত্রা — চার স্তরের ভেতর দিয়ে

এবার দেখুন lend rina B1 1 লাইনটি ঢোকার পর কোন স্তর কী করে। প্রতিটি ধাপে কে কাজ করছে আর কী হাতবদল হচ্ছে, খেয়াল করুন:

  1. CLIparseলেখা → টাইপ
  2. Servicelend()কাজ সাজানো
  3. DomainLibraryনিয়ম আর বদল
  4. Serviceexpectedফল ফেরত
  5. CLIprintlnমানুষের ভাষায়
cli/command.cppলেখাকে একবারই পরীক্ষা
"lend rina B1 1"
  → getline → istringstream → "lend"
  → LendCmd{ .member = "rina", .book = "B1", .day = 1 }
Command = std::variant<AddCmd, LendCmd, ReturnCmd, ...>
"lend rina B1 abc" হলে এখানেই থামে: unexpected("bad day")
service/library_service.cppকে কাজ করবে, কোন ক্রমে
std::visit(overloaded{
    [&](const LendCmd& c) { return lib_.lend(c.member, c.book, c.day); },
    ...
}, cmd);
service নিজে কোনো নিয়ম জানে না — শুধু সঠিক জায়গায় পাঠায়
domain/library.cppআগে সব যাচাই, পরে একবারে বদল
B1 আছে?            হ্যাঁ
কপি বাকি?          out 0 < total 2   হ্যাঁ
rina-র কাছে B1?   না
বদল: loans_ += {rina, B1, day 1};  B1.out = 1
I1, I2, I3 — তিনটিই এখনো সত্য
ফলাফল ওপরে ফেরেমান অথবা কারণ — কখনো দুটোই নয়
std::expected<Money, Error>  →  has_value() == true, value 0
Storage-কে এই কমান্ডে ডাকা হয়নি — শুধু save/load-এ।
domain জানে না ফলটি কে দেখবে: CLI, GUI, না টেস্ট
cli/main.cppফলকে বার্তায় রূপান্তর
if (r) std::println("ok");
else   std::println("error: {}", message(r.error()));
> ok
  1. CLI — লেখা থেকে টাইপ। লাইনটি পড়ে শব্দে ভাগ করা হলো (পর্ব ১৯-এর getline + istringstream), তারপর একটি LendCmd struct-এ রাখা হলো। সব কমান্ড মিলে একটি std::variant (পর্ব ৩৮)। লেখার তুলনা পুরো প্রোগ্রামে এখানেই শেষ; এরপর সবাই টাইপ দিয়ে কথা বলে।
  2. Service — কাজ সাজানো। std::visit দেখে কোন রূপের কমান্ড এসেছে, আর সঠিক domain ফাংশনে পাঠায়। variant-এর কোনো রূপ বাদ পড়লে compile-ই হবে না — নতুন কমান্ড যোগ করে ভুলে যাওয়া অসম্ভব।
  3. Domain — নিয়ম। Library::lend তিনটি পরীক্ষা করে, তারপরই শুধু অবস্থা বদলায়। এই এক ফাংশনই I1 আর I2 রক্ষা করছে; অন্য কেউ সরাসরি out বদলাতে পারে না, কারণ সেটা private.
  4. ফলাফল ওপরে। domain একটি std::expected ফেরত দেয় — সফল হলে মান, ব্যর্থ হলে একটি Error. এই কমান্ডে storage-এর কোনো ভূমিকা নেই; নকশায় সংরক্ষণ ঘটে শুধু save-এ, তাই প্রতিটি ধার দ্রুত।
  5. CLI — মানুষের ভাষায়। একই Error CLI-তে ইংরেজি বার্তা, ভবিষ্যতের অ্যাপে বাংলা পপ-আপ হতে পারে। নিচের স্তরগুলো একটুও না বদলে। এটাই স্তরে ভাগের আসল লাভ।

নকশার সিদ্ধান্ত — কী, কেন, আর কী দাম দিলাম

সিদ্ধান্তকেনদাম কী দিলাম
ব্যবহারকারীর ভুলে std::expected, exception নয়ভুল কমান্ড এখানে প্রতিদিনের স্বাভাবিক ঘটনা, ব্যতিক্রম নয়। ফেরত টাইপেই লেখা থাকে “এটা ব্যর্থ হতে পারে”, তাই ডাকার জায়গা ফল না দেখে এগোতে পারে না (পর্ব ৩৭)। খরচও অনুমানযোগ্য।প্রতিটি ডাকে if (!r) লিখতে হয়; exception রাখা হলো শুধু সত্যিকারের বিপদে — মেমরি শেষ, বা নিজের বাগে ভাঙা invariant
টাকা পয়সায়, একটি Money class-এfloating point-এ ০.১-এর মতো মান হুবহু থাকে না; পূর্ণসংখ্যার যোগ নির্ভুল। আলাদা টাইপ হলে ভুল করে দিন নম্বরের সাথে টাকা যোগ করা compile-ই হয় না (পর্ব ২৩)।পড়া আর ছাপার সময় রূপান্তর; গুণ-ভাগে গোল করার নিয়ম নিজে লিখতে হয়
Storage একটি interfaceটেস্টে ডিস্ক লাগে না (MemoryStorage), আর কাল SQLite বসাতে শুধু একটি নতুন class লাগবে। ওপরের স্তর বিমূর্ত চুক্তির ওপর নির্ভর করে — পর্ব ২৭-এর dependency inversion.একটি virtual call আর একটু বেশি ফাইল — এই আকারের প্রোগ্রামে খরচটা মাপারও অযোগ্য
std::map, unordered_map নয়রিপোর্ট সবসময় id-র ক্রমে, তাই আউটপুট নির্ধারিত আর golden-file টেস্ট সহজ (পর্ব ৩১)। কয়েক হাজার বইয়ে log n খোঁজা যথেষ্ট দ্রুত।লাখো বইয়ে hash table দ্রুততর হতে পারত — মেপে দেখার আগে বদলাব না (পর্ব ৪৯)
ফাইলের ফরম্যাট সাধারণ লেখা, প্রথম লাইনে সংস্করণমানুষ খুলে পড়তে পারে, git-এ পার্থক্য দেখা যায়; alo-library v1 লাইন থাকায় ভবিষ্যতে ফরম্যাট বদলালেও পুরোনো ফাইল চেনা যাবে।JSON-এর মতো প্রচলিত নয়; অন্য টুল পড়তে চাইলে export লাগবে
মূল অংশ এক thread-এএকটি কাউন্টার, একজন মানুষ — concurrency-র জটিলতার দরকার নেই (YAGNI).background autosave চাইলে mutex লাগবে — অনুশীলন ৪

৪ · Domain — invariant রক্ষাকারী class, Money আর std::expected

সবচেয়ে ভেতরের স্তর দিয়ে শুরু, কারণ বাকি সবাই এর ওপর দাঁড়ায় — আর এটাই সবচেয়ে সহজে টেস্ট করা যায়, কারণ এর কোনো I/O নেই। প্রথম টুকরো টাকা। পর্ব ২৩-এ Money টাইপের ধারণা দেখেছেন; এখানে তার একটি সংক্ষিপ্ত রূপ। এটি একটি value object — পরিচয় নয়, মানই এর সব: দুটি ৬ টাকা সবসময় সমান।

class Money {
    long long paisa_ = 0;                          // invariant: পূর্ণ পয়সা, double নয়
    constexpr explicit Money(long long p) : paisa_(p) {}
public:
    constexpr Money() = default;
    static constexpr Money paisa(long long p) { return Money{p}; }
    static constexpr Money taka(long long t)  { return Money{t * 100}; }
    constexpr long long in_paisa() const { return paisa_; }

    friend constexpr Money operator+(Money a, Money b) { return Money{a.paisa_ + b.paisa_}; }
    friend constexpr Money operator*(Money a, int n)   { return Money{a.paisa_ * n}; }
    friend constexpr auto operator<=>(const Money&, const Money&) = default;
};
std::string to_text(Money m);                      // Money::paisa(650) -> "6.50 Tk"

constructor private, তাই Money{5} লিখে কেউ বোঝাতে পারবেন না এটা ৫ টাকা না ৫ পয়সা — নাম দেওয়া factory function (Money::taka(2)) বাধ্য করে স্পষ্ট হতে। Money-এর সাথে int যোগ করার কোনো operator নেই, ফলে “জরিমানা + দিন নম্বর” ধরনের ভুল compiler-ই আটকায়। operator<=> পর্ব ৪২-এর মতো সব তুলনা একবারে দেয়।

এরপর ভুলের কারণ। পর্ব ১৯-এর Result enum-এর বড় ভাই — এখন এটি std::expected-এর error অংশ:

enum class Error { BadInput, NoSuchBook, NoSuchMember, NoCopyLeft,
                   AlreadyHas, NoSuchLoan, StorageFailed, BadFile };
std::string_view message(Error e);                 // মানুষের ভাষা — CLI ব্যবহার করে

using Day = int;                                   // পরে std::chrono-তে বদলানো যায়
inline constexpr int   kLoanDays   = 14;
inline constexpr Money kFinePerDay = Money::taka(2);

class Library {
public:
    [[nodiscard]] std::expected<void, Error>  add_copies(const std::string& book, int n);
    [[nodiscard]] std::expected<void, Error>  add_member(const std::string& member);
    [[nodiscard]] std::expected<void, Error>  lend(const std::string& m, const std::string& b, Day d);
    [[nodiscard]] std::expected<Money, Error> give_back(const std::string& m, const std::string& b, Day d);
    // শুধু পড়ার জন্য: রিপোর্ট আর storage এগুলো দিয়ে দেখে
    const std::map<std::string, Book>& books() const { return books_; }
    const std::vector<Loan>& loans() const { return loans_; }
    Money fines() const { return fines_; }
private:
    std::map<std::string, Book> books_;            // I1 রক্ষা করে Book নিজেই
    std::set<std::string>       members_;
    std::vector<Loan>           loans_;            // I2: {member, book} একবারই
    Money                       fines_;            // I3: শুধু বাড়ে
};

কয়েকটি বিষয় খেয়াল করুন। প্রতিটি বদলানোর ফাংশনের ফেরত টাইপেই লেখা “এটি ব্যর্থ হতে পারে”, আর [[nodiscard]] থাকায় ফল ফেলে দিলে compiler সতর্ক করে। পড়ার ফাংশনগুলো const& ফেরত দেয় — রিপোর্ট দেখতে পারে, কিন্তু বদলাতে পারে না (পর্ব ১০-এর const-correctness). আর সব ডেটা private — পর্ব ১৯-এ lg.items[0].qty = -5; লেখা যেত, এখানে lib.books_ লিখলে compile-ই হবে না। invariant এখন compiler-এর পাহারায়, পর্ব ২০-এ যেমন কথা দেওয়া হয়েছিল।

প্রশ্ন: loans_ কেন vector, অথচ রানেবল কোডে map? পূর্ণ সংস্করণে ধারের ইতিহাসও রাখা হয় (কে কবে নিল, কবে ফেরত দিল), আর রিপোর্ট সময়ের ক্রমে হাঁটে — তাই vector. অংশ ৫-এর এক ফাইলের রূপে ইতিহাস নেই, শুধু “এখন কার কাছে কী”, তাই {member, book} চাবির std::map সবচেয়ে ছোট সমাধান। দুটিই সঠিক; প্রশ্ন হলো কোন প্রশ্নের উত্তর দ্রুত দিতে চান। নকশা সবসময় প্রশ্ন থেকে আসে।

যাচাই আর বদল — ব্যর্থ কমান্ডে কিছুই বদলায় না

domain-এর সবচেয়ে গুরুত্বপূর্ণ প্রতিশ্রুতি পর্ব ৩৭-এর strong guarantee-এর মতো: কাজ হয় পুরোপুরি সফল, নয়তো অবস্থা ঠিক আগের মতো। কৌশলটি সরল — সব পরীক্ষা আগে, সব বদল শেষে, আর পরীক্ষা আর বদলের মাঝে এমন কিছু নেই যা ব্যর্থ হতে পারে। এবার একটি ভুল অনুরোধ দেখুন: B1-এর দুটি কপিই বাইরে, আর সাকিব তৃতীয়টি চাইছেন।

1auto cmd = parse(line);   // LendCmd{"sakib","B1",3}2auto it = books_.find(cmd.book);3if (it == books_.end()) return unexpected(NoSuchBook);4if (!it->second.can_lend()) return unexpected(NoCopyLeft);5loans_.push_back(...); it->second.take();  // not reached6if (!r) std::println("error: {}", message(r.error()));7report();
এই মুহূর্তের অবস্থা
B1 total / out
loans_.size()
it
r
I1: 0 ≤ out ≤ total
  1. পার্স সফল। লেখাটি ঠিকঠাক — তিনটি শব্দ, শেষেরটি সংখ্যা। CLI-এর যাচাই শুধু আকার দেখে; বই আছে কি না, সেটা জানার অধিকার CLI-এর নেই।
  2. বই খোঁজা। std::map::find B1 পেল। লক্ষ করুন, operator[] নয় — books_[b] লিখলে অজানা বই নীরবে তৈরি হয়ে যেত, শুধু খোঁজার চেষ্টাতেই অবস্থা বদলে যেত!
  3. প্রথম পাহারা পার। বই আছে। এখনো কিছুই বদলানো হয়নি — এই নিয়মটিই পুরো ফাংশনের প্রাণ।
  4. দ্বিতীয় পাহারায় থামা। can_lend() মিথ্যা, কারণ out == total. ফাংশন std::unexpected ফেরত দিয়ে এখানেই শেষ। এটি একটি guard clause — নিয়ম ভাঙবে এমন পথ শুরুতেই বন্ধ।
  5. লাইন ৫ কখনো চলেনি। CLI ফলের error() থেকে বার্তা বানাল। কোনো exception ওড়েনি, কোনো stack unwinding হয়নি — একটি সাধারণ ফেরত মান।
  6. প্রমাণ: অবস্থা অক্ষত। report আগের মতোই: B1-এর দুটিই বাইরে, ধার দুটি। ভেরিয়েবলের সারিতে দেখুন, প্রথম থেকে শেষ পর্যন্ত একটি সংখ্যাও বদলায়নি, আর I1 সবসময় সত্য। টেস্টেও ঠিক এটাই যাচাই করা হবে — শুধু “error এল” নয়, “অবস্থা বদলায়নি”।
মূল ধারণা

দুই ধরনের যাচাই, দুই জায়গায়। CLI দেখে লেখার আকার (“সংখ্যা তো?”), domain দেখে ব্যবসার নিয়ম (“কপি আছে তো?”)। আকারের ভুল domain পর্যন্ত পৌঁছায় না; নিয়মের ভুল domain ছাড়া কেউ ধরতে পারে না। একই যাচাই দুই জায়গায় লিখলে একদিন দুটি আলাদা হয়ে যাবে।

৫ · এক ফাইলে পুরো সিস্টেম — চালিয়ে দেখুন

আসল প্রজেক্টে ফাইল অনেক, কিন্তু অনলাইনে চালাতে একটি ফাইল লাগে। তাই নিচে একটি সংক্ষিপ্ত রূপ — domain (Library class) আর CLI (main) পুরোপুরি আছে, service আর storage বাদ (storage-এর একটি সরল রূপ অংশ ৭-এর টেস্টে চলবে)। Money class-এর জায়গায় সরল using Paisa = long long, আর error হিসেবে সরাসরি বার্তার std::string. ইনপুট আগে থেকেই দেওয়া — একটি সেশন, যাতে ইচ্ছা করে ছয় রকম ভুল রাখা আছে। “Run” চাপুন:

#include <algorithm>
#include <expected>
#include <iostream>
#include <map>
#include <print>
#include <sstream>
#include <string>

using Paisa  = long long;                          // money in paisa, never double
using Result = std::expected<Paisa, std::string>;  // value or a reason
constexpr int   kLoanDays   = 14;
constexpr Paisa kFinePerDay = 200;                 // 2 Tk for each late day

class Library {                                    // invariant: 0 <= out <= total
    struct Copies { int total = 0, out = 0; };
    std::map<std::string, Copies> books_;
    std::map<std::pair<std::string, std::string>, int> loans_;  // {member, book} -> day
    Paisa fines_ = 0;
public:
    Result add(const std::string& b, int n) {
        if (n <= 0) return std::unexpected("copies must be positive");
        books_[b].total += n; return 0;
    }
    Result lend(const std::string& m, const std::string& b, int day) {
        auto it = books_.find(b);
        if (it == books_.end()) return std::unexpected("no book " + b);
        if (it->second.out == it->second.total) return std::unexpected("no copy of " + b + " left");
        if (!loans_.try_emplace({m, b}, day).second) return std::unexpected(m + " already has " + b);
        ++it->second.out; return 0;                // change state only after every check
    }
    Result give_back(const std::string& m, const std::string& b, int day) {
        auto it = loans_.find({m, b});
        if (it == loans_.end() || day < it->second) return std::unexpected("no such loan");
        Paisa fine = std::max(0, day - it->second - kLoanDays) * kFinePerDay;
        loans_.erase(it); --books_[b].out; fines_ += fine; return fine;
    }
    void report() const {
        for (const auto& [id, c] : books_) std::println("{:<4} {}/{} on shelf", id, c.total - c.out, c.total);
        std::println("loans: {}  fines: {}.{:02} Tk", loans_.size(), fines_ / 100, fines_ % 100);
    }
};

int main() {
    Library lib;
    for (std::string line; std::getline(std::cin, line); ) {
        std::istringstream in{line};
        std::string cmd, a, b; int n = 0;
        in >> cmd;
        if (cmd == "report") { lib.report(); continue; }
        Result r = std::unexpected("unknown command '" + cmd + "'");
        if      (cmd == "add"    && in >> a >> n)      r = lib.add(a, n);
        else if (cmd == "lend"   && in >> a >> b >> n) r = lib.lend(a, b, n);
        else if (cmd == "return" && in >> a >> b >> n) r = lib.give_back(a, b, n);
        else if (cmd == "add" || cmd == "lend" || cmd == "return") r = std::unexpected("bad numbers in '" + line + "'");
        if (!r)                   std::println("error: {}", r.error());
        else if (cmd == "return") std::println("ok, fine {}.{:02} Tk", *r / 100, *r % 100);
        else                      std::println("ok");
    }
}

১৪টি ইনপুট-লাইনের জবাবে আসে: ok, ok, শূন্য কপিতে copies must be positive, দুটি সফল ধার, তারপর তিনটি ভুল — no copy of B1 left, no book B9, আর bad numbers in 'lend rina B2 abc'. রিনা দিন ১-এ নিয়ে দিন ১৮-তে ফেরত দিলেন: ১৭ দিন, অর্থাৎ ৩ দিন দেরি, ৩ × ২০০ = ৬০০ পয়সা — ok, fine 6.00 Tk. একই বই আবার ফেরত দিতে চাইলে no such loan; তামিম সময়মতো ফেরত দিলেন, জরিমানা 0.00; রিনা B2 নিলেন; fly অজানা। শেষে report: B1 2/2, B2 0/1, loans: 1 fines: 6.00 Tk. খেয়াল করুন: (১) lend-এ find, কিন্তু add-এ books_[b] — add-এ নতুন বই তৈরি হওয়াটাই উদ্দেশ্য। (২) try_emplace একসাথে “আছে কি না” পরীক্ষা আর ঢোকানো করে; ঢোকানো হলেই শুধু out বাড়ে — আর তার পরে ব্যর্থ হতে পারে এমন কিছু নেই। (৩) std::unexpected("...") একটি const char* রাখে, যা std::string-এ রূপান্তর হয়ে error-এ বসে। (৪) CLI-তে in >> a >> n সফল না হলে domain-কে ডাকাই হয় না — আকারের যাচাই ওপরে, নিয়মের যাচাই নিচে।

C জানলে

C কোর্সের ক্যাপস্টোনেও একই দর্শন — interface আগে, header-ই চুক্তি, ধাপে ধাপে বানানো, test আর sanitizer. পার্থক্য প্রকাশের ক্ষমতায়: C-তে ব্যর্থতা জানাতে হয় int ফেরত কোড আর out parameter দিয়ে, যা ডাকার জায়গা চাইলে উপেক্ষা করতে পারে; C++-এ std::expected মান আর কারণকে একটি টাইপে বাঁধে। C-তে invariant রক্ষা হয় opaque pointer আর শৃঙ্খলায়, C++-এ private আর constructor-এ। storage-এর “interface” C-তে function pointer-এর struct, C++-এ virtual ফাংশন। পাশাপাশি দেখতে C কোর্সের ক্যাপস্টোন পর্বটি খুলুন।

৬ · Storage — একটি interface, দুটি রূপ, আর নিরাপদ round trip

খাতা ভিজে গেলে সব হারায় — নাসরিন আপার তৃতীয় সমস্যা। প্রোগ্রাম বন্ধ হলে মেমরির সব ডেটা মুছে যায়, তাই দরকার persistence: ডেটাকে প্রোগ্রামের জীবনের বাইরে টিকিয়ে রাখা। কিন্তু domain যদি সরাসরি ফাইল খোলে, তাহলে প্রতিটি টেস্ট ডিস্কে লিখবে, ধীর হবে, আর একটির ফাইল আরেকটিকে নষ্ট করবে। সমাধান পর্ব ২৬-এর abstract class: একটি চুক্তি, যার পেছনে যেকোনো রূপ বসানো যায়।

#pragma once
#include <expected>
#include <string>
#include "domain/error.h"

class Storage {                                    // চুক্তি: একটি লেখা রাখা ও ফেরত দেওয়া
public:
    virtual ~Storage() = default;                  // base pointer দিয়ে মুছলেও ঠিক destructor
    [[nodiscard]] virtual std::expected<void, Error> write(const std::string& text) = 0;
    [[nodiscard]] virtual std::expected<std::string, Error> read() const = 0;
};
#include "storage/file_storage.h"
#include <fstream>
#include <sstream>

namespace fs = std::filesystem;

std::expected<void, Error> FileStorage::write(const std::string& text) {
    fs::path tmp = path_;
    tmp += ".tmp";                                 // আগে পাশের একটি ফাইলে লিখি
    {
        std::ofstream out(tmp, std::ios::binary);
        out << text;
        out.close();                               // এখনই flush, যাতে ভুল ধরা যায়
        if (!out) return std::unexpected(Error::StorageFailed);
    }                                              // RAII: যা-ই ঘটুক, ফাইল বন্ধ
    std::error_code ec;
    fs::rename(tmp, path_, ec);                    // তারপর এক ধাপে পুরোনোটার জায়গায়
    if (ec) return std::unexpected(Error::StorageFailed);
    return {};
}

std::expected<std::string, Error> FileStorage::read() const {
    std::ifstream in(path_, std::ios::binary);
    if (!in) return std::unexpected(Error::StorageFailed);
    std::ostringstream all;
    all << in.rdbuf();                             // পুরো ফাইল একবারে
    return all.str();
}
#pragma once
#include "storage/storage.h"

class MemoryStorage final : public Storage {       // টেস্টের জন্য: ডিস্ক নেই
public:
    std::expected<void, Error> write(const std::string& text) override {
        if (fail_next_) { fail_next_ = false; return std::unexpected(Error::StorageFailed); }
        data_ = text; return {};
    }
    std::expected<std::string, Error> read() const override { return data_; }
    void fail_next_write() { fail_next_ = true; }  // ডিস্ক-ভরা অবস্থার অভিনয়
private:
    std::string data_;
    bool fail_next_ = false;
};

তিনটি জিনিস লক্ষ করুন। এক, FileStorage::write সরাসরি আসল ফাইলে লেখে না। আগে library.txt.tmp-এ লেখে, সফল হলে std::filesystem::rename দিয়ে পুরোনো ফাইলের জায়গায় বসায়। লেখার মাঝপথে বিদ্যুৎ গেলে নষ্ট হয় শুধু tmp ফাইল; আসলটি আগের অবস্থায় থাকে। এই কৌশলের নাম atomic write. POSIX সিস্টেমে একই ফাইল-সিস্টেমের ভেতরে rename একটি ধাপে ঘটে বলে নিশ্চয়তা দেওয়া আছে; তবে লেখাটি সত্যিই ডিস্কে পৌঁছেছে কি না (fsync) — সেই নিশ্চয়তা স্ট্যান্ডার্ড C++ দেয় না, তার জন্য অপারেটিং সিস্টেমের ফাংশন লাগে। দুই, block-এর }-এ std::ofstream নিজেই ফাইল বন্ধ করে — পর্ব ১৭-এর RAII; কোনো পথে close ভুলে যাওয়ার সুযোগ নেই। তিন, MemoryStorage একটি test double, আর ইচ্ছা করলে ব্যর্থও হতে পারে — ফলে “ডিস্ক ভরা থাকলে কী হয়” টেস্ট করতে আসল ডিস্ক ভরাতে হয় না।

service কোন রূপ পেল, জানে না — constructor-এ একটি Storage& নেয়। এর নাম dependency injection: নির্ভরতা বাইরে থেকে ঢোকানো।

// আসল প্রোগ্রাম                          // টেস্ট
FileStorage disk{"library.txt"};           MemoryStorage mem;
Library lib;                               Library lib;
LibraryService svc{lib, disk};             LibraryService svc{lib, mem};

save → ফাইল → load: একটি round trip

মেমরির object-কে লেখায় রূপান্তরকে বলে serialization, আর উল্টোটাকে parsing. একটি সংরক্ষণ-ব্যবস্থা তখনই বিশ্বাসযোগ্য, যখন “লিখে আবার পড়লে হুবহু একই জিনিস” — এর পরীক্ষাকে বলে round-trip test. সবচেয়ে সূক্ষ্ম অংশ load: ফাইলটি ভাঙা হলে কী হবে? ধাপে ধাপে দেখুন:

  1. মেমরিLibraryআসল অবস্থা
  2. লেখাserializev1 ফরম্যাট
  3. ডিস্কtmp → renameএক ধাপে বদল
  4. পড়াparseপ্রতিটি লাইন যাচাই
  5. নতুনLibraryতারপর বদল
মেমরিতেসংরক্ষণের ঠিক আগে
books_   B1 {total 2, out 1}   B2 {total 1, out 0}
members_ rina, tamim
loans_   {rina, B1, day 1}
fines_   600 paisa
serialize()সাধারণ লেখা, প্রথম লাইনে সংস্করণ
alo-library v1
book B1 2
book B2 1
member rina
member tamim
loan rina B1 1
fines 600
out লেখা হয়নি — loan লাইন থেকে আবার গোনা যায়
FileStorage::writeপুরোনো ফাইল শেষ মুহূর্ত পর্যন্ত অক্ষত
library.txt.tmp  ← নতুন লেখা পুরোটা
close(), !out?   না, সফল
rename(tmp, library.txt)
এর মাঝে বিদ্যুৎ গেলে: library.txt পুরোনো, কিন্তু সম্পূর্ণ
load() · parseফাইলও বাইরের ইনপুট — বিশ্বাস নয়, যাচাই
প্রথম লাইন "alo-library v1"?       হ্যাঁ
book B1 2      → add_copies("B1", 2)     ok
loan rina B1 1 → lend("rina","B1",1)     ok
loan sakib B7 3 → NoSuchBook  → unexpected(BadFile)
একই domain ফাংশন দিয়ে তৈরি, তাই invariant আবার যাচাই হয়
LibraryService::loadসব সফল হলে তবেই বদল
auto fresh = parse_library(text);      // নতুন, আলাদা object
if (!fresh) return std::unexpected(fresh.error());
lib_ = std::move(*fresh);              // move: কপি নয় (পর্ব ৩৬)
report → হুবহু ধাপ ১-এর মতো · ভাঙা ফাইলে পুরোনো lib_ অক্ষত
  1. মেমরিতে আসল অবস্থা। দুটি বই, দুজন সদস্য, একটি ধার, ৬ টাকা জরিমানা। round trip-এর লক্ষ্য: পরে ঠিক এটাই ফিরে পাওয়া।
  2. লেখায় রূপান্তর। প্রতিটি লাইন একটি তথ্য। একটি সূক্ষ্ম সিদ্ধান্ত: out লেখা হয়নি, কারণ সেটা loan লাইন গুনেই পাওয়া যায়। একই তথ্য দুবার রাখলে একদিন দুটি আলাদা হবে — তখন কোনটা সত্য? প্রথম লাইনের সংস্করণ ভবিষ্যতের জন্য বীমা।
  3. নিরাপদে ডিস্কে। tmp-এ পুরোটা লেখা, flush আর যাচাই, তারপর rename. যেকোনো মুহূর্তে প্রোগ্রাম মরে গেলেও ডিস্কে থাকে হয় পুরোনো সম্পূর্ণ ফাইল, নয়তো নতুন সম্পূর্ণ ফাইল — অর্ধেক নয়।
  4. পড়া মানে যাচাই। ফাইলটি কেউ হাতে বদলে থাকতে পারেন, বা অন্য সংস্করণের হতে পারে। তাই প্রতিটি লাইন domain-এর সাধারণ ফাংশন (add_copies, lend) দিয়ে প্রয়োগ করা হয় — ফলে invariant-এর যাচাই আলাদা করে লিখতে হয় না। একটি লাইনও ভুল হলে পুরো load বাতিল।
  5. সব ঠিক হলে তবেই বদল। নতুন Library আলাদা একটি object-এ তৈরি হয়; সফল হলে শুধু তখন std::move দিয়ে পুরোনোটার জায়গায়। ভাঙা ফাইল দিলে lib_ একটুও বদলায় না — ঠিক যেমন ব্যর্থ lend-এ বদলায়নি। একই নীতি, বড় মাপে।

রিপোর্ট — container, algorithm আর ranges

domain যা জানে, রিপোর্ট তা থেকে প্রশ্নের উত্তর বানায়: কার কাছে দেরি হচ্ছে? কোন বই সবচেয়ে জনপ্রিয়? এখানে মডিউল ৫-এর যন্ত্রপাতি — views::filter, ranges::sort-এর projection আর views::take. রিপোর্ট শুধু const& দিয়ে পড়ে, তাই সে নিজে কখনো invariant ভাঙতে পারে না:

// দিন `today`-তে যাদের দেরি হচ্ছে
auto late = lib.loans() | std::views::filter([today](const Loan& l) {
    return today - l.day > kLoanDays;
});
for (const Loan& l : late)
    std::println("{:<8} {:<4} late {} days", l.member, l.book, today - l.day - kLoanDays);

// সবচেয়ে বেশি ধার হওয়া ৩টি বই
auto top = lib.books() | std::views::values | std::ranges::to<std::vector>();
std::ranges::sort(top, std::ranges::greater{}, &Book::times_lent);   // projection
for (const Book& b : top | std::views::take(3))
    std::println("{:<4} {}", b.id(), b.times_lent());

views::filter কোনো নতুন vector বানায় না — হাঁটার সময় শর্ত দেখে (পর্ব ৩৫-এর lazy evaluation). std::ranges::to C++23 থেকে আছে; এর আগে হাতে vector বানিয়ে কপি করতে হতো। sort-এর তৃতীয় argument একটি member function pointer — ranges algorithm-এর projection সেটি ডেকে তুলনার মান বের করে, ফলে আলাদা comparator lambda লিখতে হয় না। আর রিপোর্টের আউটপুট নির্ধারিত (map-এর ক্রম), তাই golden-file টেস্টে মেলানো যায়।

৭ · ধাপে ধাপে বানানো — ছয় মাইলস্টোন, টেস্ট harness আর CMake

পর্ব ১৯-এর শিক্ষা এখানে আরও জরুরি: পুরো সিস্টেম একবারে লিখে প্রথমবার compile করবেন না। ছয়টি মাইলস্টোন, প্রতিটির শেষে প্রোগ্রাম চলে আর টেস্ট সবুজ। লক্ষ করুন, ক্রমটি ভেতর থেকে বাইরে — আগে domain, শেষে সাজসজ্জা:

  1. M1domainI/O ছাড়া
  2. M2CLIকথা বলা
  3. M3expectedসব ভুল
  4. M4Storageমেমরিতে
  5. M5file+CMakeআসল ডিস্ক
  6. M6reportsশেষ ঘষামাজা
M1 · domain আর Moneyএকটিও cin বা println নেই
Money, Error, Book, Library::add_copies / lend / give_back
টেস্ট: সফল ধার · শেষ কপি · কপি নেই → অবস্থা অক্ষত
      ঠিক ১৪ দিনে ফেরত → 0 · ১৭ দিনে → 6.00 Tk
M2 · CLI লুপtext protocol থেকে Command variant
getline → parse_command → std::visit → println
টেস্ট: sessions/basic.txt → golden output মেলে
      EOF-এ শান্তভাবে শেষ · অজানা কমান্ডে বার্তা
M3 · সব ভুলের পথপ্রতিটি Error-এর অন্তত একটি টেস্ট
BadInput · NoSuchBook · NoSuchMember · NoCopyLeft
AlreadyHas · NoSuchLoan
টেস্ট: sessions/errors.txt — প্রতিটি ভুলের পর report অপরিবর্তিত
এখানেই MVP: নাসরিন আপা ব্যবহার শুরু করতে পারেন
M4 · Storage interfaceপ্রথমে শুধু MemoryStorage
serialize · parse_library · save · load
টেস্ট: round trip হুবহু · ভাঙা ফাইলে BadFile, অবস্থা অক্ষত
      fail_next_write() → StorageFailed
M5 · FileStorage আর বিল্ডtmp + rename, CMake, ctest
cmake -S . -B build && cmake --build build && ctest --test-dir build
টেস্ট: আসল ফাইলে save → প্রোগ্রাম বন্ধ → খুলে load
      -fsanitize=address,undefined-এ সব টেস্ট পরিষ্কার
M6 · রিপোর্ট আর READMEranges, format, ডকুমেন্টেশন
overdue <day> · top · report-এর সুন্দর টেবিল
টেস্ট: M1–M5-এর সব টেস্ট হুবহু আবার পাস (refactoring-এর প্রমাণ)
ঐচ্ছিক: std::jthread দিয়ে background autosave
  1. M1 — domain, কোনো I/O ছাড়া। সবচেয়ে মূল্যবান অংশ আগে, আর সবচেয়ে সহজে টেস্টযোগ্য অবস্থায়। এখানে কোনো কীবোর্ড-পর্দা নেই, তাই প্রতিটি নিয়ম একটি unit test-এ এক লাইনে যাচাই করা যায়।
  2. M2 — CLI. এবার মানুষ কথা বলতে পারেন। প্রথম golden-file সেশন-টেস্ট: ইনপুট ফাইল আর আগে যাচাই করে রাখা আউটপুট ফাইল। একে বলে end-to-end test — পুরো প্রোগ্রাম বাইরে থেকে।
  3. M3 — সব ভুলের পথ। প্রতিটি Error-এর জন্য অন্তত একটি টেস্ট, আর প্রতিটিতে “অবস্থা বদলায়নি” যাচাই। কাজ এখানে থামলেও নাসরিন আপার হাতে ব্যবহারযোগ্য প্রোগ্রাম থাকত — এটাই MVP.
  4. M4 — Storage, প্রথমে মেমরিতে। interface আর MemoryStorage দিয়ে save/load-এর সব যুক্তি টেস্ট হয়ে যায়, ডিস্ক ছোঁয়ার আগেই। ব্যর্থতার অভিনয়ও এখানে সস্তা।
  5. M5 — আসল ফাইল আর বিল্ড-ব্যবস্থা। এখন শুধু একটি নতুন class (FileStorage) আর CMake. ফাইল অনেক হওয়ায় পর্ব ৪৭-এর CMake, আর পর্ব ৪৮-এর sanitizer দিয়ে সব টেস্ট।
  6. M6 — রিপোর্ট আর ঘষামাজা। ranges-এ নতুন রিপোর্ট, সুন্দর টেবিল, README. ভেতরে যত বদলই হোক, আগের সব টেস্ট পাস — সেটাই প্রমাণ যে কিছু ভাঙেনি।

একটি ছোট টেস্ট harness — নিজের হাতে

আসল প্রজেক্টে GoogleTest বা Catch2-এর মতো framework চলে (পর্ব ৪৮)। কিন্তু মূল ধারণাটি এতই সরল যে দশ লাইনে লেখা যায়: একটি শর্ত, একটি নাম, PASS বা FAIL ছাপা, আর শেষে ব্যর্থতার সংখ্যা exit code হিসেবে — যাতে CI বা ctest বুঝতে পারে। এমন ছোট কাঠামোকে বলে test harness. নিচের টেস্টে Storage interface, MemoryStorage, round trip আর ভাঙা ফাইল — অংশ ৬-এর সব কিছু, একটি সরল Catalog-এর ওপর:

#include <algorithm>
#include <expected>
#include <format>
#include <map>
#include <print>
#include <sstream>
#include <string>

struct Storage {                                   // the storage interface
    virtual ~Storage() = default;
    virtual void write(const std::string& text) = 0;
    virtual std::string read() const = 0;
};
struct MemoryStorage : Storage {                   // test double: no disk at all
    std::string data;
    void write(const std::string& text) override { data = text; }
    std::string read() const override { return data; }
};
using Catalog = std::map<std::string, int>;        // book id -> copies

void save(const Catalog& c, Storage& s) {
    std::string out;
    for (const auto& [id, n] : c) out += std::format("{} {}\n", id, n);
    s.write(out);
}
std::expected<Catalog, std::string> load(const Storage& s) {
    Catalog c; std::istringstream in{s.read()};
    std::string id; int n = 0;
    while (in >> id >> n) {
        if (n <= 0) return std::unexpected("bad copies for " + id);
        c[id] = n;
    }
    if (!in.eof()) return std::unexpected("broken line");
    return c;
}
long long fine_for(int lent, int back) { return std::max(0, back - lent - 14) * 200LL; }

int failures = 0;
void check(bool ok, const char* name) {
    std::println("{} {}", ok ? "PASS" : "FAIL", name);
    if (!ok) ++failures;
}

int main() {
    check(fine_for(1, 15) == 0, "returned on day 14: no fine");
    check(fine_for(1, 18) == 600, "3 days late: 6.00 Tk");
    MemoryStorage mem;
    const Catalog books{{"B1", 2}, {"B2", 1}};
    save(books, mem);
    auto back = load(mem);
    check(back && *back == books, "save then load gives the same catalog");
    mem.write("B1 2\nB2 -4\n");
    check(!load(mem), "negative copies are rejected");
    mem.write("B1 two\n");
    check(!load(mem), "broken number is rejected");
    return failures;                               // 0 means every test passed
}

আউটপুটে পাঁচটি লাইন, প্রতিটি PASS দিয়ে শুরু: জরিমানার দুটি সীমানা (ঠিক ১৪ দিন আর ৩ দিন দেরি), round trip, ঋণাত্মক কপি আর অক্ষরে লেখা সংখ্যা। save আর load শুধু Storage& চেনে — MemoryStorage দিলেও চলে, কাল FileStorage দিলেও চলবে; টেস্টের একটি লাইনও বদলাতে হবে না। ভাঙা ফাইল ধরার কৌশলটি দেখুন: লুপ থামে দুই কারণে — লেখা শেষ (তখন eof() সত্য), অথবা "two"-এর মতো কিছু int-এ পড়া যায়নি (তখন eof নয়)। দ্বিতীয়টিকে আমরা “broken line” বলি। একটি ভালো অভ্যাস: harness assert-এর মতো প্রথম ব্যর্থতায় থামে না, সব টেস্ট চালিয়ে কতগুলো ব্যর্থ তা জানায় — একবার চালিয়েই পুরো ছবি পাওয়া যায়। এখানে স্ট্যান্ডার্ড std::string error, কিন্তু আসল প্রজেক্টে Error enum, যাতে টেস্ট নির্দিষ্ট কারণটাও যাচাই করতে পারে (load(mem).error() == Error::BadFile).

CMake — পুরো প্রজেক্টের বিল্ড এক জায়গায়

পর্ব ৪৭-এর মতো, domain + storage + service মিলে একটি library target, যার সাথে দুটি executable link হয় — আসল প্রোগ্রাম আর টেস্ট। ফলে টেস্ট ঠিক সেই কোডই পরীক্ষা করে, যা প্রোগ্রামে চলে (এটি চালানোর কোড নয়, বিল্ডের বিবরণ):

cmake_minimum_required(VERSION 3.25)
project(alo_library LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(library_core
    src/domain/library.cpp
    src/domain/money.cpp
    src/storage/file_storage.cpp
    src/service/library_service.cpp)
target_include_directories(library_core PUBLIC src)
target_compile_options(library_core PUBLIC -Wall -Wextra -Wpedantic)

add_executable(alo src/cli/main.cpp src/cli/command.cpp)
target_link_libraries(alo PRIVATE library_core)

enable_testing()
add_executable(library_tests tests/test_main.cpp tests/test_library.cpp)
target_link_libraries(library_tests PRIVATE library_core)
add_test(NAME unit COMMAND library_tests)

ctest চালালে library_tests-এর exit code দেখে — ০ মানে সফল। harness-এর return failures; ঠিক এই কারণেই। -Wextra আর -Wpedantic GCC আর Clang-এর flag; MSVC-র flag আলাদা (যেমন /W4), তাই আসল প্রজেক্টে compiler দেখে শর্তসাপেক্ষে দেওয়া হয়।

পরীক্ষা ও ইন্টারভিউতে
  • std::expected কখন, exception কখন? — ব্যর্থতা যখন স্বাভাবিক ও প্রত্যাশিত (ভুল ইনপুট, না পাওয়া) তখন expected; যখন বিরল ও ডাকার জায়গায় সামলানো যায় না (মেমরি শেষ, constructor-এ ব্যর্থতা) তখন exception.
  • storage-কে interface বানানোর লাভ কী? — ওপরের স্তর বিমূর্ত চুক্তির ওপর নির্ভর করে; টেস্টে in-memory রূপ, আসলে ফাইল বা ডেটাবেস — কোনো কোড না বদলে।
  • invariant কী, class কীভাবে রক্ষা করে? — object-এর সবসময়-সত্য শর্ত; ডেটা private, constructor বৈধ অবস্থায় শুরু করায়, আর প্রতিটি public ফাংশন শেষে শর্তটি সত্য রাখে।
  • ফাইল নিরাপদে overwrite করবেন কীভাবে? — অস্থায়ী ফাইলে পুরোটা লিখে, flush ও যাচাই করে, তারপর rename দিয়ে আসল ফাইলের জায়গায়।
  • এই প্রজেক্টে RAII কোথায়? — std::ofstream scope শেষে ফাইল বন্ধ করে, container নিজের মেমরি ছাড়ে, std::jthread destructor-এ থামতে বলে ও join করে, lock_guard নিজেই mutex ছাড়ে।

৮ · AI-এর যুগে — ক্যাপস্টোনে AI আপনার pair programmer, মালিক আপনি

আজ একটি প্রম্পটেই এমন পুরো একটি প্রজেক্ট AI দিয়ে লেখানো যায় — হাজার লাইন, সুন্দর README সহ। কিন্তু ক্যাপস্টোনের উদ্দেশ্য কোড জমা দেওয়া নয়; উদ্দেশ্য হলো আপনি একটি সিস্টেম নকশা করতে, ব্যাখ্যা করতে আর প্রমাণ করতে পারেন। ইন্টারভিউতে কেউ জিজ্ঞেস করবেন “load ব্যর্থ হলে পুরোনো অবস্থা কেন থাকে?” — তখন AI পাশে থাকবে না। তাই পর্ব ১৯-এর pair programming নিয়মগুলো এখানে আরও কঠোর:

🔍 Review চান, শুধু কোড নয়

নিজের লেখা library.cpp দিয়ে বলুন: “কোন পথে invariant ভাঙতে পারে?” AI-এর code review দ্বিতীয় চোখ — রায় নয়, প্রশ্নের তালিকা।

পর্যালোচক

🧪 টেস্টের ধারণা চান

“কোন edge case আমি বাদ দিয়েছি?” AI এখানে খুব কাজের। কিন্তু প্রতিটি প্রত্যাশিত ফল নিজে spec থেকে মিলিয়ে নিন।

পরীক্ষক

📖 ব্যাখ্যা চান

বুঝতে না পারা লাইন (try_emplace কেন?) ব্যাখ্যা করাতে বলুন, তারপর নিজের ভাষায় README-তে লিখুন। যা ব্যাখ্যা করতে পারেন না, তা জমা দেবেন না।

শিক্ষক

একটি review-প্রম্পট। লক্ষ করুন — কোড চাওয়া হচ্ছে না, চাওয়া হচ্ছে প্রশ্ন আর ব্যর্থ হবে এমন টেস্ট:

আমি C++23-এ একটি লাইব্রেরির বই ধারের CLI বানাচ্ছি (g++ -std=c++23 -Wall -Wextra). স্তর: CLI → service → domain → storage. নিচে আমার domain/library.cpp দিলাম। Invariant: I1: প্রতিটি বইয়ে 0 <= out <= total I2: একজন সদস্যের কাছে একই বই একবারই I3: মোট জরিমানা শুধু বাড়ে, পয়সায় তোমার কাজ: ১. পুরো কোড নতুন করে লিখবে না। ২. কোন ফাংশনের কোন পথে কোনো invariant ভাঙতে পারে, লাইন ধরে দেখাও। ৩. কোথাও ব্যর্থ হলে অবস্থা আংশিক বদলে থাকে কি না দেখাও। ৪. প্রতিটি সমস্যার জন্য একটি ছোট টেস্ট দাও, যা এখনকার কোডে ব্যর্থ হবে। ৫. কোনো দাবি নিশ্চিত না হলে "নিশ্চিত নই" লেখো। [এখানে কোড]

AI-এর সাহায্যে লেখা ক্যাপস্টোন যাচাইয়ের চেকলিস্ট

AI দ্রুত লিখতে পারে। কিন্তু invariant কী, টেস্ট কী প্রমাণ করে,
আর কোন সিদ্ধান্ত কেন — এগুলো আপনার মাথায় না থাকলে প্রজেক্টটি আপনার নয়।

৯ · এরপর কোথায় — বাড়ানোর দিক, portfolio আর কোর্সের মানচিত্র

MVP শেষ হলে প্রজেক্ট শেষ নয় — বরং এখন এটি একটি নিরাপদ ভিত্তি, যার ওপর আরও কিছু বসানো যায়। প্রতিটি দিক একটি নতুন দক্ষতা; প্রতিটিতে আগের স্তরগুলো কতটা অক্ষত থাকে, সেটাই নকশার মান মাপে:

দিককী করবেনকোন স্তর বদলায়
Background autosaveপ্রতি কয়েক মিনিটে নিজে থেকে save — std::jthread আর একটি mutex দিয়ে (পর্ব ৪৫; অনুশীলন ৪)শুধু service
JSON exportঅন্য টুল বা ওয়েবসাইট যাতে পড়তে পারে; nlohmann/json-এর মতো প্রচলিত library দিয়েনতুন একটি serializer
SQLite storageহাজারো সদস্যে ফাইল পুরোটা লেখার বদলে ডেটাবেস; DBMS ও SQL কোর্সের ধারণানতুন SqliteStorage class
GUIQt বা Dear ImGui দিয়ে জানালা আর বোতাম; CLI-এর জায়গায় নতুন একটি ওপরের স্তরশুধু CLI-এর জায়গা
Network APIHTTP দিয়ে মোবাইল অ্যাপ থেকে ধার — তখন একাধিক অনুরোধ একসাথে, তাই transaction ভাবতে হবে (ভাবনার প্রশ্ন ৫)নতুন ওপরের স্তর + concurrency

লক্ষ করুন, কোনো সারিতেই “domain বদলায়” নেই। নকশা ঠিক থাকলে নিয়মের হৃদয় সবচেয়ে স্থির অংশ — চারপাশের প্রযুক্তি বদলায়, লাইব্রেরির নিয়ম বদলায় না।

Portfolio — কাজটি দেখান

চাকরির বাজারে, বিশেষ করে প্রথম চাকরিতে, একটি ভালো প্রজেক্ট অনেক সময় সনদের চেয়ে বেশি কথা বলে। কিন্তু GitHub-এ কোড রাখাই যথেষ্ট নয় — যিনি দেখবেন, তাঁর হাতে হয়তো পাঁচ মিনিট। সেই পাঁচ মিনিটে কী দেখাবেন:

  • README প্রথম পর্দায়: এক লাইনে কী সমস্যা সমাধান করে, একটি ছোট নমুনা সেশন, বিল্ডের তিনটি কমান্ড, আর চার স্তরের ছবি। তারপর “নকশার সিদ্ধান্ত” অংশ — অংশ ৩-এর টেবিলটিই।
  • পরিষ্কার commit-ইতিহাস: মাইলস্টোন ধরে ধরে (M3: all error paths tested). ইতিহাস দেখে বোঝা যায় আপনি ধাপে ধাপে ভাবেন, এক রাতে কোথাও থেকে কপি করেননি।
  • CI-তে সবুজ টেস্ট: GitHub Actions-এ প্রতিটি push-এ বিল্ড আর ctest (continuous integration). README-র ওপরে একটি সবুজ badge অনেক কিছু বলে।
  • সৎ সীমারেখা: “যা এখনো নেই” অংশ — concurrency নেই, একটি কাউন্টার ধরে নেওয়া হয়েছে। সীমা জানা ইঞ্জিনিয়ার সীমা না জানা ইঞ্জিনিয়ারের চেয়ে অনেক বেশি বিশ্বাসযোগ্য।

Interviewer-রা সাধারণত কোডের আকার দেখেন না; দেখেন আপনি কেন একটি জিনিস এভাবে করলেন, অন্যভাবে করলে কী হতো, আর কীভাবে জানেন যে এটা কাজ করে। “কেন std::map?” প্রশ্নে “কারণ রিপোর্ট সাজানো চাই, আর কয়েক হাজার বইয়ে log n যথেষ্ট — মাপলে বদলাব” একটি চমৎকার উত্তর; “AI দিয়েছিল” নয়।

কোর্সের মানচিত্র — আপনি যা শিখেছেন, আর ক্যাপস্টোনে কোথায়

মডিউলমূল দক্ষতাক্যাপস্টোনে কোথায়
১ · ভিত্তিবস্তু দিয়ে ভাবা, toolchain, টাইপসমস্যাকে দায়িত্বে ভাঙা; using Day = int
২ · ভাষার যন্ত্রপাতিI/O, লুপ, ফাংশন, reference, namespace, enum classCLI লুপ, Error enum, const& parameter
৩ · মেমরি ও রিসোর্সvector, string, RAII, lifetimeএকটিও new নেই; ofstream, container — সব RAII
৪ · অবজেক্ট-ওরিয়েন্টেড ডিজাইনclass, constructor, operator, interface, SOLIDLibrary-র invariant, Money, Storage
৫ · জেনেরিক ও STLmap, set, algorithm, lambda, rangesবই আর ধারের container, রিপোর্ট
৬ · আধুনিক C++move, expected, variant, constexpr, নিরাপদ C++প্রতিটি ফলাফল, Command variant, load-এ move
৭ · সিস্টেম ও প্রজেক্টফাইল, thread, CMake, টেস্ট, পারফরম্যান্সFileStorage, autosave, বিল্ড, harness

এই পর্বের সাথে কোর্সের শেখানোর অংশ শেষ হলো। পরের মডিউল ৮ পরীক্ষা ও ইন্টারভিউর প্রস্তুতির — কোড পড়ে আউটপুট বলা, MCQ, লিখিত-মৌখিক প্রশ্ন আর কোডিং ইন্টারভিউ। সেখানে প্রতিটি প্রশ্নে আপনি দেখবেন, এই ক্যাপস্টোনের কোনো না কোনো সিদ্ধান্তের পেছনের কারণটাই আসলে জিজ্ঞেস করা হচ্ছে।

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

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

প্র ০১Gall’s law বলে, কাজ করা জটিল সিস্টেম সবসময় কাজ করা সরল সিস্টেম থেকে বড় হয়। এটা কি সত্যিই নিয়ম, নাকি শুধু সতর্কতার উপদেশ? ইতিহাসে এর প্রমাণ কোথায়?

John Gall ছিলেন একজন শিশুরোগ বিশেষজ্ঞ, সফটওয়্যার ইঞ্জিনিয়ার নন। ১৯৭৫ সালে প্রকাশিত তাঁর রসিকতা-মেশানো বই “Systemantics”-এ তিনি লেখেন: কাজ করে এমন প্রতিটি জটিল সিস্টেম দেখা যায় কাজ করা একটি সরল সিস্টেম থেকে বিবর্তিত হয়েছে; শুরু থেকেই জটিল করে নকশা করা সিস্টেম কখনো কাজ করে না, আর জোড়াতালি দিয়েও চালানো যায় না — শুরু করতে হয় আবার একটি কাজ করা সরল সিস্টেম দিয়ে। এটি পদার্থবিদ্যার নিয়মের মতো প্রমাণিত সূত্র নয়; এটি একটি পর্যবেক্ষণ। কিন্তু সফটওয়্যারের ইতিহাসে পর্যবেক্ষণটি বারবার মিলেছে।

সবচেয়ে বিখ্যাত জোড়া উদাহরণ Multics আর Unix. ষাটের দশকের মাঝামাঝি MIT, General Electric আর Bell Labs মিলে Multics শুরু করে — শুরু থেকেই উচ্চাকাঙ্ক্ষী, বহু ব্যবহারকারী, নিরাপত্তা, সবকিছু একসাথে। প্রজেক্ট দীর্ঘ হতে থাকায় ১৯৬৯ সালে Bell Labs সরে আসে। সেই বছরই Ken Thompson আর Dennis Ritchie একটি পুরোনো PDP-7 যন্ত্রে অনেক ছোট একটি সিস্টেম লেখেন, যা পরে Unix নাম পায়। Unix প্রথমে প্রায় কিছুই পারত না, কিন্তু যা পারত তা কাজ করত — আর সেখান থেকে ধাপে ধাপে বেড়ে আজকের Linux, macOS, Android-এর পূর্বপুরুষ। ওয়েবের গল্পও একই: Tim Berners-Lee-এর প্রথম ওয়েব ছিল একটি সরল লেখার ফরম্যাট (HTML), একটি সরল প্রোটোকল (HTTP) আর একটি সার্ভার। আজকের বিশাল ওয়েব সেই ছোট, কাজ করা ব্যবস্থার ওপর স্তরে স্তরে জমেছে।

উল্টো দিকও আছে। Fred Brooks ১৯৭৫ সালের “The Mythical Man-Month” বইয়ে “second-system effect”-এর কথা লেখেন: প্রথম সিস্টেমে সফল দল দ্বিতীয়টিতে সব জমানো ইচ্ছা একসাথে ঢুকিয়ে দেয়, আর সেটি ভারী হয়ে ডোবে।

এই পর্বের মাইলস্টোন ঠিক Gall’s law-এর প্রয়োগ। M1-এ শুধু domain — কাজ করে, টেস্টসহ। M2-এ তার ওপর CLI. প্রতিটি ধাপে আমাদের হাতে একটি কাজ করা সিস্টেম থাকে, আর নতুন জটিলতা আসে ছোট মাপে, যাতে কিছু ভাঙলে কারণ খুঁজতে হয় শুধু শেষ যোগটুকুতে। লক্ষ করুন, “সরল” মানে “খারাপ নকশা” নয়: M1-এর domain-এ invariant আর std::expected শুরু থেকেই ছিল। সরলতা পরিধিতে, মানে নয়।

মূল উপলব্ধি: জটিল সিস্টেম নকশা করে বানানো যায় না, চাষ করে বড় করতে হয়। প্রতিটি দিনের শেষে একটি কাজ করা সিস্টেম থাকাটাই সবচেয়ে বড় নিরাপত্তা।

প্র ০২Conway’s law বলে, একটি সিস্টেমের নকশা তার নির্মাতা দলের যোগাযোগের কাঠামোর নকল হয়। একা বানানো ক্যাপস্টোনে এর কী প্রাসঙ্গিকতা? চার স্তরের নকশা কি দলের ভাগও ঠিক করে দেয়?

Melvin Conway ১৯৬৮ সালে Datamation পত্রিকায় “How Do Committees Invent?” প্রবন্ধে যুক্তি দেন: যে প্রতিষ্ঠান একটি সিস্টেম নকশা করে, তারা অনিবার্যভাবে এমন নকশা বানায় যার কাঠামো প্রতিষ্ঠানের যোগাযোগের কাঠামোর মতো। কারণটা সরল — দুটি অংশ একে অপরের সাথে কথা বলতে পারে কেবল যদি তাদের নির্মাতারা কথা বলেন। Fred Brooks তাঁর “The Mythical Man-Month”-এ ধারণাটিকে “Conway’s law” নাম দিয়ে জনপ্রিয় করেন। প্রচলিত একটি রসিকতা আছে: চারটি দল মিলে একটি compiler বানালে সেটি চার-pass-এর compiler হবে।

বাস্তবে এটা চোখে পড়ে। যে প্রতিষ্ঠানে ডেটাবেস দল, ব্যাকএন্ড দল আর অ্যাপ দল আলাদা তলায় বসে আর খুব কম কথা বলে, তাদের সিস্টেমে স্তরগুলোর মাঝে মোটা, আনুষ্ঠানিক interface থাকে — আর একটি ছোট পরিবর্তনে তিন দলের মিটিং লাগে। উল্টো দিকে, পরে অনেক প্রতিষ্ঠান নিয়মটি সচেতনভাবে ব্যবহার করে: আগে ঠিক করে কেমন স্থাপত্য চাই, তারপর সেই অনুযায়ী দল সাজায়। এই কৌশলকে কেউ কেউ “inverse Conway maneuver” বলেন। microservice আর ছোট স্বাধীন দলের ধারণা এর ঘনিষ্ঠ আত্মীয়।

একা বানানো প্রজেক্টে প্রাসঙ্গিকতা দুই দিক থেকে। প্রথমত, এক মানুষের মাথাও একটি “প্রতিষ্ঠান” — যা একসাথে ভাবেন, তা কোডেও জড়িয়ে থাকে। আপনি যদি “ফাইল থেকে পড়া” আর “জরিমানার নিয়ম” একই চিন্তায় রাখেন, কোডেও তারা একই ফাংশনে মিশে যাবে। স্তরে ভাগ করা আসলে নিজের চিন্তাকে দলের মতো ভাগ করা: domain লেখার সময় আপনি “নিয়ম-দলের সদস্য”, ফাইলের কথা ভাবা নিষেধ।

দ্বিতীয়ত, প্রজেক্ট বড় হলে আমাদের চার স্তর সত্যিই দলের সীমানা হতে পারে: একজন GUI বানাবেন, আরেকজন SQLite storage. তাঁদের মাঝে চুক্তি হবে library.h আর storage.h — দুটি ছোট header. যত ছোট আর স্থির interface, তত কম মিটিং। ভুল জায়গায় সীমানা টানলে (ধরুন domain আর storage এক দলে মেশানো) সেই ভুল একসময় দলের কাঠামোতেও আটকে যায়, আর তখন বদলানো দ্বিগুণ কঠিন।

মূল উপলব্ধি: স্থাপত্য শুধু কোডের প্রশ্ন নয়, মানুষের যোগাযোগেরও। পরিষ্কার interface মানে পরিষ্কার দায়িত্ব — কোডে, দলে, এমনকি নিজের মাথায়।

প্র ০৩“Make it work, make it right, make it fast” — এই ক্রম কেন? শুরু থেকেই দ্রুত আর নিখুঁত কোড লিখলে কি সময় বাঁচে না?

উক্তিটি সাধারণত Kent Beck-এর নামে প্রচলিত — Extreme Programming-এর অন্যতম প্রবর্তক, আর ২০০২ সালের “Test-Driven Development: By Example” বইয়ের লেখক। তিনটি ধাপের প্রতিটির অর্থ নির্দিষ্ট। Work: প্রোগ্রামটি সঠিক ফল দেয়, টেস্ট দিয়ে প্রমাণিত — কোড সুন্দর না হলেও। Right: একই আচরণ রেখে নকশা পরিষ্কার করা — নাম, স্তর, দায়িত্বের ভাগ; এটাই refactoring, আর এর নিরাপত্তা-জাল হলো আগের ধাপের টেস্ট। Fast: মেপে দেখে, যেখানে সত্যিই ধীর, শুধু সেখানে দ্রুত করা।

ক্রমটি উল্টালে কী হয়? প্রথমে দ্রুত করতে চাইলে আপনি অনুমান করেন কোথায় সময় যাবে — আর পর্ব ৪৯-এ দেখেছেন, এমন অনুমান প্রায়ই ভুল হয়। Donald Knuth ১৯৭৪ সালের প্রবন্ধ “Structured Programming with go to Statements”-এ লেখেন, প্রোগ্রামাররা অগুরুত্বপূর্ণ অংশের গতি নিয়ে বিপুল সময় নষ্ট করেন, আর অকালে optimization “সব অনিষ্টের মূল” — তবে একই অনুচ্ছেদে তিনি যোগ করেন, সত্যিই গুরুত্বপূর্ণ সামান্য অংশটুকুর সুযোগ হাতছাড়া করাও উচিত নয়। পুরো উক্তিটি তাই “কখনো optimize করবেন না” নয়, “মেপে, ঠিক জায়গায়”।

প্রথমেই নিখুঁত নকশা চাইলেও বিপদ: সমস্যাটি পুরো বোঝার আগেই আপনি বিমূর্ত কাঠামো বানিয়ে ফেলেন, যা পরে ভুল প্রমাণিত হয় — over-engineering. আমাদের প্রজেক্টে দেখুন: অংশ ৫-এর এক ফাইলের রূপটি “work” — সঠিক, টেস্টযোগ্য, কিন্তু service বা storage-এর ভাগ নেই। তার আচরণ golden-file-এ বাঁধা হলে আমরা নির্ভয়ে চার স্তরে ভাগ করি — “right”. আর “fast”? কয়েকশো বইয়ের লাইব্রেরিতে std::map মাইক্রোসেকেন্ডে উত্তর দেয়; দ্রুত করার কোনো কারণই নেই। কখনো লাখো বই হলে profiler-এ মেপে তবেই unordered_map ভাবব।

একটি গুরুত্বপূর্ণ সূক্ষ্মতা: “work” মানে নোংরা কোড চিরকাল নয়, আর “fast” মানে নকশায় গতিকে উপেক্ষা করাও নয়। ভুল algorithm (প্রতিটি খোঁজায় পুরো ফাইল পড়া) কোনো পরের ধাপে সারানো সহজ নয় — জটিলতার বড় সিদ্ধান্ত (O(n) বনাম O(n²)) শুরুতেই ভাবতে হয়। ক্রমটি অগ্রাধিকারের, অবহেলার নয়।

মূল উপলব্ধি: সঠিকতা প্রথমে, কারণ ভুল উত্তর দ্রুত দিলেও ভুল। পরিষ্কার নকশা দ্বিতীয়, কারণ টেস্ট থাকলে তা নিরাপদ। গতি শেষে, আর শুধু মাপার পরে।

প্র ০৪জরিমানা মাত্র কয়েক টাকা — এখানেও কি পূর্ণসংখ্যায় পয়সা রাখা জরুরি? floating point-এর ত্রুটি বাস্তবে কতটা ক্ষতি করেছে, আর গোল করার নিয়ম কে ঠিক করে?

পর্ব ১৯-এ দেখেছেন, double সংখ্যা রাখে দুইয়ের ঘাতে, তাই ০.১-এর মতো দশমিক হুবহু রাখা যায় না, আর 0.1 + 0.2 হুবহু 0.3 হয় না। প্রশ্নটা হলো — ছোট অঙ্কে এটা কি সত্যিই গুরুত্বপূর্ণ? উত্তর: সমস্যা অঙ্কের আকারে নয়, সংখ্যায় আর পুনরাবৃত্তিতে। একটি লাইব্রেরি বছরে হাজারো ফেরত নেয়; প্রতিটি যোগে ক্ষুদ্র ত্রুটি, আর বছর শেষে হিসাবরক্ষকের খাতার সাথে প্রোগ্রামের মোট মেলে না। আর হিসাবের জগতে “প্রায় মেলে” মানে “মেলে না” — অডিটে প্রতিটি পয়সার ব্যাখ্যা লাগে।

ক্ষুদ্র ত্রুটি জমার একটি বিখ্যাত ঘটনা ভ্যাঙ্কুভার স্টক এক্সচেঞ্জের সূচক। ১৯৮২ সালের শুরুতে সূচকটি ১০০০ মান দিয়ে চালু হয়, আর প্রতিটি লেনদেনের পর আবার হিসাব হতো — কিন্তু প্রতিবার তিন ঘর দশমিকের পরের অংশ গোল না করে কেটে ফেলা হতো। প্রতিবারের ক্ষতি নগণ্য, কিন্তু দিনে হাজারো হিসাব। প্রায় দুই বছর পর সূচক দেখাচ্ছিল ৫২০-এর কাছাকাছি, অথচ সঠিক হিসাবে হওয়ার কথা ছিল এক হাজারের ওপরে। কোনো বাজার-ধস হয়নি; হয়েছিল শুধু একটি গোল করার ভুল, লক্ষবার।

লক্ষ করুন, এই ঘটনার মূল শিক্ষা “double খারাপ” নয় — শিক্ষা হলো গোল করার নিয়ম লুকিয়ে থাকলে বিপদ। পূর্ণসংখ্যার পয়সা আপনাকে বাধ্য করে প্রতিটি ভাগে নিয়মটি লিখে দিতে। ধরুন নাসরিন আপা চান, ৩ জন মিলে একটি হারানো বইয়ের ১০০ টাকা ভাগ করে দেবেন। ১০০০০ পয়সা ÷ ৩ = ৩৩৩৩, বাকি ১ পয়সা — কে দেবেন? Martin Fowler তাঁর ২০০২ সালের “Patterns of Enterprise Application Architecture” বইয়ে Money pattern-এ ঠিক এই সমস্যার সমাধান দেন: “allocate” — ভাগশেষের পয়সাগুলো প্রথম কয়েকজনের ভাগে একটি একটি করে যোগ করা, যাতে যোগফল সবসময় হুবহু মূল অঙ্ক। double-এ এই প্রশ্ন ওঠেই না; ভুলটি নীরবে ঘটে।

বিকল্পও আছে: দশমিক-ভিত্তিক সংখ্যা। C23 ঐচ্ছিকভাবে _Decimal64-এর মতো decimal floating টাইপ যোগ করেছে, আর C++-এর জন্য একটি technical report ছিল, কিন্তু স্ট্যান্ডার্ড C++-এ এখনো কোনো decimal টাইপ নেই। তাই C++-এ প্রচলিত পথ পূর্ণসংখ্যার একটি Money class, বা প্রয়োজনে কোনো library.

মূল উপলব্ধি: পূর্ণসংখ্যার টাকা ছোট অঙ্কেও জরুরি, কারণ ত্রুটি জমে সংখ্যার গুণে। আর এর সবচেয়ে বড় উপহার নির্ভুলতা নয় — প্রতিটি গোল করার সিদ্ধান্তকে কোডে দৃশ্যমান করা।

প্র ০৫দুটি কাউন্টার থেকে একই মুহূর্তে একটি বইয়ের শেষ কপি দুজনকে ধার দেওয়া হলে কী হবে? আসল POS বা ইনভেন্টরি সিস্টেম এই সমস্যা কীভাবে সামলায়?

আমাদের lend দুটি ধাপে কাজ করে: পরীক্ষা (“কপি আছে?”) আর বদল (“out বাড়াও”)। এক thread-এ এটা নিরাপদ। কিন্তু দুটি thread বা দুটি কম্পিউটার একসাথে চালালে ঘটতে পারে: দুজনেই পরীক্ষা করলেন — দুজনেই দেখলেন একটি কপি আছে — দুজনেই বদলালেন। ফল: out = 3, total = 2, I1 ভাঙা। এই ধরনের বাগকে বলে “check-then-act” race, আর পর্ব ৪৫-এ দেখেছেন, একই মেমরিতে এমন সমান্তরাল লেখা C++-এ data race — undefined behavior.

এক প্রোগ্রামের ভেতরে সমাধান সরল: পরীক্ষা আর বদল দুটোই একটি mutex-এর পাহারায়, যাতে মাঝখানে কেউ ঢুকতে না পারে। কিন্তু কাউন্টারগুলো যখন আলাদা কম্পিউটার, তখন একটি সাধারণ mutex কাজে আসে না। তখন ডেটাবেসের transaction. Jim Gray ১৯৮১ সালের প্রবন্ধ “The Transaction Concept: Virtues and Limitations”-এ ধারণাটি গুছিয়ে লেখেন, আর ডেটাবেস ও transaction-এর কাজের জন্য ১৯৯৮ সালে Turing Award পান। ১৯৮৩ সালে Theo Härder আর Andreas Reuter এর চারটি গুণের নাম দেন ACID: atomicity (সব অথবা কিছুই না), consistency (নিয়ম ভাঙে না), isolation (একসাথে চলা transaction যেন একে অপরকে না দেখে), durability (সফল হলে টিকে থাকে)।

isolation দুই প্রধান কৌশলে আসে। Pessimistic locking: কাউন্টার ১ বইয়ের সারিটি “তালা” দেয়; কাউন্টার ২ অপেক্ষা করে। নিরাপদ, কিন্তু ভিড়ের সময় লাইন লম্বা হয়। Optimistic concurrency: প্রতিটি সারিতে একটি সংস্করণ নম্বর; বদলের সময় বলা হয় “শুধু তখনই বদলাও, যদি সংস্করণ এখনো ৭ থাকে”। দ্বিতীয়জন দেখেন সংস্করণ ইতিমধ্যে ৮ — তাঁর অনুরোধ ব্যর্থ, আবার চেষ্টা করুন। ভিড় কম হলে দ্রুত। অনলাইন টিকিট বিক্রির সাইটে শেষ আসনটি “কেউ আগেই নিয়ে নিয়েছেন” বার্তা অনেক সময় এমন কৌশলেরই ফল।

অনেক বাস্তব সিস্টেম আরও একটি ব্যবসায়িক সিদ্ধান্ত নেয়: কখনো কখনো অসঙ্গতি মেনে নিয়ে পরে মেটানো। ইন্টারনেট চলে গেলেও সুপারশপের কাউন্টার বিক্রি থামায় না; স্থানীয়ভাবে লিখে রাখে, পরে কেন্দ্রীয় সার্ভারের সাথে মেলায়, আর দ্বন্দ্ব হলে মানুষ সিদ্ধান্ত নেন। কোনটি ঠিক — কঠোর নিশ্চয়তা না সবসময় চালু থাকা — তা প্রযুক্তি নয়, ব্যবসা ঠিক করে।

মূল উপলব্ধি: আমাদের “আগে সব যাচাই, তারপর একবারে বদল” নিয়ম transaction-এর এক-thread রূপ। concurrency এলে সেই নিয়মকে একটি তালার ভেতরে বা ডেটাবেসের transaction-এ তুলে দিতে হয় — আর তখনই domain-কে I/O-মুক্ত রাখার মূল্য বোঝা যায়।

অনুশীলন — ক্যাপস্টোনকে নিজের করে নিন

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

  1. cancel-এর spec আর টেস্ট: অংশ ২-এর cancel rina B1 কমান্ডের জন্য অন্তত চারটি acceptance criteria লিখুন। তারপর ভাবুন: এটি domain-এর নতুন ফাংশন, নাকি service-এ give_back ডেকে করা যায়?

    Criteria: (১) ধারের দিনেই বাতিল করলে কপি ফেরত আসে, জরিমানা শূন্য, আর ধারের রেকর্ড মুছে যায়; (২) অন্য দিনে চাইলে Error::TooLateToCancel, কিছুই বদলায় না — তখন return করতে হবে; (৩) এমন ধার না থাকলে NoSuchLoan; (৪) মোট জরিমানা কখনো কমে না (I3) — তাই cancel আগের কোনো জরিমানা ফেরত দেয় না। নকশার প্রশ্নের উত্তর: domain-এ নতুন ফাংশন। কারণ “একই দিনে হলে জরিমানা নেই, পরে হলে নিষেধ” — এটি একটি ব্যবসার নিয়ম, আর ব্যবসার নিয়ম শুধু domain-এ থাকবে। service-এ give_back ডেকে আর দিন মিলিয়ে করলে নিয়মটি দুই স্তরে ছড়িয়ে পড়ত, আর পরে কেউ GUI থেকে সরাসরি give_back ডাকলে নিয়ম পাশ কাটানো যেত। টেস্ট: দিন ৫-এ ধার, দিন ৫-এ cancel → ok, report-এ কপি তাকে; দিন ৬-এ cancel → error, report অপরিবর্তিত।

  2. আউটপুট অনুমান: অংশ ৫-এর প্রোগ্রামকে এই ইনপুট দিলে কী ছাপবে? add B1 1 / lend rina B1 3 / lend rina B1 4 / add B1 1 / lend rina B1 5 / return rina B1 40 / report. কোন লাইনে কোন invariant রক্ষা পেল?

    আউটপুট: ok, ok, error: no copy of B1 left (একটিই কপি, বাইরে — I1 রক্ষা), ok (কপি বেড়ে দুই), তারপর error: rina already has B1 — কপি এখন আছে, কিন্তু রিনার কাছে আগেই একটি; try_emplace ব্যর্থ হলো, তাই out বাড়েনি (I2 রক্ষা)। লক্ষ করুন, পরীক্ষার ক্রম গুরুত্বপূর্ণ: কপির পরীক্ষা আগে, তাই আগের লাইনে বার্তা ছিল “no copy”, “already has” নয়। এরপর return rina B1 40: ৪০ − ৩ = ৩৭ দিন, ২৩ দিন দেরি, ২৩ × ২০০ = ৪৬০০ পয়সা — ok, fine 46.00 Tk. শেষে B1 2/2 on shelf আর loans: 0 fines: 46.00 Tk.

  3. স্তরের পরীক্ষা: নাসরিন আপা ফাইলের বদলে SQLite চান, আর সাথে একটি Qt জানালা। কোন কোন ফাইল নতুন হবে, কোনগুলো বদলাবে, আর কোনগুলো একটুও বদলাবে না? যদি দেখেন library.cpp বদলাতে হচ্ছে, তাহলে নকশার কোথায় ভুল ছিল?

    নতুন: storage/sqlite_storage.h/.cpp (Storage interface মেনে) আর gui/ ফোল্ডার (Qt জানালা, যা CLI-এর মতো service-কে ডাকে)। বদলাবে: শুধু wiring — যেখানে FileStorage তৈরি করে service-কে দেওয়া হয়, সেখানে SqliteStorage; আর CMake-এ নতুন target ও library link. একটুও বদলাবে না: পুরো domain/, storage.h, service-এর যুক্তি, আর সব domain টেস্ট। library.cpp বদলাতে হলে বুঝতে হবে domain কোনোভাবে storage বা UI-এর কিছু জানত — হয়তো কোথাও ফাইলের নাম, একটি println, বা SQL-এর ধারণা ঢুকে পড়েছিল। একটি সূক্ষ্ম কথা: আমাদের Storage interface “পুরো লেখা একবারে” ধরে নেয়; ডেটাবেসে প্রতিটি পরিবর্তন আলাদা সারিতে লেখা স্বাভাবিক। তাই বাস্তবে interface-টি একটু বদলাতে হতে পারে (যেমন record_loan) — interface-ও চিরকালের নয়, তবে domain তবুও অক্ষত থাকে।

  4. নিজে লিখুন (কোড): service-এ একটি background autosave যোগ করুন: প্রতি ৬০ সেকেন্ডে save(), কিন্তু প্রোগ্রাম বন্ধের সময় অপেক্ষা ছাড়াই থামবে। std::jthread, std::stop_token আর একটি mutex ব্যবহার করুন। কোন কোন জায়গায় lock লাগবে?

    মূল অংশ: service-এ std::mutex mtx_;, std::condition_variable_any cv_; আর শেষ member হিসেবে std::jthread saver_; (শেষে, যাতে destructor-এ thread আগে থামে, তারপর বাকি member ধ্বংস হয়)। constructor-এ:

    saver_ = std::jthread([this](std::stop_token st) {
        std::unique_lock lk(mtx_);
        while (!st.stop_requested()) {
            cv_.wait_for(lk, st, std::chrono::seconds(60), [] { return false; });
            if (!st.stop_requested()) (void)save_locked();
        }
    });

    C++20-এর wait_for-এর stop_token-যুক্ত রূপটি (condition_variable_any-তে) stop চাওয়া হলে সাথে সাথে জেগে ওঠে — তাই sleep_for(60s)-এর মতো এক মিনিট আটকে থাকে না। jthread-এর destructor নিজেই request_stop() আর join() করে — RAII. lock লাগবে প্রতিটি public ফাংশনে (lend, give_back, report, save, load), কারণ save যখন Library পড়ছে, তখন অন্য thread বদলালে data race. save_locked() নামের ভেতরের ফাংশনটি ধরে নেয় lock আগেই নেওয়া, যাতে একই mutex দুবার নিতে গিয়ে deadlock না হয়। টেস্ট: MemoryStorage-এ কতবার write হলো গুনে দেখা, আর service ধ্বংসের সময় দ্রুত থামে কি না।

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

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

ক্রমস্তরে ভাগ করা একটি C++ সিস্টেমে “বই ধার দাও” অনুরোধ যে ক্রমে প্রক্রিয়া হয়, সেই ক্রমে সাজান (▲▼ চেপে সরান)

  1. CLI লাইনটি পড়ে একটি টাইপ-করা কমান্ডে রূপান্তর করে
  2. service কমান্ডটি দেখে সঠিক domain ফাংশনে পাঠায়
  3. domain সব নিয়ম যাচাই করে
  4. সব যাচাই সফল হলে domain একবারে অবস্থা বদলায়
  5. domain একটি std::expected ফল ফেরত দেয়
  6. CLI ফলটিকে মানুষের ভাষার বার্তায় ছাপে

একটি লাইব্রেরি প্রোগ্রামে ব্যবহারকারী প্রায়ই ভুল বইয়ের id লেখেন। এই ব্যর্থতা জানাতে C++23-এ কোনটি সবচেয়ে উপযুক্ত?

  • একটি std::runtime_error exception ছোড়া
  • ফেরত মান হিসেবে -1
  • std::expected<T, Error> ফেরত দেওয়া
  • একটি global ভ্যারিয়েবলে error কোড রাখা

std::map<std::string, Book> books; থাকলে, শুধু খোঁজার জন্য books[id] লেখার সমস্যা কী?

  • map-এ operator[] নেই, compile হয় না
  • চাবি না থাকলে নীরবে একটি নতুন খালি উপাদান তৈরি হয়ে যায়
  • এটি find-এর চেয়ে অনেক ধীর, O(n)
  • চাবি না থাকলে std::out_of_range ছোড়ে

একটি ফাইলে সংরক্ষিত ডেটা নিরাপদে overwrite করার প্রচলিত কৌশল কোনটি, যাতে লেখার মাঝপথে প্রোগ্রাম বন্ধ হলেও পুরোনো সম্পূর্ণ ফাইলটি টিকে থাকে?

  • আসল ফাইলটি খুলে সরাসরি নতুন লেখা লেখা
  • আগে আসল ফাইল মুছে, তারপর নতুন ফাইল তৈরি করা
  • প্রতিটি লাইন আলাদা করে flush করে লেখা
  • একটি অস্থায়ী ফাইলে পুরোটা লিখে, সফল হলে rename দিয়ে আসলটির জায়গায় বসানো

একটি সিস্টেমে Storage নামের abstract class আছে, যার দুটি রূপ FileStorage আর MemoryStorage. এই নকশার প্রধান লাভ কী?

  • ওপরের কোড না বদলে টেস্টে মেমরির রূপ আর আসল প্রোগ্রামে ফাইলের রূপ বসানো যায়
  • virtual ফাংশন থাকায় প্রোগ্রাম দ্রুত চলে
  • ফাইল ছাড়াই আসল প্রোগ্রামের ডেটা চিরকাল টিকে থাকে
  • Storage-এর object সরাসরি তৈরি করে ব্যবহার করা যায়

একটি হিসাবের ফাংশন প্রথমে সব শর্ত যাচাই করে, আর সব সফল হলে তবেই অবস্থা বদলায়। এতে কোন নিশ্চয়তা পাওয়া যায়?

  • ফাংশনটি সবসময় দ্রুততম হয়
  • ফাংশনটি কখনো ব্যর্থ হয় না
  • ব্যর্থ হলে অবস্থা ঠিক আগের মতো থাকে — অর্ধেক-বদলানো অবস্থা হয় না
  • একাধিক thread একসাথে ডাকলেও আর কোনো সুরক্ষা লাগে না

একটি C++ প্রোগ্রামে std::jthread, সাধারণ std::thread-এর তুলনায় কোন সুবিধা দেয়?

  • এটি সাধারণ thread-এর চেয়ে অনেক দ্রুত চলে
  • destructor-এ নিজেই থামতে বলে আর join করে, আর stop_token দিয়ে থামার অনুরোধ পাঠানো যায়
  • ভাগ করা ডেটায় mutex ছাড়াই data race হয় না
  • এটি C++11 থেকে আছে, std::thread এসেছে পরে

শব্দকোষ

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

মনে রাখুন

  1. সিস্টেম = ছোট অংশ, প্রতিটির একটি কাজ। CLI কথা বলে, service সাজায়, domain নিয়ম রক্ষা করে, storage টিকিয়ে রাখে; প্রতিটি স্তর শুধু নিচেরটাকে চেনে।
  2. invariant লিখে রাখুন, class দিয়ে রক্ষা করুন। ডেটা private, সব যাচাই আগে, বদল একবারে শেষে — ব্যর্থ কমান্ডে অবস্থা একটুও বদলায় না।
  3. প্রত্যাশিত ব্যর্থতায় std::expected, সত্যিকারের বিপদে exception; টাকা পূর্ণসংখ্যার Money-তে, গোল করার নিয়ম কোডে দৃশ্যমান।
  4. storage একটি interface; ফাইলে লেখা tmp + rename-এ, load আলাদা object-এ তৈরি করে সফল হলে তবেই বদল; round-trip টেস্ট প্রমাণ দেয়।
  5. ভেতর থেকে বাইরে, ছয় মাইলস্টোনে, প্রতিটির শেষে টেস্ট; CMake-এ বিল্ড, README-তে সিদ্ধান্ত, আর AI-কে reviewer হিসেবে — যা ব্যাখ্যা করতে পারেন না, তা জমা দেবেন না।

পরের পর্বে: মডিউল ৮ শুরু — পরীক্ষা ও ইন্টারভিউর প্রস্তুতি। প্রথমে “আউটপুট কী হবে?”: ছোট ছোট C++ কোড পড়ে চালানোর আগেই ফল বলা — evaluation order, object lifetime, overload আর virtual call-এর ফাঁদ চেনার অনুশীলন।

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

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