Panduan Padat untuk Prinsip SOLID

1. Perkenalan

Dalam tutorial ini, kita akan membahas prinsip SOLID dari Desain Berorientasi Objek.

Pertama, kita akan mulai dengan mengeksplorasi alasan munculnya dan mengapa kita harus mempertimbangkannya saat merancang perangkat lunak. Kemudian, kami akan menguraikan setiap prinsip bersama beberapa kode contoh untuk menekankan intinya.

2. Alasan Prinsip SOLID

Prinsip SOLID pertama kali dikonseptualisasikan oleh Robert C. Martin dalam makalahnya tahun 2000, Design Principles and Design Patterns. Konsep-konsep ini kemudian dibangun oleh Michael Feathers, yang memperkenalkan kami pada akronim SOLID. Dan dalam 20 tahun terakhir, 5 prinsip ini telah merevolusi dunia pemrograman berorientasi objek, mengubah cara kita menulis perangkat lunak.

Jadi, apa itu SOLID dan bagaimana itu membantu kita menulis kode yang lebih baik? Sederhananya, prinsip desain Martin dan Feathers mendorong kami untuk membuat perangkat lunak yang lebih mudah dirawat, dapat dipahami, dan fleksibel . Akibatnya, seiring bertambahnya ukuran aplikasi kami, kami dapat mengurangi kerumitannya dan menghindari banyak sakit kepala di kemudian hari!

5 konsep berikut membentuk prinsip SOLID kami:

  1. S perapian di tungku Tanggung Jawab
  2. O pena / Tertutup
  3. Pergantian L iskov
  4. Saya nterface Pemisahan
  5. D ependency Pembalikan

Meskipun beberapa dari kata-kata ini mungkin terdengar menakutkan, kata-kata tersebut dapat dengan mudah dipahami dengan beberapa contoh kode sederhana. Di bagian berikut, kita akan mendalami apa arti masing-masing prinsip ini, bersama dengan contoh Java cepat untuk mengilustrasikan masing-masing.

3. Tanggung Jawab Tunggal

Mari kita mulai dengan prinsip tanggung jawab tunggal. Seperti yang mungkin kita harapkan, asas ini menyatakan bahwa sebuah kelas hendaknya hanya memiliki satu tanggung jawab. Selain itu, seharusnya hanya ada satu alasan untuk berubah.

Bagaimana prinsip ini membantu kita membangun perangkat lunak yang lebih baik? Mari kita lihat beberapa manfaatnya:

  1. Pengujian - Kelas dengan satu tanggung jawab akan memiliki kasus pengujian yang jauh lebih sedikit
  2. Kopling lebih rendah - Lebih sedikit fungsionalitas dalam satu kelas akan memiliki lebih sedikit ketergantungan
  3. Organisasi - Kelas yang lebih kecil dan teratur lebih mudah dicari daripada kelas monolitik

Ambil, misalnya, kelas untuk merepresentasikan sebuah buku sederhana:

public class Book { private String name; private String author; private String text; //constructor, getters and setters }

Dalam kode ini, kami menyimpan nama, penulis, dan teks yang terkait dengan sebuah instance dari sebuah Buku .

Sekarang mari tambahkan beberapa metode untuk membuat kueri teks:

public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }

Sekarang, kelas Buku kami berfungsi dengan baik, dan kami dapat menyimpan buku sebanyak yang kami suka dalam aplikasi kami. Tapi, apa gunanya menyimpan informasi jika kita tidak dapat mengeluarkan teks ke konsol kita dan membacanya?

Mari berhati-hati dan tambahkan metode cetak:

public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }

Namun, kode ini melanggar prinsip tanggung jawab tunggal yang kami uraikan sebelumnya. Untuk memperbaiki kekacauan kita, kita harus menerapkan kelas terpisah yang hanya peduli dengan pencetakan teks kita:

public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }

Hebat. Kami tidak hanya mengembangkan kelas yang meringankan tugas pencetakan Buku , tetapi kami juga dapat memanfaatkan kelas BookPrinter kami untuk mengirim teks kami ke media lain.

Baik itu email, logging, atau apa pun, kami memiliki kelas terpisah yang didedikasikan untuk masalah yang satu ini.

4. Terbuka untuk Perpanjangan, Ditutup untuk Modifikasi

Sekarang, waktunya untuk 'O' - lebih secara formal dikenal sebagai prinsip terbuka-tertutup . Sederhananya, kelas harus terbuka untuk ekstensi, tetapi ditutup untuk modifikasi. Dengan melakukan itu, kami menghentikan diri kami dari memodifikasi kode yang ada dan menyebabkan potensi bug baru dalam aplikasi yang sebaliknya.

Tentu saja, satu pengecualian untuk aturan tersebut adalah saat memperbaiki bug dalam kode yang sudah ada.

Mari jelajahi konsepnya lebih jauh dengan contoh kode cepat. Sebagai bagian dari proyek baru, bayangkan kita telah menerapkan kelas Gitar .

Ini sepenuhnya matang dan bahkan memiliki kenop volume:

public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }

Kami meluncurkan aplikasi, dan semua orang menyukainya. Namun, setelah beberapa bulan, kami memutuskan bahwa Gitar tersebut agak membosankan dan dapat dilakukan dengan pola nyala api yang mengagumkan agar terlihat sedikit lebih 'rock and roll'.

Pada titik ini, mungkin tergoda untuk membuka kelas Gitar dan menambahkan pola nyala api - tetapi siapa yang tahu kesalahan apa yang mungkin muncul dalam aplikasi kita.

Sebagai gantinya, mari kita tetap berpegang pada prinsip terbuka-tertutup dan cukup memperluas kelas Gitar kita :

public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }

Dengan memperluas kelas Gitar kami dapat yakin bahwa aplikasi kami yang ada tidak akan terpengaruh.

5. Pergantian Liskov

Selanjutnya dalam daftar kami adalah substitusi Liskov, yang bisa dibilang paling kompleks dari 5 prinsip. Sederhananya, jika kelas A adalah subtipe dari kelas B , maka kita harus dapat mengganti B dengan A tanpa mengganggu perilaku program kita.

Mari langsung beralih ke kode untuk membantu memahami konsep ini:

public interface Car { void turnOnEngine(); void accelerate(); }

Di atas, kami mendefinisikan antarmuka Mobil sederhana dengan beberapa metode yang harus dapat dipenuhi oleh semua mobil - menyalakan mesin, dan mempercepat maju.

Mari terapkan antarmuka kita dan berikan beberapa kode untuk metode:

public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }

Seperti yang dijelaskan kode kami, kami memiliki mesin yang dapat dihidupkan, dan kami dapat meningkatkan daya. Tapi tunggu, ini tahun 2019, dan Elon Musk telah menjadi orang yang sibuk.

We are now living in the era of electric cars:

public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }

By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.

One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.

6. Interface Segregation

The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.

Let's start with an interface that outlines our roles as a bear keeper:

public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }

As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.

Let's fix this by splitting our large interface into 3 separate ones:

public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }

Now, thanks to interface segregation, we're free to implement only the methods that matter to us:

public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }

And finally, we can leave the dangerous stuff to the crazy people:

public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }

Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.

7. Dependency Inversion

The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.

To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:

public class Windows98Machine {}

But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:

public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }

This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.

Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.

Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:

public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }

Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.

Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.

8. Conclusion

In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.

Kami mulai dengan sedikit sejarah SOLID dan alasan prinsip-prinsip ini ada.

Huruf demi huruf, kami telah memecah arti setiap prinsip dengan contoh kode cepat yang melanggarnya. Kami kemudian melihat cara memperbaiki kode kami dan membuatnya mematuhi prinsip SOLID.

Seperti biasa, kode tersedia di GitHub.