Liskov Substitution Principle: Pewarisan yang Terlihat Benar Tapi Menghancurkan Segalanya
Prinsip Substitusi Liskov (LSP) adalah salah satu dari lima prinsip SOLID desain berorientasi objek. Prinsip ini, yang dinamai berdasarkan Barbara Liskov, merupakan fondasi penting untuk membuat perangkat lunak yang kuat, terpelihara, dan mudah diperluas. Secara sederhana, LSP menyatakan bahwa jika S adalah subtipe dari T, maka objek dari T dapat digantikan dengan objek dari S (yaitu, objek dari tipe S) tanpa mengubah properti yang diinginkan dari program tersebut (correctness, task performed, dll.).
Dalam kata lain, kelas turunan (subkelas) harus dapat menggantikan kelas dasar (superclass) tanpa menyebabkan kesalahan atau perilaku tak terduga dalam program. Jika sebuah subkelas melanggar LSP, itu berarti pewarisan tidak digunakan dengan benar, dan itu dapat menyebabkan masalah yang sulit di-debug dan dipelihara.
Mengapa Liskov Substitution Principle Penting?
LSP sangat penting karena beberapa alasan:
- Meningkatkan Keterpemeliharaan: Ketika subkelas mematuhi LSP, perubahan pada subkelas tidak akan berdampak pada kode yang menggunakan kelas dasar. Ini membuat kode lebih mudah dipelihara dan diperbarui.
- Meningkatkan Reusabilitas: LSP mendorong pembuatan kode yang dapat digunakan kembali. Ketika subkelas mematuhi LSP, mereka dapat digunakan di mana pun kelas dasar digunakan, tanpa menimbulkan masalah.
- Mengurangi Risiko Kesalahan: Ketika subkelas melanggar LSP, itu dapat menyebabkan kesalahan yang sulit di-debug. LSP membantu mengurangi risiko kesalahan dengan memastikan bahwa subkelas berperilaku seperti yang diharapkan.
- Mendukung Prinsip Open/Closed: LSP mendukung Prinsip Open/Closed, yang menyatakan bahwa entitas perangkat lunak (kelas, modul, fungsi, dll.) harus terbuka untuk perluasan, tetapi tertutup untuk modifikasi. Dengan mematuhi LSP, kita dapat memperluas fungsionalitas program kita dengan menambahkan subkelas baru tanpa mengubah kode yang ada.
- Membantu desain yang lebih baik: Memikirkan tentang LSP memaksa kita untuk merencanakan dan mendesain hierarki kelas kita dengan lebih hati-hati, menghasilkan desain yang lebih intuitif dan lebih mudah dipahami.
Melanggar Liskov Substitution Principle: Contoh
Mari kita lihat contoh klasik pelanggaran LSP, yang sering digunakan untuk menggambarkan prinsip ini: Masalah Persegi dan Persegi Panjang.
Contoh Persegi dan Persegi Panjang
Bayangkan kita memiliki kelas `Rectangle` dengan properti `width` (lebar) dan `height` (tinggi) dan metode untuk mengatur nilai-nilai ini:
class Rectangle {
protected $width;
protected $height;
public function setWidth($width) {
$this->width = $width;
}
public function setHeight($height) {
$this->height = $height;
}
public function getWidth() {
return $this->width;
}
public function getHeight() {
return $this->height;
}
public function getArea() {
return $this->width * $this->height;
}
}
Sekarang, mari kita membuat kelas `Square` yang mewarisi dari `Rectangle`. Secara matematis, persegi adalah persegi panjang khusus di mana lebar dan tinggi sama. Jadi, terlihat masuk akal untuk membuat `Square` sebagai subkelas dari `Rectangle`:
class Square extends Rectangle {
public function setWidth($width) {
$this->width = $width;
$this->height = $width;
}
public function setHeight($height) {
$this->height = $height;
$this->width = $height;
}
}
Lihatlah metode `setWidth` dan `setHeight` di kelas `Square`. Mereka memastikan bahwa lebar dan tinggi selalu sama. Ini tampaknya logis, karena dalam persegi, kedua sisi harus sama.
Namun, inilah masalahnya: Kode yang bekerja dengan baik dengan `Rectangle` mungkin rusak ketika diberikan `Square`. Pertimbangkan fungsi berikut:
function resizeRectangle(Rectangle $rectangle, $newWidth, $newHeight) {
$rectangle->setWidth($newWidth);
$rectangle->setHeight($newHeight);
if ($rectangle->getWidth() * $rectangle->getHeight() > $newWidth * $newHeight) {
echo "Area increased as expected.\n";
} else {
echo "Area did NOT increase as expected.\n";
}
}
Fungsi ini mengharapkan sebuah objek `Rectangle`, mengatur lebar dan tingginya ke nilai baru, dan kemudian memeriksa apakah luasnya bertambah seperti yang diharapkan. Untuk objek `Rectangle` biasa, ini akan berfungsi seperti yang diharapkan.
Sekarang, mari kita coba menggunakan fungsi ini dengan objek `Square`:
$square = new Square();
resizeRectangle($square, 5, 10); // Mencoba mengatur lebar ke 5 dan tinggi ke 10
Dengan logika yang diimplementasikan dalam kelas `Square`, ketika kita mengatur lebar menjadi 5, tingginya juga akan diatur menjadi 5. Kemudian, ketika kita mengatur tinggi menjadi 10, lebarnya juga akan diatur menjadi 10. Hasil akhirnya adalah persegi dengan lebar dan tinggi 10. Oleh karena itu, kondisi di dalam fungsi `resizeRectangle` akan gagal, dan output yang salah akan dicetak.
Masalahnya di sini adalah bahwa `Square` tidak dapat menggantikan `Rectangle` tanpa mengubah perilaku program. Ini adalah pelanggaran LSP.
Mengapa ini Masalah?
Pelanggaran LSP dapat menyebabkan:
- Perilaku Tak Terduga: Kode Anda mungkin berperilaku berbeda dari yang Anda harapkan, membuat debugging menjadi sulit.
- Kode yang Rapuh: Perubahan kecil pada subkelas dapat memiliki dampak yang luas pada seluruh aplikasi.
- Kesulitan dalam Pemeliharaan: Memahami dan memodifikasi kode yang melanggar LSP bisa sangat menantang.
Cara Mematuhi Liskov Substitution Principle
Berikut adalah beberapa cara untuk mematuhi LSP:
- Pikirkan tentang Perilaku, Bukan Hanya Data: Pewarisan harus berdasarkan perilaku “is-a”. Subkelas harus benar-benar menjadi jenis kelas dasar dan berperilaku seperti itu. Dalam contoh Persegi dan Persegi Panjang, meskipun persegi adalah persegi panjang secara matematis, mereka memiliki perilaku yang berbeda dalam konteks manipulasi properti.
- Hindari Melemparkan Pengecualian yang Tidak Diharapkan: Subkelas tidak boleh melemparkan pengecualian yang tidak dilemparkan oleh kelas dasar, kecuali pengecualian tersebut merupakan subtipe dari pengecualian yang dilemparkan oleh kelas dasar. Ini karena kode yang ditulis untuk menangani pengecualian dari kelas dasar mungkin tidak dapat menangani pengecualian yang berbeda dari subkelas.
- Jangan Memperkuat Kondisi Pra: Kondisi pra sebuah metode adalah persyaratan yang harus dipenuhi sebelum metode dapat dipanggil. Subkelas tidak boleh memperkuat kondisi pra metode kelas dasar. Ini berarti bahwa subkelas harus dapat menangani semua input yang dapat ditangani oleh kelas dasar.
- Jangan Melemahkan Kondisi Pasca: Kondisi pasca sebuah metode adalah jaminan bahwa metode tersebut akan dipenuhi setelah dipanggil. Subkelas tidak boleh melemahkan kondisi pasca metode kelas dasar. Ini berarti bahwa subkelas harus menjamin semua yang dijamin oleh kelas dasar.
- Gunakan Komposisi daripada Pewarisan (dalam kasus tertentu): Jika pewarisan menyebabkan pelanggaran LSP, pertimbangkan untuk menggunakan komposisi. Komposisi melibatkan menggabungkan objek dari kelas lain dalam kelas Anda daripada mewarisi dari mereka. Dalam contoh Persegi dan Persegi Panjang, kita dapat membuat kelas `Square` yang memiliki objek `Rectangle` sebagai anggota.
- Desain dengan Antarmuka: Mendefinisikan antarmuka untuk kelas dasar Anda dapat membantu memastikan bahwa subkelas mematuhi LSP. Antarmuka menentukan kontrak yang harus dipenuhi oleh semua kelas yang mengimplementasikannya.
- Prinsip Interface Segregation: Pastikan antarmuka anda tidak terlalu “gemuk”. Jika kelas anda tidak membutuhkan semua metode dari sebuah antarmuka, maka pisahkan antarmuka tersebut menjadi beberapa antarmuka yang lebih kecil, sehingga kelas anda hanya perlu mengimplementasikan antarmuka yang dibutuhkan. Ini membantu mencegah pelanggaran LSP karena kelas anda tidak akan dipaksa untuk mengimplementasikan metode yang tidak relevan.
Solusi untuk Contoh Persegi dan Persegi Panjang
Bagaimana kita memperbaiki pelanggaran LSP dalam contoh Persegi dan Persegi Panjang?
Solusi yang umum adalah menghapus hubungan pewarisan antara `Square` dan `Rectangle`. Sebaliknya, kita dapat mempertimbangkan:
- Pendekatan Antarmuka: Definisikan antarmuka `Shape` dengan metode `getArea()` dan mungkin metode untuk mengatur dimensi secara umum. `Rectangle` dan `Square` dapat mengimplementasikan antarmuka ini secara independen.
- Komposisi: `Square` dapat memiliki instance `Rectangle` secara internal dan mengatur lebar dan tingginya agar tetap sama. Ini menghindari masalah pewarisan sepenuhnya.
- Kelas Terpisah: `Rectangle` dan `Square` adalah kelas yang sepenuhnya terpisah. Mereka tidak berhubungan melalui pewarisan.
Berikut adalah contoh pendekatan dengan kelas terpisah:
class Rectangle {
protected $width;
protected $height;
public function __construct($width, $height) {
$this->width = $width;
$this->height = $height;
}
public function setWidth($width) {
$this->width = $width;
}
public function setHeight($height) {
$this->height = $height;
}
public function getWidth() {
return $this->width;
}
public function getHeight() {
return $this->height;
}
public function getArea() {
return $this->width * $this->height;
}
}
class Square {
protected $side;
public function __construct($side) {
$this->side = $side;
}
public function setSide($side) {
$this->side = $side;
}
public function getSide() {
return $this->side;
}
public function getArea() {
return $this->side * $this->side;
}
}
Dalam solusi ini, `Rectangle` dan `Square` adalah kelas yang berbeda, dan tidak ada hubungan pewarisan di antara mereka. Masing-masing memiliki metodenya sendiri untuk mengatur dimensinya, tanpa mempengaruhi yang lain. Ini mematuhi LSP, karena `Square` tidak mencoba menggantikan `Rectangle` dan mengubah perilakunya secara tak terduga.
Contoh LSP dalam dunia nyata
Mari kita pertimbangkan contoh lain yang lebih kompleks yang mendekati skenario dunia nyata:
Pemrosesan Pembayaran
Bayangkan Anda membangun sistem pemrosesan pembayaran. Anda mungkin memiliki kelas dasar bernama `PaymentProcessor` dengan metode `processPayment()`. Anda kemudian dapat memiliki subkelas seperti `CreditCardProcessor`, `PayPalProcessor`, dan `BitcoinProcessor`.
class PaymentProcessor {
public function processPayment($amount) {
// Logika umum untuk pemrosesan pembayaran
echo "Processing payment of $" . $amount . "...\n";
}
}
class CreditCardProcessor extends PaymentProcessor {
public function processPayment($amount, $creditCardNumber, $expiryDate, $cvv) {
// Validasi detail kartu kredit
if (!$this->validateCreditCard($creditCardNumber, $expiryDate, $cvv)) {
throw new Exception("Invalid credit card details.");
}
// Proses pembayaran menggunakan API kartu kredit
echo "Processing credit card payment of $" . $amount . "...\n";
}
private function validateCreditCard($creditCardNumber, $expiryDate, $cvv) {
// Implementasi validasi kartu kredit
return true; // Misalnya, validasi selalu berhasil untuk contoh ini
}
}
class PayPalProcessor extends PaymentProcessor {
public function processPayment($amount, $paypalEmail, $paypalPassword) {
// Otentikasi dengan PayPal
if (!$this->authenticateWithPayPal($paypalEmail, $paypalPassword)) {
throw new Exception("Invalid PayPal credentials.");
}
// Proses pembayaran menggunakan API PayPal
echo "Processing PayPal payment of $" . $amount . "...\n";
}
private function authenticateWithPayPal($paypalEmail, $paypalPassword) {
// Implementasi otentikasi PayPal
return true; // Misalnya, otentikasi selalu berhasil untuk contoh ini
}
}
Perhatikan bahwa subkelas `CreditCardProcessor` dan `PayPalProcessor` *mengubah* tanda tangan metode `processPayment()`. Mereka menambahkan parameter spesifik untuk metode pembayaran mereka. Ini adalah pelanggaran LSP!
Mengapa ini merupakan pelanggaran LSP?
Bayangkan kita memiliki fungsi yang bekerja dengan objek `PaymentProcessor`:
function processOrder(PaymentProcessor $processor, $amount) {
$processor->processPayment($amount);
}
Fungsi ini mengharapkan objek `PaymentProcessor` dan memanggil metode `processPayment()` dengan jumlah pembayaran. Ini akan bekerja dengan baik jika kita meneruskan objek `PaymentProcessor` dasar.
Namun, jika kita mencoba meneruskan objek `CreditCardProcessor` atau `PayPalProcessor`, kode akan rusak karena mereka memerlukan parameter tambahan yang tidak diharapkan oleh fungsi `processOrder`.
Cara Memperbaiki Pelanggaran LSP
Untuk memperbaiki pelanggaran LSP ini, kita perlu memastikan bahwa semua subkelas `PaymentProcessor` memiliki tanda tangan metode `processPayment()` yang sama. Kita dapat melakukan ini dengan menggunakan pendekatan berikut:
- Menggunakan Antarmuka: Definisikan antarmuka `PaymentProcessorInterface` dengan metode `processPayment($paymentDetails)`. Kemudian, setiap subkelas mengimplementasikan antarmuka ini dan menerima objek `$paymentDetails` yang berisi informasi spesifik yang dibutuhkan untuk memproses pembayaran.
- Objek Transfer Data (DTO): Buat objek DTO yang berisi semua informasi yang diperlukan untuk memproses pembayaran. Metode `processPayment()` kemudian menerima objek DTO ini sebagai parameter.
Berikut adalah contoh menggunakan pendekatan Antarmuka:
interface PaymentProcessorInterface {
public function processPayment($paymentDetails);
}
class PaymentProcessor implements PaymentProcessorInterface {
public function processPayment($paymentDetails) {
// Logika umum untuk pemrosesan pembayaran
echo "Processing payment of $" . $paymentDetails['amount'] . "...\n";
}
}
class CreditCardProcessor implements PaymentProcessorInterface {
public function processPayment($paymentDetails) {
// Validasi detail kartu kredit
if (!$this->validateCreditCard($paymentDetails['creditCardNumber'], $paymentDetails['expiryDate'], $paymentDetails['cvv'])) {
throw new Exception("Invalid credit card details.");
}
// Proses pembayaran menggunakan API kartu kredit
echo "Processing credit card payment of $" . $paymentDetails['amount'] . "...\n";
}
private function validateCreditCard($creditCardNumber, $expiryDate, $cvv) {
// Implementasi validasi kartu kredit
return true; // Misalnya, validasi selalu berhasil untuk contoh ini
}
}
class PayPalProcessor implements PaymentProcessorInterface {
public function processPayment($paymentDetails) {
// Otentikasi dengan PayPal
if (!$this->authenticateWithPayPal($paymentDetails['paypalEmail'], $paymentDetails['paypalPassword']) {
throw new Exception("Invalid PayPal credentials.");
}
// Proses pembayaran menggunakan API PayPal
echo "Processing PayPal payment of $" . $paymentDetails['amount'] . "...\n";
}
private function authenticateWithPayPal($paypalEmail, $paypalPassword) {
// Implementasi otentikasi PayPal
return true; // Misalnya, otentikasi selalu berhasil untuk contoh ini
}
}
function processOrder(PaymentProcessorInterface $processor, $paymentDetails) {
$processor->processPayment($paymentDetails);
}
// Penggunaan
$creditCardDetails = [
'amount' => 100,
'creditCardNumber' => '1234567890123456',
'expiryDate' => '12/24',
'cvv' => '123'
];
$creditCardProcessor = new CreditCardProcessor();
processOrder($creditCardProcessor, $creditCardDetails);
Dengan pendekatan ini, semua subkelas `PaymentProcessor` mengimplementasikan antarmuka `PaymentProcessorInterface` dan menerima array `$paymentDetails`. Ini memungkinkan kita untuk mengirimkan objek `CreditCardProcessor` atau `PayPalProcessor` ke fungsi `processOrder()` tanpa menyebabkan kesalahan, karena semua implementasi `processPayment()` menerima parameter yang sama (walaupun mungkin menggunakan data yang berbeda dari array tersebut).
Kapan Pewarisan Bukan Pilihan yang Tepat
Meskipun pewarisan adalah alat yang ampuh, penting untuk diingat bahwa itu bukan selalu pilihan yang tepat. Dalam beberapa kasus, komposisi atau pendekatan lain mungkin lebih sesuai. Berikut adalah beberapa situasi di mana Anda mungkin ingin mempertimbangkan untuk menghindari pewarisan:
- Ketika hubungan “is-a” tidak benar-benar ada: Jika subkelas tidak benar-benar merupakan jenis kelas dasar, pewarisan mungkin bukan pilihan yang tepat. Dalam contoh Persegi dan Persegi Panjang, meskipun persegi adalah persegi panjang secara matematis, mereka memiliki perilaku yang berbeda dalam konteks manipulasi properti.
- Ketika subkelas perlu menimpa banyak metode kelas dasar: Jika subkelas perlu menimpa sejumlah besar metode kelas dasar, ini mungkin merupakan indikasi bahwa pewarisan tidak digunakan dengan benar. Dalam kasus ini, Anda mungkin ingin mempertimbangkan untuk menggunakan komposisi atau pendekatan lain.
- Ketika Anda ingin menghindari masalah kerapuhan: Pewarisan dapat menyebabkan kode yang rapuh, di mana perubahan kecil pada kelas dasar dapat memiliki dampak yang luas pada seluruh aplikasi. Jika Anda ingin menghindari masalah kerapuhan, Anda mungkin ingin mempertimbangkan untuk menggunakan komposisi atau pendekatan lain.
Kesimpulan
Prinsip Substitusi Liskov adalah prinsip penting dalam desain berorientasi objek. Dengan mematuhi LSP, kita dapat membuat perangkat lunak yang lebih kuat, terpelihara, dan mudah diperluas. Pelanggaran LSP dapat menyebabkan perilaku yang tak terduga, kode yang rapuh, dan kesulitan dalam pemeliharaan. Penting untuk memahami LSP dan cara menerapkannya dalam kode Anda untuk menghindari masalah ini.
Ingatlah bahwa pewarisan harus didasarkan pada perilaku “is-a”, dan subkelas harus dapat menggantikan kelas dasar tanpa mengubah properti yang diinginkan dari program. Jika pewarisan menyebabkan pelanggaran LSP, pertimbangkan untuk menggunakan komposisi atau pendekatan lain. Dengan mengikuti prinsip-prinsip ini, Anda dapat menulis kode yang lebih baik dan lebih mudah dikelola.
Dengan pemahaman yang baik tentang LSP, Anda dapat merancang hierarki kelas yang lebih solid dan menghindari jebakan umum yang dapat menyebabkan kode yang sulit dipelihara. Selalu ingat untuk memprioritaskan perilaku daripada hanya data saat memutuskan apakah akan menggunakan pewarisan atau tidak. Dengan demikian, Anda akan dapat membuat perangkat lunak yang lebih tangguh dan dapat diandalkan.
“`