এই পর্বে যা শিখবেন
- কখন একটি টাইপ-পরিবার “বন্ধ” রাখা দরকার, আর
sealed,permits,final/sealed/non-sealedদিয়ে তা কীভাবে লেখা হয় instanceofআরswitch-এ type pattern, আর record pattern দিয়ে ডেটা খুলে দেখা (nested সহ)- Exhaustiveness কী প্রমাণ করে,
defaultকেন অনেক সময় ক্ষতিকর, আর নতুন উপ-টাইপ যোগ হলে compiler কী করে whenguard, case-এর ক্রম ও dominance, আরcase null- sealed + record দিয়ে algebraic data type, একটি ছোট expression evaluator, আর কখন polymorphism বনাম কখন pattern matching
১ · একটি পেমেন্টের তিনটি পরিণতি — বন্ধ টাইপ-পরিবার
ধরুন আপনি একটি মোবাইল ওয়ালেট অ্যাপের পেমেন্ট অংশ লিখছেন। ব্যবহারকারী “Pay” চাপলে সার্ভার থেকে উত্তর আসে, আর সেই উত্তর ঠিক তিন রকমের একটি। সফল — তখন সাথে আসে একটি লেনদেন নম্বর (TxID) আর টাকার পরিমাণ। ব্যর্থ — তখন আসে কারণ, যেমন “ব্যালান্স কম”। অপেক্ষমাণ — ব্যাংক এখনো নিশ্চিত করেনি, কয়েক সেকেন্ড পরে আবার দেখতে হবে। চতুর্থ কোনো রকম নেই। আর প্রতিটি রকমের সাথে আসা তথ্যও আলাদা।
পর্ব ২০-২১-এ আপনি এমন জিনিস মডেল করতেন একটি interface আর কয়েকটি class দিয়ে। কিন্তু সাধারণ interface সবার জন্য খোলা — যে কেউ, যেকোনো ফাইলে, implements PaymentResult লিখে পঞ্চম, ষষ্ঠ রকম বানাতে পারে। পেমেন্ট গেটওয়ের মতো plugin-ব্যবস্থায় এই খোলামেলা ভাবটাই চাই। কিন্তু পেমেন্টের ফল এমন জিনিস নয়। এর রকমগুলো ব্যবসার নিয়মেই স্থির। আর যে কোড ফলটা দেখে সিদ্ধান্ত নেয় — স্ক্রিনে কী দেখাবে, SMS যাবে কি না, আবার চেষ্টা করবে কি না — তার জানা দরকার যে তিনটির প্রতিটি সামলানো হয়েছে।
ক্রিকেটে DRS রিভিউয়ের পর টিভি আম্পায়ারের সিদ্ধান্ত তিন রকমের একটি: আউট, নট আউট, অথবা আম্পায়ার্স কল। স্কোরবোর্ড চালানোর সফটওয়্যার জানে এর বাইরে কিছু আসবে না, তাই প্রতিটির জন্য আলাদা পর্দা তৈরি রাখে। কেউ যদি মাঝপথে “আধা আউট” নামে চতুর্থ রকম ঢুকিয়ে দিত, স্কোরবোর্ড জানতই না কী দেখাবে। sealed হলো সেই নিয়মের বই, যেখানে লেখা থাকে: “সিদ্ধান্ত মাত্র এই তিনটি”। আর নিয়ম বদলে চতুর্থ রকম এলে, যে যে পর্দা তৈরি নেই — compiler সবগুলোর তালিকা দেয়।
Java 17 আর 21 মিলে এর জন্য দুটি হাতিয়ার দিয়েছে। প্রথমটি sealed টাইপ — যা বলে “আমার উপ-টাইপ শুধু এরাই”। দ্বিতীয়টি pattern matching — যা দিয়ে switch প্রতিটি রকম চিনে নিয়ে তার ভেতরের তথ্য বের করে আনে। পর্ব ২৫-এর record-এর সাথে মিলিয়ে কোডটা দেখতে এরকম:
sealed interface PaymentResult permits Success, Failed, Pending {}
record Success(String txId, int amount) implements PaymentResult {}
record Failed(String reason) implements PaymentResult {}
record Pending(int waitSec) implements PaymentResult {}
String text = switch (result) {
case Success(var txId, var amount) -> "সফল: " + amount + " টাকা";
case Failed(var reason) -> "ব্যর্থ: " + reason;
case Pending(var waitSec) -> waitSec + " সেকেন্ড পরে দেখুন";
}; // default নেই — compiler জানে এই তিনটিই সবলক্ষ করুন কী নেই: কোনো instanceof-শিকল নেই, কোনো cast নেই, কোনো default নেই। প্রথম লাইনটি টাইপ-পরিবার বন্ধ করেছে। পরের তিন লাইন প্রতিটি রকমের ডেটা বলে দিয়েছে। আর switch প্রতিটি রকম খুলে তার অংশগুলোকে নাম দিয়েছে। পুরো পর্বটা এই ছোট কোডের ভেতরের নিয়মগুলো নিয়ে।
Sealed টাইপ বলে “রকম কয়টি”; pattern matching বলে “প্রতিটি রকমে কী করব”; আর compiler প্রমাণ করে যে কোনো রকম বাদ পড়েনি। এই তিনটি একসাথে পেলে আপনি এমন ডেটা মডেল করতে পারেন যার প্রতিটি সম্ভাব্য অবস্থা কোডে স্পষ্ট লেখা থাকে।
এই বৈশিষ্ট্যগুলো এসেছে OpenJDK-র Project Amber থেকে, ধাপে ধাপে। প্রতিটি আগে preview হিসেবে এসেছে, তারপর স্থায়ী হয়েছে। instanceof-এর pattern আর record স্থায়ী হয় Java 16-এ। Sealed class Java 15 ও 16-এ preview ছিল, Java 17-এ স্থায়ী হয় (JEP 409). switch-এর pattern matching (JEP 441) আর record pattern (JEP 440) কয়েক দফা preview-র পর স্থায়ী হয় Java 21-এ। Java 17 আর 21 দুটোই LTS সংস্করণ, তাই আজ বেশিরভাগ নতুন প্রজেক্টে এগুলো ব্যবহার করা যায়।
২ · sealed আর permits — কে উত্তরাধিকারী হতে পারবে
একটি class বা interface-এর আগে sealed লিখলে, আর permits-এর পরে নামের তালিকা দিলে, শুধু সেই নামগুলোই সরাসরি তাকে extends বা implements করতে পারে। এই তালিকাকে বলে permits clause. তালিকার বাইরের কেউ চেষ্টা করলে কী হয়, দেখুন। তালিকায় তিনটি নাম, কিন্তু কেউ চতুর্থ একটি record লিখল:
sealed interface PaymentResult permits Success, Failed, Pending {}
// ... Success, Failed, Pending আগের মতো ...
record Refunded(String txId) implements PaymentResult {} // তালিকায় নেইMain.java:5: error: class is not allowed to extend sealed class: PaymentResult (as it is not listed in its 'permits' clause)
record Refunded(String txId) implements PaymentResult {}
^
1 error
বার্তাটি পরিষ্কার: “permits তালিকায় নাম নেই, তাই extend করার অনুমতি নেই”। (এখানে interface হলেও বার্তায় “sealed class” লেখা — javac দুটোর জন্য একই বার্তা দেয়। OpenJDK 21-এ যাচাই করা; অনলাইন রানারে ফাইলের নাম prog.java দেখাতে পারে।) Sealed টাইপ বদলানোর অধিকার তাই তার মালিকের হাতে। নতুন রকম যোগ করতে হলে permits তালিকাতেও নাম লিখতে হবে — অর্থাৎ সিদ্ধান্তটা হবে জেনেশুনে, এক জায়গায়।
অনুমোদিত উপ-টাইপের তিনটি পছন্দ
Sealed টাইপ শুধু প্রথম স্তর বন্ধ করে। তার নিচে কী হবে, সেটা প্রতিটি অনুমোদিত উপ-টাইপকে নিজে ঘোষণা করতে হয়। আর ঘোষণা না করলে compiler মানে না। তিনটি পছন্দের যেকোনো একটি লিখতেই হবে:
| modifier | মানে | কখন |
|---|---|---|
final | এর নিচে আর কোনো subclass নেই (পর্ব ১৮-এর final class) | বেশিরভাগ ক্ষেত্রে। Record নিজে থেকেই final, তাই record-এ কিছু লিখতে হয় না |
sealed | এর নিচেও একটি বন্ধ তালিকা, নিজের permits সহ | শ্রেণিবিন্যাস কয়েক স্তরের, যেমন Failed-এর নিচে InsufficientBalance, Timeout |
non-sealed | এই শাখাটি আবার সবার জন্য খোলা — যে কেউ extend করতে পারে | একটি শাখায় বাইরের লোককে বাড়ানোর সুযোগ দিতে, যেমন “কাস্টম আকার” |
non-sealed হলো Java-র প্রথম hyphen-যুক্ত keyword. Modifier না লিখলে কী হয়, দেখুন:
sealed class Shape permits Circle, Square {}
class Circle extends Shape {} // final/sealed/non-sealed কোনোটাই নেই
final class Square extends Shape {}Main.java:2: error: sealed, non-sealed or final modifiers expected
class Circle extends Shape {}
^
1 error
কেন এই কড়াকড়ি? কারণ sealed-এর পুরো মূল্য হলো compiler-এর জানা যে সব উপ-টাইপ কী কী। Circle চুপচাপ খোলা থাকলে যে কেউ class Oval extends Circle লিখে পরিবারে ঢুকে পড়ত। Java তাই আপনাকে প্রতিটি শাখার সিদ্ধান্ত লিখে দিতে বাধ্য করে। non-sealed লিখলেও exhaustiveness নষ্ট হয় না: switch-এ case Custom c লিখলে Custom-এর সব subclass, এমনকি পরে কেউ বানালেও, ওই এক case-এ ধরা পড়ে।
কোথায় থাকতে পারে, আর কখন permits বাদ দেওয়া যায়
- একই module বা package: অনুমোদিত উপ-টাইপগুলোকে sealed টাইপের কাছাকাছি থাকতে হয়। আপনার কোড কোনো নামযুক্ত module-এ থাকলে সবাইকে সেই module-এ থাকতে হবে; module ছাড়া সাধারণ প্রজেক্টে (unnamed module) সবাইকে একই package-এ থাকতে হবে। কারণ compiler-কে একসাথে পুরো পরিবার দেখতে হয়।
- একই ফাইলে থাকলে
permitsঐচ্ছিক: সব উপ-টাইপ যদি একই.javaফাইলে থাকে,sealed interface Shape {}লিখলেই চলে। Compiler ফাইলের ভেতরে যারাShape-কে implement করেছে, তাদের নিয়েই তালিকা বানিয়ে নেয়। ছোট টাইপ-পরিবারে (যেমন এই পর্বের উদাহরণগুলো) এটাই সুবিধাজনক। তবে বড় প্রজেক্টে স্পষ্টpermitsলিখে রাখলে পাঠক এক নজরে পুরো পরিবার দেখতে পান। - প্রতিটি অনুমোদিত উপ-টাইপকে সরাসরি extend/implement করতেই হবে: তালিকায় নাম আছে কিন্তু class-টি আসলে extend করেনি — এটাও compile error.
C-তে এই জিনিস হাতে বানাতে হয় “tagged union” দিয়ে — একটি enum ট্যাগ আর একটি union, আর ট্যাগ ভুল পড়লে compiler কিছুই বলে না (দেখুন C-র struct ও union). C++17-এর std::variant আর std::visit এর টাইপ-নিরাপদ রূপ (C++-এর optional, variant আর any). Python 3.10-এর match-এ class pattern দিয়ে প্রায় একই রকম খুলে দেখা যায়, কিন্তু টাইপ-পরিবার বন্ধ করার বা সব রকম সামলানো হয়েছে কি না প্রমাণের কাজ ভাষা নিজে করে না (Python-এর if/elif আর match).
৩ · Type pattern — instanceof থেকে switch
পর্ব ২০-এ দেখেছেন Java 16-এর instanceof pattern: if (r instanceof Success s) — পরীক্ষা সফল হলে s নামে cast করা ভ্যারিয়েবল তৈরি হয়। Success s অংশটির নাম type pattern: একটি টাইপ, আর মিল হলে যে নামে মানটি পাওয়া যাবে। তিনটি রকম সামলাতে এভাবে লিখলে দাঁড়ায় একটি শিকল:
// শুধু instanceof দিয়ে (Java 16+)
String text;
if (r instanceof Success s) text = "সফল: " + s.amount();
else if (r instanceof Failed f) text = "ব্যর্থ: " + f.reason();
else if (r instanceof Pending p) text = "অপেক্ষা: " + p.waitSec();
else throw new IllegalStateException("অজানা ফল"); // সত্যিই কি দরকার?Cast-এর ঝামেলা গেছে, কিন্তু দুটি সমস্যা রয়ে গেছে। এক, শেষ লাইনের else throw — compiler জানে না যে আর কোনো রকম নেই, তাই আপনাকে একটি “অসম্ভব” শাখা লিখতে হয়। দুই, কাল Refunded যোগ হলে এই শিকল চুপচাপ compile হবে, আর ভুলটা ধরা পড়বে চলার সময়, হয়তো কোনো গ্রাহকের ফোনে। Java 21-এ একই pattern switch-এর case-এ বসানো যায় (switch expression, পর্ব ১০):
// Java 21: pattern matching for switch (JEP 441)
String text = switch (r) {
case Success s -> "সফল: " + s.amount();
case Failed f -> "ব্যর্থ: " + f.reason();
case Pending p -> "অপেক্ষা: " + p.waitSec();
};পার্থক্যটা শুধু দেখতে সুন্দর হওয়া নয়। switch একটি একক কাঠামো, তাই compiler পুরোটা একসাথে বিচার করতে পারে: সব রকম ঢাকা হয়েছে কি না (অংশ ৫), কোনো case অন্যটির আড়ালে চাপা পড়েছে কি না (অংশ ৬)। if-else-শিকল আলাদা আলাদা statement, তাই সেখানে এই বিচার হয় না। পর্ব ১০-এ switch চলত শুধু সংখ্যা, String বা enum-এর ওপর। এখন যেকোনো reference টাইপের ওপর চলে, আর case মানে “এই মান কি এই আকারের?”
পুরোনো শিকল সবসময় খারাপ নয়, তবে সন্দেহ করুন। একটিমাত্র টাইপ পরীক্ষা করতে if (x instanceof Success s) একদম ঠিক। কিন্তু একই ভ্যারিয়েবলের ওপর তিন-চারটি else if (… instanceof …) দেখলে জিজ্ঞেস করুন: পরিবারটা কি বন্ধ? হলে sealed + switch লিখুন। আর খোলা হলে পর্ব ২০-এর polymorphism.
৪ · Record pattern — ডেটা খুলে দেখা, guard আর case null
Type pattern-এ case Success s লিখে তারপর s.txId(), s.amount() ডাকতে হয়। Record-এর বেলায় Java 21 আরেক ধাপ এগিয়েছে: record যেভাবে তৈরি হয়, ঠিক সেই আকারেই তাকে খুলে ফেলা যায়। new Success("TX9K2", 500) দিয়ে বানালে case Success(var txId, var amount) দিয়ে খোলা যায়। এর নাম record pattern (JEP 440). মিল হলে record-এর প্রতিটি component নিজের নামে একটি ভ্যারিয়েবলে চলে আসে। var-এর জায়গায় আসল টাইপও লেখা যায়: case Success(String txId, int amount).
নিচের প্রোগ্রামে পাঁচটি উত্তর এসেছে — একটি null সহ। বড় অঙ্কের সফল লেনদেনের জন্য আলাদা বার্তা চাই, তাই একটি guard (when) আছে। চালিয়ে দেখুন:
sealed interface PaymentResult permits Success, Failed, Pending {}
record Success(String txId, int amount) implements PaymentResult {}
record Failed(String reason) implements PaymentResult {}
record Pending(int waitSec) implements PaymentResult {}
class Main {
static String message(PaymentResult r) {
return switch (r) {
case null -> "কোনো উত্তর আসেনি";
case Success(var txId, var amount) when amount >= 10000 ->
"বড় লেনদেন সফল: " + amount + " টাকা, TxID " + txId;
case Success(var txId, var amount) -> "সফল: " + amount + " টাকা, TxID " + txId;
case Failed(var reason) -> "ব্যর্থ: " + reason;
case Pending(var waitSec) -> waitSec + " সেকেন্ড পরে আবার দেখুন";
};
}
public static void main(String[] args) {
PaymentResult[] results = { new Success("TX9K2", 500), new Failed("ব্যালান্স কম"),
new Pending(30), new Success("TX7Q1", 15000), null };
for (PaymentResult r : results) {
System.out.println(message(r));
}
}
}আউটপুট পাঁচ লাইন: সফল: 500 টাকা, TxID TX9K2, ব্যর্থ: ব্যালান্স কম, 30 সেকেন্ড পরে আবার দেখুন, বড় লেনদেন সফল: 15000 টাকা, TxID TX7Q1, কোনো উত্তর আসেনি. কোনো cast নেই, কোনো getter ডাকা নেই — txId, amount, reason সরাসরি ভ্যারিয়েবল হিসেবে এসেছে। Guard-ওয়ালা Success case আছে guard-ছাড়া Success-এর আগে, কারণ case-গুলো ওপর থেকে নিচে পরীক্ষা হয় আর প্রথম মিলটিই জেতে। ৫০০ টাকার লেনদেন প্রথম Success case-এর আকারে মেলে, কিন্তু guard মিথ্যা বলে সেটি বাদ পড়ে আর দ্বিতীয়টিতে যায়। case null না থাকলে শেষ উত্তরে NullPointerException হতো (অংশ ৫)। সব টাইপ এখানে একই ফাইলে আর public ছাড়া লেখা, যাতে অনলাইনে চলে; নিজের প্রজেক্টে সাধারণত প্রতিটি নিজের ফাইলে থাকে, আর main class হয় public class Main. TxID আর অঙ্কগুলো বানানো উদাহরণ।
প্রতিটি উত্তরের জন্য switch কোন case-এ থামল, আর কোন ভ্যারিয়েবলে কী বসল — ধাপে ধাপে দেখুন:
1return switch (r) {2 case null -> "কোনো উত্তর আসেনি";3 case Success(var txId, var amount)4 when amount >= 10000 -> "বড় লেনদেন …";5 case Success(var txId, var amount) -> "সফল …";6 case Failed(var reason) -> "ব্যর্থ: " + reason;7 case Pending(var waitSec) -> waitSec + " সেকেন্ড …";8};
| r | |
| যে case জিতল | |
| বাঁধা ভ্যারিয়েবল |
- ৫০০ টাকার Success.
rnull নয়, তাই লাইন ২ বাদ। লাইন ৩-এ আকার মেলে —txIdআরamountবাঁধা হয় — কিন্তু guard500 >= 10000মিথ্যা, তাই case-টি বাদ। লাইন ৫-এ আবার আকার মেলে, এবার কোনো শর্ত নেই: “সফল: 500 টাকা …”. - Failed. দুটি
Successcase আকারেই মেলে না (object-টিFailed), তাই সরাসরি লাইন ৬. Record-এর একমাত্র componentreason-এ বসে “ব্যালান্স কম”. কোনো cast বাgetReason()লাগল না। - Pending. লাইন ৭-এ মিল,
waitSec = 30. লক্ষ করুন, প্রতিটি case-এর ভ্যারিয়েবল শুধু সেই case-এর ভেতরে থাকে — লাইন ৬-এরreasonএখানে নেই, তাই নামের সংঘর্ষও নেই। - ১৫,০০০ টাকার Success. লাইন ৩-এ আকার মেলে, guard
15000 >= 10000সত্য — এখানেই থামে। লাইন ৫-এ আর যাওয়াই হয় না। ক্রমটা উল্টো হলে কী হতো? Guard-ছাড়া case সবSuccessআগেই ধরে ফেলত, তাই Java সেটা compile করতেই দেয় না (অংশ ৬)। - null.
case nullসবার আগে পরীক্ষা হয়। এটি না থাকলেswitchnull পেলে কোনো case না দেখেইNullPointerExceptionছুড়ত — পুরোনোswitch-এর আচরণ এটাই, আর Java 21 সেটা বজায় রেখেছে, যদি না আপনি নিজেcase nullলেখেন।
Nested record pattern আর instanceof-এ record pattern
Record-এর component নিজেও একটি record হলে pattern-এর ভেতরে আরেকটি pattern বসানো যায়। ধরুন একটি ডেলিভারি অ্যাপে record Rider(String name, String phone) আর record PickedUp(Rider rider, String hub). তখন case PickedUp(Rider(var name, var phone), var hub) -> এক লাইনে দুই স্তর খুলে দেয়। অংশ ৭-এ expression গাছে এর আসল শক্তি দেখবেন। Record pattern instanceof-এও চলে: if (o instanceof Success(var id, var amt) && amt > 1000).
৫ · Exhaustiveness — compiler প্রমাণ করে কোনো রকম বাদ পড়েনি
পর্ব ১০-এ দেখেছেন, enum-এর ওপর switch expression-এ সব মান না থাকলে compiler আপত্তি করে। এই নিয়মের নাম exhaustiveness. Sealed টাইপে একই নিয়ম আরও শক্তিশালী হয়ে কাজ করে। Compiler permits তালিকা পড়ে জানে PaymentResult মানে ঠিক Success, Failed আর Pending. তিনটিরই case থাকলে switch সম্পূর্ণ — default লাগে না। একটি বাদ পড়লে:
Main.java:8: error: the switch expression does not cover all possible input values
return switch (r) {
^
1 error
মান ফেরত না দেওয়া switch statement-এও, case-এ pattern থাকলেই, একই নিয়ম খাটে। তখন বার্তায় লেখা থাকে “the switch statement does not cover all possible input values”. (দুটিই OpenJDK 21-এ যাচাই করা।) আর মনে রাখুন, guard-ওয়ালা case exhaustiveness-এর হিসাবে গোনা হয় না। শুধু case Success(…) when amount >= 10000 থাকলে compiler ধরে নেয় ছোট অঙ্কের Success বাকি রয়ে গেছে, আর একই error দেয়।
এবার আসল দৃশ্য। ছয় মাস পরে ব্যবসায় নতুন নিয়ম এল: টাকা ফেরত (refund) হলে আলাদা অবস্থা দেখাতে হবে। আপনি Refunded যোগ করলেন। এখন কী হয়, ধাপে ধাপে দেখুন:
- পরিবার
৩টি রকমসব switch সম্পূর্ণ - নতুন রকম
Refundedpermits-এ যোগ - javac
২টি errorঅসম্পূর্ণ switch - default-ওয়ালা
নীরবধরা পড়ে না - ঠিক করা
case Refundedআবার compile
PaymentResult.java sealed … permits Success, Failed, Pending
Screen.java message(r) → switch: Success, Failed, Pending
Sms.java smsText(r) → switch: Success, Failed, Pending
Report.java label(r) → switch: Success, default
sealed interface PaymentResult
permits Success, Failed, Pending, Refunded {}
record Refunded(String txId, int amount) implements PaymentResult {}
Screen.java:4: error: the switch expression does not cover all possible input values Sms.java:6: error: the switch expression does not cover all possible input values 2 errors
return switch (r) {
case Success s -> "PAID";
default -> "NOT PAID"; // Refunded-ও এখানে — ভুল লেবেল!
};
case Refunded(var txId, var amount) -> amount + " টাকা ফেরত দেওয়া হয়েছে"; // Report.java: default সরিয়ে প্রতিটি রকম লিখুন
- শুরু। তিনটি রকম, আর তিনটি ফাইলে তিনটি
switch. দুটিতে তিন রকমেরই case আছে।Report.java-র লেখক অলসতা করে একটিdefaultদিয়ে বাকি সব ঢেকে দিয়েছেন। আপাতত সব চলে। - নতুন রকম যোগ।
Refundedrecord লেখা হলো, আরpermits-এ নাম বসল। পরিবার বদলানোর সিদ্ধান্ত এক জায়গায়, এক লাইনে। - Compiler প্রতিটি অসম্পূর্ণ switch-এর তালিকা দেয়।
Screen.javaআরSms.java-র switch এখন আর সম্পূর্ণ নয়, তাই প্রজেক্ট compile-ই হয় না। এটা বিরক্তিকর মনে হতে পারে, কিন্তু আসলে compiler আপনার হয়ে পুরো codebase খুঁজে দিয়েছে: “এই এই জায়গায় refund-এর কথা কেউ ভাবেনি”। কোনো গ্রাহক কিছু দেখার আগেই। - default-ওয়ালা switch নীরব থাকে।
Report.javaদিব্যি compile হয়, কারণdefault“বাকি সব” ধরে। ফলে refund হওয়া লেনদেনের লেবেল হয় “NOT PAID” — হিসাবের রিপোর্টে নীরব ভুল। Exhaustive switch-এdefaultলেখা মানে compiler-এর এই সাহায্য নিজে হাতে বন্ধ করা। - ঠিক করা। দুটি ফাইলে
case Refunded(…)যোগ হলো, আরReport.java-তেdefaultসরিয়ে প্রতিটি রকম আলাদা করে লেখা হলো। এখন আবার সব switch সম্পূর্ণ, আর পঞ্চম রকম এলে আবার একই পাহারা পাওয়া যাবে।
Sealed টাইপের ওপর switch-এ default লিখবেন না, প্রতিটি রকম আলাদা করে লিখুন। তাহলে নতুন রকম যোগ হলে compiler নিজেই সব অসম্পূর্ণ জায়গা দেখিয়ে দেবে। default তখনই ঠিক, যখন টাইপটা সত্যিই খোলা (যেমন Object, String, বা non-sealed শাখা)।
চলার সময় দুটি নিরাপত্তা
- null: sealed পরিবার সম্পূর্ণ হলেও null তার সদস্য নয়।
case nullনা থাকলে null এলেswitchসরাসরি NullPointerException ছোড়ে। OpenJDK 21-এ trace-এর প্রথম লাইনException in thread "main" java.lang.NullPointerException, তার নিচেjava.util.Objects.requireNonNullথেকে আসা একটি লাইন। null আসতে পারে এমন জায়গায়case null ->লিখুন, অথবাcase null, default ->একসাথে লিখুন। আরও ভালো উপায় হলো, null যেন ঢুকতেই না পারে এমন নকশা (পর্ব ৩৬-এর Optional). - আলাদা করে compile করা কোড: ধরুন
PaymentResultএকটি library-তে আছে, আর আপনার অ্যাপের switch আগের সংস্করণ দেখে compile হয়েছিল। পরে library-তে নতুন রকম এল, কিন্তু আপনার অ্যাপ আবার compile হয়নি। তখন চলার সময় অজানা রকম এলে Java চুপ থাকে না। Compiler আগেই সম্পূর্ণ switch-এর ভেতরে একটি লুকানো শাখা বসিয়ে রেখেছিল, যা MatchException ছোড়ে। ফলে ভুলটা নীরবে চলে না, চোখে পড়ে।
৬ · ক্রম, guard আর dominance — কোন case আগে লিখবেন
Pattern switch ওপর থেকে নিচে পরীক্ষা করে, আর প্রথম মিলটিই জেতে। তাই ক্রম গুরুত্বপূর্ণ। কিন্তু Java একটি ভুল ক্রম নিজেই ধরে। কোনো case যদি এমন হয় যে তার আগের কোনো case তার সব মান আগেই ধরে ফেলবে, তাহলে সেটি কোনোদিন চলতে পারবে না। এমন মৃত কোড Java মানে না। এই নিয়মের নাম dominance:
return switch (r) {
case Success s -> "ok " + s.txId();
case Success s when s.amount() > 10000 -> "big"; // কখনো পৌঁছাবে না
case Failed f -> "fail " + f.reason();
case Pending p -> "wait " + p.waitSec();
};Main.java:9: error: this case label is dominated by a preceding case label
case Success s when s.amount() > 10000 -> "big";
^
1 error
প্রথম case সব Success ধরে ফেলে, শর্ত ছাড়াই। তাই দ্বিতীয়টি “dominated” — চাপা পড়া। Record pattern-এও হুবহু একই বার্তা আসে। আর টাইপের ক্ষেত্রেও একই কথা: case Object o-এর পরে case Integer i লিখলে একই error, কারণ প্রতিটি Integer আগেই একটি Object. (সবগুলো OpenJDK 21-এ যাচাই করা।) নিয়মটা মনে রাখার সহজ উপায় হলো পর্ব ১০-এর else-if সারির নিয়ম: নির্দিষ্ট আগে, সাধারণ পরে। এখানে পার্থক্য একটাই — compiler নিজে পাহারা দেয়।
| পরিস্থিতি | ফল |
|---|---|
guard-ওয়ালা case Success s when …, তারপর guard-ছাড়া case Success s | ঠিক আছে — এটাই সঠিক ক্রম |
guard-ছাড়া case Success s, তারপর guard-ওয়ালা একই টাইপ | compile error: dominated |
subclass আগে, superclass পরে (Integer তারপর Number) | ঠিক আছে |
superclass আগে, subclass পরে (Object তারপর Integer) | compile error: dominated |
| শুধু guard-ওয়ালা case, guard-ছাড়া কোনো case নেই | sealed টাইপে compile error: সব মান ঢাকা হয়নি |
Guard কতটা ব্যবহার করবেন
when-এর পরে যেকোনো boolean শর্ত লেখা যায়, আর pattern-এর ভ্যারিয়েবল সেখানে ব্যবহার করা যায়। কিন্তু compiler guard-এর ভেতরে কী আছে তা বিশ্লেষণ করে না। তাই when amount >= 10000 আর when amount < 10000 — দুটো একসাথে সব সংখ্যা ঢাকলেও compiler তা জানে না, আর guard-ছাড়া একটি case চাইবে। ব্যবহারিক পরামর্শ: guard রাখুন ব্যতিক্রমের জন্য, আর প্রতিটি রকমের জন্য guard-ছাড়া একটি “স্বাভাবিক” case রাখুন।
একটি নতুন সংস্করণের সুবিধা — _: কোনো component-এর মান দরকার না হলে নামের বদলে _ লেখা যায়: case Success(_, var amount). এর নাম unnamed pattern. সাবধান: Java 21-এ এটি শুধু preview ছিল। Java 21-এ সাধারণভাবে compile করলে আসে unnamed variables are a preview feature and are disabled by default. Java 22 থেকে এটি স্থায়ী (JEP 456). এই কোর্স Java 21-এ চলে, তাই আমরা দরকার না থাকলেও var দিয়ে নাম রাখছি।
৭ · sealed + record = algebraic data type — একটি ছোট ক্যালকুলেটর
এতক্ষণ যা বানালেন, তার একটি পুরোনো ও সুন্দর নাম আছে: algebraic data type. নামটা ভয়ের নয়। দুটি সরল গঠন জুড়ে বানানো টাইপ:
“এবং” — product type
একটি Success-এ txId এবং amount দুটোই থাকে। এমন টাইপকে বলে product type, আর Java-তে এর পরিষ্কার রূপ record.
“অথবা” — sum type
একটি PaymentResult হয় Success, অথবা Failed, অথবা Pending. এমন টাইপকে বলে sum type, আর Java-তে এর রূপ sealed interface.
(নাম দুটো গণিত থেকে। ধরুন একটি record-এ দুটি boolean আছে। তাহলে মোট সম্ভাব্য মান ২ × ২ = ৪, তাই “product” বা গুণফল। আর sealed পরিবারের মোট মান হয় প্রতিটি রকমের মানের যোগফল, তাই “sum”.) এই দুই গঠন দিয়ে আশ্চর্য রকম অনেক কিছু মডেল করা যায়, এমনকি এমন ডেটাও যা নিজের ভেতরে নিজেকেই ধারণ করে। একটি গাণিতিক expression ভাবুন: 2 + 3 * 4. এটি হয় একটি সংখ্যা, অথবা দুটি expression-এর যোগ, অথবা দুটি expression-এর গুণ। লক্ষ করুন, “expression”-এর সংজ্ঞায় আবার “expression” আছে — এটি একটি গাছ:
sealed interface Expr permits Num, Add, Mul {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
class Main {
static int eval(Expr e) {
return switch (e) {
case Num(var v) -> v;
case Add(var l, var r) -> eval(l) + eval(r);
case Mul(var l, var r) -> eval(l) * eval(r);
};
}
static String show(Expr e) {
return switch (e) {
case Num(var v) -> String.valueOf(v);
case Mul(Num(var a), Num(var b)) when a == 1 -> String.valueOf(b);
case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
case Mul(var l, var r) -> show(l) + " * " + show(r);
};
}
public static void main(String[] args) {
Expr e = new Add(new Num(2), new Mul(new Num(3), new Num(4)));
System.out.println(show(e) + " = " + eval(e));
Expr f = new Mul(new Num(1), new Num(7));
System.out.println(show(f) + " = " + eval(f));
System.out.println(e);
}
}আউটপুট তিন লাইন: (2 + 3 * 4) = 14, 7 = 7, আর Add[left=Num[value=2], right=Mul[left=Num[value=3], right=Num[value=4]]]. eval একটি recursive method: Add আর Mul-এর দুই পাশ নিজেই আবার eval করে, আর Num-এ এসে থামে। show-এর দ্বিতীয় case-এ আছে একটি nested record pattern: Mul(Num(var a), Num(var b)) মেলে শুধু তখন, যখন গুণের দুই পাশই সংখ্যা। Guard a == 1 দিয়ে “১ দিয়ে গুণ”-কে সরল করে শুধু 7 দেখায়। এই guard-ওয়ালা Mul case সাধারণ Mul-এর আগে আছে, অংশ ৬-এর নিয়মে। শেষ লাইনটি record-এর নিজে থেকে তৈরি toString() (পর্ব ২৫) — গাছের পুরো গঠন এক নজরে দেখা যায়।
eval(Add(Num 2, Mul(Num 3, Num 4))) ভেতরে কীভাবে চলে, ধাপে ধাপে দেখুন। Call stack-এ কোন ডাক জমছে আর কোনটা ফল ফেরত দিচ্ছে, সেদিকে নজর রাখুন:
1static int eval(Expr e) {2 return switch (e) {3 case Num(var v) -> v;4 case Add(var l, var r) -> eval(l) + eval(r);5 case Mul(var l, var r) -> eval(l) * eval(r);6 };7}
| call stack (ওপরে সবশেষ) | |
| যে case মিলল | |
| ফেরত মান |
- শুরু: একটি Add. সবচেয়ে বাইরের মান
Add, তাই লাইন ৪ মেলে। Record pattern এক ধাক্কায় দুই পাশ খুলে দেয়:l = Num(2),r = Mul(Num 3, Num 4). এখনeval(l)ডাকতে হবে, তাই এই ডাক অপেক্ষায় থাকে। - বাঁ দিক: Num 2. নতুন একটি
evalstack-এ উঠল।Num(var v)মেলে,v = 2, সাথে সাথে ফেরত। এটিই base case — এখানে আর কোনো ডাক নেই। Recursion থামে এখানেই। - ডান দিক: Mul.
Addএবারeval(r)ডাকে।rএকটিMul, তাই লাইন ৫ মেলে আর খুলে দেয়l = Num(3),r = Num(4). লক্ষ করুন, প্রতিটি ডাকের নিজেরlআরrআছে। বাইরেরAdd-এরl,rনিজের frame-এ অক্ষত আছে। - গুণের দুই পাশ।
eval(Num 3)ফেরত দেয় 3, তারপরeval(Num 4)ফেরত দেয় 4 — দুটোই লাইন ৩-এর base case. - Mul শেষ।
3 * 4 = 12, আরMul-এর ডাক stack থেকে নেমে যায়। কোনো cast নেই, কোনোinstanceofনেই। প্রতিটি ধাপেswitchনিজেই বলে দিয়েছে মানটি কোন রকমের। - Add শেষ: 14. অপেক্ষায় থাকা
Addএবার2 + 12 = 14ফেরত দেয়। গাছের আকারই হিসাবের ক্রম ঠিক করেছে। তাই গুণ আগে হয়েছে, কোনো “operator precedence” নিয়ম লিখতে হয়নি।
এখন ভাবুন, এই ক্যালকুলেটরে নতুন কাজ যোগ করা কত সহজ: show একটি নতুন method, simplify আরেকটি, countNodes আরেকটি — Num, Add, Mul record-এ একটি লাইনও না ছুঁয়ে। আর নতুন রকম (ধরুন Sub) যোগ করলে compiler প্রতিটি method দেখিয়ে দেবে যেখানে Sub-এর case লাগবে (অনুশীলন ৩)।
বাস্তবে কোথায় এই নকশা
API-র ফল
PaymentResult, LoginResult (Ok, WrongPassword, Locked, NeedsOtp) — exception না ছুড়ে প্রতিটি পরিণতি টাইপে লেখা থাকে, আর caller সবগুলো সামলাতে বাধ্য হয়।
ডেলিভারির অবস্থা
Placed, PickedUp(rider), InTransit(hub), Delivered(receiver), Cancelled(reason) — প্রতিটি অবস্থার সাথে শুধু সেই অবস্থার তথ্য। “Delivered কিন্তু rider নেই” ধরনের অসম্ভব অবস্থা লেখাই যায় না।
কমান্ড
একটি CLI বা chat-bot-এ Deposit(amount), Withdraw(amount), Transfer(to, amount), Balance() — parser একটি Command বানায়, আর একটি switch সেটা চালায়।
JSON মান
JSON-এর মান ঠিক ছয় রকম: null, boolean, number, string, array, object. sealed interface JsonValue আর ছয়টি record দিয়ে পুরো JSON মডেল হয়, আর array/object-এর ভেতরে আবার JsonValue — ক্যালকুলেটরের মতোই গাছ।
৮ · Polymorphism না pattern matching? — Expression Problem আবার
পর্ব ২০-এ শিখেছিলেন, instanceof-শিকল দেখলে polymorphism-এ ভাঙুন — প্রতিটি টাইপ নিজের আচরণ নিজের method-এ রাখুক। আর এই পর্বে উল্টো কথা শুনছেন: টাইপ দেখে switch করুন। দুটোই কি ঠিক? হ্যাঁ — কারণ দুটো দুই রকম পরিবর্তনের জন্য তৈরি। পর্ব ২০-এর ভাবনার প্রশ্ন ৪-এ দেখা Expression Problem-এর ছকটা মনে করুন: সারিতে টাইপ, কলামে কাজ।
| Polymorphism (পর্ব ২০-২১) | Sealed + pattern matching | |
|---|---|---|
| কোড কোথায় থাকে | প্রতিটি কাজ প্রতিটি class-এর ভেতরে | প্রতিটি কাজ একটি method-এ, ভেতরে সব রকমের case |
| নতুন টাইপ যোগ | সহজ — শুধু নতুন class | প্রতিটি switch বদলাতে হয় (compiler দেখিয়ে দেয়) |
| নতুন কাজ যোগ | প্রতিটি class খুলে method যোগ | সহজ — শুধু নতুন method |
| কে নতুন টাইপ আনতে পারে | যে কেউ, বাইরের library থেকেও | শুধু sealed টাইপের মালিক |
| ডেটা আর আচরণ | একসাথে (encapsulation) | আলাদা — ডেটা স্বচ্ছ, আচরণ বাইরে |
| উপযুক্ত যেখানে | পেমেন্ট গেটওয়ে, plugin, notification চ্যানেল | API-র ফল, কমান্ড, expression গাছ, JSON, অবস্থা-যন্ত্র |
মূল প্রশ্নটি তাই: আপনার সিস্টেম কোন দিকে বেশি বাড়বে? নতুন নতুন পেমেন্ট পদ্ধতি আসবে, কিন্তু প্রতিটির কাজ (fee, pay, refund) মোটামুটি স্থির — তাহলে polymorphism. পেমেন্টের ফল তিন-চার রকমেই থাকবে, কিন্তু সেই ফল দিয়ে কাজ বাড়তেই থাকবে (স্ক্রিন, SMS, রিপোর্ট, অডিট লগ, অ্যানালিটিক্স) — তাহলে sealed + pattern matching. পুরোনো OOP ভাষায় দ্বিতীয় ধরনের প্রয়োজন মেটাতে লোকে Visitor pattern লিখত। তাতে প্রতিটি class-এ একটি accept method আর প্রতিটি কাজের জন্য একটি visitor class লাগত। Sealed + switch সেই একই কাজ অনেক কম আনুষ্ঠানিকতায় করে।
Java-র ভাষা-স্থপতি Brian Goetz ২০২২ সালের একটি প্রবন্ধে এই দ্বিতীয় ধারাকে Java-র প্রেক্ষাপটে data-oriented programming নামে ব্যাখ্যা করেন (নামটি অন্য প্রেক্ষাপটে আগেও ব্যবহৃত হয়েছে)। এটা OOP-এর বদলি নয়, পাশে আরেকটি হাতিয়ার। বড় সিস্টেমের সীমানায় — যেখানে বাইরের দুনিয়া থেকে ডেটা আসে বা বাইরে যায় (API, ফাইল, মেসেজ) — ডেটা প্রায়ই নিছক ডেটা। সেখানে record আর sealed পরিষ্কার। আর ভেতরের জটিল আচরণ, যেখানে state লুকিয়ে রাখা আর বদলানো দরকার, সেখানে class আর polymorphism. একই প্রজেক্টে দুটোই থাকে (ভাবনার প্রশ্ন ৩)।
- sealed class কী, কোন Java সংস্করণে স্থায়ী? — যে class/interface
permitsদিয়ে নির্দিষ্ট করে দেয় কারা তার সরাসরি উপ-টাইপ হবে; Java 17-এ স্থায়ী (JEP 409). - sealed টাইপের উপ-টাইপকে কী ঘোষণা করতে হয়? —
final,sealedবাnon-sealed-এর যেকোনো একটি; record নিজে থেকেই final. - sealed interface-এর ওপর switch-এ default কেন লাগে না? — compiler সব অনুমোদিত উপ-টাইপ জানে, তাই সবগুলোর case থাকলে switch সম্পূর্ণ (exhaustive) বলে প্রমাণিত।
- record pattern কী? —
case Point(var x, var y)রূপের pattern, যা record-এর টাইপ মিলিয়ে তার component-গুলো ভ্যারিয়েবলে খুলে দেয়; Java 21-এ স্থায়ী। - pattern switch-এ null এলে কী হয়? —
case nullনা থাকলেNullPointerException; থাকলে সেই case চলে।
৯ · AI-এর যুগে — বন্ধ পরিবার চিনতে আর যাচাই করতে শিখুন
AI কোড-সহকারীরা প্রচুর পুরোনো Java কোড দেখে শিখেছে। Java 8 আর 11 যুগের কোড সেখানে অনেক বেশি, Java 21-এর তুলনায়। তাই “পেমেন্টের ফল দেখে বার্তা দেখাও” বললে প্রায়ই পাবেন instanceof-শিকল আর প্রতিটি শাখায় cast, শেষে একটি else throw. বা একটি String status field আর switch (status), যেখানে "SUCESS"-এর মতো বানান ভুল compile-এ ধরা পড়ে না। আবার উল্টোটাও ঘটে: যেখানে বাইরের লোককে নতুন টাইপ যোগ করতে দিতে হবে (plugin), সেখানেও AI সবকিছু sealed করে দেয়।
| AI যা করে | কেন সমস্যা | আপনি যা করবেন |
|---|---|---|
instanceof-শিকল আর cast | নতুন রকম এলে চুপচাপ compile হয়, ভুল ধরা পড়ে চলার সময় | sealed + exhaustive switch চান |
Sealed টাইপের switch-এ default | নতুন রকম default-এ পড়ে নীরবে ভুল ফল দেয় | প্রতিটি রকম আলাদা case, default নয় |
String/int status কোড আর পাশে nullable field | “Failed কিন্তু txId আছে” ধরনের অসম্ভব অবস্থা তৈরি করা যায় | প্রতিটি অবস্থা আলাদা record, শুধু নিজের তথ্য নিয়ে |
| Guard-ছাড়া case আগে, guard-ওয়ালা পরে | compile error (dominated); AI প্রায়ই guard মুছে “ঠিক” করে | নির্দিষ্ট case আগে রাখুন |
case Success(_, var a) লিখে Java 21 দাবি | Java 21-এ _ preview — compile হয় না | সংস্করণ বলে দিন; Java 21-এ var দিয়ে নাম |
| খোলা plugin-ব্যবস্থাও sealed করা | বাইরের কেউ নতুন রকম যোগ করতে পারে না | “কে নতুন রকম আনবে?” প্রশ্নের উত্তর দিয়ে বেছে নিন |
AI-কে এমন কোড লিখতে বা রিভিউ করতে বলার সময় এই কাঠামো ব্যবহার করুন:
AI-এর লেখা কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Algebraic data type আর pattern matching ১৯৭০-এর দশকের ML থেকে শুরু করে Hope ও পরে Haskell-এর মতো ভাষার ধারণা। প্রায় চার দশক পরে Java-তে এল কেন, আর এত দেরি কেন?
জন্ম গবেষণাগারে। ১৯৭০-এর দশকে এডিনবরা বিশ্ববিদ্যালয়ে Robin Milner-এর দল একটি theorem prover (LCF) তৈরির সময় তার নিয়ন্ত্রণ-ভাষা হিসেবে ML বানান। ML-এর বড় অবদান ছিল type inference আর শক্ত টাইপ-ব্যবস্থা। প্রায় একই সময়ে, একই বিশ্ববিদ্যালয়ে Rod Burstall ও তাঁর সহকর্মীরা NPL আর পরে Hope ভাষায় ডেটা-টাইপকে “এই রকম অথবা ওই রকম” হিসেবে ঘোষণা করা আর function-এর ভেতরে pattern দিয়ে খুলে দেখার ধারণা দাঁড় করান। পরে Standard ML-এ এই দুই ধারা মেশে। ১৯৯০ সালে প্রকাশিত Haskell-এর প্রথম রিপোর্টে এটি ভাষার কেন্দ্রে বসে। এই ভাষাগুলোতে compiler বহু আগে থেকেই বলতে পারত “আপনার pattern-এ একটি রকম বাদ পড়েছে”।
Java-র ভিন্ন শিকড়। ১৯৯৫ সালে Java এসেছিল C++ আর Smalltalk-এর পরিবার থেকে, যেখানে মূল কথা ছিল object, encapsulation আর polymorphism. সেই দর্শনে “টাইপ দেখে switch করা” ছিল খারাপ অভ্যাসের লক্ষণ। পর্ব ২০-এর পরামর্শও তাই ছিল: instanceof-শিকল দেখলে polymorphism-এ ভাঙুন। খোলা class-ব্যবস্থায় compiler কখনো জানতে পারত না সব subclass কারা, তাই exhaustiveness-এর প্রশ্নই উঠত না।
কেন বদলাল। দুটি জিনিস বদলেছে। প্রথমত, ২০১০-এর দশকে সফটওয়্যারের বড় অংশ হয়ে গেল সার্ভিস-থেকে-সার্ভিসে ডেটা পাঠানো — JSON, মেসেজ, API-র উত্তর। এই ডেটার কোনো “আচরণ” নেই, শুধু আকার আছে। দ্বিতীয়ত, JVM-এরই দুই ভাষা, Scala (২০০৪) আর Kotlin (২০১৬-তে ১.০), দেখিয়ে দিল যে object আর algebraic data type একই ভাষায় শান্তিতে থাকতে পারে। Java-র দল তখন সিদ্ধান্ত নিল টুকরো টুকরো করে আনার: আগে record (product type), তারপর sealed (sum type), তারপর pattern. প্রতিটি ধাপ আলাদাভাবে কাজের, আবার একসাথে মিলে পুরো ব্যবস্থা।
দেরির আরেক কারণ — সামঞ্জস্য। Java-র প্রতিশ্রুতি হলো পুরোনো কোড নতুন সংস্করণেও চলবে। ফলে নতুন বৈশিষ্ট্য এমনভাবে বসাতে হয়েছে যাতে পুরোনো switch-এর অর্থ না বদলায়। যেমন null এলে আগের মতোই NullPointerException হয়, যদি না case null লেখা থাকে। এই সাবধানতা ধীরগতির, কিন্তু কোটি কোটি লাইনের পুরোনো কোড ভাঙে না।
মূল উপলব্ধি: ভালো ধারণা প্রায়ই গবেষণার ভাষায় জন্মায় আর মূলধারায় আসে কয়েক দশক পরে, যখন বাস্তব সমস্যা সেটাকে প্রয়োজনীয় করে তোলে। Java-তে ADT এসেছে ধাপে ধাপে, পুরোনো কোড না ভেঙে।
প্র ০২JEP 409 (sealed class) কোন সমস্যার সমাধান হিসেবে এসেছিল, আর Project Amber-এর “ছোট ছোট ধাপ, preview দিয়ে যাচাই” কৌশল এই বৈশিষ্ট্যগুলোকে কীভাবে গড়েছে?
Sealed-এর আগে কী ছিল। Java 17-এর আগে একটি টাইপ-পরিবার “বন্ধ” করার দুটি অসম্পূর্ণ উপায় ছিল। একটি হলো class-কে package-private রাখা — তাতে বাইরের কেউ extend করতে পারে না, কিন্তু টাইপটা বাইরে ব্যবহারও করতে পারে না। অন্যটি হলো সব constructor private রেখে nested subclass বানানো — কাজ চলে, কিন্তু কৌশলটা আনাড়ি, আর compiler তবুও জানে না সব subclass কারা। JEP 409 সমস্যাটি স্পষ্ট করে বলেছিল: একটি টাইপের লেখক যেন ঘোষণা করে বলতে পারেন কোন কোড তাকে implement করার জন্য দায়ী। আর সেই ঘোষণা যেন টাইপের ব্যবহার সীমিত না করে, শুধু বাড়ানো সীমিত করে। অর্থাৎ accessibility আর extensibility আলাদা করা।
Project Amber. ২০১৭ সালের দিকে OpenJDK-তে Project Amber শুরু হয়, Brian Goetz-এর নেতৃত্বে। লক্ষ্য ছিল Java-কে ছোট ছোট, ব্যবহারিক ধাপে আরও সংক্ষিপ্ত করা। এর ফল একে একে এসেছে: var (Java 10), switch expression (14), text block (15), record আর instanceof pattern (16), sealed (17), আর switch-এর pattern ও record pattern (21). লক্ষ করুন, এগুলো একটি বড় পরিকল্পনার টুকরো। Record ছাড়া record pattern নেই, sealed ছাড়া exhaustiveness নেই, switch expression ছাড়া pattern switch অর্ধেক কাজের।
Preview-র ভূমিকা। Java 12 থেকে “preview feature” ব্যবস্থা চালু হয়। পুরোপুরি তৈরি বৈশিষ্ট্য সংকলকে আসে, কিন্তু --enable-preview ছাড়া চলে না, আর মতামতের ভিত্তিতে বদলাতে পারে। Sealed দুই দফা preview-তে ছিল (Java 15, 16). Switch-এর pattern matching ছিল চার দফা (17 থেকে 20). এই সময়ে নকশা সত্যিই বদলেছে। যেমন guard প্রথমে লেখা হতো && দিয়ে, পরে when keyword এসেছে, কারণ && pattern আর boolean expression-এর সীমানা ঘোলাটে করত। Record pattern-এর একটি অংশ (enhanced for-এ record pattern) preview-তে এসে পরে বাদও পড়েছে।
কেন এই কৌশল কাজের। ভাষার কোনো বৈশিষ্ট্য একবার স্থায়ী হলে তা আর ফেরানো যায় না, কারণ লক্ষ লক্ষ প্রজেক্ট তার ওপর নির্ভর করবে। Preview সেই ঝুঁকি কমায়। আর ছোট ধাপে আনায় প্রতিটি সংস্করণে মানুষ একটি নতুন জিনিস শেখে, ব্যবহার করে, মতামত দেয়।
মূল উপলব্ধি: Sealed class শুধু একটি keyword নয়, Amber-এর বড় নকশার একটি টুকরো — “বাড়ানোর অধিকার” আর “ব্যবহারের অধিকার” আলাদা করার উপায়, যা exhaustive pattern matching-কে সম্ভব করেছে।
প্র ০৩Brian Goetz-এর “Data Oriented Programming in Java” (২০২২) প্রবন্ধের মূল নীতিগুলো কী? এগুলো কি OOP-এর বিরোধী?
২০২২ সালে InfoQ-তে প্রকাশিত এই প্রবন্ধে Brian Goetz দেখান, record, sealed টাইপ আর pattern matching মিলে Java-তে একটি নতুন ধারার প্রোগ্রামিং সম্ভব হয়েছে। প্রবন্ধে তিনি কয়েকটি নীতি দেন, যা মোটামুটি এরকম।
ডেটা মডেল করুন — পুরো ডেটা, আর শুধু ডেটা। প্রতিটি অবস্থার জন্য আলাদা record, শুধু তার দরকারি তথ্য নিয়ে। একটি বড় class-এ status field আর পাঁচটি nullable field রাখবেন না, যার কোনগুলো কখন অর্থপূর্ণ তা কেবল মন্তব্যে লেখা।
ডেটা অপরিবর্তনীয়। Record-এর field final, তাই ডেটা immutable. একটি মান তৈরি হলে কেউ পেছন থেকে বদলাতে পারে না। ফলে thread-এর মধ্যে ভাগাভাগি আর যুক্তি করা সহজ।
সীমানায় যাচাই করুন। বাইরে থেকে ডেটা ঢোকার সময়ই (record-এর constructor-এ) নিয়ম যাচাই করুন — যেমন amount ঋণাত্মক নয়। তারপর ভেতরের কোড নিশ্চিন্তে ধরে নিতে পারে যে মানটি বৈধ।
অবৈধ অবস্থা যেন লেখাই না যায়। “Failed কিন্তু txId আছে”, বা “Delivered কিন্তু rider নেই” — sealed + record দিয়ে এমন অবস্থার জন্য কোনো টাইপই থাকে না। এই বাক্যটি আসলে ML-ধারার প্রোগ্রামারদের পুরোনো স্লোগান, যা Yaron Minsky-র মতো লেখকেরা জনপ্রিয় করেছেন।
OOP-এর বিরোধী? না, আর Goetz নিজেও তা স্পষ্ট বলেছেন। OOP-এর শক্তি জটিল, পরিবর্তনশীল state-কে একটি স্থির interface-এর পেছনে লুকিয়ে রাখা। বড়, দীর্ঘজীবী উপাদানে, যেমন একটি connection pool বা একটি game engine, সেটাই দরকার। কিন্তু ছোট, ক্ষণস্থায়ী ডেটা — একটি API-র উত্তর, একটি কমান্ড, একটি ইভেন্ট — লুকানোর কিছু নেই, শুধু আকার আছে। সেখানে getter-setter-ওয়ালা class বানানো অকারণ আনুষ্ঠানিকতা। Goetz-এর যুক্তি মোটামুটি এরকম: যে সরল ডেটা সিস্টেমের সীমানা পেরিয়ে আসে আর যায় (মেসেজ, API-র উত্তর), সেখানে data-oriented নকশা। আর জটিল আচরণ ও state লুকানোর জায়গায় OOP.
মূল উপলব্ধি: Data-oriented programming মানে ডেটাকে নিছক ডেটা হিসেবে স্বীকার করা — স্বচ্ছ, অপরিবর্তনীয়, প্রতিটি অবস্থা আলাদা টাইপে। এটা OOP-কে বাদ দেয় না, যেখানে OOP অতিরিক্ত, সেখানে বিকল্প দেয়।
প্র ০৪“default সবসময় রাখুন” — পুরোনো এই নিরাপত্তা-পরামর্শ exhaustive switch-এ কেন উল্টো ফল দেয়? কখন default রাখাই ঠিক?
পুরোনো পরামর্শের যুক্তি। পুরোনো switch statement-এ কোনো case না মিললে কিছুই ঘটত না, প্রোগ্রাম চুপচাপ পরের লাইনে চলে যেত। একটি অপ্রত্যাশিত int বা String মান এলে ভুলটা কোথাও ধরা পড়ত না। তাই coding standard-গুলো বলত: সবসময় default রাখুন, অন্তত একটি error লগ করুন বা exception ছুড়ুন। সংখ্যা বা String-এর মতো খোলা টাইপে এই পরামর্শ আজও ঠিক, কারণ সম্ভাব্য মান অসীম।
বন্ধ টাইপে কী বদলায়। Sealed টাইপ বা enum-এ সম্ভাব্য রকম compiler-এর জানা। এখানে default-এর অর্থ দাঁড়ায় “এখন যা আছে, আর ভবিষ্যতে যা আসবে — সবই এখানে”। দ্বিতীয় অংশটাই বিপদ। অংশ ৫-এর stepper-এ দেখেছেন: Refunded এলে default-ওয়ালা রিপোর্ট চুপচাপ “NOT PAID” লেখে। Compiler কিছু বলে না, কারণ আপনি নিজেই বলে দিয়েছেন “বাকি সব আমি সামলাচ্ছি”। যে নিরাপত্তা-জাল ভুল ধরার জন্য ছিল, সেটাই ভুল লুকিয়ে ফেলে।
Default ছাড়া কি অরক্ষিত? না। Java সম্পূর্ণ switch-এ নিজেই একটি লুকানো শাখা বসায়। অপ্রত্যাশিত রকম চলার সময় এলে (আলাদা করে compile করা library-র ক্ষেত্রে) সেই শাখা MatchException ছোড়ে। অর্থাৎ compile-এর সময় compiler পাহারা দেয়, আর চলার সময় JVM. default লিখে আপনি প্রথম পাহারাটা বন্ধ করেন, বিনিময়ে কিছুই পান না।
অন্য ভাষার অভিজ্ঞতা। একই আলোচনা Rust, Swift, Kotlin-এ হয়েছে। Rust-এ _ => wildcard দিয়ে সব ধরা যায়। কিন্তু অন্য crate-এর enum-এর বেলায় লেখক নিজে #[non_exhaustive] চিহ্ন দিলে তবেই wildcard বাধ্যতামূলক হয় — অর্থাৎ “ভবিষ্যতে রকম বাড়বে” ঘোষণাটা টাইপের মালিকের, ব্যবহারকারীর নয়। Swift-এ @unknown default আছে। এটা অপরিচিত রকম ধরে, কিন্তু পরিচিত কোনো রকম বাদ পড়লে compiler সতর্ক করে। Java-তে সমতুল্য ভাবনা হলো non-sealed শাখা: যেখানে খোলা থাকা দরকার, সেখানে টাইপের মালিক জেনেশুনে খুলে দেন।
কখন default ঠিক। টাইপ খোলা হলে (Object, String, int), সত্যিই “বাকি সব একই রকম” হলে (যেমন শুধু একটি বিশেষ রকমে আলাদা আচরণ, বাকি সব উপেক্ষা), বা non-sealed শাখার অজানা subclass ধরতে। মাপকাঠি হলো: নতুন রকম এলে কি আপনি জানতে চাইবেন? চাইলে default নয়।
মূল উপলব্ধি: নিরাপত্তার নিয়ম প্রেক্ষাপটনির্ভর। খোলা টাইপে default ভুল ধরে, বন্ধ টাইপে ভুল লুকায়। Compiler-কে আপনার হয়ে সব ক্ষেত্র গুনে দেখতে দিন।
প্র ০৫Scala, Kotlin-এর sealed class আর Rust-এর enum — Java-র sealed + record-এর সাথে তুলনা করলে কী মিল, কী পার্থক্য?
Scala. Scala (২০০৪) শুরু থেকেই JVM-এ object আর functional ধারা মেলানোর চেষ্টা করেছে। এখানে sealed trait আর case class দিয়ে ঠিক এই পর্বের নকশা লেখা হয়, আর match expression pattern দিয়ে খোলে। Scala-য় sealed মানে “সব উপ-টাইপ একই source ফাইলে থাকতে হবে”। Java-র নিয়ম একটু শিথিল: একই package বা module, সাথে স্পষ্ট permits. Scala 3 (২০২১) এর জন্য সংক্ষিপ্ত enum রূপও এনেছে।
Kotlin. Kotlin-এ sealed class ১.০ (২০১৬) থেকেই আছে, আর sealed interface এসেছে ১.৫-এ। data class হলো record-এর মতো। when expression-এ সব রকম থাকলে else লাগে না। পরের সংস্করণগুলোতে when statement-এও exhaustiveness বাধ্যতামূলক হয়েছে। Kotlin-এর is পরীক্ষার পরে “smart cast” নিজে থেকে হয়। তবে Java-র record pattern-এর মতো গঠন ধরে nested খুলে দেখা Kotlin-এ এখনো ভাষার মূল অংশ নয়। এই একটি জায়গায় Java এগিয়ে।
Rust. Rust-এর (১.০, ২০১৫) enum আসলে পুরোদস্তুর sum type: enum PaymentResult { Success { tx_id: String, amount: u32 }, Failed(String), Pending(u32) }. প্রতিটি রকমের সাথে নিজের ডেটা থাকে, আর match exhaustive না হলে compile error. Rust-এর মান-ভিত্তিক মেমরি-ব্যবস্থায় এই enum-এর মান আলাদা heap object না হয়ে সরাসরি জায়গায় থাকে (যেমন stack-এ), একটি ছোট ট্যাগ আর সবচেয়ে বড় রকমের সমান জায়গা নিয়ে — C-র tagged union-এর নিরাপদ রূপ। Java-তে প্রতিটি record একটি আলাদা heap object, যা reference দিয়ে ধরা হয়। তাই Java-র ADT হালকা, কিন্তু Rust-এর মতো “শূন্য খরচ” নয়। Java-র Project Valhalla এই দূরত্ব কমানোর চেষ্টা করছে।
একটি নামের ফাঁদ — C#. C#-এ sealed শব্দের অর্থ সম্পূর্ণ আলাদা: “কেউ extend করতে পারবে না”, যা Java-র final. তাই C# জানা কেউ Java-র sealed দেখে বিভ্রান্ত হতে পারেন। Java-র sealed মানে “শুধু নির্দিষ্ট কয়েকজন extend করতে পারবে”। ইন্টারভিউতে এই পার্থক্য জিজ্ঞেস করা হতে পারে।
বড় ছবি। সব আধুনিক ভাষা একই জায়গায় পৌঁছেছে: sum type + product type + exhaustive pattern matching. পার্থক্য শুধু বানানে আর খুঁটিনাটিতে। Swift-এর associated value-ওয়ালা enum, TypeScript-এর discriminated union — সবই একই ধারণার রূপ। Java শেষে এসেছে, কিন্তু record pattern-এর nested গঠন আর পুরোনো কোডের সাথে সামঞ্জস্যের দিক থেকে যত্ন নিয়ে এসেছে।
মূল উপলব্ধি: একটি ভাষায় ADT আর pattern matching ভালোভাবে শিখলে অন্য সব ভাষায় একই ধারণা চিনতে পারবেন। বাকি থাকে শুধু ভাষাভেদে নিয়ম — কোথায় উপ-টাইপ থাকতে পারে, মেমরিতে কীভাবে থাকে, আর “sealed” শব্দের অর্থ কী।
অনুশীলন
বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।
-
নতুন রকম, compiler-এর পাহারা: অংশ ৪-এর প্রোগ্রামে
record Refunded(String txId, int amount) implements PaymentResult {}যোগ করুন, কিন্তুpermitsতালিকা বদলাবেন না। কী error আসবে? তারপরpermits-এ নাম যোগ করুন — এবার কী error? শেষে ঠিক করুন।প্রথমবার:
class is not allowed to extend sealed class: PaymentResult (as it is not listed in its 'permits' clause)— record-এর লাইনে।permits-এ নাম যোগ করার পর record ঠিক আছে, কিন্তু এবারmessage-এর switch আর সম্পূর্ণ নয়:the switch expression does not cover all possible input values. ঠিক করতে একটি case যোগ করুন, যেমনcase Refunded(var txId, var amount) -> amount + " টাকা ফেরত, TxID " + txId;. লক্ষ করুন, দুটি ধাপেই ভুল ধরা পড়ল চালানোর আগে — একবার পরিবারে ঢোকার সময়, একবার ব্যবহারের জায়গায়। ভাবুন, এখানেdefaultথাকলে দ্বিতীয় error-টি আসত কি না (আসত না)। -
ক্রম উল্টে দেখুন: অংশ ৪-এর প্রোগ্রামে guard-ওয়ালা
Successcase-টি guard-ছাড়াSuccesscase-এর নিচে সরান। চালানোর আগে অনুমান করুন — ১৫,০০০ টাকার লেনদেনে কী ছাপবে, নাকি অন্য কিছু ঘটবে?কিছুই ছাপবে না, কারণ প্রোগ্রাম compile-ই হবে না:
this case label is dominated by a preceding case label, guard-ওয়ালা case-এর লাইনে। guard-ছাড়াcase Success(var txId, var amount)সবSuccessধরে ফেলে, তাই পরেরটি কখনো চলতে পারত না। পুরোনোif-elseশিকলে এই ভুল নীরবে চলত — বড় লেনদেনও “সফল” বার্তা পেত, আর কেউ টের পেত না। (OpenJDK 21-এ যাচাই করা।) -
ক্যালকুলেটরে বিয়োগ: অংশ ৭-এর প্রোগ্রামে
record Sub(Expr left, Expr right)যোগ করুন (permits-এও)। কোন কোন method-এ compiler error দেবে? প্রয়োজনীয় case লিখেnew Sub(new Num(10), new Add(new Num(2), new Num(3)))-এর ফল বের করুন।sealed interface Expr permits Num, Add, Mul, Sub {} record Sub(Expr left, Expr right) implements Expr {} // eval-এ: case Sub(var l, var r) -> eval(l) - eval(r); // show-এ: case Sub(var l, var r) -> "(" + show(l) + " - " + show(r) + ")";দুটি method-এই error আসবে —
evalআরshow— কারণ দুটোর switch-ই আর সম্পূর্ণ নয়। দুটিতে case যোগ করার পর ফল10 - (2 + 3) = 5, আরshowদেখাবে(10 - (2 + 3)). খেয়াল করুন: নতুন রকম যোগ করতে প্রতিটি কাজ ছুঁতে হলো — Expression Problem-এর এই দিকটি sealed নকশার খরচ। কিন্তু compiler তালিকা দিয়েছে, তাই কোনোটা বাদ পড়ার ভয় নেই। -
Polymorphism না sealed? নিচের চারটি ক্ষেত্রে কোন নকশা বেছে নেবেন, এক লাইনে কারণসহ বলুন — (ক) একটি ই-কমার্স সাইটে পেমেন্ট গেটওয়ে, যেখানে প্রতি বছর নতুন গেটওয়ে যুক্ত হয়; (খ) একটি login API-র উত্তর: Ok, WrongPassword, Locked, NeedsOtp; (গ) একটি স্প্রেডশিটের সূত্র (সংখ্যা, ঘরের ঠিকানা, যোগ, গুণ, SUM) — যার ওপর evaluate, print, ভুল-যাচাই, আর পরে আরও কাজ লাগবে; (ঘ) একটি অ্যাপের plugin-ব্যবস্থা, যেখানে বাইরের ডেভেলপাররা নিজেদের plugin লিখবেন।
(ক) Polymorphism — নতুন টাইপ ঘন ঘন আসে, কাজ (
pay,fee,refund) স্থির; নতুন গেটওয়ে = নতুন class. (খ) Sealed + record — রকমগুলো ব্যবসার নিয়মে স্থির, প্রতিটির তথ্য আলাদা (Locked-এ কতক্ষণ, NeedsOtp-এ কোন ফোন নম্বরে), আর caller-কে প্রতিটি সামলাতে বাধ্য করা দরকার। (গ) Sealed + record — ক্যালকুলেটরের মতো গাছ, রকম মোটামুটি স্থির কিন্তু কাজ বাড়তেই থাকবে। (ঘ) Polymorphism (interface) — বাইরের লোক নতুন টাইপ আনবেন, তাই পরিবার বন্ধ করা চলবে না; sealed করলে তাঁরা implement-ই করতে পারবেন না। মাপকাঠি সবসময় একই: কে নতুন রকম আনবে, আর কোন দিকে সিস্টেম বেশি বাড়বে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি sealed interface PaymentResult-এ নতুন রকম Refunded যোগ করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- 🤔 ঠিক করা হয় নতুন রকমে কী তথ্য থাকবে (txId, amount)
- 📝 record Refunded(…) লেখা হয়, আর permits তালিকায় তার নাম বসে
- 🛑 compile করলে javac প্রতিটি অসম্পূর্ণ switch-এ error দেখায়
- ➕ প্রতিটি সেই switch-এ case Refunded(…) যোগ করা হয়
- ✅ প্রজেক্ট আবার compile হয়, সব switch সম্পূর্ণ
sealed interface Shape permits Circle, Square {} আছে। একই package-এ কেউ record Triangle(double b, double h) implements Shape {} লিখলে কী হবে?
- ঠিকঠাক compile হবে, কারণ record নিজে থেকেই final
- compile হবে, কিন্তু Triangle তৈরি করলে চলার সময় exception
- compile error, কারণ Triangle permits তালিকায় নেই
- Compiler নিজে থেকে Triangle-কে permits-এ যোগ করে নেবে
Java-তে একটি sealed class-এর অনুমোদিত (permitted) subclass-কে নিচের কোনটি অবশ্যই ঘোষণা করতে হয়?
- abstract
- final, sealed অথবা non-sealed — এদের একটি
- public
- static
sealed interface Shape permits Circle, Square {} — যেখানে Circle আর Square দুটিই record. switch (shape) expression-এ শুধু case Circle c -> … আর case Square s -> … আছে, কোনো default নেই। কী হবে?
- ঠিকঠাক compile হবে, default লাগবে না
- compile error, কারণ switch expression-এ default বাধ্যতামূলক
- compile হবে, কিন্তু javac সতর্কবার্তা দেবে
- চলার সময় সবসময় MatchException
Object x = 5; String t = switch (x) { case Object o -> "obj"; case Integer i -> "int"; }; — কী হবে?
- t-এর মান হবে "obj"
- "int", কারণ x আসলে Integer
- চলার সময় ClassCastException
- compile error: পরের case আগেরটির দ্বারা dominated
একটি sealed interface-এর সব রকমের case আছে, কিন্তু case null নেই — এমন pattern switch-এ null মান দিলে কী হয়?
- প্রথম case চলে
- কোনো case চলে না, switch চুপচাপ শেষ হয়
- NullPointerException
- compile error
switch-এ record pattern (যেমন case Point(var x, var y) ->) আর type pattern কোন Java সংস্করণে স্থায়ী (preview ছাড়া) হয়েছে?
- Java 16
- Java 17
- Java 21
- Java 22
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- sealed + permits = বন্ধ টাইপ-পরিবার (Java 17). তালিকার বাইরের কেউ extend করতে পারে না; প্রতিটি অনুমোদিত উপ-টাইপ
final,sealedবাnon-sealed; সবাই একই package/module-এ, একই ফাইলে থাকলেpermitsবাদ দেওয়া যায়। - Type pattern আর record pattern (switch-এ Java 21):
case Success sটাইপ মেলায়,case Success(var txId, var amount)সাথে ভেতরের মানও খুলে দেয়, nested সহ — কোনো cast নেই। - Exhaustiveness: sealed টাইপের সব রকম থাকলে
defaultলাগে না। নতুন রকম এলে compiler প্রতিটি অসম্পূর্ণ switch দেখায় — তাই বন্ধ টাইপেdefaultলিখবেন না। - ক্রম ও null: প্রথম মিলটিই জেতে; guard-ওয়ালা/নির্দিষ্ট case আগে, নইলে “dominated” compile error.
case nullনা থাকলে null এলেNullPointerException. - sealed + record = algebraic data type. টাইপ বেশি বাড়লে polymorphism, কাজ বেশি বাড়লে sealed + pattern matching — Expression Problem-এর দুই দিক, একই প্রজেক্টে দুটোই থাকে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৭ · nested, inner আর anonymous classপরবর্তী পর্বclass-এর ভেতরে class — কখন, কেন, আর কোন ফাঁদ।
- Java প্রোগ্রামিং · কোর্স হোম৫৯টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- Python · if/elif আর matchপাশের ট্র্যাকPython-এর structural pattern matching — একই ধারণা, অন্য ভাষায়।
- C++ · optional, variant আর anyগভীরে যানstd::variant আর std::visit — C++-এর sum type, মেমরির আরও কাছ থেকে।