Un ghid solid pentru principiile SOLID

1. Introducere

În acest tutorial, vom discuta principiile SOLID ale proiectării orientate pe obiecte.

În primul rând, vom începe prin a explora motivele pentru care au apărut și de ce ar trebui să le luăm în considerare atunci când proiectăm software. Apoi, vom contura fiecare principiu alături de un exemplu de cod pentru a sublinia punctul.

2. Motivul principiilor SOLID

Principiile SOLID au fost conceptualizate pentru prima dată de Robert C. Martin în lucrarea sa din 2000, Design Principles and Design Patterns. Aceste concepte au fost ulterior construite de Michael Feathers, care ne-a introdus acronimul SOLID. Și în ultimii 20 de ani, aceste 5 principii au revoluționat lumea programării orientate pe obiecte, schimbând modul în care scriem software-ul.

Deci, ce este SOLID și cum ne ajută să scriem un cod mai bun? Pur și simplu, principiile de proiectare ale lui Martin și Feathers ne încurajează să creăm software mai ușor de întreținut, mai ușor de înțeles și mai flexibil . În consecință, pe măsură ce aplicațiile noastre cresc în dimensiune, le putem reduce complexitatea și ne putem salva o mulțime de dureri de cap mai departe pe drum!

Următoarele 5 concepte alcătuiesc principiile noastre SOLID:

  1. S Responsabilitatea ingle
  2. O stilou / Închis
  3. L iskov Înlocuire
  4. I nterface Segregarea
  5. D ependency Inversion

Deși unele dintre aceste cuvinte pot părea descurajante, ele pot fi ușor de înțeles cu câteva exemple de cod simple. În secțiunile următoare, vom analiza profund ceea ce înseamnă fiecare dintre aceste principii, împreună cu un exemplu rapid Java pentru a ilustra fiecare.

3. Responsabilitate unică

Să începem lucrurile cu principiul responsabilității unice. Așa cum ne-am putea aștepta, acest principiu afirmă că o clasă ar trebui să aibă o singură responsabilitate. În plus, ar trebui să aibă un singur motiv pentru a se schimba.

Cum ne ajută acest principiu să construim un software mai bun? Să vedem câteva dintre beneficiile sale:

  1. Testare - O clasă cu o responsabilitate va avea mult mai puține cazuri de testare
  2. Cuplare mai mică - Mai puține funcționalități într-o singură clasă vor avea mai puține dependențe
  3. Organizare - Cursurile mai mici și bine organizate sunt mai ușor de căutat decât cele monolitice

Luați, de exemplu, o clasă pentru a reprezenta o carte simplă:

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

În acest cod, stocăm numele, autorul și textul asociate cu o instanță a unei cărți .

Să adăugăm acum câteva metode pentru a interoga textul:

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); } }

Acum, clasa noastră de Carte funcționează bine și putem stoca câte cărți dorim în aplicația noastră. Dar la ce bun este stocarea informațiilor dacă nu putem scoate textul în consola noastră și să-l citim?

Să aruncăm atenție vântului și să adăugăm o metodă de imprimare:

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

Cu toate acestea, acest cod încalcă principiul responsabilității unice pe care l-am subliniat anterior. Pentru a remedia mizeria noastră, ar trebui să implementăm o clasă separată care se ocupă doar de tipărirea textelor noastre:

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.. } }

Minunat. Nu numai că am dezvoltat o clasă care scutește Cartea de sarcinile sale de tipărire, dar putem, de asemenea, să folosim clasa noastră BookPrinter pentru a ne trimite textul către alte medii.

Fie că este vorba de e-mail, înregistrare sau orice altceva, avem o clasă separată dedicată acestei preocupări.

4. Deschis pentru extindere, Închis pentru modificare

Acum, este timpul pentru „O” - mai cunoscut formal ca principiul deschis-închis . Pur și simplu, clasele ar trebui să fie deschise pentru extensie, dar închise pentru modificare. Procedând astfel, ne oprim să modificăm codul existent și să provocăm noi erori potențiale într-o aplicație altfel fericită.

Desigur, singura excepție de la regulă este atunci când se remediază erori în codul existent.

Să explorăm conceptul în continuare cu un exemplu de cod rapid. Ca parte a unui nou proiect, imaginați-vă că am implementat o clasă de chitară .

Este complet dezvoltat și are chiar și un buton de volum:

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

Lansăm aplicația și toată lumea o iubește. Cu toate acestea, după câteva luni, decidem că chitara este puțin plictisitoare și ar putea face cu un model de flacără minunat pentru a face să pară ceva mai „rock and roll”.

În acest moment, ar putea fi tentant să deschidem doar clasa de chitară și să adăugăm un model de flacără - dar cine știe ce erori ar putea provoca în aplicația noastră.

În schimb, să respectăm principiul deschis-închis și să extindem pur și simplu clasa noastră de chitară :

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

Prin extinderea clasei de chitară putem fi siguri că aplicația noastră existentă nu va fi afectată.

5. Înlocuirea lui Liskov

Următorul pe lista noastră este substituția Liskov, care este, fără îndoială, cel mai complex dintre cele 5 principii. Pur și simplu, dacă clasa A este un subtip al clasei B , atunci ar trebui să putem înlocui B cu A fără a perturba comportamentul programului nostru.

Să trecem direct la cod pentru a ne înfășura capul în jurul acestui concept:

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

Mai sus, definim o interfață simplă pentru mașină , cu câteva metode pe care toate mașinile ar trebui să le poată îndeplini - pornirea motorului și accelerarea înainte.

Să implementăm interfața noastră și să oferim un cod pentru metodele:

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); } }

După cum descrie codul nostru, avem un motor pe care îl putem porni și putem crește puterea. Dar așteaptă, este 2019, iar Elon Musk a fost un om ocupat.

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.

Am început cu o istorie rapidă SOLID și motivele pentru care există aceste principii.

Litere cu litere, am defalcat semnificația fiecărui principiu cu un exemplu de cod rapid care îl încalcă. Am văzut apoi cum să reparăm codul nostru și să îl facem să respecte principiile SOLID.

Ca întotdeauna, codul este disponibil pe GitHub.