Java Core · جاوا پایه پایهBeginner ~57 دقیقه مطالعه~48 min read
عمیق در OOP: کلاس انتزاعی در برابر اینترفیس و چهار ستونOOP Deep Dive: Abstract vs Interface & the Four Pillars
در این فصل چهار ستون OOP، تفاوت واقعی کلاس انتزاعی و اینترفیس، مسئلهی الماس، dispatch ایستا در برابر پویا، هموردایی، sealed و اصل لیسکوف را با تشبیههای ملموس و پانزده سؤال مصاحبهی حیلهای، از صفر تا سطح ارشد یاد میگیری.This chapter teaches the four pillars of OOP, the real difference between abstract classes and interfaces, the diamond problem, static versus dynamic dispatch, covariant returns, sealed types, and the Liskov principle—built from scratch with concrete analogies and fifteen tricky interview questions.
تو پنج سال است کد شیءگرا مینویسی، اما مصاحبهی ارشد جای دیگری است: آنجا نمیپرسند «وراثت چیست»، بلکه یک قطعه کد جلویت میگذارند و میگویند «دقیقاً چه چاپ میشود و چرا». این فصل قرار است تو را از همان جملههای ساده تا آن تلههای ظریف برساند — و مهمتر، کاری کند که چرایی هر رفتار را حس کنی، نه اینکه حفظش کنی.
بیایید قبل از هر چیز یک قطبنما بسازیم، چون تقریباً تمام تلههای این فصل دور یک محور میچرخند.
یک جمله را در ذهنت آتش بزن، بقیهی فصل نتیجهی مستقیم آن است: در جاوا دسترسی به فیلد، متدهای static، و انتخاب متد overload شده را کامپایلر بر اساس نوع «ایستا» (نوعی که در کد اعلام کردهای) حل میکند؛ اما فراخوانی متدهای نمونه (instance) را JVM در زمان اجرا بر اساس نوع «پویا» (نوع واقعیِ شیء) حل میکند. در ادامه میبینیم: چهار ستون OOP، کلاس انتزاعی در برابر اینترفیس، متدهای default/static/private، مسئلهی الماس، ترکیب بهجای وراثت، dispatch ایستا در برابر پویا، overloading در برابر overriding، بازگشت هموردا، final/sealed، اصل لیسکوف، و در پایان پانزده سؤال مصاحبهی حیلهای.
بخش ۰ — واژههایی که باید از قبل حس کنی
قبل از رفتن به دل بحث، سه واژه را بیجیموجرز باز کنیم، چون بقیهی فصل مدام از آنها استفاده میکند.
یک جعبهی پستی را تصور کن که رویش برچسب «کتاب» زدهای. این برچسب همان نوع ایستا (static type) است: چیزی که تو اعلام کردهای، همانی که کامپایلر با نگاه به کد میبیند. اما درون جعبه ممکن است واقعاً یک «رمان جیبی» باشد — این محتوای واقعی همان نوع پویا (dynamic type) است: چیزی که در زمان اجرا واقعاً آنجاست. وقتی مینویسی Animal a = new Dog();، برچسب Animal است ولی محتوای واقعی Dog است. تمام دعوای این فصل سرِ این است: کدام تصمیم با نگاه به «برچسب» گرفته میشود و کدام با باز کردن «جعبه».
- ناوردایی (invariant): قانونی که یک شیء در تمام طول عمرش باید نگه دارد؛ مثل «موجودی حساب هیچوقت منفی نمیشود». اگر شیء بگذارد این قانون بشکند، خراب است.
- اتصال/coupling: میزان وابستگی یک قطعهکد به جزئیات قطعهی دیگر. اتصال زیاد یعنی با تغییر یکی، دیگری هم میشکند.
- dispatch: یعنی «کدام پیادهسازیِ متد واقعاً اجرا شود». وقتی میگوییم dispatch «پویا» است یعنی این تصمیم در لحظهی اجرا گرفته میشود، و وقتی «ایستا» است یعنی سرِ کامپایل قفل شده.
چهار ستون
OOP در جاوا در بنیادش دربارهی این است که چه کسی چه چیزی را میداند و چه کسی در چه لحظهای تصمیم میگیرد. کپسولهسازی کنترل میکند چه کسی از وضعیت داخلی خبر دارد؛ انتزاع و چندریختی کنترل میکنند تصمیم چه زمانی گرفته شود. حالا ستونها را یکییکی میسازیم.
۱. کپسولهسازی (Encapsulation)
در سوپرمارکت هر کسی هر قوطی را از قفسه برمیدارد و در سبد میگذارد — هیچ محافظتی نیست. در داروخانه اما داروها پشت پیشخواناند؛ تو درخواست میدهی و داروساز بررسی میکند که نسخه معتبر باشد، بعد دارو را میدهد. کپسولهسازی یعنی همین پیشخوان: داده پشت آن است و هر تغییری باید از یک نقطهی کنترلشده رد شود.
کپسولهسازی یعنی بستهبندی داده با کدی که رویش عمل میکند و محدود کردن دسترسی مستقیم به داده، تا خودِ شیء ناورداییهایش را کنترل کند. این بههیچوجه یعنی «افزودن getter و setter» نیست — کلاسی که برای هر فیلد خصوصی یک setter عمومی دارد، در واقع یک struct مبدل است بدون هیچ محافظتی از ناوردایی. پیشخوانی که به همه اجازه میدهد هر دارویی را بینسخه ببرند، دیگر داروخانه نیست.
حالا به جاوا برگردیم:
public final class Money {
private final long cents; // ناوردایی: هیچ سنتِ کسریای وجود ندارد
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) // ناوردایی اینجا تضمین میشود
throw new IllegalArgumentException("currency mismatch");
return new Money(this.cents + other.cents, currency);
}
// بدون setter. تغییر وضعیت، نمونهی جدید تولید میکند.
}
نکته: فراخواننده نمیتواند یک Money را در وضعیت ناسازگار بسازد و نمیتواند آن را به وضعیت نامعتبر تغییر دهد. قانونِ «ارز باید یکسان باشد» دقیقاً در یک نقطه زندگی میکند. کپسولهسازی واقعی دربارهی محافظت از ناورداییها است و تغییرناپذیری (immutability) قویترین شکل آن است — چون چیزی که اصلاً تغییر نمیکند، هرگز نمیتواند به وضعیت خراب برود.
۲. وراثت (Inheritance)
وراثت مثل کارآموزی است که نهتنها مهارتهای استادش را میگیرد، بلکه عادتهای درونیاش را هم. اگر استاد در نهان روش خاصی برای انجام کاری داشته باشد و بعداً آن را عوض کند، کارآموز هم بیخبر تحتتأثیر قرار میگیرد. این نزدیکیِ زیاد، هم قدرت وراثت است و هم دام آن.
وراثت (extends) یک رابطهی is-a (هست-یک) را مدل میکند و پیادهسازی را به اشتراک میگذارد. جاوا وراثت تککلاسه دارد (فقط یک ابرکلاس) اما وراثت چندگانه از اینترفیس دارد. وراثت پرسوءاستفادهترین ستون است، چون قویترین اتصال زبان را میسازد: زیرکلاس به پیادهسازیِ والد وابسته میشود، نه فقط به قرارداد آن.
abstract class Shape {
abstract double area(); // زیرکلاس باید فراهم کند
String describe() { // پیادهسازی مشترک
return getClass().getSimpleName() + " area=" + area();
}
}
class Circle extends Shape {
private final double r;
Circle(double r) { this.r = r; }
@Override double area() { return Math.PI * r * r; }
}
اینجا Shape میگوید «هر شکلی مساحت دارد ولی من نمیدانم چطور حسابش کنم» (متد abstract)، اما متد مشترک describe() را برای همه فراهم میکند. Circle فقط بخش گمشده را پر میکند.
۳. چندریختی (Polymorphism)
واژهی «چندریختی» یعنی «چندشکلی»: یک نام، چند رفتار. اما در مصاحبه باید دو نوعِ کاملاً متفاوت آن را از هم جدا کنی، وگرنه گیر میافتی.
چندریختی زیرنوع (زمان اجرا): یک دکمهی «شروع» روی دستگاههای مختلف بزن — روی قهوهساز قهوه دم میکند، روی ماشین لباسشویی میشوید. یک فرمان، ولی رفتار به دستگاه واقعی بستگی دارد. این همان overriding است. چندریختی موقتی/ad-hoc (زمان کامپایل): کلمهی «شیر» را در نظر بگیر؛ معنیاش (حیوان، نوشیدنی، شیرِ آب) از روی جمله روشن میشود، پیش از هر اتفاقی. این همان overloading است: کامپایلر از روی نوعِ آرگومان تصمیم میگیرد.
- چندریختی زیرنوع (زمان اجرا): overriding. یک ارجاع از نوع
ShapeمتدCircle.area()را بر اساس شیء واقعیِ زمان اجرا فراخوانی میکند. این همان dispatch پویا است. - چندریختی موقتی (زمان کامپایل): overloading. اینکه کدام
print(x)اجرا شود، کامپایلر از روی نوع ایستای آرگومان انتخاب میکند.
Shape s = new Circle(2);
System.out.println(s.area()); // 12.56... — dispatch پویا به Circle
برچسبِ s نوعِ Shape است، اما JVM جعبه را باز میکند و میبیند واقعاً Circle است، پس area() نسخهی Circle را صدا میزند.
۴. انتزاع (Abstraction)
انتزاع یعنی نمایش چه کاری یک شیء انجام میدهد و پنهان کردن چگونگی آن. مثل پدال گاز ماشین: تو میدانی «فشار بده تا سریعتر برود»، ولی لازم نیست از انفجارهای درون موتور خبر داشته باشی. کلاس انتزاعی و اینترفیس دو ابزار این کارند — و باقی فصل عمدتاً دربارهی انتخاب هوشمندانه میان این دو است.
کلاس انتزاعی در برابر اینترفیس
یک کلاس انتزاعی مثل قالب کیکِ نیمهآماده است: بخشی از کیک از قبل شکل گرفته (متدهای concrete، فیلدهای مشترک) و تو فقط رویه را تمام میکنی. چون شکلِ پایه یکی است، فقط میتوانی از یک قالب استفاده کنی. یک اینترفیس مثل گواهینامهی مهارت است: «این فرد میتواند شنا کند»، «میتواند رانندگی کند». یک نفر میتواند چند گواهینامه داشته باشد، و این گواهینامهها چیزی دربارهی درونِ او نمیگویند، فقط قابلیتهایش را اعلام میکنند.
هر دو مکانیزم انتزاعاند؛ تفاوتشان در چیزی است که میتوانند حمل کنند و نحوهی ترکیبشدنشان. این جدول را با دقت بخوان، چون هر سطرش یک سؤال بالقوهی مصاحبه است:
| جنبه | کلاس انتزاعی | اینترفیس |
|---|---|---|
| قابل نمونهسازی | خیر | خیر |
| فیلدهای نمونه (state) | بله | خیر — فقط ثابتهای public static final |
| سازنده (constructor) | بله (از طریق super() زیرکلاس) |
خیر |
| وراثت چندگانه | خیر (تکابرکلاس) | بله (پیادهسازی چندتایی) |
| بدنهی متد | بله (concrete + abstract) | default، static، private از جاوا ۸/۹؛ در غیر اینصورت abstract |
| مُدیفایرهای دسترسی روی اعضا | هر کدام (public/protected/private/package) |
متدها بهطور ضمنی public (یا private برای کمکیها)؛ متد abstract با protected/package نداریم |
فیلد final/static غیرثابت |
بله | فیلدها همیشه public static final |
| نمایانگر | «is-a» + هویت/وضعیت مشترک | قابلیت «can-do» / قرارداد |
| ایمنی تکامل | افزودن متد concrete آزادانه | افزودن متد abstract پیادهسازها را میشکند؛ default راه فرار است |
| متدهای Object | میتوان equals/hashCode را abstract کرد تا override اجباری شود |
نمیتوان برای equals/hashCode/toString متد default داد (خطای کامپایل) |
چه زمانی کدام را انتخاب کنیم
- اینترفیس وقتی یک قابلیت را تعریف میکنی که انواع نامرتبط ممکن است داشته باشند (
Comparable،AutoCloseable،Serializable)، وقتی یک نوع باید در چند سلسلهمراتب شرکت کند، یا وقتی حداکثر جداسازی (decoupling) میخواهی («بهسمت اینترفیس برنامه بنویس»). - کلاس انتزاعی وقتی زیرنوعها واقعاً گونههای یک چیز واحداند، فیلد یا منطق سازنده یا وضعیت مشترک دارند، یا به کمکیهای
protectedو اسکلت template-method نیاز دارند. - قاعدهی سرانگشتی: پیشفرض را اینترفیس بگذار؛ فقط وقتی مجبوری وضعیتِ قابلتغییر یا پیادهسازیِ غیرعمومی به اشتراک بگذاری سراغ کلاس انتزاعی برو. بسیاری از طراحیها هر دو را با هم بهکار میبرند — یک
public interfaceبرای قرارداد و یکabstractسطح-package برای زیرساخت مشترک (بهAbstractListوAbstractMapدر JDK نگاه کن).
یک اینترفیس میتواند از چند اینترفیس ارث ببرد؛ یک کلاس میتواند چند اینترفیس پیادهسازی کند اما فقط از یک کلاس ارث ببرد. همین عدمتقارن، دلیل اصلیِ برتری اینترفیس برای طراحی انعطافپذیر است. تو فقط یک «قالب کیک» داری، ولی هر تعداد «گواهینامه» که بخواهی.
متدهای default، static و private در اینترفیس
اینجا یک معما هست: اگر اینترفیس فقط قرارداد است و بدنهی متد ندارد، پس چطور جاوا بعدها stream() را به Collection اضافه کرد بدون اینکه میلیونها کلاسِ موجود که Collection را پیاده کرده بودند بشکنند؟
تصور کن قراردادی با هزار پیمانکار داری و میخواهی بندی تازه اضافه کنی. اگر بگویی «همه باید این کار جدید را انجام دهند»، هزار قرارداد باطل میشود چون هیچکدام آن بند را ندارند. اما اگر بنویسی «این کار جدید، مگر آنکه پیمانکار خودش روش دیگری اعلام کند، بهطور پیشفرض اینطور انجام میشود»، هیچ قراردادی نمیشکند. متد default دقیقاً همان بندِ «بهطور پیشفرض» است.
جاوا ۸ متدهای default و static را به اینترفیس افزود تا پلتفرم بتواند اینترفیسها را تکامل دهد (مثل افزودن Collection.stream()) بدون شکستن هر پیادهساز. جاوا ۹ متدهای private (و private static) را افزود تا کد مشترک میان متدهای default به اشتراک گذاشته شود، بدون آنکه بیرون بزند و بخشی از قرارداد عمومی شود.
public interface Discount {
double rate(); // abstract
default double apply(double price) { // default — ارثبردنی
return price * (1 - normalized()); // فراخوانی کمکیِ private
}
static Discount none() { return () -> 0.0; } // static — ارث برده نمیشود
private double normalized() { // جاوا ۹+، کمکیِ پنهان
return Math.max(0, Math.min(1, rate()));
}
}
قواعد کلیدی که باید حفظ باشی:
- متدهای
staticاینترفیس ارث برده نمیشوند و باید روی خودِ اینترفیس فراخوانی شوند (Discount.none())، نه روی نمونهی پیادهساز یا زیراینترفیس. - متدهای
defaultارث برده میشوند و میتوانند override شوند. - یک متد
defaultنمیتواندfinal،staticیاabstractباشد؛ به وضعیت نمونه دسترسی ندارد (اینترفیس اصلاً وضعیتی ندارد) مگر از راه فراخوانی متدهای abstract.
مسئلهی الماس (Diamond) و حل آن
فرض کن دو رئیس داری و هر دو یک دستورالعمل همنام به تو دادهاند: رئیس A میگوید «سلام را اینطور بگو»، رئیس B میگوید «سلام را آنطور بگو». اگر کسی از تو بخواهد «سلام کن»، گیج میشوی — کدام؟ جاوا این سردرگمی را تحمل نمیکند: تو را مجبور میکند در زمان کامپایل صریحاً تعیین کنی از کدام رئیس پیروی میکنی.
ابهام کلاسیک وراثت چندگانه: دو ابرنوع، متد default یکسانی فراهم میکنند. جاوا این ابهام را در زمان کامپایل ممنوع میکند و تو را به حل صریح آن وامیدارد.
interface A { default String hello() { return "A"; } }
interface B { default String hello() { return "B"; } }
class C implements A, B {
// بدون این override خطای کامپایل:
// "class C inherits unrelated defaults for hello() from A and B"
@Override public String hello() {
return A.super.hello() + B.super.hello(); // رفع ابهام صریح
}
}
دقت کن به نحوِ خاص A.super.hello() — این تنها راهی است که میگویی «نسخهی رئیس A را میخواهم». قواعد حل، بهترتیب تقدم:
۱. کلاس برنده است: یک متد concrete یا abstract از یک ابرکلاس بر هر default اینترفیس غلبه میکند. («کلاس همیشه بر اینترفیس پیروز است.»)
۲. مشخصترین اینترفیس برنده است: اگر D extends A باشد و default را override کند، نسخهی D بر A غلبه میکند.
۳. در غیر اینصورت باید خودت override کنی و میتوانی با Interface.super.method() واگذاری (delegate) کنی.
هرگز ابهامِ خاموش وجود ندارد. برخلاف C++ که ممکن است بیسروصدا یک نسخه را انتخاب کند، جاوا در تعارضِ default کامپایل نمیشود تا وقتی تو تصمیم بگیری. «امنیت» اینجا یعنی «هیچ رفتار مبهمی از زیر دستت در نمیرود».
ترکیب بهجای وراثت (Composition over Inheritance)
با وراثت انگار با ابرکلاس ازدواج کردهای: به همهی عادتهای درونیاش گره خوردهای و اگر او فردا رفتار داخلیاش را عوض کند، زندگی تو هم بههم میریزد. با ترکیب (composition) انگار یک متخصص را استخدام کردهای: فقط از خدماتِ اعلامشدهاش استفاده میکنی و برایت مهم نیست پشت پرده چطور کارش را میکند. اگر روزی خواستی، یک متخصص دیگر میآوری. رابطه شل و قابلتعویض است.
وراثت تو را به پیادهسازیِ ابرکلاس گره میزند و کپسولهسازی را میشکند: override کردن یک متد میتواند بهطور خاموش متد دیگری را که آن را داخلی صدا میزند بشکند (مسئلهی «کلاس پایهی شکننده» / self-use). مثال کلاسیکِ جاش بلاک (Josh Bloch) زیرکلاس کردن HashSet برای شمردن درجهاست — اما addAll داخلاً add را صدا میزند، پس شمارش دوبرابر میشود:
// خراب: وراثت این واقعیت را که addAll داخلاً add را صدا میزند لو میدهد
class CountingSet<E> extends HashSet<E> {
int count = 0;
@Override public boolean add(E e) { count++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
count += c.size(); // سپس super.addAll دوباره add() را صدا میزند!
return super.addAll(c); // شمارش دوبرابر
}
}
اگر سه عنصر با addAll اضافه کنی، count میشود ۶ نه ۳: یکبار c.size() سهتا اضافه کرد، بعد super.addAll داخلاً سهبار add را صدا زد که هرکدام یکبار دیگر شمردند. حالا نسخهی درست با ترکیب:
// درست: ترکیب — واگذاری به نمونهی نگهداشته، وابسته فقط به قرارداد Set
final class CountingSet<E> implements Set<E> {
private final Set<E> delegate;
private int count = 0;
CountingSet(Set<E> delegate) { this.delegate = delegate; }
@Override public boolean add(E e) { count++; return delegate.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
count += c.size(); return delegate.addAll(c); // بدون نشت: addAll داخلیِ delegate مبهم است
}
public int count() { return count; }
// ... بقیهی متدهای Set را به delegate فوروارد کن
}
ترکیب به اینترفیس (Set) وابسته است، نه پیادهسازی؛ پس فراخوانیهای داخلیِ delegate نامرئیاند و نمیتوانند تو را بشکنند. وراثت را فقط وقتی ترجیح بده که رابطهی واقعیِ is-a وجود دارد و ابرکلاس عمداً برای توسعه طراحی و مستند شده باشد.
Dispatch ایستا در برابر پویا
حالا برمیگردیم به همان قطبنمای ابتدای فصل و دقیقترش میکنیم.
- Dispatch پویا (فراخوانی متد virtual): متدهای نمونه. JVM متد را در «vtable»ِ کلاسِ واقعیِ شیء در زمان اجرا جستجو میکند. (
vtableصرفاً جدولی است که JVM برای هر کلاس نگه میدارد و میگوید هر متد به کدام پیادهسازی اشاره میکند.) - Dispatch ایستا: متدهای
static،private،final(که override نمیشوند)، دسترسی به فیلد، و انتخاب overload. همه در زمان کامپایل از روی نوع ایستا حل میشوند.
class Animal { String noise() { return "?"; } static String kind() { return "Animal"; } }
class Dog extends Animal { @Override String noise() { return "woof"; } static String kind() { return "Dog"; } }
Animal a = new Dog();
System.out.println(a.noise()); // "woof" — پویا: نوع واقعی Dog
System.out.println(a.kind()); // "Animal" — ایستا: پنهان، از نوع ایستای Animal حل میشود
متدهای static پنهان (hidden) میشوند، نه override. a.kind() از روی نوعِ اعلامشدهی Animal حل میشود، پس "Animal" چاپ میشود — با اینکه شیء واقعاً Dog است. این یکی از رایجترین تلههای مصاحبه است؛ همیشه اول به برچسب (نوع ایستا) نگاه کن وقتی متد static یا فیلد میبینی.
Overloading در برابر Overriding
این دو واژه شبیهاند و همین شباهت، منبع بیشمار اشتباه است. جدول زیر تفاوتشان را مو به مو میگوید:
| Overloading | Overriding | |
|---|---|---|
| حل | زمان کامپایل (نوع ایستای آرگومانها) | زمان اجرا (نوع پویای گیرنده) |
| امضا | لیست پارامتر متفاوت | امضای یکسان |
| نوع بازگشتی | آزادانه متفاوت | یکسان یا زیرنوع هموردا (covariant) |
| دسترسی | هر کدام | یکسان یا بازتر (نمیتوان تنگتر کرد) |
throws |
هر کدام | استثناهای checked یکسان یا باریکتر |
@Override |
ندارد | توصیهشده؛ توسط کامپایلر بررسی میشود |
static |
قابل overload | متدهای static پنهان میشوند، نه override |
قواعدی که یک override کننده باید رعایت کند (به اینها میگویند «قواعد سازگاری امضا»):
- نوع بازگشتی: هموردا — میتواند زیرنوع برگرداند (از جاوا ۵).
- مُدیفایر دسترسی: نمیتواند محدودتر باشد (متد
publicرا نمیتوان بهصورتprotectedoverride کرد). - استثناهای checked: نمیتواند استثناهای checkedِ جدید یا وسیعتر پرتاب کند؛ اما میتواند کمتر/باریکتر یا unchecked پرتاب کند.
بازگشت هموردا (Covariant returns)
«هموردا (covariant)» یعنی «همجهت با سلسلهمراتب حرکت میکند»: چون Cat زیرنوعِ Animal است، override میتواند نوع بازگشتی را از Animal به Cat باریکتر کند.
class Animal { Animal reproduce() { return new Animal(); } }
class Cat extends Animal {
@Override Cat reproduce() { return new Cat(); } // بازگشت هموردا: Cat زیرنوع Animal
}
به همین دلیل است که override های clone() میتوانند نوع دقیق را برگردانند (بهجای Object خام) و سلسلهمراتبهای builder/fluent تمیز کامپایل میشوند.
final و sealed
- کلاس
final: نمیتوان از آن ارث برد (String،Integer). هم بهینهسازی را ممکن میکند و هم تضمین میدهد ناوردایی از راه override شکسته نشود. - متد
final: نمیتوان override کرد؛ فراخوانیاش بهصورت ایستا dispatch میشود. - فیلد
final: یکبار مقداردهی میشود؛ پایهی انتشار امن (safe publication) و تغییرناپذیری است. sealed(نهاییشده در جاوا ۱۷): یک کلاس یا اینترفیس با کلیدواژهیpermitsمحدود میکند که دقیقاً کدام انواع میتوانند از آن ارث ببرند یا پیادهسازیاش کنند. زیرنوعهای مجاز باید خودشانfinal،sealedیاnon-sealedباشند و باید در همان module باشند (یا همان package اگر در module بینام باشیم).
یک کلاس final مثل مهمانیای است که هیچکس دعوت نیست جز میزبان. یک کلاس معمولیِ باز مثل مهمانی درِ باز است: هر کسی میتواند وارد شود. یک کلاس sealed حدِ وسط طلایی است: یک فهرست مهمانِ مشخص داری (permits Circle, Rectangle) — نه بستهی کامل، نه بازِ کامل. و چون فهرست مهمان معلوم است، میتوانی برای «هر مهمانِ ممکن» برنامه بریزی و مطمئن باشی کسی جا نمانده.
public sealed interface Shape permits Circle, Rectangle {}
public final class Circle implements Shape { /* ... */ }
public non-sealed class Rectangle implements Shape {} // سلسلهمراتب را دوباره باز میکند
sealing به تو یک مجموعهی بسته از زیرنوعها میدهد که تطبیق الگوی switch جامع (exhaustive) را بدون شاخهی default ممکن میکند — چون کامپایلر همهی حالات ممکن را میشناسد. این ویژگیِ توانمندساز برای مدلسازیِ سبکِ «نوعدادهی جبری» (ADT) است و به کامپایلر اجازهی بررسی جامعیت را میدهد:
double area(Shape s) {
return switch (s) { // نیازی به default نیست — Shape مهروموم است
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
};
}
اگر بعداً کسی زیرنوع سومی به Shape اضافه کند، این switch دیگر کامپایل نمیشود و کامپایلر داد میزند که یک حالت را پوشش ندادهای. با شاخهی default این خطا خاموش میشد و باگ در زمان اجرا پیدا میشد. پس نبودِ default یعنی «کامپایلر نگهبانِ من است».
زاویهی لیسکوف (Liskov)
فرض کن یک باتری AA را با باتری AAِ دیگری عوض میکنی؛ انتظار داری دستگاه بدون هیچ تغییری کار کند. اصل لیسکوف همین است: هر زیرنوع باید بتواند بیسروصدا جای ابرنوع بنشیند و درستیِ برنامه دستنخورده بماند. اگر باتریِ جانشین شکلش درست باشد ولی ولتاژش فرق کند، از نظر سیستم نوع «جا میافتد» اما در عمل دستگاه را میسوزاند — این همان بخشِ رفتاری است که جاوا نمیتواند برایت چک کند.
اصل جانشینی لیسکوف (LSP): اشیای یک زیرنوع باید بتوانند جایگزین ابرنوع شوند بدون تغییر درستی برنامه. سیستم نوع جاوا بخشِ امضا را اجبار میکند (بازگشت هموردا، عدم دسترسی تنگتر، عدم استثنای checked وسیعتر) اما نمیتواند بخشِ رفتاری را اجبار کند — آن بخش با توست:
- پیششرطها (preconditions) نباید توسط زیرنوع تقویت شوند («بیش از والد نخواه»).
- پسشرطها (postconditions) نباید تضعیف شوند («کمتر از والد نده»).
- ناورداییهای ابرنوع باید حفظ شوند.
- قید تاریخچه (history constraint): زیرنوع نباید تغییرات وضعیتی را که ابرنوع منع میکند مجاز کند. (به همین دلیل زیرکلاسِ قابلتغییرِ
Point3DبرایPointتغییرناپذیر یک نقض کلاسیک است: والد قول داده بود «هرگز تغییر نمیکنم» و فرزند این قول را زیر پا گذاشت.)
نقض استاندارد، Square extends Rectangle است: اگر انتظار میرود Rectangle.setWidth ارتفاع را بدون تغییر بگذارد، یک Square که عرض و ارتفاع را بههم جفت میکند، هر کدی را که به آن پسشرط تکیه دارد میشکند. راهحل معمولاً ترکیب یا یک انتزاع مشترک است، نه وراثت.
تلهها و نکات ظریف رایج
- فراخوانی متد قابلoverride از داخل سازنده. در طول
super()، متدِ override کنندهی زیرکلاس پیش از مقداردهی فیلدهای زیرکلاس اجرا میشود — پسnull/0میبیند. هرگز متد قابلoverride را از سازنده صدا نزن. - «سایهاندازیِ» فیلد (shadowing) چندریخت نیست. فیلدها با نوع ایستا حل میشوند:
Animal a = new Dog(); a.nameازAnimal.nameاستفاده میکند. - متدهای
staticپنهان میشوند، نه override — بدون معنای@Override. - Overload + autoboxing + varargs: کامپایلر ابتدا (۱) تطبیق دقیق/widening، سپس (۲) boxing، سپس (۳) varargs را ترجیح میدهد. این میتواند بهطور خاموش تغییر دهد کدام overload اجرا شود.
equals/hashCodeرا نمیتوانdefaultکرد در اینترفیس.- افزودن متد abstract به اینترفیسِ منتشرشده همهی پیادهسازها را میشکند — برای تکامل امن از
defaultاستفاده کن. - فیلدهای
protectedدر پایهی abstract به هر زیرکلاس نشت میکنند و قراردادی گسترده و شکننده میسازند. فیلدprivateبههمراه accessorِ محافظتشده را ترجیح بده.
سؤالات مصاحبه
حالا وقت میدان است. هر سؤال را اول خودت جواب بده، بعد پاسخ را باز کن.
class A { int x = 1; int get() { return x; } }
class B extends A { int x = 2; int get() { return x; } }
A a = new B();
System.out.println(a.x + " " + a.get());
پاسخ: 1 2. عبارت a.x یک دسترسی به فیلد است → با نوع ایستای A حل میشود → 1. اما a.get() یک فراخوانی virtual است → dispatch پویا به B.get() → که B.x یعنی 2 را برمیگرداند. خلاصه: فیلدها سایه میاندازند (ایستا)، متدها override میشوند (پویا). همان جملهی قطبنمای فصل، مو به مو.
class Base { Base() { init(); } void init() { System.out.println("base"); } }
class Sub extends Base {
String msg = "hello";
@Override void init() { System.out.println(msg); }
}
new Sub();
پاسخ: null چاپ میکند. ترتیب اجرا: Sub() → super() (سازندهی Base) → این سازنده init() را صدا میزند که روی شیء Sub است پس نسخهی override شده اجرا میشود → اما msg هنوز مقداردهی نشده، چون مقداردهیِ فیلدهای زیرکلاس بعد از بازگشت super() اجرا میشود. پس msg برابر null است. درس: هرگز متد قابلoverride را از سازنده فراخوانی نکن.
پاسخ: وراثت چندگانهی نوع و رفتار (از راه default) امن است، چون اینترفیسها هیچ وضعیتِ نمونهای حمل نمیکنند؛ پس ابهامِ الماسی روی فیلدها وجود ندارد، و تعارضِ متد default با قواعد صریح حل میشود (کلاس برنده، مشخصترین اینترفیس برنده، وگرنه خودت باید override کنی). اما وراثت چندگانهی کلاس چیدمانِ فیلد و ترتیب سازندهی مبهم میساخت — دو والد با فیلدهای همنام، و معلوم نیست کدام سازنده اول اجرا شود.
پاسخ: کلاس انتزاعی را وقتی انتخاب کن که به وضعیت نمونه (فیلد قابلتغییر یا final)، منطق سازنده، کمکیهای غیرعمومیِ protected، یا یک سلسلهمراتبِ اکیدِ تک‑is‑a نیاز داری. اینترفیسها هنوز هم نمیتوانند وضعیت یا عضو نمونهی غیرعمومی نگه دارند. در غیر اینصورت اینترفیس را برای انعطافِ وراثت چندگانه و جداسازی ترجیح بده.
پاسخ: پیادهسازیِ default یک ابراینترفیسِ مشخص را فراخوانی میکند. وقتی لازم است که کلاس، defaultهای متعارض را از چند اینترفیس ارث میبرد (الماس) و باید رفع ابهام کند، یا وقتی یک default را override میکنی اما همچنان میخواهی نسخهی اصلی را هم صدا بزنی.
void f(Object o) { System.out.println("Object"); }
void f(int... i) { System.out.println("varargs"); }
f(1);
پاسخ: Object. حل overload سه فاز دارد. فاز ۱ (بدون boxing و بدون varargs): int بدون boxing زیرنوعِ Object نیست، پس شکست میخورد. فاز ۲ (boxing مجاز): int به Integer جعبه میشود و Integer یک Object است → f(Object) تطبیق مییابد. varargs (فاز ۳) فقط اگر فازهای ۱–۲ چیزی نیابند امتحان میشود. چون boxing در فاز ۲ جواب داد، هرگز به varargs نمیرسیم. پس Object چاپ میشود. قاعدهی طلایی: varargs همیشه آخرین چاره است.
پاسخ: خیر. override کننده میتواند استثناهای checkedِ یکسان، کمتر، یا باریکتر پرتاب کند، یا هر استثنای unchecked (runtime) — اما هرگز استثنای checkedِ جدید یا وسیعتر. این کار جانشینی را برای فراخوانندگانی که بر اساس امضای والد بلوک catch نوشتهاند حفظ میکند؛ وگرنه استثنایی از دستشان در میرفت که انتظارش را نداشتند.
پاسخ: یک override میتواند زیرنوعی از نوع بازگشتیِ والد را برگرداند (از جاوا ۵). مثالهای تمیز: الگوهای builder، و Cat reproduce() که Animal reproduce() را override میکند. همچنین override های clone() معمولاً نوع بازگشتیِ اعلامشدهی Object را به کلاسِ دقیق باریک میکنند تا فراخواننده مجبور به cast نباشد.
interface I { default String who() { return "I"; } }
class P { public String who() { return "P"; } }
class Q extends P implements I { }
new Q().who();
پاسخ: "P". کلاس همیشه بر defaultِ اینترفیس پیروز است. با اینکه I متد who() را فراهم میکند، متدِ concreteِ ارثبرده از ابرکلاس P تقدم دارد و هیچ خطای کامپایلی هم رخ نمیدهد (برخلاف الماسِ دواینترفیس که خطا میداد). این همان قاعدهی «کلاس برنده است» است.
پاسخ: عموماً نه. اگر Rectangle متدهای مستقلِ setWidth/setHeight را با پسشرطِ «تنظیم عرض، ارتفاع را بیتغییر میگذارد» ارائه کند، یک Square که این دو را جفت میکند آن پسشرط را تضعیف میکند و کلاینتهای متکی به آن را میشکند — یک نقضِ LSP. انتزاعِ مشترکِ تغییرناپذیر یا ترکیب را ترجیح بده. (اگر هر دو تغییرناپذیر و بدون setter مستقل باشند، این طراحی میتواند قابلقبول باشد.)
پاسخ: خطای کامپایل است: اینترفیسها نمیتوانند برای متدهای Object (equals، hashCode، toString) متد default بدهند. دلیل: هر کلاس اینها را از Object ارث میبرد و طبق قاعدهی «کلاس برنده است» نسخهی Object همیشه غلبه میکند، پس آن default هرگز اجرا نمیشد و کدِ مرده بود — زبان برای جلوگیری از سردرگمی آن را ممنوع کرده.
پاسخ: sealed (جاوا ۱۷) مجموعهی زیرنوعهای مجاز را به فهرستی شناختهشده و قابلرؤیت برای کامپایلر محدود میکند. برخلاف final همچنان یک سلسلهمراتبِ ثابت را مجاز میکند؛ برخلاف کلاس انتزاعیِ باز، توسعهی دلخواه را منع میکند. این کار تطبیق الگوی switch جامع بدون default، تکاملِ امنترِ API، و مدلسازیِ ADT را ممکن میکند. زیرنوعهای مجاز باید final، sealed یا non-sealed باشند.
class A { static String s() { return "A"; } }
class B extends A { static String s() { return "B"; } }
A ref = new B();
System.out.println(ref.s());
پاسخ: "A". متدهای static پنهان میشوند، نه override؛ ref.s() در زمان کامپایل از روی نوع ایستای A حل میشود. (فراخوانی یک متد static از راه یک ارجاعِ نمونه از نظر قانونی مجاز است اما نامطلوب، دقیقاً چون همین رفتار را پنهان میکند و خواننده را گمراه میکند.)
پاسخ: لزوماً نه. کپسولهسازی دربارهی محافظت از ناورداییها است، نه صرفاً پنهان کردنِ فیلدها پشتِ accessor. یک مجموعهی کامل از setterهای عمومی، هر انتقالِ وضعیت را افشا میکند و به فراخواننده اجازه میدهد وضعیت نامعتبر بسازد — این یک «کیسهی دادهی قابلتغییر» است، نه یک شیءِ کپسوله. کپسولهسازیِ واقعی انتقالها را اعتبارسنجی میکند، ناوردایی را در یک نقطه نگه میدارد، و اغلب تغییرناپذیری را ترجیح میدهد.
پاسخ: هیچکدام. متدهای private اصلاً ارث برده نمیشوند، پس یک متد با امضای یکسان در زیرکلاس، یک متدِ جدید و نامرتبط است (بدون چندریختی — dispatch ایستا). متدهای final ارث برده میشوند اما نمیتوان override کرد. بنابراین هر دو بهصورت ایستا bind میشوند، و همین دلیلِ آن است که کامپایلر/JIT میتواند آنها را تهاجمی inline کند (چون از قبل میداند دقیقاً کدام کد اجرا میشود).
نکاتِ سنیور و موارد پیشرفته
فصل بالا مکانیک را به تو داد: نوع ایستا در برابر پویا، چهار ستون، الماس، لیسکوف. سطح سنیور جایی است که همان مکانیک با دلِ JVM، با تکاملِ چندسالهی کتابخانهها، و با جاوای مدرن (records، pattern matching، ADT مبتنی بر sealed) برخورد میکند. هرچه اینجاست افزودنی است — همان تلهها و حقایقی که پاسِ اول عمداً روی میز جا گذاشت.
نُه چیزی که پاسِ اول لمسشان نکرد: (۱) records بهعنوان جایگزین واقعیِ کلاسهای immutableِ دستنویس؛ (۲) ناممکنبودنِ equals + وراثت — چرا واقعاً نمیتوانی هر دو را درست انجام دهی؛ (۳) اینکه dispatch واقعاً در سطح bytecode و JIT چطور کار میکند (vtable/itable، call site از monomorphic تا megamorphic)؛ (۴) متدهای پل (bridge) ناشی از erasure و بازگشت هموردا؛ (۵) تلهی override با دسترسی package-private؛ (۶) بازانتزاع (re-abstraction) و خطرِ خاموشِ «کلاس برنده است» وقتی یک override تصادفی رخ میدهد؛ (۷) pattern matching در switch بهصورت عمیق (record patterns، نگهبانها، dominance، null، MatchException)؛ (۸) ترتیب مقداردهی اولیه + انتشار امن زیر مدل حافظهی جاوا؛ (۹) کلاسهای value-based و افقِ Valhalla.
۱. records — همان چیزی که Money واقعاً دنبالش بود
آن کلاس Money که در فصل دستی نوشتیم (فیلدهای final، بدون setter، equals که باید خودت بنویسی) دقیقاً الگویی است که جاوا ۱۶ آن را به یک کلمهکلیدی تبدیل کرد: record. یک record یک حاملِ دادهی تغییرناپذیر است که کامپایلر برایش سازندهی canonical، accessorها، و equals/hashCode/toString سازگار با هم را خودکار میسازد.
public record Money(long cents, String currency) {
public Money { // سازندهی compact — بدنهی canonical
Objects.requireNonNull(currency);
if (cents < 0) throw new IllegalArgumentException("negative");
// اینجا نیازی به this.cents = cents نیست؛ کامپایلر خودش انجام میدهد
}
public Money plus(Money o) {
if (!currency.equals(o.currency)) throw new IllegalArgumentException("mismatch");
return new Money(cents + o.cents, currency);
}
}
نکتهی سنیور که خیلیها نمیدانند: record برای ناورداییها امنتر از یک کلاس معمولیِ Serializable است. در دیسریالایز کردنِ یک کلاس عادی، JVM شیء را بدون فراخوانی سازنده میسازد و بایتها را مستقیم روی فیلدها میریزد — پس مهاجم میتواند یک شیء «ناممکن» (مثلاً cents = -100) بسازد که هیچوقت از سازنده رد نمیشد. اما دیسریالایزِ record همیشه از سازندهی canonical عبور میکند، پس همان if (cents < 0) دوباره اجرا میشود. این یک بردِ امنیتیِ واقعی است.
record فقط final بودنِ ارجاع را تضمین میکند، نه تغییرناپذیریِ چیزی که ارجاع به آن اشاره دارد. اگر یک کامپوننتِ قابلتغییر داری (مثل int[] یا List)، accessor تولیدشده همان ارجاع درونی را برمیگرداند و بیرون میتواند آن را دستکاری کند. باید هم در سازندهی compact و هم در accessor کپیِ دفاعی بگذاری:
public record Trade(String sym, int[] lots) {
public Trade { // کپی هنگام ورود
lots = lots.clone();
}
public int[] lots() { // کپی هنگام خروج
return lots.clone();
}
}
record نمیتواند از کلاس دیگری extends کند (همیشه بهطور ضمنی extends Record است و final)، اما میتواند اینترفیس پیادهسازی کند. همین باعث میشود record + sealed = مدلسازیِ نوع دادهی جبری (ADT): یک sealed interface و چند record بهعنوان زیرنوع، دقیقاً همان الگویی که در فصل با Shape/Circle/Rectangle دیدی، ولی حالا هر case یک حاملِ دادهٔ کاملِ خودکار است.
۲. ناممکنبودنِ equals + وراثت (Effective Java، آیتم ۱۰)
فصل گفت equals را در اینترفیس نمیتوان default کرد. اما تلهی عمیقتر و کلاسیکترِ مصاحبه این است: نمیتوانی یک کلاسِ قابلنمونهسازی (instantiable) را extends کنی، یک «مؤلفهی ارزشی» جدید اضافه کنی، و در همان حال قراردادِ equals را هم سالم نگه داری. این یک ترجیح سلیقهای نیست؛ یک ناممکنیِ ریاضی است.
فرض کن Point(x, y) با equals مبتنی بر instanceof داری، و ColorPoint(x, y, color) را از آن extends میکنی:
- اگر
ColorPoint.equalsرنگ را هم چک کند، تقارن (symmetry) میشکند:point.equals(colorPoint)میشودtrue(چونPoint.equalsرنگ نمیبیند) ولیcolorPoint.equals(point)میشودfalse. - اگر برای رفعِ تقارن،
ColorPoint.equalsوقتی طرف مقابل صرفاًPointاست رنگ را نادیده بگیرد، تراگذری (transitivity) میشکند: دوColorPointقرمز و آبی هر دو با یکPointسفید مساوی میشوند ولی با هم مساوی نیستند.
راهحلِ «getClass() != obj.getClass() بهجای instanceof» تقارن و تراگذری را حفظ میکند، اما لیسکوف را میشکند: حالا یک زیرکلاسِ کاملاً بیضرر (که فقط رفتار اضافه میکند، نه فیلد) دیگر با نمونهی والدش مساوی نیست، و کدی مثل HashSet<Point>.contains(subInstance) خراب میشود. جمعبندی: با وراثت، یا تقارن/تراگذری را قربانی میکنی یا لیسکوف را. راهِ فرار، ترکیب است: ColorPoint یک Point را بهعنوان فیلد نگه میدارد، نه اینکه از آن ارث ببرد.
به همین دلیل record امن است: record ذاتاً final است، پس اصلاً امکانِ ساختنِ زیرنوعی که مؤلفهی ارزشی اضافه کند وجود ندارد و کل این تله بیموضوع میشود.
۳. dispatch در واقعیت: در سطح bytecode و JIT
فصل گفت instance methodها «پویا» و بقیه «ایستا» dispatch میشوند. سنیورِ واقعی میداند این چهار (بعلاوه یک) opcode چه هستند:
| فراخوانی | opcode | معنی |
|---|---|---|
متد static |
invokestatic |
بدون گیرنده، در زمان لینک قفل |
private، سازنده، super.m() |
invokespecial |
دقیقاً یک هدف، بدون جستوجوی پویا |
| متد معمولیِ کلاس | invokevirtual |
جستوجو در vtableِ نوع واقعی |
| متد از طریق ارجاعِ اینترفیس | invokeinterface |
جستوجو در itable (گرانتر) |
| lambda / method reference | invokedynamic |
لینک تنبل، یک بار توسط LambdaMetafactory |
نکتهی مهم: فراخوانی از طریق اینترفیس (invokeinterface) ذاتاً از فراخوانی از طریق کلاس (invokevirtual) گرانتر است، چون یک کلاس میتواند چند اینترفیس را در چیدمانِ متفاوت پیاده کند، پس یک vtableِ ساده با ایندکسِ ثابت کافی نیست و JVM باید سراغ itable برود. در حلقههای داغ این تفاوت اندازهگیریشدنی است.
JIT هر call site را رصد میکند و برایش «حافظهی درونخطی (inline cache)» میسازد:
- monomorphic: همیشه یک نوعِ واقعی دیده — JIT متد را inline میکند، انگار اصلاً فراخوانیِ مجازی نبود.
- bimorphic: دو نوع — با یک
ifهندل میشود. - megamorphic: چند نوعِ زیاد — inline cache شکست میخورد و برمیگردد به vtable lookupِ کامل؛ inline از بین میرود و کد کند میشود.
وقتی کلاس یا متد final است، یا نوع sealed است، JIT با تحلیل سلسلهمراتب کلاس (CHA) ثابت میکند فقط یک پیادهسازی ممکن است، پس devirtualize میکند: فراخوانیِ مجازی را به فراخوانیِ مستقیمِ inlineشده تبدیل میکند. این یکی از دلایلِ واقعیِ عملکردی برای final/sealed است، نه فقط «تمیزیِ طراحی».
نمودار وضعیتهای یک call site — چرخهی زندگیِ یک فراخوانیِ مجازی از دید JIT:
stateDiagram-v2
[*] --> Unlinked
Unlinked --> Monomorphic: first type seen
Monomorphic --> Bimorphic: 2nd type seen
Bimorphic --> Megamorphic: 3rd+ types
Monomorphic --> Inlined: JIT + CHA
Megamorphic --> VtableLookup: cache busted
۴. متدهای پل (bridge methods)
این را در javap میبینی ولی هرگز در سورس ننوشتهای. بهخاطر erasureِ جنریکها، کامپایلر گاهی یک متدِ مصنوعی (synthetic) بهنام bridge میسازد تا چندریختی سالم بماند:
class Node<T> { public void set(T t) { } }
class IntNode extends Node<Integer> {
public void set(Integer i) { } // فکر میکنی override است
}
پس از erasure، امضای والد set(Object) میشود ولی فرزند set(Integer) دارد — اگر همین بماند، Node<Integer> n = new IntNode(); n.set(5); به override نمیرسید. پس کامپایلر یک متدِ پلِ مصنوعی set(Object o) { set((Integer)o); } در IntNode میسازد. بازگشتِ هموردا هم دقیقاً همینطور یک پل میسازد (نسخهای که Object برمیگرداند و به نسخهی واقعی delegate میکند).
چون این متدها واقعیاند، getClass().getMethods() آنها را هم برمیگرداند. اگر با reflection روی متدها حلقه بزنی، ممکن است یک متد را دوبار ببینی. با Method.isBridge() یا isSynthetic() فیلترشان کن. همچنین چون پل با (Integer) o کست میکند، پاسدادنِ نوع اشتباه از طریق ارجاعِ raw یک ClassCastException در جایی میدهد که در سورس اصلاً کستی ننوشتهای.
۵. تلهی override با دسترسی package-private
فصل گفت مُدیفایرِ دسترسی هنگام override نمیتواند تنگتر شود. اما تلهی خطرناکتر این است: یک متدِ package-private (بدون مُدیفایر) اصلاً توسط زیرکلاسی در پکیجِ متفاوت override نمیشود — حتی اگر امضا موبهمو یکی باشد. آنها دو متدِ کاملاً جدا هستند، و dispatch پویا میانشان اتفاق نمیافتد.
// package a
public class Base {
void hook() { System.out.println("base"); } // package-private
public void run() { hook(); }
}
// package b
public class Sub extends a.Base {
void hook() { System.out.println("sub"); } // NOT an override!
}
فراخوانیِ new b.Sub().run() چاپ میکند base، نه sub — چون Base.run() به Base.hook() بایند شده و Sub.hook() که در پکیجِ دیگری است اصلاً همان متد بهشمار نمیرود. اگر روی Sub.hook() بنویسی @Override، کامپایل نمیشود. این باگ در پترنِ template-method میان پکیجها (یا در تستهایی که میخواهند یک hook را override کنند) واقعاً آدمها را میسوزاند.
۶. بازانتزاع و خطرِ خاموشِ «کلاس برنده است»
دو موردِ ظریف که ادامهی مستقیمِ بحثِ default methodها هستند:
بازانتزاع (re-abstraction): یک زیراینترفیس میتواند یک متدِ default والد را دوباره abstract اعلام کند و پیادهسازی را از پیادهسازها بگیرد و آنها را مجبور به بازنویسی کند:
interface A { default String id() { return "A"; } }
interface B extends A { String id(); } // دوباره abstract شد — هرکس B را پیاده کند باید id بدهد
قاعدهی «کلاس همیشه بر اینترفیس برنده است» معمولاً امن است، ولی یک حالتِ خطرناکِ تکاملی دارد. فرض کن کتابخانهای اینترفیسی به تو میدهد که بعداً یک متدِ default جدید به آن اضافه میکند — مثلاً default int size(). اگر ابرکلاسِ بیربطِ تو از قبل یک public int size() با معنایی کاملاً متفاوت داشته باشد، طبق قاعدهی «کلاس برنده است»، متدِ ابرکلاسِ تو بهطور خاموش جای default اینترفیس را میگیرد و default هرگز اجرا نمیشود — بدون هیچ خطای کامپایل. این نوع باگ در ارتقای نسخهی کتابخانهها پیش میآید و کشفش سخت است.
۷. pattern matching در switch، عمیق (جاوا ۲۱)
فصل نشان داد که sealed باعث میشود switch بدون default جامع (exhaustive) شود. جاوا ۲۱ این را بسیار جلوتر برد: الگوهای واکاوی record (record deconstruction) و نگهبانها (guarded patterns) با when.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 100 -> "huge circle"; // نگهبان
case Circle(double r) -> "circle " + r; // واکاویِ مستقیمِ کامپوننت
case Rectangle(double w, double h) -> "rect " + (w * h);
}; // بدون default — جامع است
}
- ترتیب/غلبه (dominance): اگر یک الگو الگوی بعدی را «میپوشاند» (مثلاً
case Circle cرا قبل ازcase Circle(double r) when ...بگذاری)، کامپایل نمیشود — برخلافif/elseِ عادی که فقط باگ میداد. کامپایلر ترتیب را تحمیل میکند. - null: برخلاف switchِ سنتی که روی
nullمقدارِNullPointerExceptionمیداد، در pattern switch اگرcase nullنگذاری، ورودیِnullباز هم NPE میدهد؛ اگر میخواهی هندلش کنی باید صریحcase null ->بنویسی (یاcase null, default). MatchExceptionدر زمان اجرا: یک switchِ جامع رویsealedکه بدونdefaultکامپایل شده، اگر سلسلهمراتب بعداً تغییر کند و کلاسِ switch دوباره کامپایل نشود (compilation جداگانه)، در زمان اجرا مقداری میرسد که هیچ caseای مطابقش نیست وMatchExceptionپرتاب میشود. یعنی جامعبودنِ زمانِ کامپایل، تضمینِ زمانِ اجرا نیست اگر jarها ناهماهنگ باشند.
۸. ترتیب مقداردهی اولیه و انتشار امن
فصل تلهی «فراخوانیِ متدِ overridable از سازنده» را نشان داد. ترتیبِ کاملی که پشت آن است، خودش یک سؤالِ مصاحبه است. برای new Sub():
۱) بلوکها/فیلدهای static (اول والد، بعد فرزند) — فقط یکبار در کل عمر برنامه، هنگام لود کلاس.
۲) super(...) تا بالای زنجیره؛ در هر سطح: initializerهای نمونه و بلوکهای instance آن سطح، سپس بدنهی سازندهی آن سطح.
۳) سپس initializerهای فیلدِ فرزند و بلوکهای instanceِ فرزند اجرا میشوند — و بعد بدنهی سازندهی فرزند. برای همین بود که در تلهی سازنده، msg هنوز null بود.
مدل حافظهی جاوا (JMM) یک ضمانتِ ویژه به فیلدهای final میدهد: اگر یک شیء ناوردا (فقط فیلدهای final) را بسازی و ارجاعش را منتشر کنی، هر thread دیگری که آن ارجاع را ببیند، حتماً مقادیرِ کاملِ آن فیلدهای final را میبیند — بدون نیاز به synchronized یا volatile. این «انتشار امن (safe publication)» است. اما این تضمین میشکند اگر ارجاعِ this را از داخلِ سازنده بیرون بدهی (مثلاً this را در یک لیستِ استاتیک ثبت کنی) قبل از تمامشدنِ سازنده — چون هنوز فیلدهای final منجمد (frozen) نشدهاند. پس «immutable = thread-safe رایگان» فقط وقتی درست است که this از سازنده فرار نکند.
۹. کلاسهای value-based و افقِ Valhalla
جاوا انواعی دارد که با @jdk.internal.ValueBased علامت خوردهاند: wrapperها (Integer, Long…)، Optional, و اکثرِ انواعِ java.time مثل LocalDate. اینها هویت (identity) ندارند؛ فقط ارزششان مهم است و باید با .equals() مقایسه شوند.
==روی wrapperها:Integer a = 127, b = 127; a == bمیشودtrue(کش-128..127) ولی با128میشودfalse. این چندریختیِ خطرناک نیست، فقط identity است — همیشهequalsبزن.synchronizedروی value-based ممنوع است: روی یکInteger,Optional, یاLocalDateقفل نگذار (synchronized(myInteger)). هویتِ این اشیا تضمینشده نیست و در آیندهی Valhalla ممکن است اصلاً شیءِ heap نداشته باشند؛ javac و JIT برایش هشدار میدهند و رفتار میتواند خراب شود. پروژهی Valhalla قصد دارد اینها به «value class» تبدیل شوند که مثلintبدون سربارِ شیء و بدون هویت کار کنند.
سؤالات مصاحبهی سنیور
پاسخ: چون بینِ سه شرطِ قرارداد گیر میکنی. اگر equalsِ زیرکلاس مؤلفهی جدید (رنگ) را چک کند، تقارن میشکند (point.equals(colorPoint) میشود true ولی برعکسش false، چون والد رنگ نمیبیند). اگر برای رفعِ آن، وقتی طرف مقابل فقط والد است رنگ را نادیده بگیری، تراگذری میشکند. اگر سراغ getClass() بهجای instanceof بروی، تقارن/تراگذری حفظ میشود ولی لیسکوف میشکند (زیرنوعِ بیضرر دیگر با والد مساوی نیست). این یک ناممکنیِ ساختاری است؛ راهحل ترکیب (نگهداشتنِ والد بهعنوان فیلد) است. و دقیقاً برای همین record ذاتاً final است.
// package a
public class Base { void hook(){System.out.print("base");} public void run(){hook();} }
// package b
public class Sub extends a.Base { void hook(){System.out.print("sub");} }
// جای دیگر: new b.Sub().run();
پاسخ: base. متدِ hook در Base دسترسیِ package-private دارد، پس Sub.hook() که در پکیجِ متفاوتی است اصلاً آن را override نمیکند — دو متدِ جدا هستند. Base.run() به Base.hook() بایند میماند. اگر روی Sub.hook() عبارتِ @Override بگذاری، کامپایل نمیشود. درسِ سنیور: hookهای template-method را همیشه protected یا public کن، نه package-private، وگرنه override میانپکیجی خاموش شکست میخورد.
پاسخ: یک متدِ مصنوعیِ synthetic که کامپایلر برای حفظِ چندریختی میسازد، در دو حالت: (الف) erasureِ جنریکها — وقتی IntNode extends Node<Integer> متد set(Integer) را override میکند، ولی امضای eraseشدهی والد set(Object) است، کامپایلر set(Object o){ set((Integer)o); } را میسازد تا فراخوانی از طریقِ Node واقعاً به override برسد. (ب) بازگشتِ هموردا — یک پل که نسخهی با بازگشتِ نوعِ والد را به نسخهی باریکشده delegate میکند. این متدها در getMethods() reflection ظاهر میشوند؛ با Method.isBridge() فیلترشان کن. همچنین میتوانند در استفادهی نادرست از rawها یک ClassCastException غیرمنتظره بدهند.
sealed interface S permits X, Y {} record X(int a) implements S {} record Y(int b) implements S {}
String f(S s){ return switch(s){ case X x -> "x"; case X(int a) when a>0 -> "pos"; case Y y -> "y"; }; }
پاسخ: خطای dominance: case X x هر نمونهی X را میگیرد، پس case X(int a) when a>0 که بعد از آن آمده هرگز قابلدسترس نیست و کامپایلر خطا میدهد. باید الگوی خاصتر/نگهباندار قبل از الگوی کلی بیاید:
case X(int a) when a>0 -> "pos";
case X x -> "x";
برخلافِ زنجیرهی if/else که فقط بیسروصدا باگ میداد، pattern switch ترتیبِ درست را در زمانِ کامپایل تحمیل میکند.
پاسخ: با MatchException. جامعبودن در زمان کامپایل بررسی میشود بر اساس زیرنوعهای شناختهشدهی آن لحظه. اگر بعداً یک زیرنوعِ جدید به سلسلهمراتبِ sealed (در یک jarِ دیگر) اضافه شود و کلاسِ حاویِ switch دوباره کامپایل نشود (separate compilation)، در زمان اجرا نمونهای میرسد که با هیچ caseای مطابق نیست، و JVM MatchException پرتاب میکند. درس: سازگاریِ زمانِ کامپایل تضمینِ زمانِ اجرا نیست وقتی artifactها ناهماهنگ deploy شوند.
پاسخ: مدل حافظهی جاوا به فیلدهای final یک تضمینِ ویژه میدهد: پس از پایانِ سازنده، مقادیرِ فیلدهای final «منجمد (frozen)» میشوند و هر thread که ارجاعِ شیء را ببیند حتماً مقادیرِ کاملِ آنها را میبیند — این «انتشار امن» بدون synchronized/volatile است. اما دو شرط دارد: (۱) همهی فیلدهای وضعیت باید final باشند؛ یک فیلدِ غیر-final میتواند برای threadهای دیگر «قدیمی (stale)» دیده شود. (۲) this نباید از سازنده فرار کند (مثلاً ثبتِ this در یک listener یا لیستِ استاتیک درونِ سازنده) — چون قبل از انجماد منتشر شده و threadِ دیگر میتواند شیءِ نیمهساخته ببیند. پس immutabilityِ سالم + عدمِ فرارِ this = thread-safe رایگان.
پاسخ: اینها کلاسهای value-based هستند (@jdk.internal.ValueBased)؛ هویتشان تضمینشده نیست. دو مرجعِ بهظاهر مختلف میتوانند در واقع یک شیء باشند (کشِ Integer در بازهی -128..127)، یا در آیندهی Valhalla اصلاً شیءِ heapِ مستقلی نداشته باشند. قفلگذاری بر چیزی که هویتش تضمین نشده یعنی ممکن است ناخواسته روی شیءِ مشترک قفل بگذاری (deadlock/contention) یا رفتار در آینده بشکند. javac و JIT برای این کار هشدار میدهند. همیشه روی یک شیءِ اختصاصیِ private final Object lock = new Object(); قفل بگذار.
- record: جایگزینِ مدرنِ کلاس immutable با سازندهی canonical/compact،
equals/hashCodeخودکار،finalذاتی و امنِ سریالایز؛ باsealedیک ADT کامل. مراقبِ کپیِ دفاعیِ کامپوننتِ قابلتغییر باش. - equals + وراثت + مؤلفهی جدید با هم ناممکناند (تقارن/تراگذری در برابر لیسکوف)؛ راهِ فرار ترکیب است و
finalبودنِ record کل تله را حذف میکند. - dispatch در سطحِ bytecode:
invokestatic/special/virtual/interface/dynamic؛invokeinterfaceگرانتر است، وfinal/sealedبه JIT اجازهی devirtualization میدهند. - bridge methodها در reflection ظاهر میشوند (با
isBridge()فیلتر کن)؛ package-private میان پکیجها override نمیشود (hookها راprotectedکن). - pattern switch: مراقبِ dominance،
nullوMatchExceptionِ زمانِ اجرا باش. final در JMM انتشارِ امن میدهد اگرthisاز سازنده فرار نکند. روی کلاسهای value-based قفل نگذار و با==مقایسه نکن.
- یک جمله همهچیز را توضیح میدهد: فیلد،
static، و انتخابِ overload با نوعِ ایستا (برچسب) حل میشوند؛ متدهای نمونه با نوعِ پویا (محتوای جعبه). - چهار ستون: کپسولهسازی = محافظت از ناوردایی (نه صرفاً getter/setter)؛ وراثت = is‑a با اتصالِ سفت؛ چندریختی = دو نوعِ متمایز (override پویا، overload ایستا)؛ انتزاع = چه، نه چگونه.
- کلاس انتزاعی در برابر اینترفیس: اینترفیس برای قابلیت و چندسلسلهمراتبی و جداسازی؛ کلاس انتزاعی برای وضعیت/سازنده/کمکیِ
protected. پیشفرض را اینترفیس بگذار. default/static/private(جاوا ۸/۹) اینترفیسها را قابلتکامل کردند؛ الماس با قواعدِ «کلاس برنده / مشخصترین اینترفیس / وگرنه خودت override کن» و نحوِA.super.m()حل میشود.- ترکیب را بر وراثت ترجیح بده مگر is‑a واقعی و طراحیشده برای توسعه.
final/sealed(جاوا ۱۷) هویتِ بسته میسازند وswitchجامع را ممکن میکنند؛ لیسکوف بخشِ رفتاری را که کامپایلر چک نمیکند بر دوش تو میگذارد.- تلهها: فراخوانیِ متد قابلoverride از سازنده، سایهاندازیِ فیلد، پنهانشدنِ static، و boxing که بر varargs غلبه میکند.
You have written object-oriented code for five years, but a senior interview is a different arena: they will not ask "what is inheritance." They will drop a snippet in front of you and say "exactly what prints, and why." This chapter takes you from the plain sentences all the way to those subtle traps — and, more importantly, makes you feel the reason behind each behavior instead of memorizing it.
Before anything else, let us build a compass, because nearly every trap in this chapter rotates around a single axis.
Burn one sentence into your mind; the rest of the chapter is a direct consequence of it: in Java, field access, static methods, and overload selection are resolved by the compiler using the "static" type (the type you declared in code); but instance-method calls are resolved by the JVM at runtime using the "dynamic" type (the object's actual type). Ahead we cover: the four pillars of OOP, abstract class vs interface, default/static/private interface methods, the diamond problem, composition over inheritance, static vs dynamic dispatch, overloading vs overriding, covariant returns, final/sealed, the Liskov principle, and finally fifteen tricky interview questions.
Part 0 — words you must feel first
Before diving in, let us unpack three words plainly, because the rest of the chapter leans on them constantly.
Picture a mailbox with a label you wrote on it: "Books." That label is the static type — what you declared, what the compiler sees by reading the code. But inside the box there might actually be a "pocket novel" — that real content is the dynamic type, what is truly there at runtime. When you write Animal a = new Dog();, the label reads Animal but the real content is Dog. Every fight in this chapter is about this: which decision is made by reading the "label," and which by opening the "box."
- Invariant: a rule an object must hold for its entire lifetime; e.g., "the account balance is never negative." If the object lets that rule break, it is broken.
- Coupling: how much one piece of code depends on the internal details of another. High coupling means changing one breaks the other.
- Dispatch: "which implementation of a method actually runs." When we say dispatch is "dynamic," the decision is made at runtime; "static" means it is locked at compile time.
The Four Pillars
OOP in Java is fundamentally about who knows what and who decides at which moment. Encapsulation controls who knows the internal state; abstraction and polymorphism control when a decision is made. Let us build the pillars one by one.
1. Encapsulation
In a supermarket, anyone grabs any can off the shelf and drops it in a basket — no protection. In a pharmacy, the medicines sit behind a counter; you request, and the pharmacist checks that the prescription is valid before handing anything over. Encapsulation is exactly that counter: the data is behind it, and every change must pass through one controlled point.
Encapsulation is bundling data with the code that operates on it and restricting direct access to that data so the object controls its own invariants. It is not "adding getters and setters" — a class with a public setter for every private field is a struct in disguise with no invariant protection. A counter that lets everyone walk off with any drug, no prescription, is no longer a pharmacy.
Back to Java:
public final class Money {
private final long cents; // invariant: no fractional cents
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) // invariant enforced here
throw new IllegalArgumentException("currency mismatch");
return new Money(this.cents + other.cents, currency);
}
// No setters. State transitions produce new instances.
}
The point: callers cannot construct a Money in an inconsistent state, and cannot mutate one into an invalid one. The rule "currencies must match" lives in exactly one place. Real encapsulation is about guarding invariants, immutability being its strongest form — because something that never changes can never drift into a broken state.
2. Inheritance
Inheritance is like an apprentice who takes not only the master's skills but the master's inner habits. If the master secretly does something a certain way and later changes it, the apprentice is affected without knowing. That closeness is both inheritance's power and its trap.
Inheritance (extends) models an is-a relationship and shares implementation. Java has single class inheritance (one superclass) but multiple interface inheritance. Inheritance is the most abused pillar because it creates the language's tightest coupling: a subclass depends on the implementation of its parent, not just its contract.
abstract class Shape {
abstract double area(); // subclass MUST provide
String describe() { // shared implementation
return getClass().getSimpleName() + " area=" + area();
}
}
class Circle extends Shape {
private final double r;
Circle(double r) { this.r = r; }
@Override double area() { return Math.PI * r * r; }
}
Here Shape says "every shape has an area but I don't know how to compute it" (the abstract method), yet it provides the shared describe() for everyone. Circle fills in only the missing piece.
3. Polymorphism
"Polymorphism" means "many shapes": one name, many behaviors. But in an interview you must separate its two entirely different kinds, or you will get caught.
Subtype (runtime) polymorphism: press "Start" on different machines — on a coffee maker it brews, on a washing machine it washes. One command, but the behavior depends on the actual machine. That is overriding. Ad-hoc (compile-time) polymorphism: the word "bank" — is it a riverbank or a money bank? The sentence settles it, before anything happens. That is overloading: the compiler decides from the argument's type.
- Subtype (runtime) polymorphism: overriding. A
Shapereference invokesCircle.area()based on the actual runtime object. This is dynamic dispatch. - Ad-hoc (compile-time) polymorphism: overloading. Which
print(x)runs is chosen by the compiler from the argument's static type.
Shape s = new Circle(2);
System.out.println(s.area()); // 12.56... — dynamic dispatch to Circle
The label on s is Shape, but the JVM opens the box, sees it is really a Circle, and calls Circle's version of area().
4. Abstraction
Abstraction is exposing what an object does while hiding how. Like a car's gas pedal: you know "press it to go faster" without knowing the explosions inside the engine. Abstract classes and interfaces are the two tools — and the rest of this chapter is largely about choosing wisely between them.
Abstract class vs Interface
An abstract class is like a half-finished cake mold: part of the cake is already shaped (concrete methods, shared fields) and you just finish the top. Because the base shape is one thing, you can use only one mold. An interface is like a skill certificate: "this person can swim," "can drive." One person can hold many certificates, and those certificates say nothing about what is inside them — only what they can do.
Both are abstraction mechanisms; they differ in what they can carry and how they compose. Read this table carefully, because each row is a potential interview question:
| Aspect | Abstract class | Interface |
|---|---|---|
| Instantiable | No | No |
| Instance fields (state) | Yes | No — only public static final constants |
| Constructors | Yes (run via subclass super()) |
No |
| Multiple inheritance | No (single superclass) | Yes (implement many) |
| Method bodies | Yes (concrete + abstract) | default, static, private since Java 8/9; abstract otherwise |
| Access modifiers on members | any (public/protected/private/package) |
methods implicitly public (or private for helpers); no protected/package-private abstract methods |
final/static non-constant fields |
Yes | Fields are always public static final |
| Represents | "is-a" + shared identity/state | "can-do" capability / contract |
| State evolution safety | Add concrete method freely | Adding abstract method breaks implementers; default is the escape hatch |
| Object superclass methods | Can declare equals/hashCode abstract to force override |
Cannot provide default for equals/hashCode/toString (compile error) |
When to pick which
- Interface when you are defining a capability that unrelated types may have (
Comparable,AutoCloseable,Serializable), when a type must participate in multiple hierarchies, or when you want maximum decoupling ("program to interfaces"). - Abstract class when subtypes are genuinely variants of one thing, share fields/constructor logic/state, or need
protectedhelpers and template-method skeletons. - Rule of thumb: default to interfaces; reach for an abstract class only when you must share mutable state or non-public implementation. Many designs use both — a
public interfacefor the contract and apackage-private abstractbase for shared plumbing (seeAbstractList,AbstractMapin the JDK).
An interface can extend multiple interfaces; a class can implement many interfaces but extend only one class. This asymmetry is the reason interfaces win for flexible design. You get only one "cake mold," but as many "certificates" as you like.
default, static, and private interface methods
Here is a puzzle: if an interface is just a contract with no method bodies, how did Java later add stream() to Collection without breaking the millions of existing classes that already implemented Collection?
Imagine you hold a contract with a thousand vendors and want to add a new clause. If you say "everyone must now do this new task," all thousand contracts break, because none of them have that clause. But if you write "this new task, unless the vendor declares another way, is by default done like this," no contract breaks. A default method is exactly that "by default" clause.
Java 8 added default and static methods to interfaces so the platform could evolve interfaces (e.g., adding Collection.stream()) without breaking every implementer. Java 9 added private (and private static) interface methods to share code between default methods without exposing it as part of the public contract.
public interface Discount {
double rate(); // abstract
default double apply(double price) { // default — inheritable
return price * (1 - normalized()); // calls private helper
}
static Discount none() { return () -> 0.0; } // static — not inherited
private double normalized() { // Java 9+, hidden helper
return Math.max(0, Math.min(1, rate()));
}
}
Key rules to keep memorized:
staticinterface methods are not inherited and must be called on the interface itself (Discount.none()), never on an implementing instance or subinterface.defaultmethods are inherited and can be overridden.- A
defaultmethod cannot befinal,static, orabstract; it has no instance state to touch (interfaces have none) beyond calling abstract methods.
The diamond problem and its resolution
Suppose you have two bosses and each gave you an identically named instruction: boss A says "greet like this," boss B says "greet like that." If someone tells you "greet," you freeze — which one? Java does not tolerate that confusion: it forces you, at compile time, to explicitly state which boss you follow.
The classic multiple-inheritance ambiguity: two supertypes provide the same default method. Java forbids the ambiguity at compile time and forces you to resolve it explicitly.
interface A { default String hello() { return "A"; } }
interface B { default String hello() { return "B"; } }
class C implements A, B {
// Compile error WITHOUT this override:
// "class C inherits unrelated defaults for hello() from A and B"
@Override public String hello() {
return A.super.hello() + B.super.hello(); // explicit disambiguation
}
}
Notice the special syntax A.super.hello() — it is the only way to say "I want boss A's version." Resolution rules, in precedence order:
- Class wins: a concrete or abstract method from a superclass beats any interface
default. ("Class always wins over interface.") - Most specific interface wins: if interface
D extends Aand overrides the default,D's version beatsA's. - Otherwise you must override and may delegate with
Interface.super.method().
There is never silent ambiguity. Unlike C++, which might quietly pick a version, Java refuses to compile on a default conflict until you decide. "Safety" here means "no ambiguous behavior slips past you."
Composition over inheritance
With inheritance you are effectively married to the superclass: bound to all its inner habits, and if it changes its internal behavior tomorrow, your life is upended too. With composition you have hired a specialist: you use only their declared services and do not care how they work behind the scenes. If you ever want, you bring in a different specialist. The relationship is loose and swappable.
Inheritance couples you to a superclass's implementation and breaks encapsulation: overriding one method can silently break another that calls it internally (the fragile base class / self-use problem). Josh Bloch's canonical example is subclassing HashSet to count insertions — but addAll internally calls add, so counts double:
// BROKEN: inheritance leaks the fact that addAll calls add
class CountingSet<E> extends HashSet<E> {
int count = 0;
@Override public boolean add(E e) { count++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
count += c.size(); // then super.addAll calls add() again!
return super.addAll(c); // double counts
}
}
If you add three elements with addAll, count becomes 6 instead of 3: once c.size() added three, then super.addAll internally called add three times, each counting again. Now the correct version, with composition:
// FIXED: composition — delegate to a held instance, depend only on the Set contract
final class CountingSet<E> implements Set<E> {
private final Set<E> delegate;
private int count = 0;
CountingSet(Set<E> delegate) { this.delegate = delegate; }
@Override public boolean add(E e) { count++; return delegate.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
count += c.size(); return delegate.addAll(c); // no leakage: delegate's addAll is opaque
}
public int count() { return count; }
// ... forward remaining Set methods to delegate
}
Composition depends on the interface (Set), not the implementation, so internal self-calls in the delegate are invisible and cannot break you. Prefer inheritance only when there is a genuine is-a relationship and the superclass was deliberately designed and documented for extension.
Static vs dynamic dispatch
Now back to the chapter's compass, sharpened.
- Dynamic dispatch (virtual method invocation): instance methods. The JVM looks up the method in the object's actual-class "vtable" at runtime. (A
vtableis simply a per-class table the JVM keeps saying which implementation each method points to.) - Static dispatch:
staticmethods,privatemethods,finalmethods (cannot be overridden), field access, and overload selection. All resolved at compile time from the static type.
class Animal { String noise() { return "?"; } static String kind() { return "Animal"; } }
class Dog extends Animal { @Override String noise() { return "woof"; } static String kind() { return "Dog"; } }
Animal a = new Dog();
System.out.println(a.noise()); // "woof" — dynamic: actual type Dog
System.out.println(a.kind()); // "Animal" — static: hidden, resolved by static type Animal
static methods are hidden, not overridden. a.kind() is resolved from the declared type Animal, so it prints "Animal" — even though the object is really a Dog. This is one of the most common interview traps; whenever you see a static method or a field, look at the label (static type) first.
Overloading vs overriding
These two words look alike, and that resemblance is the source of countless mistakes. This table spells out the difference line by line:
| Overloading | Overriding | |
|---|---|---|
| Resolved | Compile time (static type of args) | Runtime (dynamic type of receiver) |
| Signature | Different parameter list | Identical signature |
| Return type | Can differ freely | Same or covariant subtype |
| Access | Any | Same or wider (can't narrow) |
throws |
Any | Same or narrower checked exceptions |
@Override |
N/A | Recommended; compiler-checked |
static |
Can overload | Static methods are hidden, not overridden |
Rules an overrider must obey (these are the "signature compatibility rules"):
- Return type: covariant — may return a subtype (since Java 5).
- Access modifier: cannot be more restrictive (a
publicmethod can't be overridden asprotected). - Checked exceptions: cannot throw new or broader checked exceptions; may throw fewer/narrower or unchecked ones.
Covariant returns
"Covariant" means "moves in the same direction as the hierarchy": because Cat is a subtype of Animal, the override may narrow the return type from Animal to Cat.
class Animal { Animal reproduce() { return new Animal(); } }
class Cat extends Animal {
@Override Cat reproduce() { return new Cat(); } // covariant return: Cat <: Animal
}
This is why clone() overrides can return the exact type (instead of a raw Object), and why builder/fluent hierarchies compile cleanly.
final and sealed
finalclass: cannot be subclassed (String,Integer). Enables optimizations and guarantees no override-based invariant breakage.finalmethod: cannot be overridden; the call is statically dispatched.finalfield: assigned once; the basis of safe publication and immutability.sealed(finalized in Java 17): a class or interface restricts, via thepermitskeyword, exactly which types may extend or implement it. Permitted subtypes must themselves befinal,sealed, ornon-sealed, and must be in the same module (or the same package if in an unnamed module).
A final class is like a party where no one is invited except the host. A plain open class is an open-door party: anyone can walk in. A sealed class is the golden middle: you have a specific guest list (permits Circle, Rectangle) — neither fully closed nor fully open. And because the guest list is known, you can plan for "every possible guest" and be sure no one was left out.
public sealed interface Shape permits Circle, Rectangle {}
public final class Circle implements Shape { /* ... */ }
public non-sealed class Rectangle implements Shape {} // reopens the hierarchy
Sealing gives you a closed set of subtypes, which makes exhaustive switch pattern matching possible without a default branch — because the compiler knows all possible cases. It is the enabling feature for algebraic-data-type-style modeling and lets the compiler verify exhaustiveness:
double area(Shape s) {
return switch (s) { // no default needed — Shape is sealed
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
};
}
If someone later adds a third subtype to Shape, this switch no longer compiles and the compiler shouts that you have missed a case. With a default branch that error would be silenced and the bug would surface at runtime. So the missing default means "the compiler is my guard."
The Liskov angle
Swap an AA battery for another AA battery; you expect the device to work with no change. The Liskov principle is exactly that: any subtype must be able to quietly take the supertype's place while the program's correctness stays intact. If the replacement battery has the right shape but the wrong voltage, the type system says it "fits," but in practice it fries the device — that is the behavioral part Java cannot check for you.
The Liskov Substitution Principle (LSP): objects of a subtype must be substitutable for the supertype without altering correctness. Java's type system enforces the signature part (covariant returns, no narrower access, no broader checked exceptions) but cannot enforce the behavioral part — that is on you:
- Preconditions may not be strengthened by a subtype ("don't demand more than the parent").
- Postconditions may not be weakened ("don't deliver less than the parent").
- Invariants of the supertype must be preserved.
- History constraint: a subtype must not allow state changes the supertype forbids. (This is why a mutable
Point3Dsubclass of an immutablePointis a classic violation: the parent promised "I never change," and the child broke that promise.)
The canonical violation is Square extends Rectangle: if Rectangle.setWidth is expected to leave height unchanged, a Square that couples width and height breaks any code relying on that postcondition. The fix is usually composition or a shared abstraction, not inheritance.
Common pitfalls & gotchas
- Calling an overridable method from a constructor. During
super(), the subclass's overriding method runs before the subclass's fields are initialized — so it seesnull/0. Never call overridable methods from constructors. - Field "shadowing" is not polymorphic. Fields are resolved by static type:
Animal a = new Dog(); a.nameusesAnimal.name. staticmethods are hidden, not overridden — no@Overridesemantics.- Overload + autoboxing + varargs: the compiler prefers (1) exact/widening match, then (2) boxing, then (3) varargs. This can silently change which overload runs.
equals/hashCodecan't bedefaulted in interfaces.- Adding an abstract method to a published interface breaks all implementers — use
defaultto evolve safely. - Protected fields in an abstract base leak into every subclass, forming a wide, fragile contract. Prefer private fields + protected accessors.
Interview Questions
Now it is game time. Answer each yourself first, then unfold the answer.
class A { int x = 1; int get() { return x; } }
class B extends A { int x = 2; int get() { return x; } }
A a = new B();
System.out.println(a.x + " " + a.get());
Answer: 1 2. a.x is a field access → resolved by static type A → 1. But a.get() is a virtual call → dynamic dispatch to B.get() → returns B.x = 2. In short: fields shadow (static), methods override (dynamic). The chapter's compass sentence, exactly.
class Base { Base() { init(); } void init() { System.out.println("base"); } }
class Sub extends Base {
String msg = "hello";
@Override void init() { System.out.println(msg); }
}
new Sub();
Answer: Prints null. Execution order: Sub() → super() (the Base ctor) → that ctor calls init() on the Sub object, so the overridden version runs → but msg is not yet assigned, because subclass field initializers run after super() returns. So msg is null. Lesson: never call overridable methods from constructors.
Answer: Multiple inheritance of type and behavior (via defaults) is safe because interfaces carry no instance state, so there is no diamond ambiguity over fields, and default-method conflicts are resolved by explicit rules (class wins, most-specific interface wins, else you must override). Multiple class inheritance would create ambiguous field layout and constructor ordering — two parents with same-named fields, and no clear order for whose constructor runs first.
Answer: Choose an abstract class when you need instance state (mutable or final fields), constructor logic, protected non-public helpers, or a strict single is-a hierarchy. Interfaces still cannot hold state or non-public instance members. Otherwise prefer interfaces for multiple-inheritance flexibility and decoupling.
Answer: It invokes a specific superinterface's default implementation. Required when a class inherits conflicting defaults from multiple interfaces (diamond) and must disambiguate, or when overriding a default but still wanting to call the original.
void f(Object o) { System.out.println("Object"); }
void f(int... i) { System.out.println("varargs"); }
f(1);
Answer: Object. Overload resolution has three phases. Phase 1 (no boxing, no varargs): int is not a subtype of Object without boxing, so it fails. Phase 2 (boxing allowed): int boxes to Integer, which is an Object → f(Object) matches. Varargs (phase 3) is only tried if phases 1–2 find nothing. Since boxing succeeded in phase 2, we never reach varargs, so it prints Object. Golden rule: varargs is always the last resort.
Answer: No. An overrider may throw the same, fewer, or narrower checked exceptions, or any unchecked (runtime) exceptions — but never a new or broader checked exception. This preserves substitutability for callers who wrote their catch blocks based on the parent's signature; otherwise an exception they never expected would escape them.
Answer: An override may return a subtype of the parent's return type (since Java 5). Clean examples: builder patterns, and Cat reproduce() overriding Animal reproduce(). Also clone() overrides commonly narrow the declared Object return to the exact class, so callers need no cast.
interface I { default String who() { return "I"; } }
class P { public String who() { return "P"; } }
class Q extends P implements I { }
new Q().who();
Answer: "P". Class always wins over an interface default. Even though I provides who(), the concrete method inherited from superclass P takes precedence, and no compile error occurs (unlike the two-interface diamond, which errors). This is the "class wins" rule.
Answer: Generally no. If Rectangle exposes independent setWidth/setHeight with the postcondition that setting width leaves height unchanged, a Square that couples them weakens that postcondition, breaking clients that rely on it — an LSP violation. Prefer an immutable shared abstraction or composition. (If both are immutable with no independent setters, the design can be acceptable.)
Answer: It's a compile error: interfaces may not provide default methods for Object's methods (equals, hashCode, toString). Rationale: every class already inherits these from Object, and by the "class wins" rule the Object version would always win, so the default would never run — dead code. The language forbids it to avoid confusion.
Answer: sealed (Java 17) restricts the set of permitted subtypes to a known, compiler-visible list. Unlike final it still allows a fixed hierarchy; unlike an open abstract class it prevents arbitrary extension. This enables exhaustive switch pattern matching without a default, safer API evolution, and algebraic-data-type modeling. Permitted subtypes must be final, sealed, or non-sealed.
class A { static String s() { return "A"; } }
class B extends A { static String s() { return "B"; } }
A ref = new B();
System.out.println(ref.s());
Answer: "A". Static methods are hidden, not overridden; ref.s() is resolved at compile time from the static type A. (Calling a static method through an instance reference is legal but discouraged precisely because it hides this behavior and misleads the reader.)
Answer: Not necessarily. Encapsulation is about protecting invariants, not merely hiding fields behind accessors. A full set of public setters exposes every state transition and lets callers create invalid states — it's a mutable data bag, not an encapsulated object. True encapsulation validates transitions, keeps invariants in one place, and often favors immutability.
Answer: Neither. private methods are not inherited at all, so a same-signature method in a subclass is a new, unrelated method (no polymorphism — statically dispatched). final methods are inherited but cannot be overridden. Both are therefore statically bound, which is also why the compiler/JIT can inline them aggressively (it already knows exactly which code runs).
Senior notes & advanced edge cases
The chapter above gave you the mechanics: static vs dynamic type, the four pillars, the diamond, Liskov. Senior level is where those mechanics collide with the JVM's internals, with library evolution over years, and with the modern Java (records, pattern matching, sealed ADTs) that reshaped this whole topic. Everything here is additive — the traps and facts the first pass deliberately left on the table.
Nine things the first pass didn't touch: (1) records as the real replacement for hand-written immutable classes; (2) the equals-plus-inheritance impossibility — why you literally cannot do both correctly; (3) how dispatch actually works at the bytecode + JIT level (vtable/itable, monomorphic→megamorphic call sites); (4) bridge methods from erasure and covariant returns; (5) the package-private "override" trap; (6) re-abstraction and the silent "class wins" accidental-override hazard; (7) pattern matching for switch in depth (record patterns, guards, dominance, null, MatchException); (8) initialization order + safe publication under the Java Memory Model; (9) value-based classes and the Valhalla horizon.
1. Records — what Money was really reaching for
That hand-written Money from the chapter (final fields, no setters, an equals you must write yourself) is exactly the pattern Java 16 turned into a keyword: the record. A record is an immutable data carrier for which the compiler auto-generates the canonical constructor, accessors, and mutually-consistent equals/hashCode/toString.
public record Money(long cents, String currency) {
public Money { // compact constructor — the canonical body
Objects.requireNonNull(currency);
if (cents < 0) throw new IllegalArgumentException("negative");
// no need for this.cents = cents; the compiler assigns after this block
}
public Money plus(Money o) {
if (!currency.equals(o.currency)) throw new IllegalArgumentException("mismatch");
return new Money(cents + o.cents, currency);
}
}
A senior fact many miss: a record is safer for invariants than an ordinary Serializable class. When you deserialize a normal class, the JVM builds the object without calling any constructor and pours the bytes straight onto the fields — so an attacker can craft an "impossible" object (say cents = -100) that would never have passed the constructor. Record deserialization, by contrast, always goes through the canonical constructor, so that same if (cents < 0) runs again. That is a real security win.
A record only makes the reference final, not whatever the reference points to. If a component is mutable (an int[], a List), the generated accessor hands back the internal reference and outsiders can mutate it. You must copy defensively in both the compact constructor and the accessor:
public record Trade(String sym, int[] lots) {
public Trade { // copy on the way in
lots = lots.clone();
}
public int[] lots() { // copy on the way out
return lots.clone();
}
}
A record cannot extends another class (it implicitly extends Record and is final), but it can implement interfaces. That is what makes record + sealed a clean algebraic data type (ADT): one sealed interface and several record subtypes — exactly the Shape/Circle/Rectangle shape from the chapter, except every case is now a full, auto-generated data carrier.
2. The equals + inheritance impossibility (Effective Java, Item 10)
The chapter said equals cannot be a default in an interface. The deeper, classic interview trap is this: you cannot extend an instantiable class, add a new "value component," and still keep the equals contract intact. This is not a style preference; it is a mathematical impossibility.
Take Point(x, y) with an instanceof-based equals, and extend it as ColorPoint(x, y, color):
- If
ColorPoint.equalsalso checks color, symmetry breaks:point.equals(colorPoint)istrue(becausePoint.equalsnever sees color) butcolorPoint.equals(point)isfalse. - If you "fix" symmetry by making
ColorPoint.equalsignore color when the other side is a plainPoint, transitivity breaks: a red and a blueColorPointare each equal to one whitePoint, yet not equal to each other.
The getClass() != obj.getClass() approach (instead of instanceof) preserves symmetry and transitivity, but breaks Liskov: now a perfectly harmless subclass (one that only adds behavior, no fields) is no longer equal to an instance of its parent, and code like HashSet<Point>.contains(subInstance) silently fails. Bottom line: with inheritance you sacrifice either symmetry/transitivity or Liskov. The escape is composition: ColorPoint holds a Point as a field instead of inheriting from it.
This is precisely why records are safe here: a record is inherently final, so a value-component-adding subtype cannot exist and the whole trap is moot.
3. How dispatch really works: bytecode + JIT
The chapter said instance methods dispatch "dynamically" and the rest "statically." A real senior knows the four (plus one) opcodes behind that:
| Call | opcode | meaning |
|---|---|---|
static method |
invokestatic |
no receiver, bound at link time |
private, constructor, super.m() |
invokespecial |
exactly one target, no dynamic lookup |
| ordinary class method | invokevirtual |
lookup in the actual type's vtable |
| method via an interface reference | invokeinterface |
lookup in an itable (costlier) |
| lambda / method reference | invokedynamic |
lazy link, once, via LambdaMetafactory |
The key insight: a call through an interface (invokeinterface) is inherently costlier than a call through a class (invokevirtual), because a class can implement several interfaces in different layouts, so a simple fixed-index vtable is not enough and the JVM must consult an itable. In hot loops this difference is measurable.
The JIT profiles each call site and builds an inline cache:
- monomorphic: always one actual type seen — the JIT inlines the method, as if the virtual call were never there.
- bimorphic: two types — handled with a single
if. - megamorphic: many types — the inline cache busts and falls back to a full vtable lookup; inlining is lost and the code slows.
When a class or method is final, or a type is sealed, the JIT can use Class Hierarchy Analysis (CHA) to prove only one implementation is possible, so it devirtualizes: it turns the virtual call into a direct, inlinable one. This is a real performance reason for final/sealed, not just "clean design."
State of a call site — the lifecycle of a virtual call as the JIT sees it:
stateDiagram-v2
[*] --> Unlinked
Unlinked --> Monomorphic: first type seen
Monomorphic --> Bimorphic: 2nd type seen
Bimorphic --> Megamorphic: 3rd+ types
Monomorphic --> Inlined: JIT + CHA
Megamorphic --> VtableLookup: cache busted
4. Bridge methods
You see these in javap but never wrote them in source. Because of generics erasure, the compiler sometimes emits a synthetic method called a bridge to keep polymorphism intact:
class Node<T> { public void set(T t) { } }
class IntNode extends Node<Integer> {
public void set(Integer i) { } // you think this overrides
}
After erasure the parent's signature is set(Object) but the child has set(Integer) — left as is, Node<Integer> n = new IntNode(); n.set(5); would not reach the override. So the compiler generates a synthetic bridge set(Object o) { set((Integer)o); } in IntNode. A covariant return creates a bridge the same way (an Object-returning version delegating to the real narrowed one).
Because these methods are real, getClass().getMethods() returns them too. If you loop over methods via reflection you may see one method twice. Filter with Method.isBridge() or isSynthetic(). Also, because the bridge casts with (Integer) o, passing the wrong type through a raw reference triggers a ClassCastException at a spot where you wrote no cast in source.
5. The package-private "override" trap
The chapter said an override cannot narrow the access modifier. The more dangerous trap is this: a package-private (no-modifier) method is simply NOT overridden by a subclass in a different package — even if the signature matches exactly. They are two entirely separate methods, and dynamic dispatch does not connect them.
// package a
public class Base {
void hook() { System.out.println("base"); } // package-private
public void run() { hook(); }
}
// package b
public class Sub extends a.Base {
void hook() { System.out.println("sub"); } // NOT an override!
}
Calling new b.Sub().run() prints base, not sub — because Base.run() is bound to Base.hook(), and Sub.hook(), being in a different package, is not even considered the same method. Put @Override on Sub.hook() and it won't compile. This bug really burns people in cross-package template-method designs (or in tests that try to override a hook).
6. Re-abstraction and the silent "class wins" hazard
Two subtleties that follow directly from the default-methods discussion:
Re-abstraction: a subinterface can re-declare a parent's default method as abstract, stripping the implementation and forcing implementers to provide their own:
interface A { default String id() { return "A"; } }
interface B extends A { String id(); } // abstract again — any B implementer must define id
The "class always wins over interface" rule is usually safe, but it has a dangerous evolution mode. Suppose a library gives you an interface and later adds a new default method — say default int size(). If your unrelated superclass already has a public int size() with a completely different meaning, then by "class wins" your superclass method silently takes the place of the interface default, and the default never runs — with no compile error. This kind of bug appears on library upgrades and is nasty to track down.
7. Pattern matching for switch, deeply (Java 21)
The chapter showed that sealed makes a switch exhaustive without a default. Java 21 pushed this much further: record deconstruction patterns and guarded patterns with when.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double r) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 100 -> "huge circle"; // guard
case Circle(double r) -> "circle " + r; // deconstructs the component
case Rectangle(double w, double h) -> "rect " + (w * h);
}; // no default — it's exhaustive
}
- Dominance (ordering): if one pattern "dominates" a later one (e.g.
case Circle cplaced beforecase Circle(double r) when ...), it won't compile — unlike a plainif/elsechain that would just harbor a bug. The compiler enforces order. - null: unlike a traditional switch that NPEs on
null, a pattern switch still NPEs on anullinput unless you addcase null; to handle it you must writecase null ->explicitly (orcase null, default). MatchExceptionat runtime: an exhaustiveswitchover asealedtype compiled without adefaultwill, if the hierarchy later changes and the switch's class is not recompiled (separate compilation), receive a value matching no case at runtime and throwMatchException. Compile-time exhaustiveness is not a runtime guarantee if your jars are out of sync.
8. Initialization order and safe publication
The chapter showed the "calling an overridable method from a constructor" trap. The full order behind it is itself an interview question. For new Sub():
staticblocks/fields (parent first, then child) — once per program lifetime, at class load.super(...)up the chain; at each level: that level's instance initializers and instance blocks, then that level's constructor body.- Then the child's field initializers and instance blocks run — and only then the child's constructor body. That is exactly why, in the constructor trap,
msgwas stillnull.
The Java Memory Model (JMM) gives final fields a special guarantee: if you construct an immutable object (only final fields) and publish its reference, any other thread that sees that reference is guaranteed to see the fully-initialized values of those final fields — with no synchronized or volatile. This is "safe publication." But the guarantee breaks if you let the this reference escape from inside the constructor (e.g. registering this in a static list) before the constructor finishes — because the final fields are not yet frozen. So "immutable = free thread-safety" is only true when this does not escape the constructor.
9. Value-based classes and the Valhalla horizon
Java has types marked @jdk.internal.ValueBased: the wrappers (Integer, Long…), Optional, and most of java.time such as LocalDate. These have no identity; only their value matters, and they must be compared with .equals().
==on wrappers:Integer a = 127, b = 127; a == bistrue(the-128..127cache) but with128it isfalse. Not dangerous polymorphism — just identity leaking through. Always useequals.synchronizedon a value-based class is forbidden: do not lock on anInteger,Optional, orLocalDate(synchronized(myInteger)). Their identity is not guaranteed, and under future Valhalla they may not be heap objects at all; javac and the JIT warn about it and the behavior can break. Project Valhalla intends to turn these into "value classes" that behave likeint— no object overhead, no identity.
Senior interview questions
Answer: Because you get squeezed between three contract clauses. If the subclass equals checks the new component (color), symmetry breaks (point.equals(colorPoint) is true but the reverse is false, since the parent doesn't see color). If you fix that by ignoring color when the other side is a bare parent, transitivity breaks. If you switch to getClass() instead of instanceof, symmetry/transitivity survive but Liskov breaks (a harmless subtype is no longer equal to its parent). It's a structural impossibility; the fix is composition (hold the parent as a field). And this is exactly why a record is inherently final.
// package a
public class Base { void hook(){System.out.print("base");} public void run(){hook();} }
// package b
public class Sub extends a.Base { void hook(){System.out.print("sub");} }
// elsewhere: new b.Sub().run();
Answer: base. Base.hook is package-private, so Sub.hook(), living in a different package, does not override it — they are two separate methods. Base.run() stays bound to Base.hook(). Put @Override on Sub.hook() and it won't compile. Senior lesson: make template-method hooks protected or public, never package-private, or cross-package overriding fails silently.
Answer: A synthetic method the compiler emits to preserve polymorphism, in two cases: (a) generics erasure — when IntNode extends Node<Integer> overrides set(Integer) but the parent's erased signature is set(Object), the compiler generates set(Object o){ set((Integer)o); } so a call through Node actually reaches the override; (b) covariant returns — a bridge that delegates the parent-typed-return version to the narrowed one. These show up in reflection's getMethods(); filter them with Method.isBridge(). They can also throw an unexpected ClassCastException when raw types are misused.
sealed interface S permits X, Y {} record X(int a) implements S {} record Y(int b) implements S {}
String f(S s){ return switch(s){ case X x -> "x"; case X(int a) when a>0 -> "pos"; case Y y -> "y"; }; }
Answer: A dominance error: case X x catches every X, so the later case X(int a) when a>0 is unreachable and the compiler rejects it. The more specific / guarded pattern must come before the general one:
case X(int a) when a>0 -> "pos";
case X x -> "x";
Unlike an if/else chain that would just silently harbor the bug, a pattern switch enforces correct order at compile time.
Answer: With MatchException. Exhaustiveness is checked at compile time against the subtypes known then. If a new subtype is later added to the sealed hierarchy (in a different jar) and the class containing the switch is not recompiled (separate compilation), at runtime an instance arrives that matches no case and the JVM throws MatchException. Lesson: compile-time coverage is not a runtime guarantee when artifacts are deployed out of sync.
Answer: The Java Memory Model gives final fields a special guarantee: after the constructor finishes, final field values are "frozen," and any thread that sees the object's reference is guaranteed to see their fully-initialized values — that's safe publication without synchronized/volatile. But it has two conditions: (1) all state fields must be final; a non-final field can be seen stale by other threads. (2) this must not escape the constructor (e.g. registering this in a listener or static list inside the constructor) — because it's published before the freeze, and another thread could see a half-built object. So proper immutability + no this-escape = free thread-safety.
Answer: They are value-based classes (@jdk.internal.ValueBased); their identity is not guaranteed. Two apparently distinct references can actually be one object (the Integer cache over -128..127), or under future Valhalla they may have no independent heap object at all. Locking on something whose identity isn't guaranteed means you might unintentionally lock on a shared object (deadlock/contention) or have the behavior break in the future. javac and the JIT warn about this. Always lock on a dedicated private final Object lock = new Object();.
- record: the modern immutable-class replacement — canonical/compact constructor, auto
equals/hashCode, inherentlyfinal, deserialization-safe; withsealeda full ADT. Watch defensive copies for mutable components. - equals + inheritance + a new component are jointly impossible (symmetry/transitivity vs Liskov); composition is the escape, and record's
final-ness removes the trap. - dispatch at the bytecode level:
invokestatic/special/virtual/interface/dynamic;invokeinterfaceis costlier, andfinal/sealedlet the JIT devirtualize. - bridge methods show up in reflection (filter with
isBridge()); package-private methods aren't overridden across packages (make hooksprotected). - pattern switch: watch dominance,
null, and runtimeMatchException. final grants JMM safe publication ifthisdoesn't escape the constructor. Never lock on or==-compare value-based classes.
- One sentence explains it all: fields,
static, and overload selection resolve by the static type (the label); instance methods resolve by the dynamic type (the box's contents). - Four pillars: encapsulation = guarding invariants (not just getters/setters); inheritance = is-a with tight coupling; polymorphism = two distinct kinds (dynamic override, static overload); abstraction = what, not how.
- Abstract vs interface: interface for capability, multi-hierarchy, and decoupling; abstract class for state/constructor/
protectedhelpers. Default to interfaces. default/static/private(Java 8/9) made interfaces evolvable; the diamond is resolved by "class wins / most-specific interface / else you override," with theA.super.m()syntax.- Prefer composition over inheritance unless there's a genuine is-a designed for extension.
final/sealed(Java 17) create closed identities and enable exhaustiveswitch; Liskov puts the behavioral part the compiler can't check on your shoulders.- Traps: calling an overridable method from a constructor, field shadowing, static hiding, and boxing beating varargs.