Prinsip SOLID yang Harus Diketahui Setiap Pengembang

Pemrograman berorientasi objek telah membawa pendekatan baru untuk desain aplikasi ke dalam pengembangan perangkat lunak. Secara khusus, OOP memungkinkan pemrogram untuk menggabungkan entitas, disatukan oleh tujuan atau fungsi yang sama, dalam kelas yang terpisah, yang dirancang untuk memecahkan masalah independen dan independen dari bagian lain dari aplikasi. Namun, penggunaan OOP saja tidak berarti bahwa pengembang aman dari kemungkinan membuat kode yang kabur dan membingungkan yang sulit dipertahankan. Robert Martin, untuk membantu semua orang yang ingin mengembangkan aplikasi OOP berkualitas tinggi, mengembangkan lima prinsip pemrograman dan desain berorientasi objek, berbicara tentang itu, dengan bantuan Michael Fazers, mereka menggunakan akronim SOLID.



Bahan, terjemahan yang kami terbitkan hari ini, didedikasikan untuk dasar-dasar SOLID dan ditujukan untuk pengembang pemula.

Apa itu SOLID?


Berikut adalah singkatan SOLID:

  • S: Prinsip Tanggung Jawab Tunggal.
  • O: Prinsip Terbuka-Tertutup.
  • L: Prinsip Substitusi Liskov (Prinsip Pergantian Barbara Liskov).
  • I: Prinsip Segregasi Antarmuka.
  • D: Prinsip Pembalikan Ketergantungan.

Sekarang kita akan mempertimbangkan prinsip-prinsip ini dalam contoh skema. Perhatikan bahwa tujuan utama dari contoh-contoh ini adalah untuk membantu pembaca memahami prinsip-prinsip SOLID, mempelajari cara menerapkannya, dan bagaimana mengikuti mereka ketika merancang aplikasi. Penulis materi tidak berusaha untuk mencapai kode kerja yang dapat digunakan dalam proyek nyata.

Prinsip tanggung jawab tunggal


"Satu tugas. Hanya satu hal. " - Loki menceritakan Skurge dalam film Thor: Ragnarok.
Setiap kelas harus menyelesaikan hanya satu masalah.

Sebuah kelas seharusnya hanya bertanggung jawab atas satu hal. Jika suatu kelas bertanggung jawab untuk menyelesaikan beberapa masalah, subsistemnya yang mengimplementasikan solusi dari masalah ini ternyata saling terkait satu sama lain. Perubahan dalam satu subsistem tersebut menyebabkan perubahan yang lain.

Perhatikan bahwa prinsip ini berlaku tidak hanya untuk kelas, tetapi juga untuk komponen perangkat lunak dalam arti yang lebih luas.

Sebagai contoh, pertimbangkan kode ini:

class Animal {    constructor(name: string){ }    getAnimalName() { }    saveAnimal(a: Animal) { } } 

Kelas Animal disajikan di sini menggambarkan beberapa jenis hewan. Kelas ini melanggar prinsip tanggung jawab tunggal. Bagaimana tepatnya prinsip ini dilanggar?

Sesuai dengan prinsip tanggung jawab tunggal, kelas harus menyelesaikan hanya satu tugas. Dia memecahkan keduanya dengan bekerja dengan gudang data dalam metode saveAnimal dan memanipulasi properti objek di konstruktor dan metode getAnimalName .

Bagaimana struktur kelas seperti itu dapat menyebabkan masalah?

Jika prosedur untuk bekerja dengan gudang data yang digunakan oleh perubahan aplikasi, maka Anda harus membuat perubahan pada semua kelas yang bekerja dengan gudang. Arsitektur ini tidak fleksibel, perubahan dalam beberapa subsistem mempengaruhi yang lain, yang menyerupai efek domino.

Untuk membawa kode di atas sejalan dengan prinsip tanggung jawab tunggal, kita akan membuat kelas lain yang tugasnya hanya bekerja dengan repositori, khususnya, menyimpan objek kelas Animal di dalamnya:

 class Animal {   constructor(name: string){ }   getAnimalName() { } } class AnimalDB {   getAnimal(a: Animal) { }   saveAnimal(a: Animal) { } } 

Inilah yang dikatakan Steve Fenton tentang ini: “Ketika merancang kelas, kita harus berusaha untuk mengintegrasikan komponen terkait, yaitu, di mana perubahan terjadi karena alasan yang sama. Kita harus mencoba memisahkan komponen, perubahan yang menyebabkan berbagai alasan. "

Penerapan prinsip tanggung jawab tunggal yang benar mengarah pada konektivitas tingkat tinggi dari elemen-elemen di dalam modul, yaitu kenyataan bahwa tugas-tugas yang diselesaikan di dalamnya sesuai dengan tujuan utamanya.

Prinsip terbuka-tertutup


Entitas perangkat lunak (kelas, modul, fungsi) harus terbuka untuk ekspansi, tetapi tidak untuk modifikasi.

Kami terus bekerja di kelas Animal .

 class Animal {   constructor(name: string){ }   getAnimalName() { } } 

Kami ingin memilah-milah daftar hewan, yang masing-masing diwakili oleh objek dari kelas Animal , dan mencari tahu suara apa yang mereka buat. Bayangkan kita memecahkan masalah ini menggunakan fungsi AnimalSounds :

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';   } } AnimalSound(animals); 

Masalah utama dengan arsitektur ini adalah bahwa fungsi menentukan jenis suara apa yang dibuat oleh hewan ketika menganalisis objek tertentu. Fungsi AnimalSound tidak sesuai dengan prinsip keterbukaan-keterbukaan, karena, misalnya, ketika jenis hewan baru muncul, kita perlu mengubahnya untuk menggunakannya untuk mengenali suara yang dibuat oleh mereka.

Tambahkan elemen baru ke array:

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse'),   new Animal('snake') ] //... 

Setelah itu, kita harus mengubah kode fungsi AnimalSound :

 //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';       if(a[i].name == 'snake')           return 'hiss';   } } AnimalSound(animals); 

Seperti yang Anda lihat, ketika menambahkan hewan baru ke array, Anda harus menambahkan kode fungsi. Contohnya sangat sederhana, tetapi jika arsitektur yang serupa digunakan dalam proyek nyata, fungsinya harus terus diperluas, menambahkan ekspresi baru if itu.

Bagaimana cara membawa fungsi AnimalSound sejalan dengan prinsip buka-tutup? Misalnya, seperti ini:

 class Animal {       makeSound();       //... } class Lion extends Animal {   makeSound() {       return 'roar';   } } class Squirrel extends Animal {   makeSound() {       return 'squeak';   } } class Snake extends Animal {   makeSound() {       return 'hiss';   } } //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       a[i].makeSound();   } } AnimalSound(animals); 

Anda mungkin memperhatikan bahwa kelas Animal sekarang memiliki metode makeSound virtual. Dengan pendekatan ini, perlu bahwa kelas yang dirancang untuk menggambarkan hewan tertentu memperluas kelas Animal dan menerapkan metode ini.

Akibatnya, setiap kelas yang mendeskripsikan hewan akan memiliki metode makeSound sendiri, dan ketika iterasi pada array dengan hewan dalam fungsi AnimalSound , cukup memanggil metode ini untuk setiap elemen array.

Jika sekarang Anda menambahkan objek yang menggambarkan hewan baru ke array, Anda tidak perlu mengubah fungsi AnimalSound . Kami membawanya sejalan dengan prinsip keterbukaan-kedekatan.

Pertimbangkan contoh lain.

Misalkan kita punya toko. Kami memberi pelanggan diskon 20% menggunakan kelas ini:

 class Discount {   giveDiscount() {       return this.price * 0.2   } } 

Sekarang diputuskan untuk membagi pelanggan menjadi dua kelompok. Pelanggan favorit ( fav ) diberikan diskon 20%, dan pelanggan VIP ( vip ) - gandakan diskon, yaitu - 40%. Untuk mengimplementasikan logika ini, diputuskan untuk memodifikasi kelas sebagai berikut:

 class Discount {   giveDiscount() {       if(this.customer == 'fav') {           return this.price * 0.2;       }       if(this.customer == 'vip') {           return this.price * 0.4;       }   } } 

Pendekatan ini melanggar prinsip keterbukaan-kedekatan. Seperti yang Anda lihat, di sini, jika kami perlu memberikan diskon khusus kepada kelompok pelanggan tertentu, kami harus menambahkan kode baru ke kelas.

Untuk memproses kode ini sesuai dengan prinsip keterbukaan-kedekatan, kami menambahkan kelas baru ke proyek yang memperluas kelas Discount . Di kelas baru ini, kami menerapkan mekanisme baru:

 class VIPDiscount: Discount {   getDiscount() {       return super.getDiscount() * 2;   } } 

Jika Anda memutuskan untuk memberikan diskon 80% kepada pelanggan "super-VIP", seharusnya terlihat seperti ini:

 class SuperVIPDiscount: VIPDiscount {   getDiscount() {       return super.getDiscount() * 2;   } } 

Seperti yang Anda lihat, pemberdayaan kelas digunakan di sini, bukan modifikasinya.

Prinsip substitusi Barbara Liskov


Sangatlah penting bahwa subclass berfungsi sebagai pengganti superclasses mereka.

Tujuan dari prinsip ini adalah bahwa kelas warisan dapat digunakan sebagai pengganti kelas induk dari mana mereka dibentuk tanpa mengganggu program. Jika ternyata tipe kelas diperiksa dalam kode, maka prinsip substitusi dilanggar.

Pertimbangkan penerapan prinsip ini, kembali ke contoh dengan kelas Animal . Kami akan menulis fungsi yang dirancang untuk mengembalikan informasi tentang jumlah anggota tubuh hewan.

 //... function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);       if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);   } } AnimalLegCount(animals); 

Fungsi tersebut melanggar prinsip substitusi (dan prinsip keterbukaan-penutupan). Kode ini harus tahu tentang jenis semua objek yang diproses olehnya dan, tergantung pada jenisnya, gunakan fungsi yang sesuai untuk menghitung anggota tubuh hewan tertentu. Akibatnya, saat membuat jenis hewan baru, fungsinya harus ditulis ulang:

 //... class Pigeon extends Animal {      } const animals[]: Array<Animal> = [   //...,   new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);        if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);       if(typeof a[i] == Pigeon)           return PigeonLegCount(a[i]);   } } AnimalLegCount(animals); 

Agar fungsi ini tidak melanggar prinsip substitusi, kami mengubahnya menggunakan persyaratan yang dirumuskan oleh Steve Fenton. Mereka terdiri dalam kenyataan bahwa metode yang menerima atau mengembalikan nilai dengan jenis beberapa superclass ( Animal dalam kasus kami) juga harus menerima dan mengembalikan nilai yang jenisnya adalah subclass ( Pigeon ).

Berbekal pertimbangan ini, kita dapat mengulang fungsi AnimalLegCount :

 function AnimalLegCount(a: Array<Animal>) {   for(let i = 0; i <= a.length; i++) {       a[i].LegCount();   } } AnimalLegCount(animals); 

Sekarang fungsi ini tidak tertarik pada jenis objek yang diteruskan ke sana. Dia hanya memanggil metode LegCount mereka. Yang dia tahu tentang tipe adalah bahwa objek yang dia proses harus milik kelas Animal atau subkelasnya.

Metode LegCount sekarang akan muncul di kelas Animal :

 class Animal {   //...   LegCount(); } 

Dan subclassnya perlu mengimplementasikan metode ini:

 //... class Lion extends Animal{   //...   LegCount() {       //...   } } //... 

Sebagai hasilnya, misalnya, ketika mengakses metode LegCount untuk instance dari kelas Lion , metode yang diterapkan dalam kelas ini dipanggil dan apa yang dapat diharapkan dari memanggil metode seperti itu dikembalikan.

Sekarang fungsi AnimalLegCount tidak perlu tahu tentang objek subclass tertentu dari kelas Animal yang diproses untuk mengetahui informasi tentang jumlah anggota tubuh pada hewan yang diwakili oleh objek ini. Fungsi ini hanya memanggil metode LegCount dari kelas Animal , karena subclass dari kelas ini harus mengimplementasikan metode ini sehingga mereka dapat digunakan sebagai gantinya, tanpa melanggar operasi program yang benar.

Prinsip pemisahan antarmuka


Buat antarmuka yang sangat khusus yang dirancang untuk klien tertentu. Klien tidak harus bergantung pada antarmuka yang tidak mereka gunakan.

Prinsip ini bertujuan untuk mengatasi kekurangan yang terkait dengan implementasi antarmuka yang besar.

Pertimbangkan antarmuka Shape :

 interface Shape {   drawCircle();   drawSquare();   drawRectangle(); } 

Ini menjelaskan metode untuk menggambar lingkaran ( drawCircle ), kotak ( drawSquare ) dan persegi panjang ( drawRectangle ). Akibatnya, kelas yang mengimplementasikan antarmuka ini dan mewakili bentuk geometris individual, seperti Lingkaran, Kotak, dan Persegi Panjang, harus berisi implementasi semua metode ini. Ini terlihat seperti ini:

 class Circle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Square implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Rectangle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } 

Kode aneh ternyata. Sebagai contoh, kelas Rectangle mewakili metode mengimplementasikan persegi panjang ( drawCircle dan drawSquare ) yang tidak perlu sama sekali. Hal yang sama dapat dilihat ketika menganalisis kode dua kelas lainnya.

Misalkan kita memutuskan untuk menambahkan metode lain ke antarmuka Shape , drawTriangle , yang dirancang untuk menggambar segitiga:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle();   drawTriangle(); } 

Ini akan menghasilkan kelas-kelas yang mewakili bentuk-bentuk geometris tertentu yang harus mengimplementasikan metode drawTriangle juga. Kalau tidak, kesalahan akan terjadi.

Seperti yang Anda lihat, dengan pendekatan ini tidak mungkin untuk membuat kelas yang mengimplementasikan metode untuk menghasilkan lingkaran, tetapi tidak menerapkan metode untuk menurunkan kotak, persegi panjang, dan segitiga. Metode seperti itu dapat diimplementasikan sehingga ketika itu adalah output, kesalahan dilemparkan menunjukkan bahwa operasi seperti itu tidak dapat dilakukan.

Prinsip pemisahan antarmuka memperingatkan kita untuk tidak membuat antarmuka seperti Shape dari contoh kita. Klien (kami memiliki kelas Circle , Square dan Rectangle ) tidak boleh mengimplementasikan metode yang tidak perlu mereka gunakan. Selain itu, prinsip ini menunjukkan bahwa antarmuka harus menyelesaikan hanya satu tugas (dalam hal ini mirip dengan prinsip tanggung jawab tunggal), oleh karena itu segala sesuatu yang melampaui ruang lingkup tugas ini harus ditransfer ke antarmuka atau antarmuka lain.

Dalam kasus kami, antarmuka Shape memecahkan masalah untuk solusi yang diperlukan untuk membuat antarmuka terpisah. Mengikuti ide ini, kami mengerjakan ulang kode dengan membuat antarmuka terpisah untuk menyelesaikan berbagai tugas yang sangat khusus:

 interface Shape {   draw(); } interface ICircle {   drawCircle(); } interface ISquare {   drawSquare(); } interface IRectangle {   drawRectangle(); } interface ITriangle {   drawTriangle(); } class Circle implements ICircle {   drawCircle() {       //...   } } class Square implements ISquare {   drawSquare() {       //...   } } class Rectangle implements IRectangle {   drawRectangle() {       //...   } } class Triangle implements ITriangle {   drawTriangle() {       //...   } } class CustomShape implements Shape {  draw(){     //...  } } 

Sekarang antarmuka ICircle hanya digunakan untuk menggambar lingkaran, serta antarmuka khusus lainnya untuk menggambar bentuk lainnya. Antarmuka Shape dapat digunakan sebagai antarmuka universal.

Prinsip Pembalikan Ketergantungan


Objek ketergantungan harus berupa abstraksi, bukan sesuatu yang spesifik.

  1. Modul tingkat atas tidak harus bergantung pada modul tingkat bawah. Kedua jenis modul harus bergantung pada abstraksi.
  2. Abstraksi tidak harus bergantung pada detail. Rinciannya harus bergantung pada abstraksi.

Dalam proses pengembangan perangkat lunak, ada saat ketika fungsionalitas aplikasi berhenti masuk dalam modul yang sama. Ketika ini terjadi, kita harus menyelesaikan masalah dependensi modul. Sebagai hasilnya, misalnya, mungkin ternyata komponen tingkat tinggi bergantung pada komponen tingkat rendah.

 class XMLHttpService extends XMLHttpRequestService {} class Http {   constructor(private xmlhttpService: XMLHttpService) { }   get(url: string , options: any) {       this.xmlhttpService.request(url,'GET');   }   post() {       this.xmlhttpService.request(url,'POST');   }   //... } 

Di sini, kelas Http adalah komponen tingkat tinggi, dan XMLHttpService adalah komponen tingkat rendah. Arsitektur seperti itu melanggar klausa A dari prinsip inversi dependensi: “Modul tingkat yang lebih tinggi tidak harus bergantung pada modul tingkat yang lebih rendah. Kedua jenis modul harus bergantung pada abstraksi. "

Kelas Http terpaksa bergantung pada kelas XMLHttpService . Jika kami memutuskan untuk mengubah mekanisme yang digunakan oleh kelas Http untuk berinteraksi dengan jaringan, katakanlah itu akan menjadi layanan Node.js atau, misalnya, layanan rintisan yang digunakan untuk tujuan pengujian, kami harus mengedit semua instance dari kelas Http dengan mengubah kode yang sesuai. Ini melanggar prinsip keterbukaan-kedekatan.

Kelas Http seharusnya tidak tahu persis apa yang digunakan untuk membangun koneksi jaringan. Oleh karena itu, kami akan membuat antarmuka Connection :

 interface Connection {   request(url: string, opts:any); } 

Antarmuka Connection berisi deskripsi metode request dan kami meneruskan argumen tipe Connection ke kelas Http :

 class Http {   constructor(private httpConnection: Connection) { }   get(url: string , options: any) {       this.httpConnection.request(url,'GET');   }   post() {       this.httpConnection.request(url,'POST');   }   //... } 

Sekarang, terlepas dari apa yang digunakan untuk mengatur interaksi dengan jaringan, kelas Http dapat menggunakan apa yang diteruskan ke sana, tanpa khawatir tentang apa yang tersembunyi di balik antarmuka Connection .

Kami menulis ulang kelas XMLHttpService sehingga mengimplementasikan antarmuka ini:

 class XMLHttpService implements Connection {   const xhr = new XMLHttpRequest();   //...   request(url: string, opts:any) {       xhr.open();       xhr.send();   } } 

Sebagai hasilnya, kita dapat membuat banyak kelas yang mengimplementasikan antarmuka Connection dan cocok untuk digunakan di kelas Http untuk mengatur pertukaran data melalui jaringan:

 class NodeHttpService implements Connection {   request(url: string, opts:any) {       //...   } } class MockHttpService implements Connection {   request(url: string, opts:any) {       //...   } } 

Seperti yang Anda lihat, di sini modul tingkat tinggi dan tingkat rendah bergantung pada abstraksi. Kelas Http (modul tingkat tinggi) tergantung pada antarmuka Connection (abstraksi). Kelas XMLHttpService , NodeHttpService dan MockHttpService (modul tingkat rendah) juga bergantung pada antarmuka Connection .

Selain itu, perlu dicatat bahwa mengikuti prinsip inversi ketergantungan, kami mengamati prinsip substitusi Barbara Liskov. Yaitu, ternyata tipe-tipe XMLHttpService , NodeHttpService dan MockHttpService dapat berfungsi sebagai pengganti Connection tipe dasar.

Ringkasan


Di sini kami melihat lima prinsip SOLID yang harus dipatuhi oleh setiap pengembang OOP. Pada awalnya, ini mungkin tidak mudah, tetapi jika Anda berjuang untuk ini, memperkuat keinginan praktik, prinsip-prinsip ini menjadi bagian alami dari alur kerja, yang memiliki dampak positif besar pada kualitas aplikasi dan sangat memudahkan dukungan mereka.

Pembaca yang budiman! Apakah Anda menggunakan prinsip SOLID dalam proyek Anda?

Source: https://habr.com/ru/post/id426413/


All Articles