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:
- Precondition — syarat yang harus dipenuhi pemanggil sebelum memanggil method (misalnya: string tidak boleh kosong).
- Postcondition — jaminan yang diberikan method setelah selesai (misalnya: hasil selalu berawalan
"Rp"). - 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
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:
[log] Pembayaran diterima
Error tak terduga: Pesan melebihi 20 karakterMari kita bedah kejadiannya:
- Baris 3-9:
LoggerDasar::tulis()menerima string apa pun — kontraknya longgar dan tidak melempar apa pun. - Baris 11-21:
LoggerTerbatasmeng-override method yang sama, tetapi menambah pemeriksaanstrlen(). Pesan melebihi 20 karakter kini ditolak dengan exception. Inilah precondition yang diperketat. - Baris 23-26: fungsi
catatAktivitas()bertipe parameterLoggerDasar. Penulis fungsi ini tidak punya alasan menyiapkantry-catch, karena kontrak yang dikenalnya tidak pernah melempar. - 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
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:
Total: Rp2.500.000
Total: 2500000Class 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
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:
Lebar 5, tinggi 4, luas: 20
Lebar 5, tinggi 4, luas: 16Inilah yang terjadi:
- Class dasar menyimpan
lebardantinggisebagai dua properti independen. Invariant-nya implisit tapi tegas: mengubah lebar tidak boleh mengubah tinggi. BujurSangkarmeng-override kedua setter agar keempat sisinya selalu sama — sebuah invariant yang sah untuk bujur sangkar, tetapi bertabrakan langsung dengan invariant class dasarnya.- Fungsi
ujiLuas()mengatur lebar 5 lalu tinggi 4, lalu mencetak luas. DenganPersegiPanjang, hasilnya20sesuai harapan. - Dengan
BujurSangkar, pemanggilansetTinggi(4)diam-diam menimpa lebar menjadi 4. Luas keluar sebagai16, bukan20— 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
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:
Persegi panjang 5x4: 20
Bujur sangkar 4x4: 16Tiga 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 pelanggaran | Wujudnya | Efek ke pemanggil |
|---|---|---|
| Precondition diperketat | subclass menolak input yang diterima class dasar | exception tak terduga |
| Postcondition dilemahkan | hasil di luar jaminan class dasar | keluaran salah tanpa error |
| Exception baru | subclass melempar exception yang tak pernah dijanjikan | alur program berhenti |
| Invariant dilanggar | subclass merusak kondisi yang selalu benar di class dasar | logika 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.