Una guida solida ai principi SOLIDI

1. Introduzione

In questo tutorial, discuteremo i principi SOLID della progettazione orientata agli oggetti.

Innanzitutto, inizieremo esplorando i motivi per cui sono nati e perché dovremmo considerarli durante la progettazione del software. Quindi, illustreremo ogni principio insieme ad un codice di esempio per enfatizzare il punto.

2. La ragione per i principi SOLID

I principi SOLID sono stati concettualizzati per la prima volta da Robert C. Martin nel suo documento del 2000, Design Principles and Design Patterns. Questi concetti sono stati successivamente sviluppati da Michael Feathers, che ci ha introdotto all'acronimo SOLID. E negli ultimi 20 anni, questi 5 principi hanno rivoluzionato il mondo della programmazione orientata agli oggetti, cambiando il modo in cui scriviamo software.

Allora, cos'è SOLID e come ci aiuta a scrivere codice migliore? In poche parole, i principi di progettazione di Martin e Feathers ci incoraggiano a creare software più manutenibile, comprensibile e flessibile . Di conseguenza, man mano che le nostre applicazioni crescono di dimensioni, possiamo ridurre la loro complessità e risparmiarci un sacco di grattacapi lungo la strada!

I seguenti 5 concetti costituiscono i nostri principi SOLID:

  1. S Responsabilità ingle
  2. O pen / Chiuso
  3. Sostituzione di L iskov
  4. I nterface Segregazione
  5. D ependency Inversion

Sebbene alcune di queste parole possano sembrare scoraggianti, possono essere facilmente comprese con alcuni semplici esempi di codice. Nelle sezioni seguenti, approfondiremo il significato di ciascuno di questi principi, insieme a un rapido esempio Java per illustrare ciascuno di essi.

3. Responsabilità unica

Iniziamo con il principio della responsabilità unica. Come ci si potrebbe aspettare, questo principio afferma che una classe dovrebbe avere una sola responsabilità. Inoltre, dovrebbe avere solo un motivo per cambiare.

In che modo questo principio ci aiuta a creare software migliore? Vediamo alcuni dei suoi vantaggi:

  1. Test : una classe con una responsabilità avrà molti meno casi di test
  2. Abbinamento inferiore : meno funzionalità in una singola classe avranno meno dipendenze
  3. Organizzazione : le classi più piccole e ben organizzate sono più facili da cercare rispetto a quelle monolitiche

Prendi, ad esempio, una classe per rappresentare un semplice libro:

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

In questo codice memorizziamo il nome, l'autore e il testo associati a un'istanza di un libro .

Aggiungiamo ora un paio di metodi per interrogare il testo:

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

Ora, la nostra classe Libro funziona bene e possiamo memorizzare tutti i libri che vogliamo nella nostra applicazione. Ma a cosa serve memorizzare le informazioni se non possiamo inviare il testo alla nostra console e leggerlo?

Facciamo attenzione al vento e aggiungiamo un metodo di stampa:

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

Questo codice, tuttavia, viola il principio di responsabilità unica che abbiamo delineato in precedenza. Per risolvere il nostro pasticcio, dovremmo implementare una classe separata che si occupa solo della stampa dei nostri testi:

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

Eccezionale. Non solo abbiamo sviluppato una classe che solleva il libro dai suoi compiti di stampa, ma possiamo anche sfruttare la nostra classe BookPrinter per inviare il nostro testo ad altri media.

Che si tratti di e-mail, registrazione o qualsiasi altra cosa, abbiamo una classe separata dedicata a questo problema.

4. Aperto per estensione, chiuso per modifica

Ora, è il momento della "O", più formalmente nota come principio di apertura-chiusura . In poche parole, le classi dovrebbero essere aperte per l'estensione, ma chiuse per la modifica. In tal modo, ci impediamo di modificare il codice esistente e di causare potenziali nuovi bug in un'applicazione altrimenti felice.

Ovviamente, l' unica eccezione alla regola è quando si risolvono bug nel codice esistente.

Esploriamo ulteriormente il concetto con un rapido esempio di codice. Come parte di un nuovo progetto, immagina di aver implementato una lezione di chitarra .

È completamente sviluppato e ha anche una manopola del volume:

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

Lanciamo l'applicazione e tutti la adorano. Tuttavia, dopo alcuni mesi, decidiamo che la chitarra è un po 'noiosa e potrebbe avere un fantastico motivo a fiamma per farlo sembrare un po' più "rock and roll".

A questo punto, potresti essere tentato di aprire semplicemente la classe Guitar e aggiungere uno schema di fiamma, ma chissà quali errori potrebbero generare nella nostra applicazione.

Invece, restiamo fedeli al principio aperto-chiuso ed estendiamo semplicemente la nostra classe di chitarra :

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

Estendendo la classe Guitar possiamo essere sicuri che la nostra applicazione esistente non sarà influenzata.

5. Sostituzione Liskov

Il prossimo nella nostra lista è la sostituzione di Liskov, che è probabilmente il più complesso dei 5 principi. In poche parole, se la classe A è un sottotipo della classe B , allora dovremmo essere in grado di sostituire B con A senza interrompere il comportamento del nostro programma.

Passiamo direttamente al codice per capire meglio questo concetto:

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

Sopra, definiamo una semplice interfaccia Auto con un paio di metodi che tutte le auto dovrebbero essere in grado di soddisfare: accendere il motore e accelerare in avanti.

Implementiamo la nostra interfaccia e forniamo del codice per i metodi:

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

Come descrive il nostro codice, abbiamo un motore che possiamo accendere e possiamo aumentare la potenza. Ma aspetta, è il 2019, ed Elon Musk è stato un uomo impegnato.

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.

Abbiamo iniziato con un breve pezzo di storia SOLIDA e le ragioni per cui esistono questi principi.

Lettera per lettera, abbiamo suddiviso il significato di ogni principio con un rapido esempio di codice che lo viola. Abbiamo quindi visto come correggere il nostro codice e farlo aderire ai principi SOLID.

Come sempre, il codice è disponibile su GitHub.