Java Core · جاوا پایه متوسطIntermediate ~52 دقیقه مطالعه~45 min read
جنریکها و سیستم نوعGenerics & the Type System
در این درس یاد میگیری جنریکها چطور ایمنی نوع را در زمان کامپایل میسازند و در زمان اجرا پاک (erase) میشوند، و با تسلط بر PECS، erasure، واریانس آرایه/جنریک و متدهای پل، خطاهای مبهم کامپایل برایت شفاف میشوند.In this lesson you'll learn how generics build compile-time type safety on top of a type system that's erased at runtime, and by mastering PECS, erasure, array/generic variance, and bridge methods, those mysterious compile errors finally make sense.
بیا از همان اول با هم روراست باشیم: جنریکها یکی از آن بخشهایی از جاواست که همه فکر میکنند بلدند، تا وقتی مصاحبهگر میپرسد «چرا نمیشود به List<? extends Number> چیزی add کرد؟» و ذهن قفل میکند. کل ماجرا در واقع دور یک تصمیم طراحی میچرخد و اگر آن یک تصمیم را عمیق بفهمی، بقیهٔ سؤالات مثل دومینو میافتند. بیا آن یک تصمیم را با هم بکاویم.
در این مسیر قدمبهقدم جلو میرویم:
- مدل ذهنی بنیادی — جنریک یعنی «قرارداد زمان کامپایل» و مفهوم erasure (پاکشدن نوع).
- کلاسها و متدهای جنریک — چطور یک نوع را پارامتری کنیم.
- کرانها (bounds) — محدود کردن
Tباextends. - Wildcardها و قانون طلایی PECS — چرا و کِی از
?،? extends،? superاستفاده کنیم. - Type erasure و همهٔ چیزهایی که ممنوع میکند — قلب تپندهٔ فصل.
- متدهای پل (bridge methods) — متدهای پنهانی که کامپایلر میسازد.
- نوعهای reifiable، واریانس آرایه در برابر جنریک، کرانهای بازگشتی، استنتاج نوع.
- دامها، بهترین شیوهها و ۱۴ سؤال مصاحبه با پاسخ کامل.
نگران واژههای تازه نباش؛ هر کدام را همانجا که برای اولین بار ظاهر میشوند، از صفر میسازیم.
بخش ۰ — چند واژه که باید حسشان کنی
قبل از شروع، سه واژه را با یک تشبیه ساده در ذهنت جا بینداز تا بعداً سرعت بگیریم.
تصور کن یک شرکت کاریابی داری. زمان کامپایل مثل مرحلهای است که روی کاغذ قرارداد مینویسی «این جایگاه فقط برای مهندس نرمافزار است». زمان اجرا (runtime) مثل روزی است که کارمند واقعاً سرِ کار میآید. حالا نکتهٔ عجیب جنریک این است: بعد از امضای قرارداد، شرکت آن جملهٔ «فقط مهندس نرمافزار» را از روی کاغذ پاک میکند و فقط مینویسد «کارمند». به این پاککردن میگوییم erasure. کامپایلر هنگام امضا سختگیری میکند، اما در زمان اجرا آن قید تخصصی دیگر روی کاغذ نیست.
- type safety (ایمنی نوع): تضمین اینکه در یک ظرف رشته، عددِ اشتباهی نیفتد — و این تضمین را کامپایلر میدهد، نه دعا و امید.
- erasure (پاکشدن نوع): حذف اطلاعات نوع جنریک هنگام تبدیل کد به بایتکد، برای سازگاری با کد قدیمی.
- reified (تجسمیافته): یعنی «اطلاعاتش در زمان اجرا واقعاً موجود است». جنریکها reified نیستند؛ پاک میشوند.
مدل ذهنی: جنریک یک قرارداد در زمان کامپایل است
جنریکها (generics) در جاوا فقط برای یک هدف وجود دارند: انتقال خطاهای نوع از زمان اجرا به زمان کامپایل. یعنی بهجای اینکه برنامه سرِ کاربر بترکد، همان لحظه که کد مینویسی خطا بگیری.
پیش از جاوا ۵ مجبور بودی اینطور بنویسی:
List list = ...;
String s = (String) list.get(0); // امیدوار بودی که واقعاً String باشد
آن (String) یک cast است — یعنی به کامپایلر میگویی «به من اعتماد کن، این رشته است». اما اگر اشتباه میکردی، تازه در زمان اجرا ClassCastException میخوردی. جنریک این «اعتماد کن» را با «اثبات کن» جایگزین کرد: کامپایلر خودش امنیت cast را ثابت میکند و سپس اطلاعات نوع را پاک (erase) میکند تا بایتکد تقریباً همان بایتکد نسخهٔ پیشازجنریک شود. همین یک تصمیم — erasure برای سازگاری با گذشته (backward compatibility) — تقریباً همهٔ سؤالات «چرا نمیتوانم فلان کار را بکنم» را توضیح میدهد.
این دو جمله را مثل دو کفهٔ ترازو کنار هم نگه دار؛ کل فصل از تنش میان این دو میجوشد:
۱. در زمان کامپایل، List<String> و List<Integer> دو نوع کاملاً متفاوت و ناسازگارند. کامپایلر این را سختگیرانه اعمال میکند.
۲. در زمان اجرا هر دو فقط List هستند. کلاسی به نام List<String>.class وجود ندارد؛ فقط List.class هست. برای ArrayList دقیقاً یک شیء Class وجود دارد، صرفنظر از پارامترسازی (parameterization، یعنی همان چیزی که داخل <> میگذاری).
کلاسها و متدهای جنریک
یک جعبهٔ پستی را تصور کن که یک جای خالی برای برچسب دارد. خودِ جعبه همیشه یکی است، اما وقتی برچسب «کتاب» میزنی، دیگر انتظار داری فقط کتاب داخلش باشد؛ برچسب «لیوان» یعنی فقط لیوان. آن جای خالیِ برچسب، همان متغیر نوع (type variable) است و اسمش را میگذاریم T.
یک کلاس جنریک روی یک یا چند متغیر نوع پارامتری میشود:
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> b = new Box<>(); // عملگر diamond نوع <String> را استنتاج میکند
b.set("hi");
String s = b.get(); // بدون نیاز به cast
آن <> خالی سمت راست را عملگر diamond (الماس) میگویند؛ چون تو سمت چپ گفتهای Box<String>، کامپایلر خودش پُرش میکند و لازم نیست دوباره String بنویسی.
حالا دقت کن: یک متد جنریک میتواند پارامتر نوع خودش را، مستقل از هر پارامتر کلاس، اعلام کند. نکتهٔ نحوی مهم این است که <T> پیش از نوع بازگشتی میآید:
public static <T> List<T> singletonList(T item) {
List<T> list = new ArrayList<>();
list.add(item);
return list;
}
// معمولاً استنتاج میشود؛ اما میتوان صریح داد:
List<String> xs = Collections.<String>emptyList();
قرارداد نامگذاری (فقط برای خوانایی، هیچ اجباری نیست): حروف بزرگ تکحرفی — T (type)، E (element)، K/V (key/value)، R (result)، N (number).
کامپایلر اگر متغیر نوعت را Banana بنامی هم اعتراضی ندارد. اما وقتی خواننده E میبیند فوراً میفهمد «عنصرِ یک مجموعه» و K/V یعنی «کلید/مقدار». تبعیت از این قرارداد یعنی احترام به کسی که کدت را میخواند.
پارامترهای نوع کراندار (bounded)
فرض کن مسابقهای میسازی که در آن باید شرکتکنندهها را با هم «مقایسه» کنی تا برنده را پیدا کنی. نمیتوانی هر چیزی را قبول کنی — یک سنگ که خودش را با سنگ دیگر مقایسه نمیکند. پس شرط میگذاری: «فقط چیزهایی که بلدند خودشان را با همنوعشان مقایسه کنند». این شرطگذاری روی نوع، همان کران (bound) است.
یک کران بالا (upper bound) متغیر نوع را به زیرنوعهای یک نوع محدود میکند و همزمان به تو اجازهٔ استفاده از اعضای آن نوع را میدهد:
// T باید با خودش Comparable باشد تا بتوانیم compareTo صدا بزنیم
public static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T x : list)
if (x.compareTo(best) > 0) best = x;
return best;
}
اگر extends Comparable<T> را برنمیداشتی، کامپایلر نمیگذاشت x.compareTo(best) بنویسی — چون یک T خام معلوم نیست اصلاً متد compareTo داشته باشد. پس کران دو کار همزمان میکند: محدود میکند و قدرت میدهد.
میتوانی چند کران (multiple bounds) با & داشته باشی. قاعده: اگر یک کران کلاسی وجود دارد باید اول بیاید، سپس اینترفیسها:
<T extends Number & Comparable<T>> // T هم Number است هم Comparable
روی یک پارامتر نوع کران super وجود ندارد — <T super X> غیرمجاز است. super فقط در wildcardها ظاهر میشود (که همین الان میرسیم). این عدمتقارن خیلیها را به دام میاندازد؛ آن را همینجا به خاطر بسپار.
Wildcardها و قانون طلایی PECS
اول یک واژه: یک wildcard یعنی همان ?، یک نوع ناشناخته (unknown) است — یعنی «نمیدانم دقیقاً چه نوعی، ولی یک نوع مشخص است».
ولی چرا اصلاً به چنین چیزی نیاز داریم؟ چون جنریکها ناوردا (invariant) هستند. «ناوردا» یعنی رابطهٔ ارثبریِ عنصرها به ظرفها منتقل نمیشود: List<String> یک List<Object> نیست، حتی با اینکه String یک Object است.
یک جعبهٔ «فقط سیب» را تصور کن. اگر اجازه بدهیم آن را بهعنوان یک جعبهٔ «هر میوهای» ببینیم، آنگاه کسی میتواند یک موز داخلش بگذارد. بعد صاحب اصلی که فکر میکند فقط سیب دارد، دست میبرد و انتظار سیب دارد اما موز درمیآورد. ناوردایی جنریک دقیقاً جلوی همین فاجعه را میگیرد.
اگر ناوردایی نبود، این ممکن میشد:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // غیرمجاز — و دلیلش این است
objects.add(42); // یک Integer را در List<String> میگذاشت
String s = strings.get(0); // ClassCastException در زمان اجرا
Wildcardها مقداری از این انعطاف را، اما بهشکل ایمن، بازمیگردانند:
List<? extends Number>— لیستی از یک زیرنوع ناشناختهٔNumber. میتوانیNumberبخوانی، اما نمیتوانی چیزی اضافه کنی (جزnull)، چون کامپایلر نوع دقیق عنصر را نمیداند.List<? super Integer>— لیستی از یک ابرنوع ناشناختهٔInteger. میتوانیInteger(و زیرنوعهایش) اضافه کنی، اما وقتی میخوانی، خروجی بهصورتObjectدرمیآید.
حالا یادیار طلایی که در هر مصاحبهای میپرسند، PECS — Producer Extends, Consumer Super (تولیدکننده extends، مصرفکننده super):
اگر یک ساختار داده به تو داده میدهد (تولیدکننده / producer است، از آن میخوانی)، بنویس ? extends. اگر یک ساختار از تو داده میگیرد (مصرفکننده / consumer است، در آن مینویسی)، بنویس ? super. اگر هم میخوانی هم مینویسی، اصلاً wildcard نزن و نوع دقیق را بگذار.
// src مقدار T تولید میکند -> ? extends T ؛ dst مصرف میکند -> ? super T
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T t : src) dst.add(t);
}
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums); // Integer میخواند، در List<Number> مینویسد — ایمن
و اما سؤالی که همه را زمین میزند: چرا نمیتوان به ? extends اضافه کرد؟ چون List<? extends Number> ممکن است در واقعیت یک List<Long> باشد. اگر کامپایلر اجازه میداد add(3) (یک Integer) بزنی، یک List<Long> را خراب میکردی. تنها مقداری که با هر ? extends Number ممکن سازگار است، null است — چون null هر نوع ارجاعی میتواند باشد.
و wildcard بیکران، یعنی List<?>، بهمعنای «لیستی از یک چیزِ نامعلوم» است. وقتی منطق متد اصلاً به نوع عنصر وابسته نیست (مثل list.size() یا list.clear()) از آن استفاده کن.
این دو را قاطی نکن: میتوانی یک List<String> را به List<?> نسبت دهی، اما به List<Object> نه. اولی یعنی «لیستی از یک نوع، هر چه باشد»؛ دومی یعنی «لیستی که مشخصاً هر شیئی را قبول میکند» — و همانطور که دیدیم، List<String> این نیست.
گرفتن (capture)
تصور کن یک غریبه وارد اتاق میشود و اسمش را نمیدانی. برای اینکه بتوانی دربارهاش حرف بزنی، موقتاً صدایش میکنی «آقای ایکس». حالا در همان مکالمه، هر بار «آقای ایکس» یعنی همان یک نفر. کامپایلر هم با wildcard دقیقاً همین کار را میکند: به آن نوع ناشناخته یک نام داخلی تازه میدهد به اسم CAP#1.
به همین دلیل گاهی خطاهایی مثل «required: CAP#1, found: Object» میبینی. مشکل اینجاست که کامپایلر تضمین نمیکند «آقای ایکس» در دو فراخوانی مختلف همان یک نفر باشد. راهحل کلاسیک، یک متد کمکی جنریک خصوصی است که wildcard را در یک نوع نامدار «میگیرد» (capture میکند):
public static void swapFirstTwo(List<?> list) {
swapHelper(list); // wildcard را در یک نوع نامدار capture کن
}
private static <T> void swapHelper(List<T> list) {
T tmp = list.get(0);
list.set(0, list.get(1));
list.set(1, tmp); // حالا مجاز: نوع عنصر یک نام دارد، T
}
Type erasure و همهٔ چیزهایی که ممنوع میکند
اینجا قلب فصل است. بیا دقیقاً ببینیم کامپایلر چه میکند: هر متغیر نوع را با چپترین کرانش جایگزین میکند (یا با Object اگر بیکران باشد)، castهای لازم را درج میکند و در صورت نیاز متدهای پل (bridge method) میسازد. اطلاعات نوع جنریک فقط در متادیتای .class (صفتهای Signature) برای بازتاب (reflection، یعنی وارسی کد در زمان اجرا) باقی میماند — نه در بررسیهای نوعِ زمانِ اجرا.
پس Box<T> ما عملاً پس از erasure این میشود:
public class Box {
private Object value;
public void set(Object value) { this.value = value; }
public Object get() { return value; }
}
و آن String s = b.get(); سمت فراخواننده، یک cast (String) که کامپایلر خودش درج کرده را میگیرد. حالا بیا شش پیامد عملی این پاکشدن را یکبهیک ببینیم؛ اینها دقیقاً همان «چرا نمیشود»های مصاحبهاند.
۱. نه new T[] نه new T(). زمان اجرا نمیداند T چیست، پس نمیتواند حافظه برایش تخصیص دهد. راهحلهای اصطلاحی:
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[n]; // unchecked، اما ترفند استاندارد
T[] arr2 = (T[]) Array.newInstance(clazz, n); // اگر یک Class<T> همراه داری
۲. نه x instanceof T نه T.class. اینها به اطلاعات نوعِ reified نیاز دارند که وجود ندارد. میتوانی x instanceof List<?> (wildcard بیکران) بنویسی اما نه x instanceof List<String> — دومی خطای کامپایل است.
۳. یک شیء کلاس برای همهٔ پارامترسازیها.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
۴. نمیتوان روی امضاهای erasure-برابر overload کرد. این دو تداخل دارند چون هر دو به foo(List) پاک میشوند:
void foo(List<String> a) {}
void foo(List<Integer> a) {} // خطای کامپایل: name clash، erasure یکسان
۵. اعضای static نمیتوانند از پارامتر نوع کلاس استفاده کنند. یک فیلد static بین همهٔ پارامترسازیها مشترک است؛ چون فقط یک کلاس در زمان اجرا وجود دارد، T آنجا بیمعناست.
۶. استثناهای جنریک. نمیتوانی catch (T e) کنی و یک کلاس جنریک نمیتواند Throwable را extend کند — چون catch بر اساس نوعِ زمان اجرا dispatch (توزیع) میشود که پاک شده است.
هر جا در کد نیاز داشتی جاوا در زمان اجرا بداند T دقیقاً چیست — تخصیص آرایه، instanceof، catch، .class — بدان که به دیوار erasure خوردهای. اطلاعاتی که سراغش را میگیری، همان لحظهٔ کامپایل پاک شده و دیگر آنجا نیست.
متدهای پل (bridge methods)
Erasure یک مشکل ظریف برای override (بازنویسی متد) میسازد. این را ببین:
class StringBox implements Comparable<StringBox> {
public int compareTo(StringBox o) { return 0; }
}
Comparable<StringBox> به Comparable خام پاک میشود که متدش compareTo(Object) است. اما StringBox فقط compareTo(StringBox) را تعریف کرده. حالا چندریختی (polymorphism) به مشکل میخورد: اگر کسی از طریق یک ارجاع Comparable متد را صدا بزند، به compareTo(Object) میرسد که تو تعریفش نکردهای! برای حل این، کامپایلر یک متد پل میسازد:
// مصنوعی، تولیدشده توسط کامپایلر
public int compareTo(Object o) {
return compareTo((StringBox) o); // cast و واگذاری
}
این متد مثل یک پل بین دنیای خامِ Comparable.compareTo(Object) و متد واقعی تو compareTo(StringBox) عمل میکند. به همین دلیل است که یک اسکن بازتابی گاهی متدهای «اضافی» نشان میدهد و گاهی نام bridge در stack trace ظاهر میشود. ضمناً آن cast (StringBox) o میتواند در همان پل ClassCastException بیندازد، اگر کسی Comparable.compareTo خام را با نوع اشتباه صدا بزند.
نوعهای reifiable
یادت هست گفتیم reified یعنی «اطلاعاتش در زمان اجرا موجود است»؟ یک نوع reifiable است اگر اطلاعات کاملش در زمان اجرا در دسترس باشد. اینها reifiable هستند: نوعهای اولیه (primitive مثل int)، نوعهای غیرجنریک، نوعهای خام (List)، wildcardهای بیکران (List<?>) و آرایههای نوعهای reifiable.
اما نوعهای پارامتری مثل List<String> reifiable نیستند — چون همان String داخلش پاک میشود. دقیقاً به همین دلیل است که ساخت آرایه از یک نوع غیرreifiable — new List<String>[10] — خطای کامپایل است، و به همین دلیل varargs از یک نوع جنریک، هشدار @SafeVarargs / «unchecked generic array creation» تولید میکند.
آرایهها هموردا (covariant)؛ جنریکها ناوردا (invariant)
تصور کن دو نگهبان سرِ در انبار داری. نگهبان آرایه خوشبین است: هنگام ورود به تو اجازهٔ زیادی میدهد، اما لحظهای که واقعاً میخواهی جنس اشتباه را قفسه بگذاری، تازه جلویت را میگیرد و داد میزند (خطای زمان اجرا). نگهبان جنریک سختگیر است: همان دمِ در، روی نقشه، اجازهٔ ورود جنس مشکوک را نمیدهد (خطای زمان کامپایل). نگهبان دوم دردسر کمتری در عمل میسازد چون فاجعه را زودتر میگیرد.
آرایهها نوع عنصرشان را در زمان اجرا بهخاطر میسپارند و هموردا (covariant) هستند — یعنی رابطهٔ ارثبری منتقل میشود: String[] یک Object[] هست. این یک حفرهٔ شناختهشده در سیستم نوع است که با یک بررسی زمان اجرا وصله شده:
Object[] arr = new String[3]; // مجاز: covariance
arr[0] = 42; // بدون مشکل کامپایل میشود...
// در زمان اجرا ArrayStoreException میاندازد
جنریکها عمداً بدهبستان مخالف را انتخاب کردند — ناوردایی اعمالشده در زمان کامپایل — دقیقاً برای اینکه خطاهایی مثل ArrayStoreException را به زمان اجرا هل ندهند. همین ناسازگاری فلسفه، دلیل آن است که ترکیب آرایه و جنریک تشویق نمیشود. new T[] ممنوع است، و:
List<String>[] lists = new List<String>[10]; // خطای کامپایل
// اگر مجاز بود، covariance + erasure اجازه میداد یک List<Integer>
// را از طریق alias نوع Object[] ذخیره و بهصورت List<String> unchecked بازخوانی کنی.
بهجای آرایهٔ جنریک، List<List<String>> را ترجیح بده. لیستها با فلسفهٔ ناوردای جنریک هماهنگاند و از دام heap pollution دورت نگه میدارند.
کرانهای جنریک بازگشتی (F-bounded polymorphism)
اعلان Enum<E extends Enum<E>> در نگاه اول دوری و بیمعنا بهنظر میرسد، اما در واقع خیلی دقیق است: میگوید «E یک enum است که مقایسه و هویتش روی خودِ E کار میکند».
تصور کن یک ترازو میسازی که قرار است هر شیء را فقط با همنوع دقیق خودش بسنجد، نه با هر چیزی. برای این کار باید در تعریفِ خود شیء بگویی «من با نمونههایی از نوعِ خودم سنجیده میشوم». این خودارجاعی همان چیزی است که کد پایین میسازد.
این الگوی خودارجاع یا F-bounded باعث میشود متدها زیرنوعِ مشخص را برگردانند، نه پایه را:
// سازندهٔ روان (fluent) که زیرنوع مشخص را برمیگرداند، نه پایه
abstract class Builder<T extends Builder<T>> {
protected abstract T self();
private final List<String> parts = new ArrayList<>();
public T add(String p) { parts.add(p); return self(); }
}
class HtmlBuilder extends Builder<HtmlBuilder> {
protected HtmlBuilder self() { return this; }
public String build() { return "<html>"; }
}
new HtmlBuilder().add("a").add("b").build(); // add() یک HtmlBuilder برمیگرداند، پس build() قابلدیدن است
اگر add نوع پایه Builder را برمیگرداند، بعد از .add("a") دیگر build() را نمیدیدی چون build فقط روی HtmlBuilder است. خودارجاعی این را حل میکند. JDK همین شکل را در کرانهای Comparable<T> و Enum بهکار میبرد.
استنتاج نوع (type inference)
خبر خوب: جاوای مدرن بیشتر آرگومانهای نوع را خودش حدس میزند تا تو کمتر تایپ کنی.
- Diamond یعنی
new HashMap<>()— از هدف انتساب استنتاج میکند (جاوا ۷+). - استنتاج نوع متد — از نوع آرگومانها و هدف. (target typing بهبودیافتهٔ جاوا ۸ اجازه میدهد
Collections.emptyList()داخلList<String> x = Collections.emptyList();درست استنتاج شود.) var(جاوا ۱۰+) — نوع static متغیر را از مقداردهی اولیه استنتاج میکند.var list = new ArrayList<String>();بهlistنوعArrayList<String>میدهد.
مراقب باش: var x = new ArrayList<>(); به ArrayList<Object> استنتاج میشود! چون var میگوید «نوع را از سمت راست بگیر» و diamond میگوید «نوع را از سمت چپ بگیر» — و وقتی هر دو به هم اشاره میکنند، هیچ زمینهای نمیماند و پیشفرض Object میشود. بنویس var x = new ArrayList<String>();.
استنتاج در واقع یک فرایند حلمحدودیت (constraint-solving) است — کامپایلر مجموعهای معادله دربارهٔ نوعها جمع میکند و دنبال جوابی میگردد که همه را ارضا کند. وقتی جوابی نیابد خطاهایی مثل «cannot infer type-variable(s) T» میگیری. با دادن یک شاهد صریح (Foo.<String>bar(...)) یا یک نوع هدفِ صریح رفعش کن.
دامها و نکات ظریف
- نوعهای خام (raw types) (
ListبهجایList<?>) همهٔ بررسی جنریک را برای کل شیء خاموش میکنند، نه فقط یک فراخوانی — یک مینِ عملنکرده زیر پای بقیهٔ کد. تنها دلیل استفاده: همکاری با کد قدیمیِ پیشازجاوا۵. @SuppressWarnings("unchecked")باید روی کوچکترین دامنهٔ ممکن (یک متغیر) باشد، هرگز کل متد، و همیشه با توضیحی که چرا ایمن است.- بازگرداندن
List<? extends X>از API عمومی تو، هر فراخواننده را به حالت فقطخواندنی میبرد — معمولاً چیزی نیست که بخواهی. Wildcardها به پارامترهای متد تعلق دارند، نه معمولاً به نوع بازگشتی. - آلودگی heap (heap pollution) از varargs غیربررسیشده:
@SafeVarargsهشدار را خفه میکند اما یک وعده است که خودت باید تأییدش کنی — هرگز در آرایهٔ varargs ننویس. Class<T>(یک توکن نوع، type token) دریچهٔ فرار وقتی است که واقعاً به اطلاعات نوعِ زمان اجرا نیاز داری:Enum.valueOf(clazz, name)،list.toArray(new String[0])، دیسریالسازی سفارشی.
بهترین شیوهها
۱. نوعها و متدهای جنریک را بر نوع خام و cast ترجیح بده.
۲. PECS را اعمال کن: extends برای تولیدکننده، super برای مصرفکننده، نوع دقیق فقط وقتی پارامتر هر دو است.
۳. @SuppressWarnings("unchecked") را کمینه و مستدل نگه دار.
۴. لیست را بر آرایه ترجیح بده؛ هرگز آرایهٔ جنریک نساز.
۵. از wildcardهای کراندار برای انعطاف API برای فراخواننده استفاده کن، اما نوع بازگشتی را مشخص (concrete) نگه دار.
۶. فقط وقتی erasure واقعاً مانعت شد سراغ توکن Class<T> یا Array.newInstance برو.
سؤالات مصاحبه
حالا وقت آن است که همهچیز را در قالب پرسشوپاسخ محکم کنی. هر سؤال را اول خودت جواب بده، بعد پاسخ را ببین.
کامپایلر متغیرهای نوع را با کرانشان (یا Object) جایگزین میکند، castها را درج میکند و پارامترسازی را از سیستم نوعِ زمان اجرا حذف میکند. پیامدها: نه new T[]، نه x instanceof T / T.class، نمیتوان دو متدی که امضایشان برابر پاک میشود را overload کرد، اعضای static نمیتوانند به T ارجاع دهند، و نوع جنریک نمیتواند Throwable را extend کند.
Producer-Extends, Consumer-Super. Collections.copy(List<? super T> dst, List<? extends T> src): src مقدار T تولید میکند (? extends)، dst مصرفشان میکند (? super). از تولیدکننده میخوانی و در مصرفکننده مینویسی.
چون ممکن است در واقعیت یک List<Long> باشد. کامپایلر نمیتواند اثبات کند هیچ عنصر مشخصی با پارامتر نوعِ واقعیِ ناشناخته سازگار است، پس هر add جز null را رد میکند — و null با همهٔ نوعهای ارجاعی سازگار است.
آرایههای هموردا بررسی ذخیره را به زمان اجرا موکول میکنند (ArrayStoreException)؛ جنریکهای ناوردا خطای معادل را در زمان کامپایل میگیرند. به همین دلیل new T[] و new List<String>[10] ممنوعاند — ترکیب covariance با erasure اجازه میداد آلودگی heap غیربررسیشده به زمان اجرا نفوذ کند.
متدی مصنوعی که کامپایلر برای حفظ چندریختی پس از erasure میسازد. وقتی یک متد جنریک را با پارامتر باریکتر override میکنی (مثلاً compareTo(StringBox) که Comparable.compareTo(Object) را override میکند)، کامپایلر compareTo(Object) را اضافه میکند که cast و واگذاری میکند تا dispatch پویا همچنان متد تو را بیابد.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());
true. فقط یک ArrayList.class وجود دارد؛ پارامترسازی پاک شده، پس هر دو نمونه کلاسِ زمان اجرای یکسان دارند.
public static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
void bad() { String[] arr = new T[10]; } // داخل یک متد جنریک
new T[10] غیرمجاز است — T reifiable نیست، پس نوع عنصر آرایه در زمان اجرا ناشناخته است. رفع: @SuppressWarnings("unchecked") T[] arr = (T[]) new Object[10]; یا یک توکن Class<T> بده و Array.newInstance(clazz, 10) را بهکار ببر.
یک کران F-bounded (خودارجاع): E یک نوع enum است که با خودش پارامتری شده، پس اعضایی مثل compareTo(E) و getDeclaringClass() روی نوع enum مشخص کار میکنند نه پایهٔ خام. همان الگوی سازندههای روانِ خودبازگرداننده.
List<?> لیستی با نوع عنصر ناشناخته است — بهصورت Object خوانده میشود، نمیتوان غیرnull اضافه کرد، اما هنوز type-check میشود. List<Object> لیستی است که مشخصاً Object نگه میدارد — میتوانی هرچیزی اضافه کنی، اما نمیتوانی List<String> را به آن نسبت دهی. List خام بررسی جنریک را کاملاً خاموش و ناایمن است؛ جز برای interop قدیمی اجتنابش کن.
نه — هر دو به f(List) پاک میشوند و خطای name-clash میدهند. Erasure آرگومان نوعِ متمایزکننده را حذف میکند، پس دو امضا یکسان میشوند.
catch و dispatch استثنا در JVM روی کلاسِ زمان اجرا کار میکنند که پاک شده. چون هویت T در زمان اجرا از بین رفته، catch (T e) و class MyException<T> extends Exception ممنوعاند.
وقتی کامپایلر یک wildcard یعنی ? میبیند، یک نام داخلی تازه (CAP#1) به آن میدهد تا بهعنوان یک نوع ناشناختهٔ واحد استدلال کند. عملیاتی که نیاز دارند دو سرِ عملیات همان نوع دقیق را داشته باشند (مثل list.set(i, list.get(j)) روی List<?>) شکست میخورند چون کامپایلر فرض نمیکند CAP#1 در فراخوانیهای مختلف با CAP#1 برابر است. با واگذاری به یک کمکیِ جنریک خصوصی <T> void helper(List<T>) که wildcard را در T نامدار capture میکند رفعش کن.
Reifiable = نوع کامل در زمان اجرا معلوم: primitiveها، کلاسهای غیرجنریک، نوعهای خام، wildcardهای بیکران و آرایههایشان. List<String> reifiable نیست، پس یک پارامتر varargs جنریک آرایهای از نوع غیرreifiable میسازد — از اینرو هشدار «unchecked generic array creation» و انوتیشن @SafeVarargs که وعدهای است هرگز در آن آرایه ننویسی (اجتناب از heap pollution).
Diamond هیچ زمینهای برای استنتاج ندارد، پس به ArrayList<Object> پیشفرض میشود و بیسروصدا نوع عنصر موردنظرت را گشاد میکند. بنویس var x = new ArrayList<String>(); یا List<String> x = new ArrayList<>();.
نکاتِ سنیور و موارد پیشرفته
تا اینجا مکانیک جنریک را فهمیدی: erasure، PECS، واریانس، متد پل. اما آنچه یک سنیور را از یک میدلِ باسواد جدا میکند، این است که بداند این مکانیک در دنیای واقعی — در Jackson، در امضاهای واقعی JDK، در Stream API، در باگهای عجیبِ زمان اجرا — کجا زانو میزند و چطور دورش میزنند. این بخش دقیقاً همان لایه است.
۱. یک استثنا در erasure که ۹۰٪ برنامهنویسها نمیدانند: جایی که اطلاعات نوع جنریک واقعاً در زمان اجرا باقی میماند — و کل کتابخانههای serialization رویش سوارند (super type token).
۲. چرا JDK مینویسد Comparable<? super T> نه Comparable<T> — و کجا نوشتنِ سادهاش کدت را میشکند.
۳. PECS روی اینترفیسهای تابعی (Function<? super T, ? extends R>) — چرا Stream.map اینشکلی امضا شده.
۴. Heap pollution از نمای مکانیکی: CCE واقعاً کجا پرتاب میشود.
۵. قواعد @SafeVarargs و تغییر جاوا ۹.
۶. الگوهای سنیور: generic singleton factory و ظرف ناهمگنِ نوعامن (THC).
۷. هزینهٔ کارایی erasure، چرا IntStream جدا وجود دارد، و Project Valhalla.
۸. تلههای تولیدی: مسمومشدن با raw type، و چند بهروزرسانیِ مدرن جاوا.
استثنای بزرگ erasure: نوع در متادیتای کلاس زنده میماند
در متن اصلی خواندی که erasure اطلاعات نوع را «پاک میکند». این ۹۹٪ درست است، اما یک درِ پشتی حیاتی دارد که کل اکوسیستم microservice رویش بنا شده. erasure اطلاعات نوع را از نمونهها (instances) پاک میکند، ولی از امضای اعضای کلاس در متادیتای .class (همان صفت Signature که در متن اشاره شد) پاک نمیکند. یعنی:
- نوعِ یک شیء در زمان اجرا ناشناخته است:
new ArrayList<String>().getClass()فقطArrayListمیدهد. - ولی نوعی که در کدِ کلاس نوشته شده — امضای فیلد، پارامتر متد، یا ابرکلاس جنریک — از طریق reflection قابل بازیابی است.
وقتی جنس را از جعبه درمیآوری، دیگر برچسبی ندارد (نمونه = erased). اما فاکتور فروشگاه هنوز نوشته «۳ عدد کتاب» (کدِ کلاس = محفوظ). super type token یعنی: بهجای پرسیدن از خودِ کالا، فاکتور را میخوانی.
اینجاست که ترفند super type token زاده میشود. تو نمیتوانی List<String>.class بنویسی، اما میتوانی یک زیرکلاسِ ناشناسِ (anonymous subclass) جنریک بسازی و نوعش را در ابرکلاسش «حک» کنی:
abstract class TypeRef<T> {
private final Type type;
protected TypeRef() {
// getClass() -> کلاسِ ناشناسِ ما ؛ ابرکلاسش TypeRef<List<String>> است
Type sc = getClass().getGenericSuperclass(); // TypeRef<List<String>>
this.type = ((ParameterizedType) sc).getActualTypeArguments()[0];
}
public Type getType() { return type; }
}
// {} یعنی یک زیرکلاسِ ناشناس ساختیم -> نوع در Signature ابرکلاسش قفل شد
TypeRef<List<String>> ref = new TypeRef<List<String>>() {};
System.out.println(ref.getType()); // java.util.List<java.lang.String>
آن {} انتهایی حیاتی است: بدون آن، هیچ زیرکلاسی وجود ندارد و getGenericSuperclass() چیزی نمیدهد. این دقیقاً همان چیزی است که در Jackson (TypeReference)، Gson (TypeToken) و Spring (ParameterizedTypeReference / ResolvableType) میبینی:
// Jackson: بدون این، Jackson نمیداند عناصرِ لیست باید User باشند
List<User> users = mapper.readValue(json, new TypeReference<List<User>>() {});
// Spring RestClient / WebClient
List<User> body = restClient.get().uri("/users").retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
اگر بنویسی mapper.readValue(json, List.class)، Jackson فقط میداند «یک List بساز» و هر عنصر را به LinkedHashMap تبدیل میکند، نه User. بعد وقتی users.get(0).getEmail() صدا میزنی، یک ClassCastException میخوری که به هیچ خطِ کدِ خودت اشاره نمیکند — چون cast را کامپایلر جای دیگری درج کرده. این کلاسیکترین باگِ «چرا LinkedHashMap؟» در microserviceهاست.
نمودار زیر نشان میدهد اطلاعات نوع کجا میمیرد و کجا زنده میماند (کپشن دوزبانه): Where type info dies (instances) vs. survives (class Signature) / کجا نوع میمیرد و کجا میماند:
flowchart TD
Src["Source: TypeRef< —List< —String> —> —(){}"] --> Comp[javac erasure]
Comp --> Inst["Instance runtime type<br/>= raw TypeRef (erased)"]
Comp --> Sig["Class Signature metadata<br/>= TypeRef< —List< —String> —> — (kept)"]
Inst -->|getClass| Lost["List< —String> —? LOST"]
Sig -->|getGenericSuperclass| Found["ParameterizedType<br/>List< —String> — RECOVERED"]
Found --> Libs["Jackson / Gson / Spring<br/>deserialize correctly"]
چرا JDK مینویسد Comparable<? super T> نه Comparable<T>
در متن اصلی متد max را با کران <T extends Comparable<T>> دیدی. ساده و درست است — اما JDK واقعی اینطور امضا نمیکند. امضای واقعیِ Collections.max این است:
public static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)
آن ? super T تصادفی نیست؛ یک کاربردِ ظریفِ PECS روی خودِ کران است. سناریو را ببین:
class Animal implements Comparable<Animal> { ... }
class Dog extends Animal { } // Dog خودش Comparable تعریف نمیکند
List<Dog> dogs = ...;
Dog یک Comparable<Dog> نیست — چون compareTo را از Animal ارث برده، پس Dog در واقع Comparable<Animal> است. حالا:
- با کرانِ ساده
<T extends Comparable<T>>: کامپایلر میخواهدDog extends Comparable<Dog>که برقرار نیست →Collections.max(dogs)کامپایل نمیشود. - با کرانِ واقعی
<T extends Comparable<? super T>>: کامپایلر میپذیردDog extends Comparable<Animal>چونAnimalیک ابرنوعِDogاست → کامپایل میشود.
هر جا نوعی «خودش را مصرف میکند» (یک Comparable یا Consumer که روی T کار میکند)، کرانش را با ? super T بنویس تا زیرکلاسهایی که رفتار را از والد ارث بردهاند هم بپذیرد. این تفاوتِ بین کدِ کتابخانهایِ حرفهای و کدِ شکننده است. در مصاحبه اگر بگویی «چون ممکن است compareTo روی ابرنوع تعریف شده باشد» امتیاز کامل میگیری.
PECS روی اینترفیسهای تابعی: چرا Stream.map آنشکلی است
PECS فقط برای List نیست؛ بهشکل تماشایی روی امضاهای functional-interface در Stream API اعمال میشود. امضای واقعی map:
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
هر دو wildcard منطق دارند و مستقیماً از PECS میآیند:
? super Tروی ورودیِ تابع —Functionیک مصرفکنندهٔTاست (Tرا میگیرد). طبق Consumer-Super، اگر یکFunction<Object, R>داری، باید بتوانی آن را رویStream<String>هم map کنی.? super Tاین را ممکن میکند.? extends Rروی خروجیِ تابع —Functionیک تولیدکنندهٔRاست (Rمیدهد). طبق Producer-Extends، اگر تابعتIntegerبرمیگرداند، باید بتوانی جریانی ازNumberبسازی.
همین الگو در همهجای JDK هست: Consumer<? super T> در forEach، Supplier<? extends T> در Optional.or، Predicate<? super T> در filter. یادیار: پارامترِ ورودیِ تابع = مصرف = super ؛ نتیجهٔ تابع = تولید = extends. دلیلی که stream.map(Object::toString) روی هر جریانی کار میکند، دقیقاً همین ? super T است — و کدِ کتابخانهایِ خوب، wildcardها را سخاوتمندانه روی پارامترها میپاشد.
Heap pollution از نمای مکانیکی: cast واقعاً کجا میترکد؟
متن اصلی heap pollution را نام برد، اما نکتهٔ سنیوری این است که بفهمی چطور اتفاق میافتد و کجا منفجر میشود. تعریف دقیق: heap pollution یعنی متغیری از نوعِ پارامتریشده به شیئی اشاره کند که آن نوع نیست — مثلاً یک List<String> که واقعاً Integer دارد. erasure این را تا لحظهٔ خواندن پنهان نگه میدارد.
List<String> strings = new ArrayList<>();
List raw = strings; // raw type: هشدار unchecked
raw.add(42); // heap pollution! یک Integer داخل List<String>
String s = strings.get(0); // <-- ClassCastException اینجا پرتاب میشود
خطِ raw.add(42) بیسروصدا رد میشود — هیچ استثنایی نمیدهد، چون در زمان اجرا add فقط Object میگیرد. انفجار در strings.get(0) رخ میدهد، جایی که کامپایلر یک (String) نامرئی درج کرده. این یعنی در لاگِ prod، استثنا به خطی اشاره میکند که هیچ cast صریحی ندارد — تلهٔ کلاسیکِ دیباگ. همیشه دنبال منبعِ آلودگی (raw type یا @SuppressWarnings بدجا) بگرد، نه خطی که ترکید.
راهِ دومِ تزریق آلودگی، varargs جنریک است — که مستقیماً ما را به @SafeVarargs میرساند.
قواعد دقیق @SafeVarargs و تغییر جاوا ۹
هر متدی که یک پارامترِ varargs از نوع جنریک بگیرد (T... args)، پشتصحنه یک T[] میسازد — و چون آرایهٔ نوعِ غیرقابلتجسم (non-reifiable) ممنوع است، کامپایلر هشدار «unchecked generic array creation» میدهد. @SafeVarargs این هشدار را ساکت میکند، اما یک قول است که خودت باید تضمینش کنی: «من داخل آن آرایه چیزی نمینویسم و ارجاعش را بیرون نمیدهم».
@SafeVarargs
static <T> List<T> listOf(T... items) { // فقط میخوانیم -> امن
return new ArrayList<>(Arrays.asList(items));
}
// نمونهٔ ناامن — چرا @SafeVarargs یک قول است نه یک تضمین کامپایلری:
@SafeVarargs
static <T> T[] dangerous(T... items) {
Object[] arr = items;
arr[0] = "boom"; // heap pollution اگر T مثلا Integer باشد
return items;
}
@SafeVarargs فقط روی متدهایی مجاز است که قابل override نیستند — چون یک زیرکلاس میتواند override را ناامن کند. پیش از جاوا ۹ این یعنی: متدهای static، متدهای final نمونه، و سازندهها. از جاوا ۹ به بعد، متدهای private نمونه هم اضافه شدند (چون private هم قابل override نیست). اگر پیش از جاوا ۹ روی یک متد private بگذاریاش، خطای کامپایل میگیری.
الگوی سنیورِ جنریک: ظرفِ ناهمگنِ نوعامن
گاهی میخواهی یک نگاشت (map) داشته باشی که کلیدهایش نوعهای مختلف را نگه دارند و هر مقدار دقیقاً با نوعِ کلیدش همخوان باشد. ترفندِ typesafe heterogeneous container: بهجای پارامتریکردنِ خودِ ظرف، کلید را پارامتری کن — از Class<T> بهعنوان type token استفاده کن:
public class Favorites {
private final Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> type, T instance) {
map.put(Objects.requireNonNull(type), type.cast(instance)); // cast دفاعی
}
public <T> T get(Class<T> type) {
return type.cast(map.get(type)); // cast پویا -> امنیت زمان اجرا
}
}
Favorites f = new Favorites();
f.put(String.class, "hello");
f.put(Integer.class, 42);
String s = f.get(String.class); // بدون cast، کاملاً نوعامن
(T) x یک cast بدونِبررسیِ زمان اجراست (erasure!)، اما Class::cast یک cast پویاست که در زمان اجرا واقعاً بررسی میکند و اگر بد باشد همانجا ClassCastException میدهد — نه بعداً و جای دیگر. این پل زدن بین «نوعِ زمان کامپایل» و «بررسیِ زمان اجرا» امضای یک سنیور است. Enum.valueOf, EnumMap, و بخش زیادی از Spring ApplicationContext.getBean(Class) روی همین الگو سوارند.
هزینهٔ کارایی erasure، IntStream، و Project Valhalla
erasure فقط یک بحث آکادمیک نیست؛ یک هزینهٔ کارایی واقعی دارد. چون T در زمان اجرا Object است، جنریکها نمیتوانند primitive نگه دارند — List<int> وجود ندارد، فقط List<Integer>. یعنی هر عدد boxed میشود: یک شیءِ جدا روی heap، با سربارِ حافظه و فشار روی garbage collector و از دست رفتنِ locality در cache.
یک List<Long> با میلیونها عنصر میتواند چند برابرِ یک long[] حافظه ببرد و بهطرز محسوسی کندتر باشد. دقیقاً به همین دلیل JDK مجبور شد IntStream، LongStream، DoubleStream را جدا بسازد — چون Stream<int> ممکن نیست و بدون آنها هر عملیاتِ عددی غرقِ boxing میشد. وقتی روی داغِ محاسباتِ عددی هستی، عمداً از تخصصیهای primitive استفاده کن.
Project Valhalla تلاش میکند این شکاف را ببندد. JEP 401 (Value Classes and Objects) بهصورت preview در JDK 28 وارد میشود (ادغام حدود میانهٔ ۲۰۲۶، انتشار مارس ۲۰۲۷): value classها هویت (identity) ندارند، پس JVM میتواند آنها را flatten و scalarize کند تا Integer[] به کاراییِ int[] نزدیک شود. اما دقت کن: generics تخصصیشده (specialized generics) — یعنی List<Point> که واقعاً تخت ذخیره شود — هنوز فاز ۲ است و در JDK 28 نیست. تا آن روز، بهخاطر erasure عناصرِ یک مجموعهٔ جنریک همچنان روی heap materialize میشوند.
تلههای تولیدی و چند بهروزرسانی مدرن
متن اصلی گفت raw type «همهٔ بررسیها را خاموش میکند»، اما وسعتش را دستکم نگیر: طبق JLS وقتی از یک raw type استفاده میکنی، همهٔ اعضای جنریکِ آن کلاس erased میشوند — حتی متدهایی که هیچ ربطی به پارامترِ نوعِ کلاس ندارند. مثال تکاندهنده:
class Box<T> {
T val;
<U> U identity(U u) { return u; } // U کاملا مستقل از T
}
Box raw = new Box();
Number n = raw.identity(42); // خطای کامپایل! خروجی به Object erase شد
اینجا identity هیچ ربطی به T ندارد، ولی چون Box را raw صدا زدی، کلِ متدهای جنریکش erased شدند. این نشان میدهد raw type یک تصمیمِ سطحِکلاس است نه سطحِفراخوانی.
چند بهروزرسانی که یک سنیورِ ۲۰۲۶ باید بشناسد:
- Diamond با کلاس ناشناس (جاوا ۹+). پیش از جاوا ۹
new Box<>() {}(دیاموند + بدنهٔ کلاس ناشناس) ممنوع بود و باید نوع را صریح مینوشتی. از جاوا ۹ اگر نوعِ استنتاجشده «قابلبیان» (denotable) باشد، مجاز است. - Type annotations (JSR 308، جاوا ۸+). حالا میتوانی روی آرگومانهای نوع هم annotation بگذاری:
List<@NonNull String>. ابزارهایی مثل Checker Framework از این برای بررسیِ null در زمان کامپایل استفاده میکنند — لایهای فراتر از خودِ جنریک. - cast با نوعِ اشتراکی (intersection type). میتوانی یک lambda را به دو نوع همزمان cast کنی تا هم رفتار و هم نشانهگر بگیرد:
(Runnable & Serializable) () -> {}. این یک لامبدای serializable میسازد بدون کلاس نامدار.
- erasure نوع را از نمونهها پاک میکند نه از امضای کلاس؛ super type token (
new TypeReference<...>(){}) از این درِ پشتی استفاده میکند و کل serialization جاوا رویش سوار است. - JDK کرانها را با
? super Tمینویسد (Comparable<? super T>) تا زیرکلاسهایی که رفتار را ارث بردهاند هم بپذیرد. - PECS روی توابع هم هست:
Function<? super T, ? extends R>— ورودی مصرف (super)، خروجی تولید (extends). - heap pollution بیصدا تزریق میشود ولی جایی میترکد که cast نامرئیِ کامپایلر است — نه خطِ آلوده.
@SafeVarargsیک قول است؛ از جاوا ۹ روی متدهای private نمونه هم مجاز شد.- الگوهای طلایی: generic singleton factory و ظرفِ ناهمگنِ نوعامن با
Class::cast. - erasure یعنی boxing و نبودِ
Stream<int>— دلیلِ وجودِIntStream؛ Valhalla (JEP 401، JDK 28 preview) هویت را حذف میکند ولی generics تخصصیشده هنوز فاز ۲ است.
سؤالات مصاحبهٔ سطح سنیور
با ترفند super type token. List<User>.class وجود ندارد، اما اگر یک زیرکلاسِ ناشناس بسازی — new TypeReference<List<User>>() {} — آن List<User> در صفتِ Signatureِ ابرکلاسِ آن زیرکلاس حک میشود، و erasure نمونهها را پاک میکند نه متادیتای کلاس را. Jackson با getClass().getGenericSuperclass() و cast به ParameterizedType آن List<User> را بازیابی میکند و میفهمد عناصر باید User باشند. بدون آن، readValue(json, List.class) هر عنصر را LinkedHashMap میسازد و بعداً یک ClassCastException میگیری. همین مکانیک پشتِ Gson TypeToken و Spring ParameterizedTypeReference هم هست.
چون یک زیرکلاس ممکن است compareTo را از والدش ارث ببرد و بنابراین Comparable<Parent> باشد نه Comparable<Self>. مثلاً class Animal implements Comparable<Animal> و class Dog extends Animal — اینجا Dog یک Comparable<Animal> است، نه Comparable<Dog>. با کرانِ سادهٔ Comparable<T>، فراخوانیِ Collections.max(listOfDogs) کامپایل نمیشود چون Dog extends Comparable<Dog> برقرار نیست. ? super T میگوید «T باید با خودش یا هر ابرنوعش قابلمقایسه باشد» و مورد Dog/Animal را میپذیرد. این کاربردِ PECS روی خودِ کران است: Comparable مصرفکنندهٔ T است، پس Consumer-Super.
هر دو مستقیماً از PECS میآیند. یک Function ورودیاش را مصرف میکند و خروجیاش را تولید میکند. ? super T روی ورودی (Consumer-Super) اجازه میدهد یک تابعِ عمومیتر — مثلاً Function<Object, R> — روی Stream<String> هم بهکار برود؛ به همین خاطر map(Object::toString) روی هر جریانی کار میکند. ? extends R روی خروجی (Producer-Extends) اجازه میدهد تابعی که زیرنوعِ R میسازد (مثلاً Integer وقتی R برابر Number است) خروجیِ درستی بدهد. این الگو در Consumer<? super T> (forEach)، Supplier<? extends T> و Predicate<? super T> تکرار میشود.
Heap pollution یعنی متغیری از نوعِ پارامتریشده به شیئی اشاره کند که آن نوعِ واقعی را ندارد — مثل List<String> که یک Integer درش است. معمولاً از راهِ raw type یا @SuppressWarnings("unchecked")ِ نادرست یا varargs جنریک تزریق میشود. نکتهٔ ظریف: خطِ آلودهکننده (مثل raw.add(42)) هیچ استثنایی نمیدهد، چون در زمان اجرا add فقط Object میگیرد. انفجار وقتی رخ میدهد که مقدار را میخوانی — مثل String s = list.get(0) — چون آنجا کامپایلر یک cast نامرئیِ (String) درج کرده. پس CCE به خطی اشاره میکند که هیچ cast صریحی ندارد؛ برای دیباگ باید منبعِ آلودگی را پیدا کنی نه خطی که ترکید.
قولش این است: «این متد داخلِ آرایهٔ varargsِ جنریک چیزی نمینویسد و ارجاعِ آن آرایه را به بیرون درز نمیدهد» — یعنی heap pollution ایجاد نمیکند. کامپایلر این را بررسی نمیکند؛ صرفاً هشدارِ «unchecked generic array creation» را ساکت میکند و مسئولیتش با توست. چون یک زیرکلاس میتواند یک override را ناامن کند، annotation فقط روی متدهای غیرقابلoverride مجاز است: پیش از جاوا ۹ یعنی static، final نمونه، و سازندهها. از جاوا ۹ متدهای private نمونه هم اضافه شدند (private هم override نمیشود). اگر پیش از جاوا ۹ روی private بگذاری، خطای کامپایل میگیری.
چون T بعد از erasure برابر Object است، جنریکها فقط نوعهای ارجاعی نگه میدارند نه primitive؛ پس List<int> یا Stream<int> غیرممکن است و هر عدد باید boxed شود — یک Integer جدا روی heap، با سربارِ حافظه، فشارِ GC و از دست رفتنِ cache locality. برای همین JDK مجبور شد IntStream/LongStream/DoubleStream را جدا بسازد تا مسیرهای عددیِ داغ غرقِ boxing نشوند. Project Valhalla (JEP 401، preview در JDK 28) با value classهای بدونهویت اجازه میدهد JVM آنها را flatten کند تا Integer[] به int[] نزدیک شود — اما specialized generics (که List<Point> را واقعاً تخت کند) هنوز فاز ۲ است و در JDK 28 نیست، پس عناصرِ مجموعههای جنریک همچنان روی heap materialize میشوند.
حتی متدهای جنریکی که هیچ ربطی به پارامترِ نوعِ کلاس ندارند هم erased میشوند. مثلاً:
class Box<T> {
<U> U identity(U u) { return u; } // U مستقل از T
}
Box raw = new Box(); // raw type
Number n = raw.identity(42); // خطای کامپایل: خروجی Object است
اینجا identity هیچ ارتباطی با T ندارد، ولی چون Box را بهصورت raw استفاده کردی، طبق JLS همهٔ متدهای جنریکِ آن نوع erased میشوند و identity خروجیِ Object میدهد. درسش: raw type یک تصمیمِ سطحِکلاس است — همه یا هیچ — نه فقط دربارهٔ همان یک فراخوانی. هرگز از raw type استفاده نکن مگر برای interop با کدِ پیشازجاوا۵.
از Class<T> بهعنوان type token برای کلید استفاده میکنی، نه پارامتریکردنِ خودِ ظرف:
class Favorites {
private final Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> type, T value) { map.put(type, type.cast(value)); }
public <T> T get(Class<T> type) { return type.cast(map.get(type)); }
}
(T) map.get(type) یک cast بدونِبررسی است — بهخاطر erasure در زمان اجرا هیچکاری نمیکند و اگر نوع اشتباه باشد، خطا بعداً و جای دیگری میترکد. اما type.cast(...) یک cast پویاست که در همان لحظه نوع را واقعاً بررسی میکند و در صورت خطا فوراً ClassCastException میدهد. این «پل زدنِ» نوعِ زمانکامپایل به بررسیِ زماناجرا امنیت را کامل میکند و همان الگویی است که EnumMap, Collections.checkedList, و ApplicationContext.getBean(Class) در Spring بهکار میبرند.
- جنریکها خطاها را از زمان اجرا به زمان کامپایل میآورند، سپس با erasure نوع را پاک میکنند تا با کد قدیمی سازگار بمانند — و همین یک تصمیم، ریشهٔ تقریباً همهٔ محدودیتهاست.
- در زمان کامپایل
List<String>وList<Integer>متفاوتاند؛ در زمان اجرا هر دو فقطListبا یکClassمشترکاند. - کرانها با
extendsمحدود و توانمند میکنند؛superروی پارامتر نوع وجود ندارد. - PECS: از تولیدکننده بخوان (
? extends)، در مصرفکننده بنویس (? super)؛ به? extendsنمیشود add کرد چون شایدList<Long>باشد. - Erasure اینها را ممنوع میکند:
new T[]،new T()،instanceof T،T.class، overload با erasure یکسان،Tدر اعضای static، و extend کردنThrowable. - متدهای پل چندریختی را پس از erasure حفظ میکنند.
- آرایهها هموردا (خطا در زمان اجرا:
ArrayStoreException)، جنریکها ناوردا (خطا در زمان کامپایل) — پس لیست را بر آرایهٔ جنریک ترجیح بده. - الگوی F-bounded (
Enum<E extends Enum<E>>) نوع مشخص را برمیگرداند؛ به درد سازندههای روان میخورد. - مراقب
var x = new ArrayList<>();باش کهObjectمیشود، و از raw type و@SuppressWarningsبیقید پرهیز کن.
Let's be honest from the start: generics are one of those parts of Java everyone thinks they know, right up until an interviewer asks "why can't you add to a List<? extends Number>?" and the mind goes blank. The whole subject actually pivots on a single design decision, and once you truly understand that one decision, the rest of the questions topple like dominoes. Let's dig into that one decision together.
We'll move step by step:
- The foundational mental model — generics as a "compile-time contract," and the idea of erasure.
- Generic classes and methods — how to parameterize a type.
- Bounds — restricting
Twithextends. - Wildcards and the golden PECS rule — why and when to use
?,? extends,? super. - Type erasure and everything it forbids — the beating heart of the chapter.
- Bridge methods — the hidden methods the compiler generates.
- Reifiable types, array vs. generic variance, recursive bounds, type inference.
- Pitfalls, best practices, and 14 interview questions with full answers.
Don't worry about new jargon — we build every term from scratch the moment it first appears.
Part 0 — a few words you must feel
Before we begin, plant three words in your head with a simple analogy so we can move faster later.
Imagine you run a staffing agency. Compile time is like the stage where you write on paper "this role is only for a software engineer." Runtime is the day the employee actually shows up for work. Here's the strange twist with generics: after the contract is signed, the agency erases that "software engineer only" clause from the paper and just writes "employee." We call that erasing erasure. The compiler is strict while signing, but at runtime that specialty clause is no longer on the paper.
- type safety: the guarantee that a wrong-typed value (an integer) never lands in a string container — and it's the compiler that gives this guarantee, not hope and prayer.
- erasure: removing generic type information when code is compiled to bytecode, for compatibility with old code.
- reified: means "its information is actually present at runtime." Generics are not reified; they're erased.
Mental model: generics are a compile-time contract
Generics in Java exist for exactly one purpose: to move type errors from runtime to compile time. That is, instead of your program blowing up in the user's face, you catch the error the moment you write the code.
Before Java 5 you were forced to write:
List list = ...;
String s = (String) list.get(0); // you hoped it really was a String
That (String) is a cast — you're telling the compiler "trust me, this is a string." But if you were wrong, you only found out at runtime with a ClassCastException. Generics replaced "trust me" with "prove it": the compiler itself proves the cast is safe, then erases the type info so the bytecode is (almost) identical to the pre-generics version. That single decision — erasure for backward compatibility — explains nearly every "why can't I do X" question about generics.
Keep these two sentences balanced like the two pans of a scale; the whole chapter springs from the tension between them:
At compile time,
List<String>andList<Integer>are completely different, incompatible types. The compiler enforces this rigorously.At runtime, both are just
List. There is noList<String>.class; onlyList.classexists. There is exactly oneClassobject forArrayList, regardless of parameterization (the thing you put inside<>).
Generic classes and methods
Picture a mailing box with a blank slot for a label. The box itself is always the same, but once you stick on the label "books," you expect only books inside; a "glasses" label means only glasses. That blank label slot is the type variable, and we name it T.
A generic class parameterizes over one or more type variables:
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> b = new Box<>(); // the diamond operator infers <String>
b.set("hi");
String s = b.get(); // no cast needed
That empty <> on the right is the diamond operator; because you already said Box<String> on the left, the compiler fills it in and you don't repeat String.
Now notice: a generic method can declare its own type parameters, independent of any class parameters. The key syntactic point is that <T> goes before the return type:
public static <T> List<T> singletonList(T item) {
List<T> list = new ArrayList<>();
list.add(item);
return list;
}
// Usually inferred, but you can supply it explicitly:
List<String> xs = Collections.<String>emptyList();
Naming convention (readability only, nothing enforces it): single uppercase letters — T (type), E (element), K/V (key/value), R (result), N (number).
The compiler wouldn't object if you named your type variable Banana. But when a reader sees E they instantly think "element of a collection," and K/V means "key/value." Following the convention is a courtesy to whoever reads your code.
Bounded type parameters
Suppose you build a contest where you must "compare" contestants to find the winner. You can't accept just anything — a rock doesn't compare itself to another rock. So you set a condition: "only things that know how to compare themselves to their own kind." That condition on the type is the bound.
An upper bound restricts a type variable to subtypes of some type, and simultaneously grants you access to that type's members:
// T must be Comparable with itself, so we can call compareTo
public static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T x : list)
if (x.compareTo(best) > 0) best = x;
return best;
}
Without extends Comparable<T>, the compiler wouldn't let you write x.compareTo(best) — a bare T isn't known to even have a compareTo. So a bound does two things at once: it restricts and it empowers.
You can have multiple bounds with &. The rule: a class bound (if any) must come first, then interfaces:
<T extends Number & Comparable<T>> // T is a Number AND Comparable
There is no super bound on a type parameter — <T super X> is illegal. super only appears in wildcards (which we're about to reach). This asymmetry trips a lot of people up; commit it to memory right here.
Wildcards and the golden PECS rule
First, a word: a wildcard is ?, an unknown type — meaning "I don't know exactly which type, but it is one specific type."
But why do we even need such a thing? Because generics are invariant. "Invariant" means the subtype relationship of the elements does not carry over to the containers: List<String> is not a List<Object>, even though String is an Object.
Picture an "apples only" box. If we let it be viewed as an "any fruit" box, then someone could drop a banana in. Later the real owner, who thinks they only have apples, reaches in expecting an apple and pulls out a banana. Generic invariance prevents exactly that disaster.
If there were no invariance, this would be possible:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // ILLEGAL — and here's why
objects.add(42); // would put an Integer into a List<String>
String s = strings.get(0); // ClassCastException at runtime
Wildcards restore some of that flexibility, but safely:
List<? extends Number>— a list of some unknown subtype ofNumber. You can readNumbers out, but you cannot add anything (exceptnull), because the compiler doesn't know the exact element type.List<? super Integer>— a list of some unknown supertype ofInteger. You can addIntegers (and its subtypes), but reads come out asObject.
Now the golden mnemonic they ask in every interview, PECS — Producer Extends, Consumer Super:
If a data structure gives you data (it's a producer, you read from it), write ? extends. If a structure takes data from you (it's a consumer, you write into it), write ? super. If you both read and write, don't use a wildcard at all — use the exact type.
// src PRODUCES T's -> ? extends T ; dst CONSUMES T's -> ? super T
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T t : src) dst.add(t);
}
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>();
copy(ints, nums); // reads Integers, writes into a List<Number> — safe
And the question that floors everyone: why can't you add to a ? extends? Because List<? extends Number> might in reality be a List<Long>. If the compiler let you add(3) (an Integer), you'd corrupt a List<Long>. The only value guaranteed compatible with every possible ? extends Number is null — because null can be any reference type.
An unbounded wildcard, List<?>, means "list of some unknown thing." Use it when the method logic doesn't depend on the element type at all (e.g. list.size(), list.clear()).
Don't confuse these: you can assign a List<String> to a List<?>, but not to a List<Object>. The first means "a list of some one type, whatever it is"; the second means "a list that specifically accepts any object" — and as we saw, a List<String> is not that.
Capture
Picture a stranger walking into the room whose name you don't know. To talk about them, you temporarily call them "Mr. X." Now, within that same conversation, every "Mr. X" means that one same person. The compiler does exactly this with a wildcard: it gives the unknown type a fresh internal name, CAP#1.
This is why you'll occasionally see errors like "required: CAP#1, found: Object." The trouble is the compiler won't guarantee that "Mr. X" is the same person across two different calls. The classic fix is a private generic helper method that captures the wildcard into a named type:
public static void swapFirstTwo(List<?> list) {
swapHelper(list); // capture the wildcard into a named type
}
private static <T> void swapHelper(List<T> list) {
T tmp = list.get(0);
list.set(0, list.get(1));
list.set(1, tmp); // now legal: the element type has a name, T
}
Type erasure and everything it forbids
Here is the heart of the chapter. Let's see exactly what the compiler does: it replaces each type variable with its leftmost bound (or Object if unbounded), inserts the necessary casts, and generates bridge methods where needed. The generic type information survives only in .class metadata (Signature attributes) for reflection (inspecting code at runtime) — not in the runtime type checks.
So our Box<T> effectively becomes, after erasure:
public class Box {
private Object value;
public void set(Object value) { this.value = value; }
public Object get() { return value; }
}
And the caller's String s = b.get(); gets a compiler-inserted (String) cast. Now let's walk through the six practical consequences of this erasure, one by one; these are precisely the interview "why can't I" questions.
1. No new T[] and no new T(). The runtime doesn't know what T is, so it can't allocate memory for it. Idiomatic workarounds:
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[n]; // unchecked, but the standard trick
T[] arr2 = (T[]) Array.newInstance(clazz, n); // if you carry a Class<T> token
2. No x instanceof T and no T.class. These need reified type info that doesn't exist. You can write x instanceof List<?> (unbounded wildcard) but not x instanceof List<String> — the latter is a compile error.
3. One class object for all parameterizations.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
4. You cannot overload on erasure-equal signatures. These two clash because both erase to foo(List):
void foo(List<String> a) {}
void foo(List<Integer> a) {} // COMPILE ERROR: name clash, same erasure
5. Static members can't use the class's type parameter. There's one static field shared across all parameterizations; since there's only one runtime class, T is meaningless there.
6. Generic exceptions. You can't catch (T e), and a generic class can't extend Throwable — because catch dispatches on the runtime type, which is erased.
Anywhere in your code you need Java to know at runtime exactly what T is — array allocation, instanceof, catch, .class — know you've hit the erasure wall. The information you're asking for was erased at the moment of compilation and simply isn't there anymore.
Bridge methods
Erasure creates a subtle problem for overriding. Consider:
class StringBox implements Comparable<StringBox> {
public int compareTo(StringBox o) { return 0; }
}
Comparable<StringBox> erases to raw Comparable, whose method is compareTo(Object). But StringBox only defines compareTo(StringBox). Now polymorphism runs into trouble: if someone calls the method through a Comparable reference, they land on compareTo(Object), which you never defined! To fix this, the compiler synthesizes a bridge method:
// synthetic, generated by the compiler
public int compareTo(Object o) {
return compareTo((StringBox) o); // casts and delegates
}
This method acts like a bridge between the raw world of Comparable.compareTo(Object) and your real method compareTo(StringBox). That's why a reflective scan sometimes shows "extra" methods and why bridge names sometimes surface in stack traces. Also, that (StringBox) o cast can throw ClassCastException right at the bridge if someone calls raw Comparable.compareTo with the wrong type.
Reifiable types
Remember we said reified means "its information is present at runtime"? A type is reifiable if its full information is available at runtime. These are reifiable: primitives (like int), non-generic types, raw types (List), unbounded wildcards (List<?>), and arrays of reifiable types.
But parameterized types like List<String> are not reifiable — because that inner String is erased. This is precisely why array creation of a non-reifiable type — new List<String>[10] — is a compile error, and why varargs of a generic type produce the @SafeVarargs / "unchecked generic array creation" warning.
Arrays are covariant; generics are invariant
Imagine two guards at your warehouse door. The array guard is optimistic: at entry he lets a lot slide, but the moment you actually try to put the wrong item on the shelf, he stops you and shouts (a runtime error). The generic guard is strict: right at the door, on the blueprint, he refuses to admit suspicious goods (a compile-time error). The second guard causes fewer problems in practice because he catches the disaster earlier.
Arrays remember their component type at runtime and are covariant — meaning the inheritance relationship carries over: String[] is a Object[]. This is a known hole in the type system, patched with a runtime check:
Object[] arr = new String[3]; // legal: covariance
arr[0] = 42; // compiles fine...
// throws ArrayStoreException at runtime
Generics deliberately chose the opposite tradeoff — invariance enforced at compile time — precisely so as not to push errors like ArrayStoreException to runtime. This clash of philosophies is why mixing arrays and generics is discouraged. new T[] is banned, and:
List<String>[] lists = new List<String>[10]; // COMPILE ERROR
// If allowed, covariance + erasure would let you store a List<Integer>
// through an Object[] alias and read it back as List<String> unchecked.
Prefer List<List<String>> over a generic array. Lists align with the invariant philosophy of generics and keep you clear of heap pollution.
Recursive generic bounds (F-bounded polymorphism)
The declaration Enum<E extends Enum<E>> looks circular and meaningless at first glance, but it's actually very precise: it says "E is an enum whose comparison and identity operate on E itself."
Picture building a scale meant to weigh each object only against its exact own kind, not against anything. To do that, in the object's own definition you must say "I am measured against instances of my own type." That self-reference is exactly what the code below builds.
This self-referential or F-bounded pattern makes methods return the concrete subtype, not the base:
// Fluent builder that returns the concrete subtype, not the base
abstract class Builder<T extends Builder<T>> {
protected abstract T self();
private final List<String> parts = new ArrayList<>();
public T add(String p) { parts.add(p); return self(); }
}
class HtmlBuilder extends Builder<HtmlBuilder> {
protected HtmlBuilder self() { return this; }
public String build() { return "<html>"; }
}
new HtmlBuilder().add("a").add("b").build(); // add() returns HtmlBuilder, so build() is visible
If add returned the base type Builder, then after .add("a") you'd no longer see build() because build only exists on HtmlBuilder. The self-reference solves this. The JDK uses the same shape in Comparable<T> bounds and Enum.
Type inference
Good news: modern Java infers most type arguments for you, so you type less.
- Diamond,
new HashMap<>()— infers from the assignment target (Java 7+). - Method type inference — from argument and target types. (Java 8's improved target typing lets
Collections.emptyList()infer correctly insideList<String> x = Collections.emptyList();.) var(Java 10+) — infers the variable's static type from the initializer.var list = new ArrayList<String>();giveslisttypeArrayList<String>.
Watch out: var x = new ArrayList<>(); infers ArrayList<Object>! Because var says "take the type from the right" and the diamond says "take the type from the left" — and when both point at each other, no context remains and it defaults to Object. Write var x = new ArrayList<String>();.
Inference is really a constraint-solving process — the compiler collects a set of equations about the types and looks for a solution that satisfies them all. When it can't find one you get errors like "cannot infer type-variable(s) T." Fix it by supplying an explicit witness (Foo.<String>bar(...)) or an explicit target type.
Common pitfalls and gotchas
- Raw types (
Listinstead ofList<?>) turn off all generic checking for the whole object, not just the one call — an unexploded mine under the rest of your code. The only reason to use them: interoperating with legacy pre-Java-5 code. @SuppressWarnings("unchecked")should be on the smallest possible scope (a single variable), never a whole method, and always with a comment justifying why it's safe.- Returning
List<? extends X>from your public API forces every caller into read-only mode — usually not what you want. Wildcards belong on method parameters, not typically on return types. - Heap pollution from unchecked varargs:
@SafeVarargssilences the warning but is a promise you must verify — never write into the varargs array. Class<T>(a type token) is the escape hatch when you genuinely need runtime type info:Enum.valueOf(clazz, name),list.toArray(new String[0]), custom deserialization.
Best practices
- Favor generic types and methods over raw types and casts.
- Apply PECS:
extendsfor producers,superfor consumers, exact type only when a parameter is both. - Keep
@SuppressWarnings("unchecked")minimal and justified. - Prefer lists to arrays; never create generic arrays.
- Use bounded wildcards to make APIs flexible for callers, but keep return types concrete.
- Reach for a
Class<T>token orArray.newInstanceonly when erasure genuinely blocks you.
Interview Questions
Now it's time to lock everything in with Q&A. Answer each one yourself first, then read the answer.
The compiler replaces type variables with their bounds (or Object), inserts casts, and discards parameterization from the runtime type system. Consequences: no new T[], no x instanceof T / T.class, can't overload two methods whose signatures erase equal, static members can't reference T, and generic types can't extend Throwable.
Producer-Extends, Consumer-Super. Collections.copy(List<? super T> dst, List<? extends T> src): src produces T's (? extends), dst consumes them (? super). You read from producers and write to consumers.
Because it might really be a List<Long>. The compiler can't prove any specific element is assignment-compatible with the unknown actual type parameter, so it rejects every add except null, which is compatible with all reference types.
Covariant arrays defer a store check to runtime (ArrayStoreException); invariant generics catch the equivalent error at compile time. This is why new T[] and new List<String>[10] are banned — combining covariance with erasure would let unchecked heap pollution slip through to runtime.
A synthetic method the compiler creates to preserve polymorphism after erasure. When you override a generic method with a narrowed parameter (e.g. compareTo(StringBox) overriding Comparable.compareTo(Object)), the compiler adds compareTo(Object) that casts and delegates so dynamic dispatch still finds your method.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());
true. There is a single ArrayList.class; parameterization is erased, so both instances share the same runtime class.
public static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
void bad() { String[] arr = new T[10]; } // inside some generic method
new T[10] is illegal — T is not reifiable, so the array's component type is unknown at runtime. Fix: @SuppressWarnings("unchecked") T[] arr = (T[]) new Object[10]; or pass a Class<T> token and use Array.newInstance(clazz, 10).
An F-bounded (self-referential) bound: E is an enum type parameterized by itself, so members like compareTo(E) and getDeclaringClass() operate on the concrete enum type rather than the raw base. It's the same pattern used for self-returning fluent builders.
List<?> is a list of unknown element type — read as Object, can't add non-null, still type-checked. List<Object> is a list that specifically holds Object — you can add anything, but you can't assign a List<String> to it. Raw List disables generic checks entirely and is unsafe; avoid except for legacy interop.
No — both erase to f(List), producing a name-clash compile error. Erasure removes the distinguishing type argument, so the two signatures become identical.
catch and the JVM's exception dispatch operate on the runtime class, which is erased. Since T's identity is gone at runtime, catch (T e) and class MyException<T> extends Exception are forbidden.
When the compiler sees a wildcard ?, it assigns it a fresh internal name (CAP#1) so it can reason about it as a single unknown type. Operations that require the two ends to share that exact type (e.g. list.set(i, list.get(j)) on a List<?>) fail because the compiler won't assume CAP#1 equals CAP#1 across calls. Fix by delegating to a private generic helper <T> void helper(List<T>), which captures the wildcard into a named T.
Reifiable = full type known at runtime: primitives, non-generic classes, raw types, unbounded wildcards, and arrays thereof. List<String> is not reifiable, so a generic varargs parameter creates an array of a non-reifiable type — hence the "unchecked generic array creation" warning and the @SafeVarargs annotation, which is a promise you never write into that array (avoiding heap pollution).
The diamond has no context to infer from, so it defaults to ArrayList<Object>, silently widening your intended element type. Write var x = new ArrayList<String>(); or List<String> x = new ArrayList<>();.
Senior notes & advanced edge cases
By now you understand the mechanics: erasure, PECS, variance, bridge methods. But what separates a senior from a well-read mid-level engineer is knowing where those mechanics buckle in the real world — inside Jackson, inside the actual JDK signatures, inside the Stream API, inside those bizarre runtime bugs — and how teams work around it. This section is exactly that layer.
- The one exception to erasure that 90% of developers don't know: the one place generic type info actually survives at runtime — and the entire serialization ecosystem rides on it (super type tokens).
- Why the JDK writes
Comparable<? super T>rather thanComparable<T>— and where the naive bound breaks your code. - PECS on functional interfaces (
Function<? super T, ? extends R>) — whyStream.mapis signed the way it is. - Heap pollution mechanically: where the
ClassCastExceptionactually gets thrown. - The precise
@SafeVarargsrules and the Java 9 change. - Senior patterns: the generic singleton factory and the typesafe heterogeneous container.
- The performance cost of erasure, why
IntStreamexists separately, and Project Valhalla. - Production traps: raw-type poisoning, plus a few modern-Java updates.
The big erasure exception: type info survives in class metadata
The main text told you erasure "wipes out" type info. That's 99% true, but it has one critical back door that the entire microservice ecosystem is built on. Erasure wipes type info from instances, but not from the signatures of class members in .class metadata (the Signature attribute the chapter mentioned). Concretely:
- The type of an object is unknown at runtime:
new ArrayList<String>().getClass()yields onlyArrayList. - But a type written into the class code — a field signature, a method parameter, or a generic superclass — is recoverable via reflection.
Once you take an item out of its box, it has no label anymore (instance = erased). But the store's receipt still says "3 books" (class code = retained). A super type token means: instead of asking the item itself, you read the receipt.
This is where the super type token trick is born. You can't write List<String>.class, but you can build an anonymous generic subclass and let it "bake" the type into its supertype:
abstract class TypeRef<T> {
private final Type type;
protected TypeRef() {
// getClass() -> our anonymous class; its superclass is TypeRef<List<String>>
Type sc = getClass().getGenericSuperclass(); // TypeRef<List<String>>
this.type = ((ParameterizedType) sc).getActualTypeArguments()[0];
}
public Type getType() { return type; }
}
// {} means we made an anonymous subclass -> the type is locked into its supertype Signature
TypeRef<List<String>> ref = new TypeRef<List<String>>() {};
System.out.println(ref.getType()); // java.util.List<java.lang.String>
That trailing {} is essential: without it there is no subclass, and getGenericSuperclass() gives you nothing usable. This is exactly what you see in Jackson (TypeReference), Gson (TypeToken), and Spring (ParameterizedTypeReference / ResolvableType):
// Jackson: without this, it can't know the list elements should be User
List<User> users = mapper.readValue(json, new TypeReference<List<User>>() {});
// Spring RestClient / WebClient
List<User> body = restClient.get().uri("/users").retrieve()
.body(new ParameterizedTypeReference<List<User>>() {});
If you write mapper.readValue(json, List.class), Jackson only knows "build a List" and turns each element into a LinkedHashMap, not a User. Later, when you call users.get(0).getEmail(), you get a ClassCastException that points at no line you wrote — because the compiler inserted that cast somewhere else. This is the classic "why is it a LinkedHashMap?" bug in microservices.
The diagram shows where type info dies and where it survives (bilingual caption): Where type info dies (instances) vs. survives (class Signature) / کجا نوع میمیرد و کجا میماند:
flowchart TD
Src["Source: TypeRef< —List< —String> —> —(){}"] --> Comp[javac erasure]
Comp --> Inst["Instance runtime type<br/>= raw TypeRef (erased)"]
Comp --> Sig["Class Signature metadata<br/>= TypeRef< —List< —String> —> — (kept)"]
Inst -->|getClass| Lost["List< —String> —? LOST"]
Sig -->|getGenericSuperclass| Found["ParameterizedType<br/>List< —String> — RECOVERED"]
Found --> Libs["Jackson / Gson / Spring<br/>deserialize correctly"]
Why the JDK writes Comparable<? super T>, not Comparable<T>
In the main text you saw max bounded as <T extends Comparable<T>>. It's simple and correct — but the real JDK doesn't sign it that way. The actual signature of Collections.max is:
public static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)
That ? super T is not an accident; it's a subtle application of PECS to the bound itself. Watch this scenario:
class Animal implements Comparable<Animal> { ... }
class Dog extends Animal { } // Dog defines no Comparable of its own
List<Dog> dogs = ...;
Dog is not a Comparable<Dog> — it inherits compareTo from Animal, so Dog is actually a Comparable<Animal>. Now:
- With the naive bound
<T extends Comparable<T>>: the compiler wantsDog extends Comparable<Dog>, which doesn't hold →Collections.max(dogs)won't compile. - With the real bound
<T extends Comparable<? super T>>: the compiler acceptsDog extends Comparable<Animal>becauseAnimalis a supertype ofDog→ it compiles.
Whenever a type "consumes itself" (a Comparable or Consumer that operates on T), write its bound with ? super T so it also accepts subclasses that inherited the behavior from a parent. That's the difference between professional library code and brittle code. In an interview, saying "because compareTo may be defined on a supertype" earns full marks.
PECS on functional interfaces: why Stream.map looks like that
PECS isn't just for List; it applies spectacularly to the functional-interface signatures in the Stream API. The real signature of map:
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
Both wildcards are deliberate and come straight from PECS:
? super Ton the function's input — aFunctionis a consumer ofT(it takes aT). By Consumer-Super, if you have aFunction<Object, R>, you should be able to map it over aStream<String>.? super Tallows exactly that.? extends Ron the function's output — aFunctionis a producer ofR(it returns anR). By Producer-Extends, if your function returnsInteger, you should be able to build aStream<Number>.
The same shape is everywhere in the JDK: Consumer<? super T> in forEach, Supplier<? extends T> in Optional.or, Predicate<? super T> in filter. Mnemonic: a function's input parameter = consume = super; a function's result = produce = extends. The reason stream.map(Object::toString) works over any stream is precisely that ? super T — and good library code sprinkles wildcards generously onto parameters.
Heap pollution mechanically: where does the cast actually blow up?
The main text named heap pollution, but the senior point is understanding how it happens and where it detonates. Precise definition: heap pollution is when a variable of a parameterized type points to an object that isn't of that type — e.g. a List<String> that actually holds an Integer. Erasure keeps this hidden right up until you read.
List<String> strings = new ArrayList<>();
List raw = strings; // raw type: unchecked warning
raw.add(42); // heap pollution! an Integer into a List<String>
String s = strings.get(0); // <-- ClassCastException is thrown HERE
The raw.add(42) line passes silently — no exception, because at runtime add just takes Object. The detonation happens at strings.get(0), where the compiler inserted an invisible (String) cast. That means in a prod log the exception points at a line with no explicit cast — a classic debugging trap. Always hunt for the source of the pollution (a raw type or a misplaced @SuppressWarnings), not the line that blew up.
The second way to inject pollution is generic varargs — which leads us straight to @SafeVarargs.
The precise @SafeVarargs rules and the Java 9 change
Any method that takes a generic varargs parameter (T... args) builds a T[] behind the scenes — and since arrays of a non-reifiable type are forbidden, the compiler emits an "unchecked generic array creation" warning. @SafeVarargs silences it, but it's a promise you must uphold yourself: "I won't write into that array, and I won't leak its reference out."
@SafeVarargs
static <T> List<T> listOf(T... items) { // we only read -> safe
return new ArrayList<>(Arrays.asList(items));
}
// An UNSAFE example — why @SafeVarargs is a promise, not a compiler guarantee:
@SafeVarargs
static <T> T[] dangerous(T... items) {
Object[] arr = items;
arr[0] = "boom"; // heap pollution if T is, say, Integer
return items;
}
@SafeVarargs is only permitted on methods that cannot be overridden — because a subclass could make an override unsafe. Before Java 9 that meant: static methods, final instance methods, and constructors. From Java 9 onward, private instance methods were added too (private methods can't be overridden either). Put it on a private method before Java 9 and you get a compile error.
A senior-grade generic pattern: the typesafe heterogeneous container
Sometimes you want a map whose keys hold different types, with each value guaranteed to match its key's type. The typesafe heterogeneous container trick: instead of parameterizing the container, parameterize the key — use Class<T> as a type token:
public class Favorites {
private final Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> type, T instance) {
map.put(Objects.requireNonNull(type), type.cast(instance)); // defensive cast
}
public <T> T get(Class<T> type) {
return type.cast(map.get(type)); // dynamic cast -> runtime safety
}
}
Favorites f = new Favorites();
f.put(String.class, "hello");
f.put(Integer.class, 42);
String s = f.get(String.class); // no cast, fully typesafe
(T) x is an unchecked cast (erasure!) — it does nothing at runtime, so a wrong type blows up later, elsewhere. But Class::cast is a dynamic cast that actually checks at runtime and throws ClassCastException right there if it's wrong. This bridge between "compile-time type" and "runtime check" is the signature of a senior. Enum.valueOf, EnumMap, Collections.checkedList, and much of Spring's ApplicationContext.getBean(Class) ride on this pattern.
The performance cost of erasure, IntStream, and Project Valhalla
Erasure isn't just an academic topic; it has a real performance cost. Because T is Object at runtime, generics cannot hold primitives — there is no List<int>, only List<Integer>. Every number gets boxed: a separate heap object, with memory overhead, garbage-collector pressure, and lost cache locality.
A List<Long> with millions of elements can consume several times the memory of a long[] and be measurably slower. That's precisely why the JDK had to build IntStream, LongStream, and DoubleStream separately — Stream<int> is impossible, and without them every numeric operation would drown in boxing. On a numeric hot path, deliberately reach for the primitive specializations.
Project Valhalla is trying to close this gap. JEP 401 (Value Classes and Objects) arrives as a preview in JDK 28 (integration around mid-2026, release March 2027): value classes have no identity, so the JVM can flatten and scalarize them, making Integer[] approach int[] efficiency. But note the catch: specialized generics — a List<Point> that's actually stored flat — is still Phase 2 work and not in JDK 28. Until then, because of erasure, elements of a generic collection still materialize on the heap.
Production traps and a few modern-Java updates
The main text said a raw type "turns off all checking," but don't underestimate its reach: per the JLS, once you use a raw type, all generic members of that class are erased — even methods that have nothing to do with the class's type parameter. A jarring example:
class Box<T> {
T val;
<U> U identity(U u) { return u; } // U is fully independent of T
}
Box raw = new Box();
Number n = raw.identity(42); // COMPILE ERROR! the return erased to Object
Here identity has no relation to T, yet because you called Box raw, all of its generic methods were erased. This shows a raw type is a class-level decision, not a call-level one.
A few updates a 2026 senior should recognize:
- Diamond with anonymous classes (Java 9+). Before Java 9,
new Box<>() {}(diamond + anonymous class body) was illegal and you had to spell the type out. Since Java 9 it's allowed when the inferred type is "denotable." - Type annotations (JSR 308, Java 8+). You can now annotate type arguments themselves:
List<@NonNull String>. Tools like the Checker Framework use this for compile-time null checking — a layer beyond generics themselves. - Intersection-type casts. You can cast a lambda to two types at once to give it both behavior and a marker:
(Runnable & Serializable) () -> {}. This produces a serializable lambda with no named class.
- Erasure wipes type info from instances, not from class signatures; the super type token (
new TypeReference<...>(){}) exploits this back door, and all of Java serialization rides on it. - The JDK writes bounds with
? super T(Comparable<? super T>) so subclasses that inherited the behavior are still accepted. - PECS applies to functions too:
Function<? super T, ? extends R>— input consumes (super), output produces (extends). - Heap pollution is injected silently but detonates where the compiler's invisible cast is — not the polluting line.
@SafeVarargsis a promise; since Java 9 it's allowed on private instance methods too.- Golden patterns: the generic singleton factory, and the typesafe heterogeneous container with
Class::cast. - Erasure means boxing and no
Stream<int>— the reasonIntStreamexists; Valhalla (JEP 401, JDK 28 preview) removes identity but specialized generics is still Phase 2.
Senior-level interview questions
With the super type token trick. List<User>.class doesn't exist, but if you build an anonymous subclass — new TypeReference<List<User>>() {} — that List<User> is baked into the Signature attribute of the subclass's superclass, and erasure wipes instances, not class metadata. Jackson calls getClass().getGenericSuperclass(), casts to ParameterizedType, and recovers List<User>, learning the elements must be User. Without it, readValue(json, List.class) produces LinkedHashMap elements and you get a later ClassCastException. The same mechanic underlies Gson's TypeToken and Spring's ParameterizedTypeReference.
Because a subclass may inherit compareTo from its parent and therefore be Comparable<Parent>, not Comparable<Self>. For example class Animal implements Comparable<Animal> and class Dog extends Animal — here Dog is a Comparable<Animal>, not a Comparable<Dog>. With the naive bound Comparable<T>, the call Collections.max(listOfDogs) won't compile because Dog extends Comparable<Dog> doesn't hold. ? super T says "T must be comparable to itself or any of its supertypes" and accepts the Dog/Animal case. It's PECS applied to the bound itself: Comparable consumes T, so Consumer-Super.
Both come straight from PECS. A Function consumes its input and produces its output. ? super T on the input (Consumer-Super) lets a more general function — say Function<Object, R> — be used over a Stream<String>; that's why map(Object::toString) works on any stream. ? extends R on the output (Producer-Extends) lets a function that produces a subtype of R (e.g. Integer when R is Number) still yield the right stream. The pattern repeats in Consumer<? super T> (forEach), Supplier<? extends T>, and Predicate<? super T>.
Heap pollution is when a variable of a parameterized type points to an object that isn't of that actual type — like a List<String> holding an Integer. It's usually injected via a raw type, a wrong @SuppressWarnings("unchecked"), or generic varargs. The subtle point: the polluting line (like raw.add(42)) throws nothing, because at runtime add just takes Object. The detonation happens when you read the value — like String s = list.get(0) — because that's where the compiler inserted an invisible (String) cast. So the CCE points at a line with no explicit cast; to debug it you must find the source of the pollution, not the line that blew up.
Its promise: "this method won't write into the generic varargs array and won't leak that array's reference out" — i.e. it won't create heap pollution. The compiler does not verify this; it merely silences the "unchecked generic array creation" warning and puts the responsibility on you. Because a subclass could make an override unsafe, the annotation is only allowed on non-overridable methods: before Java 9 that meant static, final instance methods, and constructors. Since Java 9, private instance methods were added (private can't be overridden either). Put it on a private method before Java 9 and you get a compile error.
Because after erasure T is Object, generics hold only reference types, not primitives; so List<int> or Stream<int> is impossible and every number must be boxed — a separate Integer on the heap, with memory overhead, GC pressure, and lost cache locality. That's why the JDK had to build IntStream/LongStream/DoubleStream separately, so numeric hot paths don't drown in boxing. Project Valhalla (JEP 401, preview in JDK 28) uses identity-free value classes so the JVM can flatten them, making Integer[] approach int[] — but specialized generics (making List<Point> genuinely flat) is still Phase 2 and not in JDK 28, so elements of generic collections still materialize on the heap.
Even generic methods with no relation to the class's type parameter get erased. For example:
class Box<T> {
<U> U identity(U u) { return u; } // U independent of T
}
Box raw = new Box(); // raw type
Number n = raw.identity(42); // COMPILE ERROR: return is Object
Here identity has nothing to do with T, but because you used Box raw, per the JLS all of that type's generic methods are erased and identity returns Object. The lesson: a raw type is a class-level decision — all or nothing — not just about that one call. Never use a raw type except to interop with pre-Java-5 code.
You use Class<T> as a type token for the key rather than parameterizing the container:
class Favorites {
private final Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> type, T value) { map.put(type, type.cast(value)); }
public <T> T get(Class<T> type) { return type.cast(map.get(type)); }
}
(T) map.get(type) is an unchecked cast — due to erasure it does nothing at runtime, so a wrong type blows up later and elsewhere. But type.cast(...) is a dynamic cast that actually checks the type on the spot and throws ClassCastException immediately if it's wrong. This "bridging" of the compile-time type to a runtime check makes it fully safe, and it's the same pattern EnumMap, Collections.checkedList, and Spring's ApplicationContext.getBean(Class) employ.
- Generics move errors from runtime to compile time, then erase the type for backward compatibility — and that one decision is the root of nearly every restriction.
- At compile time
List<String>andList<Integer>differ; at runtime both are justListsharing oneClass. - Bounds with
extendsboth restrict and empower; there is nosuperon a type parameter. - PECS: read from producers (
? extends), write to consumers (? super); you can't add to? extendsbecause it might be aList<Long>. - Erasure forbids:
new T[],new T(),instanceof T,T.class, overloading with equal erasure,Tin static members, and extendingThrowable. - Bridge methods preserve polymorphism after erasure.
- Arrays are covariant (runtime error:
ArrayStoreException), generics are invariant (compile-time error) — so prefer lists over generic arrays. - The F-bounded pattern (
Enum<E extends Enum<E>>) returns the concrete type; great for fluent builders. - Watch
var x = new ArrayList<>();becomingObject, and avoid raw types and careless@SuppressWarnings.