Pewarisan (extends) terasa alami saat belajar OOP: tinggal turunkan class, tambah sedikit method, selesai. Namun pada sistem yang hidup bertahun-tahun, pewarisan yang dipakai untuk meminjam implementasi justru menjadi sumber bug halus — subclass menempel pada detail internal superclass yang boleh berubah kapan saja. Prinsip composition over Inheritance menawarkan jalan lain: susun objek dari bagian-bagian kecil yang dimiliki dan diatur lewat delegasi, bukan dari class besar yang diwarisi. Chapter ini membuktikan dua jebakan klasik pewarisan, lalu menunjukkan bahwa komposisi menyelesaikan keduanya dengan kode yang lebih aman.

Jebakan Pertama Penghitung Ganda #

Contoh klasiknya berasal dari buku Effective Java: kita ingin Set yang menghitung berapa kali elemen ditambahkan. Versi pewarisan tampak wajar — cukup extends HashSet lalu override add dan addAll:

import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class KoleksiDihitungWarisan {
    public static void main(String[] args) {
        SetDiukur<String> himpunan = new SetDiukur<>();
        himpunan.addAll(List.of("Budi", "Siti", "Dewi"));
        System.out.println("Jumlah elemen : " + himpunan.size());
        System.out.println("Tambah tercatat: " + himpunan.jumlahTambah());
    }
}

// Pewarisan dari HashSet: implementasi berbasis detail internal
class SetDiukur<E> extends HashSet<E> {
    private int jumlahTambah = 0;

    @Override
    public boolean add(E e) {
        jumlahTambah++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        jumlahTambah += c.size();
        return super.addAll(c);
    }

    int jumlahTambah() {
        return jumlahTambah;
    }
}

Output yang Dihasilkan:

text
Jumlah elemen : 3
Tambah tercatat: 6

Hasilnya mencatat 6 penambahan padahal elemen hanya 3. Mari kita bedah penyebabnya:

  1. Baris 25-29 (addAll): override ini menambah c.size() (tiga) ke penghitung, lalu memanggil super.addAll(c).
  2. Baris 19-23 (add): di dalam java.util.AbstractCollection, method addAll bawaan diimplementasikan sebagai loop yang memanggil add(e) untuk setiap elemen — dan pemanggilan tersebut terikat secara dinamis ke override di SetDiukur.
  3. Akibat gabungan: setiap elemen terhitung dua kali — sekali di addAll override, sekali lagi di add override. Kelas HashSet memang meng-override addAll secara efisien, tetapi kontrak “implementasi addAll memanggil add” adalah detail internal yang tidak dijamin spesifikasinya.

Perbaikan satu baris (hapus addAll) berhenti bekerja begitu implementasi JDK berubah. Inilah inti masalahnya: subclass mewarisi implementasi, dan implementasi boleh berganti antar versi pustaka.

Komposisi dengan Delegasi #

Versi komposisi tidak lagi mewarisi HashSet. Class pembungkus memiliki sebuah Set dan mendelegasikan setiap operasi ke objek tersebut:

import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class KoleksiDihitungKomposisi {
    public static void main(String[] args) {
        SetDiukur<String> himpunan = new SetDiukur<>(new HashSet<>());
        himpunan.addAll(List.of("Budi", "Siti", "Dewi"));
        System.out.println("Jumlah elemen : " + himpunan.size());
        System.out.println("Tambah tercatat: " + himpunan.jumlahTambah());
    }
}

// Komposisi: memiliki Set, mendelegasikan pekerjaan kepadanya
class SetDiukur<E> {
    private final Set<E> set;
    private int jumlahTambah = 0;

    SetDiukur(Set<E> set) {
        this.set = set;
    }

    public boolean add(E e) {
        jumlahTambah++;
        return set.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        int ukuranSebelum = set.size();
        boolean berubah = set.addAll(c);
        jumlahTambah += set.size() - ukuranSebelum;
        return berubah;
    }

    public int size() {
        return set.size();
    }

    int jumlahTambah() {
        return jumlahTambah;
    }
}

Output yang Dihasilkan:

text
Jumlah elemen : 3
Tambah tercatat: 3

Mari kita bedah struktur delegasi tersebut:

  1. Baris 17 (private final Set<E> set): field bertipe interface Set, bukan class HashSet. Pembungkus tidak bergantung pada detail implementasi mana pun — TreeSet atau ConcurrentHashMap.newKeySet() dapat masuk tanpa perubahan kode.
  2. Baris 20-22 (SetDiukur(Set<E> set)): dependensi disuntikkan lewat constructor. Pola ini disebut forwarding: pembungkus menerima permintaan, meneruskannya, menambahkan catatannya sendiri.
  3. Baris 29-34 (addAll): penghitung kini mengukur hasil yang teramati — selisih ukuran sebelum dan sesudah — bukan cara internal addAll bekerja. Berapa pun addAll memanggil add di dalam, penghitungan tetap benar.

Jebakan Kedua Overridable Call di Constructor #

Pewarisan menyimpan jebakan kedua yang berlangsung sebelum subclass selesai dibentuk: constructor superclass yang memanggil method yang dapat di-override. Aturan eksekusi JVM menyatakan constructor super() berjalan lebih dulu, sebelum field subclass diinisialisasi — sehingga override di subclass membaca field yang masih null:

public class PanggilanDiConstructor {
    public static void main(String[] args) {
        PapanSkor papan = new PapanSkor("Budi");
        System.out.println("Nama tersimpan: " + papan.nama());
    }
}

class Dasbor {
    Dasbor() {
        mulai(); // constructor memanggil method yang dapat di-override
    }

    void mulai() {
        System.out.println("Dasbor siap");
    }
}

class PapanSkor extends Dasbor {
    private final String nama;

    PapanSkor(String nama) {
        this.nama = nama;
    }

    String nama() {
        return nama;
    }

    @Override
    void mulai() {
        System.out.println("Halo " + nama + ", papan dibuat");
    }
}

Output yang Dihasilkan:

text
Halo null, papan dibuat
Nama tersimpan: Budi

Urutan peristiwanya sebagai berikut:

  1. Baris 3 (new PapanSkor("Budi")): JVM mengalokasikan objek, lalu menjalankan constructor Dasbor terlebih dahulu.
  2. Baris 10 (mulai() di constructor Dasbor): pemanggilan terikat secara dinamis ke override PapanSkor.mulai() — meski objek PapanSkor belum selesai dibentuk.
  3. Baris 31 ("Halo " + nama): field nama belum sempat diisi karena constructor PapanSkor belum berjalan, sehingga tercetak null. Baris berikutnya membuktikan field kemudian terisi: Nama tersimpan: Budi.

Kapan Pewarisan Tetap Layak #

Pewarisan tetap tepat saat hubungan benar-benar is-a dan superclass memang dirancang untuk diwarisi — misalnya abstract class tanpa field mutable, atau hierarki tipe seperti sealed interface dengan implementasi terbatas. Komposisi unggul saat kita hanya ingin meminjam perilaku: menghitung, mencatat, membatasi, atau menunda. Aturan praktisnya: wariskan tipe, komposisikan perilaku. Topik berikutnya membawa kita ke disiplin yang lebih luas lagi — menjaga kode tetap terbaca manusia lewat Clean Code.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

KoleksiDihitungKomposisi.java

Unduh berkas
java
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class KoleksiDihitungKomposisi {
    public static void main(String[] args) {
        SetDiukur<String> himpunan = new SetDiukur<>(new HashSet<>());
        himpunan.addAll(List.of("Budi", "Siti", "Dewi"));
        System.out.println("Jumlah elemen : " + himpunan.size());
        System.out.println("Tambah tercatat: " + himpunan.jumlahTambah());
    }
}

// Komposisi: memiliki Set, mendelegasikan pekerjaan kepadanya
class SetDiukur<E> {
    private final Set<E> set;
    private int jumlahTambah = 0;

    SetDiukur(Set<E> set) {
        this.set = set;
    }

    public boolean add(E e) {
        jumlahTambah++;
        return set.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        int ukuranSebelum = set.size();
        boolean berubah = set.addAll(c);
        jumlahTambah += set.size() - ukuranSebelum;
        return berubah;
    }

    public int size() {
        return set.size();
    }

    int jumlahTambah() {
        return jumlahTambah;
    }
}

KoleksiDihitungWarisan.java

Unduh berkas
java
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class KoleksiDihitungWarisan {
    public static void main(String[] args) {
        SetDiukur<String> himpunan = new SetDiukur<>();
        himpunan.addAll(List.of("Budi", "Siti", "Dewi"));
        System.out.println("Jumlah elemen : " + himpunan.size());
        System.out.println("Tambah tercatat: " + himpunan.jumlahTambah());
    }
}

// Pewarisan dari HashSet: implementasi berbasis detail internal
class SetDiukur<E> extends HashSet<E> {
    private int jumlahTambah = 0;

    @Override
    public boolean add(E e) {
        jumlahTambah++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        jumlahTambah += c.size();
        return super.addAll(c);
    }

    int jumlahTambah() {
        return jumlahTambah;
    }
}

PanggilanDiConstructor.java

Unduh berkas
java
public class PanggilanDiConstructor {
    public static void main(String[] args) {
        PapanSkor papan = new PapanSkor("Budi");
        System.out.println("Nama tersimpan: " + papan.nama());
    }
}

class Dasbor {
    Dasbor() {
        mulai(); // constructor memanggil method yang dapat di-override
    }

    void mulai() {
        System.out.println("Dasbor siap");
    }
}

class PapanSkor extends Dasbor {
    private final String nama;

    PapanSkor(String nama) {
        this.nama = nama;
    }

    String nama() {
        return nama;
    }

    @Override
    void mulai() {
        System.out.println("Halo " + nama + ", papan dibuat");
    }
}