Open/Closed Principle memungkinkan kita menambah perilaku lewat class baru yang mengikuti kontrak yang sama. Pertanyaannya sekarang: seberapa ketat “mengikuti kontrak” itu? Sebuah subclass boleh saja lolos pemeriksaan implements atau extends di level bahasa, tetapi tetap membuat program pemanggilnya berperilaku salah atau bahkan crash. Liskov Substitution Principle (LSP) — huruf “L” dalam SOLID — adalah penjaga gerbangnya: subtypes harus bisa menggantikan tipe dasarnya tanpa merusak kebenaran program. Chapter ini membedah pelanggaran klasiknya satu per satu, semuanya dengan demo yang bisa Anda jalankan.

Apa Itu Prinsip Substitusi Liskov #

Prinsip ini dirumuskan oleh Barbara Liskov pada 1987 dan dipadatkan menjadi: jika S adalah subtype dari T, maka objek bertipe T boleh diganti objek bertipe S tanpa mengubah kebenaran program. Dalam bahasa sederhana: kode yang menerima class dasar tidak boleh mendapat kejutan ketika yang masuk ternyata subclass.

“Kebenaran program” di sini dijaga oleh tiga elemen kontrak:

  1. Precondition — syarat yang harus dipenuhi pemanggil sebelum memanggil method (misalnya: string tidak boleh kosong).
  2. Postcondition — jaminan yang diberikan method setelah selesai (misalnya: hasil selalu berawalan "Rp").
  3. Invariant — kondisi yang harus selalu benar sepanjang umur objek (misalnya: lebar dan tinggi saling independen).

Pelanggaran Pertama: Syarat Masukan Diperketat #

Pelanggaran paling umum: subclass menolak input yang selama ini diterima class dasarnya. Perhatikan dua class logger berikut:

php
<?php

class LoggerDasar
{
    public function tulis(string $pesan): void
    {
        echo "[log] $pesan\n";
    }
}

class LoggerTerbatas extends LoggerDasar
{
    public function tulis(string $pesan): void
    {
        if (strlen($pesan) > 20) {
            throw new InvalidArgumentException("Pesan melebihi 20 karakter");
        }

        echo "[log] $pesan\n";
    }
}

function catatAktivitas(LoggerDasar $logger, string $pesan): void
{
    $logger->tulis($pesan);
}

$logger = new LoggerTerbatas();
catatAktivitas($logger, "Pembayaran diterima");

try {
    catatAktivitas($logger, "Pesanan dikirim ke gudang X");
} catch (InvalidArgumentException $e) {
    echo "Error tak terduga: ", $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
[log] Pembayaran diterima
Error tak terduga: Pesan melebihi 20 karakter

Mari kita bedah kejadiannya:

  1. Baris 3-9: LoggerDasar::tulis() menerima string apa pun — kontraknya longgar dan tidak melempar apa pun.
  2. Baris 11-21: LoggerTerbatas meng-override method yang sama, tetapi menambah pemeriksaan strlen(). Pesan melebihi 20 karakter kini ditolak dengan exception. Inilah precondition yang diperketat.
  3. Baris 23-26: fungsi catatAktivitas() bertipe parameter LoggerDasar. Penulis fungsi ini tidak punya alasan menyiapkan try-catch, karena kontrak yang dikenalnya tidak pernah melempar.
  4. Baris 31-35: pemanggil menangkap exception hanya karena kita mengetahui isi LoggerTerbatas. Pada kode produksi yang sesungguhnya, exception ini akan menghentikan alur program.

Perhatikan pula bahwa LoggerTerbatas::tulis() tidak mendeklarasikan throws apa pun di signature-nya — PHP tidak memiliki mekanisme semacam itu untuk exception runtime, sehingga pelanggaran ini sepenuhnya senyap di level bahasa.

Pelanggaran Kedua: Jaminan Keluaran Dilemahkan #

Kebalikannya juga pelanggaran: subclass mengembalikan hasil yang berada di luar jaminan class dasar. Class dasar berikut menjanjikan setiap hasil format selalu berawalan "Rp":

php
<?php

class PemformatHarga
{
    public function format(int $harga): string
    {
        return "Rp" . number_format($harga, 0, ",", ".");
    }
}

class PemformatHargaSingkat extends PemformatHarga
{
    public function format(int $harga): string
    {
        return (string) $harga;
    }
}

function tampilkanTotal(PemformatHarga $formatter, int $total): void
{
    echo "Total: ", $formatter->format($total), "\n";
}

tampilkanTotal(new PemformatHarga(), 2500000);
tampilkanTotal(new PemformatHargaSingkat(), 2500000);

Output yang Dihasilkan:

text
Total: Rp2.500.000
Total: 2500000

Class PemformatHarga::format() menjamin hasil selalu berformat "Rp2.500.000" — awalan mata uang plus pemisah ribuan. Subclass PemformatHargaSingkat mengembalikan string polos "2500000" tanpa peringatan apa pun. Tidak ada exception, tidak ada warning; program tetap berjalan. Justru karena senyap, postcondition yang dilemahkan ini lebih berbahaya daripada pelanggaran pertama: laporan keuangan bisa salah format tanpa satu pun baris error muncul.

Studi Kasus BujurSangkar dan PersegiPanjang #

Contoh paling terkenal dalam sejarah OOP: bujur sangkar adalah persegi panjang secara matematika, sehingga terasa wajar menurunkan BujurSangkar dari PersegiPanjang. Demo berikut menunjukkan mengapa hubungan warisan ini justru merusak program:

php
<?php

class PersegiPanjang
{
    protected int $lebar = 0;
    protected int $tinggi = 0;

    public function setLebar(int $lebar): void
    {
        $this->lebar = $lebar;
    }

    public function setTinggi(int $tinggi): void
    {
        $this->tinggi = $tinggi;
    }

    public function hitungLuas(): int
    {
        return $this->lebar * $this->tinggi;
    }
}

class BujurSangkar extends PersegiPanjang
{
    public function setLebar(int $lebar): void
    {
        $this->lebar = $lebar;
        $this->tinggi = $lebar;
    }

    public function setTinggi(int $tinggi): void
    {
        $this->tinggi = $tinggi;
        $this->lebar = $tinggi;
    }
}

function ujiLuas(PersegiPanjang $bentuk): void
{
    $bentuk->setLebar(5);
    $bentuk->setTinggi(4);

    echo "Lebar 5, tinggi 4, luas: ", $bentuk->hitungLuas(), "\n";
}

ujiLuas(new PersegiPanjang());
ujiLuas(new BujurSangkar());

Output yang Dihasilkan:

text
Lebar 5, tinggi 4, luas: 20
Lebar 5, tinggi 4, luas: 16

Inilah yang terjadi:

  1. Class dasar menyimpan lebar dan tinggi sebagai dua properti independen. Invariant-nya implisit tapi tegas: mengubah lebar tidak boleh mengubah tinggi.
  2. BujurSangkar meng-override kedua setter agar keempat sisinya selalu sama — sebuah invariant yang sah untuk bujur sangkar, tetapi bertabrakan langsung dengan invariant class dasarnya.
  3. Fungsi ujiLuas() mengatur lebar 5 lalu tinggi 4, lalu mencetak luas. Dengan PersegiPanjang, hasilnya 20 sesuai harapan.
  4. Dengan BujurSangkar, pemanggilan setTinggi(4) diam-diam menimpa lebar menjadi 4. Luas keluar sebagai 16, bukan 20 — kontrak “lebar dan tinggi independen” sudah pecah.

Masalahnya bukan pada subclass-nya melainkan pada hubungan warisan yang dipaksakan. Warisan menyiratkan substitusi penuh; jika kontrak tidak bisa dipenuhi 100%, warisan adalah pilihan yang salah.

Perbaikan: Kontrak yang Jujur #

Solusinya bukan menambal ujiLuas() dengan cabang instanceof, melainkan merancang ulang hubungannya. Ambil hanya perilaku yang benar-benar bisa dijanjikan bersama — menghitung luas dari bentuk yang sudah jadi — dan jadikan itu kontraknya:

php
<?php

interface BentukDenganLuas
{
    public function hitungLuas(): int;
}

final class PersegiPanjang implements BentukDenganLuas
{
    public function __construct(
        private readonly int $lebar,
        private readonly int $tinggi,
    ) {
    }

    public function hitungLuas(): int
    {
        return $this->lebar * $this->tinggi;
    }
}

final class BujurSangkar implements BentukDenganLuas
{
    public function __construct(private readonly int $sisi)
    {
    }

    public function hitungLuas(): int
    {
        return $this->sisi * $this->sisi;
    }
}

function tampilkanLuas(BentukDenganLuas $bentuk, string $label): void
{
    echo "$label: ", $bentuk->hitungLuas(), "\n";
}

tampilkanLuas(new PersegiPanjang(5, 4), "Persegi panjang 5x4");
tampilkanLuas(new BujurSangkar(4), "Bujur sangkar 4x4");

Output yang Dihasilkan:

text
Persegi panjang 5x4: 20
Bujur sangkar 4x4: 16

Tiga keputusan desain di atas layak dicermati. Pertama, BentukDenganLuas hanya memuat method yang kedua bentuk jaminan bisa penuhi — tidak ada setter, tidak ada invariant yang bertabrakan. Kedua, kedua class kini final dan tidak saling mewarisi; hubungan “is-a” yang palsu diputus tuntas. Ketiga, dimensi diisi lewat constructor readonly sehingga objek tidak bisa berubah status di tengah jalan — setiap nilai dihitung sekali dan konsisten selamanya.

Berpikir dalam Kontrak #

Dari ketiga demo di atas, pola pelanggarannya bisa dirangkum menjadi tabel kecil yang layak ditempel di meja kerja:

Jenis pelanggaranWujudnyaEfek ke pemanggil
Precondition diperketatsubclass menolak input yang diterima class dasarexception tak terduga
Postcondition dilemahkanhasil di luar jaminan class dasarkeluaran salah tanpa error
Exception barusubclass melempar exception yang tak pernah dijanjikanalur program berhenti
Invariant dilanggarsubclass merusak kondisi yang selalu benar di class dasarlogika internal rusak

Karena PHP tidak mengeksekusi kontrak ini secara otomatis, dokumentasinya menjadi bagian dari desain. PHPDoc pada interface dan class dasar sebaiknya menyatakan secara eksplisit: input apa yang ditolak, hasil seperti apa yang dijamin, exception apa saja yang mungkin terjadi. Anotasi tipe seperti union dan intersection membantu di level signature — mesin menjaga arah variance parameter dan return type secara otomatis, seperti dibahas tuntas di chapter Covariance dan Contravariance — tetapi semantik perilaku tetap harus dijaga oleh disiplin kita saat menulis subclass.

Ringkasan #

LSP mengubah cara kita menilai inheritance: hubungan warisan bukan sekadar kesamaan bentuk data, melainkan janji bahwa subclass memenuhi seluruh kontrak class dasarnya — precondition tidak boleh diperketat, postcondition tidak boleh dilemahkan, dan invariant harus terjaga. Ketika janji itu tak bisa dipenuhi, pecah hierarki dan gunakan kontrak yang lebih sempit. Dengan substitusi yang aman, kita bisa melangkah ke prinsip SOLID berikutnya yang memperketat sisi konsumen: Interface Segregation.

Referensi #