Java Core · جاوا پایه متوسطIntermediate ~57 دقیقه مطالعه~48 min read
جاوا مدرن ۸ تا ۲۱: Record، Sealed و Pattern MatchingModern Java 8→21: Records, Sealed, Pattern Matching
در این فصل قدمبهقدم یاد میگیری چطور Record، Sealed و Pattern Matching با هم کد پرگوی جاوا را به کدی کوتاه، امن و دادهمحور تبدیل میکنند و چرا این سهگانه از جاوا ۸ تا ۲۱ چهرهی زبان را عوض کرد.A friendly, step-by-step tour of how records, sealed types, and pattern matching team up to turn verbose Java into short, safe, data-oriented code — and why this trio reshaped the language from Java 8 to 21.
سلام. بیا یک چیز را همین اول صاف کنیم: جاوا در بیست سال اولش تو را مجبور میکرد برای سادهترین «یک بستهی داده» دهها خط کد تکراری بنویسی. نسخههای ۸ تا ۲۱ آمدند تا این عذاب را تمام کنند. در این فصل قرار نیست فقط فهرستی از ویژگیها را حفظ کنی؛ قرار است بفهمی هر کدام چه دردی را درمان میکنند، از کجا آمدهاند و در مصاحبهی سنیور چطور باید دربارهشان حرف بزنی.
در این مسیر با هم میرویم:
- مدل ذهنی بزرگ — چرا میگوییم جاوا «دادهمحور» شد و این سهگانه چه ربطی به هم دارند.
- خط زمانی — چه چیزی در کدام نسخه نهایی شد (با شمارهی JEP).
- Record — حامل شفاف داده که ۴۰ خط کد را به یک خط تبدیل میکند.
- Sealed — بستن درِ سلسلهمراتب تا کامپایلر بتواند فکر کند.
- Pattern Matching — از
instanceofساده تاswitchجامع و تجزیهی ساختار. - ابزارهای کمسروصدا — text block،
var، بهبود generics و یک نگاه گذرا به virtual thread. - دامها، بهترین شیوهها و ۱۴ پرسش مصاحبه با پاسخ کامل.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از اینکه وارد کد شویم، سه اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا با تشبیه در ذهنت جا بیندازمشان تا بعداً سرت را نخارانی.
تصور کن یک رستوران دو جور «چیز» دارد. اول، هر غذا خودش چند جزء دارد: پیتزا = (خمیر، پنیر، مخلفات). این یعنی «نوع ضرب» (product type) — یک چیز که همهی اجزایش را با هم دارد. دوم، کل منو یک فهرست بسته است: یا پیتزا سفارش میدهی، یا برگر، یا سالاد؛ چیز چهارمی وجود ندارد. این یعنی «نوع جمع» (sum type) — یکی از میان چند حالت مشخص. وقتی این دو را کنار هم بگذاری به «انواع دادهی جبری» (algebraic data types یا ADT) میرسی: منویی که هم میدانی هر غذا از چه ساخته شده و هم میدانی کل حالتها دقیقاً چند تاست. در جاوا، record همان نوع ضرب است و sealed همان نوع جمع؛ و pattern matching گارسونی است که سفارش را میگیرد و مطمئن میشود هیچ غذایی از قلم نیفتاده.
- تغییرناپذیر (immutable): چیزی که بعد از ساختهشدن دیگر عوض نمیشود، مثل یک سنگ حکاکیشده. نقطهی مقابلش «تغییرپذیر» (mutable) است، مثل تختهسفیدی که مدام پاک و دوباره نوشته میشود.
- جامع / exhaustive: یعنی «همهی حالتها را پوشش دادی، چیزی جا نماند». مثل چکلیستی که تیک همهی خانههایش خورده.
- کامپایلر (compiler): برنامهای که کد تو را قبل از اجرا میخواند و به بایتکد ترجمه میکند. حرف اصلی این فصل این است: هر چه بیشتر بتوانیم خطاها را به زمان کامپایل منتقل کنیم (یعنی قبل از اینکه برنامه اصلاً اجرا شود)، شبهای آرامتری داریم.
مدل ذهنی: جاوا به یک زبان دادهمحور تبدیل شد
جاوای کلاسیک مثل یک کارگاه نجاری سنتی بود: برای هر صندلی (هر شیء) باید دستی اره میکشیدی، میخ میزدی، رنگ میزدی — کلی کار تکراری برای چیزی که فقط میخواستی «چند تختهی بههمچسبیده» باشد. جاوای مدرن یک خط تولید کنارش گذاشت: تو فقط میگویی «صندلی از این اجزا تشکیل شده» و خط تولید بقیهی کار — ساختن، مقایسهکردن، چاپکردن — را خودش انجام میدهد. کارگاه نجاری (شیءگرایی) هنوز هست و برای کارهای هنری لازم است؛ اما برای «بستههای داده» دیگر مجبور نیستی دستی کار کنی.
جاوا در دو دههی نخست، تو را مجبور میکرد همهچیز را بهصورت شیءهای تغییرپذیر با کدهای تکراری دستنویس مدل کنی. نسخههای ۸ تا ۲۱ بهآرامی یک پارادایم دوم را روی هستهی شیءگرا افزودند: برنامهنویسی دادهمحور (data-oriented programming). یعنی بهجای اینکه داده را پشت لایههای رفتار پنهان کنی، آن را شفاف و بیواسطه مدل میکنی و منطق را بیرون، در قالب توابعی که روی این دادهها الگومطابقت میکنند، نگه میداری.
سه ستون این پارادایم که با هم طراحی شدهاند اینهاست:
- Record — حامل شفاف داده (همان «نوع ضرب» منو).
- Sealed type — مجموعهای بسته از حالتهای ممکن (همان «نوع جمع» منو).
- Pattern matching — تجزیهی ساختار بههمراه توزیع جامع (همان گارسون).
این سه با هم به تو انواع دادهی جبری و تحلیل حالت (case analysis) امن و بررسیشده توسط کامپایلر میدهند — همان راحتی enum در Scala/Kotlin/Rust، اما بهصورت بومی روی JVM.
این سه ویژگی جدا جدا هم مفیدند، اما قدرت واقعیشان وقتی آزاد میشود که کنار هم بهکار روند: sealed به کامپایلر میگوید «حالتها دقیقاً همینهاست»، record هر حالت را تمیز مدل میکند، و pattern matching مطمئن میشود هیچ حالتی از قلم نیفتاده. اگر یکی را برداری، جادو نصفه میماند.
بقیهی ویژگیها آمدند تا کد روزمره کمسروصداتر شود: var، switch expression، text block، و — در جاوا ۲۱ — virtual thread (که در فصل مستقل خودش عمیق بررسی میشود). این فصل نقشهای دقیق است از اینکه چه چیزی، در کدام نسخه عرضه شد و چطور در سطح سنیور از آن استفاده کنی.
خط زمانی عرضه (و زمان نهاییشدن)
قبل از جدول، یک کلمه را باز کنم که در ادامه زیاد میبینی:
جاوا ویژگیهای بزرگ را اول بهصورت preview (پیشنمایش) منتشر میکند: کد کار میکند اما باید با پرچم --enable-preview کامپایل کنی و ممکن است در نسخهی بعد تغییر کند یا حتی حذف شود. وقتی ویژگی final (نهایی) شد یعنی پایدار است و میتوانی بدون ترس در تولید (production) رویش حساب کنی. LTS هم یعنی Long-Term Support؛ نسخههایی مثل ۸، ۱۱، ۱۷ و ۲۱ که سالها پشتیبانی میشوند و شرکتها معمولاً رویشان میمانند.
| ویژگی | Preview / اولین ظهور | نهاییشده (LTS پررنگ) | JEP |
|---|---|---|---|
Lambda، Stream، Optional، default method |
— | جاوا ۸ | — |
var (استنتاج نوع متغیر محلی) |
— | جاوا ۱۰ | 286 |
var در پارامتر lambda |
— | جاوا ۱۱ | 323 |
| Switch expression | ۱۲/۱۳ | جاوا ۱۴ | 361 |
| Text block | ۱۳/۱۴ | جاوا ۱۵ | 378 |
Pattern matching برای instanceof |
۱۴/۱۵ | جاوا ۱۶ | 394 |
| Record | ۱۴/۱۵ | جاوا ۱۶ | 395 |
| Sealed class/interface | ۱۵/۱۶ | جاوا ۱۷ | 409 |
Pattern matching برای switch |
۱۷ تا ۲۰ (۴ پیشنمایش) | جاوا ۲۱ | 441 |
| Record pattern | ۱۹/۲۰ | جاوا ۲۱ | 440 |
| Virtual thread | ۱۹/۲۰ | جاوا ۲۱ | 444 |
| Sequenced collection | — | جاوا ۲۱ | 431 |
| String template | ۲۱ (پیشنمایش)، ۲۲ (پیشنمایش دوم) | کنار گذاشته شد — هرگز نهایی نشد | 430/459 |
Unnamed pattern و variable (_) |
۲۱ (پیشنمایش) | جاوا ۲۲ | 456 |
نکتهی کلیدی برای سنیور: تا جاوا ۲۱ (نسخهی LTS فعلی)، Record، Sealed type، pattern matching برای switch و record pattern همگی نهایی و آمادهی تولید هستند.
شاید در بلاگها دیده باشی که با STR."..." میشود متغیر را داخل رشته درونیابی (interpolation) کرد. این ویژگی (String template) پیشنمایش شد، پیشنمایش دومش هم آمد، و بعد کاملاً پس گرفته شد. یعنی در جاوای فعلی هیچ درونیابی رشتهی رسمی و نهاییشدهای وجود ندارد. اگر در کدت یا در پاسخ مصاحبه رویش تکیه کنی، اشتباه است. Text block رشتهی چندخطی میدهد، اما درونیابی نه.
Record: حامل شفاف دادهی تغییرناپذیر
یک کلاس معمولی مثل یک پروندهی پرسنلی زنده است: میشود درونش را عوض کرد، امضا اضافه کرد، رفتارها چسباند. اما خیلی وقتها تو فقط یک «کارت شناسایی» میخواهی: یک تکه دادهی ثابت که همین که چاپ شد دیگر عوض نمیشود، و دو کارت با اطلاعات یکسان یعنی «یک نفر». Record دقیقاً همین کارت شناسایی است — یک بستهی شفاف که هویتش همان محتوایش است.
Record یک کلاس final است که وضعیت آن یک فهرست مرتب و ثابت از مؤلفهها (components) است. حالا «مؤلفه» را باز کنم: مؤلفه یعنی همان فیلدهایی که داخل پرانتز اسم Record مینویسی — دانههای دادهای که این بسته را میسازند. کامپایلر برایت اینها را خودکار تولید میکند:
- یک سازندهی canonical (سازندهی «اصلی» که دقیقاً همان مؤلفهها را میگیرد)،
- فیلدهای
private final، - اکسسورهایی (accessor؛ همان متد خواندن مقدار) دقیقاً همنام مؤلفهها — بدون پیشوند
get، - و
equals،hashCodeوtoStringکه «ساختاری» (structural) از روی مؤلفهها ساخته میشوند. «ساختاری» یعنی بر اساس محتوا مقایسه میکند، نه بر اساس اینکه آیا دو ارجاع به یک شیء در حافظه اشاره میکنند.
public record Point(int x, int y) {}
همین یک خط معادل حدود ۴۰ خط کلاس مقداری (value class) دستنویس است. ببین چطور استفاده میشود:
Point p = new Point(3, 4);
int a = p.x(); // اکسسور، نه getX()
boolean eq = p.equals(new Point(3, 4)); // true — برابری ساختاری
System.out.println(p); // Point[x=3, y=4]
سالها بزرگترین منبع باگ در کلاسهای داده این بود که برنامهنویس equals و hashCode را یا فراموش میکرد یا اشتباه مینوشت — و بعد آن شیء بهعنوان کلید Map یا عضو Set بد رفتار میکرد. Record این را برایت رایگان و درست تولید میکند. یعنی یک دستهی کامل از باگهای خاموش، از ریشه خشک میشود.
معناشناسیای که باید بدانی
- تغییرناپذیری کمعمق (shallow immutability). مؤلفهها
finalهستند، اما اگر مؤلفهای از نوع تغییرپذیر باشد (مثلint[]یاList)، محتوای آن هنوز میتواند عوض شود. «کمعمق» یعنی فقط لایهی رویی قفل است، نه تهوتوی آن. اگر تغییرناپذیری واقعی میخواهی، در سازنده کپی دفاعی (defensive copy) بگیر. equals/hashCodeمبتنی بر مؤلفهاند، پس Recordها بهعنوان کلیدMapو درSetامناند.- Record نمیتواند از کلاس دیگری ارث ببرد (بهطور ضمنی از
java.lang.Recordارث میبرد) اما میتواند interface پیاده کند. - Record میتواند generic باشد، عضو static و متد نمونه (instance method) اضافه اعلام کند، و حتی میتوان Record تودرتو/محلی داخل یک متد داشت.
فرض کن کسی اصل سند خانهاش را به تو میسپارد. اگر همان اصل را نگه داری، او هر وقت بخواهد میتواند بیاید و رویش خطخطی کند و «وضعیت» تو را خراب کند. کپی دفاعی یعنی همان لحظه که سند را گرفتی، یک فتوکپی مستقل بگیری و اصل را پس بدهی. حالا هرچه او با نسخهی خودش بکند، به تو ربطی ندارد. List.copyOf(members) دقیقاً همین فتوکپی تغییرناپذیر است.
سازندهی compact در برابر canonical
سازندهی compact به تو اجازه میدهد بدون بازنویسی پارامترها یا انتساب فیلدها، اعتبارسنجی یا نرمالسازی کنی — انتساب به فیلدهای final در پایان بهطور ضمنی انجام میشود:
public record Range(int lo, int hi) {
public Range { // فرم compact — بدون فهرست پارامتر
if (lo > hi) throw new IllegalArgumentException("lo > hi");
// نرمالسازی: به *پارامتر* دوباره مقدار بده، نه به فیلد
hi = Math.max(lo, hi);
}
}
به این ظرافت دقت کن: درون سازندهی compact تو به پارامترها دوباره مقدار میدهی؛ کامپایلر مقادیر نهایی آنها را هنگام خروج از بلاک در فیلدها کپی میکند. در سازندهی compact مجاز نیستی this.lo = ... بنویسی.
غریزهات میگوید در سازنده بنویسی this.hi = .... اما اینجا خطای کامپایل میگیری. فرمول ذهنی ساده است: در compact «فقط پارامتر را دستکاری کن و کامپایلر آخرش خودش در فیلد میریزد». اگر دلت انتساب صریح this.field میخواهد، باید به سازندهی canonical کامل سوییچ کنی.
سازندهی canonical نسخهی با امضای کامل است؛ وقتی به کپی دفاعی نیاز داری از آن استفاده کن:
public record Team(String name, List<String> members) {
public Team(String name, List<String> members) { // canonical، صریح
this.name = name;
this.members = List.copyOf(members); // کپی دفاعی و تغییرناپذیر
}
}
میتوانی سازندههای ثانویه هم اضافه کنی که با this(...) به canonical واگذار (delegate) میکنند — یعنی کارِ ساختن را به سازندهی اصلی میسپارند تا منطق در یک جا بماند.
چه زمانی Record نباید استفاده شود
Record برای دادهای است که هویتش همان محتوایش است. آن را در این موارد به کار نبر:
- برای انواع
@Entityدر JPA (به سازندهی بدون آرگومان و هویت تغییرپذیر نیاز دارند)، - جایی که به ارثبری یا تغییر وضعیت در چرخهی عمر نیاز داری.
Record برای DTO، value object، کلید map، tuple و — مهمتر از همه — حالتهای (cases) یک سلسلهمراتب sealed عالی است. همین ما را میرساند به ستون دوم.
کلاسها و اینترفیسهای Sealed: بستن سلسلهمراتب
یک اینترفیس معمولی مثل یک مهمانی درِ باز است: هر کلاسی از هر گوشهی دنیا میتواند آن را implement کند و تو هیچوقت نمیدانی چند «نوع» مهمان داری. برای همین وقتی میخواهی همهی حالتها را مدیریت کنی، همیشه باید یک شاخهی default بگذاری، چون شاید کسی فردا یک نوع جدید بسازد. sealed یعنی بیرونِ درِ مهمانی یک فهرست چسباندهای: «فقط این سه نفر اجازهی ورود دارند». حالا کامپایلر هم آن فهرست را میبیند و میداند که کل مهمانها دقیقاً همین سه تا هستند — نه بیشتر، نه کمتر.
نوع sealed دقیقاً اعلام میکند چه انواعی مجازند آن را extend یا implement کنند. این کار یک سلسلهمراتب باز و ناشناختنی را به یک جمع جبری بسته (closed algebraic sum) تبدیل میکند که کامپایلر میتواند دربارهاش استدلال کند. «جمع بسته» را یادت هست؟ همان «نوع جمع» منو: یکی از میان چند حالت مشخص و شمردهشده.
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
قواعد بند permits (یعنی همان «فهرست مهمانان»):
- هر زیرنوع مجاز باید
final،sealedیاnon-sealedباشد.non-sealedعمداً آن شاخه را برای گسترش بیشتر دوباره باز میکند — مثل اینکه بگویی «این یک نفر میتواند هر تعداد همراه بیاورد». - زیرنوعهای مجاز باید در همان ماژول (module) باشند (یا همان package در ماژول بینام). ماژول یعنی واحد بستهبندی کد در سیستم ماژول جاوا؛ منطقش این است که کامپایلر باید بتواند همهی زیرنوعها را ببیند تا فهرست را تضمین کند.
- اگر همهی زیرنوعهای مجاز در یک فایل منبع باشند، میتوانی
permitsرا حذف کنی — کامپایلر خودش آن را استنتاج میکند.
sealed + record = یک ADT. حالا کامپایلر مجموعهی کامل Shapeها را میداند. و همین دانستن، دقیقاً چیزی است که switch جامع را بدون شاخهی default ممکن میسازد. بدون sealed، کامپایلر هیچوقت مطمئن نیست که همهی حالتها را پوشش دادهای؛ با sealed، میتواند تضمین کند.
Pattern matching: از instanceof تا switch جامع
حالا گارسون داستانمان میرسد. Pattern matching یعنی «به کامپایلر بگو دنبال چه شکلی از داده میگردی، و اگر پیدا شد، همانجا اجزایش را هم به من بده». بیا از سادهترین شکلش شروع کنیم.
Pattern matching برای instanceof (جاوا ۱۶)
قدیمها برای اینکه مطمئن شوی یک جعبه «کتاب» است سه کار جدا میکردی: (۱) نگاه میکردی آیا کتاب است؟ (۲) اگر بود، برچسب «کتاب» رویش میزدی (کست میکردی)، (۳) بعد میگذاشتیاش در قفسهی کتابها. سه مرحله برای یک کار ساده. Pattern matching یعنی بازرسی که همان لحظهی دیدن، اگر کتاب بود، خودش برچسب میزند و تحویلت میدهد: یک حرکت بهجای سه.
سهگانهی کلاسیک تست-کست-انتساب در یک الگوی نوع (type pattern) فشرده میشود که هنگام موفقیت تست، یک متغیر را bind میکند. «bind» یعنی همان «برچسبزدن»: اگر داده با الگو جور در آمد، مقدارش را در یک متغیر تازه میریزد که آمادهی استفاده است.
// قدیمی
if (obj instanceof String) {
String s = (String) obj;
return s.length();
}
// جاوا ۱۶ به بعد
if (obj instanceof String s) { // s در جایی که تست درست است در دسترس است
return s.length();
}
این bind از دامنهی جریانی (flow scoping) پیروی میکند. «دامنهی جریانی» یعنی متغیر s دقیقاً جایی در دسترس است که کامپایلر بتواند ثابت کند instanceof برقرار بوده — نه لزوماً داخل یک بلاک {} مشخص، بلکه هرجا که مسیرِ منطقِ برنامه تضمین کند تست موفق بوده. این شامل سمت راست && و حتی بعد از یک return زودهنگام هم میشود:
if (!(obj instanceof String s)) return 0;
return s.length(); // مجاز: فقط وقتی به اینجا میرسیم که الگو مطابقت کرده باشد
ببین چقدر تمیز است: چون در حالت «کتاب نبودن» زود return کردهایم، تنها راهِ رسیدن به خط بعد این است که s واقعاً یک String باشد — و کامپایلر همین را میفهمد.
Switch expression (جاوا ۱۴) و pattern matching برای switch (جاوا ۲۱)
اول یک قدم عقب: تفاوت switch statement قدیمی و switch expression چیست؟
switch قدیمی (statement) مثل دادن یک سری دستور پشتسرهم بود: «این کار را بکن، آن کار را بکن». و اگر break یادت میرفت، اجرا مثل توپی که از پلهها میافتد به case بعدی سُر میخورد (همان fall-through بدنام). اما switch expression مثل سفارشدادن در کافه است: یک چیز میگویی و یک مقدار تحویل میگیری. هر شاخه باید دقیقاً یک نتیجه بدهد، و دیگر خبری از سُرخوردن نیست.
Switch expression (جاوا ۱۴) دام fall-through را برطرف کرد و اجازه داد switch یک مقدار تولید کند:
int days = switch (month) {
case JAN, MAR, MAY, JUL, AUG, OCT, DEC -> 31;
case APR, JUN, SEP, NOV -> 30;
case FEB -> 28; // فرم پیکانی: بدون fall-through، بدون break
};
آن پیکان -> قهرمان ماجراست: هر شاخه مستقل است، خودش مقدارش را میدهد و نمیتواند به شاخهی بعد نشت کند.
حالا جهش بزرگ جاوا ۲۱: الگوهای نوع میتوانند در برچسبهای case ظاهر شوند. در ترکیب با یک نوع sealed، switch بدون default جامع میشود — و اگر بعداً یک Shape چهارم اضافه کنی، switch تا زمانی که آن را مدیریت نکنی کامپایل نمیشود.
double area(Shape s) {
return switch (s) { // به default نیازی نیست
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
همان «کامپایل نمیشود» طلاست. تصور کن یک سیستم بزرگ داری و یک Shape جدید اضافه میکنی. با این سبک، کامپایلر مثل یک همکار دلسوز همهی جاهایی که باید بهروز شوند را به تو نشان میدهد و تا وقتی درستشان نکنی نمیگذارد برنامه build شود. این یعنی جابهجاکردن یک دسته باگ از «کشف در ساعت ۳ بامداد روی production» به «کشف در همان لحظهی تایپکردن». به این میگویند جامعیت در زمان کامپایل (compile-time exhaustiveness).
Record pattern (جاوا ۲۱): تجزیهی ساختار (destructuring)
Record pattern مثل این است که یک بستهی پستی برسد که تویش جعبههای تودرتو دارد، و تو بهجای اینکه لایهبهلایه بازش کنی، یک الگوی مقوایی داشته باشی که همهی لایهها را همزمان میشکافد و دقیقاً همان قطعهای که میخواهی را کف دستت میگذارد. «تجزیهی ساختار» (destructuring) یعنی همین: همزمان با مطابقتدادن شکل، اجزای درونی را هم بیرون بکش.
Record pattern اجازه میدهد در یک گام هم مطابقت دهی و هم مؤلفهها را جدا کنی، با تودرتویی به هر عمقی:
sealed interface Expr permits Num, Add, Mul {}
record Num(double v) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
double eval(Expr e) {
return switch (e) {
case Num(double v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
توجه کن که این یک ماشینحساب کامل و بازگشتی (recursive) است که در چند خط جا شده — و هیچ defaultی هم ندارد، چون Expr یک نوع sealed است و کامپایلر میداند حالتها همین سه تاست.
تجزیهی تودرتو مثل خود داده خوانده میشود:
// مطابقت با «یک Add که سمت چپش عدد ثابت ۰ است»
case Add(Num(var v), Expr r) when v == 0 -> eval(r); // 0 + r == r
ببین چطور الگو تقریباً شکلِ همان چیزی است که دنبالش میگردی: یک Add که تویش یک Num با مقدار صفر است. کد شبیه توصیفِ داده شده، نه دستورالعملِ کندوکاو در آن.
الگوهای نگهباندار با when
گاهی «نوعِ درست» کافی نیست؛ میخواهی یک شرط اضافه هم برقرار باشد. when مثل نگهبانی است که میگوید: «دایره؟ باشد، ولی فقط اگر شعاعش از ۱۰۰ بزرگتر باشد بگو دایرهی غولپیکر». اگر شرط برقرار نبود، سراغ حالت بعدی میرویم.
بند when یک نگهبان بولی به الگو اضافه میکند. ترتیب مهم است — حالتهای نگهباندار باید پیش از حالت عمومیتر بدوننگهبان بیایند:
String describe(Shape s) {
return switch (s) {
case Circle c when c.radius() > 100 -> "huge circle";
case Circle c -> "circle";
case Rectangle r when r.w() == r.h() -> "square";
case Rectangle r -> "rectangle";
case Triangle t -> "triangle";
};
}
حالتها از بالا به پایین تست میشوند. اگر case Circle c بدون نگهبان را بالاتر بگذاری، آن همهی دایرهها را میبلعد و دیگر هیچوقت به case Circle c when ... نمیرسی. کامپایلر این را میفهمد و آن حالتِ دستنیافتنی (unreachable) را رد میکند. قانون: نگهبانها اول، حالتِ عمومی آخر.
null در switch
از نظر تاریخی switch(null) یک NullPointerException (بهاختصار NPE؛ خطای «اشاره به هیچ») پرتاب میکرد. از جاوا ۲۱ میتوانی یک case null صریح (بهاختیار case null, default) اضافه کنی. بدون آن، switch الگویی همچنان روی null یک NPE پرتاب میکند — خصومت با null حفظ میشود مگر اینکه خودت انتخاب کنی رامش کنی.
String render(Object o) {
return switch (o) {
case null -> "nothing";
case String s -> "str:" + s;
default -> "other";
};
}
پیشفرض جاوا این است: «null یک مقدار نامنتظره است، پس اگر خودت صراحتاً سراغش نرفتی، همان NPE قدیمی را میگیری». این عمدی است تا رفتار قابلپیشبینی بماند. اگر میخواهی null را مثل یک حالت عادی مدیریت کنی، فقط کافی است case null بنویسی.
Text block، var و بهبود generics
اینها ستونهای اصلی پارادایم نیستند، اما همان ابزارهای کمسروصداییاند که کد روزمره را تمیز میکنند.
Text block (جاوا ۱۵)
قبل از text block، نوشتن یک JSON چندخطی در جاوا شکنجه بود: هر خط را در " میگذاشتی، \n میچسباندی، \" فرار میدادی. text block مثل این است که کل بلوک را همانطور که هست بین سهتا """ بگذاری و خیالت راحت باشد.
رشتههای چندخطی با """، که فضای خالی ابتدایی جانبی نسبت به جداکنندهی پایانی حذف میشود:
String json = """
{
"id": 42,
"name": "Ada"
}
"""; // یک newline پایانی وجود دارد مگر با \ تمام کنی
عملگرهای کلیدی: \ در انتهای خط، newline را حذف میکند (برای وقتی یک خط منطقی را در چند خط فیزیکی میشکنی)؛ \s یک فاصلهی پایانی را که وگرنه حذف میشد اجباری میکند. Text blockها در زمان اجرا String معمولیاند — بدون درونیابی (interpolation)؛ string template قرار بود این را اضافه کند اما، همانطور که گفتیم، کنار گذاشته شد.
جاوا برای اینکه بفهمد چقدر فضای خالی ابتدایی «تزئینی» است و باید حذف شود، به کمتورفتهترین خط نگاه میکند — و این شامل خطِ """ پایانی هم میشود! پس اگر سهتا کوتیشنِ پایانی را جابهجا بگذاری، بیسروصدا فضای خالی ابتدای همهی خطها عوض میشود. اگر خروجیات تورفتگی عجیب داشت، اول به همترازی """ پایانی شک کن.
var (جاوا ۱۰/۱۱)
وقتی میگویی var list = new ArrayList<String>()، کامپایلر مثل همکاری است که سمت راست را میبیند و میگوید «معلوم است، این یک ArrayList<String> است، لازم نیست دوبار بگویی». این حدس نیست؛ یک نتیجهگیری قطعی از روی چیزی است که همانجا نوشتهای.
var یعنی استنتاج نوع متغیر محلی. نوع ایستای مقداردهندهی اولیه (initializer) را استنتاج میکند، پس کاملاً type-safe است — نه تایپینگ پویا (dynamic typing).
var list = new ArrayList<String>(); // ArrayList<String>
var entry = Map.entry("k", 1); // Map.Entry<String, Integer>
یک سوءتفاهم رایج: بعضی فکر میکنند var یعنی جاوا شبیه پایتون یا جاوااسکریپت «بینوع» شده. نه! نوع متغیر در زمان کامپایل قطعی و ثابت است؛ فقط تو مجبور نیستی آن را بنویسی. اگر بعداً چیزی از نوعِ اشتباه به list بدهی، همان خطای کامپایل همیشگی را میگیری.
قواعد و دامها: فقط برای متغیرهای محلی با مقداردهنده، for/try-with-resources، و پارامترهای lambda (جاوا ۱۱)؛ نه برای فیلدها، پارامترهای متد یا نوع بازگشتی. var x = null; و var x = () -> {} کامپایل نمیشوند — چون هیچ نوعی برای استنتاج وجود ندارد (از null یا یک lambda خالی نمیشود نوع فهمید). وقتی نوع از سمت راست بدیهی است var را ترجیح بده؛ وقتی صریحبودن به خواننده کمک میکند، آن را صریح نگه دار.
بهبود generics در طول این دوره
- استنتاج الماس
<>(جاوا ۷) در جاوا ۹ گسترش یافت تا با کلاسهای ناشناس (anonymous class) هم کار کند. «الماس» همان<>است که اجازه میدهد نوع سمت راست را تکرار نکنی. - target typing بهبودیافته برای فراخوانی متدهای generic و lambdaها در سراسر جاوا ۸ به بعد به این معناست که بهندرت به نوع صریح مانند
Collections.<String>emptyList()نیاز داری. «target typing» یعنی کامپایلر از جایی که مقدار قرار است برود نوع را حدس میزند. varبا generics برای متغیرهای محلیtry/حلقه تعامل دارد و شلوغی نامهای طولانی نوع پارامتری را کاهش میدهد.
var x = new HashMap<>(); نوع HashMap<Object,Object> را استنتاج میکند — چون سمت راست (<>) هیچ محدودیتی برای پارامترهای نوع ندارد، و var هم که چیزی سمت چپ نگذاشته. نتیجه: یک map از Object به Object که احتمالاً آنی نیست که میخواستی. وقتی var و <> را با هم به کار میبری، حتماً پارامترهای نوع را در سمت راست مشخص کن: var x = new HashMap<String,Integer>();.
Virtual thread (جاوا ۲۱) — نسخهی یکپاراگرافی
Thread.ofVirtual().start(...) و Executors.newVirtualThreadPerTaskExecutor() به تو threadهای ارزان و زمانبندیشده توسط JVM میدهند که هنگام I/O مسدودکننده از thread حامل سیستمعامل جدا (unmount) میشوند. «unmount» یعنی thread مجازی موقتاً جایش را روی thread واقعیِ سیستمعامل خالی میکند تا کار دیگری اجرا شود، و وقتی I/O تمام شد دوباره سوار میشود. اینها مدل thread-per-request را تا میلیونها کار همزمان بدون پیچیدگی reactive مقیاسپذیر میکنند.
معناشناسی همزمانی، pinning (وقتی thread مجازی بهاجبار به thread حامل چسبیده میماند و نمیتواند unmount شود) و دامهای ThreadLocal در فصل مستقلِ همزمانی پوشش داده میشود. اینجا فقط میخواستیم بدانی virtual thread هم بخشی از انقلاب جاوا ۲۱ است.
دامها و نکات ظریف رایج
بیا مهمترین تلهها را یکجا جمع کنیم — اینها همانهاییاند که در مصاحبه یا در کد واقعی گازت میگیرند:
- Recordها عمیقاً تغییرناپذیر نیستند. یک
record Config(Map<String,String> props)همان map را با فراخواننده به اشتراک میگذارد. در سازندهی canonical کپی بگیر. - دام انتساب در سازندهی compact. نوشتن
this.x = x;در سازندهی compact خطای کامپایل است؛ تو پارامتر را تغییر میدهی و اجازه میدهی کامپایلر آن را انتساب کند. - بازگشت خاموش جامعیت با
default. اگر برای «احتیاط» یکdefaultبه switch یک sealed اضافه کنی، خطای کامپایل هنگام افزودن زیرنوع جدید را از دست میدهی. برای سلسلهمراتبهای بسته، بدونdefaultرا ترجیح بده تا کامپایلر تو را به مدیریت حالتهای جدید مجبور کند. - ترتیب نگهبان. یک
case Circle cعمومیتر پیش ازcase Circle c when ...حالت نگهباندار را دستنیافتنی میکند — کامپایلر آن را رد میکند. نگهبانها را اول بگذار. - دامنهی bind در
instanceofجریانی است؛ bindای که در شرطwhileمعرفی میشود، پس از یکbreakکه با نادرستشدن شرط از حلقه خارج میشود، در دامنه نیست. var+ الماس =var x = new HashMap<>();نوعHashMap<Object,Object>را استنتاج میکند — سمت راست هیچ محدودیتی برای پارامترهای نوع ندارد. آنها را مشخص کن.- تورفتگی text block از کمتورفتهترین خط شامل
"""پایانی محاسبه میشود؛ همتراز نبودن جداکنندهی پایانی بهطور خاموش فضای خالی ابتدایی را عوض میکند.
بهترین شیوهها
- حالتهای جایگزین دامنه را با
sealed interface+ حالتهایrecordمدل کن، سپس باswitchالگویی توزیع کن — بدونdefault، تا جامعیت توسط کامپایلر اجبار شود. - برای هر DTO، رویداد و value object از Record استفاده کن؛
equals/hashCode/toStringدرست را رایگان میگیری. varرا برای مقداردهندههای بدیهی و انواع generic طولانی نگه دار؛ جایی که نوع نیت را مستند میکند آن را بنویس.- switch expression را بر statement ترجیح بده — هر حالت را مجبور به تولید مقدار میکند و باگهای fall-through را حذف میکند.
- در سازندههای Record اعتبارسنجی و کپی دفاعی کن؛ هرگز اجزای داخلی تغییرپذیر را از طریق اکسسور فاش نکن.
پرسشهای مصاحبه
حالا وقت آن است که همهچیز را در قالب سؤالهای واقعی مصاحبهی سنیور تمرین کنیم. هر کدام را اول خودت جواب بده، بعد پاسخ را باز کن.
فیلدهای private final، یک سازندهی canonical، اکسسورهای مؤلفه، و equals/hashCode/toString. equals تنها زمانی true برمیگرداند که شیء دیگر همان نوع Record باشد و همهی مؤلفههای متناظر برابر باشند (با Objects.equals و مقایسهی درست primitive شامل معناشناسی Double.compare برای double/float، پس -0.0 و NaN طبق برابری اصلاحشدهی IEEE رفتار میکنند). همین Recordها را بهعنوان کلید hash امن میکند.
فقط بهصورت کمعمق. ارجاعهای مؤلفه final هستند، اما محتوای یک مؤلفهی تغییرپذیر میتواند عوض شود. تغییرناپذیری واقعی به کپی دفاعی در سازندهی canonical و عدم نشت ارجاع داخلی نیاز دارد. جملهی طلایی برای مصاحبه: «Record قفلِ در است، نه قفلِ محتویاتِ اتاق».
چون کار سازندهی compact اعتبارسنجی/نرمالسازی روی پارامترهاست؛ کامپایلر انتساب فیلدها را خودکار در پایان با مقادیر (احتمالاً بازمقداردهیشدهی) پارامترها انجام میدهد. انتساب به this.field آنجا خطای کامپایل است. اگر واقعاً انتساب صریح میخواهی، از سازندهی canonical کامل استفاده کن.
کامپایلر ثابت میکند که الگوهای case هر زیرنوع مجاز نوع ورودی sealed را پوشش میدهند (با در نظر گرفتن record patternهای تودرتو و مدیریت null). با یک سلسلهمراتب sealed میتوانی default را حذف کنی؛ آنگاه افزودن زیرنوع جدید در هر جایی که switch بهروز نشده کامپایل را میشکند — دقیقاً همان امنیتی که میخواهی. یک default آن خطا را خاموش میکند و این ایمنی را از تو میگیرد.
sealed interface I permits A, B {}
record A() implements I {}
record B() implements I {}
Object o = null;
String r = switch ((I) o) {
case A a -> "A";
case B b -> "B";
};
System.out.println(r);
یک NullPointerException پرتاب میکند. یک switch الگویی بدون case null صریح همچنان با null خصومت دارد. برای مدیریت آن به case null -> ... نیاز داری. این تله دقیقاً آزمایش میکند که آیا فهمیدهای «جامعبودن» بهمعنای «امن در برابر null» نیست.
متغیر bind دقیقاً در نواحیای در دامنه است که کامپایلر بتواند ثابت کند الگو مطابقت کرده. این شامل شاخهی then، سمت راست &&، و کد پس از return/throw زودهنگام در حالت نفیشده است. با تحلیل جریانی از نوع definite-assignment تعیین میشود، نه با بلاکهای لغوی (lexical). یعنی مرزِ دامنه را «مسیرِ منطق» تعیین میکند، نه آکولادها.
حالتها از بالا به پایین تست میشوند. یک case Circle c بدون نگهبان همهی دایرهها را میگیرد، پس هر case Circle c when ... بعدی دستنیافتنی است — کامپایلر برچسبهای case دستنیافتنی را رد میکند. نگهبانها اول میآیند.
sealed نوع جمع بسته (مجموعهای ثابت از جایگزینها) را میدهد، record انواع ضرب (tupleهای نامدار) که همان جایگزینها هستند را میدهد، و switch الگویی تحلیل حالت جامع و تجزیهکننده را میدهد. نتیجه، تمامیت (totality) بررسیشده توسط کامپایلر است: سیستم نوع، نه code review، تضمین میکند که هر حالت مدیریت شده. این دستهای از باگهای ClassCastException/شاخهی مفقود را از زمان اجرا به زمان کامپایل منتقل میکند و مدلهای دامنه را خودمستند میسازد. این همان جملهای است که یک سنیور را از یک میانرده جدا میکند.
public record Portfolio(List<String> tickers) {
public List<String> tickers() { return tickers; }
}
اکسسور صریح فهرست داخلی را مستقیماً برمیگرداند، پس فراخوانندگان میتوانند وضعیت Record را از طریق ارجاع بازگشتی تغییر دهند. راهحل واقعی این است که در یک سازندهی canonical بنویسی this.tickers = List.copyOf(tickers) تا فهرست بهاشتراکگذاشتهشده تغییرناپذیر شود (اکسسور تولیدشده هم همین مشکل را دارد اگر منبع تغییرپذیر باشد، پس اصلاح در سازنده کلید ماجراست).
نه — استنتاج نوع ایستا در زمان کامپایل است؛ متغیر یک نوع ایستای ثابت از مقداردهندهاش دارد. نمیتوان برای فیلدها، پارامترهای متد، نوع بازگشتی، یا بدون مقداردهنده از آن استفاده کرد، و نمیتواند از null یا یک lambda/method reference خام استنتاج کند.
بهصورت پیشنمایش (JEP 430) در ۲۱، پیشنمایش دوم در ۲۲ عرضه شدند و سپس کنار گذاشته شدند — هیچ درونیابی رشتهی نهاییشدهای در جاوای فعلی وجود ندارد. Text block رشتههای چندخطی میدهد اما بدون درونیابی. طراحی خود را حول string template بنا نکن. (این سؤال اغلب برای سنجش این پرسیده میشود که آیا اخبار زبان را دنبال میکنی یا فقط از حفظ درس خواندهای.)
record Pair(Object a, Object b) {}
Object p = new Pair("x", 42);
String s = switch (p) {
case Pair(String a, Integer b) -> a + b;
case Pair(var a, var b) -> "other";
default -> "none";
};
"x42". اولین record pattern هر دو مؤلفه را تجزیه و نوعبررسی میکند: a به "x" (مطابق String) و b به 42 (مطابق Integer، با autobox) bind میشود، پس به "x42" الحاق میشود. Record patternها روی هر مؤلفهی تودرتو تست instanceof انجام میدهند.
وقتی مجموعهی هستهای کنترلشدهای از پیادهسازیها میخواهی اما باید به اشخاص ثالث یا پلاگینها اجازه دهی یک شاخهی خاص را گسترش دهند (مثلاً یک sealed interface Event پایه که sealed CustomEvent permits ... بسته میماند اما non-sealed PluginEvent عمداً باز است). این دریچهی فرار است که بقیهی سلسلهمراتب را بسته و بهطور جامع قابلبررسی نگه میدارد.
equals در Record برای مؤلفههای اعشاری از مقایسهی سبک Double.compare استفاده میکند که تحت آن NaN با NaN برابر است و 0.0 با -0.0 تفاوت دارد — عمداً برخلاف عملگر ==. اگر به معناشناسی == تکیه کنی غافلگیر میشوی؛ این طراحی عمدی است تا Record با hashCode سازگار بماند (یادت باشد: هرجا equals هست، hashCode هم باید همداستان باشد).
نکاتِ سنیور و موارد پیشرفته
تا اینجا فهمیدی Record، Sealed و Pattern Matching چه هستند و چطور کار میکنند. اما فرقِ یک سنیور با یک میانرده در همان جاهایی است که کتابها ساکت میمانند: وقتی این ویژگیها با serialization، فریمورکها، generics، کامپایل جداگانه و پرفورمنس گلاویز میشوند. این بخش دقیقاً همان لایهی پنهان است.
- serialization در Record — چرا Record امنتر از یک کلاس Serializable معمولی است.
- Record و فریمورکها — Jackson، Spring و چرا Record برای JPA Entity مناسب نیست.
- generics و pattern matching — چرا
case List<String>کامپایل نمیشود. - MatchException و کامپایل جداگانه — دامِ «یا دوباره کامپایل کن یا در تولید کرش کن».
- جامعبودن، dominance و default مخفی — چیزی که کامپایلر پشت پرده تزریق میکند.
- Record ارزشنوع نیست — داستان identity و پروژهی Valhalla.
- متغیر بینام
_، الگوهای primitive و پرفورمنس.
serialization در Record: یک ارتقای امنیتی پنهان
بیایید از جایی شروع کنیم که کمتر کسی میداند اما در مصاحبهی امنیتی طلاست. serialization کلاسیک جاوا یک حفرهی بدنام دارد: هنگام deserialize، JVM شیء را بدون فراخوانی هیچ سازندهای میسازد. یعنی همهی اعتبارسنجیهایی که در سازنده گذاشتهای دور زده میشوند — مهاجم میتواند بایتهای دستکاریشده بفرستد و شیئی بسازد که هرگز نمیتوانستی با new بسازی (پایهی بسیاری از حملات gadget-chain).
یک کلاس Serializable معمولی مثل خانهای است که درِ ورودیاش نگهبان دارد (سازنده که ورودیها را چک میکند)، اما یک تونل زیرزمینی هم دارد که deserialization از آن وارد میشود و نگهبان را کامل دور میزند. Record آن تونل را میبندد: هر ورود، حتی از مسیر deserialization، مجبور است از درِ ورودی — یعنی سازندهی canonical — رد شود.
Record برخلاف کلاس معمولی، هنگام deserialize از سازندهی canonical عبور میکند. یعنی همان کپی دفاعی و همان if اعتبارسنجی که در سازنده نوشتهای، روی دادهی deserializeشده هم اجرا میشود و invariantها حفظ میمانند.
public record Percentage(int value) implements java.io.Serializable {
public Percentage {
if (value < 0 || value > 100)
throw new IllegalArgumentException("out of range: " + value);
}
}
// حتی اگر مهاجم بایتهای دستکاریشده با value=999 بفرستد،
// deserialization از سازندهی canonical رد میشود و همانجا IllegalArgumentException میگیرد.
شکلِ serializeشدهی یک Record دقیقاً از روی مؤلفههایش ساخته میشود و قابل سفارشیسازی نیست: writeObject/readObject/readResolve/serialPersistentFields در Record نادیده گرفته میشوند و یک Record نمیتواند Externalizable باشد. مزیت: امنیت و پیشبینیپذیری. عیب: اگر ترتیب یا نوعِ مؤلفهها را عوض کنی، سازگاری با نسخههای قبلیِ serializeشده میشکند. serialVersionUID همچنان کار میکند و برای Record مقدار پیشفرضش 0L است مگر خودت تعریف کنی — پس برای پایداری باینری، صریح تعریفش کن.
Record در دنیای واقعیِ فریمورکها
فریمورکها Record را از طریق reflection میشناسند: Class::isRecord و Class::getRecordComponents که آرایهای از RecordComponent برمیگرداند (نام، نوع، اکسسور و annotationها). این API همان چیزی است که به Jackson و Spring اجازه میدهد بدون سازندهی no-arg و بدون setter، Record بسازند.
- Jackson از نسخهی 2.12 به بعد Record را بومی پشتیبانی میکند: با استفاده از سازندهی canonical آبجکت را میسازد. برای نگاشت نامِ فیلد JSON به مؤلفه معمولاً
@JsonPropertyروی مؤلفه لازم است مگر با-parametersکامپایل کنی تا نام پارامترها حفظ شود. - Spring: در
@ConfigurationPropertiesبا constructor binding و در کنترلرها (بدنهی request) Record بسیار خوب مینشیند. برای Spring Data، Record برای DTO/interface projection عالی است.
Hibernate برای موجودیتها به یک سازندهی بدونآرگومان، فیلدهای تغییرپذیر (برای dirty checking و lazy loading) و امکان ساخت proxy با subclass نیاز دارد. Record هم final است (proxy غیرممکن)، هم بدون سازندهی no-arg است، هم تغییرناپذیر. پس Record را بهعنوان @Entity استفاده نکن. اما جاهای درستش: DTOهای خروجی، JPQL constructor expression مثل select new com.app.UserDto(u.id, u.name) from User u، و projectionها. برای JPA @Embeddable هم پشتیبانیِ کامل و پایدار وجود ندارد — با احتیاط.
برای «حاملِ دادهی تغییرناپذیر»، Record جایگزین بومی و بدون وابستگی و بدون annotation processor است — پس نیاز به @Value تقریباً از بین رفته. اما لومبوک هنوز جای خودش را دارد: @Builder برای اشیای با فیلدهای زیاد و اختیاری، و DTOهای تغییرپذیر. جملهی سنیور: «برای value objectها Record، برای اشیای پیچیدهی تغییرپذیر یا builderمحور هنوز لومبوک/کلاس».
generics و pattern matching: دامِ erasure
اینجا یکی از ظریفترین سؤالات سنیور است. چرا این کامپایل نمیشود؟
Object o = List.of("a", "b");
switch (o) {
case List<String> l -> ... // خطای کامپایل!
}
چون جاوا generics را با erasure پیاده میکند: در زمان اجرا List<String> و List<Integer> هر دو فقط List هستند و اطلاعات <String> پاک شده. کامپایلر نمیتواند در زمان اجرا چک کند که «آیا این یک List از String است»، پس این الگو غیرقابلبررسی (not reifiable) است و رد میشود. راه درست:
case List<?> l -> ... // مجاز: wildcard قابلبررسی است
اما وقتی نوع از context قابل استنتاج باشد، جاوا اجازه میدهد آرگومان نوع را در record pattern حذف کنی و خودش استنتاج کند:
record Box<T>(T content) {}
static String f(Box<String> b) {
return switch (b) {
case Box(var s) -> s.toUpperCase(); // s بهصورت String استنتاج میشود
};
}
اگر واقعاً میدانی محتوا String است اما نوع در دست کامپایلر نیست، مجبوری case List<?> l بگیری و بعد دستی cast کنی — که یعنی برگشتی به همان ناامنیِ erasure. این را در طراحیِ API با sealed + record دور بزن، نه با cast.
MatchException و کامپایل جداگانه: دامِ «در تولید کرش میکند»
این نکته آنقدر مهم است که یک سؤال کامل مصاحبه پایینش هست. یک switch جامعِ روی sealed در زمان کامپایل به default نیاز ندارد. اما کامپایلر پشت پرده یک default مخفی تزریق میکند که در زمان اجرا MatchException پرتاب میکند. چرا؟ بهخاطر کامپایل جداگانه (separate compilation).
نمودار زیر سناریو را نشان میدهد؛ کلاس اپلیکیشن در برابر نسخهی ۱ کتابخانه کامپایل شده اما کتابخانه به نسخهی ۲ رفته:
قبل از نمودار — یک خط: کتابخانه یک زیرنوع جدید اضافه میکند، اما اپ دوباره کامپایل نمیشود.
flowchart LR
subgraph LibV1["Lib v1"]
S1["sealed Shape permits Circle, Rect"]
end
subgraph App["App compiled against v1"]
SW["exhaustive switch on Shape<br/>(no default in source)"]
end
subgraph LibV2["Lib v2 (deployed, NOT recompiled with App)"]
S2["sealed Shape permits Circle, Rect, Triangle"]
end
S1 --> SW
LibV2 -. "new Triangle at runtime" .-> SW
SW -- "no branch matches" --> MX["MatchException"]
اگر کتابخانه یک Triangle اضافه کند و در زمان اجرا یک Triangle به آن switch برسد — که اپ هرگز برایش شاخهای نداشت — نتیجه MatchException است. این یک تغییر ناسازگارِ مهاجرت (migration-incompatible) است.
در فکرِ سنیورت این را حک کن: در یک سلسلهمراتب sealed که کاربران با switchِ جامع رویش مصرف میکنند، افزودن یک زیرنوع جدید مثل حذف یک متد از یک اینترفیس عمومی خطرناک است. کدِ مصرفکننده که دوباره کامپایل نشده، در زمان اجرا با MatchException میترکد. راهحلها: (۱) کاربران را وادار به کامپایل مجدد کن، یا (۲) اگر سلسلهمراتب قرار است رشد کند، آن را sealed نکن یا در APIهای عمومی یک default معنادار بگذار. پس sealed یک قرارداد باینری است، نه فقط یک جزئیات پیادهسازی.
جامعبودن، dominance و default مخفی
دو قانونِ کامپایلری که سنیورها باید بتوانند نامشان را ببرند:
- Exhaustiveness (جامعبودن): کامپایلر باید ثابت کند الگوها همهی زیرنوعهای sealed را (بههمراه تجزیهی تودرتوی record pattern) پوشش میدهند. جامعبودن روی الگوهای تودرتو هم کار میکند: اگر
Shapeسه حالت داشته باشد و یکیشان خودش sealed باشد، کامپایلر تا عمق دنبال میکند. - Dominance (تسلط): اگر یک الگوی عامتر پیش از یک الگوی خاصتر بیاید، الگوی دوم دستنیافتنی است و کامپایلر ردش میکند (همان قانون «نگهبانها اول» که در فصل دیدی، اما عمومیتر:
case CharSequence cقبل ازcase String sیعنیStringهرگز نمیرسد).
تفاوت ظریف تاریخی: در یک switchِ جامع روی enum، جاوا هنوز تو را وادار به default (یا پوشش همه) نمیکند مگر switch expression باشد، و اگر مقداری خارج از enum برسد (مثلاً enum بعد از کامپایل تغییر کرده)، switch expression بدون شاخه هم میتواند MatchException/رفتار نامنتظره بدهد. برای مدلهای دامنه، sealed + record جامعبودنِ قویتری از enum ساده میدهد چون هر حالت میتواند دادهی خودش را حمل کند.
یک ترفند تمیز: enumها میتوانند یک sealed interface را implement کنند و در switchِ جامع کنار recordها بنشینند. این وقتی مفید است که بعضی حالتها بدونِ داده (enum) و بعضی با داده (record) باشند — مثلاً sealed interface Status permits Active, Inactive, Suspended که دو تا enum-like و یکی record با دلیلِ suspension است.
Record ارزشنوع نیست (هنوز): داستان identity و Valhalla
یک باور غلطِ رایج: «Record چون تغییرناپذیر است، لابد سبک و بدون هدر heap است.» نه. Record در جاوای فعلی هنوز یک شیء معمولی با identity است: روی heap تخصیص مییابد، یک header دارد، و == روی دو Record با محتوای یکسان false میدهد (چون identity متفاوت است — همیشه equals بزن نه ==).
پروژهی Valhalla قرار است «value class»های واقعی (بدون identity، قابل flatten در آرایهها و بدون هدرِ heap) بیاورد. تا آن زمان، Record فقط سینتکس تمیز است نه بهینهسازیِ حافظه. در یک حلقهی داغ که میلیونها Record کوچک میسازی، همان فشار GC و allocation کلاس معمولی را داری. این را در مصاحبهی پرفورمنس بدان: Record ارزانیات را در نگهداری کد میدهد، نه لزوماً در حافظه.
ابزارهای تازهتر: متغیر بینام و الگوهای primitive
- متغیر و الگوی بینام
_(جاوا ۲۲): وقتی به یک binding نیاز نداری اما نحو مجبورت میکند نامی بگذاری،_بنویس. در record pattern عالی است:case Point(var x, _)یعنی «فقط x را میخواهم». برخلاف نام معمولی، چند_در یک الگو مجاز است و درcatch (Exception _)و پارامتر lambda هم کار میکند.
case Box(_) -> "a box, contents irrelevant";
for (var _ : items) count++; // فقط شمارش، مقدار مهم نیست
- الگوهای نوعِ primitive (JEP 455): توانایی نوشتن
case int iیاcase Integer i when i > 0وinstanceofروی primitiveها. این تا جاوای فعلی (۲۶) هنوز در فازِ preview است — پس در تولید و در پاسخِ مصاحبه رویش حساب نکن، فقط بدان در راه است.
یک switchِ الگویی در سطح سورس شبیه زنجیرهای از instanceof بهنظر میرسد، اما کامپایلر آن را به یک invokedynamic با bootstrapِ SwitchBootstraps.typeSwitch پایین میآورد که در اولین فراخوانی لینک میشود و بعد سریع است. اکسسورِ Record هم فقط یک getfield است. پس نگرانِ «الگوها کندند» نباش؛ نگرانِ allocation و طراحیِ درستِ سلسلهمراتب باش. بهینهسازیِ زودرسِ اینجا اتلاف وقت است.
- serialization: Record هنگام deserialize از سازندهی canonical رد میشود، پس اعتبارسنجی حفظ میشود و در برابر حملات bypass-constructor امن است؛ اما فرمِ serialization قفل است و
serialVersionUIDرا صریح بگذار. - فریمورکها: Jackson/Spring با reflection (
getRecordComponents) Record را میسازند؛ برای JPA@Entityمناسب نیست، برای DTO/projection عالی است؛-parametersرا کامپایل کن. - generics:
case List<String>بهخاطر erasure غیرقابلبررسی است؛ فقطList<?>یا استنتاج در record pattern. - MatchException: switchِ جامع یک default مخفی دارد؛ افزودن permitted subtype یک تغییرِ باینریِ خطرناک است که کد کامپایلنشده را در زمان اجرا میترکاند.
- dominance/exhaustiveness: الگوی عام قبل از خاص = خطای کامپایل؛ enum میتواند sealed interface را پیاده کند.
- Valhalla: Record هنوز identity دارد و روی heap است؛ ارزانی در نگهداری، نه در حافظه.
_و الگوهای primitive:_نهایی شده (۲۲)؛ الگوهای primitive هنوز previewاند.
در deserialization کلاسیک، JVM شیء را بدون فراخوانی هیچ سازندهای میسازد و همهی اعتبارسنجیهای سازنده دور زده میشوند — پایهی بسیاری از حملات gadget-chain. Record برعکس، هنگام deserialize از سازندهی canonical عبور میکند، پس کپی دفاعی و چکهای invariant روی دادهی deserializeشده هم اجرا میشوند و نمیتوان با بایت دستکاریشده یک Record نامعتبر ساخت. در ضمن Record فرمِ serialize را قفل میکند: readObject/writeObject/readResolve نادیده گرفته میشوند و Externalizable ممنوع است. جملهی طلایی: «Record آن تونلِ زیرزمینیِ deserialization را که نگهبان را دور میزد، میبندد.»
اپ در زمان اجرا با MatchException میترکد. switchِ جامع در زمان کامپایل به default نیاز نداشت، اما کامپایلر یک default مخفی تزریق کرده که اگر هیچ شاخهای مطابقت نکند MatchException میاندازد. وقتی نمونهی زیرنوعِ جدید (که هنگام کامپایلِ اپ وجود نداشت) به switch برسد، هیچ شاخهای نمیگیردش. این یک تغییرِ migration-incompatible است و نیازمند کامپایل مجددِ اپ. درسِ معماری: در یک sealed عمومی، افزودن زیرنوع درست مثل شکستنِ قرارداد باینری است؛ اگر سلسلهمراتب قرار است رشد کند، sealedش نکن.
بهخاطر type erasure: در زمان اجرا List<String> و List<Integer> هر دو فقط List هستند، پس نوعِ آرگومان generic قابلبررسی (reifiable) نیست و کامپایلر نمیتواند تستِ زماناجرا برایش بسازد. مجاز: case List<?> l (wildcard قابلبررسی است). و در record pattern اگر نوع از context قابل استنتاج باشد میتوانی آرگومانِ نوع را حذف کنی، مثل case Box(var s) که s را String استنتاج میکند. راهِ درستِ مدلسازی «لیستی از نوعِ خاص» با pattern، نه cast جنریک، بلکه sealed + record است.
نه. در جاوای فعلی Record یک شیء معمولی با identity است: روی heap تخصیص مییابد، header دارد، و == روی دو Record با محتوای برابر false میدهد (همیشه equals). تغییرناپذیریاش بهمعنای ارزانبودنِ حافظه نیست؛ در حلقهی داغ همان فشار allocation/GC کلاس معمولی را داری. پروژهی Valhalla value classهای واقعی (بدون identity و قابل flatten) را در راه دارد، اما تا آن زمان Record فقط سینتکسِ تمیز است. صرفهی Record در نگهداری کد است نه حافظه.
Hibernate برای موجودیت به سازندهی no-arg، فیلدهای تغییرپذیر (برای dirty checking و lazy loading) و امکانِ ساختِ proxy با subclass نیاز دارد؛ Record هم final است، هم بدون no-arg، هم تغییرناپذیر — پس هر سه شرط را میشکند. جای درستش: DTOهای خروجی، JPQL constructor expression (select new com.app.UserDto(u.id, u.name) ...)، و Spring Data projectionها. برای @Embeddable پشتیبانیِ کاملِ پایدار نیست. خلاصه: Record لایهی انتقالِ داده است، نه لایهی persistence.
Dominance: اگر یک الگوی عامتر (مثل case CharSequence c یا یک case Circle c بدون نگهبان) پیش از الگوی خاصتر یا نگهباندار بیاید، الگوی دوم دستنیافتنی است و کامپایلر با خطا ردش میکند — پس ترتیب باید از خاص به عام باشد. default مخفی: در یک switchِ جامعِ روی sealed، هرچند در سورس default ننوشتهای، کامپایلر یک شاخهی مصنوعی تزریق میکند که در زمان اجرا MatchException میاندازد — تا اگر بهخاطر کامپایل جداگانه یک حالتِ پیشبینینشده برسد، بهجای رفتار تعریفنشده، یک استثنای شفاف بگیری.
_ (جاوا ۲۲) یک bindingِ بدونِ نام است: وقتی مقدار را لازم نداری اما نحو مجبورت میکند چیزی بنویسی. برخلاف نامِ معمولی، چند _ در یک scope مجاز است. جاهایش: مؤلفهی بیاهمیت در record pattern (case Point(var x, _))، متغیرِ حلقهی for-each وقتی فقط میشماری، پارامترِ استفادهنشدهی lambda، و بلاکِ catch (Exception _). خوانایی را بالا میبرد چون به خواننده میگوید «این مقدار عمداً نادیده گرفته شده».
- جاوا ۸ تا ۲۱ یک پارادایم دوم — برنامهنویسی دادهمحور — را کنار شیءگرایی گذاشت، با سه ستونِ همطراحی: Record، Sealed، Pattern matching.
- Record بستهی شفاف و تغییرناپذیرِ کمعمق است؛ کامپایلر سازنده، اکسسور و
equals/hashCode/toStringدرست را رایگان میدهد. برای تغییرناپذیری واقعی، کپی دفاعی کن. - Sealed درِ سلسلهمراتب را میبندد تا کامپایلر مجموعهی کامل حالتها را بداند؛ همین است که
switchجامعِ بدونdefaultرا ممکن میکند. - Pattern matching از
instanceofبا flow scoping شروع میشود و تاswitchجامع، record pattern (تجزیهی ساختار) و نگهبانwhenبالا میرود.nullتا وقتیcase nullنگذاری خصمانه میماند. - sealed + record + pattern switch = ADT با تمامیت بررسیشده توسط کامپایلر: باگهای شاخهی مفقود از زمان اجرا به زمان کامپایل منتقل میشوند.
- ابزارهای کمسروصدا: text block (چندخطی، بدون درونیابی)،
var(استنتاج نوع ایستا، نه تایپینگ پویا)، بهبود generics، و virtual thread (اشارهی گذرا). - مراقب باش: String template پس گرفته شد؛ روی آن حساب نکن. تا جاوا ۲۱، بقیهی اینها نهایی و آمادهی تولیدند.
Let's get one thing straight up front: for its first two decades, Java made you write dozens of lines of repetitive boilerplate just to model "a little bundle of data." Java 8 through 21 came to end that misery. In this chapter you won't just memorize a feature list — you'll actually understand what pain each feature cures, where it came from, and how to talk about it in a senior interview.
Here's the path we'll walk together:
- The big mental model — why we say Java became "data-oriented" and how this trio fits together.
- The timeline — what was finalized in which release (with JEP numbers).
- Records — the transparent data carrier that turns 40 lines into one.
- Sealed — closing the door on a hierarchy so the compiler can reason.
- Pattern matching — from plain
instanceofto exhaustiveswitchand destructuring. - The quiet tools — text blocks,
var, generics polish, and a glance at virtual threads. - Pitfalls, best practices, and 14 interview questions with full answers.
Part 0 — a few words you must feel before we start
Before we touch code, three terms recur throughout this chapter. Let me plant them in your mind with analogies now, so you don't get stuck later.
Imagine a restaurant has two kinds of "thing." First, every dish is made of parts: pizza = (dough, cheese, toppings). That's a product type — one thing that has all its parts together. Second, the whole menu is a closed list: you order either pizza, or a burger, or a salad — there's no fourth option. That's a sum type — one among a fixed set of choices. Put those two together and you get algebraic data types (ADTs): a menu where you know both what each dish is made of and exactly how many total options exist. In Java, record is the product type, sealed is the sum type, and pattern matching is the waiter who takes the order and makes sure no dish was forgotten.
- Immutable: something that can't change once created, like text carved in stone. Its opposite is mutable, like a whiteboard you keep erasing and rewriting.
- Exhaustive: "you covered every case, nothing left out" — like a checklist with every box ticked.
- Compiler: the program that reads your code before it runs and translates it to bytecode. The whole theme of this chapter is: the more errors we can push to compile time (before the program even runs), the calmer our nights are.
Mental model: Java became a data-oriented language
Classic Java was like a traditional carpenter's shop: for every chair (every object) you had to saw, nail, and paint by hand — endless repetitive work for something that was really just "a few boards joined together." Modern Java added a factory line next to it: you just say "a chair is made of these parts" and the line does the rest — building, comparing, printing. The carpenter's shop (OOP) is still there and still needed for artisanal work; but for "data bundles" you no longer have to hand-craft everything.
For its first two decades Java forced you to model everything as mutable objects with hand-written boilerplate. Java 8→21 quietly added a second paradigm on top of the OOP core: data-oriented programming. That means: instead of hiding data behind layers of behavior, you model it transparently and directly, and keep the logic outside, as functions that pattern-match over that data.
The three co-designed pillars are:
- Records — transparent data carriers (the menu's "product type").
- Sealed types — a closed set of alternatives (the menu's "sum type").
- Pattern matching — destructuring plus exhaustive dispatch (the waiter).
Read together they give you algebraic data types and safe, compiler-checked case analysis — the same ergonomics as Scala/Kotlin/Rust enums, but native to the JVM.
Each feature is useful alone, but their real power is unleashed only when used together: sealed tells the compiler "the cases are exactly these," record models each case cleanly, and pattern matching makes sure no case is missed. Remove any one and the magic is only half there.
The rest arrived to make everyday code less noisy: var, switch expressions, text blocks, and — landing in Java 21 — virtual threads (covered deeply in its own chapter). This chapter is a precise map of what shipped, in which release, and how to wield it at a senior level.
Timeline of what landed (and when it became final)
Before the table, let me unpack a word you'll see a lot below:
Java ships big features first as a preview: the code works but you must compile with the --enable-preview flag, and it may change or even be removed in the next release. When a feature becomes final, it's stable and you can rely on it in production without fear. LTS means Long-Term Support — releases like 8, 11, 17, and 21 that get years of support and that companies tend to stay on.
| Feature | Preview / first seen | Finalized (LTS in bold) | JEP |
|---|---|---|---|
Lambdas, Streams, Optional, default methods |
— | Java 8 | — |
var (local-variable type inference) |
— | Java 10 | 286 |
var in lambda params |
— | Java 11 | 323 |
| Switch expressions | 12/13 | Java 14 | 361 |
| Text blocks | 13/14 | Java 15 | 378 |
Pattern matching for instanceof |
14/15 | Java 16 | 394 |
| Records | 14/15 | Java 16 | 395 |
| Sealed classes/interfaces | 15/16 | Java 17 | 409 |
Pattern matching for switch |
17–20 (4 previews) | Java 21 | 441 |
| Record patterns | 19/20 | Java 21 | 440 |
| Virtual threads | 19/20 | Java 21 | 444 |
| Sequenced collections | — | Java 21 | 431 |
| String templates | 21 (preview), 22 (2nd preview) | withdrawn — never finalized | 430/459 |
Unnamed patterns & variables (_) |
21 (preview) | Java 22 | 456 |
The senior takeaway: as of Java 21 (the current LTS), records, sealed types, pattern matching for switch, and record patterns are all final and production-safe.
You may have seen blog posts using STR."..." to interpolate variables inside a string. That feature (string templates) was previewed, got a second preview, and was then fully withdrawn. So in current Java there is no official, finalized string interpolation. Relying on it in your code — or in an interview answer — is a mistake. Text blocks give multi-line literals, but no interpolation.
Records: transparent carriers for immutable data
A regular class is like a living personnel file: you can change its insides, add signatures, attach behaviors. But very often you just want an "ID card": a fixed slice of data that, once printed, doesn't change, and where two cards with identical info mean "the same person." A record is exactly that ID card — a transparent bundle whose identity is its contents.
A record is a final class whose state is a fixed ordered list of components. Let me unpack "component": it's just the fields you write inside the parentheses after the record's name — the grains of data that make up this bundle. The compiler auto-generates for you:
- a canonical constructor (the "main" constructor that takes exactly those components),
private finalfields,- accessors (an accessor is just the read method) named exactly like the components — no
getprefix, - plus
equals,hashCode, andtoStringderived structurally from the components. "Structurally" means it compares by contents, not by whether two references point to the same object in memory.
public record Point(int x, int y) {}
That one line is equivalent to ~40 lines of a hand-written value class. Here's how it's used:
Point p = new Point(3, 4);
int a = p.x(); // accessor, not getX()
boolean eq = p.equals(new Point(3, 4)); // true — structural equality
System.out.println(p); // Point[x=3, y=4]
For years, the biggest source of bugs in data classes was that programmers either forgot equals/hashCode or wrote them wrong — and then the object misbehaved as a Map key or Set member. Records generate these for free and correctly. That dries up an entire class of silent bugs at the root.
Semantics you must know
- Shallow immutability. Components are
final, but if a component is a mutable type (e.g.int[]orList), its contents can still change. "Shallow" means only the top layer is locked, not the depths. If you need real immutability, make a defensive copy in the constructor. equals/hashCodeare component-based, so records are safe asMapkeys and inSets.- A record cannot extend another class (it implicitly extends
java.lang.Record) but can implement interfaces. - Records can be generic, can declare static members, additional instance methods, and even nested/local records inside a method.
Suppose someone hands you the original deed to their house. If you keep the original, they can come back anytime and scribble on it, corrupting your "state." A defensive copy means: the moment you receive the deed, you make an independent photocopy and give the original back. Now whatever they do with their copy is none of your concern. List.copyOf(members) is exactly that immutable photocopy.
Compact vs canonical constructors
The compact constructor lets you validate or normalize without re-listing parameters or assigning fields — assignment to the final fields is implicit at the end:
public record Range(int lo, int hi) {
public Range { // compact form — no parameter list
if (lo > hi) throw new IllegalArgumentException("lo > hi");
// normalization: reassign the *parameter*, not a field
hi = Math.max(lo, hi);
}
}
Note the subtlety: inside a compact constructor you reassign the parameters; the compiler copies their final values into the fields when the block exits. You may not assign this.lo = ... in a compact constructor.
Your instinct says to write this.hi = ... in the constructor. But here you'll get a compile error. The simple mental rule: in a compact constructor "just tweak the parameter and the compiler will copy it into the field at the end." If you crave an explicit this.field assignment, you must switch to a full canonical constructor.
The canonical constructor is the full-signature version; use it when you need defensive copies:
public record Team(String name, List<String> members) {
public Team(String name, List<String> members) { // canonical, explicit
this.name = name;
this.members = List.copyOf(members); // defensive, immutable copy
}
}
You can also add secondary constructors that delegate to the canonical one via this(...) — that is, they hand the work of building off to the main constructor so the logic lives in one place.
When not to use a record
Records are for data whose identity is its contents. Do not use them for:
- JPA
@Entitytypes (they need a no-arg constructor and mutable identity), - anywhere you need inheritance or lifecycle mutation.
They're perfect for DTOs, value objects, map keys, tuples, and — crucially — the cases of a sealed hierarchy. Which brings us to pillar two.
Sealed classes and interfaces: closing the hierarchy
A normal interface is like an open-door party: any class from any corner of the world can implement it, and you never know how many "types" of guest you have. That's why, when you want to handle all cases, you always need a default branch — someone might invent a new type tomorrow. sealed means you've posted a list on the door: "only these three are allowed in." Now the compiler sees that list too and knows the guests are exactly these three — no more, no less.
A sealed type declares exactly which types may extend or implement it. This turns an open, unknowable hierarchy into a closed algebraic sum the compiler can reason about. Remember "closed sum"? It's the menu's "sum type": one among a fixed, enumerated set of cases.
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
Rules of the permits clause (the "guest list"):
- Every permitted subtype must be
final,sealed, ornon-sealed.non-sealeddeliberately re-opens that branch for further extension — like saying "this one guest may bring any number of companions." - Permitted subtypes must be in the same module (or same package if unnamed module). A module is the packaging unit of code in Java's module system; the logic is that the compiler must be able to see all subtypes to guarantee the list.
- If all permitted subtypes sit in the same source file, you can omit
permits— the compiler infers it.
sealed + records = an ADT. The compiler now knows the complete set of Shapes. And that knowing is exactly what makes exhaustive switch possible without a default branch. Without sealed, the compiler can never be sure you covered every case; with sealed, it can guarantee it.
Pattern matching: from instanceof to exhaustive switch
Now our waiter arrives. Pattern matching means "tell the compiler what shape of data you're looking for, and if it's found, hand me its pieces right there." Let's start with the simplest form.
Pattern matching for instanceof (Java 16)
In the old days, to be sure a box was a "book" you did three separate things: (1) check whether it's a book, (2) if so, slap a "book" label on it (cast it), (3) then place it on the book shelf. Three steps for one simple job. Pattern matching is an inspector who, the moment they see a book, labels it and hands it over: one move instead of three.
The classic test-cast-assign trio collapses into a type pattern that binds a variable when the test succeeds. "Bind" is that same "labeling": if the data matches the pattern, its value is placed into a fresh variable ready to use.
// Old
if (obj instanceof String) {
String s = (String) obj;
return s.length();
}
// Java 16+
if (obj instanceof String s) { // s is in scope where the test is true
return s.length();
}
The binding respects flow scoping. "Flow scoping" means s is available exactly where the compiler can prove instanceof held — not necessarily inside a specific {} block, but wherever the program's logical path guarantees the test succeeded. That includes the right side of && and even after an early return:
if (!(obj instanceof String s)) return 0;
return s.length(); // legal: we only reach here if the pattern matched
See how clean that is: because we return early in the "not a string" case, the only way to reach the next line is if s really is a String — and the compiler figures exactly that out.
Switch expressions (Java 14) and pattern matching for switch (Java 21)
First a step back: what's the difference between an old switch statement and a switch expression?
The old switch (a statement) was like giving a sequence of commands: "do this, do that." And if you forgot break, execution slid into the next case like a ball tumbling down stairs (the infamous fall-through). But a switch expression is like buying something at a café: you say one thing and receive one value back. Each branch must yield exactly one result, and there's no more sliding through.
Switch expressions (Java 14) fixed the fall-through footgun and let switch produce a value:
int days = switch (month) {
case JAN, MAR, MAY, JUL, AUG, OCT, DEC -> 31;
case APR, JUN, SEP, NOV -> 30;
case FEB -> 28; // arrow form: no fall-through, no break
};
That -> arrow is the hero: each branch is independent, yields its own value, and can't leak into the next.
Now the big Java 21 leap: type patterns can appear in case labels. Combined with a sealed type, the switch becomes exhaustive with no default — and if you later add a fourth Shape, the switch fails to compile until you handle it.
double area(Shape s) {
return switch (s) { // no default needed
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
That "fails to compile" is gold. Imagine a large system and you add a new Shape. In this style, the compiler acts like a caring colleague and points you to every spot that must be updated, refusing to build until you fix them. That's moving a class of bugs from "discovered at 3 AM in production" to "discovered the instant you type." This is called compile-time exhaustiveness.
Record patterns (Java 21): destructuring
A record pattern is like receiving a parcel with boxes nested inside, and instead of unwrapping layer by layer, you have a cardboard template that splits all the layers at once and drops exactly the piece you wanted into your palm. "Destructuring" is precisely that: while matching the shape, also pull the inner pieces out.
Record patterns let you match and pull components apart in one step, nesting arbitrarily deep:
sealed interface Expr permits Num, Add, Mul {}
record Num(double v) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
double eval(Expr e) {
return switch (e) {
case Num(double v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
Notice this is a complete recursive calculator packed into a few lines — with no default at all, because Expr is sealed and the compiler knows the cases are exactly these three.
Nested destructuring reads like the data itself:
// match "an Add whose left side is the literal 0"
case Add(Num(var v), Expr r) when v == 0 -> eval(r); // 0 + r == r
See how the pattern is nearly the shape of what you're hunting for: an Add containing a Num with value zero. The code looks like a description of the data, not instructions to dig through it.
Guarded patterns with when
Sometimes "the right type" isn't enough; you want an extra condition to hold too. when is like a bouncer saying: "A circle? Fine, but only call it a giant circle if its radius exceeds 100." If the condition fails, we move on to the next case.
A when clause adds a boolean guard to a pattern. Order matters — guarded cases must precede the more general unguarded one:
String describe(Shape s) {
return switch (s) {
case Circle c when c.radius() > 100 -> "huge circle";
case Circle c -> "circle";
case Rectangle r when r.w() == r.h() -> "square";
case Rectangle r -> "rectangle";
case Triangle t -> "triangle";
};
}
Cases are tested top-to-bottom. If you put the unguarded case Circle c higher, it swallows all circles and you'd never reach case Circle c when .... The compiler catches this and rejects the unreachable case. Rule: guards first, general case last.
null in switch
Historically switch(null) threw NullPointerException (NPE for short — the "pointing at nothing" error). Since Java 21 you may add an explicit case null (optionally case null, default). Without it, a pattern switch still throws NPE on null — the null-hostility is preserved unless you opt in to taming it.
String render(Object o) {
return switch (o) {
case null -> "nothing";
case String s -> "str:" + s;
default -> "other";
};
}
Java's default is: "null is an unexpected value, so if you didn't explicitly go looking for it, you get the same old NPE." This is deliberate, to keep behavior predictable. If you want to handle null as an ordinary case, just write case null.
Text blocks, var, and generics polish
These aren't the paradigm's core pillars, but they're the quiet tools that keep everyday code clean.
Text blocks (Java 15)
Before text blocks, writing a multi-line JSON in Java was torture: each line wrapped in ", \n glued on, \" escaped. A text block is like dropping the whole block in as-is between three """ and relaxing.
Multi-line string literals with """, incidental leading whitespace stripped relative to the closing delimiter:
String json = """
{
"id": 42,
"name": "Ada"
}
"""; // trailing newline present unless you end with \
Key operators: \ at end of line suppresses the newline (for when you break one logical line into several physical lines); \s forces a trailing space that stripping would otherwise remove. Text blocks are still ordinary Strings at runtime — no interpolation (string templates would have added that, but, as we said, were withdrawn).
To figure out how much leading whitespace is "decorative" and should be stripped, Java looks at the least-indented line — and that includes the closing """ line! So if you misalign the closing triple-quote, the leading whitespace of every line silently changes. If your output has weird indentation, suspect the alignment of the closing """ first.
var (Java 10/11)
When you write var list = new ArrayList<String>(), the compiler is like a colleague who sees the right-hand side and says "obviously that's an ArrayList<String>, no need to say it twice." This is not a guess; it's a definite deduction from what you literally wrote right there.
var is local-variable type inference. It infers the static type of the initializer, so it's fully type-safe — not dynamic typing.
var list = new ArrayList<String>(); // ArrayList<String>
var entry = Map.entry("k", 1); // Map.Entry<String, Integer>
A common misconception: some think var means Java went "untyped" like Python or JavaScript. No! The variable's type is fixed and definite at compile time; you just aren't forced to write it. If you later assign something of the wrong type to list, you get the same old compile error.
Rules and gotchas: only for locals with an initializer, for/try-with-resources, and lambda parameters (Java 11); not for fields, method params, or return types. var x = null; and var x = () -> {} don't compile — there's no type to infer (you can't derive a type from null or a bare lambda). Prefer var when the type is obvious from the right-hand side; keep it explicit when it aids the reader.
Enhanced generics across the era
- Diamond
<>inference (Java 7) was extended in Java 9 to work with anonymous classes. The "diamond" is that<>which lets you not repeat the type on the right. - Improved target typing for generic method calls and lambdas throughout Java 8+ means you rarely need explicit witness types like
Collections.<String>emptyList(). "Target typing" means the compiler infers the type from where the value is going. varinteracts with generics for localtry/loop variables, reducing the noise of long parameterized type names.
var x = new HashMap<>(); infers HashMap<Object,Object> — because the right side (<>) puts no constraint on the type parameters, and var supplied nothing on the left. The result: a map from Object to Object, probably not what you wanted. When you combine var and <>, always specify the type parameters on the right: var x = new HashMap<String,Integer>();.
Virtual threads (Java 21) — the one-paragraph version
Thread.ofVirtual().start(...) and Executors.newVirtualThreadPerTaskExecutor() give you JVM-scheduled, cheap threads that unmount from their carrier OS thread on blocking I/O. "Unmount" means the virtual thread temporarily vacates its spot on the real OS thread so other work can run, then remounts when the I/O completes. They make the thread-per-request model scale to millions of concurrent tasks without reactive plumbing.
Concurrency semantics, pinning (when a virtual thread is forced to stay stuck to its carrier thread and can't unmount), and ThreadLocal pitfalls are covered in the dedicated concurrency chapter. Here we just wanted you to know virtual threads are part of the Java 21 revolution too.
Common pitfalls and gotchas
Let's gather the top traps in one place — these are the ones that bite in interviews or real code:
- Records aren't deeply immutable. A
record Config(Map<String,String> props)shares the map with the caller. Copy in the canonical constructor. - Compact-constructor assignment trap. Writing
this.x = x;in a compact constructor is a compile error; you mutate the parameter and let the compiler assign it. - Exhaustiveness silently regained by
default. If you add adefaultto a sealed switch "to be safe," you lose the compile error when a new subtype is added. For closed hierarchies, prefer nodefaultso the compiler forces you to handle new cases. - Guard ordering. A more general
case Circle cbeforecase Circle c when ...makes the guarded case unreachable — the compiler rejects it. Put guards first. instanceofbinding scope is flow-sensitive; a binding introduced in awhilecondition is not in scope after abreakthat leaves the loop when the condition is false.var+ diamond =var x = new HashMap<>();infersHashMap<Object,Object>— the right side has nothing to constrain the type parameters. Specify them.- Text block indentation is computed from the least-indented line including the closing
"""; misaligning the closing delimiter silently changes leading whitespace.
Best practices
- Model domain alternatives as
sealed interface+recordcases, then dispatch with patternswitch— nodefault, so exhaustiveness is compiler-enforced. - Use records for every DTO, event, and value object; you get correct
equals/hashCode/toStringfor free. - Keep
varfor obvious initializers and long generic types; write the type where it documents intent. - Prefer switch expressions over statements — they force every case to yield a value and eliminate fall-through bugs.
- Validate and defensively copy in record constructors; never expose mutable internals through accessors.
Interview Questions
Now it's time to drill everything through real senior-interview questions. Try answering each yourself first, then open the answer.
Private final fields, a canonical constructor, component accessors, and equals/hashCode/toString. equals returns true iff the other object is the same record type and all corresponding components are equal (using Objects.equals, and the proper primitive comparison including Double.compare semantics for double/float, so -0.0 and NaN behave per IEEE-corrected equality). This makes records safe as hash keys.
Only shallowly. The component references are final, but a mutable component's contents can change. True immutability requires defensive copies in the canonical constructor and never leaking the internal reference. A golden interview line: "A record locks the door, not the contents of the room."
Because the compact constructor's job is validation/normalization on the parameters; the compiler emits the field assignments automatically at the end using the (possibly reassigned) parameter values. Assigning to this.field there is a compile error. If you genuinely want explicit assignment, use a full canonical constructor.
The compiler proves the case patterns cover every permitted subtype of the sealed input type (accounting for nested record patterns and null handling). With a sealed hierarchy you can omit default; then adding a new subtype breaks compilation everywhere the switch isn't updated — exactly the safety you want. A default silences that error and takes that safety away.
sealed interface I permits A, B {}
record A() implements I {}
record B() implements I {}
Object o = null;
String r = switch ((I) o) {
case A a -> "A";
case B b -> "B";
};
System.out.println(r);
It throws NullPointerException. A pattern switch without an explicit case null is still null-hostile. You'd need case null -> ... to handle it. This trap specifically tests whether you understand that "exhaustive" does not mean "null-safe."
The binding variable is in scope precisely in the regions where the compiler can prove the pattern matched. That includes the then branch, the right side of &&, and code after an early return/throw in the negated case. It's determined by definite-assignment-style flow analysis, not lexical blocks. In other words, the "logical path" defines the scope boundary, not the braces.
Cases are tested top-to-bottom. An unguarded case Circle c matches all circles, so any later case Circle c when ... is unreachable — the compiler rejects unreachable case labels. Guards go first.
sealed gives the closed sum type (a fixed set of alternatives), record gives the product types (named tuples) that are the alternatives, and pattern-matching switch gives exhaustive, destructuring case analysis. The result is compiler-verified totality: the type system, not code review, guarantees every case is handled. It shifts a class of runtime ClassCastException/missing-branch bugs to compile time and makes domain models self-documenting. This is the exact sentence that separates a senior from a mid-level engineer.
public record Portfolio(List<String> tickers) {
public List<String> tickers() { return tickers; }
}
The explicit accessor returns the internal list directly, so callers can mutate the record's state via the returned reference. The real fix is this.tickers = List.copyOf(tickers) in a canonical constructor, making the shared list immutable (the generated accessor has the same issue when the source is mutable, so fixing it in the constructor is the key).
No — it's static type inference performed at compile time; the variable has a fixed static type from its initializer. It can't be used for fields, method parameters, return types, or without an initializer, and it can't infer from null or a bare lambda/method reference.
They shipped as a preview (JEP 430) in 21, a second preview in 22, and were then withdrawn — no finalized string interpolation exists in current Java. Text blocks give multi-line literals but no interpolation. Don't design around string templates. (This question is often asked to gauge whether you follow language news or just memorized an old study guide.)
record Pair(Object a, Object b) {}
Object p = new Pair("x", 42);
String s = switch (p) {
case Pair(String a, Integer b) -> a + b;
case Pair(var a, var b) -> "other";
default -> "none";
};
"x42". The first record pattern destructures and type-checks both components: a binds to "x" (matches String), b to 42 (matches Integer, autoboxed), so it concatenates to "x42". Record patterns do the instanceof test on each nested component.
When you want a controlled core set of implementations but must allow third parties or plugins to extend one specific branch (e.g. a base sealed interface Event where sealed CustomEvent permits ... stays closed but a non-sealed PluginEvent is deliberately open). It's the escape hatch that keeps the rest of the hierarchy closed and exhaustively checkable.
Record equals uses Double.compare-style comparison for floating-point components, under which NaN equals NaN and 0.0 differs from -0.0 — deliberately unlike the == operator. If you rely on == semantics you'll be surprised; this is by design so records are consistent with hashCode (remember: wherever equals goes, hashCode must agree).
Senior notes & advanced edge cases
By now you know what records, sealed types, and pattern matching are and how they work. But the gap between a mid-level and a senior engineer lives exactly where the textbooks fall silent: where these features collide with serialization, frameworks, generics, separate compilation, and performance. This section is that hidden layer.
- Record serialization — why records are safer than an ordinary Serializable class.
- Records in real frameworks — Jackson, Spring, and why records don't fit JPA entities.
- Generics & pattern matching — why
case List<String>won't compile. - MatchException & separate compilation — the "recompile or crash in prod" trap.
- Exhaustiveness, dominance, and the hidden default the compiler injects.
- Records are not value types — the identity story and Project Valhalla.
- Unnamed variables
_, primitive patterns, and performance.
Record serialization: a hidden security upgrade
Let's start where few people look but interviewers pounce. Classic Java serialization has an infamous hole: on deserialize, the JVM builds the object without calling any constructor. Every validation you put in the constructor is bypassed — an attacker can send tampered bytes and materialize an object you could never construct with new (the foundation of many gadget-chain attacks).
An ordinary Serializable class is like a house whose front door has a guard (the constructor that checks inputs) — but also a secret tunnel that deserialization enters through, bypassing the guard entirely. Records seal that tunnel: every entry, even via deserialization, is forced through the front door — the canonical constructor.
Unlike a normal class, a record's deserialization goes through the canonical constructor. That means the same defensive copy and the same validation if you wrote in the constructor also run on the deserialized data, and invariants are preserved.
public record Percentage(int value) implements java.io.Serializable {
public Percentage {
if (value < 0 || value > 100)
throw new IllegalArgumentException("out of range: " + value);
}
}
// Even if an attacker sends tampered bytes with value=999,
// deserialization passes through the canonical constructor and throws right there.
A record's serialized shape is derived strictly from its components and is not customizable: writeObject/readObject/readResolve/serialPersistentFields are ignored for records, and a record cannot be Externalizable. The upside is security and predictability. The downside: if you change the order or type of components, compatibility with previously serialized bytes breaks. serialVersionUID still applies, and for a record it defaults to 0L unless you declare one — so for binary stability, declare it explicitly.
Records in the real world of frameworks
Frameworks recognize records through reflection: Class::isRecord and Class::getRecordComponents, which returns an array of RecordComponent (name, type, accessor, annotations). This API is exactly what lets Jackson and Spring build records with no no-arg constructor and no setters.
- Jackson supports records natively since 2.12: it constructs the object via the canonical constructor. Mapping a JSON field name to a component usually needs
@JsonPropertyon the component, unless you compile with-parametersso parameter names are retained. - Spring: records fit beautifully in
@ConfigurationProperties(constructor binding) and in controller request bodies. For Spring Data, records are ideal for DTO / interface projections.
Hibernate needs a no-arg constructor for entities, mutable fields (for dirty checking and lazy loading), and the ability to build a proxy by subclassing. A record is final (no proxy), has no no-arg constructor, and is immutable. So don't use a record as an @Entity. Where they do belong: outbound DTOs, JPQL constructor expressions like select new com.app.UserDto(u.id, u.name) from User u, and projections. Even as a JPA @Embeddable, support is not fully stable — tread carefully.
For an "immutable data carrier," records are now the native, dependency-free, annotation-processor-free replacement — so the need for @Value has largely evaporated. Lombok still has its place, though: @Builder for objects with many optional fields, and mutable DTOs. The senior line: "Records for value objects; Lombok/classes still for complex mutable or builder-heavy objects."
Generics & pattern matching: the erasure trap
Here's one of the subtlest senior questions. Why does this not compile?
Object o = List.of("a", "b");
switch (o) {
case List<String> l -> ... // compile error!
}
Because Java implements generics via erasure: at run time List<String> and List<Integer> are both just List, and the <String> information is gone. The compiler cannot check at run time "is this a List of String," so the pattern is not reifiable and is rejected. The right way:
case List<?> l -> ... // allowed: a wildcard is reifiable
But when the type is inferable from context, Java lets you drop the type arguments in a record pattern and infers them:
record Box<T>(T content) {}
static String f(Box<String> b) {
return switch (b) {
case Box(var s) -> s.toUpperCase(); // s is inferred as String
};
}
If you truly know the contents are Strings but the type isn't available to the compiler, you're forced to match case List<?> l and then cast by hand — a regression back to erasure unsafety. Design your way around this with sealed + record, not with a cast.
MatchException & separate compilation: the "crashes in prod" trap
This point is important enough to earn a full interview question below. An exhaustive switch over a sealed type needs no default at compile time. But the compiler injects a hidden default that throws MatchException at run time. Why? Because of separate compilation.
The diagram shows the scenario; the application class was compiled against library v1, but the library has moved to v2:
Before the diagram, one line: the library adds a new subtype, but the app is not recompiled.
flowchart LR
subgraph LibV1["Lib v1"]
S1["sealed Shape permits Circle, Rect"]
end
subgraph App["App compiled against v1"]
SW["exhaustive switch on Shape<br/>(no default in source)"]
end
subgraph LibV2["Lib v2 (deployed, NOT recompiled with App)"]
S2["sealed Shape permits Circle, Rect, Triangle"]
end
S1 --> SW
LibV2 -. "new Triangle at runtime" .-> SW
SW -- "no branch matches" --> MX["MatchException"]
If the library adds a Triangle and, at run time, a Triangle reaches that switch — which the app never had a branch for — the result is a MatchException. This is a migration-incompatible change.
Carve this into your senior instincts: in a sealed hierarchy that consumers switch over exhaustively, adding a new subtype is as dangerous as removing a method from a public interface. Consumer code that wasn't recompiled blows up at run time with MatchException. Remedies: (1) force consumers to recompile, or (2) if the hierarchy is meant to grow, don't seal it — or provide a meaningful default in public APIs. So sealed is a binary contract, not just an implementation detail.
Exhaustiveness, dominance, and the hidden default
Two compiler rules seniors should be able to name:
- Exhaustiveness: the compiler must prove the patterns cover every subtype of the sealed type (including nested record-pattern destructuring). Exhaustiveness works on nested patterns too: if
Shapehas three cases and one of them is itself sealed, the compiler follows it all the way down. - Dominance: if a more general pattern comes before a more specific one, the second is unreachable and the compiler rejects it (the "guards first" rule from the chapter, generalized:
case CharSequence cbeforecase String smeansStringis never reached).
A subtle historical difference: in an exhaustive switch over an enum, Java still doesn't force you to add a default (or cover all) unless it's a switch expression, and if a value outside the enum arrives (e.g. the enum changed after compilation), even a branch-free switch expression can throw MatchException/behave unexpectedly. For domain models, sealed + record gives stronger exhaustiveness than a plain enum because each case can carry its own data.
A clean trick: enums can implement a sealed interface and sit alongside records in an exhaustive switch. This helps when some cases carry no data (enum) and some carry data (record) — for example sealed interface Status permits Active, Inactive, Suspended, where two are enum-like and one is a record holding a suspension reason.
Records are not value types (yet): identity and Valhalla
A common misconception: "a record is immutable, so surely it's lightweight with no heap overhead." No. In current Java a record is still an ordinary object with identity: it's heap-allocated, has a header, and == on two records with identical contents returns false (their identity differs — always use equals, never ==).
Project Valhalla is set to bring true "value classes" (no identity, flattenable in arrays, no heap header). Until then, a record is just clean syntax, not a memory optimization. In a hot loop creating millions of tiny records, you have the same GC and allocation pressure as an ordinary class. Know this in a performance interview: a record buys you cheapness in code maintenance, not necessarily in memory.
Newer tools: unnamed variables and primitive patterns
- Unnamed variable and pattern
_(Java 22): when you don't need a binding but the syntax forces you to name something, write_. It shines in record patterns:case Point(var x, _)means "I only want x." Unlike a normal name, multiple_are allowed in one pattern, and it also works incatch (Exception _)and lambda parameters.
case Box(_) -> "a box, contents irrelevant";
for (var _ : items) count++; // just counting, value irrelevant
- Primitive type patterns (JEP 455): the ability to write
case int iorcase Integer i when i > 0andinstanceofover primitives. As of current Java (26) this is still in preview — so don't rely on it in production or in an interview answer; just know it's coming.
A pattern switch looks like a chain of instanceof at the source level, but the compiler lowers it to an invokedynamic with the SwitchBootstraps.typeSwitch bootstrap, which links on first call and is fast thereafter. A record accessor is just a getfield. So don't worry that "patterns are slow"; worry about allocation and getting the hierarchy design right. Premature optimization here is a waste of time.
- Serialization: a record deserializes through its canonical constructor, so validation is preserved and it's immune to bypass-constructor attacks; but the serialization form is locked, so declare
serialVersionUIDexplicitly. - Frameworks: Jackson/Spring build records via reflection (
getRecordComponents); not fit for a JPA@Entity, ideal for DTO/projections; compile with-parameters. - Generics:
case List<String>is non-reifiable due to erasure; onlyList<?>or inference inside a record pattern. - MatchException: an exhaustive switch has a hidden default; adding a permitted subtype is a dangerous binary change that blows up un-recompiled code at run time.
- Dominance/exhaustiveness: general pattern before specific = compile error; an enum can implement a sealed interface.
- Valhalla: a record still has identity and lives on the heap; cheap in maintenance, not in memory.
_and primitive patterns:_is final (22); primitive patterns are still preview.
In classic deserialization the JVM builds the object without calling any constructor, bypassing every constructor validation — the basis of many gadget-chain attacks. A record, by contrast, deserializes through its canonical constructor, so defensive copies and invariant checks also run on the deserialized data, and you can't materialize an invalid record from tampered bytes. Additionally, a record locks its serialized form: readObject/writeObject/readResolve are ignored and Externalizable is forbidden. Golden line: "A record seals the deserialization tunnel that used to bypass the guard."
The app blows up at run time with MatchException. The exhaustive switch needed no default at compile time, but the compiler injected a hidden default that throws MatchException when no branch matches. When an instance of the new subtype (which didn't exist when the app was compiled) reaches the switch, no branch catches it. This is a migration-incompatible change requiring the app to be recompiled. The architectural lesson: in a public sealed hierarchy, adding a subtype is like breaking a binary contract; if the hierarchy is meant to grow, don't seal it.
Because of type erasure: at run time List<String> and List<Integer> are both just List, so the generic type argument is not reifiable and the compiler can't generate a run-time test for it. Allowed: case List<?> l (a wildcard is reifiable). And in a record pattern, if the type is inferable from context you can drop the type argument, e.g. case Box(var s) infers s as String. The right way to model "a list of a specific type" with patterns is sealed + record, not a generic cast.
No. In current Java a record is an ordinary object with identity: heap-allocated, has a header, and == on two records with equal contents returns false (always equals). Its immutability doesn't imply memory cheapness; in a hot loop you have the same allocation/GC pressure as a normal class. Project Valhalla is bringing true value classes (no identity, flattenable), but until then a record is just clean syntax. A record's saving is in code maintenance, not memory.
Hibernate needs a no-arg constructor for entities, mutable fields (for dirty checking and lazy loading), and the ability to build a proxy by subclassing; a record is final, has no no-arg constructor, and is immutable — it violates all three. Where it fits: outbound DTOs, JPQL constructor expressions (select new com.app.UserDto(u.id, u.name) ...), and Spring Data projections. Even @Embeddable support isn't fully stable. In short: a record is the data-transfer layer, not the persistence layer.
Dominance: if a more general pattern (e.g. case CharSequence c or an unguarded case Circle c) comes before a more specific or guarded one, the second is unreachable and the compiler rejects it — so ordering must go specific-to-general. Hidden default: in an exhaustive switch over a sealed type, even though you wrote no default in source, the compiler injects a synthetic branch that throws MatchException at run time — so that if separate compilation lets an unforeseen case arrive, you get a clear exception instead of undefined behavior.
_ (Java 22) is an unnamed binding: for when you don't need the value but the syntax forces you to write something. Unlike a normal name, multiple _ are allowed in one scope. Uses: an irrelevant component in a record pattern (case Point(var x, _)), a for-each loop variable when you're only counting, an unused lambda parameter, and catch (Exception _). It improves readability by telling the reader "this value is deliberately ignored."
- Java 8→21 added a second paradigm — data-oriented programming — next to OOP, with three co-designed pillars: records, sealed, pattern matching.
- Records are transparent, shallowly immutable bundles; the compiler gives you a constructor, accessors, and correct
equals/hashCode/toStringfor free. For real immutability, defensively copy. - Sealed closes a hierarchy's door so the compiler knows the full set of cases; that's what enables exhaustive
switchwith nodefault. - Pattern matching starts at
instanceofwith flow scoping and climbs to exhaustiveswitch, record patterns (destructuring), andwhenguards.nullstays hostile until you writecase null. - sealed + records + pattern switch = ADTs with compiler-verified totality: missing-branch bugs shift from runtime to compile time.
- Quiet tools: text blocks (multi-line, no interpolation),
var(static type inference, not dynamic typing), generics polish, and virtual threads (a passing flag). - Watch out: string templates were withdrawn — don't rely on them. As of Java 21, everything else here is final and production-ready.