Prinsip ini berbicara tentang subclass yang kuat. Prinsip ini pertama kali dikemukakan oleh Barbara Liskov. Jika sebuah class memiliki behavior, maka seluruh turunannya wajib memiliki behavior tersebut secara natural. Melakukan sebuah throwable secara default pada sebuah method milik turunan yang ternyata tidak memiliki behavior seperti abstrak melanggar Liskov Substitution Principle. Prinsip Liskov Substitution ini membuat kita berpikir lebih dalam lagi ketika membuat design sebuah class beserta turunannya. Contoh yang ada di google kadangkala memberi solusi dari Liskov Substitution menggunakan Interface Segregation. Padahal antara Liskov Substitution dengan Interface Segregation itu berbeda. Uncle Bob sendiri di dalam blognya juga menegaskan bahwa prinsip ini bukan tentang inheritance, melainkan tentang subtype yang kuat. Sebuah object harus replacable terhadap turunannya. Perfect example tentang Liskov Substitution adalah Kotak dengan subclass-nya: Persegi dan Persegi Panjang. Kali ini gw akan coba memberi contoh lain selain Kotak, Persegi dan Persegi Panjang.
Pelanggaran Liskov Substitution Principle
Contoh kasusnya adalah kita bikin program yang mengeluarkan output dari tingkah laku Burung seperti di bawah ini:
Class Bird
public interface Bird{
String getFood();
double getFlyDistance();
String getSleepBehavior();
}
Pada interface di atas ada 3 behavior burung yang umum dan setiap jenisnya unik. Otomatis setiap subclass dari Bird di atas wajib mengimplementasi 3 behavior di atas. Misalnya pada burung merpati (pigeon):
Class Pigeon
public class Pigeon implements Bird{
@Override
public String getFood(){
return "fruits";
}
@Override
public double getFlyDistance(){
return 700;
}
@Override
public String getSleepBehavior(){
return "on elevated perches at night";
}
}
Merpati bisa makan buah, terbang sejauh 700 mil, dan tidur di ketinggian pada malam hari. Code untuk melakukan eksekusi menggunakan design di atas kira-kira seperti ini:
Class BirdBehavior
public class BirdBehavior{
public void main(){
Bird bird = new Pigeon();
String food = bird.getFood();
double flyDistance = bird.getFlyDistance();
String sleepBehavior = bird.getSleepBehavior();
System.out.println("sleepBehavior = " + sleepBehavior);
System.out.println("food = " + food);
System.out.println("fly distance in miles = " + flyDistance);
}
}
Sekarang lanjut ke turunan selanjutnya, misalnya burung flamingo:
Class Flamingo
public class Flamingos implements Bird{
@Override
public String getFood(){
return "larva";
}
@Override
public double getFlyDistance(){
return 373;
}
@Override
public String getSleepBehavior(){
return "one leg";
}
}
Flamingo memakan larva, bisa terbang sejauh 373 mil, dan tidur dengan satu kaki. Semuanya masih baik-baik saja
Class BirdBehavior
public class BirdBehavior{
public void main(){
Bird bird = new Flamingos();
String food = bird.getFood();
double flyDistance = bird.getFlyDistance();
String sleepBehavior = bird.getSleepBehavior();
System.out.println("sleepBehavior = " + sleepBehavior);
System.out.println("food = " + food);
System.out.println("fly distance in miles = " + flyDistance);
}
}
Code di atas berjalan lancar sesuai semestinya. Mari lanjut ke jenis burung lainnya, seperti burung unta (Ostrich):
Class Ostrich
public class Ostrich implements Bird{
@Override
public String getFood(){
return "plants";
}
@Override
public double getFlyDistance(){
throw new IllegalAccessException("Ostrich can not fly");
}
@Override
public String getSleepBehavior(){
throw new IllegalAccessException("Ostrich don't sleep at all");
}
}
Class BirdBehavior
public class BirdBehavior{
public void main(){
Bird bird = new Ostrich();
String food = bird.getFood();
double flyDistance = bird.getFlyDistance();
String sleepBehavior = bird.getSleepBehavior();
System.out.println("sleepBehavior = " + sleepBehavior);
System.out.println("food = " + food);
System.out.println("fly distance in miles = " + flyDistance);
}
}
Prit...!! disini pelanggaran terjadi. Burung unta memang bisa makan tanaman. Masalahnya burung unta ga bisa terbang dan ga tidur (fun fact, gw juga baru tahu fakta ini, padahal gw nambahin behavior sleep itu ngasal alias random aja🤣). Ketika implementasinya kita ganti ke Ostrich, code-nya bakal error karena saat melakukan eksekusi bird.fly() dan/atau bird.sleep() sebagai burung unta akan mengakibatkan runtime IllegalAccessException secara natural. Inilah yang disebut dengan pelanggaran Liskov Substitution Principle.
Solusinya
Pada contoh di atas, burung unta tidak memenuhi requirement burung pada design di atas. Jadi sudah jelas burung unta ga bisa mengimplementasi interface Bird karena object tersebut ga replacable. Done! Secara design class di awal, Ostrich memang berbeda dengan Bird. Jadi solusinya cukup seperti ini:
Class Ostrich
public class Ostrich {
public String getFood(){
return "plants";
}
}
Atau misalkan burung unta ini memang terbagi dalam beberapa sub-spesies dan kita ingin behavior tentang burung unta ini lebih detail lagi, maka bisa diimprove lagi dengan membuat struktur sendiri untuk burung unta. Misalkan seperti berikut:
Interface CommonOstrich
public interface CommonOstrich{
default String getFood(){
return "plants";
}
String neckColor();
}
Class SomaliOstrich
public class SomaliOstrich implements CommonOstrich{
@Override
public String neckColor(){
return "grey-blue";
}
}
Class NorthAfricanOstrich
public class NorthAfricanOstrich implements CommonOstrich{
@Override
public String neckColor(){
return "pink";
}
}
Salah satu solusi yang sering ditemui saat googling adalah dengan memecah Bird menjadi FlyableBird dan SleepableBird. Menurut gw solusi itu sangat misleading, malah menambah masalah baru terhadap kompleksitas pada type yang sudah ada dan akan mengganggu entitas lainnya. Memecah Bird menjadi FlyableBird dan apalagi SleepableBird hanya akan membuat kompleks hierarki Bird. Selain itu, subtype lah yang harus mengikuti super type, dan implementation class lah yang harus mengikuti abstract/interface. Bukan sebaliknya. Jika Ostrich memang tidak memenuhi kriteria Bird yang sudah ditentukan di awal maka Ostrich memang "bukan" Bird pada design kita, jangan dipaksa. Jika seekor burung memang didesain harus bisa terbang sedari awal, maka seluruh burung wajib bisa terbang, dan yang ga bisa terbang berarti BUKAN burung! Ini inti dari Liskov Substitution Principle, bukan memecahnya. Itu hal yang berbeda dan akan membuat desain menjadi inkonsisten. Logika sederhananya: Jika "semua Bird BISA terbang" dan "Ostrich GA BISA terbang", maka kesimpulannya: "Ostrich BUKAN Bird". Method fly() dan sleep() merupakan invarian dari struktur Bird, yaitu behavior yang harus ada pada semua Bird yang kita design di awal. Jika memang ada beberapa behavior dari Bird yang di-reuse pada Ostrich, refactor menggunakan Composition over Inheritance bisa dipertimbangkan, ga harus lewat inheritance karena akan membuat hirarki makin ribet.
Liskov Substitution Principle Pada Java
Sebenarnya Java juga menerapkan Liskov Substitution Principle pada Collection Framework. Collection Framework pada Java terdiri dari List, Set, Queue, Dequeue, dan Map. Semuanya meng-extend interface Collection kecuali Map. Meskipun Map juga bagian dari Collection Framework, tapi Map tidak bisa meng-extend interface Collection karena Collection didesain untuk menampung satu tipe sebagai elemen, sedangkan Map menampung dua tipe sebagai key dan value. Contohnya saat insertion pada Map butuh dua parameter pada method put, sedangkan Collection hanya butuh satu parameter pada method add. Memaksakan Map menjadi Collection tentu akan terjadi perombakan besar-besaran. Jadinya kedua tipe tersebut tidak compatible dan harus dipisah. Oleh karena itu, Map dibuatkan interface sendiri meskipun juga merupakan bagian dari Collection framework, sama seperti halnya Ostrich di atas.
Arrays.asList()
Melanggar Liskov Substitution Principle?
Seperti yang sudah diketahui, secara natural objek yang dihasilkan Arrays.asList() ga akan bisa melakukan add() atau remove() dan akan menghasilkan exception. Bagi yang pernah menggunakan Arrays.asList() pasti bertanya-tanya, apakah Arrays.asList() ini melanggar Liskov Substitution Principle? Jawabannya tidak. Coba saja liat behavior add() atau remove() dari Arrays.asList(), dia sama sekali ga melakukan throw Exception di sana. Bahkan Arrays.asList() sendiri ga meng-override method add() dan remove(). Kok bisa? Karena sesungguhnya itu turunan dari behavior AbstractList. Parentnya sendiri secara default emang melakukan throwable pada method add() dan remove(). Jadi, turunannya dikasih pilihan mau ikut parentnya untuk sama-sama melakukan throwable pada method add() dan remove() atau turunannya punya kelebihan tersendiri untuk bisa melakukan itu seperti pada ArrayList dan LinkedList. Kasusnya berbeda karena kalau turunan secara natural punya banyak "kelebihan" dari parent gak apa. Yang melanggar itu kalau turunannya ga sehebat parent yang punya behavior tersebut secara natural.
Kesimpulan
Dengan prinsip Liskov Substitution ini membuat kita lebih berhati-hati dalam menentukan design aplikasi. Jangan sampai code yang sudah berjalan jadi terganggu gara-gara ada behavior pada salah satu turunannya ternyata perilakunya menyimpang dari design awal. Design yang menyimpang itulah yang sesat dan menyesatkan🤣. Design seperti itu seringkali membingungkan dan susah dimengerti ketika ada pengembangan-pengembangan lanjutannya dan fungsinya jadi bergeser. Makanya penting untuk menganalisa dan menentukan tujuan yang jelas tentang design yang akan dibuat. Konsisten terhadap design yang telah ditentukan, seperti pada contoh di atas, jika dari awal behavior dari Bird memang adalah eat(), fly() dan sleep() maka seluruh subtype-nya secara natural minimal wajib melakukan hal yang sama. Kalau behavior-nya secara natural memang ga bisa mengikuti supertype-nya, ya udah jangan dipaksa, biarkan Ostrich menjadi dirinya sendiri.
Prinsip SOLID lainnya: