Tampilan
Kenapa penting
Bab sebelumnya membuatmu bisa menulis Box<T> dan Repository<T, ID>. Tetapi begitu membaca dokumentasi JDK atau Spring, kamu akan bertemu signature seperti <T extends Comparable<? super T>> T max(Collection<? extends T> coll) — dan banyak developer berhenti membaca di situ. Bab ini membongkar tanda-tanda tersebut: bounded type agar method generic bisa memanggil method milik T, wildcard agar API-mu menerima List<CardPayment> maupun List<BankTransfer>, dan type erasure yang menjelaskan kenapa beberapa hal "logis" justru ditolak compiler. Di tim, pemahaman ini membedakan orang yang menulis utilitas yang fleksibel dari orang yang menaburkan cast dan @SuppressWarnings untuk membungkam compiler.
Konsep inti
Bounded type: <T extends ...>
Di dalam <T> biasa, compiler hanya tahu T adalah Object — kamu tidak bisa memanggil compareTo atau doubleValue. Bounded type memberi batas atas: <T extends Comparable<T>> berarti "T apa saja, asalkan bisa dibandingkan dengan sesamanya". Kata kuncinya selalu extends, baik batasnya class maupun interface.
java
import java.util.List;
public class BoundedDemo {
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.get(0);
for (T item : items) {
if (item.compareTo(best) > 0) { // boleh: T pasti punya compareTo
best = item;
}
}
return best;
}
static <T extends Number> double sum(List<T> numbers) {
double total = 0;
for (T n : numbers) {
total += n.doubleValue(); // boleh: T pasti turunan Number
}
return total;
}
record Version(int major, int minor) implements Comparable<Version> {
@Override
public int compareTo(Version other) {
int byMajor = Integer.compare(major, other.major);
return byMajor != 0 ? byMajor : Integer.compare(minor, other.minor);
}
}
public static void main(String[] args) {
System.out.println(max(List.of(3, 17, 8)));
System.out.println(max(List.of("kopi", "teh", "gula")));
System.out.println(max(List.of(new Version(1, 9), new Version(2, 0), new Version(1, 12))));
System.out.println(sum(List.of(1, 2, 3)) + " " + sum(List.of(2.5, 0.25)));
}
}Output:
text
17
teh
Version[major=2, minor=0]
6.0 2.75Memanggil max dengan record Product yang tidak mengimplementasikan Comparable langsung ditolak compiler (pesannya panjang, intinya inference variable E has incompatible bounds ... upper bounds: Comparable<T>). Batas juga bisa lebih dari satu: <T extends Number & Comparable<T>> — class dulu, lalu interface, dipisah &.
Generics itu invariant
Ini sumber kebingungan paling umum. Integer adalah turunan Number, tetapi List<Integer> bukan turunan List<Number>:
text
InvariantErr.java:6: error: incompatible types: List<Integer> cannot be converted to List<Number>
List<Number> nums = ints;
^Alasannya keamanan. Seandainya diizinkan, nums.add(2.5) akan memasukkan Double ke list yang oleh pemilik aslinya dianggap berisi Integer saja. Sifat ini disebut invariant. Akibatnya, method double total(List<Number>) tidak bisa menerima List<Integer> — di sinilah wildcard dibutuhkan.
Wildcard: ?, ? extends, ? super
Wildcard ? berarti "suatu tipe yang tidak diketahui". Ada tiga bentuk:
java
import java.util.ArrayList;
import java.util.List;
public class WildcardDemo {
static void printAll(List<?> items) { // tipe isi apa saja, hanya dibaca
System.out.println(items.size() + " item: " + items);
}
static double total(List<? extends Number> amounts) { // PRODUCER: kita membaca Number
double sum = 0;
for (Number n : amounts) {
sum += n.doubleValue();
}
return sum;
}
static void addDefaultQuantities(List<? super Integer> sink) { // CONSUMER: kita menulis Integer
sink.add(1);
sink.add(5);
sink.add(10);
}
public static void main(String[] args) {
List<Integer> qty = List.of(2, 3);
List<Double> weights = List.of(0.5, 1.25);
printAll(qty);
printAll(List.of("kopi", "teh"));
System.out.println(total(qty) + " " + total(weights));
List<Number> numbers = new ArrayList<>();
List<Object> objects = new ArrayList<>();
addDefaultQuantities(numbers);
addDefaultQuantities(objects);
System.out.println(numbers + " " + objects);
Object fromSuper = objects.get(0); // dari ? super, yang pasti hanya Object
System.out.println(fromSuper);
}
}Output:
text
2 item: [2, 3]
2 item: [kopi, teh]
5.0 1.75
[1, 5, 10] [1, 5, 10]
1| Bentuk | Menerima | Membaca elemen sebagai | Menambah elemen |
|---|---|---|---|
List<?> | list tipe apa pun | Object | tidak bisa (kecuali null) |
List<? extends Number> | List<Number>, List<Integer>, List<Double> | Number | tidak bisa |
List<? super Integer> | List<Integer>, List<Number>, List<Object> | Object | Integer dan turunannya |
Kenapa ? extends Number tidak bisa ditambah? Karena compiler tidak tahu list aslinya List<Integer> atau List<Double>. Mencoba amounts.add(1) menghasilkan:
text
ExtendsAddErr.java:4: error: incompatible types: int cannot be converted to CAP#1
amounts.add(1);
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Number from capture of ? extends NumberCAP#1 adalah nama sementara yang diberikan compiler untuk "tipe tak diketahui itu" (capture). Sebaliknya, ? super Integer menjamin list sanggup menampung Integer — tetapi saat dibaca, yang pasti hanya Object.
Prinsip PECS
Cara mengingatnya: PECS — Producer Extends, Consumer Super. Bila parameter menghasilkan data yang kamu baca, pakai ? extends T. Bila parameter menerima data yang kamu tulis, pakai ? super T. Bila keduanya, jangan pakai wildcard — pakai T biasa. JDK sendiri mengikuti aturan ini:
java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class PecsDemo {
public static void main(String[] args) {
// public static <T> boolean addAll(Collection<? super T> c, T... elements)
List<Object> auditLog = new ArrayList<>();
Collections.addAll(auditLog, "ORDER_CREATED", "ORDER_PAID");
System.out.println(auditLog);
// public static <T> void copy(List<? super T> dest, List<? extends T> src)
List<Integer> src = List.of(7, 8);
List<Number> dest = new ArrayList<>(List.of(0, 0, 0));
Collections.copy(dest, src);
System.out.println(dest);
List<Number> tooSmall = new ArrayList<>();
Collections.copy(tooSmall, src);
}
}Output:
text
[ORDER_CREATED, ORDER_PAID]
[7, 8, 0]
Exception in thread "main" java.lang.IndexOutOfBoundsException: Source does not fit in dest
at java.base/java.util.Collections.copy(Collections.java:585)
at PecsDemo.main(PecsDemo.java:19)addAll menulis String ke Collection<? super String> — maka List<Object> diterima. copy membaca dari src (producer, extends) dan menulis ke dest (consumer, super), sehingga List<Integer> bisa disalin ke List<Number>. Perhatikan juga: copy menimpa elemen per indeks, bukan menambah. dest harus sudah berisi minimal sebanyak src — new ArrayList<>() yang kosong memicu IndexOutOfBoundsException. Untuk menambahkan, pakai dest.addAll(src).
Type erasure dan batasannya
Generics di Java hanya ada saat kompilasi. Setelah compiler memeriksa tipe, informasi type argument dihapus (type erasure): List<String> dan List<Integer> menjadi List biasa di bytecode, T diganti batasnya (Object, atau misalnya Comparable untuk <T extends Comparable<T>>), dan compiler menyisipkan cast di tempat yang perlu. Desain ini dipilih agar kode baru tetap kompatibel dengan library lama sebelum Java 5.
java
import java.util.ArrayList;
import java.util.List;
public class ErasureDemo {
static class TypedBox<T> {
private final Class<T> type;
private Object raw;
TypedBox(Class<T> type) { this.type = type; }
void put(Object value) {
if (!type.isInstance(value)) {
throw new IllegalArgumentException("Butuh " + type.getSimpleName()
+ ", dapat " + value.getClass().getSimpleName());
}
raw = value;
}
T get() { return type.cast(raw); } // cast aman, dicek saat runtime
}
public static void main(String[] args) {
List<String> names = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
System.out.println(names.getClass() == numbers.getClass());
System.out.println(names.getClass().getName());
Object payload = names;
System.out.println(payload instanceof List<?>); // boleh: hanya cek "ini List"
TypedBox<Long> amount = new TypedBox<>(Long.class);
amount.put(170_000L);
System.out.println(amount.get() + 1);
amount.put("170000");
}
}Output:
text
true
java.util.ArrayList
true
170001
Exception in thread "main" java.lang.IllegalArgumentException: Butuh Long, dapat String
at ErasureDemo$TypedBox.put(ErasureDemo.java:14)
at ErasureDemo.main(ErasureDemo.java:34)Karena T tidak ada saat runtime, beberapa hal ditolak compiler:
text
NewTErr.java:4: error: unexpected type
return new T();
^
required: class
found: type parameter T
NewTErr.java:7: error: Object cannot be safely cast to List<String>
return payload instanceof List<String>;
^new T()— JVM tidak tahu class mana yang harus dibuat. Solusi umum: terimaClass<T>(sepertiTypedBox) atau object pembuat dari luar (factory; versi ringkasnya dengan lambda di bab berikutnya).instanceof List<String>dariObject— runtime hanya bisa memastikan "iniList", bukan isinya. Pakaiinstanceof List<?>.new T[10], fieldstatic T, dancatchuntuk tipe generic juga tidak diizinkan.
Pola Class<T> inilah yang kamu lihat di API seperti objectMapper.readValue(json, OrderDto.class) — library butuh tipe runtime yang tidak bisa diambil dari T.
Raw type: jebakan warisan masa lalu
Raw type adalah tipe generic yang dipakai tanpa type argument: List alih-alih List<String>. Java masih menerimanya demi kompatibilitas, tetapi raw type mematikan pengecekan generics:
java
import java.util.ArrayList;
import java.util.List;
public class RawTypeDemo {
static void legacyAppend(List list) { // gaya kode lama sebelum Java 5
list.add(42);
}
public static void main(String[] args) {
List<String> skus = new ArrayList<>();
skus.add("KOPI-250");
legacyAppend(skus); // compiler hanya memberi warning
System.out.println(skus.size() + " " + skus);
for (String sku : skus) { // meledak di sini, jauh dari penyebabnya
System.out.println(sku.toLowerCase());
}
}
}javac hanya memberi catatan yang mudah terlewat di log build:
text
Note: RawTypeDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.Saat dijalankan:
text
2 [KOPI-250, 42]
kopi-250
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at RawTypeDemo.main(RawTypeDemo.java:15)Integer berhasil masuk ke List<String> — kondisi ini disebut heap pollution. Exception muncul di loop, bukan di legacyAppend yang bersalah. Kompilasi dengan -Xlint:unchecked untuk melihat baris penyebabnya, dan perlakukan warning unchecked sebagai bug.
Contoh di dunia backend
Modul pembayaran punya beberapa jenis Payment. Tim fraud ingin semua pembayaran di atas ambang tertentu dipindahkan ke satu antrean review umum. Dengan PECS, satu method bisa membaca dari List<CardPayment> maupun List<BankTransfer>, dan menulis ke antrean bertipe List<Payment> atau List<Object>. Method largest meniru signature Collections.max.
java
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
sealed interface Payment permits CardPayment, BankTransfer {
String id();
long amount();
}
record CardPayment(String id, long amount, String last4) implements Payment {}
record BankTransfer(String id, long amount, String bank) implements Payment {}
record Money(long amount) implements Comparable<Money> {
@Override
public int compareTo(Money other) { return Long.compare(amount, other.amount); }
}
public class PaymentReport {
// Producer: hanya membaca Payment dari source → ? extends
// Consumer: hanya menulis Payment ke sink → ? super
static int collectLarge(Collection<? extends Payment> source,
Collection<? super Payment> sink,
long threshold) {
int moved = 0;
for (Payment p : source) {
if (p.amount() >= threshold) {
sink.add(p);
moved++;
}
}
return moved;
}
// Pola yang sama dengan Collections.max: T cukup bisa dibandingkan dengan dirinya atau supertype-nya
static <T extends Comparable<? super T>> T largest(Collection<? extends T> items) {
T best = null;
for (T item : items) {
if (best == null || item.compareTo(best) > 0) {
best = item;
}
}
if (best == null) {
throw new IllegalArgumentException("Koleksi kosong");
}
return best;
}
static long sumAmounts(List<? extends Payment> payments) {
long total = 0;
for (Payment p : payments) {
total += p.amount();
}
return total;
}
public static void main(String[] args) {
List<CardPayment> cards = List.of(
new CardPayment("PAY-1", 250_000, "4242"),
new CardPayment("PAY-2", 12_000_000, "1881"));
List<BankTransfer> transfers = List.of(
new BankTransfer("PAY-3", 15_500_000, "BCA"),
new BankTransfer("PAY-4", 90_000, "BRI"));
List<Object> fraudReviewQueue = new ArrayList<>(); // antrean umum berisi Object
int moved = collectLarge(cards, fraudReviewQueue, 10_000_000)
+ collectLarge(transfers, fraudReviewQueue, 10_000_000);
System.out.println(moved + " pembayaran perlu review:");
for (Object item : fraudReviewQueue) {
System.out.println(" " + item);
}
System.out.println("Total kartu: " + sumAmounts(cards));
System.out.println("Terbesar: " + largest(List.of(new Money(90_000), new Money(15_500_000))));
}
}Output:
text
2 pembayaran perlu review:
CardPayment[id=PAY-2, amount=12000000, last4=1881]
BankTransfer[id=PAY-3, amount=15500000, bank=BCA]
Total kartu: 12250000
Terbesar: Money[amount=15500000]Tanpa wildcard, collectLarge(List<Payment> source, List<Payment> sink, ...) akan menolak List<CardPayment> dan List<Object>, sehingga pemanggil terpaksa menyalin data ke list baru atau memakai raw type. Comparable<? super T> di largest membuat method tetap bekerja bila compareTo didefinisikan di supertype, bukan di class itu sendiri.
Pertanyaan interview
1. Jelaskan prinsip PECS dan beri contohnya.
PECS adalah singkatan dari Producer Extends, Consumer Super. Parameter yang menjadi sumber data untuk dibaca diberi ? extends T; parameter yang menjadi tujuan penulisan diberi ? super T. Contohnya Collections.copy(List<? super T> dest, List<? extends T> src): src dibaca, dest ditulisi. Hasilnya API lebih fleksibel — List<Integer> bisa disalin ke List<Number>.
2. Apa itu type erasure dan apa konsekuensinya?
Type erasure adalah penghapusan informasi type argument setelah kompilasi; di runtime List<String> dan List<Integer> sama-sama ArrayList. Konsekuensinya: tidak bisa new T(), membuat array T[], atau mengecek instanceof List<String>. Bila butuh tipe saat runtime, terima Class<T> sebagai parameter, seperti pola readValue(json, OrderDto.class).
3. Apa beda List<?>, List<Object>, dan raw List?
List<Object> hanya menerima argumen yang memang List<Object>, dan boleh ditambah apa saja. List<?> menerima list bertipe apa pun, tetapi aman karena kamu tidak bisa menambah elemen (kecuali null). Raw List menerima apa saja dan boleh ditambah apa saja tanpa pengecekan — sumber heap pollution, hanya ada demi kompatibilitas kode lama.
Jebakan umum
- Mengira
List<Integer>adalahList<Number>. Generics invariant; pakaiList<? extends Number>untuk membaca. - Menambah elemen ke
List<? extends T>— selalu ditolak compiler. Untuk menulis, pakai? super T. - Memakai wildcard di tipe kembalian.
List<? extends Payment> findAll()memaksa pemanggil ikut berurusan dengan wildcard. Wildcard untuk parameter; tipe kembalian sebaiknya konkret. Collections.copyke list kosong →IndexOutOfBoundsException: Source does not fit in dest. PakaiaddAll.- Raw type dan
@SuppressWarnings("unchecked")sembarangan. Warninguncheckedadalah tanda heap pollution; perbaiki tipenya, jangan dibungkam. - Berharap bisa
new T()atauinstanceof List<String>. Erasure menghapusT; terimaClass<T>bila perlu.
Ringkasan
- Bounded type
<T extends X>membuat method generic bisa memanggil method milikX; batas ganda ditulis dengan&. - Generics invariant:
List<Integer>bukanList<Number>. Wildcard membuka fleksibilitas dengan aman. ? extends Tuntuk dibaca (producer),? super Tuntuk ditulisi (consumer) — PECS, seperti diCollections.copy.- Type erasure menghapus type argument saat runtime: tidak ada
new T(),T[], atauinstanceof List<String>; gunakanClass<T>bila butuh tipe runtime. - Raw type mematikan pengecekan generics dan menyebabkan heap pollution — perlakukan warning
uncheckedsebagai bug.